Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3dcdb8577 | |||
| aedc647fff | |||
| bd48122db2 | |||
| 4212a17e4b | |||
| cc523607d1 | |||
| b4593c69af | |||
| 4d99213d75 | |||
| 2735562b65 | |||
| 4e67062c99 | |||
| b36b60353d | |||
| 40a33d773b | |||
| 88a474a78d | |||
| b0c65a00a2 | |||
| 4547c492da |
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
|
||||
0
docs/.gitkeep
Normal file
0
docs/.gitkeep
Normal file
0
examples/.gitkeep
Normal file
0
examples/.gitkeep
Normal file
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
|
||||
0
projects/.gitkeep
Normal file
0
projects/.gitkeep
Normal file
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
pyproject.toml
Normal file
27
pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "tracker"
|
||||
version = "0.1.0"
|
||||
description = "Personal project tracker CLI with Markdown-based persistence"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"typer[all]>=0.12.0",
|
||||
"pyyaml>=6.0",
|
||||
"jinja2>=3.1.0",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
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
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests package."""
|
||||
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 == []
|
||||
1
tracker/__init__.py
Normal file
1
tracker/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Personal Tracker CLI - A Markdown-based project tracking system."""
|
||||
1
tracker/cli/__init__.py
Normal file
1
tracker/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CLI commands for the tracker."""
|
||||
898
tracker/cli/commands.py
Normal file
898
tracker/cli/commands.py
Normal file
@@ -0,0 +1,898 @@
|
||||
"""CLI commands implementation."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from tracker.models import Project, Session, NoteType, Change
|
||||
from tracker.services import (
|
||||
create_project,
|
||||
get_project,
|
||||
update_project,
|
||||
list_projects,
|
||||
ensure_project_structure,
|
||||
get_active_session,
|
||||
set_active_session,
|
||||
clear_active_session,
|
||||
validate_no_other_active_session,
|
||||
add_note,
|
||||
consolidate_notes,
|
||||
suggest_next_steps,
|
||||
generate_summary,
|
||||
)
|
||||
from tracker.storage import FileStorage, MarkdownReader, MarkdownWriter
|
||||
|
||||
|
||||
# Initialize storage and markdown utilities
|
||||
storage = FileStorage(projects_root=Path("projects"))
|
||||
markdown_reader = MarkdownReader()
|
||||
markdown_writer = MarkdownWriter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# init-project command
|
||||
# =============================================================================
|
||||
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"),
|
||||
repo_path: Optional[str] = typer.Option(None, help="Path to git repository"),
|
||||
description: str = typer.Option("", help="Project description"),
|
||||
) -> None:
|
||||
"""Create a new project with standard directory structure and files."""
|
||||
from tracker.utils.slug import generate_slug
|
||||
|
||||
slug = generate_slug(name)
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
# Create project using service
|
||||
project = create_project(
|
||||
name=name,
|
||||
slug=slug,
|
||||
description=description,
|
||||
type=type,
|
||||
tags=tag_list,
|
||||
repo_path=Path(repo_path) if repo_path else None,
|
||||
)
|
||||
|
||||
# Ensure directory structure
|
||||
ensure_project_structure(slug)
|
||||
|
||||
# Generate files from templates
|
||||
readme_content = _generate_readme(project)
|
||||
log_content = "# Log\n\n_Project activity log_\n\n"
|
||||
changelog_content = "# Changelog\n\n_Project changes_\n\n"
|
||||
tasks_content = _generate_tasks_template()
|
||||
meta_content = _generate_meta(project)
|
||||
|
||||
# Write files
|
||||
storage.write_file(slug, "README.md", readme_content)
|
||||
storage.write_file(slug, "LOG.md", log_content)
|
||||
storage.write_file(slug, "CHANGELOG.md", changelog_content)
|
||||
storage.write_file(slug, "TASKS.md", tasks_content)
|
||||
storage.write_file(slug, "meta/project.yaml", meta_content)
|
||||
|
||||
typer.echo(f"Created project '{name}' with slug '{slug}'")
|
||||
typer.echo(f"Location: {storage._project_path(slug)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# list command
|
||||
# =============================================================================
|
||||
def list_projects_cmd() -> None:
|
||||
"""Show all projects with their status, last session, and next steps."""
|
||||
projects = list_projects()
|
||||
|
||||
if not projects:
|
||||
typer.echo("No projects found. Create one with: tracker init-project <name>")
|
||||
return
|
||||
|
||||
typer.echo(f"\n{'Name':<25} {'Slug':<20} {'Status':<10} {'Last Session':<20} {'Next Step'}")
|
||||
typer.echo("-" * 100)
|
||||
|
||||
for project in projects:
|
||||
last_session = "Never"
|
||||
next_step = ""
|
||||
|
||||
# Try to get last session info from LOG.md
|
||||
log_content = storage.read_log(project.slug)
|
||||
if log_content:
|
||||
parsed = markdown_reader.parse_log_entry(log_content)
|
||||
if parsed.get("date_range"):
|
||||
last_session = parsed["date_range"].split("–")[0][:19]
|
||||
if parsed.get("next_steps"):
|
||||
next_step = parsed["next_steps"][0][:40]
|
||||
|
||||
typer.echo(
|
||||
f"{project.name:<25} {project.slug:<20} {project.status:<10} "
|
||||
f"{last_session:<20} {next_step}"
|
||||
)
|
||||
|
||||
typer.echo(f"\nTotal: {len(projects)} project(s)")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# show command
|
||||
# =============================================================================
|
||||
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)
|
||||
if not project_dict:
|
||||
typer.echo(f"Error: Project '{slug}' not found.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
project = Project(**project_dict)
|
||||
|
||||
typer.echo(f"\n{'='*60}")
|
||||
typer.echo(f"Project: {project.name}")
|
||||
typer.echo(f"{'='*60}")
|
||||
|
||||
# Basic info
|
||||
typer.echo(f"\nSlug: {project.slug}")
|
||||
typer.echo(f"Type: {project.type}")
|
||||
typer.echo(f"Status: {project.status}")
|
||||
typer.echo(f"Description: {project.description or 'N/A'}")
|
||||
|
||||
if project.tags:
|
||||
typer.echo(f"Tags: {', '.join(project.tags)}")
|
||||
|
||||
# Read LOG.md for context
|
||||
log_content = storage.read_log(slug)
|
||||
if log_content:
|
||||
parsed = markdown_reader.parse_log_entry(log_content)
|
||||
|
||||
# Last summary
|
||||
if parsed.get("summary"):
|
||||
typer.echo(f"\n--- Last Summary ---")
|
||||
typer.echo(parsed["summary"][:300] + ("..." if len(parsed.get("summary", "")) > 300 else ""))
|
||||
|
||||
# Blockers
|
||||
if parsed.get("blockers"):
|
||||
typer.echo(f"\n--- Blockers ---")
|
||||
for blocker in parsed["blockers"]:
|
||||
typer.echo(f" - {blocker}")
|
||||
|
||||
# Next steps
|
||||
if parsed.get("next_steps"):
|
||||
typer.echo(f"\n--- Next Steps ---")
|
||||
for step in parsed["next_steps"]:
|
||||
typer.echo(f" - {step}")
|
||||
|
||||
# Last activity
|
||||
if parsed.get("date_range"):
|
||||
typer.echo(f"\n--- Last Activity ---")
|
||||
typer.echo(f" {parsed['date_range']}")
|
||||
|
||||
typer.echo(f"\nLocation: {storage._project_path(slug)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# start command
|
||||
# =============================================================================
|
||||
def start_session(
|
||||
slug: str = typer.Argument(..., help="Project slug"),
|
||||
objective: Optional[str] = typer.Option(None, help="Session objective"),
|
||||
) -> None:
|
||||
"""Start a new work session for the project.
|
||||
|
||||
Validates that no other session is active, creates an active session,
|
||||
and shows recent context from the project.
|
||||
"""
|
||||
# Check 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)
|
||||
|
||||
# Validate no other active session
|
||||
if not validate_no_other_active_session(slug):
|
||||
active = get_active_session()
|
||||
typer.echo(
|
||||
f"Error: There is already an active session for project '{active.project_slug}'.",
|
||||
err=True,
|
||||
)
|
||||
typer.echo(f"Stop that session first with: tracker stop {active.project_slug}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Create session
|
||||
session = Session(
|
||||
id=str(uuid.uuid4()),
|
||||
project_slug=slug,
|
||||
started_at=datetime.now(),
|
||||
objective=objective or "",
|
||||
)
|
||||
|
||||
# Save active session
|
||||
set_active_session(session)
|
||||
|
||||
typer.echo(f"Started session for project '{slug}'")
|
||||
if objective:
|
||||
typer.echo(f"Objective: {objective}")
|
||||
|
||||
# Show recent context from LOG.md
|
||||
typer.echo("\n--- Recent Context ---")
|
||||
log_content = storage.read_log(slug)
|
||||
if log_content:
|
||||
parsed = markdown_reader.parse_log_entry(log_content)
|
||||
if parsed.get("work_done"):
|
||||
typer.echo("Recent work:")
|
||||
for item in parsed["work_done"][:3]:
|
||||
typer.echo(f" - {item[:60]}")
|
||||
if parsed.get("next_steps"):
|
||||
typer.echo("Next steps:")
|
||||
for item in parsed["next_steps"][:3]:
|
||||
typer.echo(f" - {item}")
|
||||
else:
|
||||
typer.echo("No previous sessions found.")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# note command
|
||||
# =============================================================================
|
||||
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:
|
||||
"""Add a note to the currently active session.
|
||||
|
||||
Note types:
|
||||
- work: Work performed
|
||||
- change: A change made
|
||||
- blocker: Something blocking progress
|
||||
- decision: A decision made
|
||||
- idea: An idea
|
||||
- reference: A reference or link
|
||||
"""
|
||||
# Get active session
|
||||
session_data = storage.read_active_session()
|
||||
if not session_data:
|
||||
typer.echo("Error: No active session. Start one with: tracker start <slug>", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Reconstruct session
|
||||
session = Session(**session_data)
|
||||
if isinstance(session.started_at, str):
|
||||
session.started_at = datetime.fromisoformat(session.started_at)
|
||||
|
||||
# Add note
|
||||
try:
|
||||
note = add_note(session, type, text)
|
||||
# Update active session file directly
|
||||
storage.write_active_session(session.model_dump(mode="json"))
|
||||
typer.echo(f"Added [{note['type']}] note: {text[:50]}{'...' if len(text) > 50 else ''}")
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# stop command
|
||||
# =============================================================================
|
||||
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:
|
||||
"""Stop the current session and generate summary.
|
||||
|
||||
Calculates duration, consolidates notes, generates summary,
|
||||
suggests next steps, creates session file, updates LOG.md,
|
||||
and optionally updates CHANGELOG.md.
|
||||
"""
|
||||
# Get active session
|
||||
session_data = storage.read_active_session()
|
||||
if not session_data:
|
||||
typer.echo("Error: No active session to stop.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
session = Session(**session_data)
|
||||
if isinstance(session.started_at, str):
|
||||
session.started_at = datetime.fromisoformat(session.started_at)
|
||||
|
||||
# Verify it matches the slug
|
||||
if session.project_slug != slug:
|
||||
typer.echo(
|
||||
f"Error: Active session is for project '{session.project_slug}', not '{slug}'.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# End session
|
||||
session.ended_at = datetime.now()
|
||||
session.duration_minutes = int((session.ended_at - session.started_at).total_seconds() / 60)
|
||||
|
||||
# Consolidate notes
|
||||
consolidated = consolidate_notes(session.raw_notes)
|
||||
session.work_done = consolidated["work_done"]
|
||||
session.changes = consolidated["changes"]
|
||||
session.decisions = consolidated["decisions"]
|
||||
session.blockers = consolidated["blockers"]
|
||||
session.references = consolidated["references"]
|
||||
|
||||
# Generate summary
|
||||
session.summary = generate_summary(session)
|
||||
|
||||
# Get project for heuristics
|
||||
project_dict = storage.read_project_meta(slug)
|
||||
project = Project(**project_dict) if project_dict else None
|
||||
|
||||
# Suggest next steps
|
||||
if project:
|
||||
session.next_steps = suggest_next_steps(session, project)
|
||||
else:
|
||||
session.next_steps = suggest_next_steps(session, session)
|
||||
|
||||
# Save session file
|
||||
storage.write_session_file(session)
|
||||
|
||||
# Format and append to LOG.md
|
||||
log_entry = markdown_writer.format_log_entry(session, session.summary)
|
||||
storage.append_to_log(slug, log_entry)
|
||||
|
||||
# Update README AUTOGEN sections
|
||||
_update_readme_autogen(slug, session)
|
||||
|
||||
# Optionally add to changelog
|
||||
if add_to_changelog and session.changes:
|
||||
change = Change(
|
||||
date=datetime.now().date(),
|
||||
type="code",
|
||||
title=f"Session: {session.objective or 'Work session'}",
|
||||
impact=f"{session.duration_minutes} min - {len(session.work_done)} tasks completed",
|
||||
)
|
||||
changelog_entry = f"\n- **{change.date}** [{change.type}] {change.title}: {change.impact}"
|
||||
storage.append_to_changelog(slug, changelog_entry)
|
||||
|
||||
# Clear active session
|
||||
storage.delete_active_session()
|
||||
|
||||
# Print summary
|
||||
typer.echo(f"\nSession completed for '{slug}'")
|
||||
typer.echo(f"Duration: {session.duration_minutes} minutes")
|
||||
typer.echo(f"\nSummary:\n{session.summary}")
|
||||
|
||||
if session.next_steps:
|
||||
typer.echo("\nSuggested next steps:")
|
||||
for step in session.next_steps:
|
||||
typer.echo(f" - {step}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# change command
|
||||
# =============================================================================
|
||||
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"),
|
||||
impact: str = typer.Option("", help="Impact description"),
|
||||
) -> None:
|
||||
"""Add a manual entry to the project's CHANGELOG.md."""
|
||||
if not storage._project_path(slug).exists():
|
||||
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
change = Change(
|
||||
date=datetime.now().date(),
|
||||
type=type,
|
||||
title=title,
|
||||
impact=impact,
|
||||
)
|
||||
|
||||
entry = f"- **{change.date}** [{change.type}] {change.title}: {change.impact}"
|
||||
storage.append_to_changelog(slug, entry)
|
||||
|
||||
typer.echo(f"Added change to CHANGELOG.md:")
|
||||
typer.echo(f" {entry}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# next command
|
||||
# =============================================================================
|
||||
def suggest_next(slug: str = typer.Argument(..., help="Project slug")) -> None:
|
||||
"""Suggest next steps based on project history and heuristics.
|
||||
|
||||
Uses simple rules:
|
||||
- If there are open blockers, suggest unblocking
|
||||
- If there are unvalidated changes, suggest validation
|
||||
- If work is incomplete, suggest closing open threads
|
||||
- If no progress, suggest redefining objective
|
||||
"""
|
||||
if not storage._project_path(slug).exists():
|
||||
typer.echo(f"Error: Project '{slug}' does not exist.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Get active session if exists
|
||||
session_data = storage.read_active_session()
|
||||
if session_data:
|
||||
session = Session(**session_data)
|
||||
if isinstance(session.started_at, str):
|
||||
session.started_at = datetime.fromisoformat(session.started_at)
|
||||
else:
|
||||
# Create a minimal session from project context
|
||||
session = Session(
|
||||
id="",
|
||||
project_slug=slug,
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
# Load recent work from LOG
|
||||
log_content = storage.read_log(slug)
|
||||
if log_content:
|
||||
parsed = markdown_reader.parse_log_entry(log_content)
|
||||
session.work_done = parsed.get("work_done", [])
|
||||
session.changes = parsed.get("changes", [])
|
||||
session.blockers = parsed.get("blockers", [])
|
||||
session.decisions = parsed.get("decisions", [])
|
||||
|
||||
# Get project
|
||||
project_dict = storage.read_project_meta(slug)
|
||||
project = Project(**project_dict) if project_dict else None
|
||||
|
||||
# Get suggestions
|
||||
suggestions = suggest_next_steps(session, project or Project(
|
||||
id="",
|
||||
name="",
|
||||
slug=slug,
|
||||
description="",
|
||||
type="misc",
|
||||
status="active",
|
||||
tags=[],
|
||||
root_path="",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
))
|
||||
|
||||
if suggestions:
|
||||
typer.echo(f"\nSuggested next steps for '{slug}':")
|
||||
for i, suggestion in enumerate(suggestions, 1):
|
||||
typer.echo(f" {i}. {suggestion}")
|
||||
else:
|
||||
typer.echo(f"\nNo specific suggestions for '{slug}'.")
|
||||
typer.echo("Consider defining new objectives or reviewing the project status.")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# review command
|
||||
# =============================================================================
|
||||
def review() -> None:
|
||||
"""Show an overview of all projects.
|
||||
|
||||
Displays:
|
||||
- Active projects
|
||||
- Recent sessions
|
||||
- Open blockers
|
||||
- Projects without recent activity
|
||||
"""
|
||||
projects = list_projects()
|
||||
|
||||
if not projects:
|
||||
typer.echo("No projects to review.")
|
||||
return
|
||||
|
||||
typer.echo("\n" + "=" * 60)
|
||||
typer.echo("PROJECT REVIEW")
|
||||
typer.echo("=" * 60)
|
||||
|
||||
# Categorize projects
|
||||
active_projects = [p for p in projects if p.status == "active"]
|
||||
blocked_projects = [p for p in projects if p.status == "blocked"]
|
||||
other_projects = [p for p in projects if p.status not in ("active", "blocked")]
|
||||
|
||||
# Active projects
|
||||
if active_projects:
|
||||
typer.echo(f"\n--- Active Projects ({len(active_projects)}) ---")
|
||||
for p in active_projects:
|
||||
typer.echo(f" * {p.name} ({p.slug})")
|
||||
else:
|
||||
typer.echo("\n--- No Active Projects ---")
|
||||
|
||||
# Blocked projects
|
||||
if blocked_projects:
|
||||
typer.echo(f"\n--- Blocked Projects ({len(blocked_projects)}) ---")
|
||||
for p in blocked_projects:
|
||||
typer.echo(f" ! {p.name} ({p.slug})")
|
||||
# Check for blockers in LOG
|
||||
log_content = storage.read_log(p.slug)
|
||||
if log_content:
|
||||
parsed = markdown_reader.parse_log_entry(log_content)
|
||||
for blocker in parsed.get("blockers", []):
|
||||
typer.echo(f" - {blocker}")
|
||||
|
||||
# Recent sessions
|
||||
typer.echo(f"\n--- Recent Sessions ---")
|
||||
has_recent = False
|
||||
for p in projects:
|
||||
sessions = storage.get_recent_sessions(p.slug, limit=1)
|
||||
if sessions:
|
||||
recent = sessions[0]
|
||||
typer.echo(f" {p.name}: {recent.started_at.strftime('%Y-%m-%d %H:%M')} ({recent.duration_minutes} min)")
|
||||
has_recent = True
|
||||
|
||||
if not has_recent:
|
||||
typer.echo(" No recent sessions")
|
||||
|
||||
# Check for stale projects (no activity in 7 days)
|
||||
typer.echo(f"\n--- Projects Without Recent Activity ---")
|
||||
stale_threshold = datetime.now().timestamp() - (7 * 24 * 60 * 60)
|
||||
stale_found = False
|
||||
|
||||
for p in projects:
|
||||
if p.last_session_at:
|
||||
if p.last_session_at.timestamp() < stale_threshold:
|
||||
days = int((datetime.now() - p.last_session_at).days)
|
||||
typer.echo(f" {p.name}: {days} days since last session")
|
||||
stale_found = True
|
||||
else:
|
||||
# Check LOG for any sessions
|
||||
log_content = storage.read_log(p.slug)
|
||||
if not log_content:
|
||||
typer.echo(f" {p.name}: Never had a session")
|
||||
stale_found = True
|
||||
|
||||
if not stale_found:
|
||||
typer.echo(" All projects have recent activity")
|
||||
|
||||
typer.echo("\n" + "=" * 60)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper functions
|
||||
# =============================================================================
|
||||
|
||||
def _generate_readme(project: Project) -> str:
|
||||
"""Generate README.md content for a new project."""
|
||||
return f"""# {project.name}
|
||||
|
||||
{project.description or '_No description_'}
|
||||
|
||||
## Objective
|
||||
|
||||
_TODO: Define objective_
|
||||
|
||||
## Status
|
||||
|
||||
**Current Status:** {project.status}
|
||||
|
||||
<!-- AUTOGEN:STATUS_START -->
|
||||
Status: {project.status}
|
||||
<!-- 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 -->
|
||||
<!-- AUTOGEN:NEXT_STEPS_END -->
|
||||
|
||||
## Recent Sessions
|
||||
|
||||
<!-- AUTOGEN:SESSIONS_START -->
|
||||
<!-- AUTOGEN:SESSIONS_END -->
|
||||
|
||||
_Last updated: {datetime.now().strftime('%Y-%m-%d')}_
|
||||
"""
|
||||
|
||||
|
||||
def _generate_tasks_template() -> str:
|
||||
"""Generate TASKS.md content for a new project."""
|
||||
return """# Tasks
|
||||
|
||||
## Inbox
|
||||
|
||||
-
|
||||
|
||||
## Next
|
||||
|
||||
-
|
||||
|
||||
## In Progress
|
||||
|
||||
-
|
||||
|
||||
## Blocked
|
||||
|
||||
-
|
||||
|
||||
## Waiting
|
||||
|
||||
-
|
||||
|
||||
## Done
|
||||
|
||||
-
|
||||
"""
|
||||
|
||||
|
||||
def _generate_meta(project: Project) -> str:
|
||||
"""Generate meta/project.yaml content."""
|
||||
import yaml
|
||||
data = {
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"slug": project.slug,
|
||||
"description": project.description,
|
||||
"type": project.type,
|
||||
"status": project.status,
|
||||
"tags": project.tags,
|
||||
"root_path": str(project.root_path),
|
||||
"repo_path": str(project.repo_path) if project.repo_path else None,
|
||||
"created_at": project.created_at.isoformat(),
|
||||
"updated_at": project.updated_at.isoformat(),
|
||||
"last_session_at": None,
|
||||
}
|
||||
return yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
def _update_readme_autogen(slug: str, session: Session) -> None:
|
||||
"""Update README.md AUTOGEN sections with session info."""
|
||||
# Update sessions section
|
||||
session_line = f"- {session.started_at.strftime('%Y-%m-%d %H:%M')} ({session.duration_minutes} min): {session.summary[:50]}..."
|
||||
sessions_content = storage.extract_autogen_section(slug, "SESSIONS") or ""
|
||||
sessions_content = sessions_content + f"\n{session_line}" if sessions_content else session_line
|
||||
storage.update_readme_autogen(slug, "SESSIONS", sessions_content.strip())
|
||||
|
||||
# Update next steps section
|
||||
if session.next_steps:
|
||||
next_steps_content = "\n".join([f"- {step}" for step in session.next_steps])
|
||||
existing_next = storage.extract_autogen_section(slug, "NEXT_STEPS") or ""
|
||||
if existing_next:
|
||||
# Keep existing and add new
|
||||
next_steps_content = existing_next + "\n" + next_steps_content
|
||||
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_cmd",
|
||||
"show_project",
|
||||
"start_session",
|
||||
"add_note_cmd",
|
||||
"stop_session",
|
||||
"add_change",
|
||||
"suggest_next",
|
||||
"review",
|
||||
"task_add",
|
||||
"task_move",
|
||||
]
|
||||
53
tracker/cli/main.py
Normal file
53
tracker/cli/main.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Main CLI entry point."""
|
||||
|
||||
import typer
|
||||
|
||||
from tracker.cli.commands import (
|
||||
init_project,
|
||||
list_projects_cmd,
|
||||
show_project,
|
||||
start_session,
|
||||
add_note_cmd,
|
||||
stop_session,
|
||||
add_change,
|
||||
suggest_next,
|
||||
review,
|
||||
task_add,
|
||||
task_move,
|
||||
)
|
||||
|
||||
app = typer.Typer(
|
||||
name="tracker",
|
||||
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 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()
|
||||
def callback():
|
||||
"""Personal Project Tracker - Track your projects locally with Markdown."""
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
7
tracker/models/__init__.py
Normal file
7
tracker/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Data models for the tracker."""
|
||||
from .project import Project
|
||||
from .session import Session
|
||||
from .note import Note, NoteType
|
||||
from .change import Change
|
||||
|
||||
__all__ = ["Project", "Session", "Note", "NoteType", "Change"]
|
||||
13
tracker/models/change.py
Normal file
13
tracker/models/change.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Change model definition."""
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import date
|
||||
|
||||
|
||||
class Change(BaseModel):
|
||||
"""Represents a notable change in a project."""
|
||||
|
||||
date: date
|
||||
type: str # code, infra, config, docs, automation, decision
|
||||
title: str
|
||||
impact: str = ""
|
||||
references: list[str] = Field(default_factory=list)
|
||||
22
tracker/models/note.py
Normal file
22
tracker/models/note.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Note model definition."""
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class NoteType(Enum):
|
||||
"""Types of notes that can be recorded during a session."""
|
||||
WORK = "work"
|
||||
CHANGE = "change"
|
||||
BLOCKER = "blocker"
|
||||
DECISION = "decision"
|
||||
IDEA = "idea"
|
||||
REFERENCE = "reference"
|
||||
|
||||
|
||||
class Note(BaseModel):
|
||||
"""Represents a note recorded during a session."""
|
||||
|
||||
type: NoteType
|
||||
text: str
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
21
tracker/models/project.py
Normal file
21
tracker/models/project.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Project model definition."""
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
"""Represents a tracked project."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
description: str = ""
|
||||
type: str = "misc" # code, homelab, automation, agent, research, misc
|
||||
status: str = "inbox" # inbox, next, active, blocked, waiting, done, archived
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
root_path: str = ""
|
||||
repo_path: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_session_at: Optional[datetime] = None
|
||||
23
tracker/models/session.py
Normal file
23
tracker/models/session.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Session model definition."""
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
"""Represents a work session on a project."""
|
||||
|
||||
id: str
|
||||
project_slug: str
|
||||
started_at: datetime
|
||||
ended_at: Optional[datetime] = None
|
||||
duration_minutes: Optional[int] = None
|
||||
objective: str = ""
|
||||
summary: str = ""
|
||||
work_done: list[str] = Field(default_factory=list)
|
||||
changes: list[str] = Field(default_factory=list)
|
||||
decisions: list[str] = Field(default_factory=list)
|
||||
blockers: list[str] = Field(default_factory=list)
|
||||
next_steps: list[str] = Field(default_factory=list)
|
||||
references: list[str] = Field(default_factory=list)
|
||||
raw_notes: list[dict] = Field(default_factory=list) # [{"type": "work", "text": "...", "timestamp": "..."}]
|
||||
45
tracker/services/__init__.py
Normal file
45
tracker/services/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Services layer for business logic."""
|
||||
|
||||
from .session_service import (
|
||||
get_active_session,
|
||||
set_active_session,
|
||||
clear_active_session,
|
||||
get_active_session_path,
|
||||
validate_no_other_active_session,
|
||||
)
|
||||
from .project_service import (
|
||||
create_project,
|
||||
get_project,
|
||||
update_project,
|
||||
list_projects,
|
||||
get_projects_root,
|
||||
ensure_project_structure,
|
||||
)
|
||||
from .note_service import (
|
||||
add_note,
|
||||
consolidate_notes,
|
||||
)
|
||||
from .heuristics_service import (
|
||||
suggest_next_steps,
|
||||
)
|
||||
from .summary_service import (
|
||||
generate_summary,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_active_session",
|
||||
"set_active_session",
|
||||
"clear_active_session",
|
||||
"get_active_session_path",
|
||||
"validate_no_other_active_session",
|
||||
"create_project",
|
||||
"get_project",
|
||||
"update_project",
|
||||
"list_projects",
|
||||
"get_projects_root",
|
||||
"ensure_project_structure",
|
||||
"add_note",
|
||||
"consolidate_notes",
|
||||
"suggest_next_steps",
|
||||
"generate_summary",
|
||||
]
|
||||
47
tracker/services/heuristics_service.py
Normal file
47
tracker/services/heuristics_service.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Heuristics service for suggestions based on rules."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import Session, Project
|
||||
|
||||
|
||||
def suggest_next_steps(session: Session, project: Project) -> list[str]:
|
||||
"""
|
||||
Generate suggestions based on session state and project context.
|
||||
Rules:
|
||||
- si hay blockers abiertos, sugerir "Destrabar: [bloqueos]"
|
||||
- si hay changes sin references, sugerir "Validar cambios recientes"
|
||||
- si work_done está vacío y session > 30 min, sugerir "Revisar progreso del objetivo"
|
||||
- si no hay next_steps definidos, sugerir "Definir próximos pasos"
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
# Rule: blockers open
|
||||
if session.blockers:
|
||||
for blocker in session.blockers:
|
||||
suggestions.append(f"Destrabar: {blocker}")
|
||||
|
||||
# Rule: changes without references
|
||||
changes_without_refs = []
|
||||
for change in session.changes:
|
||||
# Simple heuristic: if change doesn't reference anything specific
|
||||
if change and not any(ref in change.lower() for ref in ["#", "commit", "pr", "issue"]):
|
||||
changes_without_refs.append(change)
|
||||
|
||||
if changes_without_refs:
|
||||
suggestions.append("Validar cambios recientes")
|
||||
|
||||
# Rule: work_done empty and session > 30 minutes
|
||||
if not session.work_done:
|
||||
duration = session.duration_minutes
|
||||
if duration == 0 and session.ended_at and session.started_at:
|
||||
duration = int((session.ended_at - session.started_at).total_seconds() / 60)
|
||||
|
||||
if duration > 30:
|
||||
suggestions.append("Revisar progreso del objetivo")
|
||||
|
||||
# Rule: no next_steps defined
|
||||
if not session.next_steps:
|
||||
suggestions.append("Definir próximos pasos")
|
||||
|
||||
return suggestions
|
||||
65
tracker/services/note_service.py
Normal file
65
tracker/services/note_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Note service for note management."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import Session, NoteType, Note
|
||||
|
||||
|
||||
def add_note(session: Session, note_type: str, text: str) -> dict:
|
||||
"""
|
||||
Add a note to the session and return the note dict.
|
||||
Valid note types: work, change, blocker, decision, idea, reference
|
||||
"""
|
||||
try:
|
||||
note_type_enum = NoteType(note_type)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid note type: {note_type}. Valid types are: {[t.value for t in NoteType]}")
|
||||
|
||||
note = Note(type=note_type_enum, text=text)
|
||||
session.raw_notes.append(note.model_dump(mode="json"))
|
||||
|
||||
return {
|
||||
"type": note.type.value,
|
||||
"text": note.text,
|
||||
"created_at": note.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def consolidate_notes(raw_notes: list[dict]) -> dict:
|
||||
"""
|
||||
Consolidate raw notes into categorized sections.
|
||||
Returns dict with keys: work_done, changes, decisions, blockers, references
|
||||
"""
|
||||
result = {
|
||||
"work_done": [],
|
||||
"changes": [],
|
||||
"decisions": [],
|
||||
"blockers": [],
|
||||
"references": [],
|
||||
}
|
||||
|
||||
for note in raw_notes:
|
||||
if isinstance(note, dict):
|
||||
note_type = note.get("type", "")
|
||||
text = note.get("text", "")
|
||||
else:
|
||||
# Handle string format like "[type] text"
|
||||
parts = note.split("]", 1)
|
||||
if len(parts) == 2:
|
||||
note_type = parts[0][1:]
|
||||
text = parts[1].strip()
|
||||
else:
|
||||
continue
|
||||
|
||||
if note_type == NoteType.WORK.value:
|
||||
result["work_done"].append(text)
|
||||
elif note_type == NoteType.CHANGE.value:
|
||||
result["changes"].append(text)
|
||||
elif note_type == NoteType.DECISION.value:
|
||||
result["decisions"].append(text)
|
||||
elif note_type == NoteType.BLOCKER.value:
|
||||
result["blockers"].append(text)
|
||||
elif note_type == NoteType.REFERENCE.value:
|
||||
result["references"].append(text)
|
||||
|
||||
return result
|
||||
128
tracker/services/project_service.py
Normal file
128
tracker/services/project_service.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Project service for project management."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from ..models import Project
|
||||
|
||||
|
||||
_PROJECTS_ROOT = Path("projects")
|
||||
|
||||
|
||||
def get_projects_root() -> Path:
|
||||
"""Return the root directory for all projects."""
|
||||
return _PROJECTS_ROOT
|
||||
|
||||
|
||||
def _get_project_meta_path(slug: str) -> Path:
|
||||
"""Return the path to the project's meta/project.yaml file."""
|
||||
return _PROJECTS_ROOT / slug / "meta" / "project.yaml"
|
||||
|
||||
|
||||
def _get_project_readme_path(slug: str) -> Path:
|
||||
"""Return the path to the project's README.md file."""
|
||||
return _PROJECTS_ROOT / slug / "README.md"
|
||||
|
||||
|
||||
def create_project(
|
||||
name: str,
|
||||
slug: str,
|
||||
description: str = "",
|
||||
type: str = "misc",
|
||||
tags: Optional[list[str]] = None,
|
||||
repo_path: Optional[Path] = None,
|
||||
) -> Project:
|
||||
"""
|
||||
Create a new project and return the Project instance.
|
||||
Note: This does not write any files - that is handled by storage.
|
||||
"""
|
||||
if tags is None:
|
||||
tags = []
|
||||
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
slug=slug,
|
||||
description=description,
|
||||
type=type,
|
||||
status="inbox",
|
||||
tags=tags,
|
||||
root_path=str(_PROJECTS_ROOT / slug),
|
||||
repo_path=str(repo_path) if repo_path else None,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
def get_project(slug: str) -> Optional[Project]:
|
||||
"""
|
||||
Get a project by slug.
|
||||
Reads from meta/project.yaml in the project directory.
|
||||
"""
|
||||
meta_path = _get_project_meta_path(slug)
|
||||
if not meta_path.exists():
|
||||
return None
|
||||
|
||||
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
|
||||
|
||||
|
||||
def update_project(slug: str, **kwargs) -> Optional[Project]:
|
||||
"""
|
||||
Update a project's attributes.
|
||||
Note: This does not persist - that is handled by storage.
|
||||
"""
|
||||
project = get_project(slug)
|
||||
if project is None:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(project, key):
|
||||
setattr(project, key, value)
|
||||
|
||||
project.updated_at = datetime.now()
|
||||
return project
|
||||
|
||||
|
||||
def list_projects() -> list[Project]:
|
||||
"""
|
||||
List all projects.
|
||||
Note: This reads from file system - placeholder for storage integration.
|
||||
"""
|
||||
projects_root = get_projects_root()
|
||||
if not projects_root.exists():
|
||||
return []
|
||||
|
||||
projects = []
|
||||
for item in projects_root.iterdir():
|
||||
if item.is_dir() and not item.name.startswith("."):
|
||||
project = get_project(item.name)
|
||||
if project is not None:
|
||||
projects.append(project)
|
||||
|
||||
return projects
|
||||
|
||||
|
||||
def ensure_project_structure(slug: str) -> None:
|
||||
"""
|
||||
Ensure the project directory structure exists.
|
||||
Creates: sessions/, docs/, assets/, meta/
|
||||
Note: This creates directories only - actual file writing is storage's job.
|
||||
"""
|
||||
project_root = _PROJECTS_ROOT / slug
|
||||
directories = ["sessions", "docs", "assets", "meta"]
|
||||
|
||||
for directory in directories:
|
||||
(project_root / directory).mkdir(parents=True, exist_ok=True)
|
||||
67
tracker/services/session_service.py
Normal file
67
tracker/services/session_service.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Session service for active session management."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..models import Session
|
||||
|
||||
|
||||
_ACTIVE_SESSION_FILE = ".active_session.json"
|
||||
|
||||
|
||||
def get_active_session_path() -> Path:
|
||||
"""Return the path to the active session file in projects/ directory."""
|
||||
return Path("projects") / _ACTIVE_SESSION_FILE
|
||||
|
||||
|
||||
def get_active_session() -> Optional[Session]:
|
||||
"""Load and return the currently active session, or None if none exists."""
|
||||
path = get_active_session_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Convert started_at string back to datetime
|
||||
data["started_at"] = datetime.fromisoformat(data["started_at"])
|
||||
if data.get("ended_at"):
|
||||
data["ended_at"] = datetime.fromisoformat(data["ended_at"])
|
||||
|
||||
return Session(**data)
|
||||
|
||||
|
||||
def set_active_session(session: Session) -> None:
|
||||
"""Save the given session as the active session."""
|
||||
path = get_active_session_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = session.model_dump(mode="json")
|
||||
# Serialize datetime objects to ISO format
|
||||
data["started_at"] = session.started_at.isoformat()
|
||||
if session.ended_at:
|
||||
data["ended_at"] = session.ended_at.isoformat()
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def clear_active_session() -> None:
|
||||
"""Remove the active session file."""
|
||||
path = get_active_session_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def validate_no_other_active_session(project_slug: str) -> bool:
|
||||
"""
|
||||
Check if there is an active session for a different project.
|
||||
Returns True if no conflict exists (i.e., either no active session
|
||||
or the active session belongs to the same project).
|
||||
"""
|
||||
active = get_active_session()
|
||||
if active is None:
|
||||
return True
|
||||
return active.project_slug == project_slug
|
||||
42
tracker/services/summary_service.py
Normal file
42
tracker/services/summary_service.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Summary service for heuristic summary generation."""
|
||||
|
||||
from ..models import Session
|
||||
from .note_service import consolidate_notes
|
||||
|
||||
|
||||
def generate_summary(session: Session) -> str:
|
||||
"""
|
||||
Generate a heuristic summary from the session.
|
||||
Uses consolidate_notes to extract work_done, decisions, blockers.
|
||||
"""
|
||||
# Consolidate raw notes into categorized sections
|
||||
consolidated = consolidate_notes(session.raw_notes)
|
||||
|
||||
lines = []
|
||||
|
||||
# Work done section
|
||||
if consolidated["work_done"]:
|
||||
lines.append("Trabajo realizado:")
|
||||
for item in consolidated["work_done"]:
|
||||
lines.append(f" - {item}")
|
||||
lines.append("")
|
||||
|
||||
# Decisions section
|
||||
if consolidated["decisions"]:
|
||||
lines.append("Decisiones:")
|
||||
for item in consolidated["decisions"]:
|
||||
lines.append(f" - {item}")
|
||||
lines.append("")
|
||||
|
||||
# Blockers section
|
||||
if consolidated["blockers"]:
|
||||
lines.append("Bloqueos:")
|
||||
for item in consolidated["blockers"]:
|
||||
lines.append(f" - {item}")
|
||||
lines.append("")
|
||||
|
||||
# If no content, provide a minimal summary
|
||||
if not lines:
|
||||
return f"Session de {session.duration_minutes} minutos sin progreso registrado."
|
||||
|
||||
return "\n".join(lines)
|
||||
5
tracker/storage/__init__.py
Normal file
5
tracker/storage/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from tracker.storage.file_storage import FileStorage
|
||||
from tracker.storage.markdown_reader import MarkdownReader
|
||||
from tracker.storage.markdown_writer import MarkdownWriter
|
||||
|
||||
__all__ = ["FileStorage", "MarkdownReader", "MarkdownWriter"]
|
||||
266
tracker/storage/file_storage.py
Normal file
266
tracker/storage/file_storage.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Storage layer for file-based persistence."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from tracker.models.session import Session
|
||||
|
||||
|
||||
class FileStorage:
|
||||
"""Maneja lectura/escritura de archivos del proyecto."""
|
||||
|
||||
def __init__(self, projects_root: Path):
|
||||
self.projects_root = projects_root
|
||||
|
||||
def _project_path(self, slug: str) -> Path:
|
||||
return self.projects_root / slug
|
||||
|
||||
def _meta_path(self, slug: str) -> Path:
|
||||
return self._project_path(slug) / "meta" / "project.yaml"
|
||||
|
||||
def _log_path(self, slug: str) -> Path:
|
||||
return self._project_path(slug) / "LOG.md"
|
||||
|
||||
def _changelog_path(self, slug: str) -> Path:
|
||||
return self._project_path(slug) / "CHANGELOG.md"
|
||||
|
||||
def _tasks_path(self, slug: str) -> Path:
|
||||
return self._project_path(slug) / "TASKS.md"
|
||||
|
||||
def _readme_path(self, slug: str) -> Path:
|
||||
return self._project_path(slug) / "README.md"
|
||||
|
||||
def _sessions_path(self, slug: str) -> Path:
|
||||
return self._project_path(slug) / "sessions"
|
||||
|
||||
def read_project_meta(self, slug: str) -> dict:
|
||||
"""Lee projects/<slug>/meta/project.yaml"""
|
||||
meta_path = self._meta_path(slug)
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def write_project_meta(self, slug: str, data: dict) -> None:
|
||||
"""Escribe projects/<slug>/meta/project.yaml"""
|
||||
meta_path = self._meta_path(slug)
|
||||
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
def read_log(self, slug: str) -> str:
|
||||
"""Lee projects/<slug>/LOG.md"""
|
||||
log_path = self._log_path(slug)
|
||||
if not log_path.exists():
|
||||
return ""
|
||||
with open(log_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def append_to_log(self, slug: str, entry: str) -> None:
|
||||
"""Append a LOG.md entry."""
|
||||
log_path = self._log_path(slug)
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(entry)
|
||||
|
||||
def read_changelog(self, slug: str) -> str:
|
||||
"""Lee projects/<slug>/CHANGELOG.md"""
|
||||
changelog_path = self._changelog_path(slug)
|
||||
if not changelog_path.exists():
|
||||
return ""
|
||||
with open(changelog_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def append_to_changelog(self, slug: str, change: str) -> None:
|
||||
"""Append a CHANGELOG.md entry."""
|
||||
changelog_path = self._changelog_path(slug)
|
||||
with open(changelog_path, "a", encoding="utf-8") as f:
|
||||
f.write(change)
|
||||
|
||||
def read_tasks(self, slug: str) -> str:
|
||||
"""Lee projects/<slug>/TASKS.md"""
|
||||
tasks_path = self._tasks_path(slug)
|
||||
if not tasks_path.exists():
|
||||
return ""
|
||||
with open(tasks_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def write_tasks(self, slug: str, tasks_content: str) -> None:
|
||||
"""Escribe projects/<slug>/TASKS.md"""
|
||||
tasks_path = self._tasks_path(slug)
|
||||
with open(tasks_path, "w", encoding="utf-8") as f:
|
||||
f.write(tasks_content)
|
||||
|
||||
def read_readme(self, slug: str) -> str:
|
||||
"""Lee projects/<slug>/README.md"""
|
||||
readme_path = self._readme_path(slug)
|
||||
if not readme_path.exists():
|
||||
return ""
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def update_readme_autogen(self, slug: str, section: str, content: str) -> None:
|
||||
"""Actualiza una seccion autogenerada en README.md.
|
||||
|
||||
Busca <!-- AUTOGEN:{section}_START --> ... <!-- AUTOGEN:{section}_END -->
|
||||
y reemplaza el contenido entre esos marcadores.
|
||||
"""
|
||||
from tracker.storage.markdown_writer import MarkdownWriter
|
||||
|
||||
readme_path = self._readme_path(slug)
|
||||
current_content = self.read_readme(slug)
|
||||
|
||||
writer = MarkdownWriter()
|
||||
new_content = writer.format_autogen_section(current_content, section, content)
|
||||
|
||||
with open(readme_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
def write_session_file(self, session: Session) -> None:
|
||||
"""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
|
||||
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)
|
||||
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"
|
||||
|
||||
def read_active_session(self) -> Optional[dict]:
|
||||
"""Lee la sesion activa desde .active_session.json"""
|
||||
path = self.active_session_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def write_active_session(self, session_data: dict) -> None:
|
||||
"""Escribe la sesion activa a .active_session.json"""
|
||||
path = self.active_session_path()
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(session_data, f, indent=2, default=str)
|
||||
|
||||
def delete_active_session(self) -> None:
|
||||
"""Elimina .active_session.json"""
|
||||
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()
|
||||
138
tracker/storage/markdown_reader.py
Normal file
138
tracker/storage/markdown_reader.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Markdown reader utility."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class MarkdownReader:
|
||||
"""Lectura de archivos Markdown del proyecto."""
|
||||
|
||||
def parse_log_entry(self, content: str) -> dict:
|
||||
"""Parse una entrada de LOG.md.
|
||||
|
||||
Formato esperado:
|
||||
## 2026-03-23 10:00–11:20
|
||||
**Objetivo**
|
||||
...
|
||||
**Trabajo realizado**
|
||||
- ...
|
||||
**Cambios relevantes**
|
||||
- ...
|
||||
**Bloqueos**
|
||||
- ...
|
||||
**Decisiones**
|
||||
- ...
|
||||
**Próximos pasos**
|
||||
- ...
|
||||
**Resumen**
|
||||
...
|
||||
|
||||
Returns dict con:
|
||||
- date_range: str
|
||||
- objective: str
|
||||
- work_done: list[str]
|
||||
- changes: list[str]
|
||||
- blockers: list[str]
|
||||
- decisions: list[str]
|
||||
- next_steps: list[str]
|
||||
- summary: str
|
||||
"""
|
||||
result = {
|
||||
"date_range": "",
|
||||
"objective": "",
|
||||
"work_done": [],
|
||||
"changes": [],
|
||||
"blockers": [],
|
||||
"decisions": [],
|
||||
"next_steps": [],
|
||||
"summary": "",
|
||||
}
|
||||
|
||||
# Extraer fecha/rango
|
||||
date_match = re.search(r"##\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}[–-]\d{2}:\d{2})", content)
|
||||
if date_match:
|
||||
result["date_range"] = date_match.group(1)
|
||||
|
||||
# Extraer secciones
|
||||
sections = {
|
||||
"objective": r"\*\*Objetivo\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"work_done": r"\*\*Trabajo realizado\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"changes": r"\*\*Cambios relevantes\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"blockers": r"\*\*Bloqueos\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"decisions": r"\*\*Decisiones\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"next_steps": r"\*\*Próximos pasos\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"summary": r"\*\*Resumen\*\*\s*\n(.*?)(?=\n##|\Z)",
|
||||
}
|
||||
|
||||
for key, pattern in sections.items():
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
text = match.group(1).strip()
|
||||
if key in ("work_done", "changes", "blockers", "decisions", "next_steps"):
|
||||
# Extraer listas con bullet points
|
||||
items = re.findall(r"^\s*-\s+(.+)$", text, re.MULTILINE)
|
||||
result[key] = items
|
||||
else:
|
||||
result[key] = text
|
||||
|
||||
return result
|
||||
|
||||
def extract_autogen_section(self, content: str, section: str) -> str:
|
||||
"""Extrae contenido de una seccion AUTOGEN.
|
||||
|
||||
Busca <!-- AUTOGEN:{section}_START --> ... <!-- AUTOGEN:{section}_END -->
|
||||
Returns el contenido entre esos marcadores, o string vacio si no existe.
|
||||
"""
|
||||
pattern = rf"<!--\s*AUTOGEN:{section}_START\s*-->(.*?)<!--\s*AUTOGEN:{section}_END\s*-->"
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return ""
|
||||
|
||||
def parse_tasks(self, content: str) -> dict:
|
||||
"""Parse TASKS.md por secciones.
|
||||
|
||||
Secciones esperadas:
|
||||
- Inbox
|
||||
- Próximo
|
||||
- En curso
|
||||
- Bloqueado
|
||||
- En espera
|
||||
- Hecho
|
||||
|
||||
Returns dict con nombre de seccion -> lista de tareas
|
||||
"""
|
||||
result = {}
|
||||
current_section = None
|
||||
current_tasks = []
|
||||
|
||||
lines = content.split("\n")
|
||||
|
||||
for line in lines:
|
||||
# Detectar headers de seccion (## )
|
||||
section_match = re.match(r"^##\s+(.+)$", line)
|
||||
if section_match:
|
||||
# Guardar seccion anterior
|
||||
if current_section is not None:
|
||||
result[current_section] = current_tasks
|
||||
|
||||
current_section = section_match.group(1).strip()
|
||||
current_tasks = []
|
||||
elif current_section is not None:
|
||||
# Parsear bullet points
|
||||
task_match = re.match(r"^\s*-\s+\[([ x])\]\s*(.+)$", line)
|
||||
if task_match:
|
||||
checked = task_match.group(1) == "x"
|
||||
task_text = task_match.group(2).strip()
|
||||
current_tasks.append({"text": task_text, "done": checked})
|
||||
elif line.strip():
|
||||
# Lineas que no son bullet ni header, agregar a la ultima tarea
|
||||
if current_tasks:
|
||||
current_tasks[-1]["text"] += " " + line.strip()
|
||||
|
||||
# Guardar ultima seccion
|
||||
if current_section is not None:
|
||||
result[current_section] = current_tasks
|
||||
|
||||
return result
|
||||
241
tracker/storage/markdown_writer.py
Normal file
241
tracker/storage/markdown_writer.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Markdown writer utility."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from tracker.models.session import Session
|
||||
|
||||
|
||||
class MarkdownWriter:
|
||||
"""Escritura de archivos Markdown del proyecto."""
|
||||
|
||||
def format_log_entry(self, session: Session, summary: str) -> str:
|
||||
"""Formatea una entrada para LOG.md.
|
||||
|
||||
Formato:
|
||||
## 2026-03-23 10:00–11:20
|
||||
**Objetivo**
|
||||
...
|
||||
|
||||
**Trabajo realizado**
|
||||
- ...
|
||||
|
||||
**Cambios relevantes**
|
||||
- ...
|
||||
|
||||
**Bloqueos**
|
||||
- ...
|
||||
|
||||
**Decisiones**
|
||||
- ...
|
||||
|
||||
**Próximos pasos**
|
||||
- ...
|
||||
|
||||
**Resumen**
|
||||
...
|
||||
|
||||
Returns string formateado.
|
||||
"""
|
||||
started = session.started_at.strftime("%Y-%m-%d %H:%M")
|
||||
ended = session.ended_at.strftime("%H:%M") if session.ended_at else "En Curso"
|
||||
date_range = f"{started}–{ended}"
|
||||
|
||||
lines = [
|
||||
f"## {date_range}",
|
||||
"",
|
||||
"**Objetivo**",
|
||||
f"{session.objective or 'No especificado'}",
|
||||
"",
|
||||
"**Trabajo realizado**",
|
||||
]
|
||||
|
||||
if session.work_done:
|
||||
for item in session.work_done:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin trabajo registrado")
|
||||
|
||||
lines.extend(["", "**Cambios relevantes**"])
|
||||
if session.changes:
|
||||
for item in session.changes:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin cambios")
|
||||
|
||||
lines.extend(["", "**Bloqueos**"])
|
||||
if session.blockers:
|
||||
for item in session.blockers:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin bloqueos")
|
||||
|
||||
lines.extend(["", "**Decisiones**"])
|
||||
if session.decisions:
|
||||
for item in session.decisions:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin decisiones")
|
||||
|
||||
lines.extend(["", "**Próximos pasos**"])
|
||||
if session.next_steps:
|
||||
for item in session.next_steps:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin pasos definidos")
|
||||
|
||||
lines.extend(["", "**Resumen**", summary])
|
||||
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
def format_session_file(self, session: Session) -> str:
|
||||
"""Formatea archivo de sesion detalle en sessions/YYYY-MM-DD_HHMM.md.
|
||||
|
||||
Formato:
|
||||
# Sesion: 2026-03-23 10:00–11:20
|
||||
|
||||
## Objetivo
|
||||
...
|
||||
|
||||
## Notas
|
||||
...
|
||||
|
||||
## Trabajo realizado
|
||||
...
|
||||
|
||||
## Cambios
|
||||
...
|
||||
|
||||
## Decisiones
|
||||
...
|
||||
|
||||
## Bloqueos
|
||||
...
|
||||
|
||||
## Proximos pasos
|
||||
...
|
||||
|
||||
## Referencias
|
||||
...
|
||||
|
||||
## Duracion
|
||||
X minutos
|
||||
"""
|
||||
started = session.started_at.strftime("%Y-%m-%d %H:%M")
|
||||
ended = session.ended_at.strftime("%H:%M") if session.ended_at else "En Curso"
|
||||
|
||||
lines = [
|
||||
f"# Sesion: {started}–{ended}",
|
||||
"",
|
||||
"## Objetivo",
|
||||
f"{session.objective or 'No especificado'}",
|
||||
"",
|
||||
"## Notas",
|
||||
]
|
||||
|
||||
if session.raw_notes:
|
||||
for note in session.raw_notes:
|
||||
note_type = note.get("type", "work")
|
||||
note_text = note.get("text", "")
|
||||
lines.append(f"- [{note_type}] {note_text}")
|
||||
else:
|
||||
lines.append("- Sin notas")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Trabajo realizado",
|
||||
])
|
||||
if session.work_done:
|
||||
for item in session.work_done:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin trabajo realizado")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Cambios",
|
||||
])
|
||||
if session.changes:
|
||||
for item in session.changes:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin cambios")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Decisiones",
|
||||
])
|
||||
if session.decisions:
|
||||
for item in session.decisions:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin decisiones")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Bloqueos",
|
||||
])
|
||||
if session.blockers:
|
||||
for item in session.blockers:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin bloqueos")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Proximos pasos",
|
||||
])
|
||||
if session.next_steps:
|
||||
for item in session.next_steps:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin pasos definidos")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Referencias",
|
||||
])
|
||||
if session.references:
|
||||
for item in session.references:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin referencias")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Duracion",
|
||||
f"{session.duration_minutes} minutos",
|
||||
])
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def format_autogen_section(self, content: str, section: str, new_content: str) -> str:
|
||||
"""Reemplaza o inserta una seccion AUTOGEN en contenido Markdown.
|
||||
|
||||
Busca <!-- AUTOGEN:{section}_START --> ... <!-- AUTOGEN:{section}_END -->
|
||||
Si existe, reemplaza el contenido entre los marcadores.
|
||||
Si no existe, inserta la seccion al final.
|
||||
|
||||
Returns el contenido modificado.
|
||||
"""
|
||||
start_marker = f"<!-- AUTOGEN:{section}_START -->"
|
||||
end_marker = f"<!-- AUTOGEN:{section}_END -->"
|
||||
|
||||
full_marker = f"{start_marker}\n{new_content}\n{end_marker}"
|
||||
|
||||
# Buscar si existe la seccion
|
||||
pattern = rf"{re.escape(start_marker)}.*?{re.escape(end_marker)}"
|
||||
if re.search(pattern, content, re.DOTALL):
|
||||
# Reemplazar seccion existente
|
||||
return re.sub(pattern, full_marker, content, flags=re.DOTALL)
|
||||
else:
|
||||
# Insertar al final
|
||||
return content + "\n" + full_marker + "\n"
|
||||
|
||||
def format_readme_section(self, section: str, content: str) -> str:
|
||||
"""Formatea una seccion de README.md.
|
||||
|
||||
Para usar con format_autogen_section.
|
||||
"""
|
||||
return content
|
||||
153
tracker/templates/__init__.py
Normal file
153
tracker/templates/__init__.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Templates package for generating project files."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from tracker.models import Project
|
||||
|
||||
|
||||
def get_readme_template(project: Optional[Project] = None) -> str:
|
||||
"""Get the README.md template for a project.
|
||||
|
||||
Args:
|
||||
project: Optional project instance for personalization.
|
||||
|
||||
Returns:
|
||||
README.md template string.
|
||||
"""
|
||||
name = project.name if project else "Project Name"
|
||||
description = project.description if project else "_No description_"
|
||||
status = project.status if project else "inbox"
|
||||
|
||||
return f"""# {name}
|
||||
|
||||
{description}
|
||||
|
||||
## Objective
|
||||
|
||||
_TODO: Define objective_
|
||||
|
||||
## Status
|
||||
|
||||
**Current Status:** {status}
|
||||
|
||||
<!-- AUTOGEN:STATUS_START -->
|
||||
Status: {status}
|
||||
<!-- 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 -->
|
||||
<!-- AUTOGEN:NEXT_STEPS_END -->
|
||||
|
||||
## Recent Sessions
|
||||
|
||||
<!-- AUTOGEN:SESSIONS_START -->
|
||||
<!-- AUTOGEN:SESSIONS_END -->
|
||||
|
||||
_Last updated: {datetime.now().strftime('%Y-%m-%d')}_
|
||||
"""
|
||||
|
||||
|
||||
def get_log_template() -> str:
|
||||
"""Get the LOG.md template.
|
||||
|
||||
Returns:
|
||||
LOG.md template string.
|
||||
"""
|
||||
return """# Log
|
||||
|
||||
_Project activity log_
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def get_changelog_template() -> str:
|
||||
"""Get the CHANGELOG.md template.
|
||||
|
||||
Returns:
|
||||
CHANGELOG.md template string.
|
||||
"""
|
||||
return """# Changelog
|
||||
|
||||
_Project changes_
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def get_tasks_template() -> str:
|
||||
"""Get the TASKS.md template.
|
||||
|
||||
Returns:
|
||||
TASKS.md template string.
|
||||
"""
|
||||
return """# Tasks
|
||||
|
||||
## Inbox
|
||||
|
||||
-
|
||||
|
||||
## Next
|
||||
|
||||
-
|
||||
|
||||
## In Progress
|
||||
|
||||
-
|
||||
|
||||
## Blocked
|
||||
|
||||
-
|
||||
|
||||
## Waiting
|
||||
|
||||
-
|
||||
|
||||
## Done
|
||||
|
||||
-
|
||||
"""
|
||||
|
||||
|
||||
def get_meta_template(project: Project) -> dict:
|
||||
"""Get the meta/project.yaml template data.
|
||||
|
||||
Args:
|
||||
project: Project instance.
|
||||
|
||||
Returns:
|
||||
Dictionary suitable for YAML serialization.
|
||||
"""
|
||||
return {
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"slug": project.slug,
|
||||
"description": project.description,
|
||||
"type": project.type,
|
||||
"status": project.status,
|
||||
"tags": project.tags,
|
||||
"root_path": str(project.root_path),
|
||||
"repo_path": str(project.repo_path) if project.repo_path else None,
|
||||
"created_at": project.created_at.isoformat(),
|
||||
"updated_at": project.updated_at.isoformat(),
|
||||
"last_session_at": None,
|
||||
}
|
||||
1
tracker/utils/__init__.py
Normal file
1
tracker/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility functions."""
|
||||
87
tracker/utils/path.py
Normal file
87
tracker/utils/path.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Path utility functions."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
"""Ensure a directory exists, creating it if necessary.
|
||||
|
||||
Args:
|
||||
path: Path to the directory.
|
||||
|
||||
Returns:
|
||||
The path to the directory.
|
||||
"""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def project_root(slug: str, projects_root: Optional[Path] = None) -> Path:
|
||||
"""Get the root path for a project.
|
||||
|
||||
Args:
|
||||
slug: Project slug.
|
||||
projects_root: Root directory for all projects. Defaults to ./projects.
|
||||
|
||||
Returns:
|
||||
Path to the project root.
|
||||
"""
|
||||
if projects_root is None:
|
||||
projects_root = Path("projects")
|
||||
return projects_root / slug
|
||||
|
||||
|
||||
def relative_to_project(slug: str, relative_path: str, projects_root: Optional[Path] = None) -> Path:
|
||||
"""Get a path relative to a project.
|
||||
|
||||
Args:
|
||||
slug: Project slug.
|
||||
relative_path: Relative path within the project.
|
||||
projects_root: Root directory for all projects.
|
||||
|
||||
Returns:
|
||||
Absolute path to the file within the project.
|
||||
"""
|
||||
root = project_root(slug, projects_root)
|
||||
return root / relative_path
|
||||
|
||||
|
||||
def is_within_project(slug: str, file_path: Path, projects_root: Optional[Path] = None) -> bool:
|
||||
"""Check if a file path is within a project.
|
||||
|
||||
Args:
|
||||
slug: Project slug.
|
||||
file_path: Path to check.
|
||||
projects_root: Root directory for all projects.
|
||||
|
||||
Returns:
|
||||
True if file_path is within the project directory.
|
||||
"""
|
||||
project_path = project_root(slug, projects_root)
|
||||
try:
|
||||
file_path.resolve().relative_to(project_path.resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize a filename by removing invalid characters.
|
||||
|
||||
Args:
|
||||
filename: Original filename.
|
||||
|
||||
Returns:
|
||||
Sanitized filename safe for file system use.
|
||||
"""
|
||||
# Remove or replace invalid characters
|
||||
invalid_chars = '<>:"/\\|?*'
|
||||
for char in invalid_chars:
|
||||
filename = filename.replace(char, "_")
|
||||
|
||||
# Limit length
|
||||
if len(filename) > 255:
|
||||
filename = filename[:255]
|
||||
|
||||
return filename
|
||||
13
tracker/utils/slug.py
Normal file
13
tracker/utils/slug.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Slug generation utility."""
|
||||
|
||||
|
||||
def generate_slug(name: str) -> str:
|
||||
"""Generate a URL-safe slug from a name.
|
||||
|
||||
Args:
|
||||
name: The name to convert to a slug.
|
||||
|
||||
Returns:
|
||||
A lowercase slug with spaces replaced by hyphens.
|
||||
"""
|
||||
return name.lower().replace(" ", "-")
|
||||
91
tracker/utils/time.py
Normal file
91
tracker/utils/time.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Time utility functions."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def format_duration(minutes: int) -> str:
|
||||
"""Format duration in minutes to human-readable string.
|
||||
|
||||
Args:
|
||||
minutes: Duration in minutes.
|
||||
|
||||
Returns:
|
||||
Human-readable duration string (e.g., "1h 30m", "45m").
|
||||
"""
|
||||
if minutes < 60:
|
||||
return f"{minutes}m"
|
||||
hours = minutes // 60
|
||||
remaining_minutes = minutes % 60
|
||||
if remaining_minutes == 0:
|
||||
return f"{hours}h"
|
||||
return f"{hours}h {remaining_minutes}m"
|
||||
|
||||
|
||||
def parse_duration(duration_str: str) -> int:
|
||||
"""Parse duration string to minutes.
|
||||
|
||||
Args:
|
||||
duration_str: Duration string like "1h 30m", "45m", "2h".
|
||||
|
||||
Returns:
|
||||
Duration in minutes.
|
||||
"""
|
||||
total_minutes = 0
|
||||
duration_str = duration_str.lower().strip()
|
||||
|
||||
# Parse hours
|
||||
if "h" in duration_str:
|
||||
parts = duration_str.split()
|
||||
for part in parts:
|
||||
if "h" in part:
|
||||
total_minutes += int(part.replace("h", "")) * 60
|
||||
elif "m" in part:
|
||||
total_minutes += int(part.replace("m", ""))
|
||||
|
||||
# If no hours, try just minutes
|
||||
if total_minutes == 0 and "m" in duration_str:
|
||||
total_minutes = int(duration_str.replace("m", ""))
|
||||
elif total_minutes == 0:
|
||||
try:
|
||||
total_minutes = int(duration_str)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
return total_minutes
|
||||
|
||||
|
||||
def is_recent(dt: datetime, hours: int = 24) -> bool:
|
||||
"""Check if a datetime is within the specified hours.
|
||||
|
||||
Args:
|
||||
dt: Datetime to check.
|
||||
hours: Number of hours to consider as "recent".
|
||||
|
||||
Returns:
|
||||
True if dt is within the specified hours.
|
||||
"""
|
||||
return datetime.now() - dt < timedelta(hours=hours)
|
||||
|
||||
|
||||
def format_datetime(dt: datetime) -> str:
|
||||
"""Format datetime to standard string.
|
||||
|
||||
Args:
|
||||
dt: Datetime to format.
|
||||
|
||||
Returns:
|
||||
Formatted string in YYYY-MM-DD HH:MM format.
|
||||
"""
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def format_date(dt: datetime) -> str:
|
||||
"""Format datetime to date string.
|
||||
|
||||
Args:
|
||||
dt: Datetime to format.
|
||||
|
||||
Returns:
|
||||
Formatted string in YYYY-MM-DD format.
|
||||
"""
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
Reference in New Issue
Block a user