Skip to content

Commit d5347f9

Browse files
committed
fix(gate): recheck migration collisions at merge
1 parent 7a152d8 commit d5347f9

5 files changed

Lines changed: 178 additions & 78 deletions

File tree

src/queue/processors.ts

Lines changed: 2 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,7 @@ import {
247247
} from "../settings/agent-actions";
248248
import { isAutoCloseExempt } from "../settings/auto-close-exempt";
249249
import { resolveGlobalContributorOpenItemCap } from "../settings/global-contributor-cap";
250-
import { detectMigrationCollisions, extractMigrationNumber, KNOWN_MIGRATION_DUPLICATES } from "../db/migration-collisions";
251-
import { listMigrationFilenamesAtRef } from "../github/migration-tree";
250+
import { migrationFilenamesForLiveRecheck, resolveLiveMigrationCollisionHold } from "../services/migration-collision-recheck";
252251
import {
253252
executeAgentMaintenanceActions,
254253
executeIssueMaintenanceActions,
@@ -1603,59 +1602,6 @@ export function changedPathsForGuardrail(
16031602
return [...paths];
16041603
}
16051604

1606-
/**
1607-
* Live premerge migrations/** collision recheck (#2550). `check-migrations.mjs` (CI) only validates against
1608-
* THIS PR's own branch snapshot at the time CI ran — it can never see a sibling PR that merged a
1609-
* same-numbered migration file to `baseRef` in the meantime. This does the live check right before the
1610-
* merge-decision moment: fetch the base branch's CURRENT migration filenames, drop any filename THIS PR's
1611-
* own diff removes from the base (an outright deletion, or a rename's pre-rename name — otherwise renaming
1612-
* an existing base migration self-collides with its own old name, which is still live on `baseRef` until
1613-
* this PR merges), union what's left with THIS PR's own new migration filenames (the live tree never
1614-
* contains this PR's own not-yet-merged files, so checking main alone could never detect a collision from
1615-
* this PR's perspective — the union is load-bearing, not optional), then run the SAME collision-detection
1616-
* function scripts/check-migrations.mjs uses.
1617-
*
1618-
* Deliberately scoped to a collision involving THIS PR's own migration number(s) only (via `prNumbers`) — a
1619-
* pre-existing collision between two OTHER already-merged files (which would mean `main` itself is already
1620-
* broken, a separate problem CI already surfaces loudly) must not hold an unrelated third PR whose own
1621-
* migration number doesn't collide with anything.
1622-
*
1623-
* Fail-OPEN throughout: a missing baseRef or a failed live fetch returns undefined (no hold) rather than
1624-
* risking a false hold on inconclusive data — this is a safety net, not a new way to get PRs stuck.
1625-
*/
1626-
// Deliberately UNCACHED: this is the safety check the whole feature exists to provide, so it must always
1627-
// read the live tree fresh. A cache keyed by repo+baseRef (even a short-TTL one) can serve a snapshot taken
1628-
// BEFORE a sibling PR merged its own colliding migration — defeating the exact race this function exists to
1629-
// catch (PR A merges 0099, a still-cached pre-merge tree lets a later-processed PR B also merge its own 0099
1630-
// within the cache window). The existing GitHub rate-limit admission/backoff mechanism (the same
1631-
// `admissionKey` every other live call in this function already uses) already bounds the cost; correctness
1632-
// here matters far more than shaving a redundant API call.
1633-
async function resolveLiveMigrationCollisionHold(
1634-
args: {
1635-
repoFullName: string;
1636-
baseRef: string | null | undefined;
1637-
token: string | undefined;
1638-
admissionKey: GitHubRateLimitAdmissionKey | undefined;
1639-
prMigrationFilenames: string[];
1640-
prRemovedMigrationFilenames: string[];
1641-
},
1642-
): Promise<{ reason: string; comment: string } | undefined> {
1643-
if (!args.baseRef) return undefined;
1644-
const liveFilenames = await listMigrationFilenamesAtRef(args.repoFullName, args.baseRef, args.token, args.admissionKey);
1645-
if (liveFilenames === null) return undefined;
1646-
const removedFromBase = new Set(args.prRemovedMigrationFilenames);
1647-
const effectiveLiveFilenames = liveFilenames.filter((f) => !removedFromBase.has(f));
1648-
const union = [...new Set([...effectiveLiveFilenames, ...args.prMigrationFilenames])];
1649-
const prNumbers = new Set(args.prMigrationFilenames.map((f) => extractMigrationNumber(f)).filter((n): n is number => n !== null));
1650-
const collisions = detectMigrationCollisions(union, KNOWN_MIGRATION_DUPLICATES).filter((c) => prNumbers.has(c.number));
1651-
if (collisions.length === 0) return undefined;
1652-
const detail = collisions.map((c) => `${c.paddedNumber}: ${c.files.join(", ")}`).join("; ");
1653-
return {
1654-
reason: `live migrations/** collision on ${args.baseRef} (${detail})`,
1655-
comment: `Gittensory: a live check of \`migrations/**\` on \`${args.baseRef}\` found a migration-number collision that isn't visible from this PR's own diff — another PR merged a same-numbered migration file since this PR's CI last ran (**${detail}**). This PR is held for manual review — please rebase onto the latest \`${args.baseRef}\` and renumber your migration to the next free number before this can merge.`,
1656-
};
1657-
}
1658-
16591605
/**
16601606
* Chain the two INDEPENDENT precision circuit-breakers over a planned action set (the merge-side and close-side
16611607
* downgrades), in order. PURE — the live flag reads happen at the call site (each fail-open), so this composes
@@ -1920,25 +1866,7 @@ async function runAgentMaintenancePlanAndExecute(
19201866
// producing a false hold that can never clear (a later rename still carries the stale old name forever, on
19211867
// every subsequent maintenance pass). Only `.path` (the file's CURRENT name) and only non-removed files
19221868
// reflect what will actually exist in this PR's tree once merged.
1923-
const prMigrationFilenames = changedFiles
1924-
.filter((f) => f.status !== "removed" && f.path.startsWith("migrations/") && f.path.endsWith(".sql"))
1925-
.map((f) => f.path.slice("migrations/".length));
1926-
// Base filenames this PR's diff removes from `migrations/**` — an outright deletion's own `.path`, or a
1927-
// rename's pre-rename `.previousFilename` — so a filename that won't exist once this PR merges isn't still
1928-
// counted from the live base fetch below. Without this, renaming an EXISTING base migration within the same
1929-
// number (e.g. `migrations/0099_old.sql` -> `migrations/0099_new.sql`, fixing a typo on an already-merged
1930-
// file) unions both the old (still live) and new (this PR's) name and self-collides, even though the merged
1931-
// tree would only ever contain the new file.
1932-
const prRemovedMigrationFilenames = changedFiles.flatMap((f) => {
1933-
const removed: string[] = [];
1934-
if (f.status === "removed" && f.path.startsWith("migrations/") && f.path.endsWith(".sql")) {
1935-
removed.push(f.path.slice("migrations/".length));
1936-
}
1937-
if (f.previousFilename && f.previousFilename.startsWith("migrations/") && f.previousFilename.endsWith(".sql")) {
1938-
removed.push(f.previousFilename.slice("migrations/".length));
1939-
}
1940-
return removed;
1941-
});
1869+
const { prMigrationFilenames, prRemovedMigrationFilenames } = migrationFilenamesForLiveRecheck(changedFiles);
19421870
const migrationCollisionHold =
19431871
settings.premergeContentRecheck === true && prMigrationFilenames.length > 0
19441872
? await resolveLiveMigrationCollisionHold({

src/services/agent-action-executor.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { bumpPullRequestMergeAttempt, createPendingAgentActionIfAbsent, insertNotificationDeliveryIfAbsent, isGlobalAgentFrozen, markPullRequestApproved, markPullRequestMergeBlocked, recordAuditEvent } from "../db/repositories";
1+
import { bumpPullRequestMergeAttempt, createPendingAgentActionIfAbsent, getPullRequest, insertNotificationDeliveryIfAbsent, isGlobalAgentFrozen, listPullRequestFiles, markPullRequestApproved, markPullRequestMergeBlocked, recordAuditEvent } from "../db/repositories";
22
import { classifyMergeFailure, MERGE_RETRY_CAP } from "./merge-failure";
33
import { notifyActionToDiscord, notifyActionToSlack, type NotifyOutcome } from "./notify-discord";
44
import { createInstallationToken, githubErrorStatus, isGitHubRateLimitedError } from "../github/app";
@@ -9,10 +9,12 @@ import { closeIssue, closePullRequest, createIssueComment, createPullRequestRevi
99
import { fetchPullRequestFreshness, pullRequestFreshnessDetail } from "../github/pr-freshness";
1010
import { isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy";
1111
import { buildAgentActionAudit, isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution";
12-
import type { PlannedAgentAction } from "../settings/agent-actions";
12+
import { AGENT_LABEL_MIGRATION_COLLISION, type PlannedAgentAction } from "../settings/agent-actions";
1313
import type { AgentActionClass, AgentPendingActionParams, AutonomyLevel, AutonomyPolicy } from "../types";
1414
import { errorMessage } from "../utils/json";
1515
import { AGENT_LABEL_PENDING_CLOSURE } from "../review/linked-issue-hard-rules";
16+
import { migrationFilenamesForLiveRecheck, resolveLiveMigrationCollisionHold } from "./migration-collision-recheck";
17+
import { resolveRepositorySettings } from "../settings/repository-settings";
1618

1719
// The agent actor name on every audit record — the App acts on the maintainer's behalf per their configured
1820
// autonomy (the config IS the authorization; there is no human commenter to authorize, unlike #824).
@@ -168,12 +170,44 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE
168170
continue;
169171
}
170172
}
171-
// 7) Write-permission readiness: a PR-write action needs `pull_requests: write` granted.
173+
// 7) Actuation-time migrations/** recheck (#2550): planning already checks this, but a sibling PR can merge
174+
// a same-numbered migration while a merge waits in auto_with_approval or between planning and actuation.
175+
// Re-read the live base tree at the final mutation boundary so the checked invariant is fresh.
176+
if (action.actionClass === "merge") {
177+
const changedFiles = await listPullRequestFiles(env, ctx.repoFullName, ctx.pullNumber);
178+
const { prMigrationFilenames, prRemovedMigrationFilenames } = migrationFilenamesForLiveRecheck(changedFiles);
179+
if (prMigrationFilenames.length > 0) {
180+
const settings = await resolveRepositorySettings(env, ctx.repoFullName);
181+
if (settings.premergeContentRecheck === true) {
182+
const [pr, migrationToken] = await Promise.all([
183+
getPullRequest(env, ctx.repoFullName, ctx.pullNumber),
184+
createInstallationToken(env, ctx.installationId).catch(() => undefined),
185+
]);
186+
const migrationAdmissionKey = githubRateLimitAdmissionKeyForToken(env, migrationToken, ctx.installationId);
187+
const migrationCollisionHold = await resolveLiveMigrationCollisionHold({
188+
repoFullName: ctx.repoFullName,
189+
baseRef: pr?.baseRef,
190+
token: migrationToken,
191+
admissionKey: migrationAdmissionKey,
192+
prMigrationFilenames,
193+
prRemovedMigrationFilenames,
194+
});
195+
if (migrationCollisionHold !== undefined) {
196+
await ensurePullRequestLabel(env, ctx.installationId, ctx.repoFullName, ctx.pullNumber, AGENT_LABEL_MIGRATION_COLLISION, { createMissingLabel: true, mode }).catch(() => undefined);
197+
await createIssueComment(env, ctx.installationId, ctx.repoFullName, ctx.pullNumber, migrationCollisionHold.comment).catch(() => undefined);
198+
await audit("denied", `${migrationCollisionHold.reason} — action not executed`);
199+
continue;
200+
}
201+
}
202+
}
203+
}
204+
205+
// 8) Write-permission readiness: a PR-write action needs `pull_requests: write` granted.
172206
if (PR_WRITE_CLASSES.has(action.actionClass) && resolveAgentPermissionReadiness({ autonomy: ctx.autonomy, installationPermissions: ctx.installationPermissions }) !== "ready") {
173207
await audit("denied", "pull_requests: write not granted — maintainer must re-consent");
174208
continue;
175209
}
176-
// 8) live — perform the real mutation, recording success or the error.
210+
// 9) live — perform the real mutation, recording success or the error.
177211
try {
178212
await performAction(env, ctx, action);
179213
await audit("completed", action.reason);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { detectMigrationCollisions, extractMigrationNumber, KNOWN_MIGRATION_DUPLICATES } from "../db/migration-collisions";
2+
import { listMigrationFilenamesAtRef } from "../github/migration-tree";
3+
import type { GitHubRateLimitAdmissionKey } from "../github/client";
4+
import type { PullRequestFileRecord } from "../types";
5+
6+
export type MigrationCollisionHold = { reason: string; comment: string };
7+
8+
export function migrationFilenamesForLiveRecheck(changedFiles: readonly Pick<PullRequestFileRecord, "path" | "status" | "previousFilename">[]): { prMigrationFilenames: string[]; prRemovedMigrationFilenames: string[] } {
9+
const prMigrationFilenames = changedFiles
10+
.filter((f) => f.status !== "removed" && f.path.startsWith("migrations/") && f.path.endsWith(".sql"))
11+
.map((f) => f.path.slice("migrations/".length));
12+
const prRemovedMigrationFilenames = changedFiles.flatMap((f) => {
13+
const removed: string[] = [];
14+
if (f.status === "removed" && f.path.startsWith("migrations/") && f.path.endsWith(".sql")) {
15+
removed.push(f.path.slice("migrations/".length));
16+
}
17+
if (f.previousFilename && f.previousFilename.startsWith("migrations/") && f.previousFilename.endsWith(".sql")) {
18+
removed.push(f.previousFilename.slice("migrations/".length));
19+
}
20+
return removed;
21+
});
22+
return { prMigrationFilenames, prRemovedMigrationFilenames };
23+
}
24+
25+
/**
26+
* Live premerge migrations/** collision recheck (#2550). Always reads the base tree fresh; callers invoke this
27+
* both while planning and again at merge actuation so an approval-queue wait or concurrent sibling merge cannot
28+
* reuse a stale no-collision decision.
29+
*/
30+
export async function resolveLiveMigrationCollisionHold(args: {
31+
repoFullName: string;
32+
baseRef: string | null | undefined;
33+
token: string | undefined;
34+
admissionKey: GitHubRateLimitAdmissionKey | undefined;
35+
prMigrationFilenames: string[];
36+
prRemovedMigrationFilenames: string[];
37+
}): Promise<MigrationCollisionHold | undefined> {
38+
if (!args.baseRef) return undefined;
39+
const liveFilenames = await listMigrationFilenamesAtRef(args.repoFullName, args.baseRef, args.token, args.admissionKey);
40+
if (liveFilenames === null) return undefined;
41+
const removedFromBase = new Set(args.prRemovedMigrationFilenames);
42+
const effectiveLiveFilenames = liveFilenames.filter((f) => !removedFromBase.has(f));
43+
const union = [...new Set([...effectiveLiveFilenames, ...args.prMigrationFilenames])];
44+
const prNumbers = new Set(args.prMigrationFilenames.map((f) => extractMigrationNumber(f)).filter((n): n is number => n !== null));
45+
const collisions = detectMigrationCollisions(union, KNOWN_MIGRATION_DUPLICATES).filter((c) => prNumbers.has(c.number));
46+
if (collisions.length === 0) return undefined;
47+
const detail = collisions.map((c) => `${c.paddedNumber}: ${c.files.join(", ")}`).join("; ");
48+
return {
49+
reason: `live migrations/** collision on ${args.baseRef} (${detail})`,
50+
comment: `Gittensory: a live check of \`migrations/**\` on \`${args.baseRef}\` found a migration-number collision that isn't visible from this PR's own diff — another PR merged a same-numbered migration file since this PR's CI last ran (**${detail}**). This PR is held for manual review — please rebase onto the latest \`${args.baseRef}\` and renumber your migration to the next free number before this can merge.`,
51+
};
52+
}

0 commit comments

Comments
 (0)