diff options
| author | Claudomator Agent <agent@claudomator.dev> | 2026-03-23 07:12:08 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator.dev> | 2026-03-23 07:12:08 +0000 |
| commit | b2e77009c55ba0f07bb9ff904d9f2f6cc9ff0ee2 (patch) | |
| tree | fd031bba34b186ef236600bee1f9ece34fb53109 /internal/api/stories.go | |
| parent | bc62c3545bbcf3f9ccc508cdc43ce9ffdb5dfad0 (diff) | |
feat: Phase 4 — story-aware execution, branch clone, story completion check, deployment status
- 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 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/stories.go')
| -rw-r--r-- | internal/api/stories.go | 65 |
1 files changed, 61 insertions, 4 deletions
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) +} |
