package api import ( "database/sql" "encoding/json" "fmt" "net/http" "os/exec" "strings" "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 from origin/main // and pushes it to origin. Idempotent: treats "already exists" as success. func createStoryBranch(localPath, branchName string) error { // Fetch latest from origin so origin/main 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)) } 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 — 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", "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 { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if stories == nil { stories = []*task.Story{} } writeJSON(w, http.StatusOK, stories) } func (s *Server) handleCreateStory(w http.ResponseWriter, r *http.Request) { var st task.Story if err := json.NewDecoder(r.Body).Decode(&st); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) return } if st.Name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } if st.ID == "" { st.ID = uuid.New().String() } if st.Status == "" { st.Status = task.StoryPending } if err := s.store.CreateStory(&st); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusCreated, st) } func (s *Server) handleGetStory(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") st, err := s.store.GetStory(id) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) return } writeJSON(w, http.StatusOK, st) } func (s *Server) handleListStoryTasks(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if _, err := s.store.GetStory(id); 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 } if tasks == nil { tasks = []*task.Task{} } writeJSON(w, http.StatusOK, tasks) } func (s *Server) handleAddTaskToStory(w http.ResponseWriter, r *http.Request) { storyID := r.PathValue("id") st, err := s.store.GetStory(storyID) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) return } _ = st var input struct { Name string `json:"name"` Description string `json:"description"` Project string `json:"project"` RepositoryURL string `json:"repository_url"` Agent task.AgentConfig `json:"agent"` Claude task.AgentConfig `json:"claude"` Timeout string `json:"timeout"` Priority string `json:"priority"` Tags []string `json:"tags"` ParentTaskID string `json:"parent_task_id"` } 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.Agent.Instructions == "" && input.Claude.Instructions != "" { input.Agent = input.Claude } existing, err := s.store.ListTasksByStory(storyID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } now := time.Now().UTC() t := &task.Task{ ID: uuid.New().String(), Name: input.Name, Description: input.Description, Project: input.Project, RepositoryURL: input.RepositoryURL, Agent: input.Agent, Priority: task.Priority(input.Priority), Tags: input.Tags, DependsOn: []string{}, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, State: task.StatePending, StoryID: storyID, ParentTaskID: input.ParentTaskID, CreatedAt: now, UpdatedAt: now, } if t.Agent.Type == "" { t.Agent.Type = "claude" } if t.Priority == "" { t.Priority = task.PriorityNormal } if t.Tags == nil { t.Tags = []string{} } if input.Timeout != "" { dur, err := time.ParseDuration(input.Timeout) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid timeout: " + err.Error()}) return } t.Timeout.Duration = dur } // Auto-wire depends_on: new task depends on the last existing task (sorted ASC by created_at). if len(existing) > 0 { lastTask := existing[len(existing)-1] t.DependsOn = []string{lastTask.ID} } if err := s.store.CreateTask(t); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusCreated, t) } func (s *Server) handleUpdateStoryStatus(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") st, err := s.store.GetStory(id) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) return } var input struct { Status task.StoryState `json:"status"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) return } if !task.ValidStoryTransition(st.Status, input.Status) { writeJSON(w, http.StatusConflict, map[string]string{ "error": "invalid story status transition from " + string(st.Status) + " to " + string(input.Status), }) return } if err := s.store.UpdateStoryStatus(id, input.Status); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } 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, Project: input.ProjectID, 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, Project: input.ProjectID, 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, }) } // 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) }