Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/notifications/events.ts
Original file line number Diff line number Diff line change
@@ -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,
},
];
}
20 changes: 20 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export type GitHubWebhookPayload = {
pull_request?: GitHubPullRequestPayload;
issue?: GitHubIssuePayload;
comment?: GitHubIssueCommentPayload;
review?: GitHubReviewPayload;
reaction?: GitHubReactionPayload;
sender?: GitHubWebhookUserPayload;
label?: {
Expand Down Expand Up @@ -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;
Expand Down
163 changes: 163 additions & 0 deletions test/unit/notifications-events.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
49 changes: 49 additions & 0 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down