Compare commits
10 Commits
40a33d773b
...
a3dcdb8577
| Author | SHA1 | Date | |
|---|---|---|---|
| a3dcdb8577 | |||
| aedc647fff | |||
| bd48122db2 | |||
| 4212a17e4b | |||
| cc523607d1 | |||
| b4593c69af | |||
| 4d99213d75 | |||
| 2735562b65 | |||
| 4e67062c99 | |||
| b36b60353d |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(python:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
131
.gitignore
vendored
Normal file
131
.gitignore
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Projects data (user-generated)
|
||||
projects/*.yaml
|
||||
projects/*.md
|
||||
!projects/.gitkeep
|
||||
|
||||
# Active session file
|
||||
*.active_session.json
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.bak
|
||||
21
CLAUDE.md
Normal file
21
CLAUDE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a personal tracker project. The codebase is currently empty (initial commit only) — no build configuration, tests, or source files exist yet.
|
||||
|
||||
## Development Commands
|
||||
|
||||
Commands will be added once the project has a build system and test setup.
|
||||
|
||||
## Resumen
|
||||
|
||||
- Cuando te pida realizar un resumen del proyecto debes crear un archivo con el siguiente formato de nombre yyyy-mm-dd-HHMM-resumen.md en la carpeta resumen.
|
||||
- Si no existe crea una carpeta resumen en la raiz del proyecto.
|
||||
- Crearemos resumenes de forma incremental y el primero debe contener todo lo existente hasta el momento.
|
||||
- El archivo debe ser creado con el horario local.
|
||||
|
||||
## Commit
|
||||
- evitar agregar lo siguiente: Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
190
README.md
190
README.md
@@ -1,2 +1,190 @@
|
||||
# tracker
|
||||
# Personal Tracker
|
||||
|
||||
Herramienta CLI para seguimiento de proyectos personales con foco en continuidad entre sesiones.
|
||||
|
||||
## Características
|
||||
|
||||
- **Persistencia simple**: Archivos Markdown y YAML legibles
|
||||
- **Sin dependencias cloud**: Todo funciona offline
|
||||
- **Kanban liviano**: TASKS.md con estados (Inbox, Next, Active, Blocked, Waiting, Done)
|
||||
- **Bitácora por sesiones**: LOG.md y archivos de sesión detallados
|
||||
- **Changelog**: Registro de cambios relevantes
|
||||
- **CLI intuitiva**: Comandos para iniciar, pausar y documentar trabajo
|
||||
|
||||
## Stack
|
||||
|
||||
- Python 3.11+
|
||||
- [Typer](https://typer.tiangolo.com/) - CLI
|
||||
- [PyYAML](https://pyyaml.org/) - Metadatos
|
||||
- [Jinja2](https://jinja.palletsprojects.com/) - Plantillas
|
||||
- [Pydantic](https://docs.pydantic.dev/) - Modelos
|
||||
|
||||
## Instalación
|
||||
|
||||
### Requisitos Previos
|
||||
|
||||
- Python 3.11 o superior
|
||||
- pip (gestor de paquetes Python)
|
||||
|
||||
### Método 1: Instalación con pip
|
||||
|
||||
```bash
|
||||
# Clonar el repositorio
|
||||
git clone <url-del-repositorio>
|
||||
cd tracker
|
||||
|
||||
# Instalar en modo desarrollo
|
||||
pip install -e .
|
||||
|
||||
# Verificar instalación
|
||||
tracker --help
|
||||
```
|
||||
|
||||
### Método 2: Usando uv (recomendado)
|
||||
|
||||
```bash
|
||||
# Instalar con uv
|
||||
uv pip install -e .
|
||||
|
||||
# O desde el código fuente
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
### Verificar Instalación
|
||||
|
||||
```bash
|
||||
tracker --help
|
||||
```
|
||||
|
||||
Deberías ver la lista de comandos disponibles.
|
||||
|
||||
### Desinstalación
|
||||
|
||||
```bash
|
||||
pip uninstall tracker
|
||||
```
|
||||
|
||||
## Comandos
|
||||
|
||||
| Comando | Descripción |
|
||||
|---------|-------------|
|
||||
| `tracker init-project <slug>` | Crear nuevo proyecto |
|
||||
| `tracker list` | Listar todos los proyectos |
|
||||
| `tracker show <slug>` | Ver detalles de un proyecto |
|
||||
| `tracker start <slug>` | Iniciar sesión de trabajo |
|
||||
| `tracker note <text>` | Agregar nota a la sesión activa |
|
||||
| `tracker stop <slug>` | Finalizar sesión y generar resumen |
|
||||
| `tracker change <slug>` | Agregar entrada al changelog |
|
||||
| `tracker next <slug>` | Sugerir próximos pasos |
|
||||
| `tracker review` | Vista general de todos los proyectos |
|
||||
| `tracker task add <slug>` | Agregar tarea a una sección |
|
||||
| `tracker task move <slug>` | Mover tarea a otra sección |
|
||||
|
||||
## Estructura de un Proyecto
|
||||
|
||||
```
|
||||
projects/<slug>/
|
||||
├── README.md # Documentación principal
|
||||
├── LOG.md # Bitácora de sesiones (append-only)
|
||||
├── CHANGELOG.md # Registro de cambios
|
||||
├── TASKS.md # Kanban liviano
|
||||
├── sessions/ # Sesiones detalladas
|
||||
├── docs/ # Documentación técnica
|
||||
├── assets/ # Recursos
|
||||
└── meta/
|
||||
└── project.yaml # Metadatos
|
||||
```
|
||||
|
||||
## Flujo de Uso
|
||||
|
||||
```bash
|
||||
# 1. Crear proyecto
|
||||
tracker init-project mi-proyecto --name "Mi Proyecto" --type code
|
||||
|
||||
# 2. Iniciar sesión
|
||||
tracker start mi-proyecto --objective "Implementar feature X"
|
||||
|
||||
# 3. Agregar notas durante el trabajo
|
||||
tracker note "Implementé la función base" --type work
|
||||
tracker note "Decidí usar PostgreSQL en vez de SQLite" --type decision
|
||||
|
||||
# 4. Finalizar sesión
|
||||
tracker stop mi-proyecto
|
||||
|
||||
# 5. Ver estado
|
||||
tracker show mi-proyecto
|
||||
tracker list
|
||||
```
|
||||
|
||||
## Tipos de Nota
|
||||
|
||||
- `work` - Trabajo realizado
|
||||
- `change` - Cambio realizado
|
||||
- `blocker` - Bloqueo encontrado
|
||||
- `decision` - Decisión tomada
|
||||
- `idea` - Idea o pensamiento
|
||||
- `reference` - Referencia o link
|
||||
|
||||
## Gestión de Tareas
|
||||
|
||||
```bash
|
||||
# Agregar tarea a Inbox (por defecto)
|
||||
tracker task add mi-proyecto "Revisar documentación de API"
|
||||
|
||||
# Agregar tarea a una sección específica
|
||||
tracker task add mi-proyecto "Bug fix urgente" --section "Bloqueado"
|
||||
|
||||
# Mover tarea a otra sección
|
||||
tracker task move mi-proyecto "Bug fix urgente" "En curso"
|
||||
|
||||
# Marcar tarea como completada (mover a Hecho)
|
||||
tracker task move mi-proyecto "Bug fix urgente" "Hecho"
|
||||
```
|
||||
|
||||
**Secciones disponibles:** Inbox, Próximo, En curso, Bloqueado, En espera, Hecho
|
||||
|
||||
## Estados de Proyecto
|
||||
|
||||
- `inbox` - Recibido/revisar
|
||||
- `next` - Próximo a trabajar
|
||||
- `active` - En desarrollo activo
|
||||
- `blocked` - Bloqueado
|
||||
- `waiting` - En espera
|
||||
- `done` - Completado
|
||||
- `archived` - Archivado
|
||||
|
||||
## Tipos de Proyecto
|
||||
|
||||
- `code` - Desarrollo de software
|
||||
- `homelab` - Infraestructura personal
|
||||
- `automation` - Automatización
|
||||
- `agent` - Agentes/IA
|
||||
- `research` - Investigación
|
||||
- `misc` - Misceláneo
|
||||
|
||||
## Proyecto Demo
|
||||
|
||||
Ver `examples/demo-project/` para un proyecto de ejemplo completo.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Desarrollo
|
||||
|
||||
```bash
|
||||
# Estructura del paquete
|
||||
tracker/
|
||||
├── cli/ # Comandos CLI
|
||||
├── models/ # Modelos de datos
|
||||
├── services/ # Lógica de negocio
|
||||
├── storage/ # Persistencia archivos
|
||||
├── utils/ # Utilidades
|
||||
└── templates/ # Plantillas
|
||||
```
|
||||
|
||||
## Licencia
|
||||
|
||||
MIT
|
||||
|
||||
246
backlog/mvp-1.md
Normal file
246
backlog/mvp-1.md
Normal file
@@ -0,0 +1,246 @@
|
||||
Construye un MVP v1 de un sistema local de seguimiento de proyectos personales.
|
||||
|
||||
Objetivo:
|
||||
Crear una herramienta CLI que opere sobre archivos Markdown y YAML para ayudar a seguir proyectos personales con foco en continuidad entre sesiones. El sistema combina Kanban liviano, documentación viva, changelog y bitácora técnica por sesiones.
|
||||
|
||||
Restricciones obligatorias:
|
||||
- usar persistencia en archivos legibles
|
||||
- Markdown como formato principal
|
||||
- YAML/JSON simple para metadatos
|
||||
- sin base de datos
|
||||
- sin interfaz web
|
||||
- sin dependencias cloud
|
||||
- todo debe seguir siendo usable manualmente aunque la CLI no exista
|
||||
- una sola sesión activa a la vez en v1
|
||||
- LOG.md append-only
|
||||
- README.md solo puede modificarse en secciones autogeneradas delimitadas por marcadores AUTOGEN
|
||||
|
||||
Stack preferido:
|
||||
- Python 3.11+
|
||||
- Typer
|
||||
- Jinja2
|
||||
- PyYAML
|
||||
- GitPython opcional
|
||||
|
||||
Estructura del repositorio esperada:
|
||||
- README.md principal
|
||||
- pyproject.toml
|
||||
- tracker.yaml
|
||||
- paquete tracker/ con cli, services, models, storage, utils y templates
|
||||
- carpeta projects/
|
||||
- carpeta tests/
|
||||
- carpeta examples/
|
||||
|
||||
Cada proyecto debe generarse así:
|
||||
projects/<slug>/
|
||||
README.md
|
||||
LOG.md
|
||||
CHANGELOG.md
|
||||
TASKS.md
|
||||
sessions/
|
||||
docs/
|
||||
assets/
|
||||
meta/project.yaml
|
||||
|
||||
Comandos mínimos a implementar:
|
||||
- tracker init-project
|
||||
- tracker list
|
||||
- tracker show <slug>
|
||||
- tracker start <slug>
|
||||
- tracker note <text>
|
||||
- tracker stop <slug>
|
||||
- tracker change <slug>
|
||||
- tracker next <slug>
|
||||
- tracker review
|
||||
|
||||
Comportamiento requerido:
|
||||
|
||||
1. init-project
|
||||
- crea estructura de carpetas
|
||||
- genera README.md, LOG.md, CHANGELOG.md, TASKS.md y meta/project.yaml desde plantillas
|
||||
- acepta name, type, tags, repo-path y description
|
||||
|
||||
2. list
|
||||
- muestra nombre, slug, estado, última sesión y próximo paso si existe
|
||||
|
||||
3. show <slug>
|
||||
- muestra estado actual, contexto, último resumen, bloqueos, próximos pasos y última actividad
|
||||
|
||||
4. start <slug>
|
||||
- valida que no exista otra sesión activa
|
||||
- registra hora de inicio
|
||||
- crea sesión activa en un archivo tipo .active_session.json
|
||||
- puede aceptar objective
|
||||
- muestra contexto reciente
|
||||
|
||||
5. note <text>
|
||||
- agrega nota a la sesión activa
|
||||
- soporta tipos: work, change, blocker, decision, idea, reference
|
||||
|
||||
6. stop <slug>
|
||||
- registra fin
|
||||
- calcula duración
|
||||
- consolida notas
|
||||
- genera resumen heurístico
|
||||
- sugiere próximos pasos
|
||||
- crea archivo detallado en sessions/YYYY-MM-DD_HHMM.md
|
||||
- actualiza LOG.md
|
||||
- actualiza README.md en bloques AUTOGEN
|
||||
- opcionalmente actualiza CHANGELOG.md
|
||||
- limpia sesión activa
|
||||
|
||||
7. change <slug>
|
||||
- añade una entrada manual al CHANGELOG.md
|
||||
|
||||
8. next <slug>
|
||||
- sugiere próximos pasos por reglas simples, sin IA obligatoria
|
||||
|
||||
9. review
|
||||
- muestra proyectos activos, últimas sesiones, bloqueos abiertos y proyectos sin actividad reciente
|
||||
|
||||
Modelo mínimo:
|
||||
|
||||
Proyecto:
|
||||
- id
|
||||
- name
|
||||
- slug
|
||||
- description
|
||||
- type
|
||||
- status
|
||||
- tags
|
||||
- root_path
|
||||
- repo_path
|
||||
- created_at
|
||||
- updated_at
|
||||
- last_session_at
|
||||
|
||||
Sesión:
|
||||
- id
|
||||
- project_slug
|
||||
- started_at
|
||||
- ended_at
|
||||
- duration_minutes
|
||||
- objective
|
||||
- summary
|
||||
- work_done
|
||||
- changes
|
||||
- decisions
|
||||
- blockers
|
||||
- next_steps
|
||||
- references
|
||||
- raw_notes
|
||||
|
||||
Cambio:
|
||||
- date
|
||||
- type
|
||||
- title
|
||||
- impact
|
||||
- references
|
||||
|
||||
Valores sugeridos de type:
|
||||
- code
|
||||
- homelab
|
||||
- automation
|
||||
- agent
|
||||
- research
|
||||
- misc
|
||||
|
||||
Valores sugeridos de status:
|
||||
- inbox
|
||||
- next
|
||||
- active
|
||||
- blocked
|
||||
- waiting
|
||||
- done
|
||||
- archived
|
||||
|
||||
README.md del proyecto debe incluir:
|
||||
- nombre
|
||||
- descripción
|
||||
- objetivo
|
||||
- estado actual
|
||||
- contexto actual
|
||||
- stack / herramientas
|
||||
- arquitectura breve
|
||||
- decisiones técnicas
|
||||
- riesgos / bloqueos
|
||||
- próximos pasos
|
||||
- últimas sesiones
|
||||
|
||||
Usa bloques delimitados, por ejemplo:
|
||||
<!-- AUTOGEN:STATUS_START -->
|
||||
...
|
||||
<!-- AUTOGEN:STATUS_END -->
|
||||
|
||||
LOG.md debe ser append-only y usar entradas como:
|
||||
## 2026-03-23 10:00–11:20
|
||||
**Objetivo**
|
||||
...
|
||||
**Trabajo realizado**
|
||||
- ...
|
||||
**Cambios relevantes**
|
||||
- ...
|
||||
**Bloqueos**
|
||||
- ...
|
||||
**Decisiones**
|
||||
- ...
|
||||
**Próximos pasos**
|
||||
- ...
|
||||
**Resumen**
|
||||
...
|
||||
|
||||
CHANGELOG.md debe registrar solo cambios relevantes:
|
||||
- code
|
||||
- infra
|
||||
- config
|
||||
- docs
|
||||
- automation
|
||||
- decision
|
||||
|
||||
TASKS.md debe tener secciones:
|
||||
- Inbox
|
||||
- Próximo
|
||||
- En curso
|
||||
- Bloqueado
|
||||
- En espera
|
||||
- Hecho
|
||||
|
||||
Heurísticas mínimas:
|
||||
- si hay bloqueos abiertos, priorizar destrabar
|
||||
- si hubo cambios sin validación, sugerir validar
|
||||
- si hubo trabajo parcial, sugerir cerrar el hilo abierto
|
||||
- si no hubo avances, sugerir redefinir objetivo
|
||||
- si hubo commits recientes no documentados, sugerir registrarlos
|
||||
|
||||
Integración Git:
|
||||
- opcional
|
||||
- leer commits recientes si repo_path existe
|
||||
- no hacer commit, push ni cambios de ramas
|
||||
|
||||
Requisitos de calidad:
|
||||
- código modular
|
||||
- mensajes de error claros
|
||||
- paths multiplataforma
|
||||
- UTF-8
|
||||
- tests básicos del flujo principal
|
||||
|
||||
Entregables:
|
||||
- estructura completa del repositorio
|
||||
- CLI funcional
|
||||
- plantillas base
|
||||
- README principal
|
||||
- ejemplos de uso
|
||||
- tests básicos
|
||||
- proyecto demo
|
||||
|
||||
Prioriza claridad, mantenibilidad y bajo acoplamiento.
|
||||
|
||||
Además:
|
||||
- implementa primero la estructura y los comandos base
|
||||
- evita sobreingeniería
|
||||
- usa funciones pequeñas y testeables
|
||||
- separa claramente CLI, lógica de dominio y persistencia
|
||||
- no escondas datos importantes en formatos opacos
|
||||
- añade ejemplos de salida de comandos
|
||||
- incluye un proyecto demo ya generado
|
||||
- incluye tests del flujo init-project → start → note → stop → show
|
||||
33
examples/demo-project/CHANGELOG.md
Normal file
33
examples/demo-project/CHANGELOG.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Changelog - Demo Project
|
||||
|
||||
## [0.1.1] - 2026-03-23
|
||||
|
||||
### Agregado
|
||||
|
||||
- Documentacion de la API de exportacion en `docs/api-export.md`
|
||||
- Tests de integracion para el modulo exporter
|
||||
|
||||
### Modificado
|
||||
|
||||
- Actualizado el formato de metadatos de proyecto para incluir `last_session_at`
|
||||
- Mejorado el parseo de fechas en sesiones
|
||||
|
||||
## [0.1.0] - 2026-03-22
|
||||
|
||||
### Agregado
|
||||
|
||||
- Modulo de exportacion con soporte para JSON
|
||||
- Estructura de sesiones con metadatos en YAML front-matter
|
||||
- Archivo `TASKS.md` con seguimiento de tareas
|
||||
|
||||
### Modificado
|
||||
|
||||
- Refactorizacion de `project.yaml` para incluir `slug` y `type`
|
||||
|
||||
## [0.0.1] - 2026-03-20
|
||||
|
||||
### Agregado
|
||||
|
||||
- Estructura inicial del proyecto demo
|
||||
- Metadatos basicos en `meta/project.yaml`
|
||||
- Primera sesion de planificación
|
||||
59
examples/demo-project/LOG.md
Normal file
59
examples/demo-project/LOG.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Registro de Sesiones - Demo Project
|
||||
|
||||
## Sesion: 2026-03-22 14:30
|
||||
|
||||
**Duracion:** 2h 15min
|
||||
**Objetivo:** Implementar funcionalidad de exportacion
|
||||
|
||||
### Trabajo realizado
|
||||
|
||||
- Analice los requisitos para la exportacion a JSON y CSV
|
||||
- Diseñe la estructura de datos para los formatos de exportacion
|
||||
- Implemente la funcion base de exportacion en `exporter.py`
|
||||
- Escribi pruebas unitarias para los formateadores
|
||||
|
||||
### Bloqueos
|
||||
|
||||
- None
|
||||
|
||||
### Decisiones
|
||||
|
||||
- Decidi usar una clase base `BaseExporter` con métodos abstractos para cada formato
|
||||
- La estructura de directorios sigue el patrón `YYYY-MM-DD_HHMM.md`
|
||||
|
||||
### Proximos pasos
|
||||
|
||||
- [ ] Implementar exportacion a CSV
|
||||
- [ ] Agregar soporte para exportacion parcial (por rango de fechas)
|
||||
- [ ] Documentar la API de exportacion
|
||||
|
||||
---
|
||||
|
||||
## Sesion: 2026-03-20 10:00
|
||||
|
||||
**Duracion:** 1h 30min
|
||||
**Objetivo:** Iniciar proyecto demo
|
||||
|
||||
### Trabajo realizado
|
||||
|
||||
- Cree la estructura inicial del proyecto demo
|
||||
- Defini los metadatos base en `meta/project.yaml`
|
||||
- Configure el sistema de seguimiento basico
|
||||
- Revise la documentacion del tracker CLI
|
||||
|
||||
### Bloqueos
|
||||
|
||||
- Ninguno
|
||||
|
||||
### Decisiones
|
||||
|
||||
- El proyecto usara el tipo "misc" para mayor flexibilidad
|
||||
- Las sesiones se almacenaran en formato Markdown con metadatos YAML
|
||||
|
||||
### Proximos pasos
|
||||
|
||||
- [ ] Definir las primeras tareas del proyecto
|
||||
- [ ] Crear la estructura de documentacion
|
||||
- [ ] Establecer el flujo de trabajo regular
|
||||
|
||||
---
|
||||
21
examples/demo-project/README.md
Normal file
21
examples/demo-project/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Demo Project
|
||||
|
||||
## Descripcion
|
||||
|
||||
Proyecto de demostración del tracker personal para mostrar las capacidades del sistema de seguimiento de tareas y sesiones.
|
||||
|
||||
## Estado
|
||||
|
||||
- **Estado:** Activo
|
||||
- **Tipo:** Misc
|
||||
- **Etiquetas:** demo, example
|
||||
|
||||
## Secciones
|
||||
|
||||
- [Tareas](./TASKS.md) - Lista de tareas del proyecto
|
||||
- [Registro de sesiones](./LOG.md) - Historial de sesiones de trabajo
|
||||
- [Cambios](./CHANGELOG.md) - Registro de cambios realizados
|
||||
|
||||
---
|
||||
|
||||
*Ultima actualizacion: 2026-03-23T12:00:00*
|
||||
31
examples/demo-project/TASKS.md
Normal file
31
examples/demo-project/TASKS.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Tareas - Demo Project
|
||||
|
||||
## Inbox
|
||||
|
||||
- [ ] Revisar feedback del usuario sobre la interfaz de exportacion
|
||||
- [ ] Investigar opciones para generar reportes en PDF
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] Implementar exportacion a CSV
|
||||
- [ ] Agregar filtros por fecha en el listado de tareas
|
||||
- [ ] Crear plantilla para reportes semanales
|
||||
|
||||
## Active
|
||||
|
||||
- [ ] Implementar exportacion a CSV (iniciado 2026-03-22)
|
||||
|
||||
## Blocked
|
||||
|
||||
- [ ] Integracion con API de terceros (pendiente credenciales)
|
||||
|
||||
## Waiting
|
||||
|
||||
- [ ] Revision de codigo por parte del equipo (esperando feedback)
|
||||
|
||||
## Done
|
||||
|
||||
- [x] Crear estructura inicial del proyecto - 2026-03-20
|
||||
- [x] Implementar exportacion a JSON - 2026-03-22
|
||||
- [x] Escribir pruebas unitarias - 2026-03-22
|
||||
- [x] Documentar formato de sesiones - 2026-03-21
|
||||
0
examples/demo-project/assets/.gitkeep
Normal file
0
examples/demo-project/assets/.gitkeep
Normal file
0
examples/demo-project/docs/.gitkeep
Normal file
0
examples/demo-project/docs/.gitkeep
Normal file
13
examples/demo-project/meta/project.yaml
Normal file
13
examples/demo-project/meta/project.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
id: demo-001
|
||||
name: Demo Project
|
||||
slug: demo-project
|
||||
description: Proyecto de demostración del tracker personal
|
||||
type: misc
|
||||
status: active
|
||||
tags:
|
||||
- demo
|
||||
- example
|
||||
repo_path: null
|
||||
created_at: "2026-03-20T10:00:00"
|
||||
updated_at: "2026-03-23T12:00:00"
|
||||
last_session_at: "2026-03-22T14:30:00"
|
||||
53
examples/demo-project/sessions/2026-03-20_1000.md
Normal file
53
examples/demo-project/sessions/2026-03-20_1000.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
date: "2026-03-20T10:00:00"
|
||||
duration: 90
|
||||
objective: "Iniciar proyecto demo"
|
||||
project: demo-001
|
||||
---
|
||||
|
||||
# Sesion: 2026-03-20 10:00
|
||||
|
||||
## Objetivo
|
||||
|
||||
Iniciar el proyecto demo para demostrar las capacidades del tracker personal.
|
||||
|
||||
## Notas
|
||||
|
||||
### planeacion
|
||||
|
||||
Necesito crear un proyecto que sirva como ejemplo real de uso del sistema.
|
||||
Debe incluir:
|
||||
|
||||
- Estructura de directorios completa
|
||||
- Metadatos realistas
|
||||
- Sesiones de trabajo con contenido genuino
|
||||
- Tareas en diferentes estados
|
||||
|
||||
### observaciones
|
||||
|
||||
El sistema de tracking funciona bien para proyectos personales.
|
||||
La estructura de sesiones con front-matter YAML es flexible y util.
|
||||
Me gusta poder vincular sesiones a proyectos especificos.
|
||||
|
||||
## Trabajo realizado
|
||||
|
||||
- [x] Cree la estructura inicial del proyecto demo
|
||||
- [x] Defini los metadatos base en `meta/project.yaml`
|
||||
- [x] Configure el sistema de seguimiento basico
|
||||
- [x] Revise la documentacion del tracker CLI
|
||||
|
||||
## Bloqueos
|
||||
|
||||
Ninguno.
|
||||
|
||||
## Decisiones
|
||||
|
||||
- El proyecto usara el tipo "misc" para mayor flexibilidad
|
||||
- Las sesiones se almacenaran en formato Markdown con metadatos YAML
|
||||
- El ID del proyecto sera "demo-001" para facilitar la referencia
|
||||
|
||||
## Proximos pasos
|
||||
|
||||
- [ ] Definir las primeras tareas del proyecto
|
||||
- [ ] Crear la estructura de documentacion
|
||||
- [ ] Establecer el flujo de trabajo regular
|
||||
75
examples/demo-project/sessions/2026-03-22_1430.md
Normal file
75
examples/demo-project/sessions/2026-03-22_1430.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
date: "2026-03-22T14:30:00"
|
||||
duration: 135
|
||||
objective: "Implementar funcionalidad de exportacion"
|
||||
project: demo-001
|
||||
---
|
||||
|
||||
# Sesion: 2026-03-22 14:30
|
||||
|
||||
## Objetivo
|
||||
|
||||
Implementar la funcionalidad de exportacion de datos del proyecto a diferentes formatos.
|
||||
|
||||
## Notas
|
||||
|
||||
### investigacion
|
||||
|
||||
Analice los requisitos para la exportacion:
|
||||
|
||||
**JSON:**
|
||||
- Estructura jerarquica con metadatos completos
|
||||
- Incluye timestamps en formato ISO
|
||||
- Representacion de tareas por estado
|
||||
|
||||
**CSV:**
|
||||
- Formato plano para importacion a spreadsheets
|
||||
- Headers: id, titulo, estado, fecha_creacion, fecha_completado
|
||||
- UTF-8 encoding
|
||||
|
||||
### diseno
|
||||
|
||||
Decidi usar una clase base `BaseExporter` con:
|
||||
|
||||
```
|
||||
BaseExporter
|
||||
├── export(data) -> str
|
||||
├── format_metadata(meta) -> dict
|
||||
└── validate_data(data) -> bool
|
||||
|
||||
JsonExporter(BaseExporter)
|
||||
CsvExporter(BaseExporter)
|
||||
```
|
||||
|
||||
### codigo
|
||||
|
||||
Escribi la implementacion inicial del exporter:
|
||||
|
||||
```python
|
||||
class BaseExporter:
|
||||
def export(self, data: dict) -> str:
|
||||
raise NotImplementedError
|
||||
```
|
||||
|
||||
## Trabajo realizado
|
||||
|
||||
- [x] Analice los requisitos para la exportacion a JSON y CSV
|
||||
- [x] Diseñe la estructura de datos para los formatos de exportacion
|
||||
- [x] Implemente la funcion base de exportacion en `exporter.py`
|
||||
- [x] Escribi pruebas unitarias para los formateadores
|
||||
|
||||
## Bloqueos
|
||||
|
||||
Ninguno.
|
||||
|
||||
## Decisiones
|
||||
|
||||
- Decidi usar una clase base `BaseExporter` con metodos abstractos para cada formato
|
||||
- La estructura de directorios sigue el patron `YYYY-MM-DD_HHMM.md`
|
||||
- Los archivos de sesion incluyen front-matter con metadatos estructurados
|
||||
|
||||
## Proximos pasos
|
||||
|
||||
- [ ] Implementar exportacion a CSV
|
||||
- [ ] Agregar soporte para exportacion parcial (por rango de fechas)
|
||||
- [ ] Documentar la API de exportacion
|
||||
4
projects/mi-proyecto/CHANGELOG.md
Normal file
4
projects/mi-proyecto/CHANGELOG.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Changelog
|
||||
|
||||
_Project changes_
|
||||
|
||||
50
projects/mi-proyecto/LOG.md
Normal file
50
projects/mi-proyecto/LOG.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Log
|
||||
|
||||
_Project activity log_
|
||||
|
||||
## 2026-03-24 23:40–00:07
|
||||
|
||||
**Objetivo**
|
||||
No especificado
|
||||
|
||||
**Trabajo realizado**
|
||||
- Sin trabajo registrado
|
||||
|
||||
**Cambios relevantes**
|
||||
- Sin cambios
|
||||
|
||||
**Bloqueos**
|
||||
- Sin bloqueos
|
||||
|
||||
**Decisiones**
|
||||
- Sin decisiones
|
||||
|
||||
**Próximos pasos**
|
||||
- Definir próximos pasos
|
||||
|
||||
**Resumen**
|
||||
Session de 27 minutos sin progreso registrado.
|
||||
|
||||
## 2026-03-25 00:09–00:13
|
||||
|
||||
**Objetivo**
|
||||
No especificado
|
||||
|
||||
**Trabajo realizado**
|
||||
- Sin trabajo registrado
|
||||
|
||||
**Cambios relevantes**
|
||||
- Sin cambios
|
||||
|
||||
**Bloqueos**
|
||||
- Sin bloqueos
|
||||
|
||||
**Decisiones**
|
||||
- Sin decisiones
|
||||
|
||||
**Próximos pasos**
|
||||
- Definir próximos pasos
|
||||
|
||||
**Resumen**
|
||||
Session de 3 minutos sin progreso registrado.
|
||||
|
||||
50
projects/mi-proyecto/README.md
Normal file
50
projects/mi-proyecto/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# mi-proyecto
|
||||
|
||||
_No description_
|
||||
|
||||
## Objective
|
||||
|
||||
_TODO: Define objective_
|
||||
|
||||
## Status
|
||||
|
||||
**Current Status:** inbox
|
||||
|
||||
<!-- AUTOGEN:STATUS_START -->
|
||||
Status: inbox
|
||||
<!-- AUTOGEN:STATUS_END -->
|
||||
|
||||
## Context
|
||||
|
||||
_Current context and background_
|
||||
|
||||
## Stack / Tools
|
||||
|
||||
- _Tool 1_
|
||||
- _Tool 2_
|
||||
|
||||
## Architecture
|
||||
|
||||
_Brief architecture description_
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
_No decisions recorded yet_
|
||||
|
||||
## Risks / Blockers
|
||||
|
||||
_No blockers_
|
||||
|
||||
<!-- AUTOGEN:NEXT_STEPS_START -->
|
||||
- Definir próximos pasos
|
||||
- Definir próximos pasos
|
||||
<!-- AUTOGEN:NEXT_STEPS_END -->
|
||||
|
||||
## Recent Sessions
|
||||
|
||||
<!-- AUTOGEN:SESSIONS_START -->
|
||||
- 2026-03-24 23:40 (27 min): Session de 27 minutos sin progreso registrado....
|
||||
- 2026-03-25 00:09 (3 min): Session de 3 minutos sin progreso registrado....
|
||||
<!-- AUTOGEN:SESSIONS_END -->
|
||||
|
||||
_Last updated: 2026-03-24_
|
||||
20
projects/mi-proyecto/TASKS.md
Normal file
20
projects/mi-proyecto/TASKS.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Tasks
|
||||
|
||||
## Inbox
|
||||
-
|
||||
|
||||
## Próximo
|
||||
- [ ] Test task 1
|
||||
|
||||
## En curso
|
||||
-
|
||||
|
||||
## Bloqueado
|
||||
-
|
||||
|
||||
## En espera
|
||||
-
|
||||
|
||||
## Hecho
|
||||
- [x] Test task 2
|
||||
- [x] New test task
|
||||
12
projects/mi-proyecto/meta/project.yaml
Normal file
12
projects/mi-proyecto/meta/project.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
id: d7443ae2-9d23-4dc9-85c2-50437d6ab993
|
||||
name: mi-proyecto
|
||||
slug: mi-proyecto
|
||||
description: ''
|
||||
type: misc
|
||||
status: inbox
|
||||
tags: []
|
||||
root_path: projects/mi-proyecto
|
||||
repo_path: null
|
||||
created_at: '2026-03-24T23:39:35.125241'
|
||||
updated_at: '2026-03-24T23:39:35.125246'
|
||||
last_session_at: null
|
||||
28
projects/mi-proyecto/sessions/2026-03-24_2340.md
Normal file
28
projects/mi-proyecto/sessions/2026-03-24_2340.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Sesion: 2026-03-24 23:40–00:07
|
||||
|
||||
## Objetivo
|
||||
No especificado
|
||||
|
||||
## Notas
|
||||
- Sin notas
|
||||
|
||||
## Trabajo realizado
|
||||
- Sin trabajo realizado
|
||||
|
||||
## Cambios
|
||||
- Sin cambios
|
||||
|
||||
## Decisiones
|
||||
- Sin decisiones
|
||||
|
||||
## Bloqueos
|
||||
- Sin bloqueos
|
||||
|
||||
## Proximos pasos
|
||||
- Definir próximos pasos
|
||||
|
||||
## Referencias
|
||||
- Sin referencias
|
||||
|
||||
## Duracion
|
||||
27 minutos
|
||||
28
projects/mi-proyecto/sessions/2026-03-25_0009.md
Normal file
28
projects/mi-proyecto/sessions/2026-03-25_0009.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Sesion: 2026-03-25 00:09–00:13
|
||||
|
||||
## Objetivo
|
||||
No especificado
|
||||
|
||||
## Notas
|
||||
- [idea] prueba de idea
|
||||
|
||||
## Trabajo realizado
|
||||
- Sin trabajo realizado
|
||||
|
||||
## Cambios
|
||||
- Sin cambios
|
||||
|
||||
## Decisiones
|
||||
- Sin decisiones
|
||||
|
||||
## Bloqueos
|
||||
- Sin bloqueos
|
||||
|
||||
## Proximos pasos
|
||||
- Definir próximos pasos
|
||||
|
||||
## Referencias
|
||||
- Sin referencias
|
||||
|
||||
## Duracion
|
||||
3 minutos
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "5f6b1f86-7ce1-4079-b886-9885cff9cca5",
|
||||
"project_slug": "mi-proyecto",
|
||||
"started_at": "2026-03-24T23:40:18.747800",
|
||||
"ended_at": "2026-03-25T00:07:23.886040",
|
||||
"duration_minutes": 27,
|
||||
"objective": "",
|
||||
"summary": "Session de 27 minutos sin progreso registrado.",
|
||||
"work_done": [],
|
||||
"changes": [],
|
||||
"decisions": [],
|
||||
"blockers": [],
|
||||
"next_steps": [
|
||||
"Definir próximos pasos"
|
||||
],
|
||||
"references": [],
|
||||
"raw_notes": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "6103e2ec-e40c-4a0b-8672-cd4db18ca487",
|
||||
"project_slug": "mi-proyecto",
|
||||
"started_at": "2026-03-25T00:09:38.396081",
|
||||
"ended_at": "2026-03-25T00:13:11.888662",
|
||||
"duration_minutes": 3,
|
||||
"objective": "",
|
||||
"summary": "Session de 3 minutos sin progreso registrado.",
|
||||
"work_done": [],
|
||||
"changes": [],
|
||||
"decisions": [],
|
||||
"blockers": [],
|
||||
"next_steps": [
|
||||
"Definir próximos pasos"
|
||||
],
|
||||
"references": [],
|
||||
"raw_notes": [
|
||||
{
|
||||
"type": "idea",
|
||||
"text": "prueba de idea",
|
||||
"created_at": "2026-03-25T00:11:32.741023"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,11 @@ git = ["gitpython"]
|
||||
[project.scripts]
|
||||
tracker = "tracker.cli.main:app"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["tracker*"]
|
||||
exclude = ["tests*", "examples*", "docs*", "backlog*", "resumen*"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
85
resumen/2026-03-24-1245-resumen.md
Normal file
85
resumen/2026-03-24-1245-resumen.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Resumen del Proyecto: Personal Tracker CLI
|
||||
|
||||
## Fecha: 2026-03-24 12:45
|
||||
|
||||
## Descripción General
|
||||
|
||||
**Personal Tracker** es una herramienta CLI para seguimiento de proyectos personales, enfocada en la continuidad entre sesiones de trabajo. Utiliza archivos Markdown y YAML como persistencia, sin base de datos ni dependencias cloud.
|
||||
|
||||
## Rama Actual
|
||||
|
||||
- **develop**
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
- Python 3.11+
|
||||
- Typer (CLI)
|
||||
- PyYAML (metadatos)
|
||||
- Jinja2 (plantillas)
|
||||
- Pydantic (modelos)
|
||||
- GitPython (opcional)
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
tracker/
|
||||
├── cli/ # Comandos CLI (Typer)
|
||||
├── models/ # Modelos de datos (Pydantic)
|
||||
├── services/ # Lógica de negocio
|
||||
├── storage/ # Persistencia archivos
|
||||
├── utils/ # Utilidades
|
||||
└── templates/ # Plantillas Jinja2
|
||||
|
||||
projects/ # Proyectos creados
|
||||
tests/ # 72 tests
|
||||
examples/demo-project/ # Proyecto demo
|
||||
backlog/ # Requisitos (MVP-1.md)
|
||||
```
|
||||
|
||||
## Comandos Implementados
|
||||
|
||||
| Comando | Descripción |
|
||||
|---------|-------------|
|
||||
| `init-project` | Crear nuevo proyecto |
|
||||
| `list` | Listar proyectos |
|
||||
| `show` | Ver detalles |
|
||||
| `start` | Iniciar sesión |
|
||||
| `note` | Agregar nota |
|
||||
| `stop` | Finalizar sesión |
|
||||
| `change` | Agregar al changelog |
|
||||
| `next` | Sugerir próximos pasos |
|
||||
| `review` | Vista general |
|
||||
|
||||
## Commits en Rama develop
|
||||
|
||||
1. `525996f` - Initial commit
|
||||
2. `4547c49` - Implement storage layer
|
||||
3. `b0c65a0` - Implement core services
|
||||
4. `88a474a` - Implement CLI commands
|
||||
5. `40a33d7` - Implement project templates
|
||||
6. `b36b603` - Complete CLI commands
|
||||
7. `4e67062` - Add demo project
|
||||
8. `2735562` - Add comprehensive tests
|
||||
9. `4d99213` - Update README with MVP-1 documentation
|
||||
10. `b4593c6` - Add detailed installation guide
|
||||
11. `cc52360` - Fix pyproject.toml package discovery
|
||||
|
||||
## Instalación
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
# 72 tests implementados
|
||||
```
|
||||
|
||||
## Estado Actual
|
||||
|
||||
- MVP-1 implementado y funcional
|
||||
- CLI operativa con todos los comandos
|
||||
- Tests cubiertos
|
||||
- Documentación completa en README.md
|
||||
102
tests/conftest.py
Normal file
102
tests/conftest.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Pytest fixtures for tracker tests."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from tracker.models import Project, Session, Note, NoteType, Change
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_project_dir(tmp_path):
|
||||
"""Create a temporary directory for project tests."""
|
||||
projects_root = tmp_path / "projects"
|
||||
projects_root.mkdir()
|
||||
return projects_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_project_data():
|
||||
"""Sample project data for testing."""
|
||||
return {
|
||||
"id": "test-project-id-123",
|
||||
"name": "Test Project",
|
||||
"slug": "test-project",
|
||||
"description": "A test project for unit testing",
|
||||
"type": "code",
|
||||
"status": "active",
|
||||
"tags": ["python", "testing"],
|
||||
"root_path": "/path/to/projects/test-project",
|
||||
"repo_path": "/path/to/repos/test-project",
|
||||
"created_at": datetime(2024, 1, 15, 10, 0, 0),
|
||||
"updated_at": datetime(2024, 1, 15, 10, 0, 0),
|
||||
"last_session_at": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_project(sample_project_data):
|
||||
"""Create a sample Project instance."""
|
||||
return Project(**sample_project_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session(sample_project):
|
||||
"""Create a mock session for testing."""
|
||||
return Session(
|
||||
id="session-123",
|
||||
project_slug=sample_project.slug,
|
||||
started_at=datetime(2024, 1, 15, 10, 0, 0),
|
||||
ended_at=datetime(2024, 1, 15, 11, 30, 0),
|
||||
duration_minutes=90,
|
||||
objective="Complete initial implementation",
|
||||
summary="Worked on core features",
|
||||
work_done=["Implemented feature A", "Fixed bug B"],
|
||||
changes=["Added new endpoint"],
|
||||
decisions=["Use JSON for storage"],
|
||||
blockers=[],
|
||||
next_steps=["Add tests", "Write documentation"],
|
||||
references=["https://example.com/docs"],
|
||||
raw_notes=[
|
||||
{"type": "work", "text": "Working on feature A", "timestamp": "2024-01-15T10:15:00"},
|
||||
{"type": "idea", "text": "Consider using caching", "timestamp": "2024-01-15T10:30:00"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_note():
|
||||
"""Create a sample note for testing."""
|
||||
return Note(
|
||||
type=NoteType.WORK,
|
||||
text="Completed the implementation of feature X",
|
||||
created_at=datetime(2024, 1, 15, 10, 30, 0),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_change():
|
||||
"""Create a sample change for testing."""
|
||||
return Change(
|
||||
date=datetime(2024, 1, 15).date(),
|
||||
type="code",
|
||||
title="Added user authentication",
|
||||
impact="Improved security",
|
||||
references=["#123"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_project_with_structure(tmp_project_dir, sample_project_data):
|
||||
"""Create a temporary project with directory structure."""
|
||||
slug = sample_project_data["slug"]
|
||||
project_root = tmp_project_dir / slug
|
||||
(project_root / "sessions").mkdir(parents=True)
|
||||
(project_root / "docs").mkdir(parents=True)
|
||||
(project_root / "assets").mkdir(parents=True)
|
||||
(project_root / "meta").mkdir(parents=True)
|
||||
return project_root
|
||||
337
tests/test_flow.py
Normal file
337
tests/test_flow.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""Tests for complete flow: init -> start -> note -> stop -> show."""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from tracker.models import Session, Note, NoteType
|
||||
from tracker.services import (
|
||||
create_project,
|
||||
get_project,
|
||||
ensure_project_structure,
|
||||
set_active_session,
|
||||
get_active_session,
|
||||
clear_active_session,
|
||||
validate_no_other_active_session,
|
||||
add_note,
|
||||
consolidate_notes,
|
||||
get_projects_root,
|
||||
)
|
||||
from tracker.storage import FileStorage
|
||||
|
||||
|
||||
class TestInitProjectFlow:
|
||||
"""Tests for project initialization flow."""
|
||||
|
||||
def test_init_project_creates_structure(self, tmp_path, sample_project_data):
|
||||
"""Test that project initialization creates required directory structure."""
|
||||
slug = sample_project_data["slug"]
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
project = create_project(
|
||||
name=sample_project_data["name"],
|
||||
slug=slug,
|
||||
description=sample_project_data["description"],
|
||||
type=sample_project_data["type"],
|
||||
)
|
||||
|
||||
ensure_project_structure(slug)
|
||||
|
||||
project_root = tmp_path / slug
|
||||
assert (project_root / "sessions").is_dir()
|
||||
assert (project_root / "docs").is_dir()
|
||||
assert (project_root / "assets").is_dir()
|
||||
assert (project_root / "meta").is_dir()
|
||||
|
||||
def test_init_project_creates_meta_file(self, tmp_path, sample_project_data):
|
||||
"""Test that project initialization creates meta/project.yaml."""
|
||||
slug = sample_project_data["slug"]
|
||||
storage = FileStorage(tmp_path)
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
project = create_project(
|
||||
name=sample_project_data["name"],
|
||||
slug=slug,
|
||||
description=sample_project_data["description"],
|
||||
type=sample_project_data["type"],
|
||||
)
|
||||
|
||||
# Write the project meta (simulating what storage would do)
|
||||
meta_data = {
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"slug": project.slug,
|
||||
"description": project.description,
|
||||
"type": project.type,
|
||||
"status": project.status,
|
||||
"tags": project.tags,
|
||||
"created_at": project.created_at.isoformat(),
|
||||
"updated_at": project.updated_at.isoformat(),
|
||||
}
|
||||
storage.write_project_meta(slug, meta_data)
|
||||
|
||||
meta_path = tmp_path / slug / "meta" / "project.yaml"
|
||||
assert meta_path.exists()
|
||||
|
||||
with open(meta_path, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
assert data["name"] == sample_project_data["name"]
|
||||
assert data["slug"] == slug
|
||||
|
||||
|
||||
class TestStartSessionFlow:
|
||||
"""Tests for starting a session."""
|
||||
|
||||
def test_start_session(self, tmp_path, sample_project_data, monkeypatch):
|
||||
"""Test starting a session creates active session."""
|
||||
slug = sample_project_data["slug"]
|
||||
|
||||
# Create a fake active session path
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
project = create_project(
|
||||
name=sample_project_data["name"],
|
||||
slug=slug,
|
||||
)
|
||||
ensure_project_structure(slug)
|
||||
|
||||
session = Session(
|
||||
id="session-1",
|
||||
project_slug=slug,
|
||||
started_at=datetime.now(),
|
||||
objective="Test objective",
|
||||
)
|
||||
|
||||
set_active_session(session)
|
||||
|
||||
active = get_active_session()
|
||||
assert active is not None
|
||||
assert active.id == "session-1"
|
||||
assert active.project_slug == slug
|
||||
|
||||
def test_start_session_fails_if_other_active(self, tmp_path, sample_project_data, monkeypatch):
|
||||
"""Test that starting a session fails if another project has an active session."""
|
||||
slug1 = "project-1"
|
||||
slug2 = "project-2"
|
||||
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
session1 = Session(
|
||||
id="session-1",
|
||||
project_slug=slug1,
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
set_active_session(session1)
|
||||
|
||||
is_valid = True
|
||||
if not validate_no_other_active_session(slug2):
|
||||
is_valid = False
|
||||
|
||||
assert is_valid is False
|
||||
|
||||
|
||||
class TestAddNoteFlow:
|
||||
"""Tests for adding notes during a session."""
|
||||
|
||||
def test_add_note(self, tmp_path, sample_project_data, monkeypatch):
|
||||
"""Test adding a note during a session."""
|
||||
slug = sample_project_data["slug"]
|
||||
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
session = Session(
|
||||
id="session-1",
|
||||
project_slug=slug,
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
set_active_session(session)
|
||||
|
||||
add_note(session, "work", "Test work note")
|
||||
|
||||
assert len(session.raw_notes) == 1
|
||||
assert session.raw_notes[0]["type"] == "work"
|
||||
assert session.raw_notes[0]["text"] == "Test work note"
|
||||
|
||||
def test_add_multiple_notes(self, tmp_path, sample_project_data, monkeypatch):
|
||||
"""Test adding multiple notes during a session."""
|
||||
slug = sample_project_data["slug"]
|
||||
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
session = Session(
|
||||
id="session-1",
|
||||
project_slug=slug,
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
|
||||
add_note(session, "work", "Work note")
|
||||
add_note(session, "idea", "Idea note")
|
||||
add_note(session, "blocker", "Blocker note")
|
||||
|
||||
assert len(session.raw_notes) == 3
|
||||
assert session.raw_notes[0]["type"] == "work"
|
||||
assert session.raw_notes[1]["type"] == "idea"
|
||||
assert session.raw_notes[2]["type"] == "blocker"
|
||||
|
||||
|
||||
class TestStopSessionFlow:
|
||||
"""Tests for stopping a session."""
|
||||
|
||||
def test_stop_session(self, tmp_path, sample_project_data, monkeypatch):
|
||||
"""Test stopping a session clears active session."""
|
||||
slug = sample_project_data["slug"]
|
||||
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
session = Session(
|
||||
id="session-1",
|
||||
project_slug=slug,
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
set_active_session(session)
|
||||
|
||||
session.ended_at = datetime.now()
|
||||
session.duration_minutes = 30
|
||||
|
||||
clear_active_session()
|
||||
|
||||
active = get_active_session()
|
||||
assert active is None
|
||||
|
||||
|
||||
class TestShowProjectFlow:
|
||||
"""Tests for showing project information."""
|
||||
|
||||
def test_show_project(self, tmp_path, sample_project_data):
|
||||
"""Test showing project information."""
|
||||
slug = sample_project_data["slug"]
|
||||
storage = FileStorage(tmp_path)
|
||||
|
||||
meta_data = {
|
||||
"id": "test-id",
|
||||
"name": sample_project_data["name"],
|
||||
"slug": slug,
|
||||
"description": sample_project_data["description"],
|
||||
"type": sample_project_data["type"],
|
||||
"status": sample_project_data["status"],
|
||||
"tags": sample_project_data["tags"],
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
storage.write_project_meta(slug, meta_data)
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
project = get_project(slug)
|
||||
assert project is not None
|
||||
assert project.name == sample_project_data["name"]
|
||||
assert project.slug == slug
|
||||
|
||||
|
||||
class TestConsolidateNotes:
|
||||
"""Tests for consolidating notes after session."""
|
||||
|
||||
def test_consolidate_notes_categorizes_work(self):
|
||||
"""Test that consolidate_notes categorizes work notes."""
|
||||
raw_notes = [
|
||||
{"type": "work", "text": "Implemented feature A", "timestamp": "2024-01-15T10:00:00"},
|
||||
{"type": "work", "text": "Wrote tests", "timestamp": "2024-01-15T10:30:00"},
|
||||
]
|
||||
|
||||
consolidated = consolidate_notes(raw_notes)
|
||||
|
||||
assert len(consolidated["work_done"]) == 2
|
||||
assert "Implemented feature A" in consolidated["work_done"]
|
||||
assert "Wrote tests" in consolidated["work_done"]
|
||||
|
||||
def test_consolidate_notes_categorizes_blockers(self):
|
||||
"""Test that consolidate_notes categorizes blockers."""
|
||||
raw_notes = [
|
||||
{"type": "blocker", "text": "Waiting for API access", "timestamp": "2024-01-15T10:00:00"},
|
||||
]
|
||||
|
||||
consolidated = consolidate_notes(raw_notes)
|
||||
|
||||
assert len(consolidated["blockers"]) == 1
|
||||
assert "Waiting for API access" in consolidated["blockers"]
|
||||
|
||||
def test_consolidate_notes_categorizes_decisions(self):
|
||||
"""Test that consolidate_notes categorizes decisions."""
|
||||
raw_notes = [
|
||||
{"type": "decision", "text": "Use PostgreSQL for storage", "timestamp": "2024-01-15T10:00:00"},
|
||||
]
|
||||
|
||||
consolidated = consolidate_notes(raw_notes)
|
||||
|
||||
assert len(consolidated["decisions"]) == 1
|
||||
assert "Use PostgreSQL for storage" in consolidated["decisions"]
|
||||
|
||||
def test_consolidate_notes_categorizes_references(self):
|
||||
"""Test that consolidate_notes categorizes references."""
|
||||
raw_notes = [
|
||||
{"type": "reference", "text": "https://docs.example.com", "timestamp": "2024-01-15T10:00:00"},
|
||||
]
|
||||
|
||||
consolidated = consolidate_notes(raw_notes)
|
||||
|
||||
assert len(consolidated["references"]) == 1
|
||||
assert "https://docs.example.com" in consolidated["references"]
|
||||
|
||||
def test_consolidate_notes_categorizes_changes(self):
|
||||
"""Test that consolidate_notes categorizes changes."""
|
||||
raw_notes = [
|
||||
{"type": "change", "text": "Refactored authentication module", "timestamp": "2024-01-15T10:00:00"},
|
||||
]
|
||||
|
||||
consolidated = consolidate_notes(raw_notes)
|
||||
|
||||
assert len(consolidated["changes"]) == 1
|
||||
assert "Refactored authentication module" in consolidated["changes"]
|
||||
220
tests/test_models.py
Normal file
220
tests/test_models.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Tests for data models."""
|
||||
|
||||
from datetime import datetime, date
|
||||
|
||||
import pytest
|
||||
|
||||
from tracker.models import Project, Session, Note, NoteType, Change
|
||||
|
||||
|
||||
class TestProjectCreation:
|
||||
"""Tests for Project model creation."""
|
||||
|
||||
def test_project_creation_with_all_fields(self, sample_project_data):
|
||||
"""Test creating a project with all fields specified."""
|
||||
project = Project(**sample_project_data)
|
||||
|
||||
assert project.id == sample_project_data["id"]
|
||||
assert project.name == sample_project_data["name"]
|
||||
assert project.slug == sample_project_data["slug"]
|
||||
assert project.description == sample_project_data["description"]
|
||||
assert project.type == sample_project_data["type"]
|
||||
assert project.status == sample_project_data["status"]
|
||||
assert project.tags == sample_project_data["tags"]
|
||||
assert project.root_path == sample_project_data["root_path"]
|
||||
assert project.repo_path == sample_project_data["repo_path"]
|
||||
assert project.created_at == sample_project_data["created_at"]
|
||||
assert project.updated_at == sample_project_data["updated_at"]
|
||||
assert project.last_session_at is None
|
||||
|
||||
def test_project_creation_with_defaults(self):
|
||||
"""Test creating a project with default values."""
|
||||
now = datetime.now()
|
||||
project = Project(
|
||||
id="test-id",
|
||||
name="Test",
|
||||
slug="test",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
assert project.description == ""
|
||||
assert project.type == "misc"
|
||||
assert project.status == "inbox"
|
||||
assert project.tags == []
|
||||
assert project.root_path == ""
|
||||
assert project.repo_path is None
|
||||
assert project.last_session_at is None
|
||||
|
||||
def test_project_status_validation(self):
|
||||
"""Test that project status can be set to valid values."""
|
||||
valid_statuses = ["inbox", "next", "active", "blocked", "waiting", "done", "archived"]
|
||||
|
||||
for status in valid_statuses:
|
||||
project = Project(
|
||||
id="test-id",
|
||||
name="Test",
|
||||
slug="test",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
status=status,
|
||||
)
|
||||
assert project.status == status
|
||||
|
||||
def test_project_type_validation(self):
|
||||
"""Test that project type can be set to valid values."""
|
||||
valid_types = ["code", "homelab", "automation", "agent", "research", "misc"]
|
||||
|
||||
for project_type in valid_types:
|
||||
project = Project(
|
||||
id="test-id",
|
||||
name="Test",
|
||||
slug="test",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
type=project_type,
|
||||
)
|
||||
assert project.type == project_type
|
||||
|
||||
|
||||
class TestSessionCreation:
|
||||
"""Tests for Session model creation."""
|
||||
|
||||
def test_session_creation_with_all_fields(self, mock_session):
|
||||
"""Test creating a session with all fields specified."""
|
||||
assert mock_session.id == "session-123"
|
||||
assert mock_session.project_slug == "test-project"
|
||||
assert mock_session.started_at == datetime(2024, 1, 15, 10, 0, 0)
|
||||
assert mock_session.ended_at == datetime(2024, 1, 15, 11, 30, 0)
|
||||
assert mock_session.duration_minutes == 90
|
||||
assert mock_session.objective == "Complete initial implementation"
|
||||
assert mock_session.summary == "Worked on core features"
|
||||
assert len(mock_session.work_done) == 2
|
||||
assert len(mock_session.changes) == 1
|
||||
assert len(mock_session.decisions) == 1
|
||||
assert len(mock_session.blockers) == 0
|
||||
assert len(mock_session.next_steps) == 2
|
||||
assert len(mock_session.references) == 1
|
||||
assert len(mock_session.raw_notes) == 2
|
||||
|
||||
def test_session_creation_with_defaults(self):
|
||||
"""Test creating a session with required fields only."""
|
||||
session = Session(
|
||||
id="session-1",
|
||||
project_slug="test-project",
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
|
||||
assert session.ended_at is None
|
||||
assert session.duration_minutes is None
|
||||
assert session.objective == ""
|
||||
assert session.summary == ""
|
||||
assert session.work_done == []
|
||||
assert session.changes == []
|
||||
assert session.decisions == []
|
||||
assert session.blockers == []
|
||||
assert session.next_steps == []
|
||||
assert session.references == []
|
||||
assert session.raw_notes == []
|
||||
|
||||
def test_session_with_optional_datetime_fields(self):
|
||||
"""Test session with ended_at but no duration_minutes."""
|
||||
session = Session(
|
||||
id="session-1",
|
||||
project_slug="test-project",
|
||||
started_at=datetime(2024, 1, 15, 10, 0, 0),
|
||||
ended_at=datetime(2024, 1, 15, 11, 0, 0),
|
||||
)
|
||||
|
||||
assert session.ended_at is not None
|
||||
assert session.duration_minutes is None
|
||||
|
||||
|
||||
class TestNoteTypes:
|
||||
"""Tests for Note and NoteType models."""
|
||||
|
||||
def test_note_creation(self, sample_note):
|
||||
"""Test creating a note."""
|
||||
assert sample_note.type == NoteType.WORK
|
||||
assert sample_note.text == "Completed the implementation of feature X"
|
||||
assert sample_note.created_at == datetime(2024, 1, 15, 10, 30, 0)
|
||||
|
||||
def test_note_type_enum_values(self):
|
||||
"""Test that NoteType enum has expected values."""
|
||||
assert NoteType.WORK.value == "work"
|
||||
assert NoteType.CHANGE.value == "change"
|
||||
assert NoteType.BLOCKER.value == "blocker"
|
||||
assert NoteType.DECISION.value == "decision"
|
||||
assert NoteType.IDEA.value == "idea"
|
||||
assert NoteType.REFERENCE.value == "reference"
|
||||
|
||||
def test_note_type_assignment(self):
|
||||
"""Test assigning different note types to a note."""
|
||||
for note_type in NoteType:
|
||||
note = Note(type=note_type, text="Test note")
|
||||
assert note.type == note_type
|
||||
|
||||
def test_note_default_created_at(self):
|
||||
"""Test that note has default created_at timestamp."""
|
||||
note = Note(type=NoteType.WORK, text="Test note")
|
||||
assert note.created_at is not None
|
||||
assert isinstance(note.created_at, datetime)
|
||||
|
||||
def test_note_raw_notes_structure(self, mock_session):
|
||||
"""Test that raw_notes in session have expected structure."""
|
||||
assert len(mock_session.raw_notes) == 2
|
||||
assert mock_session.raw_notes[0]["type"] == "work"
|
||||
assert mock_session.raw_notes[0]["text"] == "Working on feature A"
|
||||
assert "timestamp" in mock_session.raw_notes[0]
|
||||
|
||||
|
||||
class TestChangeValidation:
|
||||
"""Tests for Change model validation."""
|
||||
|
||||
def test_change_creation(self, sample_change):
|
||||
"""Test creating a change."""
|
||||
assert sample_change.date == date(2024, 1, 15)
|
||||
assert sample_change.type == "code"
|
||||
assert sample_change.title == "Added user authentication"
|
||||
assert sample_change.impact == "Improved security"
|
||||
assert sample_change.references == ["#123"]
|
||||
|
||||
def test_change_creation_with_defaults(self):
|
||||
"""Test creating a change with default values."""
|
||||
change = Change(
|
||||
date=date(2024, 1, 15),
|
||||
type="docs",
|
||||
title="Updated documentation",
|
||||
)
|
||||
|
||||
assert change.impact == ""
|
||||
assert change.references == []
|
||||
|
||||
def test_change_date_as_date_not_datetime(self, sample_change):
|
||||
"""Test that change date is stored as date object."""
|
||||
assert isinstance(sample_change.date, date)
|
||||
assert not isinstance(sample_change.date, datetime)
|
||||
|
||||
def test_change_types(self):
|
||||
"""Test valid change types."""
|
||||
valid_types = ["code", "infra", "config", "docs", "automation", "decision"]
|
||||
|
||||
for change_type in valid_types:
|
||||
change = Change(
|
||||
date=date.today(),
|
||||
type=change_type,
|
||||
title=f"Test {change_type}",
|
||||
)
|
||||
assert change.type == change_type
|
||||
|
||||
def test_change_with_multiple_references(self):
|
||||
"""Test change with multiple references."""
|
||||
change = Change(
|
||||
date=date.today(),
|
||||
type="code",
|
||||
title="Major refactor",
|
||||
references=["#100", "#101", "#102"],
|
||||
)
|
||||
assert len(change.references) == 3
|
||||
assert "#100" in change.references
|
||||
assert "#102" in change.references
|
||||
209
tests/test_project_service.py
Normal file
209
tests/test_project_service.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Tests for ProjectService."""
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from tracker.models import Project
|
||||
from tracker.services import (
|
||||
create_project,
|
||||
get_project,
|
||||
update_project,
|
||||
list_projects,
|
||||
get_projects_root,
|
||||
ensure_project_structure,
|
||||
)
|
||||
|
||||
|
||||
class TestCreateProject:
|
||||
"""Tests for create_project function."""
|
||||
|
||||
def test_create_project_returns_project_instance(self):
|
||||
"""Test that create_project returns a Project instance."""
|
||||
project = create_project(
|
||||
name="Test Project",
|
||||
slug="test-project",
|
||||
description="A test project",
|
||||
type="code",
|
||||
)
|
||||
|
||||
assert isinstance(project, Project)
|
||||
assert project.name == "Test Project"
|
||||
assert project.slug == "test-project"
|
||||
assert project.description == "A test project"
|
||||
assert project.type == "code"
|
||||
|
||||
def test_create_project_generates_id(self):
|
||||
"""Test that create_project generates a UUID."""
|
||||
project = create_project(name="Test", slug="test")
|
||||
assert project.id is not None
|
||||
assert len(project.id) > 0
|
||||
|
||||
def test_create_project_sets_default_status(self):
|
||||
"""Test that create_project sets default status to inbox."""
|
||||
project = create_project(name="Test", slug="test")
|
||||
assert project.status == "inbox"
|
||||
|
||||
def test_create_project_sets_timestamps(self):
|
||||
"""Test that create_project sets created_at and updated_at."""
|
||||
before = datetime.now()
|
||||
project = create_project(name="Test", slug="test")
|
||||
after = datetime.now()
|
||||
|
||||
assert before <= project.created_at <= after
|
||||
assert before <= project.updated_at <= after
|
||||
# created_at and updated_at should be very close (within 1 second)
|
||||
time_diff = abs((project.updated_at - project.created_at).total_seconds())
|
||||
assert time_diff < 1
|
||||
|
||||
def test_create_project_with_tags(self):
|
||||
"""Test creating a project with tags."""
|
||||
tags = ["python", "testing", "cli"]
|
||||
project = create_project(name="Test", slug="test", tags=tags)
|
||||
assert project.tags == tags
|
||||
|
||||
def test_create_project_with_repo_path(self):
|
||||
"""Test creating a project with a repo path."""
|
||||
repo_path = Path("/path/to/repo")
|
||||
project = create_project(name="Test", slug="test", repo_path=repo_path)
|
||||
assert project.repo_path == str(repo_path)
|
||||
|
||||
def test_create_project_without_repo_path(self):
|
||||
"""Test creating a project without repo path."""
|
||||
project = create_project(name="Test", slug="test")
|
||||
assert project.repo_path is None
|
||||
|
||||
|
||||
class TestGetProject:
|
||||
"""Tests for get_project function."""
|
||||
|
||||
def test_get_project_returns_none_for_nonexistent(self, tmp_path):
|
||||
"""Test that get_project returns None for nonexistent project."""
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
result = get_project("nonexistent")
|
||||
assert result is None
|
||||
|
||||
def test_get_project_returns_project_when_exists(self, tmp_path, sample_project_data):
|
||||
"""Test that get_project returns Project when it exists."""
|
||||
slug = sample_project_data["slug"]
|
||||
project_dir = tmp_path / slug / "meta"
|
||||
project_dir.mkdir(parents=True)
|
||||
|
||||
meta_path = project_dir / "project.yaml"
|
||||
with open(meta_path, "w") as f:
|
||||
yaml.safe_dump(sample_project_data, f)
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
project = get_project(slug)
|
||||
assert project is not None
|
||||
assert project.slug == slug
|
||||
assert project.name == sample_project_data["name"]
|
||||
|
||||
def test_get_project_handles_invalid_yaml(self, tmp_path):
|
||||
"""Test that get_project handles invalid YAML gracefully."""
|
||||
slug = "test-project"
|
||||
project_dir = tmp_path / slug / "meta"
|
||||
project_dir.mkdir(parents=True)
|
||||
|
||||
meta_path = project_dir / "project.yaml"
|
||||
with open(meta_path, "w") as f:
|
||||
f.write("invalid: yaml: content:")
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
result = get_project(slug)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestListProjects:
|
||||
"""Tests for list_projects function."""
|
||||
|
||||
def test_list_projects_returns_empty_list_when_no_projects(self, tmp_path):
|
||||
"""Test that list_projects returns empty list when no projects exist."""
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
projects = list_projects()
|
||||
assert projects == []
|
||||
|
||||
def test_list_projects_returns_all_projects(self, tmp_path, sample_project_data):
|
||||
"""Test that list_projects returns all existing projects."""
|
||||
project1_data = sample_project_data.copy()
|
||||
project1_data["slug"] = "project-1"
|
||||
project1_data["name"] = "Project 1"
|
||||
|
||||
project2_data = sample_project_data.copy()
|
||||
project2_data["slug"] = "project-2"
|
||||
project2_data["name"] = "Project 2"
|
||||
|
||||
for project_data in [project1_data, project2_data]:
|
||||
slug = project_data["slug"]
|
||||
project_dir = tmp_path / slug / "meta"
|
||||
project_dir.mkdir(parents=True)
|
||||
meta_path = project_dir / "project.yaml"
|
||||
with open(meta_path, "w") as f:
|
||||
yaml.safe_dump(project_data, f)
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
projects = list_projects()
|
||||
assert len(projects) == 2
|
||||
slugs = [p.slug for p in projects]
|
||||
assert "project-1" in slugs
|
||||
assert "project-2" in slugs
|
||||
|
||||
def test_list_projects_ignores_hidden_directories(self, tmp_path, sample_project_data):
|
||||
"""Test that list_projects ignores hidden directories."""
|
||||
slug = sample_project_data["slug"]
|
||||
project_dir = tmp_path / slug / "meta"
|
||||
project_dir.mkdir(parents=True)
|
||||
meta_path = project_dir / "project.yaml"
|
||||
with open(meta_path, "w") as f:
|
||||
yaml.safe_dump(sample_project_data, f)
|
||||
|
||||
hidden_dir = tmp_path / ".hidden"
|
||||
hidden_dir.mkdir()
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
projects = list_projects()
|
||||
assert len(projects) == 1
|
||||
assert projects[0].slug == slug
|
||||
|
||||
|
||||
class TestEnsureProjectStructure:
|
||||
"""Tests for ensure_project_structure function."""
|
||||
|
||||
def test_ensure_project_structure_creates_directories(self, tmp_path):
|
||||
"""Test that ensure_project_structure creates required directories."""
|
||||
slug = "test-project"
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
ensure_project_structure(slug)
|
||||
|
||||
project_root = tmp_path / slug
|
||||
assert (project_root / "sessions").exists()
|
||||
assert (project_root / "docs").exists()
|
||||
assert (project_root / "assets").exists()
|
||||
assert (project_root / "meta").exists()
|
||||
|
||||
def test_ensure_project_structure_is_idempotent(self, tmp_path):
|
||||
"""Test that ensure_project_structure can be called multiple times safely."""
|
||||
slug = "test-project"
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
ensure_project_structure(slug)
|
||||
ensure_project_structure(slug)
|
||||
|
||||
project_root = tmp_path / slug
|
||||
assert (project_root / "sessions").exists()
|
||||
assert (project_root / "docs").exists()
|
||||
|
||||
def test_ensure_project_structure_creates_nested_directories(self, tmp_path):
|
||||
"""Test that ensure_project_structure creates parent directories if needed."""
|
||||
slug = "nested/project"
|
||||
|
||||
with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path):
|
||||
ensure_project_structure(slug)
|
||||
|
||||
project_root = tmp_path / slug
|
||||
assert (project_root / "sessions").exists()
|
||||
241
tests/test_session_service.py
Normal file
241
tests/test_session_service.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Tests for SessionService."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tracker.models import Session
|
||||
from tracker.services import (
|
||||
get_active_session,
|
||||
set_active_session,
|
||||
clear_active_session,
|
||||
get_active_session_path,
|
||||
validate_no_other_active_session,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_active_session_path(tmp_path, monkeypatch):
|
||||
"""Mock the active session path to use tmp_path."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
return fake_path
|
||||
|
||||
|
||||
class TestSetAndGetActiveSession:
|
||||
"""Tests for set and get active session functions."""
|
||||
|
||||
def test_set_and_get_active_session(self, tmp_path, mock_session, monkeypatch):
|
||||
"""Test setting and getting an active session."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
set_active_session(mock_session)
|
||||
retrieved = get_active_session()
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == mock_session.id
|
||||
assert retrieved.project_slug == mock_session.project_slug
|
||||
assert retrieved.started_at == mock_session.started_at
|
||||
|
||||
def test_get_active_session_returns_none_when_no_session(self, tmp_path, monkeypatch):
|
||||
"""Test that get_active_session returns None when no session exists."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
result = get_active_session()
|
||||
assert result is None
|
||||
|
||||
def test_set_active_session_creates_parent_directories(self, tmp_path, mock_session, monkeypatch):
|
||||
"""Test that set_active_session creates parent directories if needed."""
|
||||
fake_path = tmp_path / "subdir" / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
set_active_session(mock_session)
|
||||
assert fake_path.exists()
|
||||
|
||||
def test_active_session_contains_all_fields(self, tmp_path, mock_session, monkeypatch):
|
||||
"""Test that active session file contains all session fields."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
set_active_session(mock_session)
|
||||
|
||||
with open(fake_path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data["id"] == mock_session.id
|
||||
assert data["project_slug"] == mock_session.project_slug
|
||||
assert "started_at" in data
|
||||
assert "work_done" in data
|
||||
assert data["work_done"] == mock_session.work_done
|
||||
|
||||
|
||||
class TestClearActiveSession:
|
||||
"""Tests for clear_active_session function."""
|
||||
|
||||
def test_clear_active_session_removes_file(self, tmp_path, mock_session, monkeypatch):
|
||||
"""Test that clear_active_session removes the active session file."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
set_active_session(mock_session)
|
||||
assert fake_path.exists()
|
||||
|
||||
clear_active_session()
|
||||
assert not fake_path.exists()
|
||||
|
||||
def test_clear_active_session_when_no_session(self, tmp_path, monkeypatch):
|
||||
"""Test that clear_active_session does not error when no session exists."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
clear_active_session()
|
||||
assert not fake_path.exists()
|
||||
|
||||
def test_get_active_session_after_clear(self, tmp_path, mock_session, monkeypatch):
|
||||
"""Test that get_active_session returns None after clearing."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
set_active_session(mock_session)
|
||||
clear_active_session()
|
||||
|
||||
result = get_active_session()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestValidateNoOtherActiveSession:
|
||||
"""Tests for validate_no_other_active_session function."""
|
||||
|
||||
def test_validate_no_other_active_session_returns_true_when_none(self, tmp_path, monkeypatch):
|
||||
"""Test validation returns True when no active session exists."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
result = validate_no_other_active_session("any-project")
|
||||
assert result is True
|
||||
|
||||
def test_validate_no_other_active_session_returns_true_for_same_project(
|
||||
self, tmp_path, mock_session, monkeypatch
|
||||
):
|
||||
"""Test validation returns True for same project's session."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
set_active_session(mock_session)
|
||||
|
||||
result = validate_no_other_active_session(mock_session.project_slug)
|
||||
assert result is True
|
||||
|
||||
def test_validate_no_other_active_session_returns_false_for_different_project(
|
||||
self, tmp_path, mock_session, monkeypatch
|
||||
):
|
||||
"""Test validation returns False for different project's session."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
set_active_session(mock_session)
|
||||
|
||||
result = validate_no_other_active_session("different-project")
|
||||
assert result is False
|
||||
|
||||
def test_validate_no_other_active_session_with_no_active_after_clear(
|
||||
self, tmp_path, mock_session, monkeypatch
|
||||
):
|
||||
"""Test validation returns True after clearing session."""
|
||||
fake_path = tmp_path / ".active_session.json"
|
||||
|
||||
def mock_get_active_session_path():
|
||||
return fake_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tracker.services.session_service.get_active_session_path",
|
||||
mock_get_active_session_path
|
||||
)
|
||||
|
||||
set_active_session(mock_session)
|
||||
clear_active_session()
|
||||
|
||||
result = validate_no_other_active_session("any-project")
|
||||
assert result is True
|
||||
193
tests/test_storage.py
Normal file
193
tests/test_storage.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Tests for FileStorage."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from tracker.models import Session
|
||||
from tracker.storage import FileStorage
|
||||
|
||||
|
||||
class TestReadWriteProjectMeta:
|
||||
"""Tests for read/write project meta operations."""
|
||||
|
||||
def test_write_and_read_project_meta(self, tmp_project_dir):
|
||||
"""Test writing and reading project meta."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
slug = "test-project"
|
||||
|
||||
meta_data = {
|
||||
"id": "test-id",
|
||||
"name": "Test Project",
|
||||
"slug": slug,
|
||||
"description": "A test project",
|
||||
"type": "code",
|
||||
"status": "active",
|
||||
"tags": ["python"],
|
||||
"created_at": datetime(2024, 1, 15, 10, 0, 0).isoformat(),
|
||||
"updated_at": datetime(2024, 1, 15, 10, 0, 0).isoformat(),
|
||||
}
|
||||
|
||||
storage.write_project_meta(slug, meta_data)
|
||||
|
||||
result = storage.read_project_meta(slug)
|
||||
assert result["name"] == "Test Project"
|
||||
assert result["slug"] == slug
|
||||
assert result["type"] == "code"
|
||||
|
||||
def test_read_project_meta_creates_parent_directories(self, tmp_project_dir):
|
||||
"""Test that write_project_meta creates parent directories."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
slug = "new-project"
|
||||
|
||||
meta_data = {"id": "test-id", "name": "Test", "slug": slug}
|
||||
storage.write_project_meta(slug, meta_data)
|
||||
|
||||
meta_path = tmp_project_dir / slug / "meta" / "project.yaml"
|
||||
assert meta_path.exists()
|
||||
|
||||
def test_read_project_meta_raises_for_nonexistent(self, tmp_project_dir):
|
||||
"""Test that reading nonexistent project raises error."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
storage.read_project_meta("nonexistent")
|
||||
|
||||
|
||||
class TestAppendToLog:
|
||||
"""Tests for append_to_log operations."""
|
||||
|
||||
def test_append_to_log_creates_log_file(self, tmp_project_dir):
|
||||
"""Test that append_to_log creates LOG.md if it doesn't exist."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
slug = "test-project"
|
||||
|
||||
# Create project directory first
|
||||
(tmp_project_dir / slug).mkdir(parents=True)
|
||||
|
||||
storage.append_to_log(slug, "# Test Log Entry\n")
|
||||
|
||||
log_path = tmp_project_dir / slug / "LOG.md"
|
||||
assert log_path.exists()
|
||||
|
||||
def test_append_to_log_appends_content(self, tmp_project_dir):
|
||||
"""Test that append_to_log appends content to LOG.md."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
slug = "test-project"
|
||||
|
||||
# Create project directory first
|
||||
(tmp_project_dir / slug).mkdir(parents=True)
|
||||
|
||||
storage.append_to_log(slug, "# First Entry\n")
|
||||
storage.append_to_log(slug, "# Second Entry\n")
|
||||
|
||||
content = storage.read_log(slug)
|
||||
assert "# First Entry" in content
|
||||
assert "# Second Entry" in content
|
||||
|
||||
def test_read_log_returns_empty_string_for_nonexistent(self, tmp_project_dir):
|
||||
"""Test that read_log returns empty string for nonexistent log."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
result = storage.read_log("nonexistent")
|
||||
assert result == ""
|
||||
|
||||
|
||||
class TestActiveSessionStorage:
|
||||
"""Tests for active session storage operations."""
|
||||
|
||||
def test_write_and_read_active_session(self, tmp_project_dir, mock_session):
|
||||
"""Test writing and reading active session."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
session_data = mock_session.model_dump(mode="json")
|
||||
session_data["started_at"] = mock_session.started_at.isoformat()
|
||||
if mock_session.ended_at:
|
||||
session_data["ended_at"] = mock_session.ended_at.isoformat()
|
||||
|
||||
storage.write_active_session(session_data)
|
||||
|
||||
result = storage.read_active_session()
|
||||
assert result is not None
|
||||
assert result["id"] == mock_session.id
|
||||
|
||||
def test_read_active_session_returns_none_when_not_exists(self, tmp_project_dir):
|
||||
"""Test that read_active_session returns None when file doesn't exist."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
result = storage.read_active_session()
|
||||
assert result is None
|
||||
|
||||
def test_delete_active_session(self, tmp_project_dir, mock_session):
|
||||
"""Test deleting active session."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
session_data = mock_session.model_dump(mode="json")
|
||||
session_data["started_at"] = mock_session.started_at.isoformat()
|
||||
storage.write_active_session(session_data)
|
||||
|
||||
storage.delete_active_session()
|
||||
|
||||
result = storage.read_active_session()
|
||||
assert result is None
|
||||
|
||||
def test_delete_active_session_when_not_exists(self, tmp_project_dir):
|
||||
"""Test deleting active session when it doesn't exist doesn't error."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
storage.delete_active_session()
|
||||
|
||||
|
||||
class TestProjectExistence:
|
||||
"""Tests for project existence checks."""
|
||||
|
||||
def test_project_exists_returns_true_for_existing_project(self, tmp_project_dir):
|
||||
"""Test that project_exists returns True for existing project."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
slug = "test-project"
|
||||
|
||||
(tmp_project_dir / slug).mkdir()
|
||||
|
||||
assert storage.project_exists(slug) is True
|
||||
|
||||
def test_project_exists_returns_false_for_nonexistent(self, tmp_project_dir):
|
||||
"""Test that project_exists returns False for nonexistent project."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
assert storage.project_exists("nonexistent") is False
|
||||
|
||||
|
||||
class TestListProjects:
|
||||
"""Tests for listing projects."""
|
||||
|
||||
def test_list_projects_returns_all_projects(self, tmp_project_dir):
|
||||
"""Test that list_projects returns all project slugs."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
(tmp_project_dir / "project-1").mkdir()
|
||||
(tmp_project_dir / "project-2").mkdir()
|
||||
|
||||
projects = storage.list_projects()
|
||||
assert "project-1" in projects
|
||||
assert "project-2" in projects
|
||||
|
||||
def test_list_projects_excludes_hidden_directories(self, tmp_project_dir):
|
||||
"""Test that list_projects excludes hidden directories."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
(tmp_project_dir / "project-1").mkdir()
|
||||
(tmp_project_dir / ".hidden").mkdir()
|
||||
|
||||
projects = storage.list_projects()
|
||||
assert "project-1" in projects
|
||||
assert ".hidden" not in projects
|
||||
|
||||
def test_list_projects_returns_empty_list_when_no_projects(self, tmp_project_dir):
|
||||
"""Test that list_projects returns empty list when no projects exist."""
|
||||
storage = FileStorage(tmp_project_dir)
|
||||
|
||||
projects = storage.list_projects()
|
||||
assert projects == []
|
||||
@@ -35,11 +35,7 @@ markdown_writer = MarkdownWriter()
|
||||
# =============================================================================
|
||||
# init-project command
|
||||
# =============================================================================
|
||||
init_project = typer.Typer(help="Create a new project with standard structure.")
|
||||
|
||||
|
||||
@init_project.command("init-project")
|
||||
def cmd_init_project(
|
||||
def init_project(
|
||||
name: str = typer.Argument(..., help="Project name"),
|
||||
type: str = typer.Option("misc", help="Project type (code, homelab, automation, agent, research, misc)"),
|
||||
tags: str = typer.Option("", help="Comma-separated tags"),
|
||||
@@ -86,11 +82,7 @@ def cmd_init_project(
|
||||
# =============================================================================
|
||||
# list command
|
||||
# =============================================================================
|
||||
list_projects = typer.Typer(help="List all projects.")
|
||||
|
||||
|
||||
@list_projects.command("list")
|
||||
def cmd_list_projects() -> None:
|
||||
def list_projects_cmd() -> None:
|
||||
"""Show all projects with their status, last session, and next steps."""
|
||||
projects = list_projects()
|
||||
|
||||
@@ -125,11 +117,7 @@ def cmd_list_projects() -> None:
|
||||
# =============================================================================
|
||||
# show command
|
||||
# =============================================================================
|
||||
show_project = typer.Typer(help="Show project details.")
|
||||
|
||||
|
||||
@show_project.command("show")
|
||||
def cmd_show_project(slug: str = typer.Argument(..., help="Project slug")) -> None:
|
||||
def show_project(slug: str = typer.Argument(..., help="Project slug")) -> None:
|
||||
"""Show detailed project information including status, context, last summary, blockers, and next steps."""
|
||||
# Load project
|
||||
project_dict = storage.read_project_meta(slug)
|
||||
@@ -185,11 +173,7 @@ def cmd_show_project(slug: str = typer.Argument(..., help="Project slug")) -> No
|
||||
# =============================================================================
|
||||
# start command
|
||||
# =============================================================================
|
||||
start_session = typer.Typer(help="Start a work session.")
|
||||
|
||||
|
||||
@start_session.command("start")
|
||||
def cmd_start_session(
|
||||
def start_session(
|
||||
slug: str = typer.Argument(..., help="Project slug"),
|
||||
objective: Optional[str] = typer.Option(None, help="Session objective"),
|
||||
) -> None:
|
||||
@@ -248,11 +232,7 @@ def cmd_start_session(
|
||||
# =============================================================================
|
||||
# note command
|
||||
# =============================================================================
|
||||
add_note_cmd = typer.Typer(help="Add a note to the active session.")
|
||||
|
||||
|
||||
@add_note_cmd.command("note")
|
||||
def cmd_add_note(
|
||||
def add_note_cmd(
|
||||
text: str = typer.Argument(..., help="Note text"),
|
||||
type: str = typer.Option("work", help="Note type (work, change, blocker, decision, idea, reference)"),
|
||||
) -> None:
|
||||
@@ -291,11 +271,7 @@ def cmd_add_note(
|
||||
# =============================================================================
|
||||
# stop command
|
||||
# =============================================================================
|
||||
stop_session = typer.Typer(help="Stop the current session.")
|
||||
|
||||
|
||||
@stop_session.command("stop")
|
||||
def cmd_stop_session(
|
||||
def stop_session(
|
||||
slug: str = typer.Argument(..., help="Project slug"),
|
||||
add_to_changelog: bool = typer.Option(False, "--changelog", help="Add session summary to CHANGELOG.md"),
|
||||
) -> None:
|
||||
@@ -386,11 +362,7 @@ def cmd_stop_session(
|
||||
# =============================================================================
|
||||
# change command
|
||||
# =============================================================================
|
||||
add_change = typer.Typer(help="Add a change entry to CHANGELOG.md.")
|
||||
|
||||
|
||||
@add_change.command("change")
|
||||
def cmd_add_change(
|
||||
def add_change(
|
||||
slug: str = typer.Argument(..., help="Project slug"),
|
||||
type: str = typer.Option("code", help="Change type (code, infra, config, docs, automation, decision)"),
|
||||
title: str = typer.Option(..., help="Change title"),
|
||||
@@ -418,11 +390,7 @@ def cmd_add_change(
|
||||
# =============================================================================
|
||||
# next command
|
||||
# =============================================================================
|
||||
suggest_next = typer.Typer(help="Suggest next steps for a project.")
|
||||
|
||||
|
||||
@suggest_next.command("next")
|
||||
def cmd_suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None:
|
||||
def suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None:
|
||||
"""Suggest next steps based on project history and heuristics.
|
||||
|
||||
Uses simple rules:
|
||||
@@ -487,11 +455,7 @@ def cmd_suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> No
|
||||
# =============================================================================
|
||||
# review command
|
||||
# =============================================================================
|
||||
review = typer.Typer(help="Review all projects.")
|
||||
|
||||
|
||||
@review.command("review")
|
||||
def cmd_review() -> None:
|
||||
def review() -> None:
|
||||
"""Show an overview of all projects.
|
||||
|
||||
Displays:
|
||||
@@ -695,10 +659,233 @@ def _update_readme_autogen(slug: str, session: Session) -> None:
|
||||
storage.update_readme_autogen(slug, "NEXT_STEPS", next_steps_content.strip())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# task add command
|
||||
# =============================================================================
|
||||
def task_add(
|
||||
slug: str = typer.Argument(..., help="Project slug"),
|
||||
title: str = typer.Argument(..., help="Task title"),
|
||||
section: str = typer.Option("Inbox", help="Section name (Inbox, Próximo, En curso, Bloqueado, En espera, Hecho)"),
|
||||
) -> None:
|
||||
"""Add a task to the project's TASKS.md.
|
||||
|
||||
Valid sections: Inbox, Próximo, En curso, Bloqueado, En espera, Hecho.
|
||||
If the section doesn't exist, it will be created.
|
||||
"""
|
||||
# Validate project exists
|
||||
if not storage._project_path(slug).exists():
|
||||
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Valid sections (Spanish names as per spec)
|
||||
valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"}
|
||||
if section not in valid_sections:
|
||||
typer.echo(
|
||||
f"Error: Invalid section '{section}'. Valid sections are: {', '.join(sorted(valid_sections))}",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Read current TASKS.md
|
||||
tasks_content = storage.read_tasks(slug)
|
||||
|
||||
# Parse sections and tasks
|
||||
sections = _parse_tasks_sections(tasks_content)
|
||||
|
||||
# Add task to the specified section
|
||||
new_task = f"- [ ] {title}"
|
||||
if section in sections:
|
||||
sections[section].append(new_task)
|
||||
else:
|
||||
# Create new section with the task
|
||||
sections[section] = [new_task]
|
||||
|
||||
# Rebuild TASKS.md content
|
||||
new_content = _build_tasks_content(sections)
|
||||
storage.write_tasks(slug, new_content)
|
||||
|
||||
typer.echo(f"Added task to '{section}' section: {title}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# task move command
|
||||
# =============================================================================
|
||||
def task_move(
|
||||
slug: str = typer.Argument(..., help="Project slug"),
|
||||
task_title: str = typer.Argument(..., help="Task title to search for"),
|
||||
to_section: str = typer.Argument(..., help="Destination section"),
|
||||
) -> None:
|
||||
"""Move a task to a different section in TASKS.md.
|
||||
|
||||
Searches for the task by title in any section and moves it to the destination.
|
||||
Tasks moved to 'Hecho' will be marked as completed ([x]).
|
||||
"""
|
||||
# Validate project exists
|
||||
if not storage._project_path(slug).exists():
|
||||
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Valid sections
|
||||
valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"}
|
||||
if to_section not in valid_sections:
|
||||
typer.echo(
|
||||
f"Error: Invalid section '{to_section}'. Valid sections are: {', '.join(sorted(valid_sections))}",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Read current TASKS.md
|
||||
tasks_content = storage.read_tasks(slug)
|
||||
|
||||
# Parse sections and tasks
|
||||
sections = _parse_tasks_sections(tasks_content)
|
||||
|
||||
# Find the task
|
||||
found_task = None
|
||||
found_in_section = None
|
||||
task_pattern = f"- [ ] {task_title}"
|
||||
task_pattern_done = f"- [x] {task_title}"
|
||||
|
||||
for section_name, tasks in sections.items():
|
||||
for task in tasks:
|
||||
if task == task_pattern or task == task_pattern_done:
|
||||
found_task = task
|
||||
found_in_section = section_name
|
||||
break
|
||||
if found_task:
|
||||
break
|
||||
|
||||
if not found_task:
|
||||
typer.echo(f"Error: Task '{task_title}' not found in any section.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
if found_in_section == to_section:
|
||||
typer.echo(f"Task '{task_title}' is already in '{to_section}' section.")
|
||||
return
|
||||
|
||||
# Remove from original section
|
||||
sections[found_in_section].remove(found_task)
|
||||
if not sections[found_in_section]:
|
||||
del sections[found_in_section]
|
||||
|
||||
# Determine checkbox state based on destination section
|
||||
if to_section == "Hecho":
|
||||
new_task = f"- [x] {task_title}"
|
||||
else:
|
||||
new_task = f"- [ ] {task_title}"
|
||||
|
||||
# Add to destination section
|
||||
if to_section in sections:
|
||||
sections[to_section].append(new_task)
|
||||
else:
|
||||
sections[to_section] = [new_task]
|
||||
|
||||
# Rebuild TASKS.md content
|
||||
new_content = _build_tasks_content(sections)
|
||||
storage.write_tasks(slug, new_content)
|
||||
|
||||
checkbox = "[x]" if to_section == "Hecho" else "[ ]"
|
||||
typer.echo(f"Moved task '{task_title}' from '{found_in_section}' to '{to_section}' ({checkbox})")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper functions for TASKS.md parsing
|
||||
# =============================================================================
|
||||
def _parse_tasks_sections(content: str) -> dict:
|
||||
"""Parse TASKS.md content into sections and tasks.
|
||||
|
||||
Returns a dict mapping section names to lists of task strings.
|
||||
Normalizes English section names to Spanish and merges duplicate sections.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Mapping from English to Spanish section names
|
||||
section_mapping = {
|
||||
"Next": "Próximo",
|
||||
"In Progress": "En curso",
|
||||
"Blocked": "Bloqueado",
|
||||
"Waiting": "En espera",
|
||||
"Done": "Hecho",
|
||||
"Inbox": "Inbox", # Same in both
|
||||
}
|
||||
|
||||
sections = {}
|
||||
current_section = None
|
||||
current_tasks = []
|
||||
|
||||
# Match section headers (## Section Name)
|
||||
section_pattern = re.compile(r"^##\s+(.+)$")
|
||||
# Match task items (- [ ] task or - [x] task)
|
||||
task_pattern = re.compile(r"^(- \[[ x]\]) (.+)$")
|
||||
|
||||
def save_current_section():
|
||||
"""Save current section tasks, merging if normalized name already exists."""
|
||||
if current_section is not None and current_tasks:
|
||||
if current_section in sections:
|
||||
sections[current_section].extend(current_tasks)
|
||||
else:
|
||||
sections[current_section] = current_tasks
|
||||
|
||||
for line in content.split("\n"):
|
||||
section_match = section_pattern.match(line)
|
||||
if section_match:
|
||||
# Save previous section if exists
|
||||
save_current_section()
|
||||
raw_section = section_match.group(1)
|
||||
# Normalize section name
|
||||
current_section = section_mapping.get(raw_section, raw_section)
|
||||
current_tasks = []
|
||||
else:
|
||||
task_match = task_pattern.match(line)
|
||||
if task_match and current_section is not None:
|
||||
checkbox = task_match.group(1)
|
||||
title = task_match.group(2)
|
||||
current_tasks.append(f"{checkbox} {title}")
|
||||
|
||||
# Save last section
|
||||
save_current_section()
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _build_tasks_content(sections: dict) -> str:
|
||||
"""Build TASKS.md content from sections dict.
|
||||
|
||||
Maintains the order of sections as specified.
|
||||
"""
|
||||
section_order = ["Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"]
|
||||
|
||||
lines = ["# Tasks", ""]
|
||||
|
||||
for section_name in section_order:
|
||||
lines.append(f"## {section_name}")
|
||||
tasks = sections.get(section_name, [])
|
||||
if tasks:
|
||||
for task in tasks:
|
||||
lines.append(task)
|
||||
else:
|
||||
lines.append("-")
|
||||
lines.append("")
|
||||
|
||||
# Add any sections not in the standard order
|
||||
for section_name in sections:
|
||||
if section_name not in section_order:
|
||||
lines.append(f"## {section_name}")
|
||||
tasks = sections[section_name]
|
||||
if tasks:
|
||||
for task in tasks:
|
||||
lines.append(task)
|
||||
else:
|
||||
lines.append("-")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
# Register all commands at module level for direct access
|
||||
__all__ = [
|
||||
"init_project",
|
||||
"list_projects",
|
||||
"list_projects_cmd",
|
||||
"show_project",
|
||||
"start_session",
|
||||
"add_note_cmd",
|
||||
@@ -706,4 +893,6 @@ __all__ = [
|
||||
"add_change",
|
||||
"suggest_next",
|
||||
"review",
|
||||
"task_add",
|
||||
"task_move",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ import typer
|
||||
|
||||
from tracker.cli.commands import (
|
||||
init_project,
|
||||
list_projects,
|
||||
list_projects_cmd,
|
||||
show_project,
|
||||
start_session,
|
||||
add_note_cmd,
|
||||
@@ -12,6 +12,8 @@ from tracker.cli.commands import (
|
||||
add_change,
|
||||
suggest_next,
|
||||
review,
|
||||
task_add,
|
||||
task_move,
|
||||
)
|
||||
|
||||
app = typer.Typer(
|
||||
@@ -19,17 +21,26 @@ app = typer.Typer(
|
||||
help="Personal Project Tracker CLI - Track your projects with Markdown and YAML",
|
||||
)
|
||||
|
||||
# Sub-command group for task management
|
||||
task_app = typer.Typer(help="Task management commands")
|
||||
|
||||
# Register all subcommands
|
||||
app.add_typer_command(init_project, name="init-project")
|
||||
app.add_typer_command(list_projects, name="list")
|
||||
app.add_typer_command(show_project, name="show")
|
||||
app.add_typer_command(start_session, name="start")
|
||||
app.add_typer_command(add_note_cmd, name="note")
|
||||
app.add_typer_command(stop_session, name="stop")
|
||||
app.add_typer_command(add_change, name="change")
|
||||
app.add_typer_command(suggest_next, name="next")
|
||||
app.add_typer_command(review, name="review")
|
||||
|
||||
# Register task subcommands
|
||||
task_app.command("add")(task_add)
|
||||
task_app.command("move")(task_move)
|
||||
|
||||
|
||||
# Register all commands
|
||||
app.command("init-project")(init_project)
|
||||
app.command("list")(list_projects_cmd)
|
||||
app.command("show")(show_project)
|
||||
app.command("start")(start_session)
|
||||
app.command("note")(add_note_cmd)
|
||||
app.command("stop")(stop_session)
|
||||
app.command("change")(add_change)
|
||||
app.command("next")(suggest_next)
|
||||
app.command("review")(review)
|
||||
app.add_typer(task_app, name="task")
|
||||
|
||||
|
||||
@app.callback()
|
||||
|
||||
@@ -5,6 +5,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from ..models import Project
|
||||
|
||||
|
||||
@@ -49,8 +51,8 @@ def create_project(
|
||||
type=type,
|
||||
status="inbox",
|
||||
tags=tags,
|
||||
root_path=_PROJECTS_ROOT / slug,
|
||||
repo_path=repo_path,
|
||||
root_path=str(_PROJECTS_ROOT / slug),
|
||||
repo_path=str(repo_path) if repo_path else None,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
@@ -60,12 +62,20 @@ def create_project(
|
||||
def get_project(slug: str) -> Optional[Project]:
|
||||
"""
|
||||
Get a project by slug.
|
||||
Note: This reads from file system - placeholder for storage integration.
|
||||
Reads from meta/project.yaml in the project directory.
|
||||
"""
|
||||
meta_path = _get_project_meta_path(slug)
|
||||
if not meta_path.exists():
|
||||
return None
|
||||
# TODO: Load from storage (YAML)
|
||||
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
if data:
|
||||
return Project(**data)
|
||||
except (yaml.YAMLError, TypeError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Storage layer for file-based persistence."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -117,22 +118,34 @@ class FileStorage:
|
||||
f.write(new_content)
|
||||
|
||||
def write_session_file(self, session: Session) -> None:
|
||||
"""Crea projects/<slug>/sessions/YYYY-MM-DD_HHMM.md"""
|
||||
"""Crea projects/<slug>/sessions/YYYY-MM-DD_HHMM.md y el JSON correspondiente."""
|
||||
from tracker.storage.markdown_writer import MarkdownWriter
|
||||
|
||||
sessions_path = self._sessions_path(session.project_slug)
|
||||
sessions_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
started = session.started_at
|
||||
filename = started.strftime("%Y-%m-%d_%H%M.md")
|
||||
session_path = sessions_path / filename
|
||||
md_filename = started.strftime("%Y-%m-%d_%H%M.md")
|
||||
json_filename = f"{session.id}.json"
|
||||
|
||||
# Write markdown file
|
||||
writer = MarkdownWriter()
|
||||
content = writer.format_session_file(session)
|
||||
|
||||
with open(session_path, "w", encoding="utf-8") as f:
|
||||
md_path = sessions_path / md_filename
|
||||
with open(md_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
# Write JSON file for tracking
|
||||
json_path = sessions_path / json_filename
|
||||
session_data = session.model_dump(mode="json")
|
||||
# Serialize datetime objects to ISO format
|
||||
if isinstance(session_data.get("started_at"), datetime):
|
||||
session_data["started_at"] = session_data["started_at"].isoformat()
|
||||
if isinstance(session_data.get("ended_at"), datetime):
|
||||
session_data["ended_at"] = session_data["ended_at"].isoformat()
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(session_data, f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
def active_session_path(self) -> Path:
|
||||
"""Returns Path to projects/.active_session.json"""
|
||||
return self.projects_root / ".active_session.json"
|
||||
@@ -156,3 +169,98 @@ class FileStorage:
|
||||
path = self.active_session_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def write_file(self, slug: str, relative_path: str, content: str) -> None:
|
||||
"""Escribe contenido a un archivo en el proyecto.
|
||||
|
||||
Args:
|
||||
slug: Project slug.
|
||||
relative_path: Relative path within the project.
|
||||
content: Content to write.
|
||||
"""
|
||||
file_path = self._project_path(slug) / relative_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
def read_file(self, slug: str, relative_path: str) -> str:
|
||||
"""Lee contenido de un archivo en el proyecto.
|
||||
|
||||
Args:
|
||||
slug: Project slug.
|
||||
relative_path: Relative path within the project.
|
||||
|
||||
Returns:
|
||||
File content or empty string if not found.
|
||||
"""
|
||||
file_path = self._project_path(slug) / relative_path
|
||||
if not file_path.exists():
|
||||
return ""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def extract_autogen_section(self, slug: str, section: str) -> str:
|
||||
"""Extrae contenido de una seccion AUTOGEN del README.md.
|
||||
|
||||
Args:
|
||||
slug: Project slug.
|
||||
section: Section name (e.g., "SESSIONS", "NEXT_STEPS").
|
||||
|
||||
Returns:
|
||||
Content between AUTOGEN markers, or empty string if not found.
|
||||
"""
|
||||
from tracker.storage.markdown_reader import MarkdownReader
|
||||
reader = MarkdownReader()
|
||||
content = self.read_readme(slug)
|
||||
return reader.extract_autogen_section(content, section)
|
||||
|
||||
def get_recent_sessions(self, slug: str, limit: int = 5) -> list:
|
||||
"""Obtiene las sesiones mas recientes de un proyecto.
|
||||
|
||||
Args:
|
||||
slug: Project slug.
|
||||
limit: Maximum number of sessions to return.
|
||||
|
||||
Returns:
|
||||
List of Session objects sorted by date (most recent first).
|
||||
"""
|
||||
from tracker.models.session import Session
|
||||
|
||||
sessions_path = self._sessions_path(slug)
|
||||
if not sessions_path.exists():
|
||||
return []
|
||||
|
||||
sessions = []
|
||||
for json_file in sessions_path.glob("*.json"):
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = Session(**data)
|
||||
sessions.append(session)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
# Sort by started_at descending
|
||||
sessions.sort(key=lambda s: s.started_at, reverse=True)
|
||||
return sessions[:limit]
|
||||
|
||||
def list_projects(self) -> list[str]:
|
||||
"""Lista todos los slugs de proyectos.
|
||||
|
||||
Returns:
|
||||
List of project slugs.
|
||||
"""
|
||||
if not self.projects_root.exists():
|
||||
return []
|
||||
return [d.name for d in self.projects_root.iterdir() if d.is_dir() and not d.name.startswith(".")]
|
||||
|
||||
def project_exists(self, slug: str) -> bool:
|
||||
"""Verifica si un proyecto existe.
|
||||
|
||||
Args:
|
||||
slug: Project slug.
|
||||
|
||||
Returns:
|
||||
True if project exists.
|
||||
"""
|
||||
return self._project_path(slug).exists()
|
||||
|
||||
Reference in New Issue
Block a user