summaryrefslogtreecommitdiff
path: root/internal/api/stories.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/stories.go')
-rw-r--r--internal/api/stories.go170
1 files changed, 170 insertions, 0 deletions
diff --git a/internal/api/stories.go b/internal/api/stories.go
new file mode 100644
index 0000000..337da21
--- /dev/null
+++ b/internal/api/stories.go
@@ -0,0 +1,170 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+func (s *Server) handleListStories(w http.ResponseWriter, r *http.Request) {
+ filter := storage.StoryFilter{
+ Status: r.URL.Query().Get("status"),
+ EpicID: r.URL.Query().Get("epic_id"),
+ }
+ stories, err := s.store.ListStories(filter)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if stories == nil {
+ stories = []*story.Story{}
+ }
+ writeJSON(w, http.StatusOK, stories)
+}
+
+func (s *Server) handleCreateStory(w http.ResponseWriter, r *http.Request) {
+ var st story.Story
+ if err := json.NewDecoder(r.Body).Decode(&st); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if st.Name == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+ return
+ }
+ if st.ID == "" {
+ st.ID = uuid.New().String()
+ }
+ if err := s.store.CreateStory(&st); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, st)
+}
+
+func (s *Server) handleGetStory(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ st, err := s.store.GetStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+ writeJSON(w, http.StatusOK, st)
+}
+
+// handleUpdateStory replaces all editable fields on a story (everything
+// except id/created_at). No status-machine validation is performed in this
+// phase — the status string is written as given, same trust level as
+// handleUpdateProject.
+func (s *Server) handleUpdateStory(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ existing, err := s.store.GetStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+ createdAt := existing.CreatedAt
+ if err := json.NewDecoder(r.Body).Decode(existing); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ existing.ID = id // ensure ID cannot be changed via body
+ existing.CreatedAt = createdAt
+ if err := s.store.UpdateStory(existing); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, existing)
+}
+
+// taskTreeNode is one task in a story's task-tree/graph response.
+type taskTreeNode struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ State string `json:"state"`
+ AgentType string `json:"agent_type"`
+ Role string `json:"role,omitempty"`
+ ParentTaskID string `json:"parent_task_id,omitempty"`
+ DependsOn []string `json:"depends_on"`
+}
+
+// taskTreeResponse is the payload for GET /api/stories/{id}/task-tree.
+type taskTreeResponse struct {
+ StoryID string `json:"story_id"`
+ RootTaskID string `json:"root_task_id"`
+ Nodes []taskTreeNode `json:"nodes"`
+}
+
+// handleStoryTaskTree walks the task graph realizing a story, starting at
+// its root_task_id and following both parent_task_id children (via
+// ListSubtasks) and depends_on edges in either direction (via
+// ListDependents, so a sibling task that merely depends on something already
+// in the tree is still discovered even if it isn't a literal
+// parent_task_id child). Returns one flat node list; clients reconstruct
+// the graph from each node's parent_task_id/depends_on. If the story has no
+// root_task_id set, returns an empty node list, not an error.
+func (s *Server) handleStoryTaskTree(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ st, err := s.store.GetStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+
+ resp := taskTreeResponse{StoryID: id, RootTaskID: st.RootTaskID, Nodes: []taskTreeNode{}}
+ if st.RootTaskID == "" {
+ writeJSON(w, http.StatusOK, resp)
+ return
+ }
+
+ visited := map[string]bool{}
+ queue := []string{st.RootTaskID}
+ for len(queue) > 0 {
+ tid := queue[0]
+ queue = queue[1:]
+ if visited[tid] {
+ continue
+ }
+ visited[tid] = true
+
+ t, err := s.store.GetTask(tid)
+ if err != nil {
+ // A referenced task ID no longer resolves (e.g. deleted); skip it
+ // rather than failing the whole tree.
+ continue
+ }
+ dependsOn := t.DependsOn
+ if dependsOn == nil {
+ dependsOn = []string{}
+ }
+ resp.Nodes = append(resp.Nodes, taskTreeNode{
+ ID: t.ID,
+ Name: t.Name,
+ State: string(t.State),
+ AgentType: t.Agent.Type,
+ Role: t.Agent.Role,
+ ParentTaskID: t.ParentTaskID,
+ DependsOn: dependsOn,
+ })
+
+ if children, err := s.store.ListSubtasks(tid); err == nil {
+ for _, c := range children {
+ if !visited[c.ID] {
+ queue = append(queue, c.ID)
+ }
+ }
+ }
+ if dependents, err := s.store.ListDependents(tid); err == nil {
+ for _, d := range dependents {
+ if !visited[d.ID] {
+ queue = append(queue, d.ID)
+ }
+ }
+ }
+ }
+
+ writeJSON(w, http.StatusOK, resp)
+}