package handlers import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" "github.com/Molecule-AI/molecule-monorepo/platform/internal/events" "github.com/gin-gonic/gin" ) type WebhookHandler struct { workspaces *WorkspaceHandler } func NewWebhookHandler(broadcaster *events.Broadcaster) *WebhookHandler { return &WebhookHandler{ workspaces: NewWorkspaceHandler(broadcaster, nil, "", ""), } } func NewWebhookHandlerWithWorkspace(workspaces *WorkspaceHandler) *WebhookHandler { return &WebhookHandler{ workspaces: workspaces, } } // shortSHA returns the first n characters of a commit SHA, or the // full value if it's shorter than n. Safe for empty strings. func shortSHA(sha string) string { if len(sha) < 7 { return sha } return sha[:7] } // GitHub handles POST /webhooks/github/:id // It verifies X-Hub-Signature-256, maps supported events to A2A message/send, // then forwards through the same proxy flow used by /workspaces/:id/a2a. func (h *WebhookHandler) GitHub(c *gin.Context) { workspaceID := c.Param("id") secret := strings.TrimSpace(os.Getenv("GITHUB_WEBHOOK_SECRET")) if secret == "" { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "github webhook secret is not configured"}) return } rawBody, err := io.ReadAll(io.LimitReader(c.Request.Body, maxProxyRequestBody)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) return } signature := c.GetHeader("X-Hub-Signature-256") if !verifyGitHubSignature(secret, rawBody, signature) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid webhook signature"}) return } eventType := c.GetHeader("X-GitHub-Event") // Event-driven cron triggers: certain GitHub events fire matching // schedules immediately instead of forwarding to a specific workspace. if triggered, triggerErr := h.handleCronTriggerEvent(c, eventType, rawBody); triggered { if triggerErr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": triggerErr.Error()}) } return } deliveryID := c.GetHeader("X-GitHub-Delivery") payloadWorkspaceID, a2aPayload, buildErr := buildGitHubA2APayload(eventType, deliveryID, rawBody) if buildErr != nil { if buildErr == errUnsupportedGitHubEvent || buildErr == errIgnoredGitHubAction { c.JSON(http.StatusAccepted, gin.H{"status": "ignored", "reason": "unsupported event type"}) return } c.JSON(http.StatusBadRequest, gin.H{"error": buildErr.Error()}) return } if workspaceID == "" { workspaceID = payloadWorkspaceID } if workspaceID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing workspace id"}) return } forwardBody, err := json.Marshal(a2aPayload) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal a2a payload"}) return } status, respBody, proxyErr := h.workspaces.proxyA2ARequest( c.Request.Context(), workspaceID, forwardBody, "webhook:github", true, ) if proxyErr != nil { c.JSON(proxyErr.Status, proxyErr.Response) return } c.Data(status, "application/json", respBody) } var errUnsupportedGitHubEvent = fmt.Errorf("unsupported github event") var errIgnoredGitHubAction = fmt.Errorf("ignored github action") func verifyGitHubSignature(secret string, body []byte, header string) bool { const prefix = "sha256=" if !strings.HasPrefix(header, prefix) { return false } gotHex := strings.TrimPrefix(header, prefix) got, err := hex.DecodeString(gotHex) if err != nil { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := mac.Sum(nil) return hmac.Equal(got, expected) } type githubRepository struct { FullName string `json:"full_name"` } type githubSender struct { Login string `json:"login"` } type githubComment struct { Body string `json:"body"` HTMLURL string `json:"html_url"` } type githubIssue struct { Number int `json:"number"` } type githubPullRequest struct { Number int `json:"number"` } type githubIssueCommentEvent struct { WorkspaceID string `json:"workspace_id"` Action string `json:"action"` Repository githubRepository `json:"repository"` Sender githubSender `json:"sender"` Issue githubIssue `json:"issue"` Comment githubComment `json:"comment"` } type githubPRReviewCommentEvent struct { WorkspaceID string `json:"workspace_id"` Action string `json:"action"` Repository githubRepository `json:"repository"` Sender githubSender `json:"sender"` PullRequest githubPullRequest `json:"pull_request"` 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": var payload githubIssueCommentEvent if err := json.Unmarshal(rawBody, &payload); err != nil { return "", nil, fmt.Errorf("invalid issue_comment payload: %w", err) } if payload.Action != "created" { return payload.WorkspaceID, nil, errIgnoredGitHubAction } text := fmt.Sprintf( "GitHub issue_comment event (%s) in %s issue #%d by %s:\n%s", payload.Action, payload.Repository.FullName, payload.Issue.Number, payload.Sender.Login, strings.TrimSpace(payload.Comment.Body), ) 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, "issue_number": payload.Issue.Number, "comment_url": payload.Comment.HTMLURL, }), nil case "pull_request_review_comment": var payload githubPRReviewCommentEvent if err := json.Unmarshal(rawBody, &payload); err != nil { return "", nil, fmt.Errorf("invalid pull_request_review_comment payload: %w", err) } if payload.Action != "created" { return payload.WorkspaceID, nil, errIgnoredGitHubAction } text := fmt.Sprintf( "GitHub pull_request_review_comment event (%s) in %s PR #%d by %s:\n%s", payload.Action, payload.Repository.FullName, payload.PullRequest.Number, payload.Sender.Login, strings.TrimSpace(payload.Comment.Body), ) 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, "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, shortSHA(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 } } func newGitHubMessagePayload(text string, metadata map[string]interface{}) map[string]interface{} { return map[string]interface{}{ "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "role": "user", "parts": []map[string]string{ {"text": text}, }, }, "metadata": metadata, }, } } // --------------------------------------------------------------------------- // Event-driven cron triggers // // Some GitHub events don't target a specific workspace — instead they should // wake up all engineer work crons immediately so the team reacts to new issues // or PR reviews without waiting for the next 30-minute timer tick. // // Supported events: // - issues (action=opened) → fires schedules with "pick-up-work" in name // - pull_request_review (action=submitted) → fires schedules with "PR review" // or "security review" in name // // Mechanism: UPDATE next_run_at = NOW() on matching enabled schedules. The // scheduler's 30-second poll loop picks them up on the next tick. // --------------------------------------------------------------------------- // githubIssuesEvent is the minimal subset of the GitHub "issues" webhook payload. type githubIssuesEvent struct { Action string `json:"action"` Repository githubRepository `json:"repository"` Sender githubSender `json:"sender"` Issue struct { Number int `json:"number"` Title string `json:"title"` HTMLURL string `json:"html_url"` } `json:"issue"` } // githubPullRequestReviewEvent is the minimal subset of the GitHub // "pull_request_review" webhook payload. type githubPullRequestReviewEvent struct { Action string `json:"action"` Repository githubRepository `json:"repository"` Sender githubSender `json:"sender"` Review struct { State string `json:"state"` // approved, changes_requested, commented HTMLURL string `json:"html_url"` } `json:"review"` PullRequest struct { Number int `json:"number"` Title string `json:"title"` HTMLURL string `json:"html_url"` } `json:"pull_request"` } // handleCronTriggerEvent checks if the GitHub event is one that should trigger // schedules immediately. Returns (true, nil) if it handled the event and wrote // the HTTP response, (true, err) if it handled but errored, or (false, nil) if // the event is not a cron-trigger type and should fall through to A2A forwarding. func (h *WebhookHandler) handleCronTriggerEvent(c *gin.Context, eventType string, rawBody []byte) (bool, error) { ctx := c.Request.Context() switch eventType { case "issues": var payload githubIssuesEvent if err := json.Unmarshal(rawBody, &payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid issues payload"}) return true, nil } if payload.Action != "opened" { c.JSON(http.StatusAccepted, gin.H{"status": "ignored", "reason": "only issues action=opened triggers crons"}) return true, nil } // Fire all enabled schedules whose name contains "pick-up-work" (case-insensitive). result, err := db.DB.ExecContext(ctx, ` UPDATE workspace_schedules SET next_run_at = now(), updated_at = now() WHERE enabled = true AND next_run_at IS NOT NULL AND LOWER(name) LIKE '%pick-up-work%' `) if err != nil { log.Printf("Webhook: cron trigger (issues/opened) DB error: %v", err) return true, fmt.Errorf("failed to trigger schedules: %w", err) } affected, _ := result.RowsAffected() log.Printf("Webhook: issues/opened in %s #%d by %s — triggered %d pick-up-work schedule(s)", payload.Repository.FullName, payload.Issue.Number, payload.Sender.Login, affected) c.JSON(http.StatusOK, gin.H{ "status": "triggered", "event": "issues", "action": "opened", "schedules_affected": affected, }) return true, nil case "pull_request_review": var payload githubPullRequestReviewEvent if err := json.Unmarshal(rawBody, &payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pull_request_review payload"}) return true, nil } if payload.Action != "submitted" { c.JSON(http.StatusAccepted, gin.H{"status": "ignored", "reason": "only pull_request_review action=submitted triggers crons"}) return true, nil } // Fire all enabled schedules whose name contains "PR review" or "security review" (case-insensitive). result, err := db.DB.ExecContext(ctx, ` UPDATE workspace_schedules SET next_run_at = now(), updated_at = now() WHERE enabled = true AND next_run_at IS NOT NULL AND (LOWER(name) LIKE '%pr review%' OR LOWER(name) LIKE '%security review%') `) if err != nil { log.Printf("Webhook: cron trigger (pull_request_review/submitted) DB error: %v", err) return true, fmt.Errorf("failed to trigger schedules: %w", err) } affected, _ := result.RowsAffected() log.Printf("Webhook: pull_request_review/submitted in %s PR #%d by %s (state=%s) — triggered %d review schedule(s)", payload.Repository.FullName, payload.PullRequest.Number, payload.Sender.Login, payload.Review.State, affected) c.JSON(http.StatusOK, gin.H{ "status": "triggered", "event": "pull_request_review", "action": "submitted", "schedules_affected": affected, }) return true, nil default: return false, nil } }