forked from molecule-ai/molecule-core
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:
parent
a435dd3055
commit
bbeb1a4b8f
@ -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
|
||||
}
|
||||
|
||||
89
platform/internal/handlers/webhooks_workflow_test.go
Normal file
89
platform/internal/handlers/webhooks_workflow_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user