diff --git a/src/notifications/events.ts b/src/notifications/events.ts new file mode 100644 index 0000000000..52024e015a --- /dev/null +++ b/src/notifications/events.ts @@ -0,0 +1,61 @@ +import type { GitHubWebhookPayload } from "../types"; +import { nowIso } from "../utils/json"; + +export type NotificationEventType = "pull_request_changes_requested"; + +export type DetectedNotificationEvent = { + eventType: NotificationEventType; + recipientLogin: string; + repoFullName: string; + pullNumber: number; + dedupKey: string; + deeplink: string; + actorLogin: string; + detectedAt: string; +}; + +function isBotUser(user: { login?: string; type?: string } | undefined): boolean { + return user?.type === "Bot"; +} + +function normalizeLogin(login: string | undefined): string | undefined { + return login?.trim().toLowerCase() || undefined; +} + +export function detectNotificationEvents( + eventName: string, + payload: GitHubWebhookPayload, + detectedAt: string = nowIso(), +): DetectedNotificationEvent[] { + if (eventName !== "pull_request_review") return []; + if (payload.action !== "submitted" && payload.action !== "edited") return []; + + const repoFullName = payload.repository?.full_name; + const pullRequest = payload.pull_request; + const pullNumber = pullRequest?.number; + const authorLogin = pullRequest?.user?.login; + if (!repoFullName || !pullNumber || !authorLogin) return []; + + const reviewState = payload.review?.state?.toLowerCase(); + if (reviewState !== "changes_requested") return []; + + const reviewerLogin = payload.review?.user?.login ?? payload.sender?.login; + if (isBotUser(payload.review?.user) || isBotUser(payload.sender) || isBotUser(pullRequest?.user)) return []; + if (reviewerLogin && normalizeLogin(reviewerLogin) === normalizeLogin(authorLogin)) return []; + + const submittedAt = payload.review?.submitted_at ?? detectedAt; + const dedupKey = `changes_requested:${repoFullName}#${pullNumber}:${normalizeLogin(reviewerLogin) ?? "unknown"}:${submittedAt}`; + + return [ + { + eventType: "pull_request_changes_requested", + recipientLogin: authorLogin, + repoFullName, + pullNumber, + dedupKey, + deeplink: payload.review?.html_url ?? pullRequest.html_url ?? `https://github.com/${repoFullName}/pull/${pullNumber}`, + actorLogin: reviewerLogin ?? "unknown", + detectedAt, + }, + ]; +} diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 00e7209c9a..4d606b5e2f 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -76,6 +76,7 @@ import { ensurePullRequestLabel } from "../github/labels"; import { fetchPublicContributorProfile } from "../github/public"; import { refreshRegistry } from "../registry/sync"; import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck } from "../rules/advisory"; +import { detectNotificationEvents } from "../notifications/events"; import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; import { buildAndPersistContributorDecisionPack, loadDecisionPackSharedInputs } from "../services/decision-pack"; import { @@ -727,6 +728,25 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str await persistAdvisory(env, advisory); } + for (const notificationEvent of detectNotificationEvents(eventName, payload)) { + await recordAuditEvent(env, { + eventType: "notification.event_detected", + actor: notificationEvent.actorLogin, + targetKey: notificationEvent.recipientLogin, + outcome: "success", + detail: `${notificationEvent.eventType} for ${notificationEvent.repoFullName}#${notificationEvent.pullNumber}`, + metadata: { + deliveryId, + eventType: notificationEvent.eventType, + recipientLogin: notificationEvent.recipientLogin, + repoFullName: notificationEvent.repoFullName, + pullNumber: notificationEvent.pullNumber, + dedupKey: notificationEvent.dedupKey, + deeplink: notificationEvent.deeplink, + }, + }); + } + await recordWebhookEvent(env, { deliveryId, eventName, diff --git a/src/types.ts b/src/types.ts index fb1d0a17a2..4f405e81a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -133,6 +133,7 @@ export type GitHubWebhookPayload = { pull_request?: GitHubPullRequestPayload; issue?: GitHubIssuePayload; comment?: GitHubIssueCommentPayload; + review?: GitHubReviewPayload; reaction?: GitHubReactionPayload; sender?: GitHubWebhookUserPayload; label?: { @@ -189,6 +190,13 @@ export type GitHubPullRequestPayload = { body?: string | null; }; +export type GitHubReviewPayload = { + state?: string; + user?: GitHubWebhookUserPayload; + submitted_at?: string | null; + html_url?: string; +}; + export type GitHubIssuePayload = { number: number; title: string; diff --git a/test/unit/notifications-events.test.ts b/test/unit/notifications-events.test.ts new file mode 100644 index 0000000000..be90277e1a --- /dev/null +++ b/test/unit/notifications-events.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { detectNotificationEvents } from "../../src/notifications/events"; +import type { GitHubWebhookPayload } from "../../src/types"; + +const basePayload: GitHubWebhookPayload = { + action: "submitted", + repository: { name: "gittensory", full_name: "JSONbored/gittensory", owner: { login: "JSONbored" } }, + pull_request: { + number: 42, + title: "Add feature", + state: "open", + user: { login: "contributor", type: "User" }, + html_url: "https://github.com/JSONbored/gittensory/pull/42", + }, + review: { + state: "changes_requested", + user: { login: "maintainer", type: "User" }, + submitted_at: "2026-05-28T12:00:00.000Z", + html_url: "https://github.com/JSONbored/gittensory/pull/42#pullrequestreview-1", + }, + sender: { login: "maintainer", type: "User" }, +}; + +describe("detectNotificationEvents", () => { + it("emits one changes-requested event for the PR author", () => { + const events = detectNotificationEvents("pull_request_review", basePayload, "2026-05-28T12:00:01.000Z"); + + expect(events).toEqual([ + { + eventType: "pull_request_changes_requested", + recipientLogin: "contributor", + repoFullName: "JSONbored/gittensory", + pullNumber: 42, + dedupKey: "changes_requested:JSONbored/gittensory#42:maintainer:2026-05-28T12:00:00.000Z", + deeplink: "https://github.com/JSONbored/gittensory/pull/42#pullrequestreview-1", + actorLogin: "maintainer", + detectedAt: "2026-05-28T12:00:01.000Z", + }, + ]); + expect(JSON.stringify(events)).not.toMatch(/trust score|wallet|hotkey|reward estimate|reviewability/i); + }); + + it("accepts edited review actions and ignores non-changes-requested states", () => { + expect(detectNotificationEvents("pull_request_review", { ...basePayload, action: "edited" }, "2026-05-28T12:00:01.000Z")).toHaveLength(1); + expect( + detectNotificationEvents( + "pull_request_review", + { ...basePayload, review: { ...basePayload.review, state: "approved" } }, + "2026-05-28T12:00:01.000Z", + ), + ).toEqual([]); + expect(detectNotificationEvents("pull_request_review", { ...basePayload, action: "dismissed" }, "2026-05-28T12:00:01.000Z")).toEqual([]); + }); + + it("ignores unrelated webhook events and incomplete payloads", () => { + expect(detectNotificationEvents("pull_request", basePayload)).toEqual([]); + expect(detectNotificationEvents("pull_request_review", { ...basePayload, pull_request: undefined as never })).toEqual([]); + expect(detectNotificationEvents("pull_request_review", { ...basePayload, review: undefined as never })).toEqual([]); + }); + + it("suppresses self-notifications and bot-authored reviews", () => { + expect( + detectNotificationEvents("pull_request_review", { + ...basePayload, + review: { ...basePayload.review, user: { login: "contributor", type: "User" } }, + sender: { login: "contributor", type: "User" }, + }), + ).toEqual([]); + expect( + detectNotificationEvents("pull_request_review", { + ...basePayload, + review: { ...basePayload.review, user: { login: "Contributor", type: "User" } }, + sender: { login: " CONTRIBUTOR ", type: "User" }, + }), + ).toEqual([]); + expect( + detectNotificationEvents("pull_request_review", { + ...basePayload, + review: { ...basePayload.review, user: { login: "dependabot[bot]", type: "Bot" } }, + sender: { login: "dependabot[bot]", type: "Bot" }, + }), + ).toEqual([]); + expect( + detectNotificationEvents("pull_request_review", { + ...basePayload, + pull_request: { ...basePayload.pull_request!, user: { login: "dependabot[bot]", type: "Bot" } }, + }), + ).toEqual([]); + expect( + detectNotificationEvents("pull_request_review", { + ...basePayload, + review: undefined as never, + sender: { login: "github-actions[bot]", type: "Bot" }, + }), + ).toEqual([]); + }); + + it("falls back to sender login, generated deeplink, and detectedAt when review metadata is sparse", () => { + const events = detectNotificationEvents( + "pull_request_review", + { + action: "submitted", + repository: { name: "gittensory", full_name: "JSONbored/gittensory", owner: { login: "JSONbored" } }, + pull_request: { + number: 7, + title: "Sparse review", + state: "open", + user: { login: "contributor", type: "User" }, + }, + review: { + state: "changes_requested", + }, + }, + "2026-05-28T13:00:00.000Z", + ); + + expect(events).toEqual([ + { + eventType: "pull_request_changes_requested", + recipientLogin: "contributor", + repoFullName: "JSONbored/gittensory", + pullNumber: 7, + dedupKey: "changes_requested:JSONbored/gittensory#7:unknown:2026-05-28T13:00:00.000Z", + deeplink: "https://github.com/JSONbored/gittensory/pull/7", + actorLogin: "unknown", + detectedAt: "2026-05-28T13:00:00.000Z", + }, + ]); + }); + + it("uses sender login when review.user is absent", () => { + const events = detectNotificationEvents( + "pull_request_review", + { + ...basePayload, + review: { + state: "changes_requested", + submitted_at: "2026-05-28T12:00:00.000Z", + }, + sender: { login: "maintainer", type: "User" }, + }, + "2026-05-28T12:00:01.000Z", + ); + + expect(events[0]?.actorLogin).toBe("maintainer"); + expect(events[0]?.dedupKey).toContain(":maintainer:"); + }); + + it("returns no events when repository or author metadata is missing", () => { + expect( + detectNotificationEvents("pull_request_review", { + ...basePayload, + repository: undefined as never, + }), + ).toEqual([]); + expect( + detectNotificationEvents("pull_request_review", { + ...basePayload, + pull_request: { ...basePayload.pull_request!, user: undefined as never }, + }), + ).toEqual([]); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 9edbb06a14..00b6e43046 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -3461,6 +3461,55 @@ describe("queue processors", () => { .first<{ outcome: string; detail: string }>(); expect(event).toMatchObject({ outcome: "error", detail: "miner_detection_unavailable" }); }); + + it("detects a changes-requested review notification for the PR author", async () => { + const env = createTestEnv(); + + await processJob(env, { + type: "github-webhook", + deliveryId: "review-changes-requested", + eventName: "pull_request_review", + payload: { + action: "submitted", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: 42, + title: "Add feature", + state: "open", + user: { login: "contributor", type: "User" }, + html_url: "https://github.com/JSONbored/gittensory/pull/42", + }, + review: { + state: "changes_requested", + user: { login: "maintainer", type: "User" }, + submitted_at: "2026-05-28T12:00:00.000Z", + html_url: "https://github.com/JSONbored/gittensory/pull/42#pullrequestreview-1", + }, + sender: { login: "maintainer", type: "User" }, + }, + }); + + const detected = await env.DB.prepare("select actor, target_key, outcome, detail, metadata_json from audit_events where event_type = ?") + .bind("notification.event_detected") + .all<{ actor: string; target_key: string; outcome: string; detail: string; metadata_json: string }>(); + expect(detected.results).toHaveLength(1); + expect(detected.results[0]).toMatchObject({ + actor: "maintainer", + target_key: "contributor", + outcome: "success", + detail: "pull_request_changes_requested for JSONbored/gittensory#42", + }); + expect(JSON.parse(detected.results[0]!.metadata_json)).toMatchObject({ + deliveryId: "review-changes-requested", + eventType: "pull_request_changes_requested", + recipientLogin: "contributor", + repoFullName: "JSONbored/gittensory", + pullNumber: 42, + dedupKey: "changes_requested:JSONbored/gittensory#42:maintainer:2026-05-28T12:00:00.000Z", + }); + expect(JSON.stringify(detected.results[0])).not.toMatch(/trust score|wallet|hotkey|reward estimate|reviewability/i); + }); }); function completeSegment(repoFullName: string, segment: "labels" | "open_issues" | "open_pull_requests") {