diff options
| author | Claudomator Agent <agent@claudomator> | 2026-03-16 05:08:00 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator> | 2026-03-16 05:08:00 +0000 |
| commit | c2aa026f6ce1c9e216b99d74f294fc133d5fcddd (patch) | |
| tree | 2fdbc85367b92e5ba0d5cd9b789f4f4cf34aad61 /internal/api/webhook.go | |
| parent | d75a231d8865d9b14fbe3d608c9aa1bffb7ed386 (diff) | |
feat: add GitHub webhook endpoint for automatic CI failure task creation
Adds POST /api/webhooks/github that receives check_run and workflow_run
events and creates a Claudomator task to investigate and fix the failure.
- Config: new webhook_secret and [[projects]] fields in config.toml
- HMAC-SHA256 validation when webhook_secret is configured
- Ignores non-failure events (success, skipped, etc.) with 204
- Matches repo name to configured project dirs (case-insensitive)
- Falls back to single project when no name match found
- 11 new tests covering all acceptance criteria
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/webhook.go')
| -rw-r--r-- | internal/api/webhook.go | 200 |
1 files changed, 200 insertions, 0 deletions
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}) +} |
