diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/server.go | 10 | ||||
| -rw-r--r-- | internal/api/webhook.go | 200 | ||||
| -rw-r--r-- | internal/api/webhook_test.go | 313 | ||||
| -rw-r--r-- | internal/cli/serve.go | 1 | ||||
| -rw-r--r-- | internal/config/config.go | 28 |
5 files changed, 542 insertions, 10 deletions
diff --git a/internal/api/server.go b/internal/api/server.go index 7988c4c..8a20349 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/thepeterstone/claudomator/internal/config" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/storage" @@ -47,6 +48,8 @@ type Server struct { notifier notify.Notifier apiToken string // if non-empty, required for WebSocket (and REST) connections 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 } // SetAPIToken configures a bearer token that must be supplied to access the API. @@ -59,6 +62,12 @@ func (s *Server) SetNotifier(n notify.Notifier) { s.notifier = n } +// SetGitHubWebhookConfig configures the GitHub webhook secret and project list. +func (s *Server) SetGitHubWebhookConfig(secret string, projects []config.Project) { + s.webhookSecret = secret + s.projects = projects +} + // SetWorkspaceRoot configures the root directory used by handleListWorkspaces. func (s *Server) SetWorkspaceRoot(path string) { s.workspaceRoot = path @@ -118,6 +127,7 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/workspaces", s.handleListWorkspaces) s.mux.HandleFunc("GET /api/tasks/{id}/deployment-status", s.handleGetDeploymentStatus) s.mux.HandleFunc("GET /api/health", s.handleHealth) + s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook) s.mux.Handle("GET /", http.FileServerFS(webui.Files)) } diff --git a/internal/api/webhook.go b/internal/api/webhook.go new file mode 100644 index 0000000..8bf1676 --- /dev/null +++ b/internal/api/webhook.go @@ -0,0 +1,200 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/config" + "github.com/thepeterstone/claudomator/internal/task" +) + +// 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 { + HeadBranch string `json:"head_branch"` + } `json:"check_suite"` + } `json:"check_run"` + Repository struct { + Name string `json:"name"` + FullName string `json:"full_name"` + } `json:"repository"` +} + +// workflowRunPayload is the GitHub workflow_run webhook payload. +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"` + } `json:"workflow_run"` + Repository struct { + Name string `json:"name"` + FullName string `json:"full_name"` + } `json:"repository"` +} + +// validateGitHubSignature checks the HMAC-SHA256 signature from GitHub. +func validateGitHubSignature(sig string, body []byte, secret string) bool { + if !strings.HasPrefix(sig, "sha256=") { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(sig), []byte(expected)) +} + +// matchProject finds the best matching project for a repository name. +// Returns the single configured project as a fallback if there's only one. +func matchProject(projects []config.Project, repoName string) *config.Project { + repoLower := strings.ToLower(repoName) + for i := range projects { + p := &projects[i] + nameLower := strings.ToLower(p.Name) + dirBase := strings.ToLower(filepath.Base(p.Dir)) + if strings.Contains(nameLower, repoLower) || strings.Contains(dirBase, repoLower) { + return p + } + } + if len(projects) == 1 { + return &projects[0] + } + return nil +} + +func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + // Validate HMAC signature if a secret is configured. + if s.webhookSecret != "" { + sig := r.Header.Get("X-Hub-Signature-256") + if !validateGitHubSignature(sig, body, s.webhookSecret) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + } + + eventType := r.Header.Get("X-GitHub-Event") + switch eventType { + case "check_run": + s.handleCheckRunEvent(w, body) + case "workflow_run": + s.handleWorkflowRunEvent(w, body) + default: + w.WriteHeader(http.StatusNoContent) + } +} + +func (s *Server) handleCheckRunEvent(w http.ResponseWriter, body []byte) { + var p checkRunPayload + if err := json.Unmarshal(body, &p); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if p.Action != "completed" || p.CheckRun.Conclusion != "failure" { + w.WriteHeader(http.StatusNoContent) + return + } + s.createCIFailureTask(w, + p.Repository.Name, + p.Repository.FullName, + p.CheckRun.CheckSuite.HeadBranch, + p.CheckRun.HeadSHA, + p.CheckRun.Name, + p.CheckRun.HTMLURL, + ) +} + +func (s *Server) handleWorkflowRunEvent(w http.ResponseWriter, body []byte) { + var p workflowRunPayload + if err := json.Unmarshal(body, &p); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if p.Action != "completed" { + w.WriteHeader(http.StatusNoContent) + return + } + if p.WorkflowRun.Conclusion != "failure" && p.WorkflowRun.Conclusion != "timed_out" { + w.WriteHeader(http.StatusNoContent) + return + } + s.createCIFailureTask(w, + p.Repository.Name, + p.Repository.FullName, + p.WorkflowRun.HeadBranch, + p.WorkflowRun.HeadSHA, + p.WorkflowRun.Name, + p.WorkflowRun.HTMLURL, + ) +} + +func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, branch, sha, checkName, htmlURL string) { + project := matchProject(s.projects, repoName) + + instructions := fmt.Sprintf( + "A CI failure has been detected and requires investigation.\n\n"+ + "Repository: %s\n"+ + "Branch: %s\n"+ + "Commit SHA: %s\n"+ + "Check/Workflow: %s\n"+ + "Run URL: %s\n\n"+ + "Please investigate the failure by:\n"+ + "1. Reviewing recent commits on the branch\n"+ + "2. Checking the CI logs at the URL above\n"+ + "3. Identifying the root cause of the failure\n"+ + "4. Fixing the root cause and ensuring the build passes", + fullName, branch, sha, checkName, htmlURL, + ) + + now := time.Now().UTC() + t := &task.Task{ + ID: uuid.New().String(), + Name: fmt.Sprintf("Fix CI failure: %s on %s", checkName, branch), + Agent: task.AgentConfig{ + Type: "claude", + 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, + } + if project != nil { + t.Agent.ProjectDir = project.Dir + } + + if err := s.store.CreateTask(t); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"task_id": t.ID}) +} diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go new file mode 100644 index 0000000..8b0599a --- /dev/null +++ b/internal/api/webhook_test.go @@ -0,0 +1,313 @@ +package api + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/thepeterstone/claudomator/internal/config" +) + +// signBody computes the HMAC-SHA256 signature for a webhook payload. +func signBody(t *testing.T, secret, body string) string { + t.Helper() + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(body)) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +func webhookPost(t *testing.T, srv *Server, eventType, payload, sig string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewBufferString(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Event", eventType) + if sig != "" { + req.Header.Set("X-Hub-Signature-256", sig) + } + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + return w +} + +const checkRunFailurePayload = `{ + "action": "completed", + "check_run": { + "name": "CI Build", + "conclusion": "failure", + "html_url": "https://github.com/owner/myrepo/runs/123", + "head_sha": "abc123def", + "check_suite": { + "head_branch": "main" + } + }, + "repository": { + "name": "myrepo", + "full_name": "owner/myrepo" + } +}` + +const checkRunSuccessPayload = `{ + "action": "completed", + "check_run": { + "name": "CI Build", + "conclusion": "success", + "html_url": "https://github.com/owner/myrepo/runs/123", + "head_sha": "abc123def", + "check_suite": { + "head_branch": "main" + } + }, + "repository": { + "name": "myrepo", + "full_name": "owner/myrepo" + } +}` + +const workflowRunFailurePayload = `{ + "action": "completed", + "workflow_run": { + "name": "CI Pipeline", + "conclusion": "failure", + "html_url": "https://github.com/owner/myrepo/actions/runs/789", + "head_sha": "def456abc", + "head_branch": "feature-branch" + }, + "repository": { + "name": "myrepo", + "full_name": "owner/myrepo" + } +}` + +const workflowRunTimedOutPayload = `{ + "action": "completed", + "workflow_run": { + "name": "CI Pipeline", + "conclusion": "timed_out", + "html_url": "https://github.com/owner/myrepo/actions/runs/789", + "head_sha": "def456abc", + "head_branch": "main" + }, + "repository": { + "name": "myrepo", + "full_name": "owner/myrepo" + } +}` + +func TestGitHubWebhook_CheckRunFailure_CreatesTask(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; body: %s", w.Code, w.Body.String()) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + taskID, ok := resp["task_id"] + if !ok || taskID == "" { + t.Fatal("expected task_id in response") + } + tk, err := store.GetTask(taskID) + if err != nil { + t.Fatalf("task not found in store: %v", err) + } + if !strings.Contains(tk.Name, "CI Build") { + t.Errorf("task name %q does not contain check name", tk.Name) + } + 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 !contains(tk.Tags, "ci") || !contains(tk.Tags, "auto") { + t.Errorf("task tags %v missing expected ci/auto tags", tk.Tags) + } +} + +func TestGitHubWebhook_WorkflowRunFailure_CreatesTask(t *testing.T) { + srv, store := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + w := webhookPost(t, srv, "workflow_run", workflowRunFailurePayload, "") + + 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) + taskID, ok := resp["task_id"] + if !ok || taskID == "" { + t.Fatal("expected task_id in response") + } + tk, err := store.GetTask(taskID) + if err != nil { + t.Fatalf("task not found in store: %v", err) + } + if !strings.Contains(tk.Name, "CI Pipeline") { + t.Errorf("task name %q does not contain workflow name", tk.Name) + } + if !strings.Contains(tk.Name, "feature-branch") { + t.Errorf("task name %q does not contain branch", tk.Name) + } +} + +func TestGitHubWebhook_WorkflowRunTimedOut_CreatesTask(t *testing.T) { + srv, store := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + w := webhookPost(t, srv, "workflow_run", workflowRunTimedOutPayload, "") + + 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) + taskID := resp["task_id"] + if taskID == "" { + t.Fatal("expected task_id in response") + } + if _, err := store.GetTask(taskID); err != nil { + t.Fatalf("task not found in store: %v", err) + } +} + +func TestGitHubWebhook_CheckRunSuccess_Returns204(t *testing.T) { + srv, _ := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + w := webhookPost(t, srv, "check_run", checkRunSuccessPayload, "") + + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestGitHubWebhook_InvalidHMAC_Returns401(t *testing.T) { + srv, _ := testServer(t) + srv.webhookSecret = "correct-secret" + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "sha256=badhash") + + if w.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestGitHubWebhook_ValidHMAC_AcceptsRequest(t *testing.T) { + srv, store := testServer(t) + srv.webhookSecret = "my-secret" + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + sig := signBody(t, "my-secret", checkRunFailurePayload) + w := webhookPost(t, srv, "check_run", checkRunFailurePayload, sig) + + 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) + if _, err := store.GetTask(resp["task_id"]); err != nil { + t.Fatal("task not found after valid HMAC request") + } +} + +func TestGitHubWebhook_NoSecretConfigured_SkipsHMACCheck(t *testing.T) { + srv, store := testServer(t) + // No webhook secret configured — even a missing signature should pass. + 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; body: %s", w.Code, w.Body.String()) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if _, err := store.GetTask(resp["task_id"]); err != nil { + t.Fatal("task not found") + } +} + +func TestGitHubWebhook_UnknownEvent_Returns204(t *testing.T) { + srv, _ := testServer(t) + + w := webhookPost(t, srv, "push", `{"ref":"refs/heads/main"}`, "") + + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestGitHubWebhook_MissingEvent_Returns204(t *testing.T) { + srv, _ := testServer(t) + + req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewBufferString("{}")) + req.Header.Set("Content-Type", "application/json") + // No X-GitHub-Event header + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) { + srv, store := testServer(t) + // Project name doesn't match repo name, but there's only one project → fall back. + srv.projects = []config.Project{{Name: "different-name", Dir: "/workspace/someapp"}} + + w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") + + 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 tk.Agent.ProjectDir != "/workspace/someapp" { + t.Errorf("expected fallback to /workspace/someapp, got %q", tk.Agent.ProjectDir) + } +} + +func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithoutProjectDir(t *testing.T) { + srv, store := testServer(t) + // No projects configured — task should still be created, just no project dir set. + + w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") + + 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 tk.Agent.ProjectDir != "" { + t.Errorf("expected empty project dir, got %q", tk.Agent.ProjectDir) + } +} + +// contains checks if a string slice contains a value. +func contains(slice []string, val string) bool { + for _, s := range slice { + if s == val { + return true + } + } + return false +} diff --git a/internal/cli/serve.go b/internal/cli/serve.go index f22ea95..94f0c5d 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -87,6 +87,7 @@ func serve(addr string) error { if cfg.WorkspaceRoot != "" { srv.SetWorkspaceRoot(cfg.WorkspaceRoot) } + srv.SetGitHubWebhookConfig(cfg.WebhookSecret, cfg.Projects) // Register scripts. wd, _ := os.Getwd() diff --git a/internal/config/config.go b/internal/config/config.go index 8c5aebf..ce3b53f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,17 +9,25 @@ import ( "github.com/BurntSushi/toml" ) +// Project represents a named workspace project used for webhook routing. +type Project struct { + Name string `toml:"name"` + Dir string `toml:"dir"` +} + type Config struct { - DataDir string `toml:"data_dir"` - DBPath string `toml:"-"` - LogDir string `toml:"-"` - ClaudeBinaryPath string `toml:"claude_binary_path"` - GeminiBinaryPath string `toml:"gemini_binary_path"` - MaxConcurrent int `toml:"max_concurrent"` - DefaultTimeout string `toml:"default_timeout"` - ServerAddr string `toml:"server_addr"` - WebhookURL string `toml:"webhook_url"` - WorkspaceRoot string `toml:"workspace_root"` + DataDir string `toml:"data_dir"` + DBPath string `toml:"-"` + LogDir string `toml:"-"` + ClaudeBinaryPath string `toml:"claude_binary_path"` + GeminiBinaryPath string `toml:"gemini_binary_path"` + MaxConcurrent int `toml:"max_concurrent"` + DefaultTimeout string `toml:"default_timeout"` + ServerAddr string `toml:"server_addr"` + WebhookURL string `toml:"webhook_url"` + WorkspaceRoot string `toml:"workspace_root"` + WebhookSecret string `toml:"webhook_secret"` + Projects []Project `toml:"projects"` } func Default() (*Config, error) { |
