Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/lib/github/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ export interface PullRequestReviewContext {
open: boolean;
merged: boolean;
draft: boolean;
updatedAt?: string;
authorGithubId?: number;
authorLogin?: string;
}
Expand All @@ -688,6 +689,7 @@ export async function getPullRequestReviewContext(
draft?: boolean;
head?: { sha?: string };
base?: { sha?: string };
updated_at?: string;
user?: { id?: number; login?: string };
};
const headSha = data.head?.sha;
Expand All @@ -700,12 +702,17 @@ export async function getPullRequestReviewContext(
const authorGithubId = data.user?.id;
const authorLogin =
typeof data.user?.login === "string" ? data.user.login.trim() : undefined;
const updatedAt =
typeof data.updated_at === "string" && Number.isFinite(Date.parse(data.updated_at))
? data.updated_at
: undefined;
return {
headSha,
baseSha,
open: data.state === "open",
merged: data.merged === true,
draft: data.draft === true,
...(updatedAt ? { updatedAt } : {}),
...(typeof authorGithubId === "number" &&
Number.isSafeInteger(authorGithubId) &&
authorGithubId > 0
Expand Down
53 changes: 51 additions & 2 deletions src/lib/github/webhook-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ interface PullRequestEventPayload {
draft?: boolean;
head?: { sha?: string };
base?: { sha?: string };
updated_at?: string;
user?: GithubUser;
};
}
Expand All @@ -122,6 +123,29 @@ const REVIEWABLE_PR_ACTIONS = new Set([
"edited",
]);

function livePullRequestSnapshotLagsEvent(
eventUpdatedAt: string | undefined,
liveUpdatedAt: string | undefined,
): boolean {
if (!eventUpdatedAt || !liveUpdatedAt) return false;
const eventTime = Date.parse(eventUpdatedAt);
const liveTime = Date.parse(liveUpdatedAt);
return Number.isFinite(eventTime) && Number.isFinite(liveTime) && liveTime < eventTime;
}

function retryLaggingPullRequestSnapshot(
repoFullName: string,
prNumber: number,
action: string,
eventUpdatedAt: string | undefined,
liveUpdatedAt: string | undefined,
): void {
if (!livePullRequestSnapshotLagsEvent(eventUpdatedAt, liveUpdatedAt)) return;
throw new Error(
`GitHub pull request ${repoFullName}#${prNumber} has not converged for ${action}`,
);
}

/**
* The subset of a check_run/check_suite `pull_requests[]` entry we need to
* rebuild a review job. GitHub includes head/base sha on these references
Expand Down Expand Up @@ -586,11 +610,36 @@ async function handlePullRequest(
const headSha = pr.head?.sha;
const baseSha = pr.base?.sha;
if (action === "closed") {
if (live.open && !live.merged) return;
if (live.open && !live.merged) {
retryLaggingPullRequestSnapshot(
repo.full_name,
pr.number,
action,
pr.updated_at,
live.updatedAt,
);
return;
}
} else if (!live.open || live.merged || live.draft) {
retryLaggingPullRequestSnapshot(
repo.full_name,
pr.number,
action,
pr.updated_at,
live.updatedAt,
);
return;
}
if (!headSha || !baseSha || headSha !== live.headSha || baseSha !== live.baseSha) {
retryLaggingPullRequestSnapshot(
repo.full_name,
pr.number,
action,
pr.updated_at,
live.updatedAt,
);
return;
}
if (!headSha || !baseSha || headSha !== live.headSha || baseSha !== live.baseSha) return;

const db = getDb();
const installation = (
Expand Down
2 changes: 2 additions & 0 deletions tests/github-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ describe("pull-request review context", () => {
state: "open",
merged: false,
draft: false,
updated_at: "2026-07-21T10:24:14Z",
head: { sha: "head-sha" },
base: { sha: "base-sha" },
user: { id: 42, login: "octocat" },
Expand All @@ -217,6 +218,7 @@ describe("pull-request review context", () => {
headSha: "head-sha",
baseSha: "base-sha",
draft: false,
updatedAt: "2026-07-21T10:24:14Z",
authorGithubId: 42,
authorLogin: "octocat",
});
Expand Down
154 changes: 153 additions & 1 deletion tests/webhook-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from "node:path";
import { Pool } from "pg";

import { getSealingKey, seal } from "@/lib/crypto/seal";
import type { PullRequestReviewContext } from "@/lib/github/checks";
import {
GITHUB_WEBHOOK_MAX_BODY_BYTES,
signWebhookBody,
Expand Down Expand Up @@ -60,14 +61,15 @@ let liveMembershipUserLogin = "admin";
let liveMembershipOrgId = 999;
let liveMembershipOrgLogin = "octo";
let membershipFetchCount = 0;
let pullRequestReviewContext = {
let pullRequestReviewContext: PullRequestReviewContext = {
open: true,
merged: false,
headSha: "head-sha",
baseSha: "base-sha",
draft: false,
authorGithubId: 501,
authorLogin: "admin",
updatedAt: "2026-07-21T10:24:04Z",
};
let reviewCommentRoot = {
id: 8800,
Expand Down Expand Up @@ -263,6 +265,7 @@ describeDb("webhook handler behaviour", () => {
draft: false,
authorGithubId: 501,
authorLogin: "admin",
updatedAt: "2026-07-21T10:24:04Z",
};
reviewCommentRoot = {
id: 8800,
Expand Down Expand Up @@ -986,6 +989,155 @@ describeDb("webhook handler behaviour", () => {
});
});

test("a newer reopened delivery retries until GitHub live state converges", async () => {
const orgId = await seedOrg();
const inst = await seedInstallation(orgId, 252);
await seedRepo(inst, 7880, "octo/reopened");
pullRequestReviewContext = {
open: false,
merged: false,
headSha: "same-head",
baseSha: "base",
draft: false,
updatedAt: "2026-07-21T10:24:04Z",
};

const response = await post(
"pull_request",
{
action: "reopened",
installation: { id: 252 },
repository: { id: 7880, full_name: "octo/reopened", private: false },
pull_request: {
number: 9,
head: { sha: "same-head" },
base: { sha: "base" },
updated_at: "2026-07-21T10:24:14Z",
},
},
"delivery-reopened-lagging",
);

expect(response.status).toBe(200);
const deferred = await pool.query<{
delivery_completed: boolean;
dispatch_status: string;
attempts: number;
review_jobs: number;
}>(
`SELECT delivery.completed_at IS NOT NULL AS delivery_completed,
dispatch.status AS dispatch_status,
dispatch.attempts,
(SELECT count(*)::int FROM jobs WHERE kind = 'review') AS review_jobs
FROM webhook_deliveries delivery
JOIN jobs dispatch
ON dispatch.kind = 'webhook-dispatch'
AND dispatch.payload->>'deliveryId' = delivery.delivery_id
WHERE delivery.delivery_id = 'delivery-reopened-lagging'`,
);
expect(deferred.rows[0]).toEqual({
delivery_completed: false,
dispatch_status: "queued",
attempts: 1,
review_jobs: 0,
});

pullRequestReviewContext = {
...pullRequestReviewContext,
open: true,
updatedAt: "2026-07-21T10:24:14Z",
};
await pool.query(
`UPDATE jobs
SET run_after = now()
WHERE kind = 'webhook-dispatch'
AND payload->>'deliveryId' = 'delivery-reopened-lagging'`,
);
const retry = await claimJob(pool, "webhook-reopened-retry", ["webhook-dispatch"]);
expect(retry?.kind).toBe("webhook-dispatch");
await runClaimedJob(retry!, "webhook-reopened-retry", "web");

const completed = await pool.query<{
delivery_completed: boolean;
dispatch_status: string;
review_status: string;
force_full_review: boolean;
webhook_action: string;
}>(
`SELECT delivery.completed_at IS NOT NULL AS delivery_completed,
dispatch.status AS dispatch_status,
review.status AS review_status,
(review.payload->>'forceFullReview')::boolean AS force_full_review,
review.payload->'trigger'->>'webhookAction' AS webhook_action
FROM webhook_deliveries delivery
JOIN jobs dispatch
ON dispatch.kind = 'webhook-dispatch'
AND dispatch.payload->>'deliveryId' = delivery.delivery_id
JOIN jobs review
ON review.kind = 'review'
AND review.payload->>'sourceDeliveryId' = delivery.delivery_id
WHERE delivery.delivery_id = 'delivery-reopened-lagging'`,
);
expect(completed.rows[0]).toEqual({
delivery_completed: true,
dispatch_status: "done",
review_status: "queued",
force_full_review: true,
webhook_action: "reopened",
});
});

test("an older closed delivery stays ignored after a newer reopen", async () => {
const orgId = await seedOrg();
const inst = await seedInstallation(orgId, 253);
await seedRepo(inst, 7881, "octo/stale-close");
pullRequestReviewContext = {
open: true,
merged: false,
headSha: "same-head",
baseSha: "base",
draft: false,
updatedAt: "2026-07-21T10:24:14Z",
};

const response = await post(
"pull_request",
{
action: "closed",
installation: { id: 253 },
repository: { id: 7881, full_name: "octo/stale-close", private: false },
pull_request: {
number: 10,
head: { sha: "same-head" },
base: { sha: "base" },
updated_at: "2026-07-21T10:24:04Z",
},
},
"delivery-stale-close",
);

expect(response.status).toBe(200);
const state = await pool.query<{
delivery_completed: boolean;
dispatch_status: string;
review_jobs: number;
}>(
`SELECT delivery.completed_at IS NOT NULL AS delivery_completed,
dispatch.status AS dispatch_status,
(SELECT count(*)::int FROM jobs WHERE kind = 'review') AS review_jobs
FROM webhook_deliveries delivery
JOIN jobs dispatch
ON dispatch.kind = 'webhook-dispatch'
AND dispatch.payload->>'deliveryId' = delivery.delivery_id
WHERE delivery.delivery_id = 'delivery-stale-close'`,
);
expect(state.rows[0]).toEqual({
delivery_completed: true,
dispatch_status: "done",
review_jobs: 0,
});
});

for (const action of ["opened", "synchronize"] as const) {
test(`signed pull_request ${action} stays queued through exact release activation`, async () => {
const releaseSha = action === "opened" ? "1".repeat(40) : "2".repeat(40);
Expand Down