summaryrefslogtreecommitdiff
path: root/internal/storage/story.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/story.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/story.go')
-rw-r--r--internal/storage/story.go160
1 files changed, 160 insertions, 0 deletions
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)
+}