From 392c7c1ada310b2f928dca89b75ba628478f7694 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Fri, 3 Jul 2026 23:46:26 +0000 Subject: 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 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- internal/storage/epic_test.go | 117 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 internal/storage/epic_test.go (limited to 'internal/storage/epic_test.go') diff --git a/internal/storage/epic_test.go b/internal/storage/epic_test.go new file mode 100644 index 0000000..dfd5be1 --- /dev/null +++ b/internal/storage/epic_test.go @@ -0,0 +1,117 @@ +package storage + +import ( + "testing" + + "github.com/thepeterstone/claudomator/internal/story" +) + +func TestCreateEpic_AndGetEpic(t *testing.T) { + db := testDB(t) + + e := &story.Epic{ + ID: "epic-1", + Name: "Bigger Auth Rework", + Description: "Overhaul the auth flow.", + DiscoverySource: "human", + } + if err := db.CreateEpic(e); err != nil { + t.Fatalf("CreateEpic: %v", err) + } + // CreateEpic defaults Status to OPEN when unset. + if e.Status != "OPEN" { + t.Errorf("Status after create: want OPEN, got %q", e.Status) + } + + got, err := db.GetEpic("epic-1") + if err != nil { + t.Fatalf("GetEpic: %v", err) + } + if got.Name != "Bigger Auth Rework" { + t.Errorf("Name: want %q, got %q", "Bigger Auth Rework", got.Name) + } + if got.Description != "Overhaul the auth flow." { + t.Errorf("Description: want %q, got %q", "Overhaul the auth flow.", got.Description) + } + if got.Status != "OPEN" { + t.Errorf("Status: want OPEN, got %q", got.Status) + } + if got.DiscoverySource != "human" { + t.Errorf("DiscoverySource: want human, got %q", got.DiscoverySource) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Errorf("expected CreatedAt/UpdatedAt to be set, got %+v", got) + } +} + +func TestGetEpic_NotFound(t *testing.T) { + db := testDB(t) + if _, err := db.GetEpic("nope"); err == nil { + t.Fatal("expected error for missing epic, got nil") + } +} + +func TestListEpics(t *testing.T) { + db := testDB(t) + + for _, e := range []*story.Epic{ + {ID: "e1", Name: "alpha", Status: "OPEN"}, + {ID: "e2", Name: "beta", Status: "CLOSED"}, + {ID: "e3", Name: "gamma", Status: "OPEN"}, + } { + if err := db.CreateEpic(e); err != nil { + t.Fatalf("CreateEpic(%s): %v", e.ID, err) + } + } + + all, err := db.ListEpics("") + if err != nil { + t.Fatalf("ListEpics: %v", err) + } + if len(all) != 3 { + t.Errorf("want 3 epics, got %d", len(all)) + } + + open, err := db.ListEpics("OPEN") + if err != nil { + t.Fatalf("ListEpics(OPEN): %v", err) + } + if len(open) != 2 { + t.Errorf("want 2 OPEN epics, got %d", len(open)) + } + for _, e := range open { + if e.Status != "OPEN" { + t.Errorf("expected only OPEN epics, got %q", e.Status) + } + } +} + +func TestUpdateEpic(t *testing.T) { + db := testDB(t) + + e := &story.Epic{ID: "e1", Name: "original", Status: "OPEN"} + if err := db.CreateEpic(e); err != nil { + t.Fatalf("CreateEpic: %v", err) + } + e.Name = "updated" + e.Description = "now with a description" + e.Status = "CLOSED" + if err := db.UpdateEpic(e); err != nil { + t.Fatalf("UpdateEpic: %v", err) + } + got, err := db.GetEpic("e1") + if err != nil { + t.Fatalf("GetEpic: %v", err) + } + if got.Name != "updated" { + t.Errorf("Name after update: want updated, got %q", got.Name) + } + if got.Description != "now with a description" { + t.Errorf("Description after update: want %q, got %q", "now with a description", got.Description) + } + // UpdateEpic performs no status-machine validation — CLOSED is accepted + // as a plain pass-through write, matching UpdateProject's trust level. + if got.Status != "CLOSED" { + t.Errorf("Status after update: want CLOSED, got %q", got.Status) + } +} -- cgit v1.2.3