package api import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log/slog" "net/http" "os/exec" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/thepeterstone/claudomator/internal/config" "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"` 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"` 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"` PullRequests []prRef `json:"pull_requests"` } `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") slog.Info("github webhook received", "event", eventType, "bytes", len(body)) switch eventType { case "check_run": s.handleCheckRunEvent(w, body) case "workflow_run": s.handleWorkflowRunEvent(w, body) case "push": s.handlePushEvent(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 } 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, branch, p.CheckRun.HeadSHA, p.CheckRun.Name, 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 } 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, branch, 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) 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"+ "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, ) tctx := ciTriageContext{ Repo: fullName, Branch: branch, SHA: sha, CheckName: checkName, URL: htmlURL, } if project != nil { tctx.ProjectDir = project.Dir } instructions := enrichCIInstructions(context.Background(), s.llm, tctx, fallback) 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", 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, RepositoryURL: fmt.Sprintf("git@github.com:%s.git", fullName), } if project != nil { t.Project = project.Name } 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}) } // pushPayload is a minimal GitHub push webhook payload. type pushPayload struct { Ref string `json:"ref"` Repository struct { Name string `json:"name"` FullName string `json:"full_name"` } `json:"repository"` } // handlePushEvent syncs the local bare repo with GitHub when a push arrives. // The fetch runs asynchronously so the webhook returns immediately. func (s *Server) handlePushEvent(w http.ResponseWriter, body []byte) { var p pushPayload if err := json.Unmarshal(body, &p); err != nil || p.Repository.FullName == "" { w.WriteHeader(http.StatusNoContent) return } project := matchProject(s.projects, p.Repository.Name) if project == nil { slog.Info("push event: no matching project, skipping sync", "repo", p.Repository.FullName) w.WriteHeader(http.StatusNoContent) return } dbProj, err := s.store.GetProject(project.Name) if err != nil || dbProj == nil || dbProj.RemoteURL == "" { slog.Info("push event: project has no local remote, skipping sync", "project", project.Name) w.WriteHeader(http.StatusNoContent) return } githubURL := fmt.Sprintf("git@github.com:%s.git", p.Repository.FullName) if s.githubURLForTest != nil { githubURL = s.githubURLForTest(p.Repository.FullName) } bareRepo := dbProj.RemoteURL go func() { out, err := exec.Command("git", "-C", bareRepo, "fetch", githubURL, "+refs/heads/*:refs/heads/*", "--prune").CombinedOutput() if err != nil { slog.Error("github push sync failed", "repo", p.Repository.FullName, "bare", bareRepo, "error", err, "output", string(out)) } else { slog.Info("github push sync succeeded", "repo", p.Repository.FullName, "bare", bareRepo) } }() w.WriteHeader(http.StatusNoContent) }