diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:46:26 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:46:26 +0000 |
| commit | 392c7c1ada310b2f928dca89b75ba628478f7694 (patch) | |
| tree | c2b32420ce5df0da79d6d4b014fe2c784f2bd4be /internal/storage/epic.go | |
| parent | 997cd8b56bc086a02b9c7c006dd62b07b9fcd2f3 (diff) | |
feat(story): add Epic/Story data model + REST CRUD (Phase 7a)
Pure data model + CRUD -- no orchestration behavior yet (that's Phase 7b).
Revives ADR-007's stories concept (adapted; ADR-007 itself was deleted as
"more machinery than the usage pattern needed") while staying lean: reuses
the existing events/projects/task-tree machinery rather than building a
parallel hierarchy.
- internal/story: Epic/Story types. Epic is a loosely-scoped initiative
(OPEN/CLOSED, no execution semantics). Story is a shippable slice of work
realized by a task tree rooted at root_task_id; epic_id/project_id/
root_task_id are loose references (no FK enforcement, matching the
codebase's existing tolerance for unmatched reference strings like
tasks.project).
- storage: new epics/stories tables (additive migrations) + CRUD methods.
Story status is intentionally unvalidated pass-through in this phase --
no task.ValidTransition-style state machine, since there's no orchestrator
yet to make transitions meaningful.
- event: 9 new Kind constants for the ceremony this layer will eventually
drive (epic_proposed, discovery_proposed, framing_decided, groomed,
prioritized, eval_verdict, arbitration_decided, retro_captured,
human_accepted) -- defined but nothing emits them yet.
- api: GET/POST /api/epics, GET/PUT /api/epics/{id}, GET /api/epics/{id}/stories,
GET/POST /api/stories, GET/PUT /api/stories/{id}, and GET
/api/stories/{id}/task-tree -- BFS walk from root_task_id following both
parent_task_id children and depends_on-edge dependents (visited-set
guarded), returning a flat node list for a later UI phase to render as a
graph. Unauthenticated, matching the existing projects/tasks endpoints'
posture.
go build/vet/test -race -count=1 all pass, full suite.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/storage/epic.go')
| -rw-r--r-- | internal/storage/epic.go | 88 |
1 files changed, 88 insertions, 0 deletions
diff --git a/internal/storage/epic.go b/internal/storage/epic.go new file mode 100644 index 0000000..9f3863c --- /dev/null +++ b/internal/storage/epic.go @@ -0,0 +1,88 @@ +package storage + +import ( + "database/sql" + "time" + + "github.com/thepeterstone/claudomator/internal/story" +) + +// CreateEpic inserts a new epic. Defaults Status to "OPEN" if unset. +func (s *DB) CreateEpic(e *story.Epic) error { + if e.Status == "" { + e.Status = "OPEN" + } + now := time.Now().UTC() + e.CreatedAt = now + e.UpdatedAt = now + _, err := s.db.Exec( + `INSERT INTO epics (id, name, description, status, discovery_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + e.ID, e.Name, e.Description, e.Status, e.DiscoverySource, now, now, + ) + return err +} + +// GetEpic retrieves an epic by ID. +func (s *DB) GetEpic(id string) (*story.Epic, error) { + row := s.db.QueryRow(`SELECT id, name, description, status, discovery_source, created_at, updated_at FROM epics WHERE id = ?`, id) + return scanEpic(row) +} + +// ListEpics returns epics, optionally filtered by status. Pass an empty +// string to list all epics. +func (s *DB) ListEpics(status string) ([]*story.Epic, error) { + query := `SELECT id, name, description, status, discovery_source, created_at, updated_at FROM epics WHERE 1=1` + var args []interface{} + if status != "" { + query += " AND status = ?" + args = append(args, status) + } + query += " ORDER BY created_at DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var epics []*story.Epic + for rows.Next() { + e, err := scanEpicRows(rows) + if err != nil { + return nil, err + } + epics = append(epics, e) + } + return epics, rows.Err() +} + +// UpdateEpic updates an existing epic's name/description/status/discovery_source. +// Mirrors UpdateProject's shape: no existence check, no status-machine validation. +func (s *DB) UpdateEpic(e *story.Epic) error { + now := time.Now().UTC() + _, err := s.db.Exec( + `UPDATE epics SET name = ?, description = ?, status = ?, discovery_source = ?, updated_at = ? WHERE id = ?`, + e.Name, e.Description, e.Status, e.DiscoverySource, now, e.ID, + ) + if err != nil { + return err + } + e.UpdatedAt = now + return nil +} + +func scanEpic(row scanner) (*story.Epic, error) { + var e story.Epic + var description sql.NullString + var discoverySource sql.NullString + if err := row.Scan(&e.ID, &e.Name, &description, &e.Status, &discoverySource, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, err + } + e.Description = description.String + e.DiscoverySource = discoverySource.String + return &e, nil +} + +func scanEpicRows(rows *sql.Rows) (*story.Epic, error) { + return scanEpic(rows) +} |
