From b2e77009c55ba0f07bb9ff904d9f2f6cc9ff0ee2 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Mon, 23 Mar 2026 07:12:08 +0000 Subject: feat: Phase 4 — story-aware execution, branch clone, story completion check, deployment status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContainerRunner: add Store field; clone with --reference when story has a local project path; checkout story branch after clone; push to story branch instead of HEAD - executor.Store interface: add GetStory, ListTasksByStory, UpdateStoryStatus - Pool.handleRunResult: trigger checkStoryCompletion when a story task succeeds - Pool.checkStoryCompletion: transitions story to SHIPPABLE when all tasks done - serve.go: wire Store into each ContainerRunner - stories.go: update createStoryBranch to fetch+checkout from origin/master base; add GET /api/stories/{id}/deployment-status endpoint - server.go: register deployment-status route - Tests: TestPool_CheckStoryCompletion_AllComplete/PartialComplete, TestHandleStoryDeploymentStatus Co-Authored-By: Claude Sonnet 4.6 --- internal/api/server.go | 1 + internal/api/stories.go | 65 +++++++++++++++++++++++++++++++++++++++++--- internal/api/stories_test.go | 45 ++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) (limited to 'internal/api') diff --git a/internal/api/server.go b/internal/api/server.go index bb23f46..fc9bd63 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -147,6 +147,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("GET /api/stories/{id}/deployment-status", s.handleStoryDeploymentStatus) s.mux.HandleFunc("GET /api/health", s.handleHealth) s.mux.HandleFunc("GET /api/version", s.handleVersion) s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook) diff --git a/internal/api/stories.go b/internal/api/stories.go index 459d0db..640bb0e 100644 --- a/internal/api/stories.go +++ b/internal/api/stories.go @@ -1,6 +1,7 @@ package api import ( + "database/sql" "encoding/json" "fmt" "net/http" @@ -9,22 +10,35 @@ import ( "time" "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/deployment" "github.com/thepeterstone/claudomator/internal/task" ) -// createStoryBranch creates a new git branch in localPath and pushes it to origin. +// createStoryBranch creates a new git branch in localPath from origin/master (or main) +// and pushes it to origin. Idempotent: treats "already exists" as success. func createStoryBranch(localPath, branchName string) error { - out, err := exec.Command("git", "-C", localPath, "checkout", "-b", branchName).CombinedOutput() + // Fetch latest from origin so origin/master is up to date. + if out, err := exec.Command("git", "-C", localPath, "fetch", "origin").CombinedOutput(); err != nil { + return fmt.Errorf("git fetch: %w (output: %s)", err, string(out)) + } + // Try to create branch from origin/master; fall back to origin/main. + base := "origin/master" + if out, err := exec.Command("git", "-C", localPath, "rev-parse", "--verify", "origin/master").CombinedOutput(); err != nil { + if strings.Contains(string(out), "fatal") || err != nil { + base = "origin/main" + } + } + out, err := exec.Command("git", "-C", localPath, "checkout", "-b", branchName, base).CombinedOutput() if err != nil { if !strings.Contains(string(out), "already exists") { return fmt.Errorf("git checkout -b: %w (output: %s)", err, string(out)) } - // Branch exists; switch to it. + // Branch exists; switch to it — idempotent. if out2, err2 := exec.Command("git", "-C", localPath, "checkout", branchName).CombinedOutput(); err2 != nil { return fmt.Errorf("git checkout: %w (output: %s)", err2, string(out2)) } } - if out, err := exec.Command("git", "-C", localPath, "push", "-u", "origin", branchName).CombinedOutput(); err != nil { + if out, err := exec.Command("git", "-C", localPath, "push", "origin", branchName).CombinedOutput(); err != nil { return fmt.Errorf("git push: %w (output: %s)", err, string(out)) } return nil @@ -306,3 +320,46 @@ func (s *Server) handleApproveStory(w http.ResponseWriter, r *http.Request) { "task_ids": taskIDs, }) } + +// 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) { + id := r.PathValue("id") + + story, err := s.store.GetStory(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) + return + } + + tasks, err := s.store.ListTasksByStory(id) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + // Collect all commits from the latest execution of each task. + var allCommits []task.GitCommit + for _, t := range tasks { + exec, err := s.store.GetLatestExecution(t.ID) + if err != nil { + if err == sql.ErrNoRows { + continue + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + allCommits = append(allCommits, exec.Commits...) + } + + // Determine project remote URL for the deployment check. + projectRemoteURL := "" + if story.ProjectID != "" { + if proj, err := s.store.GetProject(story.ProjectID); err == nil { + projectRemoteURL = proj.RemoteURL + } + } + + status := deployment.Check(allCommits, projectRemoteURL) + writeJSON(w, http.StatusOK, status) +} diff --git a/internal/api/stories_test.go b/internal/api/stories_test.go index cf522e1..17bea07 100644 --- a/internal/api/stories_test.go +++ b/internal/api/stories_test.go @@ -7,7 +7,9 @@ import ( "net/http/httptest" "strings" "testing" + "time" + "github.com/thepeterstone/claudomator/internal/deployment" "github.com/thepeterstone/claudomator/internal/task" ) @@ -202,3 +204,46 @@ func TestHandleStoryApprove_WiresDepends(t *testing.T) { t.Errorf("task3.DependsOn: want [%s], got %v", task2.ID, task3.DependsOn) } } + +func TestHandleStoryDeploymentStatus(t *testing.T) { + srv, store := testServer(t) + + // Create a story. + now := time.Now().UTC() + story := &task.Story{ + ID: "deploy-story-1", + Name: "Deploy Status Story", + Status: task.StoryInProgress, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.CreateStory(story); err != nil { + t.Fatalf("CreateStory: %v", err) + } + + // Request deployment status — no tasks yet. + req := httptest.NewRequest("GET", "/api/stories/deploy-story-1/deployment-status", nil) + w := httptest.NewRecorder() + srv.mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var status deployment.Status + if err := json.NewDecoder(w.Body).Decode(&status); err != nil { + t.Fatalf("decode: %v", err) + } + // No tasks → no commits → IncludesFix = false (nothing to check). + if status.IncludesFix { + t.Error("expected IncludesFix=false when no commits") + } + + // 404 for unknown story. + req2 := httptest.NewRequest("GET", "/api/stories/nonexistent/deployment-status", nil) + w2 := httptest.NewRecorder() + srv.mux.ServeHTTP(w2, req2) + if w2.Code != http.StatusNotFound { + t.Errorf("expected 404 for unknown story, got %d", w2.Code) + } +} -- cgit v1.2.3