summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/server.go1
-rw-r--r--internal/api/server_test.go48
-rw-r--r--internal/api/stories.go11
-rw-r--r--internal/executor/executor.go22
4 files changed, 82 insertions, 0 deletions
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.