diff options
Diffstat (limited to 'internal/storage')
| -rw-r--r-- | internal/storage/db.go | 30 | ||||
| -rw-r--r-- | internal/storage/epic.go | 88 | ||||
| -rw-r--r-- | internal/storage/epic_test.go | 117 | ||||
| -rw-r--r-- | internal/storage/story.go | 160 | ||||
| -rw-r--r-- | internal/storage/story_test.go | 211 |
5 files changed, 606 insertions, 0 deletions
diff --git a/internal/storage/db.go b/internal/storage/db.go index d571c2e..8b563a6 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -154,6 +154,36 @@ func (s *DB) migrate() error { UNIQUE(role, version) )`, `CREATE INDEX IF NOT EXISTS idx_role_configs_role_status ON role_configs(role, status)`, + `CREATE TABLE IF NOT EXISTS epics ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'OPEN', + discovery_source TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS stories ( + id TEXT PRIMARY KEY, + epic_id TEXT, + project_id TEXT, + name TEXT NOT NULL, + branch_name TEXT, + status TEXT NOT NULL, + discovery_source TEXT, + spec TEXT, + acceptance_criteria_json TEXT NOT NULL DEFAULT '[]', + validation_json TEXT, + deploy_config TEXT, + priority TEXT, + root_task_id TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_epics_status ON epics(status)`, + `CREATE INDEX IF NOT EXISTS idx_stories_status ON stories(status)`, + `CREATE INDEX IF NOT EXISTS idx_stories_epic_id ON stories(epic_id)`, + `CREATE INDEX IF NOT EXISTS idx_stories_root_task_id ON stories(root_task_id)`, } for _, m := range migrations { if _, err := s.db.Exec(m); err != nil { 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) +} 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) + } +} diff --git a/internal/storage/story.go b/internal/storage/story.go new file mode 100644 index 0000000..7570dfc --- /dev/null +++ b/internal/storage/story.go @@ -0,0 +1,160 @@ +package storage + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/thepeterstone/claudomator/internal/story" +) + +// StoryFilter specifies criteria for listing stories. Zero-value fields are +// not applied as filters. +type StoryFilter struct { + Status string + EpicID string +} + +// CreateStory inserts a new story. +func (s *DB) CreateStory(st *story.Story) error { + now := time.Now().UTC() + st.CreatedAt = now + st.UpdatedAt = now + + ac := st.AcceptanceCriteria + if ac == nil { + ac = []string{} + } + acJSON, err := json.Marshal(ac) + if err != nil { + return fmt.Errorf("marshaling acceptance_criteria: %w", err) + } + + _, err = s.db.Exec(` + INSERT INTO stories (id, epic_id, project_id, name, branch_name, status, discovery_source, spec, acceptance_criteria_json, validation_json, deploy_config, priority, root_task_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + st.ID, st.EpicID, st.ProjectID, st.Name, st.BranchName, st.Status, st.DiscoverySource, st.Spec, + string(acJSON), rawJSONOrNull(st.Validation), rawJSONOrNull(st.DeployConfig), st.Priority, st.RootTaskID, now, now, + ) + return err +} + +// GetStory retrieves a story by ID. +func (s *DB) GetStory(id string) (*story.Story, error) { + row := s.db.QueryRow(storySelectColumns()+` FROM stories WHERE id = ?`, id) + return scanStory(row) +} + +// ListStories returns stories matching the given filter. +func (s *DB) ListStories(filter StoryFilter) ([]*story.Story, error) { + query := storySelectColumns() + ` FROM stories WHERE 1=1` + var args []interface{} + if filter.Status != "" { + query += " AND status = ?" + args = append(args, filter.Status) + } + if filter.EpicID != "" { + query += " AND epic_id = ?" + args = append(args, filter.EpicID) + } + query += " ORDER BY created_at DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var stories []*story.Story + for rows.Next() { + st, err := scanStoryRows(rows) + if err != nil { + return nil, err + } + stories = append(stories, st) + } + return stories, rows.Err() +} + +// UpdateStory replaces all editable fields (everything except id/created_at). +// This phase enforces no state-machine validation on Status transitions — +// it writes whatever status string it is given, the same trust level as +// UpdateProject/UpdateTask's field replacement (not UpdateTaskState). +func (s *DB) UpdateStory(st *story.Story) error { + now := time.Now().UTC() + + ac := st.AcceptanceCriteria + if ac == nil { + ac = []string{} + } + acJSON, err := json.Marshal(ac) + if err != nil { + return fmt.Errorf("marshaling acceptance_criteria: %w", err) + } + + _, err = s.db.Exec(` + UPDATE stories SET epic_id = ?, project_id = ?, name = ?, branch_name = ?, status = ?, discovery_source = ?, + spec = ?, acceptance_criteria_json = ?, validation_json = ?, deploy_config = ?, priority = ?, root_task_id = ?, updated_at = ? + WHERE id = ?`, + st.EpicID, st.ProjectID, st.Name, st.BranchName, st.Status, st.DiscoverySource, + st.Spec, string(acJSON), rawJSONOrNull(st.Validation), rawJSONOrNull(st.DeployConfig), st.Priority, st.RootTaskID, now, + st.ID, + ) + if err != nil { + return err + } + st.UpdatedAt = now + return nil +} + +func storySelectColumns() string { + return `SELECT id, epic_id, project_id, name, branch_name, status, discovery_source, spec, acceptance_criteria_json, validation_json, deploy_config, priority, root_task_id, created_at, updated_at` +} + +func scanStory(row scanner) (*story.Story, error) { + var st story.Story + var epicID, projectID, branchName, discoverySource, spec, validationJSON, deployConfig, priority, rootTaskID sql.NullString + var acJSON string + if err := row.Scan( + &st.ID, &epicID, &projectID, &st.Name, &branchName, &st.Status, &discoverySource, &spec, + &acJSON, &validationJSON, &deployConfig, &priority, &rootTaskID, &st.CreatedAt, &st.UpdatedAt, + ); err != nil { + return nil, err + } + st.EpicID = epicID.String + st.ProjectID = projectID.String + st.BranchName = branchName.String + st.DiscoverySource = discoverySource.String + st.Spec = spec.String + st.Priority = priority.String + st.RootTaskID = rootTaskID.String + + if acJSON == "" { + acJSON = "[]" + } + if err := json.Unmarshal([]byte(acJSON), &st.AcceptanceCriteria); err != nil { + return nil, fmt.Errorf("unmarshaling acceptance_criteria: %w", err) + } + if validationJSON.Valid && validationJSON.String != "" { + st.Validation = json.RawMessage(validationJSON.String) + } + if deployConfig.Valid && deployConfig.String != "" { + st.DeployConfig = json.RawMessage(deployConfig.String) + } + return &st, nil +} + +func scanStoryRows(rows *sql.Rows) (*story.Story, error) { + return scanStory(rows) +} + +// rawJSONOrNull returns nil (SQL NULL) for an empty json.RawMessage, or the +// raw JSON text otherwise. Used for the nullable validation_json/deploy_config +// columns, which carry freeform JSON blobs, not a fixed schema. +func rawJSONOrNull(raw json.RawMessage) interface{} { + if len(raw) == 0 { + return nil + } + return string(raw) +} diff --git a/internal/storage/story_test.go b/internal/storage/story_test.go new file mode 100644 index 0000000..9f2c723 --- /dev/null +++ b/internal/storage/story_test.go @@ -0,0 +1,211 @@ +package storage + +import ( + "encoding/json" + "testing" + + "github.com/thepeterstone/claudomator/internal/story" +) + +func TestCreateStory_AndGetStory(t *testing.T) { + db := testDB(t) + + st := &story.Story{ + ID: "story-1", + EpicID: "epic-1", + ProjectID: "proj-1", + Name: "Add login page", + BranchName: "story/add-login-page", + Status: "DISCOVERY", + DiscoverySource: "agent", + Spec: "Product: users need to log in. Arch: reuse existing session middleware.", + AcceptanceCriteria: []string{"User can log in with email/password", "Invalid creds show an error"}, + Validation: json.RawMessage(`{"type":"curl","steps":["GET /login returns 200"],"success_criteria":"all steps pass"}`), + Priority: "high", + RootTaskID: "task-1", + } + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory: %v", err) + } + + got, err := db.GetStory("story-1") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.Name != "Add login page" { + t.Errorf("Name: want %q, got %q", "Add login page", got.Name) + } + if got.EpicID != "epic-1" { + t.Errorf("EpicID: want epic-1, got %q", got.EpicID) + } + if got.ProjectID != "proj-1" { + t.Errorf("ProjectID: want proj-1, got %q", got.ProjectID) + } + if got.BranchName != "story/add-login-page" { + t.Errorf("BranchName: want story/add-login-page, got %q", got.BranchName) + } + if got.Status != "DISCOVERY" { + t.Errorf("Status: want DISCOVERY, got %q", got.Status) + } + if len(got.AcceptanceCriteria) != 2 { + t.Errorf("AcceptanceCriteria: want 2 items, got %d: %+v", len(got.AcceptanceCriteria), got.AcceptanceCriteria) + } + if string(got.Validation) == "" { + t.Errorf("expected Validation to round-trip, got empty") + } + if got.RootTaskID != "task-1" { + t.Errorf("RootTaskID: want task-1, got %q", got.RootTaskID) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Errorf("expected CreatedAt/UpdatedAt to be set, got %+v", got) + } +} + +// TestCreateStory_LooseRefsToleratesUnmatchedIDs confirms epic_id, +// project_id, and root_task_id are plain nullable-loose-ref strings with no +// FK enforcement — a story referencing IDs that don't exist anywhere else +// must still be created successfully, matching how tasks.project already +// tolerates an unmatched string. +func TestCreateStory_LooseRefsToleratesUnmatchedIDs(t *testing.T) { + db := testDB(t) + + st := &story.Story{ + ID: "story-loose", + EpicID: "epic-does-not-exist", + ProjectID: "project-does-not-exist", + Name: "Loose ref story", + Status: "DISCOVERY", + RootTaskID: "task-does-not-exist", + } + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory with unmatched loose refs should succeed, got: %v", err) + } + got, err := db.GetStory("story-loose") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.EpicID != "epic-does-not-exist" || got.ProjectID != "project-does-not-exist" || got.RootTaskID != "task-does-not-exist" { + t.Errorf("expected loose refs stored verbatim, got %+v", got) + } +} + +func TestCreateStory_DefaultsAcceptanceCriteriaToEmptyList(t *testing.T) { + db := testDB(t) + + st := &story.Story{ID: "story-nil-ac", Name: "No AC set", Status: "DISCOVERY"} + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory: %v", err) + } + got, err := db.GetStory("story-nil-ac") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.AcceptanceCriteria == nil { + t.Fatal("expected AcceptanceCriteria to be an empty slice, got nil") + } + if len(got.AcceptanceCriteria) != 0 { + t.Errorf("expected empty AcceptanceCriteria, got %+v", got.AcceptanceCriteria) + } +} + +func TestGetStory_NotFound(t *testing.T) { + db := testDB(t) + if _, err := db.GetStory("nope"); err == nil { + t.Fatal("expected error for missing story, got nil") + } +} + +func TestListStories_FilterByStatusAndEpic(t *testing.T) { + db := testDB(t) + + stories := []*story.Story{ + {ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"}, + {ID: "s2", EpicID: "epic-a", Name: "two", Status: "IN_PROGRESS"}, + {ID: "s3", EpicID: "epic-b", Name: "three", Status: "DISCOVERY"}, + } + for _, st := range stories { + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory(%s): %v", st.ID, err) + } + } + + all, err := db.ListStories(StoryFilter{}) + if err != nil { + t.Fatalf("ListStories: %v", err) + } + if len(all) != 3 { + t.Errorf("want 3 stories, got %d", len(all)) + } + + byEpic, err := db.ListStories(StoryFilter{EpicID: "epic-a"}) + if err != nil { + t.Fatalf("ListStories(epic-a): %v", err) + } + if len(byEpic) != 2 { + t.Errorf("want 2 stories for epic-a, got %d", len(byEpic)) + } + + byStatus, err := db.ListStories(StoryFilter{Status: "DISCOVERY"}) + if err != nil { + t.Fatalf("ListStories(DISCOVERY): %v", err) + } + if len(byStatus) != 2 { + t.Errorf("want 2 DISCOVERY stories, got %d", len(byStatus)) + } + + byBoth, err := db.ListStories(StoryFilter{EpicID: "epic-a", Status: "DISCOVERY"}) + if err != nil { + t.Fatalf("ListStories(epic-a, DISCOVERY): %v", err) + } + if len(byBoth) != 1 || byBoth[0].ID != "s1" { + t.Errorf("want exactly [s1], got %+v", byBoth) + } +} + +// TestUpdateStory_UnvalidatedStatusPassthrough confirms UpdateStory writes +// whatever status string it is given with no state-machine enforcement +// (unlike task.ValidTransition/UpdateTaskState) — this phase explicitly +// defers that validation to a later orchestration phase. +func TestUpdateStory_UnvalidatedStatusPassthrough(t *testing.T) { + db := testDB(t) + + st := &story.Story{ID: "s1", Name: "original", Status: "DISCOVERY"} + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory: %v", err) + } + origCreatedAt := st.CreatedAt + + // A nonsensical jump (DISCOVERY -> DONE, skipping every intermediate + // status) must still be accepted verbatim. + st.Name = "updated" + st.Status = "DONE" + st.EpicID = "epic-x" + st.RootTaskID = "task-x" + st.AcceptanceCriteria = []string{"criterion one"} + if err := db.UpdateStory(st); err != nil { + t.Fatalf("UpdateStory: %v", err) + } + + got, err := db.GetStory("s1") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.Status != "DONE" { + t.Errorf("Status after update: want DONE (unvalidated passthrough), got %q", got.Status) + } + if got.Name != "updated" { + t.Errorf("Name after update: want updated, got %q", got.Name) + } + if got.EpicID != "epic-x" { + t.Errorf("EpicID after update: want epic-x, got %q", got.EpicID) + } + if got.RootTaskID != "task-x" { + t.Errorf("RootTaskID after update: want task-x, got %q", got.RootTaskID) + } + if len(got.AcceptanceCriteria) != 1 || got.AcceptanceCriteria[0] != "criterion one" { + t.Errorf("AcceptanceCriteria after update: want [criterion one], got %+v", got.AcceptanceCriteria) + } + if !got.CreatedAt.Equal(origCreatedAt) { + t.Errorf("CreatedAt must not change on update: want %v, got %v", origCreatedAt, got.CreatedAt) + } +} |
