summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/stories.go42
-rw-r--r--internal/api/taskops.go3
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) {