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/story.go | 160 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 internal/storage/story.go (limited to 'internal/storage/story.go') 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) +} -- cgit v1.2.3