diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/stories.go | 42 | ||||
| -rw-r--r-- | internal/api/taskops.go | 3 |
2 files changed, 30 insertions, 15 deletions
diff --git a/internal/api/stories.go b/internal/api/stories.go index b93f8c3..c705a43 100644 --- a/internal/api/stories.go +++ b/internal/api/stories.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "net/http" "strconv" @@ -11,6 +12,11 @@ import ( "github.com/thepeterstone/claudomator/internal/story" ) +// errStoryNotFound is returned when a story ID does not resolve. REST maps +// it to 404 (via writeOpError, mirroring errTaskNotFound in taskops.go); MCP +// surfaces it as a tool error. +var errStoryNotFound = errors.New("story not found") + func (s *Server) handleListStories(w http.ResponseWriter, r *http.Request) { filter := storage.StoryFilter{ Status: r.URL.Query().Get("status"), @@ -208,29 +214,23 @@ func (s *Server) handleListStoryEvents(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, events) } -// handleAcceptStory is the human accept-gate for a story: it mirrors -// handleAcceptTask's pattern exactly (validate current state, transition, -// emit an event) but operates on story.Story, which (unlike task.Task) has -// no enforced state machine (story.UpdateStory is unvalidated pass-through — -// see internal/story's doc comments) — so the "only valid from REVIEW_READY" -// check is done here, not in the storage layer. -func (s *Server) handleAcceptStory(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") +// acceptStory is the shared service-layer accept-gate for a story: both +// handleAcceptStory (REST) and the accept_story chatbot MCP tool call this, +// mirroring taskops.go's shared task-operation pattern (see acceptTask) so +// the two surfaces cannot drift. +func (s *Server) acceptStory(id string, actor event.Actor) error { st, err := s.store.GetStory(id) if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) - return + return errStoryNotFound } if st.Status != "REVIEW_READY" { - writeJSON(w, http.StatusConflict, map[string]string{"error": "story cannot be accepted from status " + st.Status}) - return + return conflictf("story cannot be accepted from status %s", st.Status) } from := st.Status st.Status = "DONE" if err := s.store.UpdateStory(st); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return + return err } payload, _ := json.Marshal(struct { @@ -241,11 +241,23 @@ func (s *Server) handleAcceptStory(w http.ResponseWriter, r *http.Request) { if err := s.store.CreateEvent(&event.Event{ TaskID: id, Kind: event.KindHumanAccepted, - Actor: event.ActorUser, + Actor: actor, Payload: payload, }); err != nil { s.logger.Error("failed to record human_accepted event", "storyID", id, "error", err) } + return nil +} +// handleAcceptStory is the human accept-gate for a story: it mirrors +// handleAcceptTask's pattern exactly (validate current state, transition, +// emit an event), delegating to the shared acceptStory service-layer helper +// so the chatbot MCP accept_story tool cannot drift from this REST behavior. +func (s *Server) handleAcceptStory(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := s.acceptStory(id, event.ActorUser); err != nil { + writeOpError(w, err) + return + } writeJSON(w, http.StatusOK, map[string]string{"message": "story accepted", "story_id": id}) } diff --git a/internal/api/taskops.go b/internal/api/taskops.go index e6b6239..2b133ac 100644 --- a/internal/api/taskops.go +++ b/internal/api/taskops.go @@ -224,6 +224,9 @@ func writeOpError(w http.ResponseWriter, err error) { case errors.Is(err, errTaskNotFound): writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) return + case errors.Is(err, errStoryNotFound): + writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) + return } var ce *conflictError if errors.As(err, &ce) { |
