From b0b79966e957bd5e4fc014978dbf7e9dbebeac61 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 16 Mar 2026 22:11:34 +0000 Subject: fix: prefix SW registration path with BASE_PATH The app is served at /claudomator/ so the SW and scope must use BASE_PATH + '/api/push/sw.js' and BASE_PATH + '/' respectively. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/webhook.go | 57 ++++++++++++++++++++------ internal/api/webhook_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 13 deletions(-) (limited to 'internal/api') diff --git a/internal/api/webhook.go b/internal/api/webhook.go index 8bf1676..0530f3e 100644 --- a/internal/api/webhook.go +++ b/internal/api/webhook.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "path/filepath" "strings" @@ -17,17 +18,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"` @@ -39,11 +49,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"` @@ -97,6 +108,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) @@ -117,13 +129,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, ) } @@ -141,10 +162,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, @@ -154,6 +180,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) + } + instructions := fmt.Sprintf( "A CI failure has been detected and requires investigation.\n\n"+ "Repository: %s\n"+ @@ -175,6 +205,7 @@ 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"}, diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go index 8b0599a..1bc4aaa 100644 --- a/internal/api/webhook_test.go +++ b/internal/api/webhook_test.go @@ -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) -- cgit v1.2.3