From 15a46b0e8d6fc9b986bce6b17b471c4a29cc950c Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Sun, 22 Mar 2026 08:56:21 +0000 Subject: feat: Phase 5 — story elaboration endpoint, approve flow, branch creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/stories/elaborate: runs Claude/Gemini against project LocalPath to produce a structured story plan (name, branch_name, tasks, validation) - POST /api/stories/approve: creates story + sequentially-wired tasks/subtasks from the elaborate output and pushes the story branch to origin - createStoryBranch helper: git checkout -b + push -u origin - Tests: TestBuildStoryElaboratePrompt, TestHandleStoryApprove_WiresDepends Co-Authored-By: Claude Sonnet 4.6 --- internal/api/stories.go | 123 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) (limited to 'internal/api/stories.go') diff --git a/internal/api/stories.go b/internal/api/stories.go index 4b91653..459d0db 100644 --- a/internal/api/stories.go +++ b/internal/api/stories.go @@ -2,13 +2,34 @@ package api import ( "encoding/json" + "fmt" "net/http" + "os/exec" + "strings" "time" "github.com/google/uuid" "github.com/thepeterstone/claudomator/internal/task" ) +// createStoryBranch creates a new git branch in localPath and pushes it to origin. +func createStoryBranch(localPath, branchName string) error { + out, err := exec.Command("git", "-C", localPath, "checkout", "-b", branchName).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. + 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 { + return fmt.Errorf("git push: %w (output: %s)", err, string(out)) + } + return nil +} + func (s *Server) handleListStories(w http.ResponseWriter, r *http.Request) { stories, err := s.store.ListStories() if err != nil { @@ -183,3 +204,105 @@ func (s *Server) handleUpdateStoryStatus(w http.ResponseWriter, r *http.Request) } writeJSON(w, http.StatusOK, map[string]string{"message": "story status updated", "story_id": id, "status": string(input.Status)}) } + +func (s *Server) handleApproveStory(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + BranchName string `json:"branch_name"` + ProjectID string `json:"project_id"` + Tasks []elaboratedStoryTask `json:"tasks"` + Validation elaboratedStoryValidation `json:"validation"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if input.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + + validationJSON, _ := json.Marshal(input.Validation) + now := time.Now().UTC() + story := &task.Story{ + ID: uuid.New().String(), + Name: input.Name, + ProjectID: input.ProjectID, + BranchName: input.BranchName, + ValidationJSON: string(validationJSON), + Status: task.StoryPending, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.store.CreateStory(story); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + taskIDs := make([]string, 0, len(input.Tasks)) + var prevTaskID string + for _, tp := range input.Tasks { + t := &task.Task{ + ID: uuid.New().String(), + Name: tp.Name, + StoryID: story.ID, + Agent: task.AgentConfig{Type: "claude", Instructions: tp.Instructions}, + Priority: task.PriorityNormal, + Tags: []string{}, + DependsOn: []string{}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, + State: task.StatePending, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if prevTaskID != "" { + t.DependsOn = []string{prevTaskID} + } + if err := s.store.CreateTask(t); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + taskIDs = append(taskIDs, t.ID) + + var prevSubtaskID string + for _, sub := range tp.Subtasks { + st := &task.Task{ + ID: uuid.New().String(), + Name: sub.Name, + StoryID: story.ID, + ParentTaskID: t.ID, + Agent: task.AgentConfig{Type: "claude", Instructions: sub.Instructions}, + Priority: task.PriorityNormal, + Tags: []string{}, + DependsOn: []string{}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, + State: task.StatePending, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if prevSubtaskID != "" { + st.DependsOn = []string{prevSubtaskID} + } + if err := s.store.CreateTask(st); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + prevSubtaskID = st.ID + } + prevTaskID = t.ID + } + + // Create the story branch (non-fatal if it fails). + if input.BranchName != "" && input.ProjectID != "" { + if proj, err := s.store.GetProject(input.ProjectID); err == nil && proj.LocalPath != "" { + if err := createStoryBranch(proj.LocalPath, input.BranchName); err != nil { + s.logger.Warn("story approve: failed to create branch", "error", err, "branch", input.BranchName) + } + } + } + + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "story": story, + "task_ids": taskIDs, + }) +} -- cgit v1.2.3