diff options
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/changestats.go | 15 | ||||
| -rw-r--r-- | internal/api/deployment.go | 4 | ||||
| -rw-r--r-- | internal/api/drops.go | 165 | ||||
| -rw-r--r-- | internal/api/drops_test.go | 159 | ||||
| -rw-r--r-- | internal/api/elaborate.go | 205 | ||||
| -rw-r--r-- | internal/api/elaborate_test.go | 11 | ||||
| -rw-r--r-- | internal/api/executions.go | 42 | ||||
| -rw-r--r-- | internal/api/projects.go | 71 | ||||
| -rw-r--r-- | internal/api/push.go | 120 | ||||
| -rw-r--r-- | internal/api/push_test.go | 159 | ||||
| -rw-r--r-- | internal/api/server.go | 131 | ||||
| -rw-r--r-- | internal/api/server_test.go | 464 | ||||
| -rw-r--r-- | internal/api/stories.go | 378 | ||||
| -rw-r--r-- | internal/api/stories_test.go | 351 | ||||
| -rw-r--r-- | internal/api/task_view.go | 47 | ||||
| -rw-r--r-- | internal/api/webhook.go | 74 | ||||
| -rw-r--r-- | internal/api/webhook_test.go | 114 |
17 files changed, 2390 insertions, 120 deletions
diff --git a/internal/api/changestats.go b/internal/api/changestats.go deleted file mode 100644 index 4f18f7f..0000000 --- a/internal/api/changestats.go +++ /dev/null @@ -1,15 +0,0 @@ -package api - -import "github.com/thepeterstone/claudomator/internal/task" - -// parseChangestatFromOutput delegates to task.ParseChangestatFromOutput. -// Kept as a package-local wrapper for use within the api package. -func parseChangestatFromOutput(output string) *task.Changestats { - return task.ParseChangestatFromOutput(output) -} - -// parseChangestatFromFile delegates to task.ParseChangestatFromFile. -// Kept as a package-local wrapper for use within the api package. -func parseChangestatFromFile(path string) *task.Changestats { - return task.ParseChangestatFromFile(path) -} diff --git a/internal/api/deployment.go b/internal/api/deployment.go index d927545..8972fe2 100644 --- a/internal/api/deployment.go +++ b/internal/api/deployment.go @@ -23,7 +23,7 @@ func (s *Server) handleGetDeploymentStatus(w http.ResponseWriter, r *http.Reques if err != nil { if err == sql.ErrNoRows { // No execution yet — return status with no fix commits. - status := deployment.Check(nil, tk.Agent.ProjectDir) + status := deployment.Check(nil, tk.RepositoryURL) writeJSON(w, http.StatusOK, status) return } @@ -31,6 +31,6 @@ func (s *Server) handleGetDeploymentStatus(w http.ResponseWriter, r *http.Reques return } - status := deployment.Check(exec.Commits, tk.Agent.ProjectDir) + status := deployment.Check(exec.Commits, tk.RepositoryURL) writeJSON(w, http.StatusOK, status) } diff --git a/internal/api/drops.go b/internal/api/drops.go new file mode 100644 index 0000000..a5000f1 --- /dev/null +++ b/internal/api/drops.go @@ -0,0 +1,165 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// handleListDrops returns a JSON array of files in the drops directory. +func (s *Server) handleListDrops(w http.ResponseWriter, r *http.Request) { + if s.dropsDir == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"}) + return + } + + entries, err := os.ReadDir(s.dropsDir) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, []map[string]interface{}{}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list drops"}) + return + } + + type fileEntry struct { + Name string `json:"name"` + Size int64 `json:"size"` + Modified time.Time `json:"modified"` + } + files := []fileEntry{} + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, fileEntry{ + Name: e.Name(), + Size: info.Size(), + Modified: info.ModTime().UTC(), + }) + } + writeJSON(w, http.StatusOK, files) +} + +// handleGetDrop serves a file from the drops directory as an attachment. +func (s *Server) handleGetDrop(w http.ResponseWriter, r *http.Request) { + if s.dropsDir == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"}) + return + } + + filename := r.PathValue("filename") + if strings.Contains(filename, "/") || strings.Contains(filename, "..") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + + path := filepath.Join(s.dropsDir, filepath.Clean(filename)) + // Extra safety: ensure the resolved path is still inside dropsDir. + if !strings.HasPrefix(path, s.dropsDir) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to open file"}) + return + } + defer f.Close() + + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("Content-Type", "application/octet-stream") + io.Copy(w, f) //nolint:errcheck +} + +// handlePostDrop accepts a file upload (multipart/form-data or raw body with ?filename=). +func (s *Server) handlePostDrop(w http.ResponseWriter, r *http.Request) { + if s.dropsDir == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"}) + return + } + + if err := os.MkdirAll(s.dropsDir, 0700); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create drops directory"}) + return + } + + ct := r.Header.Get("Content-Type") + if strings.Contains(ct, "multipart/form-data") { + s.handleMultipartDrop(w, r) + return + } + + // Raw body with ?filename= query param. + filename := r.URL.Query().Get("filename") + if filename == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "filename query param required for raw upload"}) + return + } + if strings.Contains(filename, "/") || strings.Contains(filename, "..") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + path := filepath.Join(s.dropsDir, filename) + data, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read body"}) + return + } + if err := os.WriteFile(path, data, 0600); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save file"}) + return + } + writeJSON(w, http.StatusCreated, map[string]interface{}{"name": filename, "size": len(data)}) +} + +func (s *Server) handleMultipartDrop(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { // 32 MB limit + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse multipart form: " + err.Error()}) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'file' field: " + err.Error()}) + return + } + defer file.Close() + + filename := filepath.Base(header.Filename) + if filename == "" || filename == "." { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + + path := filepath.Join(s.dropsDir, filename) + dst, err := os.Create(path) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create file"}) + return + } + defer dst.Close() + + n, err := io.Copy(dst, file) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to write file"}) + return + } + + writeJSON(w, http.StatusCreated, map[string]interface{}{"name": filename, "size": n}) +} + diff --git a/internal/api/drops_test.go b/internal/api/drops_test.go new file mode 100644 index 0000000..ab67489 --- /dev/null +++ b/internal/api/drops_test.go @@ -0,0 +1,159 @@ +package api + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func testServerWithDrops(t *testing.T) (*Server, string) { + t.Helper() + srv, _ := testServer(t) + dropsDir := t.TempDir() + srv.SetDropsDir(dropsDir) + return srv, dropsDir +} + +func TestHandleListDrops_Empty(t *testing.T) { + srv, _ := testServerWithDrops(t) + + req := httptest.NewRequest("GET", "/api/drops", nil) + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + + var files []map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&files); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(files) != 0 { + t.Errorf("want empty list, got %d entries", len(files)) + } +} + +func TestHandleListDrops_WithFile(t *testing.T) { + srv, dropsDir := testServerWithDrops(t) + + // Create a file in the drops dir. + if err := os.WriteFile(filepath.Join(dropsDir, "hello.txt"), []byte("world"), 0600); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/drops", nil) + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var files []map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&files); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(files) != 1 { + t.Fatalf("want 1 file, got %d", len(files)) + } + if files[0]["name"] != "hello.txt" { + t.Errorf("name: want %q, got %v", "hello.txt", files[0]["name"]) + } +} + +func TestHandlePostDrop_Multipart(t *testing.T) { + srv, dropsDir := testServerWithDrops(t) + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + fw, err := w.CreateFormFile("file", "test.txt") + if err != nil { + t.Fatal(err) + } + fw.Write([]byte("hello world")) //nolint:errcheck + w.Close() + + req := httptest.NewRequest("POST", "/api/drops", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["name"] != "test.txt" { + t.Errorf("name: want %q, got %v", "test.txt", resp["name"]) + } + + // Verify file was created on disk. + content, err := os.ReadFile(filepath.Join(dropsDir, "test.txt")) + if err != nil { + t.Fatalf("reading uploaded file: %v", err) + } + if string(content) != "hello world" { + t.Errorf("content: want %q, got %q", "hello world", content) + } +} + +func TestHandleGetDrop_Download(t *testing.T) { + srv, dropsDir := testServerWithDrops(t) + + if err := os.WriteFile(filepath.Join(dropsDir, "download.txt"), []byte("download me"), 0600); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/drops/download.txt", nil) + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + + cd := rec.Header().Get("Content-Disposition") + if !strings.Contains(cd, "attachment") { + t.Errorf("want Content-Disposition: attachment, got %q", cd) + } + if rec.Body.String() != "download me" { + t.Errorf("body: want %q, got %q", "download me", rec.Body.String()) + } +} + +func TestHandleGetDrop_PathTraversal(t *testing.T) { + srv, _ := testServerWithDrops(t) + + // Attempt path traversal — should be rejected. + req := httptest.NewRequest("GET", "/api/drops/..%2Fetc%2Fpasswd", nil) + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + // The Go net/http router will handle %2F-encoded slashes as literal characters, + // so the filename becomes "../etc/passwd". Our handler should reject it. + if rec.Code == http.StatusOK { + t.Error("expected non-200 for path traversal attempt") + } +} + +func TestHandleGetDrop_NotFound(t *testing.T) { + srv, _ := testServerWithDrops(t) + + req := httptest.NewRequest("GET", "/api/drops/notexist.txt", nil) + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rec.Code) + } +} diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go index 30095c8..8676b36 100644 --- a/internal/api/elaborate.go +++ b/internal/api/elaborate.go @@ -172,7 +172,7 @@ func readProjectContext(workDir string) string { return "" } var sb strings.Builder - for _, filename := range []string{"CLAUDE.md", "SESSION_STATE.md"} { + for _, filename := range []string{"CLAUDE.md", ".agent/worklog.md"} { path := filepath.Join(workDir, filename) if data, err := os.ReadFile(path); err == nil { if sb.Len() > 0 { @@ -303,6 +303,197 @@ func (s *Server) elaborateWithGemini(ctx context.Context, workDir, fullPrompt st return &result, nil } +// 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) +} + func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { if s.elaborateLimiter != nil && !s.elaborateLimiter.allow(realIP(r)) { writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"}) @@ -310,7 +501,9 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { } var input struct { - Prompt string `json:"prompt"` + Prompt string `json:"prompt"` + ProjectID string `json:"project_id"` + // project_dir kept for backward compat; project_id takes precedence ProjectDir string `json:"project_dir"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { @@ -323,11 +516,15 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { } workDir := s.workDir - if input.ProjectDir != "" { + if input.ProjectID != "" { + if proj, err := s.store.GetProject(input.ProjectID); err == nil { + workDir = proj.LocalPath + } + } else if input.ProjectDir != "" { workDir = input.ProjectDir } - if input.ProjectDir != "" { + if workDir != s.workDir { go s.appendRawNarrative(workDir, input.Prompt) } diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go index 0b5c706..32cec3c 100644 --- a/internal/api/elaborate_test.go +++ b/internal/api/elaborate_test.go @@ -350,6 +350,8 @@ func TestElaborateTask_InvalidJSONFromClaude(t *testing.T) { // Fake Claude returns something that is not valid JSON. srv.elaborateCmdPath = createFakeClaude(t, "not valid json at all", 0) + // Ensure Gemini fallback also fails so we get the expected 502. + srv.geminiBinPath = "/nonexistent/gemini" body := `{"prompt":"do something"}` req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) @@ -388,14 +390,17 @@ func createFakeClaudeCapturingArgs(t *testing.T, output string, exitCode int, ar func TestElaborateTask_WithProjectContext(t *testing.T) { srv, _ := testServer(t) - // Create a temporary workspace with CLAUDE.md and SESSION_STATE.md + // Create a temporary workspace with CLAUDE.md and .agent/worklog.md workDir := t.TempDir() claudeContent := "Claude context info" sessionContent := "Session state info" if err := os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(claudeContent), 0600); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(workDir, "SESSION_STATE.md"), []byte(sessionContent), 0600); err != nil { + if err := os.MkdirAll(filepath.Join(workDir, ".agent"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workDir, ".agent", "worklog.md"), []byte(sessionContent), 0600); err != nil { t.Fatal(err) } @@ -436,7 +441,7 @@ func TestElaborateTask_WithProjectContext(t *testing.T) { t.Errorf("expected arguments to contain CLAUDE.md content, got %s", argsStr) } if !strings.Contains(argsStr, sessionContent) { - t.Errorf("expected arguments to contain SESSION_STATE.md content, got %s", argsStr) + t.Errorf("expected arguments to contain .agent/worklog.md content, got %s", argsStr) } } diff --git a/internal/api/executions.go b/internal/api/executions.go index 114425e..4d8ba9c 100644 --- a/internal/api/executions.go +++ b/internal/api/executions.go @@ -86,6 +86,48 @@ func (s *Server) handleGetExecutionLog(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, content) } +// handleGetDashboardStats returns pre-aggregated error, throughput, and billing stats. +// GET /api/stats?window=7d|24h +func (s *Server) handleGetDashboardStats(w http.ResponseWriter, r *http.Request) { + window := 7 * 24 * time.Hour + if r.URL.Query().Get("window") == "24h" { + window = 24 * time.Hour + } + since := time.Now().Add(-window) + + stats, err := s.store.QueryDashboardStats(since) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, stats) +} + +// handleGetAgentStatus returns the current status of all agents and recent rate-limit events. +// GET /api/agents/status?since=<RFC3339> +func (s *Server) handleGetAgentStatus(w http.ResponseWriter, r *http.Request) { + since := time.Now().Add(-24 * time.Hour) + if v := r.URL.Query().Get("since"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + since = t + } + } + + events, err := s.store.ListAgentEvents(since) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if events == nil { + events = []storage.AgentEvent{} + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "agents": s.pool.AgentStatuses(), + "events": events, + }) +} + // tailLogFile reads the last n lines from the file at path. func tailLogFile(path string, n int) (string, error) { data, err := os.ReadFile(path) diff --git a/internal/api/projects.go b/internal/api/projects.go new file mode 100644 index 0000000..d3dbbf9 --- /dev/null +++ b/internal/api/projects.go @@ -0,0 +1,71 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/task" +) + +func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { + projects, err := s.store.ListProjects() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if projects == nil { + projects = []*task.Project{} + } + writeJSON(w, http.StatusOK, projects) +} + +func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { + var p task.Project + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if p.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + if p.ID == "" { + p.ID = uuid.New().String() + } + if err := s.store.CreateProject(&p); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, p) +} + +func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + p, err := s.store.GetProject(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) + return + } + writeJSON(w, http.StatusOK, p) +} + +func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + existing, err := s.store.GetProject(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) + return + } + if err := json.NewDecoder(r.Body).Decode(existing); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + existing.ID = id // ensure ID cannot be changed via body + if err := s.store.UpdateProject(existing); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, existing) +} + diff --git a/internal/api/push.go b/internal/api/push.go new file mode 100644 index 0000000..dde5441 --- /dev/null +++ b/internal/api/push.go @@ -0,0 +1,120 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/storage" + webui "github.com/thepeterstone/claudomator/web" +) + +// pushSubscriptionStore is the minimal interface needed by push handlers. +type pushSubscriptionStore interface { + SavePushSubscription(sub storage.PushSubscription) error + DeletePushSubscription(endpoint string) error + ListPushSubscriptions() ([]storage.PushSubscription, error) +} + +// SetVAPIDConfig configures VAPID keys and email for web push notifications. +func (s *Server) SetVAPIDConfig(pub, priv, email string) { + s.vapidPublicKey = pub + s.vapidPrivateKey = priv + s.vapidEmail = email +} + +// SetPushStore configures the push subscription store. +func (s *Server) SetPushStore(store pushSubscriptionStore) { + s.pushStore = store +} + +// SetDropsDir configures the file drop directory. +func (s *Server) SetDropsDir(dir string) { + s.dropsDir = dir +} + +// handleGetVAPIDKey returns the VAPID public key for client-side push subscription. +func (s *Server) handleGetVAPIDKey(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"public_key": s.vapidPublicKey}) +} + +// handleServiceWorker serves sw.js with a Service-Worker-Allowed: / header so +// the SW can control the full origin even though it is registered from /api/push/sw.js. +func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) { + data, err := webui.Files.ReadFile("sw.js") + if err != nil { + http.Error(w, "service worker not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("Service-Worker-Allowed", "/") + w.WriteHeader(http.StatusOK) + w.Write(data) +} + +// handlePushSubscribe saves a new push subscription. +func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) { + var input struct { + Endpoint string `json:"endpoint"` + Keys struct { + P256DH string `json:"p256dh"` + Auth string `json:"auth"` + } `json:"keys"` + } + 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.Endpoint == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endpoint is required"}) + return + } + if input.Keys.P256DH == "" || input.Keys.Auth == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "keys.p256dh and keys.auth are required"}) + return + } + + sub := storage.PushSubscription{ + ID: uuid.New().String(), + Endpoint: input.Endpoint, + P256DHKey: input.Keys.P256DH, + AuthKey: input.Keys.Auth, + } + + store := s.pushStore + if store == nil { + store = s.store + } + + if err := store.SavePushSubscription(sub); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"id": sub.ID}) +} + +// handlePushUnsubscribe deletes a push subscription. +func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) { + var input struct { + Endpoint string `json:"endpoint"` + } + 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.Endpoint == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endpoint is required"}) + return + } + + store := s.pushStore + if store == nil { + store = s.store + } + + if err := store.DeletePushSubscription(input.Endpoint); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/push_test.go b/internal/api/push_test.go new file mode 100644 index 0000000..dfd5a3a --- /dev/null +++ b/internal/api/push_test.go @@ -0,0 +1,159 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/thepeterstone/claudomator/internal/storage" +) + +// mockPushStore implements pushSubscriptionStore for testing. +type mockPushStore struct { + mu sync.Mutex + subs []storage.PushSubscription +} + +func (m *mockPushStore) SavePushSubscription(sub storage.PushSubscription) error { + m.mu.Lock() + defer m.mu.Unlock() + // Upsert by endpoint. + for i, s := range m.subs { + if s.Endpoint == sub.Endpoint { + m.subs[i] = sub + return nil + } + } + m.subs = append(m.subs, sub) + return nil +} + +func (m *mockPushStore) DeletePushSubscription(endpoint string) error { + m.mu.Lock() + defer m.mu.Unlock() + filtered := m.subs[:0] + for _, s := range m.subs { + if s.Endpoint != endpoint { + filtered = append(filtered, s) + } + } + m.subs = filtered + return nil +} + +func (m *mockPushStore) ListPushSubscriptions() ([]storage.PushSubscription, error) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]storage.PushSubscription, len(m.subs)) + copy(cp, m.subs) + return cp, nil +} + +func testServerWithPush(t *testing.T) (*Server, *mockPushStore) { + t.Helper() + srv, _ := testServer(t) + ps := &mockPushStore{} + srv.SetVAPIDConfig("testpub", "testpriv", "mailto:test@example.com") + srv.SetPushStore(ps) + return srv, ps +} + +func TestHandleGetVAPIDKey(t *testing.T) { + srv, _ := testServerWithPush(t) + + req := httptest.NewRequest("GET", "/api/push/vapid-key", nil) + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + + var resp map[string]string + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["public_key"] != "testpub" { + t.Errorf("want public_key %q, got %q", "testpub", resp["public_key"]) + } +} + +func TestHandlePushSubscribe_CreatesSub(t *testing.T) { + srv, ps := testServerWithPush(t) + + body := `{"endpoint":"https://push.example.com/sub1","keys":{"p256dh":"key1","auth":"auth1"}}` + req := httptest.NewRequest("POST", "/api/push/subscribe", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String()) + } + + subs, _ := ps.ListPushSubscriptions() + if len(subs) != 1 { + t.Fatalf("want 1 subscription, got %d", len(subs)) + } + if subs[0].Endpoint != "https://push.example.com/sub1" { + t.Errorf("endpoint: want %q, got %q", "https://push.example.com/sub1", subs[0].Endpoint) + } +} + +func TestHandlePushSubscribe_MissingEndpoint(t *testing.T) { + srv, _ := testServerWithPush(t) + + body := `{"keys":{"p256dh":"key1","auth":"auth1"}}` + req := httptest.NewRequest("POST", "/api/push/subscribe", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } +} + +func TestHandlePushUnsubscribe_DeletesSub(t *testing.T) { + srv, ps := testServerWithPush(t) + + // Add a subscription. + ps.SavePushSubscription(storage.PushSubscription{ //nolint:errcheck + ID: "sub-1", + Endpoint: "https://push.example.com/todelete", + P256DHKey: "key", + AuthKey: "auth", + }) + + body := `{"endpoint":"https://push.example.com/todelete"}` + req := httptest.NewRequest("DELETE", "/api/push/subscribe", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String()) + } + + subs, _ := ps.ListPushSubscriptions() + if len(subs) != 0 { + t.Errorf("want 0 subscriptions after delete, got %d", len(subs)) + } +} + +func TestHandlePushUnsubscribe_MissingEndpoint(t *testing.T) { + srv, _ := testServerWithPush(t) + + body := `{}` + req := httptest.NewRequest("DELETE", "/api/push/subscribe", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 33048e4..28cfe4a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -16,6 +16,7 @@ import ( "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" + "github.com/thepeterstone/claudomator/internal/version" webui "github.com/thepeterstone/claudomator/web" "github.com/google/uuid" ) @@ -31,6 +32,7 @@ type questionStore interface { // Server provides the REST API and WebSocket endpoint for Claudomator. type Server struct { + ctx context.Context // server lifecycle context; used for pool submissions store *storage.DB logStore logStore // injectable for tests; defaults to store taskLogStore taskLogStore // injectable for tests; defaults to store @@ -51,7 +53,12 @@ type Server struct { elaborateLimiter *ipRateLimiter // per-IP rate limiter for elaborate/validate endpoints webhookSecret string // HMAC-SHA256 secret for GitHub webhook validation projects []config.Project // configured projects for webhook routing - llm *llm.Client // optional local LLM client; when set, elaboration prefers it + vapidPublicKey string + vapidPrivateKey string + vapidEmail string + pushStore pushSubscriptionStore + dropsDir string + llm *llm.Client } // SetAPIToken configures a bearer token that must be supplied to access the API. @@ -59,6 +66,12 @@ func (s *Server) SetAPIToken(token string) { s.apiToken = token } +// SetContext replaces the server's lifecycle context used for pool submissions. +// Call this before StartHub to tie task submissions to the server's shutdown signal. +func (s *Server) SetContext(ctx context.Context) { + s.ctx = ctx +} + // SetNotifier configures a notifier that is called on every task completion. func (s *Server) SetNotifier(n notify.Notifier) { s.notifier = n @@ -75,6 +88,9 @@ func (s *Server) SetWorkspaceRoot(path string) { s.workspaceRoot = path } +// Pool returns the executor pool, for graceful shutdown by the caller. +func (s *Server) Pool() *executor.Pool { return s.pool } + // SetLLM wires a local OpenAI-compatible LLM client for use by elaboration // (and future internal helpers). When non-nil, elaboration will prefer it // over the Claude CLI; on failure it falls back to claude → gemini. @@ -82,9 +98,11 @@ func (s *Server) SetLLM(c *llm.Client) { s.llm = c } + func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server { wd, _ := os.Getwd() s := &Server{ + ctx: context.Background(), store: store, logStore: store, taskLogStore: store, @@ -125,6 +143,8 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/tasks/{id}/subtasks", s.handleListSubtasks) s.mux.HandleFunc("GET /api/tasks/{id}/executions", s.handleListExecutions) s.mux.HandleFunc("GET /api/executions", s.handleListRecentExecutions) + s.mux.HandleFunc("GET /api/stats", s.handleGetDashboardStats) + s.mux.HandleFunc("GET /api/agents/status", s.handleGetAgentStatus) s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution) s.mux.HandleFunc("GET /api/executions/{id}/log", s.handleGetExecutionLog) s.mux.HandleFunc("GET /api/tasks/{id}/logs/stream", s.handleStreamTaskLogs) @@ -135,29 +155,53 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/ws", s.handleWebSocket) s.mux.HandleFunc("GET /api/workspaces", s.handleListWorkspaces) s.mux.HandleFunc("GET /api/tasks/{id}/deployment-status", s.handleGetDeploymentStatus) + s.mux.HandleFunc("GET /api/projects", s.handleListProjects) + 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) + s.mux.HandleFunc("GET /api/push/vapid-key", s.handleGetVAPIDKey) + s.mux.HandleFunc("GET /api/push/sw.js", s.handleServiceWorker) + s.mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe) + s.mux.HandleFunc("DELETE /api/push/subscribe", s.handlePushUnsubscribe) + s.mux.HandleFunc("GET /api/drops", s.handleListDrops) + s.mux.HandleFunc("GET /api/drops/{filename}", s.handleGetDrop) + s.mux.HandleFunc("POST /api/drops", s.handlePostDrop) s.mux.Handle("GET /", http.FileServerFS(webui.Files)) } -// forwardResults listens on the executor pool's result channel and broadcasts via WebSocket. +// forwardResults listens on the executor pool's result and started channels and broadcasts via WebSocket. func (s *Server) forwardResults() { + go func() { + for taskID := range s.pool.Started() { + event := map[string]interface{}{ + "type": "task_started", + "task_id": taskID, + "timestamp": time.Now().UTC(), + } + data, _ := json.Marshal(event) + s.hub.Broadcast(data) + } + }() for result := range s.pool.Results() { s.processResult(result) } } // processResult broadcasts a task completion event via WebSocket and calls the notifier if set. -// It also parses git diff stats from the execution stdout log and persists them. func (s *Server) processResult(result *executor.Result) { - if result.Execution.StdoutPath != "" { - if stats := parseChangestatFromFile(result.Execution.StdoutPath); stats != nil { - if err := s.store.UpdateExecutionChangestats(result.Execution.ID, stats); err != nil { - s.logger.Error("failed to store changestats", "execID", result.Execution.ID, "error", err) - } - } - } - event := map[string]interface{}{ "type": "task_completed", "task_id": result.TaskID, @@ -318,7 +362,7 @@ func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) { ResumeAnswer: input.Answer, SandboxDir: latest.SandboxDir, } - if err := s.pool.SubmitResume(context.Background(), tk, resumeExec); err != nil { + if err := s.pool.SubmitResume(s.ctx, tk, resumeExec); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return } @@ -363,7 +407,7 @@ func (s *Server) handleResumeTimedOutTask(w http.ResponseWriter, r *http.Request ResumeSessionID: latest.SessionID, ResumeAnswer: resumeMsg, } - if err := s.pool.SubmitResume(context.Background(), tk, resumeExec); err != nil { + if err := s.pool.SubmitResume(s.ctx, tk, resumeExec); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return } @@ -415,11 +459,17 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"version": version.Version()}) +} + func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { var input struct { Name string `json:"name"` Description string `json:"description"` ElaborationInput string `json:"elaboration_input"` + Project string `json:"project"` + RepositoryURL string `json:"repository_url"` Agent task.AgentConfig `json:"agent"` Claude task.AgentConfig `json:"claude"` // legacy alias Timeout string `json:"timeout"` @@ -443,6 +493,8 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { Name: input.Name, Description: input.Description, ElaborationInput: input.ElaborationInput, + Project: input.Project, + RepositoryURL: input.RepositoryURL, Agent: input.Agent, Priority: task.Priority(input.Priority), Tags: input.Tags, @@ -453,6 +505,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { UpdatedAt: now, ParentTaskID: input.ParentTaskID, } + if t.Agent.Type == "" { t.Agent.Type = "claude" } @@ -523,7 +576,11 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) { if tasks == nil { tasks = []*task.Task{} } - writeJSON(w, http.StatusOK, tasks) + views := make([]*taskView, len(tasks)) + for i, tk := range tasks { + views[i] = s.enrichTask(tk) + } + writeJSON(w, http.StatusOK, views) } func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { @@ -533,8 +590,43 @@ func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) return } - writeJSON(w, http.StatusOK, t) + writeJSON(w, http.StatusOK, s.enrichTask(t)) +} +// retryableDepStates are the states from which a dependency can be retried +// when cascading a retry from a dependent task. +var retryableDepStates = map[task.State]bool{ + task.StateFailed: true, + task.StateTimedOut: true, + task.StateCancelled: true, + task.StateBudgetExceeded: true, +} + +// cascadeRetryDeps resets any dependency (recursively) that is in a retryable +// terminal state, and submits it to the pool. This ensures that retrying a +// CANCELLED task that was blocked by a failed dep will also restart that dep. +func (s *Server) cascadeRetryDeps(ctx context.Context, t *task.Task) { + for _, depID := range t.DependsOn { + dep, err := s.store.GetTask(depID) + if err != nil { + s.logger.Warn("cascadeRetryDeps: dep not found", "depID", depID) + continue + } + if !retryableDepStates[dep.State] { + continue + } + // Recursively cascade first (depth-first so root deps go first). + s.cascadeRetryDeps(ctx, dep) + reset, err := s.store.ResetTaskForRetry(depID) + if err != nil { + s.logger.Warn("cascadeRetryDeps: reset failed", "depID", depID, "error", err) + continue + } + if submitErr := s.pool.Submit(ctx, reset); submitErr != nil { + s.logger.Warn("cascadeRetryDeps: submit failed", "depID", depID, "error", submitErr) + } + } } + func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") agentParam := r.URL.Query().Get("agent") // Use a different name to avoid confusion @@ -583,7 +675,11 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { } // The task `t` now has the correct agent configuration. - if err := s.pool.Submit(context.Background(), t); err != nil { + // 6. Cascade-retry any deps that are in a terminal failure state so the + // task isn't immediately re-cancelled by checkDepsReady. + s.cascadeRetryDeps(r.Context(), originalTask) + + if err := s.pool.Submit(s.ctx, t); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": fmt.Sprintf("executor pool: %v", err)}) return } @@ -611,6 +707,9 @@ func (s *Server) handleAcceptTask(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } + if t.StoryID != "" { + go s.pool.CheckStoryCompletion(r.Context(), t.StoryID) + } writeJSON(w, http.StatusOK, map[string]string{"message": "task accepted", "task_id": id}) } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 2139e36..2530d55 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -16,6 +16,7 @@ import ( "context" + "github.com/google/uuid" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/storage" @@ -89,6 +90,9 @@ func testServerWithRunner(t *testing.T, runner executor.Runner) (*Server, *stora t.Cleanup(func() { store.Close() }) logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + if mr, ok := runner.(*mockRunner); ok { + mr.logDir = t.TempDir() + } runners := map[string]executor.Runner{ "claude": runner, "gemini": runner, @@ -99,11 +103,39 @@ func testServerWithRunner(t *testing.T, runner executor.Runner) (*Server, *stora } type mockRunner struct { - err error - sleep time.Duration + err error + sleep time.Duration + logDir string + onRun func(*task.Task, *storage.Execution) error +} + +func (m *mockRunner) ExecLogDir(execID string) string { + if m.logDir == "" { + return "" + } + return filepath.Join(m.logDir, execID) } -func (m *mockRunner) Run(ctx context.Context, _ *task.Task, _ *storage.Execution) error { +func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { + if e.ID == "" { + e.ID = uuid.New().String() + } + if m.logDir != "" { + dir := m.ExecLogDir(e.ID) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + e.StdoutPath = filepath.Join(dir, "stdout.log") + e.StderrPath = filepath.Join(dir, "stderr.log") + e.ArtifactDir = dir + // Create an empty file at least + os.WriteFile(e.StdoutPath, []byte(""), 0644) + } + if m.onRun != nil { + if err := m.onRun(t, e); err != nil { + return err + } + } if m.sleep > 0 { select { case <-time.After(m.sleep): @@ -143,41 +175,26 @@ func testServerWithGeminiMockRunner(t *testing.T) (*Server, *storage.DB) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) - // Create the mock gemini binary script. Use single-quoted heredoc so - // bash does not try to evaluate the literal backticks as command - // substitution. - mockBinDir := t.TempDir() - mockGeminiPath := filepath.Join(mockBinDir, "mock-gemini-binary.sh") - mockScriptContent := `#!/bin/bash -cat <<'EOF' -` + "```json" + ` -{"type":"content_block_start","content_block":{"text":"Hello, Gemini!","type":"text"}} -{"type":"content_block_delta","content_block":{"text":" How are you?"}} -{"type":"content_block_end"} -{"type":"message_delta","message":{"role":"model"}} -{"type":"message_end"} -` + "```" + ` -EOF -exit 0 -` - if err := os.WriteFile(mockGeminiPath, []byte(mockScriptContent), 0755); err != nil { - t.Fatalf("writing mock gemini script: %v", err) - } - - // Configure GeminiRunner to use the mock script. - geminiRunner := &executor.GeminiRunner{ - BinaryPath: mockGeminiPath, - Logger: logger, - LogDir: t.TempDir(), // Ensure log directory is temporary for test - APIURL: "http://localhost:8080", // Placeholder, not used by this mock + mr := &mockRunner{ + logDir: t.TempDir(), + onRun: func(t *task.Task, e *storage.Execution) error { + lines := []string{ + `{"type":"content_block_start","content_block":{"text":"Hello, Gemini!","type":"text"}}`, + `{"type":"content_block_delta","content_block":{"text":" How are you?"}}`, + `{"type":"content_block_end"}`, + `{"type":"message_delta","message":{"role":"model"}}`, + `{"type":"message_end"}`, + } + return os.WriteFile(e.StdoutPath, []byte(strings.Join(lines, "\n")), 0644) + }, } runners := map[string]executor.Runner{ - "claude": &mockRunner{}, // Keep mock for claude to not interfere - "gemini": geminiRunner, + "claude": mr, + "gemini": mr, } pool := executor.NewPool(2, runners, store, logger) - srv := NewServer(store, pool, logger, "claude", "gemini") // Pass original binary paths + srv := NewServer(store, pool, logger, "claude", "gemini") return srv, store } @@ -200,6 +217,7 @@ func TestGeminiLogs_ParsedCorrectly(t *testing.T) { tk := createTestTask(t, srv, `{ "name": "Gemini Log Test Task", "description": "Test Gemini log parsing", + "repository_url": "https://github.com/user/repo", "agent": { "type": "gemini", "instructions": "generate some output", @@ -346,6 +364,7 @@ func TestCreateTask_Success(t *testing.T) { payload := `{ "name": "API Task", "description": "Created via API", + "repository_url": "https://github.com/user/repo", "agent": { "type": "claude", "instructions": "do the thing", @@ -399,6 +418,50 @@ func TestCreateTask_ValidationFailure(t *testing.T) { } } +func TestProject_RoundTrip(t *testing.T) { + srv, _ := testServer(t) + + payload := `{ + "name": "Project Task", + "project": "test-project", + "repository_url": "https://github.com/user/repo", + "agent": { + "type": "claude", + "instructions": "do the thing", + "model": "sonnet" + } + }` + req := httptest.NewRequest("POST", "/api/tasks", bytes.NewBufferString(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("create: want 201, got %d; body: %s", w.Code, w.Body.String()) + } + + var created task.Task + json.NewDecoder(w.Body).Decode(&created) + if created.Project != "test-project" { + t.Errorf("create response: project want 'test-project', got %q", created.Project) + } + + // GET the task and verify project is persisted + req2 := httptest.NewRequest("GET", "/api/tasks/"+created.ID, nil) + w2 := httptest.NewRecorder() + srv.Handler().ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("get: want 200, got %d; body: %s", w2.Code, w2.Body.String()) + } + + var fetched task.Task + json.NewDecoder(w2.Body).Decode(&fetched) + if fetched.Project != "test-project" { + t.Errorf("get response: project want 'test-project', got %q", fetched.Project) + } +} + func TestListTasks_Empty(t *testing.T) { srv, _ := testServer(t) @@ -436,6 +499,7 @@ func TestListTasks_WithTasks(t *testing.T) { for i := 0; i < 3; i++ { tk := &task.Task{ ID: fmt.Sprintf("lt-%d", i), Name: fmt.Sprintf("T%d", i), + RepositoryURL: "https://github.com/user/repo", Agent: task.AgentConfig{Type: "claude", Instructions: "x"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, Tags: []string{}, DependsOn: []string{}, State: task.StatePending, @@ -473,6 +537,7 @@ func createTaskWithState(t *testing.T, store *storage.DB, id string, state task. tk := &task.Task{ ID: id, Name: "test-task-" + id, + RepositoryURL: "https://github.com/user/repo", Agent: task.AgentConfig{Type: "claude", Instructions: "do something"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, @@ -851,6 +916,7 @@ func TestRunTask_ManualRunIgnoresRetryLimit(t *testing.T) { tk := &task.Task{ ID: "retry-limit-manual", Name: "Retry Limit Task", + RepositoryURL: "https://github.com/user/repo", Agent: task.AgentConfig{Instructions: "do something"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, @@ -888,6 +954,7 @@ func TestRunTask_WithinRetryLimit_Returns202(t *testing.T) { tk := &task.Task{ ID: "retry-within-1", Name: "Retry Within Task", + RepositoryURL: "https://github.com/user/repo", Agent: task.AgentConfig{Instructions: "do something"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 3, Backoff: "linear"}, @@ -935,7 +1002,7 @@ func TestDeleteTask_Success(t *testing.T) { srv, store := testServer(t) // Create a task to delete. - created := createTestTask(t, srv, `{"name":"Delete Me","agent":{"type":"claude","instructions":"x","model":"sonnet"}}`) + created := createTestTask(t, srv, `{"name":"Delete Me","repository_url":"https://github.com/user/repo","agent":{"type":"claude","instructions":"x","model":"sonnet"}}`) req := httptest.NewRequest("DELETE", "/api/tasks/"+created.ID, nil) w := httptest.NewRecorder() @@ -970,6 +1037,7 @@ func TestDeleteTask_RunningTaskRejected(t *testing.T) { tk := &task.Task{ ID: "running-task-del", Name: "Running Task", + RepositoryURL: "https://github.com/user/repo", Agent: task.AgentConfig{Instructions: "x", Model: "sonnet"}, Priority: task.PriorityNormal, Tags: []string{}, @@ -1524,6 +1592,7 @@ func TestRunTask_AgentTimesOut_TaskSetToTimedOut(t *testing.T) { tk := &task.Task{ ID: "async-timeout-1", Name: "timeout-test", + RepositoryURL: "https://github.com/user/repo", Agent: task.AgentConfig{Type: "claude", Instructions: "do something"}, Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, @@ -1581,34 +1650,31 @@ func TestRunTask_AgentCancelled_TaskSetToCancelled(t *testing.T) { } } -// TestGetTask_IncludesChangestats verifies that after processResult parses git diff stats -// from the execution stdout log, they appear in the execution history response. +// TestGetTask_IncludesChangestats verifies that changestats stored on an execution +// are returned correctly by GET /api/tasks/{id}/executions. func TestGetTask_IncludesChangestats(t *testing.T) { srv, store := testServer(t) tk := createTaskWithState(t, store, "cs-task-1", task.StateCompleted) - // Write a stdout log with a git diff --stat summary line. - dir := t.TempDir() - stdoutPath := filepath.Join(dir, "stdout.log") - logContent := "Agent output line 1\n3 files changed, 50 insertions(+), 10 deletions(-)\nAgent output line 2\n" - if err := os.WriteFile(stdoutPath, []byte(logContent), 0600); err != nil { - t.Fatal(err) - } - exec := &storage.Execution{ - ID: "cs-exec-1", - TaskID: tk.ID, - StartTime: time.Now().UTC(), - EndTime: time.Now().UTC().Add(time.Minute), - Status: "COMPLETED", - StdoutPath: stdoutPath, + ID: "cs-exec-1", + TaskID: tk.ID, + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(time.Minute), + Status: "COMPLETED", } if err := store.CreateExecution(exec); err != nil { t.Fatal(err) } - // processResult should parse changestats from the stdout log and store them. + // Pool stores changestats after execution; simulate by calling UpdateExecutionChangestats directly. + cs := &task.Changestats{FilesChanged: 3, LinesAdded: 50, LinesRemoved: 10} + if err := store.UpdateExecutionChangestats(exec.ID, cs); err != nil { + t.Fatal(err) + } + + // processResult broadcasts but does NOT parse changestats (that's the pool's job). result := &executor.Result{ TaskID: tk.ID, Execution: exec, @@ -1782,3 +1848,299 @@ func TestDeploymentStatus_NotFound(t *testing.T) { t.Fatalf("want 404, got %d", w.Code) } } + +// TestListTasks_ReadyTask_IncludesDeploymentStatus verifies that GET /api/tasks +// returns a deployment_status field for READY tasks containing deployed_commit, +// fix_commits, and includes_fix. +func TestListTasks_ReadyTask_IncludesDeploymentStatus(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "enrich-list-ready-1", task.StateReady) + exec := &storage.Execution{ + ID: "enrich-list-exec-1", + TaskID: tk.ID, + StartTime: time.Now(), + EndTime: time.Now(), + Status: "COMPLETED", + Commits: []task.GitCommit{{Hash: "aabbcc", Message: "fix: list test"}}, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/tasks", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var tasks []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil { + t.Fatalf("decode: %v", err) + } + + var found map[string]interface{} + for _, tsk := range tasks { + if tsk["id"] == tk.ID { + found = tsk + break + } + } + if found == nil { + t.Fatalf("task %q not found in list response", tk.ID) + } + + ds, ok := found["deployment_status"].(map[string]interface{}) + if !ok { + t.Fatalf("READY task missing deployment_status field; got: %v", found["deployment_status"]) + } + if _, ok := ds["deployed_commit"]; !ok { + t.Error("deployment_status missing deployed_commit") + } + if _, ok := ds["includes_fix"]; !ok { + t.Error("deployment_status missing includes_fix") + } +} + +// TestGetTask_ReadyTask_IncludesDeploymentStatus verifies that GET /api/tasks/{id} +// returns a deployment_status field for a READY task. +func TestGetTask_ReadyTask_IncludesDeploymentStatus(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "enrich-get-ready-1", task.StateReady) + exec := &storage.Execution{ + ID: "enrich-get-exec-1", + TaskID: tk.ID, + StartTime: time.Now(), + EndTime: time.Now(), + Status: "COMPLETED", + Commits: []task.GitCommit{{Hash: "ddeeff", Message: "fix: get test"}}, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + ds, ok := resp["deployment_status"].(map[string]interface{}) + if !ok { + t.Fatalf("READY task GET response missing deployment_status; got: %v", resp["deployment_status"]) + } + if _, ok := ds["deployed_commit"]; !ok { + t.Error("deployment_status missing deployed_commit") + } + if _, ok := ds["includes_fix"]; !ok { + t.Error("deployment_status missing includes_fix") + } +} + +// TestListTasks_NonReadyTask_OmitsDeploymentStatus verifies that non-READY tasks +// (e.g. PENDING) do not include a deployment_status field. +func TestListTasks_NonReadyTask_OmitsDeploymentStatus(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "enrich-list-pending-1", task.StatePending) + + req := httptest.NewRequest("GET", "/api/tasks", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + + var tasks []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil { + t.Fatalf("decode: %v", err) + } + + var found map[string]interface{} + for _, tsk := range tasks { + if tsk["id"] == tk.ID { + found = tsk + break + } + } + if found == nil { + t.Fatalf("task %q not found in list", tk.ID) + } + + if _, ok := found["deployment_status"]; ok { + t.Error("PENDING task should not include deployment_status field") + } +} + +func TestProjects_CRUD(t *testing.T) { + srv, _ := testServer(t) + + // Create + body := `{"name":"testproj","local_path":"/workspace/testproj","type":"web"}` + req := httptest.NewRequest("POST", "/api/projects", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("POST /api/projects: want 201, got %d; body: %s", w.Code, w.Body.String()) + } + var created map[string]interface{} + json.NewDecoder(w.Body).Decode(&created) + id, _ := created["id"].(string) + if id == "" { + t.Fatal("created project has no id") + } + + // Get + req = httptest.NewRequest("GET", "/api/projects/"+id, nil) + w = httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /api/projects/%s: want 200, got %d", id, w.Code) + } + + // List + req = httptest.NewRequest("GET", "/api/projects", nil) + w = httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /api/projects: want 200, got %d", w.Code) + } + var list []interface{} + json.NewDecoder(w.Body).Decode(&list) + if len(list) == 0 { + t.Error("expected at least one project in list") + } +} + +func TestHandleRunTask_CascadesRetryToFailedDeps(t *testing.T) { + // Use a blocking runner so tasks stay QUEUED long enough to assert state. + block := make(chan struct{}) + t.Cleanup(func() { close(block) }) + srv, store := testServerWithRunner(t, &mockRunner{onRun: func(*task.Task, *storage.Execution) error { + <-block + return nil + }}) + + now := time.Now().UTC() + + // Task A: the dependency, in FAILED state. + taskA := &task.Task{ + ID: "cascade-dep-a", + Name: "Dep A", + State: task.StateFailed, + DependsOn: []string{}, + Priority: task.PriorityNormal, + Tags: []string{}, + Agent: task.AgentConfig{Type: "claude", Instructions: "do A"}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.CreateTask(taskA); err != nil { + t.Fatalf("CreateTask A: %v", err) + } + + // Task B: depends on A, in CANCELLED state (was cancelled because A failed). + taskB := &task.Task{ + ID: "cascade-task-b", + Name: "Task B", + State: task.StateCancelled, + DependsOn: []string{taskA.ID}, + Priority: task.PriorityNormal, + Tags: []string{}, + Agent: task.AgentConfig{Type: "claude", Instructions: "do B"}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.CreateTask(taskB); err != nil { + t.Fatalf("CreateTask B: %v", err) + } + + // Run task B — should cascade-retry dep A. + req := httptest.NewRequest("POST", "/api/tasks/cascade-task-b/run", nil) + w := httptest.NewRecorder() + srv.mux.ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String()) + } + + // Dep A should now be QUEUED. + a, err := store.GetTask(taskA.ID) + if err != nil { + t.Fatalf("GetTask A: %v", err) + } + if a.State != task.StateQueued { + t.Errorf("dep A: want QUEUED after cascade, got %s", a.State) + } + + // Task B itself should be QUEUED. + b, err := store.GetTask(taskB.ID) + if err != nil { + t.Fatalf("GetTask B: %v", err) + } + if b.State != task.StateQueued { + t.Errorf("task B: want QUEUED, got %s", b.State) + } +} + +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 new file mode 100644 index 0000000..fa10ccd --- /dev/null +++ b/internal/api/stories.go @@ -0,0 +1,378 @@ +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 new file mode 100644 index 0000000..f43ad86 --- /dev/null +++ b/internal/api/stories_test.go @@ -0,0 +1,351 @@ +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/task_view.go b/internal/api/task_view.go new file mode 100644 index 0000000..6a4b58e --- /dev/null +++ b/internal/api/task_view.go @@ -0,0 +1,47 @@ +package api + +import ( + "database/sql" + + "github.com/thepeterstone/claudomator/internal/deployment" + "github.com/thepeterstone/claudomator/internal/task" +) + +// taskView wraps a task with computed fields that are derived from execution +// history and deployment state. It is used as the JSON response type for task +// list and get endpoints so that callers receive enriched data in one request. +type taskView struct { + *task.Task + Changestats *task.Changestats `json:"changestats,omitempty"` + DeploymentStatus *deployment.Status `json:"deployment_status,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` +} + +var failedStates = map[task.State]bool{ + task.StateFailed: true, + task.StateBudgetExceeded: true, + task.StateTimedOut: true, +} + +// enrichTask fetches the latest execution for the given task and attaches +// changestats, deployment_status, and error_msg fields. +func (s *Server) enrichTask(tk *task.Task) *taskView { + view := &taskView{Task: tk} + + exec, err := s.store.GetLatestExecution(tk.ID) + if err != nil { + if err == sql.ErrNoRows && tk.State == task.StateReady { + view.DeploymentStatus = deployment.Check(nil, tk.RepositoryURL) + } + return view + } + + if failedStates[tk.State] && exec.ErrorMsg != "" { + view.ErrorMsg = exec.ErrorMsg + } + if tk.State == task.StateReady { + view.Changestats = exec.Changestats + view.DeploymentStatus = deployment.Check(exec.Commits, tk.RepositoryURL) + } + return view +} diff --git a/internal/api/webhook.go b/internal/api/webhook.go index 9437f7d..3af4cc8 100644 --- a/internal/api/webhook.go +++ b/internal/api/webhook.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "path/filepath" "strings" @@ -18,17 +19,26 @@ import ( "github.com/thepeterstone/claudomator/internal/task" ) +// prRef is a minimal pull request entry used to extract branch names. +type prRef struct { + Head struct { + Ref string `json:"ref"` + } `json:"head"` +} + // checkRunPayload is the GitHub check_run webhook payload. type checkRunPayload struct { Action string `json:"action"` CheckRun struct { - Name string `json:"name"` - Conclusion string `json:"conclusion"` - HTMLURL string `json:"html_url"` - HeadSHA string `json:"head_sha"` - CheckSuite struct { + Name string `json:"name"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` + DetailsURL string `json:"details_url"` + HeadSHA string `json:"head_sha"` + CheckSuite struct { HeadBranch string `json:"head_branch"` } `json:"check_suite"` + PullRequests []prRef `json:"pull_requests"` } `json:"check_run"` Repository struct { Name string `json:"name"` @@ -40,11 +50,12 @@ type checkRunPayload struct { type workflowRunPayload struct { Action string `json:"action"` WorkflowRun struct { - Name string `json:"name"` - Conclusion string `json:"conclusion"` - HTMLURL string `json:"html_url"` - HeadSHA string `json:"head_sha"` - HeadBranch string `json:"head_branch"` + Name string `json:"name"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` + HeadSHA string `json:"head_sha"` + HeadBranch string `json:"head_branch"` + PullRequests []prRef `json:"pull_requests"` } `json:"workflow_run"` Repository struct { Name string `json:"name"` @@ -98,6 +109,7 @@ func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) { } eventType := r.Header.Get("X-GitHub-Event") + slog.Info("github webhook received", "event", eventType, "bytes", len(body)) switch eventType { case "check_run": s.handleCheckRunEvent(w, body) @@ -118,13 +130,22 @@ func (s *Server) handleCheckRunEvent(w http.ResponseWriter, body []byte) { w.WriteHeader(http.StatusNoContent) return } + branch := p.CheckRun.CheckSuite.HeadBranch + if branch == "" && len(p.CheckRun.PullRequests) > 0 { + branch = p.CheckRun.PullRequests[0].Head.Ref + } + htmlURL := p.CheckRun.HTMLURL + if htmlURL == "" { + htmlURL = p.CheckRun.DetailsURL + } + slog.Info("check_run webhook", "repo", p.Repository.FullName, "conclusion", p.CheckRun.Conclusion, "branch", branch, "html_url", htmlURL) s.createCIFailureTask(w, p.Repository.Name, p.Repository.FullName, - p.CheckRun.CheckSuite.HeadBranch, + branch, p.CheckRun.HeadSHA, p.CheckRun.Name, - p.CheckRun.HTMLURL, + htmlURL, ) } @@ -142,10 +163,15 @@ func (s *Server) handleWorkflowRunEvent(w http.ResponseWriter, body []byte) { w.WriteHeader(http.StatusNoContent) return } + branch := p.WorkflowRun.HeadBranch + if branch == "" && len(p.WorkflowRun.PullRequests) > 0 { + branch = p.WorkflowRun.PullRequests[0].Head.Ref + } + slog.Info("workflow_run webhook", "repo", p.Repository.FullName, "conclusion", p.WorkflowRun.Conclusion, "branch", branch, "html_url", p.WorkflowRun.HTMLURL) s.createCIFailureTask(w, p.Repository.Name, p.Repository.FullName, - p.WorkflowRun.HeadBranch, + branch, p.WorkflowRun.HeadSHA, p.WorkflowRun.Name, p.WorkflowRun.HTMLURL, @@ -155,6 +181,10 @@ func (s *Server) handleWorkflowRunEvent(w http.ResponseWriter, body []byte) { func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, branch, sha, checkName, htmlURL string) { project := matchProject(s.projects, repoName) + if htmlURL == "" && fullName != "" && sha != "" { + htmlURL = fmt.Sprintf("https://github.com/%s/commit/%s", fullName, sha) + } + fallback := fmt.Sprintf( "A CI failure has been detected and requires investigation.\n\n"+ "Repository: %s\n"+ @@ -188,20 +218,22 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, Name: fmt.Sprintf("Fix CI failure: %s on %s", checkName, branch), Agent: task.AgentConfig{ Type: "claude", + Model: "sonnet", Instructions: instructions, MaxBudgetUSD: 3.0, AllowedTools: []string{"Read", "Edit", "Bash", "Glob", "Grep"}, }, - Priority: task.PriorityNormal, - Tags: []string{"ci", "auto"}, - DependsOn: []string{}, - Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, - State: task.StatePending, - CreatedAt: now, - UpdatedAt: now, + Priority: task.PriorityNormal, + Tags: []string{"ci", "auto"}, + DependsOn: []string{}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + RepositoryURL: fmt.Sprintf("https://github.com/%s.git", fullName), } if project != nil { - t.Agent.ProjectDir = project.Dir + t.Project = project.Name } if err := s.store.CreateTask(t); err != nil { diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go index 8b0599a..967b62b 100644 --- a/internal/api/webhook_test.go +++ b/internal/api/webhook_test.go @@ -124,8 +124,8 @@ func TestGitHubWebhook_CheckRunFailure_CreatesTask(t *testing.T) { if !strings.Contains(tk.Name, "main") { t.Errorf("task name %q does not contain branch", tk.Name) } - if tk.Agent.ProjectDir != "/workspace/myrepo" { - t.Errorf("task project dir = %q, want /workspace/myrepo", tk.Agent.ProjectDir) + if tk.RepositoryURL != "https://github.com/owner/myrepo.git" { + t.Errorf("task repository url = %q, want https://github.com/owner/myrepo.git", tk.RepositoryURL) } if !contains(tk.Tags, "ci") || !contains(tk.Tags, "auto") { t.Errorf("task tags %v missing expected ci/auto tags", tk.Tags) @@ -237,6 +237,104 @@ func TestGitHubWebhook_NoSecretConfigured_SkipsHMACCheck(t *testing.T) { } } +func TestGitHubWebhook_CreatesTask_WithDefaultModel(t *testing.T) { + srv, store := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + tk, err := store.GetTask(resp["task_id"]) + if err != nil { + t.Fatalf("task not found: %v", err) + } + if tk.Agent.Model == "" { + t.Error("expected model to be set, got empty string") + } +} + +const checkRunNullBranchPayload = `{ + "action": "completed", + "check_run": { + "name": "CI Build", + "conclusion": "failure", + "html_url": "", + "details_url": "https://github.com/owner/myrepo/actions/runs/999/jobs/123", + "head_sha": "abc123def", + "check_suite": { + "head_branch": null + }, + "pull_requests": [{"head": {"ref": "feature/my-branch"}}] + }, + "repository": { + "name": "myrepo", + "full_name": "owner/myrepo" + } +}` + +func TestGitHubWebhook_CheckRunNullBranch_UsesPRRefAndDetailsURL(t *testing.T) { + srv, store := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + w := webhookPost(t, srv, "check_run", checkRunNullBranchPayload, "") + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + tk, err := store.GetTask(resp["task_id"]) + if err != nil { + t.Fatalf("task not found: %v", err) + } + if !strings.Contains(tk.Name, "feature/my-branch") { + t.Errorf("task name %q should contain PR branch", tk.Name) + } + if !strings.Contains(tk.Agent.Instructions, "actions/runs/999") { + t.Errorf("instructions should contain details_url fallback, got: %s", tk.Agent.Instructions) + } +} + +const workflowRunNullBranchPayload = `{ + "action": "completed", + "workflow_run": { + "name": "CI Pipeline", + "conclusion": "failure", + "html_url": "", + "head_sha": "def456abc", + "head_branch": null, + "pull_requests": [{"head": {"ref": "fix/something"}}] + }, + "repository": { + "name": "myrepo", + "full_name": "owner/myrepo" + } +}` + +func TestGitHubWebhook_WorkflowRunNullBranch_UsesPRRef(t *testing.T) { + srv, store := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + w := webhookPost(t, srv, "workflow_run", workflowRunNullBranchPayload, "") + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + tk, err := store.GetTask(resp["task_id"]) + if err != nil { + t.Fatalf("task not found: %v", err) + } + if !strings.Contains(tk.Name, "fix/something") { + t.Errorf("task name %q should contain PR branch", tk.Name) + } +} + func TestGitHubWebhook_UnknownEvent_Returns204(t *testing.T) { srv, _ := testServer(t) @@ -277,14 +375,14 @@ func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) { if err != nil { t.Fatalf("task not found: %v", err) } - if tk.Agent.ProjectDir != "/workspace/someapp" { - t.Errorf("expected fallback to /workspace/someapp, got %q", tk.Agent.ProjectDir) + if tk.RepositoryURL != "https://github.com/owner/myrepo.git" { + t.Errorf("expected fallback repository url, got %q", tk.RepositoryURL) } } -func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithoutProjectDir(t *testing.T) { +func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithGitHubURL(t *testing.T) { srv, store := testServer(t) - // No projects configured — task should still be created, just no project dir set. + // No projects configured — task should still be created with the GitHub remote URL. w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") @@ -297,8 +395,8 @@ func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithoutProjectDir(t *test if err != nil { t.Fatalf("task not found: %v", err) } - if tk.Agent.ProjectDir != "" { - t.Errorf("expected empty project dir, got %q", tk.Agent.ProjectDir) + if tk.RepositoryURL == "" { + t.Error("expected non-empty repository_url from GitHub webhook payload") } } |
