summaryrefslogtreecommitdiff
path: root/internal/storage/epic.go
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 23:46:26 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 23:46:26 +0000
commit392c7c1ada310b2f928dca89b75ba628478f7694 (patch)
treec2b32420ce5df0da79d6d4b014fe2c784f2bd4be /internal/storage/epic.go
parent997cd8b56bc086a02b9c7c006dd62b07b9fcd2f3 (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.go88
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)
+}