summaryrefslogtreecommitdiff
path: root/internal/storage/story.go
blob: 7570dfc19959bf6471497b068fb1f59a4d5fa07c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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)
}