diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:46:26 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:46:26 +0000 |
| commit | 392c7c1ada310b2f928dca89b75ba628478f7694 (patch) | |
| tree | c2b32420ce5df0da79d6d4b014fe2c784f2bd4be /internal/api/epics.go | |
| parent | 997cd8b56bc086a02b9c7c006dd62b07b9fcd2f3 (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/api/epics.go')
| -rw-r--r-- | internal/api/epics.go | 90 |
1 files changed, 90 insertions, 0 deletions
diff --git a/internal/api/epics.go b/internal/api/epics.go new file mode 100644 index 0000000..e22a377 --- /dev/null +++ b/internal/api/epics.go @@ -0,0 +1,90 @@ +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) handleListEpics(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + epics, err := s.store.ListEpics(status) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if epics == nil { + epics = []*story.Epic{} + } + writeJSON(w, http.StatusOK, epics) +} + +func (s *Server) handleCreateEpic(w http.ResponseWriter, r *http.Request) { + var e story.Epic + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if e.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + if e.ID == "" { + e.ID = uuid.New().String() + } + if err := s.store.CreateEpic(&e); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, e) +} + +func (s *Server) handleGetEpic(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + e, err := s.store.GetEpic(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "epic not found"}) + return + } + writeJSON(w, http.StatusOK, e) +} + +func (s *Server) handleUpdateEpic(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + existing, err := s.store.GetEpic(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "epic 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.UpdateEpic(existing); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, existing) +} + +// handleListEpicStories lists stories whose epic_id matches the path {id}. +// Mirrors handleListSubtasks: no existence check on the epic itself, just +// returns an empty list if there are no matching stories. +func (s *Server) handleListEpicStories(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + stories, err := s.store.ListStories(storage.StoryFilter{EpicID: id}) + 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) +} |
