From 940a5bab031bfe81cea9c90d64e6ebc804c366f9 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 4 Apr 2026 09:30:13 +0000 Subject: feat: story ship gate — explicit POST /api/stories/{id}/ship; remove auto-deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checkStoryCompletion now guards against re-running on already-SHIPPABLE stories and no longer auto-triggers triggerStoryDeploy on completion - New Pool.ShipStory method validates SHIPPABLE state then fires triggerStoryDeploy - POST /api/stories/{id}/ship route registered and handleShipStory handler added - Two new tests: 202 for SHIPPABLE story, 409 for non-SHIPPABLE story Co-Authored-By: Claude Sonnet 4.6 --- internal/api/server.go | 1 + internal/api/server_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++ internal/api/stories.go | 11 ++++++++++ internal/executor/executor.go | 22 ++++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/internal/api/server.go b/internal/api/server.go index aec1439..3bc4147 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -149,6 +149,7 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/stories/{id}/tasks", s.handleListStoryTasks) s.mux.HandleFunc("POST /api/stories/{id}/tasks", s.handleAddTaskToStory) s.mux.HandleFunc("PUT /api/stories/{id}/status", s.handleUpdateStoryStatus) + s.mux.HandleFunc("POST /api/stories/{id}/ship", s.handleShipStory) s.mux.HandleFunc("GET /api/stories/{id}/deployment-status", s.handleStoryDeploymentStatus) s.mux.HandleFunc("GET /api/health", s.handleHealth) s.mux.HandleFunc("GET /api/version", s.handleVersion) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 67a2fc4..2530d55 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -2096,3 +2096,51 @@ func TestHandleRunTask_CascadesRetryToFailedDeps(t *testing.T) { t.Errorf("task B: want QUEUED, got %s", b.State) } } + +func TestShipStory_ShippableStory_Returns202(t *testing.T) { + srv, store := testServer(t) + + proj := &task.Project{ + ID: "ship-proj-1", Name: "test", RemoteURL: "https://github.com/x/y", + Type: "web", DeployScript: "", + } + if err := store.CreateProject(proj); err != nil { + t.Fatalf("CreateProject: %v", err) + } + + story := &task.Story{ + ID: "ship-story-1", Name: "Ship Test", ProjectID: "ship-proj-1", + Status: task.StoryShippable, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + } + if err := store.CreateStory(story); err != nil { + t.Fatalf("CreateStory: %v", err) + } + + req := httptest.NewRequest("POST", "/api/stories/ship-story-1/ship", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Errorf("expected 202, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestShipStory_NonShippable_Returns409(t *testing.T) { + srv, store := testServer(t) + + story := &task.Story{ + ID: "nonship-1", Name: "Not Ready", ProjectID: "", + Status: task.StoryInProgress, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + } + if err := store.CreateStory(story); err != nil { + t.Fatalf("CreateStory: %v", err) + } + + req := httptest.NewRequest("POST", "/api/stories/nonship-1/ship", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected 409, got %d", w.Code) + } +} diff --git a/internal/api/stories.go b/internal/api/stories.go index 629ea7a..1743dbe 100644 --- a/internal/api/stories.go +++ b/internal/api/stories.go @@ -322,6 +322,17 @@ func (s *Server) handleApproveStory(w http.ResponseWriter, r *http.Request) { }) } +// handleShipStory triggers the merge + deploy for a SHIPPABLE story. +// POST /api/stories/{id}/ship +func (s *Server) handleShipStory(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := s.pool.ShipStory(r.Context(), id); err != nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusAccepted, map[string]string{"message": "story shipping initiated", "story_id": id}) +} + // handleStoryDeploymentStatus aggregates the deployment status across all tasks in a story. // GET /api/stories/{id}/deployment-status func (s *Server) handleStoryDeploymentStatus(w http.ResponseWriter, r *http.Request) { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index a60a771..ad79a84 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -546,6 +546,14 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage. // Subtasks are intentionally excluded — a parent task reaching READY/COMPLETED // already accounts for its subtasks. func (p *Pool) checkStoryCompletion(ctx context.Context, storyID string) { + story, err := p.store.GetStory(storyID) + if err != nil { + p.logger.Error("checkStoryCompletion: failed to get story", "storyID", storyID, "error", err) + return + } + if story.Status != task.StoryInProgress { + return // already SHIPPABLE or beyond — nothing to do + } tasks, err := p.store.ListTasksByStory(storyID) if err != nil { p.logger.Error("checkStoryCompletion: failed to list tasks", "storyID", storyID, "error", err) @@ -572,7 +580,21 @@ func (p *Pool) checkStoryCompletion(ctx context.Context, storyID string) { return } p.logger.Info("story transitioned to SHIPPABLE", "storyID", storyID) + // Deploy is now triggered explicitly by the human via POST /api/stories/{id}/ship. +} + +// ShipStory merges the story branch and runs the deploy script. +// Returns an error if the story is not in SHIPPABLE state. +func (p *Pool) ShipStory(ctx context.Context, storyID string) error { + story, err := p.store.GetStory(storyID) + if err != nil { + return fmt.Errorf("story not found: %w", err) + } + if story.Status != task.StoryShippable { + return fmt.Errorf("story is not SHIPPABLE (current status: %s)", story.Status) + } go p.triggerStoryDeploy(ctx, storyID) + return nil } // spawnCheckerTask creates and submits a checker task for the given completed task. -- cgit v1.2.3