diff options
| author | Claudomator Agent <agent@claudomator> | 2026-06-05 09:21:32 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator> | 2026-06-05 09:21:32 +0000 |
| commit | 0eb0b79396663c4901597becc6857a4cd795c58e (patch) | |
| tree | 3532c82aa3f7c6deeed03f143d1c360d7cf6cc47 /internal/api | |
| parent | fa3ed5220a28f326d443726233c37e1a7df0ced5 (diff) | |
chore: remove stories/checker backend dead code
Remove the dead stories/checker backend tracked as design debt in CLAUDE.md:
- Delete internal/api/stories.go, stories_test.go, elaborate.go
- Delete internal/task/story.go, story_test.go
- Remove story/checker columns from storage schema and all CRUD methods
- Remove checkStoryCompletion, spawnCheckerTask, triggerStoryDeploy,
createValidationTask, checkValidationResult, ShipStory, CheckStoryCompletion
from executor; simplify handleRunResult
- Remove story/checker fields from task.Task (StoryID, AcceptanceCriteria,
CheckerForTaskID, CheckerReport)
- Remove story routes and geminiBinPath from API server
- Move claudeJSONResult/extractJSON from elaborate.go to validate.go
- Rename ensureStoryBranch -> ensureBranch in ContainerRunner
- Fix git identity in bare-repo tests; fix initial-branch to use main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/agentmcp_endpoint_test.go | 4 | ||||
| -rw-r--r-- | internal/api/elaborate.go | 244 | ||||
| -rw-r--r-- | internal/api/server.go | 15 | ||||
| -rw-r--r-- | internal/api/server_test.go | 51 | ||||
| -rw-r--r-- | internal/api/stories.go | 378 | ||||
| -rw-r--r-- | internal/api/stories_test.go | 351 | ||||
| -rw-r--r-- | internal/api/taskops.go | 3 | ||||
| -rw-r--r-- | internal/api/validate.go | 18 | ||||
| -rw-r--r-- | internal/api/webhook_test.go | 16 |
9 files changed, 36 insertions, 1044 deletions
diff --git a/internal/api/agentmcp_endpoint_test.go b/internal/api/agentmcp_endpoint_test.go index b2f77d3..b9cee3a 100644 --- a/internal/api/agentmcp_endpoint_test.go +++ b/internal/api/agentmcp_endpoint_test.go @@ -52,7 +52,7 @@ func TestServer_MountsAgentMCPEndpoint(t *testing.T) { fc := &fakeAgentChannel{} token, _ := reg.Mint(fc) - srv := NewServer(store, pool, reg, logger, "claude", "gemini") + srv := NewServer(store, pool, reg, logger, "claude") ts := httptest.NewServer(srv.Handler()) defer ts.Close() @@ -87,7 +87,7 @@ func TestServer_NoRegistry_NoMCPRoute(t *testing.T) { pool := executor.NewPool(1, map[string]executor.Runner{}, store, logger) // nil registry → /mcp must not be mounted. - srv := NewServer(store, pool, nil, logger, "claude", "gemini") + srv := NewServer(store, pool, nil, logger, "claude") ts := httptest.NewServer(srv.Handler()) defer ts.Close() diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go deleted file mode 100644 index e8930a6..0000000 --- a/internal/api/elaborate.go +++ /dev/null @@ -1,244 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "os/exec" - "strings" - "time" -) - -const elaborateTimeout = 30 * time.Second - -// claudeJSONResult is the top-level object returned by `claude --output-format json`. -type claudeJSONResult struct { - Result string `json:"result"` - IsError bool `json:"is_error"` -} - -// geminiJSONResult is the top-level object returned by `gemini --output-format json`. -type geminiJSONResult struct { - Response string `json:"response"` -} - -// extractJSON returns the first top-level JSON object found in s, stripping -// surrounding prose or markdown code fences the model may have added. -func extractJSON(s string) string { - start := strings.Index(s, "{") - end := strings.LastIndex(s, "}") - if start == -1 || end == -1 || end < start { - return s - } - return s[start : end+1] -} - -func (s *Server) claudeBinaryPath() string { - if s.elaborateCmdPath != "" { - return s.elaborateCmdPath - } - if s.claudeBinPath != "" { - return s.claudeBinPath - } - return "claude" -} - -func (s *Server) geminiBinaryPath() string { - if s.geminiBinPath != "" { - return s.geminiBinPath - } - return "gemini" -} - -// elaboratedStorySubtask is a leaf unit within a story task. -type elaboratedStorySubtask struct { - Name string `json:"name"` - Instructions string `json:"instructions"` -} - -// elaboratedStoryTask is one independently-deployable unit in a story plan. -type elaboratedStoryTask struct { - Name string `json:"name"` - Instructions string `json:"instructions"` - AcceptanceCriteria string `json:"acceptance_criteria"` - Subtasks []elaboratedStorySubtask `json:"subtasks"` -} - -// elaboratedStoryValidation describes how to verify the story was successful. -type elaboratedStoryValidation struct { - Type string `json:"type"` - Steps []string `json:"steps"` - SuccessCriteria string `json:"success_criteria"` -} - -// elaboratedStory is the full implementation plan produced by story elaboration. -type elaboratedStory struct { - Name string `json:"name"` - BranchName string `json:"branch_name"` - Tasks []elaboratedStoryTask `json:"tasks"` - Validation elaboratedStoryValidation `json:"validation"` -} - -func buildStoryElaboratePrompt() string { - return `You are a software architect. Given a goal, analyze the codebase at /workspace and produce a structured implementation plan as JSON. - -Output ONLY valid JSON matching this schema: -{ - "name": "story name", - "branch_name": "story/kebab-case-name", - "tasks": [ - { - "name": "task name", - "instructions": "detailed instructions including file paths and what to change", - "acceptance_criteria": "specific, verifiable conditions a separate reviewer can check — e.g. 'run go test ./... and verify all pass; confirm GET /api/foo returns 200 with expected JSON shape'", - "subtasks": [ - { "name": "subtask name", "instructions": "..." } - ] - } - ], - "validation": { - "type": "build|test|smoke", - "steps": ["step1", "step2"], - "success_criteria": "what success looks like" - } -} - -Rules: -- Tasks must be independently buildable (each can be deployed alone) -- Subtasks within a task are order-dependent and run sequentially -- Instructions must include specific file paths, function names, and exact changes -- Instructions must end with: git add -A && git commit -m "..." && git push origin <branch> -- Validation should match the scope: small change = build check; new feature = smoke test -- acceptance_criteria must be concrete and verifiable by a separate agent — no vague assertions like "code looks good"` -} - -func (s *Server) elaborateStoryWithClaude(ctx context.Context, workDir, goal string) (*elaboratedStory, error) { - cmd := exec.CommandContext(ctx, s.claudeBinaryPath(), - "-p", goal, - "--system-prompt", buildStoryElaboratePrompt(), - "--output-format", "json", - "--model", "haiku", - ) - if workDir != "" { - cmd.Dir = workDir - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - - output := stdout.Bytes() - if len(output) == 0 { - if err != nil { - return nil, fmt.Errorf("claude failed: %w (stderr: %s)", err, stderr.String()) - } - return nil, fmt.Errorf("claude returned no output") - } - - var wrapper claudeJSONResult - if jerr := json.Unmarshal(output, &wrapper); jerr != nil { - return nil, fmt.Errorf("failed to parse claude JSON wrapper: %w (output: %s)", jerr, string(output)) - } - if wrapper.IsError { - return nil, fmt.Errorf("claude error: %s", wrapper.Result) - } - - var result elaboratedStory - if jerr := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); jerr != nil { - return nil, fmt.Errorf("failed to parse elaborated story JSON: %w (result: %s)", jerr, wrapper.Result) - } - return &result, nil -} - -func (s *Server) elaborateStoryWithGemini(ctx context.Context, workDir, goal string) (*elaboratedStory, error) { - combinedPrompt := fmt.Sprintf("%s\n\n%s", buildStoryElaboratePrompt(), goal) - cmd := exec.CommandContext(ctx, s.geminiBinaryPath(), - "-p", combinedPrompt, - "--output-format", "json", - "--model", "gemini-2.5-flash-lite", - ) - if workDir != "" { - cmd.Dir = workDir - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("gemini failed: %w (stderr: %s)", err, stderr.String()) - } - - var wrapper geminiJSONResult - if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil { - return nil, fmt.Errorf("failed to parse gemini JSON wrapper: %w (output: %s)", err, stdout.String()) - } - - var result elaboratedStory - if err := json.Unmarshal([]byte(extractJSON(wrapper.Response)), &result); err != nil { - return nil, fmt.Errorf("failed to parse elaborated story JSON: %w (response: %s)", err, wrapper.Response) - } - return &result, nil -} - -func (s *Server) handleElaborateStory(w http.ResponseWriter, r *http.Request) { - var input struct { - Goal string `json:"goal"` - ProjectID string `json:"project_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.Goal == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "goal is required"}) - return - } - if input.ProjectID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id is required"}) - return - } - - proj, err := s.store.GetProject(input.ProjectID) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) - return - } - - // Update git refs without modifying the working tree. - if proj.LocalPath != "" { - gitCmd := exec.Command("git", "-C", proj.LocalPath, "fetch", "origin") - if err := gitCmd.Run(); err != nil { - s.logger.Warn("story elaborate: git fetch failed", "error", err, "path", proj.LocalPath) - } - } - - ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout) - defer cancel() - - result, err := s.elaborateStoryWithClaude(ctx, proj.LocalPath, input.Goal) - if err != nil { - s.logger.Warn("story elaborate: claude failed, falling back to gemini", "error", err) - result, err = s.elaborateStoryWithGemini(ctx, proj.LocalPath, input.Goal) - if err != nil { - s.logger.Error("story elaborate: fallback gemini also failed", "error", err) - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": fmt.Sprintf("elaboration failed: %v", err), - }) - return - } - } - - if result.Name == "" { - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": "elaboration failed: missing required fields in response", - }) - return - } - - writeJSON(w, http.StatusOK, result) -} diff --git a/internal/api/server.go b/internal/api/server.go index 4af7325..35128d0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -46,8 +46,6 @@ type Server struct { logger *slog.Logger mux *http.ServeMux claudeBinPath string // path to claude binary; defaults to "claude" - geminiBinPath string // path to gemini binary; defaults to "gemini" - elaborateCmdPath string // overrides claudeBinPath; used in tests validateCmdPath string // overrides claudeBinPath for validate; used in tests scripts ScriptRegistry // optional; maps endpoint name → script path workDir string // working directory injected into elaborate system prompt @@ -113,7 +111,7 @@ func (s *Server) SetLLM(c *llm.Client) { } -func NewServer(store *storage.DB, pool *executor.Pool, registry *executor.Registry, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server { +func NewServer(store *storage.DB, pool *executor.Pool, registry *executor.Registry, logger *slog.Logger, claudeBinPath string) *Server { wd, _ := os.Getwd() s := &Server{ ctx: context.Background(), @@ -127,7 +125,6 @@ func NewServer(store *storage.DB, pool *executor.Pool, registry *executor.Regist logger: logger, mux: http.NewServeMux(), claudeBinPath: claudeBinPath, - geminiBinPath: geminiBinPath, workDir: wd, workspaceRoot: "/workspace", } @@ -175,16 +172,6 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/projects", s.handleCreateProject) s.mux.HandleFunc("GET /api/projects/{id}", s.handleGetProject) s.mux.HandleFunc("PUT /api/projects/{id}", s.handleUpdateProject) - s.mux.HandleFunc("POST /api/stories/elaborate", s.handleElaborateStory) - s.mux.HandleFunc("POST /api/stories/approve", s.handleApproveStory) - s.mux.HandleFunc("GET /api/stories", s.handleListStories) - s.mux.HandleFunc("POST /api/stories", s.handleCreateStory) - s.mux.HandleFunc("GET /api/stories/{id}", s.handleGetStory) - 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) s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 43d91b0..24e1d30 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -98,7 +98,7 @@ func testServerWithRunner(t *testing.T, runner executor.Runner) (*Server, *stora "gemini": runner, } pool := executor.NewPool(2, runners, store, logger) - srv := NewServer(store, pool, nil, logger, "claude", "gemini") + srv := NewServer(store, pool, nil, logger, "claude") return srv, store } @@ -194,7 +194,7 @@ func testServerWithGeminiMockRunner(t *testing.T) (*Server, *storage.DB) { "gemini": mr, } pool := executor.NewPool(2, runners, store, logger) - srv := NewServer(store, pool, nil, logger, "claude", "gemini") + srv := NewServer(store, pool, nil, logger, "claude") return srv, store } @@ -2069,50 +2069,3 @@ func TestHandleRunTask_CascadesRetryToFailedDeps(t *testing.T) { } } -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 deleted file mode 100644 index fa10ccd..0000000 --- a/internal/api/stories.go +++ /dev/null @@ -1,378 +0,0 @@ -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 the latest main -// and pushes it to remoteURL (the bare repo). Idempotent: treats "already exists" as success. -func createStoryBranch(localPath, branchName, remoteURL string) error { - // Fetch latest from the bare repo so we have an up-to-date base. - if out, err := exec.Command("git", "-C", localPath, "fetch", remoteURL, "main").CombinedOutput(); err != nil { - return fmt.Errorf("git fetch: %w (output: %s)", err, string(out)) - } - base := "FETCH_HEAD" - 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)) - } - } - if out, err := exec.Command("git", "-C", localPath, "push", remoteURL, 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 - } - - var repoURL string - if input.ProjectID != "" { - if proj, err := s.store.GetProject(input.ProjectID); err == nil { - repoURL = proj.RemoteURL - } - } - - 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, - RepositoryURL: repoURL, - StoryID: story.ID, - Agent: task.AgentConfig{Type: "claude", Instructions: tp.Instructions}, - AcceptanceCriteria: tp.AcceptanceCriteria, - 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, - RepositoryURL: repoURL, - 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 != "" && proj.RemoteURL != "" { - if err := createStoryBranch(proj.LocalPath, input.BranchName, proj.RemoteURL); 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, - }) -} - -// 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) { - 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 deleted file mode 100644 index f43ad86..0000000 --- a/internal/api/stories_test.go +++ /dev/null @@ -1,351 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/thepeterstone/claudomator/internal/deployment" - "github.com/thepeterstone/claudomator/internal/task" -) - -func TestCreateStory_API(t *testing.T) { - srv, _ := testServer(t) - - body := `{"name":"My Story","project_id":"proj-1"}` - req := httptest.NewRequest("POST", "/api/stories", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) - } - var st task.Story - if err := json.NewDecoder(w.Body).Decode(&st); err != nil { - t.Fatalf("decode response: %v", err) - } - if st.Name != "My Story" { - t.Errorf("Name: want 'My Story', got %q", st.Name) - } - if st.ID == "" { - t.Error("ID should be auto-generated") - } - if st.Status != task.StoryPending { - t.Errorf("Status: want PENDING, got %q", st.Status) - } -} - -func TestGetStory_API(t *testing.T) { - srv, _ := testServer(t) - - // Create a story first. - body := `{"name":"Get Me"}` - req := httptest.NewRequest("POST", "/api/stories", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - if w.Code != http.StatusCreated { - t.Fatalf("create story: expected 201, got %d", w.Code) - } - var created task.Story - json.NewDecoder(w.Body).Decode(&created) - - // Fetch it. - req2 := httptest.NewRequest("GET", "/api/stories/"+created.ID, nil) - w2 := httptest.NewRecorder() - srv.mux.ServeHTTP(w2, req2) - - if w2.Code != http.StatusOK { - t.Fatalf("get story: expected 200, got %d: %s", w2.Code, w2.Body.String()) - } - var got task.Story - if err := json.NewDecoder(w2.Body).Decode(&got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.ID != created.ID { - t.Errorf("ID: want %q, got %q", created.ID, got.ID) - } - if got.Name != "Get Me" { - t.Errorf("Name: want 'Get Me', got %q", got.Name) - } -} - -func TestAddTaskToStory_AutoWiresDependsOn(t *testing.T) { - srv, _ := testServer(t) - - // Create a story. - storyBody := `{"name":"Story For Tasks"}` - req := httptest.NewRequest("POST", "/api/stories", bytes.NewBufferString(storyBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - if w.Code != http.StatusCreated { - t.Fatalf("create story: %d %s", w.Code, w.Body.String()) - } - var story task.Story - json.NewDecoder(w.Body).Decode(&story) - - addTask := func(name string) *task.Task { - body := `{"name":"` + name + `","agent":{"type":"claude","instructions":"do it"}}` - r := httptest.NewRequest("POST", "/api/stories/"+story.ID+"/tasks", bytes.NewBufferString(body)) - r.Header.Set("Content-Type", "application/json") - wr := httptest.NewRecorder() - srv.mux.ServeHTTP(wr, r) - if wr.Code != http.StatusCreated { - t.Fatalf("add task %s: expected 201, got %d: %s", name, wr.Code, wr.Body.String()) - } - var tk task.Task - json.NewDecoder(wr.Body).Decode(&tk) - return &tk - } - - task1 := addTask("Task 1") - task2 := addTask("Task 2") - task3 := addTask("Task 3") - - // task1 has no dependencies. - if len(task1.DependsOn) != 0 { - t.Errorf("task1.DependsOn: want [], got %v", task1.DependsOn) - } - // task2 depends on task1. - if len(task2.DependsOn) != 1 || task2.DependsOn[0] != task1.ID { - t.Errorf("task2.DependsOn: want [%s], got %v", task1.ID, task2.DependsOn) - } - // task3 depends on task2. - if len(task3.DependsOn) != 1 || task3.DependsOn[0] != task2.ID { - t.Errorf("task3.DependsOn: want [%s], got %v", task2.ID, task3.DependsOn) - } -} - -func TestBuildStoryElaboratePrompt(t *testing.T) { - prompt := buildStoryElaboratePrompt() - checks := []struct { - label string - want string - }{ - {"schema: name field", `"name"`}, - {"schema: branch_name field", `"branch_name"`}, - {"schema: tasks field", `"tasks"`}, - {"schema: validation field", `"validation"`}, - {"rule: git push", "git push origin"}, - {"rule: sequential subtasks", "sequentially"}, - {"rule: specific file paths", "file paths"}, - } - for _, c := range checks { - if !strings.Contains(prompt, c.want) { - t.Errorf("%s: prompt should contain %q", c.label, c.want) - } - } -} - -func TestHandleStoryApprove_WiresDepends(t *testing.T) { - srv, _ := testServer(t) - - body := `{ - "name": "My Story", - "branch_name": "story/my-story", - "tasks": [ - {"name": "Task 1", "instructions": "do task 1", "subtasks": []}, - {"name": "Task 2", "instructions": "do task 2", "subtasks": []}, - {"name": "Task 3", "instructions": "do task 3", "subtasks": []} - ], - "validation": {"type": "build", "steps": ["go build ./..."], "success_criteria": "compiles"} - }` - req := httptest.NewRequest("POST", "/api/stories/approve", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) - } - - var resp struct { - Story task.Story `json:"story"` - TaskIDs []string `json:"task_ids"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if len(resp.TaskIDs) != 3 { - t.Fatalf("expected 3 task IDs, got %d", len(resp.TaskIDs)) - } - if resp.Story.Name != "My Story" { - t.Errorf("story name: want 'My Story', got %q", resp.Story.Name) - } - - // Verify depends_on chain via the store. - store := srv.store - task1, err := store.GetTask(resp.TaskIDs[0]) - if err != nil { - t.Fatalf("GetTask[0]: %v", err) - } - task2, err := store.GetTask(resp.TaskIDs[1]) - if err != nil { - t.Fatalf("GetTask[1]: %v", err) - } - task3, err := store.GetTask(resp.TaskIDs[2]) - if err != nil { - t.Fatalf("GetTask[2]: %v", err) - } - - if len(task1.DependsOn) != 0 { - t.Errorf("task1.DependsOn: want [], got %v", task1.DependsOn) - } - if len(task2.DependsOn) != 1 || task2.DependsOn[0] != task1.ID { - t.Errorf("task2.DependsOn: want [%s], got %v", task1.ID, task2.DependsOn) - } - if len(task3.DependsOn) != 1 || task3.DependsOn[0] != task2.ID { - t.Errorf("task3.DependsOn: want [%s], got %v", task2.ID, task3.DependsOn) - } -} - -func TestHandleStoryApprove_SetsRepositoryURL(t *testing.T) { - srv, store := testServer(t) - - proj := &task.Project{ - ID: "proj-repo", - Name: "claudomator", - RemoteURL: "/site/git.terst.org/repos/claudomator.git", - // LocalPath intentionally empty: branch creation is a non-fatal side effect, - // omitting it keeps the test fast and free of real git operations. - } - if err := store.CreateProject(proj); err != nil { - t.Fatalf("CreateProject: %v", err) - } - - body := `{ - "name": "Repo URL Story", - "branch_name": "story/repo-url", - "project_id": "proj-repo", - "tasks": [ - {"name": "Task A", "instructions": "do A", "subtasks": []}, - {"name": "Task B", "instructions": "do B", "subtasks": [ - {"name": "Sub B1", "instructions": "do B1"} - ]} - ], - "validation": {"type": "build", "steps": ["go build ./..."], "success_criteria": "ok"} - }` - req := httptest.NewRequest("POST", "/api/stories/approve", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) - } - - var resp struct { - TaskIDs []string `json:"task_ids"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode: %v", err) - } - - for _, id := range resp.TaskIDs { - tk, err := store.GetTask(id) - if err != nil { - t.Fatalf("GetTask %s: %v", id, err) - } - if tk.RepositoryURL != proj.RemoteURL { - t.Errorf("task %s RepositoryURL: want %q, got %q", tk.ID, proj.RemoteURL, tk.RepositoryURL) - } - } -} - -func TestApproveStory_AcceptanceCriteriaStored(t *testing.T) { - srv, store := testServer(t) - - proj := &task.Project{ - ID: "ac-proj", Name: "test", RemoteURL: "https://github.com/x/y", - Type: "web", DeployScript: "", - } - store.CreateProject(proj) - - body := `{ - "name": "AC Story", - "branch_name": "story/ac-test", - "project_id": "ac-proj", - "tasks": [ - { - "name": "Add feature", - "instructions": "implement the thing", - "acceptance_criteria": "run go test ./... and verify all pass", - "subtasks": [] - } - ], - "validation": {"type": "test", "steps": [], "success_criteria": "tests pass"} - }` - req := httptest.NewRequest("POST", "/api/stories/approve", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.mux.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) - } - - var resp struct { - TaskIDs []string `json:"task_ids"` - } - json.NewDecoder(w.Body).Decode(&resp) - if len(resp.TaskIDs) == 0 { - t.Fatal("expected task_ids in response") - } - - tk, err := store.GetTask(resp.TaskIDs[0]) - if err != nil { - t.Fatalf("GetTask: %v", err) - } - if tk.AcceptanceCriteria != "run go test ./... and verify all pass" { - t.Errorf("expected acceptance criteria stored on task, got %q", tk.AcceptanceCriteria) - } -} - -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) - } -} diff --git a/internal/api/taskops.go b/internal/api/taskops.go index 881babe..254d73c 100644 --- a/internal/api/taskops.go +++ b/internal/api/taskops.go @@ -128,9 +128,6 @@ func (s *Server) acceptTask(ctx context.Context, id string, actor event.Actor) e if err := s.store.UpdateTaskStateBy(id, task.StateCompleted, actor); err != nil { return err } - if t.StoryID != "" { - go s.pool.CheckStoryCompletion(ctx, t.StoryID) - } return nil } diff --git a/internal/api/validate.go b/internal/api/validate.go index 07d293c..9e91882 100644 --- a/internal/api/validate.go +++ b/internal/api/validate.go @@ -7,11 +7,29 @@ import ( "fmt" "net/http" "os/exec" + "strings" "time" ) const validateTimeout = 20 * time.Second +// claudeJSONResult is the top-level object returned by `claude --output-format json`. +type claudeJSONResult struct { + Result string `json:"result"` + IsError bool `json:"is_error"` +} + +// extractJSON returns the first top-level JSON object found in s, stripping +// surrounding prose or markdown code fences the model may have added. +func extractJSON(s string) string { + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start == -1 || end == -1 || end < start { + return s + } + return s[start : end+1] +} + const validateSystemPrompt = `You are a task instruction reviewer for Claudomator, an AI task runner that executes tasks by running Claude or Gemini as a subprocess. Analyze the given task name and instructions for clarity and completeness. diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go index 05fe82c..f0ede7c 100644 --- a/internal/api/webhook_test.go +++ b/internal/api/webhook_test.go @@ -422,11 +422,21 @@ func TestGitHubWebhook_PushEvent_SyncsLocalBareRepo(t *testing.T) { // Create a "github" source bare repo with a commit. githubBare := filepath.Join(dir, "github.git") localBare := filepath.Join(dir, "local.git") - for _, bare := range []string{githubBare, localBare} { - if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { - t.Fatalf("git init bare %s: %v\n%s", bare, err, out) + initBare := func(path string) { + t.Helper() + out, err := exec.Command("git", "init", "--bare", "--initial-branch=main", path).CombinedOutput() + if err != nil { + // Fallback for older git: init then update HEAD to main. + if out2, err2 := exec.Command("git", "init", "--bare", path).CombinedOutput(); err2 != nil { + t.Fatalf("git init bare %s: %v\n%s", path, err2, out2) + } + if out2, err2 := exec.Command("git", "-C", path, "symbolic-ref", "HEAD", "refs/heads/main").CombinedOutput(); err2 != nil { + t.Fatalf("set HEAD to main %s: %v\n%s\n%s", path, err, out, out2) + } } } + initBare(githubBare) + initBare(localBare) // Seed githubBare with a commit via a temp clone. clone := filepath.Join(dir, "clone") run := func(args ...string) { |
