From bbeb1a4b8f4dea573db599ea6e865b45826a5f31 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 15 Apr 2026 00:25:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(webhooks):=20#101=20=E2=80=94=20workflow?= =?UTF-8?q?=5Frun=20event=20=E2=86=92=20DevOps=20A2A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #101 layer 1: buildGitHubA2APayload now handles workflow_run events, routing failed CI runs to a workspace via the existing X-Molecule-Workspace-ID / webhook path. Only completed runs with a failure/cancelled/timed_out conclusion fan out — success/skipped/neutral are dropped via errIgnoredGitHubAction. Surface message is human-readable + includes the run URL so DevOps can jump straight to the failing job. Metadata carries the full run context (workflow_name, run_id, run_number, conclusion, head_branch, head_sha, run_url, trigger_event) for programmatic handling. 4 new tests cover the failure path, success skip, non-completed action skip, and short-SHA edge case. Layer 2 (org.yaml wiring for DevOps workspace + GITHUB_WEBHOOK_SECRET docs) stays as a follow-up PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- platform/internal/handlers/webhooks.go | 67 ++++++++++++++ .../handlers/webhooks_workflow_test.go | 89 +++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 platform/internal/handlers/webhooks_workflow_test.go diff --git a/platform/internal/handlers/webhooks.go b/platform/internal/handlers/webhooks.go index e63370fd2..259c62f03 100644 --- a/platform/internal/handlers/webhooks.go +++ b/platform/internal/handlers/webhooks.go @@ -155,6 +155,29 @@ type githubPRReviewCommentEvent struct { Comment githubComment `json:"comment"` } +// githubWorkflowRun captures the subset of GitHub's `workflow_run` event we +// route to workspaces (#101). Full schema is ~50 fields; we only need the +// handful that tell DevOps "which CI job failed, where, and how to get there." +type githubWorkflowRun struct { + ID int64 `json:"id"` + Name string `json:"name"` // workflow name, e.g. "CI" + Event string `json:"event"` // push / pull_request / etc. + Status string `json:"status"` // queued / in_progress / completed + Conclusion string `json:"conclusion"` // success / failure / cancelled / timed_out + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + HTMLURL string `json:"html_url"` + RunNumber int `json:"run_number"` +} + +type githubWorkflowRunEvent struct { + WorkspaceID string `json:"workspace_id"` + Action string `json:"action"` // requested / in_progress / completed + Repository githubRepository `json:"repository"` + Sender githubSender `json:"sender"` + WorkflowRun githubWorkflowRun `json:"workflow_run"` +} + func buildGitHubA2APayload(eventType, deliveryID string, rawBody []byte) (string, map[string]interface{}, error) { switch eventType { case "issue_comment": @@ -209,6 +232,50 @@ func buildGitHubA2APayload(eventType, deliveryID string, rawBody []byte) (string "pull_request_num": payload.PullRequest.Number, "comment_url": payload.Comment.HTMLURL, }), nil + case "workflow_run": + // #101 — CI-break notifications for DevOps Engineer. Only surface + // *completed* runs with a non-success conclusion; queued / in_progress + // are noise. A success completion is dropped too (explicit filter + // rather than `errIgnoredGitHubAction` so the behaviour is visible + // in the switch). + var payload githubWorkflowRunEvent + if err := json.Unmarshal(rawBody, &payload); err != nil { + return "", nil, fmt.Errorf("invalid workflow_run payload: %w", err) + } + if payload.Action != "completed" { + return payload.WorkspaceID, nil, errIgnoredGitHubAction + } + if payload.WorkflowRun.Conclusion == "success" || payload.WorkflowRun.Conclusion == "skipped" || payload.WorkflowRun.Conclusion == "neutral" { + return payload.WorkspaceID, nil, errIgnoredGitHubAction + } + text := fmt.Sprintf( + "GitHub CI break — workflow '%s' run #%d %s on %s@%s\nTriggered by: %s (%s)\nRepo: %s\nRun URL: %s", + payload.WorkflowRun.Name, + payload.WorkflowRun.RunNumber, + payload.WorkflowRun.Conclusion, + payload.WorkflowRun.HeadBranch, + payload.WorkflowRun.HeadSHA[:min(7, len(payload.WorkflowRun.HeadSHA))], + payload.Sender.Login, + payload.WorkflowRun.Event, + payload.Repository.FullName, + payload.WorkflowRun.HTMLURL, + ) + return payload.WorkspaceID, newGitHubMessagePayload(text, map[string]interface{}{ + "source": "github", + "event": eventType, + "action": payload.Action, + "delivery_id": deliveryID, + "repository": payload.Repository.FullName, + "sender": payload.Sender.Login, + "workflow_name": payload.WorkflowRun.Name, + "run_id": payload.WorkflowRun.ID, + "run_number": payload.WorkflowRun.RunNumber, + "conclusion": payload.WorkflowRun.Conclusion, + "head_branch": payload.WorkflowRun.HeadBranch, + "head_sha": payload.WorkflowRun.HeadSHA, + "run_url": payload.WorkflowRun.HTMLURL, + "trigger_event": payload.WorkflowRun.Event, + }), nil default: return "", nil, errUnsupportedGitHubEvent } diff --git a/platform/internal/handlers/webhooks_workflow_test.go b/platform/internal/handlers/webhooks_workflow_test.go new file mode 100644 index 000000000..5c3419b03 --- /dev/null +++ b/platform/internal/handlers/webhooks_workflow_test.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "encoding/json" + "strings" + "testing" +) + +// Tests the workflow_run → DevOps A2A routing added for #101. + +func TestBuildGitHubA2APayload_WorkflowRunFailure(t *testing.T) { + raw := []byte(`{ + "workspace_id": "ws-devops", + "action": "completed", + "repository": {"full_name": "Molecule-AI/molecule-monorepo"}, + "sender": {"login": "hongming"}, + "workflow_run": { + "id": 123456, + "name": "CI", + "event": "pull_request", + "status": "completed", + "conclusion": "failure", + "head_branch": "fix/thing", + "head_sha": "deadbeef1234567", + "html_url": "https://github.com/Molecule-AI/molecule-monorepo/actions/runs/123456", + "run_number": 42 + } + }`) + + wsID, payload, err := buildGitHubA2APayload("workflow_run", "delivery-abc", raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if wsID != "ws-devops" { + t.Errorf("workspace id: got %q want ws-devops", wsID) + } + + body, _ := json.Marshal(payload) + text := string(body) + for _, needle := range []string{"failure", "CI", "run #42", "fix/thing", "deadbee", "Molecule-AI/molecule-monorepo"} { + if !strings.Contains(text, needle) { + t.Errorf("missing %q in payload: %s", needle, text) + } + } +} + +func TestBuildGitHubA2APayload_WorkflowRunSuccessIgnored(t *testing.T) { + raw := []byte(`{ + "workspace_id": "ws-devops", + "action": "completed", + "repository": {"full_name": "x/y"}, + "sender": {"login": "u"}, + "workflow_run": {"name": "CI", "status": "completed", "conclusion": "success", "head_sha": "abcdef1"} + }`) + _, _, err := buildGitHubA2APayload("workflow_run", "d1", raw) + if err != errIgnoredGitHubAction { + t.Errorf("success run should be ignored; got err=%v", err) + } +} + +func TestBuildGitHubA2APayload_WorkflowRunNonCompletedIgnored(t *testing.T) { + raw := []byte(`{ + "workspace_id": "ws-devops", + "action": "requested", + "repository": {"full_name": "x/y"}, + "sender": {"login": "u"}, + "workflow_run": {"name": "CI", "status": "in_progress", "conclusion": "", "head_sha": "abc"} + }`) + _, _, err := buildGitHubA2APayload("workflow_run", "d2", raw) + if err != errIgnoredGitHubAction { + t.Errorf("non-completed action should be ignored; got err=%v", err) + } +} + +// Short-SHA truncation used to crash when head_sha was < 7 chars — the +// `min(7, len)` guard covers that edge case. +func TestBuildGitHubA2APayload_WorkflowRunShortSHA(t *testing.T) { + raw := []byte(`{ + "workspace_id": "ws-devops", + "action": "completed", + "repository": {"full_name": "x/y"}, + "sender": {"login": "u"}, + "workflow_run": {"name": "CI", "status": "completed", "conclusion": "failure", "head_sha": "abc", "run_number": 1} + }`) + _, _, err := buildGitHubA2APayload("workflow_run", "d3", raw) + if err != nil { + t.Errorf("short-sha path: %v", err) + } +}