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
54 changes: 44 additions & 10 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,11 @@ export async function upsertPullRequestFromGitHub(
mergedAt: pullRequests.mergedAt,
githubUpdatedAt: pullRequests.githubUpdatedAt,
labelsJson: pullRequests.labelsJson,
// #9057: also read the columns a stale payload could revert (see resolvedTitle/baseRef/etc. below).
title: pullRequests.title,
baseRef: pullRequests.baseRef,
headRef: pullRequests.headRef,
authorAssociation: pullRequests.authorAssociation,
})
.from(pullRequests)
.where(and(eq(pullRequests.repoFullName, repoFullName), eq(pullRequests.number, pr.number)))
Expand Down Expand Up @@ -407,8 +412,33 @@ export async function upsertPullRequestFromGitHub(
// disposition label (pending-closure, manual-review) even while state/headSha stayed correctly protected —
// breaking the flag-then-close two-pass machine, whose Pass 2 keys on the label's presence.
const resolvedLabelsJson = isStalePayload ? existingClaimRow!.labelsJson : jsonString(record.labels);
// #9057: the out-of-order guard protected only state/headSha/mergedAt/githubUpdatedAt/labels. Every OTHER
// column was written unconditionally from a possibly-stale payload — including the ones the linked-issue
// gate reads, which is a hard blocker and a close reason.
//
// Sequence: a contributor pushes (`synchronize`, updated_at=T1), then edits the body to add "Fixes #123"
// (`edited`, updated_at=T2). GitHub does not guarantee delivery order, and once heads diverge the two jobs
// have different coalesce keys, so `synchronize` can be dequeued AFTER `edited`. The guard correctly
// preserved state/headSha/labels — and then reverted `body` and `linkedIssuesJson` to the pre-edit
// snapshot. `synchronize` is itself in PR_PUBLIC_SURFACE_ACTIONS, so the same pass re-ran the gate against
// the reverted body: a wrong autonomous close for "No linked issue detected" on a PR that has one. That
// close reason appears in the live ledger.
//
// These columns are now protected on exactly the same condition. `payloadJson` carries `body`, which feeds
// the AI-review prompt, the screenshot gate, and linked-issue extraction; `baseRef` feeds required contexts,
// migration-collision detection, and the CI aggregate key; `title` feeds the PR-type label and the AI-review
// cache key.
const resolvedTitle = isStalePayload ? existingClaimRow!.title : pr.title;
const resolvedBaseRef = isStalePayload ? (existingClaimRow!.baseRef ?? undefined) : pr.base?.ref;
const resolvedHeadRef = isStalePayload ? (existingClaimRow!.headRef ?? undefined) : pr.head?.ref;
const resolvedAuthorAssociation = isStalePayload ? (existingClaimRow!.authorAssociation ?? undefined) : pr.author_association;
const lastSeenOpenAt = resolvedState === "open" ? (options.seenOpenAt ?? syncedAt) : null;
const preserveSparseBody = pr.body === undefined && existingClaimRow !== undefined;
// #9057: a STALE payload's body must be preserved for exactly the same reason a SPARSE one's is — in both
// cases the incoming body is not the current truth, and re-deriving linked issues from it reverts a
// just-added "Fixes #123" and can drive a wrong "No linked issue detected" close on the very next gate pass.
// Folding it into this one flag keeps the body, payloadJson, linkedIssuesJson and linkedIssueClaimedAt
// derivations below consistent by construction rather than needing four parallel stale-branches.
const preserveSparseBody = (pr.body === undefined || isStalePayload) && existingClaimRow !== undefined;
const existingPayload = preserveSparseBody ? parseJson<{ body?: string | null }>(existingClaimRow.payloadJson, {}) : undefined;
const existingBody = existingPayload?.body ?? null;
const body = preserveSparseBody ? existingBody : record.body;
Expand Down Expand Up @@ -449,13 +479,13 @@ export async function upsertPullRequestFromGitHub(
id: `${repoFullName}#${pr.number}`,
repoFullName,
number: pr.number,
title: pr.title,
title: resolvedTitle,
state: resolvedState,
authorLogin: pr.user?.login,
authorAssociation: pr.author_association,
authorAssociation: resolvedAuthorAssociation,
headSha: resolvedHeadSha,
headRef: pr.head?.ref,
baseRef: pr.base?.ref,
headRef: resolvedHeadRef,
baseRef: resolvedBaseRef,
mergedAt: resolvedMergedAt,
htmlUrl: pr.html_url,
labelsJson: resolvedLabelsJson,
Expand All @@ -476,13 +506,15 @@ export async function upsertPullRequestFromGitHub(
.onConflictDoUpdate({
target: [pullRequests.repoFullName, pullRequests.number],
set: {
title: pr.title,
// #9057: the UPDATE path is the one that actually runs for an existing row, so the stale-payload
// protections must be applied here too — not only in the INSERT values above.
title: resolvedTitle,
state: resolvedState,
authorLogin: pr.user?.login,
authorAssociation: pr.author_association,
authorAssociation: resolvedAuthorAssociation,
headSha: resolvedHeadSha,
headRef: pr.head?.ref,
baseRef: pr.base?.ref,
headRef: resolvedHeadRef,
baseRef: resolvedBaseRef,
mergedAt: resolvedMergedAt,
htmlUrl: pr.html_url,
labelsJson: resolvedLabelsJson,
Expand All @@ -496,7 +528,9 @@ export async function upsertPullRequestFromGitHub(
updatedAt: syncedAt,
},
});
return { ...record, state: resolvedState, headSha: resolvedHeadSha, mergedAt: resolvedMergedAt ?? null, labels: parseJson<string[]>(resolvedLabelsJson, []), body, linkedIssues, linkedIssueClaimedAt, bodyObservedAt, headShaObservedAt };
// #9057: the returned record must describe what was PERSISTED, not the stale incoming payload — every caller
// (handlePullRequestWebhookEvent's own closed-check, the gate pass that follows) acts on this in-process.
return { ...record, title: resolvedTitle, authorAssociation: resolvedAuthorAssociation ?? null, state: resolvedState, headSha: resolvedHeadSha, headRef: resolvedHeadRef ?? null, baseRef: resolvedBaseRef ?? null, mergedAt: resolvedMergedAt ?? null, labels: parseJson<string[]>(resolvedLabelsJson, []), body, linkedIssues, linkedIssueClaimedAt, bodyObservedAt, headShaObservedAt };
}

function resolveLinkedIssueClaimedAt(
Expand Down
63 changes: 53 additions & 10 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4874,10 +4874,12 @@ async function maybeReReviewOnCiCompletion(

/**
* Invalidate the durable CI-state cache on a legacy `status`/`workflow_run` event (#selfhost-ci-verification gate
* review finding). These two event types are NOT wired to re-review triggering (see maybeReReviewOnCiCompletion's
* own doc comment) -- that stays out of scope here -- but leaving the cache itself untouched meant a real legacy
* review finding), and (#9059) wake the affected PRs. Leaving the cache untouched meant a real legacy
* status/workflow_run transition could leave prReadyForReview reading a stale, pre-transition CI aggregate for up
* to the full cache TTL. Deliberately narrower than maybeReReviewOnCiCompletion: only resolves PR numbers via the
* to the full cache TTL; invalidating WITHOUT re-reviewing (the prior behavior) merely moved the stall, since the
* PR then waited on the ~2-minute sweep, which backpressure can skip. `codecov/patch` arrives as a commit
* STATUS and is the gate's hardest required check, so a repo whose last green signal is a status previously got
* no webhook wake at all. Deliberately narrower than maybeReReviewOnCiCompletion: only resolves PR numbers via the
* fast stored-DB head-SHA lookup (no live GitHub fork-fallback call) -- a cache entry only exists for a PR this
* process already tracks, so there is nothing to invalidate for an untracked/fork PR the DB lookup misses.
*/
Expand Down Expand Up @@ -4909,6 +4911,13 @@ async function maybeInvalidateCiCacheOnLegacyCiEvent(
for (const pr of open) {
if (pr.headSha !== headSha) continue;
await invalidateCiStateCache(env, repoFullName, pr.number).catch(() => undefined);
// #9059: invalidating the cache without waking a re-review left the PR waiting for the ~2-minute
// sweep -- which is itself skipped under REST-budget backpressure. That matters specifically because
// `codecov/patch` is a COMMIT STATUS, and it is the gate's hardest required check: a repo whose last
// green signal arrives as a status got no webhook wake at all, so the gate looked hung. Same coalesce
// guard and same call as the check_run/check_suite completion path, so a status storm cannot amplify.
if (await ciReReviewCoalesced(env, repoFullName, pr.number)) continue;
await reReviewStoredPullRequest(env, deliveryId, installationId, repoFullName, pr.number).catch(() => undefined);
}
}
}
Expand Down Expand Up @@ -5897,6 +5906,35 @@ async function maybeHandleForeignAppInstallationWebhookEvent(
return false;
}

/**
* #9056: resolve the PREVIOUS full name for a `repository.renamed` or `repository.transferred` delivery, or
* undefined when it cannot be determined (in which case the caller must do nothing rather than migrate toward
* a guessed identity). A rename changes the NAME under the same owner; a transfer changes the OWNER and keeps
* the name, carrying the old one in `changes.owner.from.{organization,user}.login`.
*
* Pure and exported so every shape — including the ones the live pipeline cannot reach, because the repository
* upsert running alongside this handler requires a full_name and would fail first — is directly testable.
* Returns undefined for a same-name result so an idempotent redelivery is a no-op.
*/
export function resolveRepositoryIdentityPredecessor(
action: string | undefined,
newFullName: string | undefined,
changes: GitHubWebhookPayload["changes"],
): string | undefined {
if (!newFullName) return undefined;
const { owner: currentOwner, name: currentName } = repoParts(newFullName);
const oldFullName =
action === "transferred"
? ((): string | undefined => {
const previousOwner = changes?.owner?.from?.organization?.login ?? changes?.owner?.from?.user?.login;
return previousOwner ? `${previousOwner}/${currentName}` : undefined;
})()
: changes?.repository?.name?.from
? `${currentOwner}/${changes.repository.name.from}`
: undefined;
return oldFullName && oldFullName !== newFullName ? oldFullName : undefined;
}

/**
* Handles a `repository` webhook with `action: "renamed"`: migrates the repo's identity forward across
* every structural repo-identity column (see repo-identity-rename.ts) BEFORE the caller's normal
Expand All @@ -5910,16 +5948,21 @@ async function maybeHandleRepositoryRenamedWebhookEvent(
eventName: string,
payload: GitHubWebhookPayload,
): Promise<void> {
if (eventName !== "repository" || payload.action !== "renamed") return;
const oldName = payload.changes?.repository?.name?.from;
// #9056: `transferred` was never handled even though it is already in WEBHOOK_METRIC_ACTIONS, so the case
// was anticipated and simply never implemented. On a transfer GitHub sends `repository.transferred` carrying
// `changes.owner.from`, then ordinary events under the NEW full_name — and since `repositories` is keyed by
// full_name with no github_id, upsertRepositoryFromGitHub just INSERTs a fresh row. Every table
// renameRepositoryIdentity migrates is then orphaned, including repository_settings: autonomy and gate
// config silently revert to defaults while the repo keeps operating, and staged maintainer approvals in
// agent_pending_actions are lost. Calibration/reversal joins on `project` split across two names, and
// nothing ever heals it.
if (eventName !== "repository" || (payload.action !== "renamed" && payload.action !== "transferred")) return;
const newFullName = payload.repository?.full_name;
if (!oldName || !newFullName) return;
const owner = payload.repository?.owner?.login ?? repoParts(newFullName).owner;
const oldFullName = `${owner}/${oldName}`;
if (oldFullName === newFullName) return;
const oldFullName = resolveRepositoryIdentityPredecessor(payload.action, newFullName, payload.changes);
if (!oldFullName || !newFullName) return;
await renameRepositoryIdentity(env, oldFullName, newFullName);
await recordAuditEvent(env, {
eventType: "github_app.repository_renamed",
eventType: payload.action === "transferred" ? "github_app.repository_transferred" : "github_app.repository_renamed",
actor: "loopover",
targetKey: newFullName,
outcome: "completed",
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,14 @@ export type GitHubWebhookPayload = {
from?: string;
};
};
/** #9056: `repository.transferred` carries the PREVIOUS owner here (the repo name itself is unchanged),
* so the old full name is `changes.owner.from.{organization,user}.login` + the current repo name. */
owner?: {
from?: {
organization?: { login?: string };
user?: { login?: string };
};
};
};
};

Expand Down
59 changes: 59 additions & 0 deletions test/unit/db-parsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,65 @@ describe("database row parser hardening", () => {
expect(stored?.headSha).toBe("a1");
});

// #9057: the guard protected only state/headSha/mergedAt/githubUpdatedAt/labels. Everything else was
// written unconditionally from the stale payload — including body and linkedIssuesJson, which the
// linked-issue gate reads as a hard blocker and close reason. Real sequence: a contributor pushes
// (`synchronize`, T1), then edits the body to add "Fixes #123" (`edited`, T2). Delivery order is not
// guaranteed and the two jobs have different coalesce keys once heads diverge, so `synchronize` could be
// dequeued last — reverting the body, and (because `synchronize` is itself in PR_PUBLIC_SURFACE_ACTIONS)
// re-running the gate against the reverted body for a wrong "No linked issue detected" close.
it("#9057: a stale webhook cannot revert the body, linked issues, title, or baseRef a newer one wrote", async () => {
const env = createTestEnv();
// The `edited` delivery lands first and adds the linked issue.
await upsertPullRequestFromGitHub(env, "owner/repo", {
number: 32, title: "Add retry logic", state: "open", user: { login: "bob" }, head: { sha: "a1", ref: "feature-v2" },
base: { ref: "main" }, body: "Fixes #123", labels: [], author_association: "MEMBER", updated_at: "2026-07-21T12:20:00.000Z",
});
const afterEdit = await getPullRequest(env, "owner/repo", 32);
expect(afterEdit?.linkedIssues).toEqual([123]);

// The delayed `synchronize` job finally dequeues carrying the PRE-edit snapshot.
const stale = await upsertPullRequestFromGitHub(env, "owner/repo", {
number: 32, title: "wip", state: "open", user: { login: "bob" }, head: { sha: "a0", ref: "old-branch" },
base: { ref: "develop" }, body: "no issue yet", labels: [], author_association: "FIRST_TIME_CONTRIBUTOR", updated_at: "2026-07-21T12:10:00.000Z",
});

// The linked issue survives — this is the one that drove the wrong close.
expect(stale.linkedIssues).toEqual([123]);
const stored = await getPullRequest(env, "owner/repo", 32);
expect(stored?.linkedIssues).toEqual([123]);
expect(stored?.body).toBe("Fixes #123");
// ...along with the other columns a stale payload could silently revert.
expect(stored?.title).toBe("Add retry logic");
expect(stored?.baseRef).toBe("main");
expect(stored?.headRef).toBe("feature-v2");
expect(stored?.authorAssociation).toBe("MEMBER");
// The RETURNED record must describe what was persisted too — in-process callers act on it directly, so a
// protected DB row plus a stale return value would still drive a wrong decision this same pass.
expect(stale).toMatchObject({
title: "Add retry logic",
baseRef: "main",
headRef: "feature-v2",
authorAssociation: "MEMBER",
body: "Fixes #123",
});
});

it("#9057: a NEWER webhook still updates body/linked issues/title/baseRef normally", async () => {
const env = createTestEnv();
await upsertPullRequestFromGitHub(env, "owner/repo", {
number: 33, title: "wip", state: "open", user: { login: "bob" }, head: { sha: "a1", ref: "old-branch" },
base: { ref: "develop" }, body: "no issue yet", labels: [], author_association: "FIRST_TIME_CONTRIBUTOR", updated_at: "2026-07-21T12:00:00.000Z",
});
await upsertPullRequestFromGitHub(env, "owner/repo", {
number: 33, title: "Add retry logic", state: "open", user: { login: "bob" }, head: { sha: "a2", ref: "feature-v2" },
base: { ref: "main" }, body: "Fixes #123", labels: [], author_association: "MEMBER", updated_at: "2026-07-21T12:30:00.000Z",
});
const stored = await getPullRequest(env, "owner/repo", 33);
expect(stored?.linkedIssues).toEqual([123]);
expect(stored).toMatchObject({ title: "Add retry logic", baseRef: "main", headRef: "feature-v2", body: "Fixes #123", authorAssociation: "MEMBER" });
});

it("a NEWER webhook (later updated_at) still applies normally", async () => {
const env = createTestEnv();
await upsertPullRequestFromGitHub(env, "owner/repo", {
Expand Down
Loading