summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/agentmcp_endpoint_test.go4
-rw-r--r--internal/api/elaborate.go244
-rw-r--r--internal/api/server.go15
-rw-r--r--internal/api/server_test.go51
-rw-r--r--internal/api/stories.go378
-rw-r--r--internal/api/stories_test.go351
-rw-r--r--internal/api/taskops.go3
-rw-r--r--internal/api/validate.go18
-rw-r--r--internal/api/webhook_test.go16
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) {