diff options
Diffstat (limited to 'internal/api/stories.go')
| -rw-r--r-- | internal/api/stories.go | 170 |
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) +} |
