feat(webhooks): #101 — workflow_run event → DevOps A2A

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-15 00:25:49 -07:00
parent a435dd3055
commit bbeb1a4b8f
2 changed files with 156 additions and 0 deletions

View File

@ -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
}

View File

@ -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)
}
}