|
| 1 | +import { readdirSync, readFileSync } from "node:fs"; |
1 | 2 | import { describe, expect, it } from "vitest"; |
2 | | -import { renameRepositoryIdentity } from "../../src/db/repo-identity-rename"; |
| 3 | +import { RENAME_OUT_OF_SCOPE_TABLES, renameRepositoryIdentity } from "../../src/db/repo-identity-rename"; |
3 | 4 | import { |
4 | 5 | getAgentCommandAnswer, |
5 | 6 | getBurdenForecast, |
@@ -1224,4 +1225,117 @@ describe("renameRepositoryIdentity", () => { |
1224 | 1225 | expect(unrelated?.n).toBe(1); |
1225 | 1226 | }); |
1226 | 1227 | }); |
| 1228 | + |
| 1229 | + describe("linked_issue_claims (#9650)", () => { |
| 1230 | + it("folds a colliding new-name row, keeping the pre-existing old-name row's claimed_at", async () => { |
| 1231 | + const env = createTestEnv(); |
| 1232 | + const ins = (repo: string, pn: number, iss: number, at: string) => |
| 1233 | + env.DB.prepare("INSERT INTO linked_issue_claims (repo_full_name, pull_number, issue_number, claimed_at) VALUES (?,?,?,?)").bind(repo, pn, iss, at).run(); |
| 1234 | + await ins(OLD, 1, 2, "2026-01-01T00:00:00.000Z"); // pre-existing old-name row |
| 1235 | + await ins(NEW, 1, 2, "2026-09-09T00:00:00.000Z"); // stray new-name row that would collide on the composite PK |
| 1236 | + await ins(OLD, 3, 4, "2026-02-02T00:00:00.000Z"); // non-colliding old-name row |
| 1237 | + |
| 1238 | + await renameRepositoryIdentity(env, OLD, NEW); |
| 1239 | + |
| 1240 | + const oldLeft = await env.DB.prepare("select count(*) as n from linked_issue_claims where repo_full_name = ?").bind(OLD).first<{ n: number }>(); |
| 1241 | + expect(oldLeft?.n).toBe(0); |
| 1242 | + // The OLD row's claimed_at survives at (1,2) -- history is never dropped in favor of the new-name row. |
| 1243 | + const kept = await env.DB.prepare("select claimed_at from linked_issue_claims where repo_full_name = ? and pull_number = 1 and issue_number = 2").bind(NEW).first<{ claimed_at: string }>(); |
| 1244 | + expect(kept?.claimed_at).toBe("2026-01-01T00:00:00.000Z"); |
| 1245 | + }); |
| 1246 | + }); |
| 1247 | + |
| 1248 | + for (const { table, col, label, insert } of [ |
| 1249 | + { |
| 1250 | + table: "bounty_lifecycle_events", |
| 1251 | + col: "repo_full_name", |
| 1252 | + label: "bounty_lifecycle_events", |
| 1253 | + insert: (env: Env, repo: string) => |
| 1254 | + env.DB.prepare("INSERT INTO bounty_lifecycle_events (id, bounty_id, repo_full_name, issue_number, status) VALUES (?,?,?,?,?)").bind("ble-1", "b1", repo, 7, "open").run(), |
| 1255 | + }, |
| 1256 | + { |
| 1257 | + table: "webhook_events", |
| 1258 | + col: "repository_full_name", |
| 1259 | + label: "webhook_events", |
| 1260 | + insert: (env: Env, repo: string) => |
| 1261 | + env.DB.prepare("INSERT INTO webhook_events (delivery_id, event_name, repository_full_name, payload_hash, status) VALUES (?,?,?,?,?)").bind("wh-1", "push", repo, "h1", "processed").run(), |
| 1262 | + }, |
| 1263 | + { |
| 1264 | + table: "score_previews", |
| 1265 | + col: "repo_full_name", |
| 1266 | + label: "score_previews", |
| 1267 | + insert: (env: Env, repo: string) => |
| 1268 | + env.DB.prepare("INSERT INTO score_previews (id, scoring_model_snapshot_id, repo_full_name, target_type, target_key) VALUES (?,?,?,?,?)").bind("sp-1", "sm1", repo, "pull_request", "k1").run(), |
| 1269 | + }, |
| 1270 | + ]) { |
| 1271 | + describe(`${label} (#9650)`, () => { |
| 1272 | + it("renames the repo-identity column, leaving zero rows under the old name", async () => { |
| 1273 | + const env = createTestEnv(); |
| 1274 | + await insert(env, OLD); |
| 1275 | + await renameRepositoryIdentity(env, OLD, NEW); |
| 1276 | + const oldLeft = await env.DB.prepare(`select count(*) as n from ${table} where ${col} = ?`).bind(OLD).first<{ n: number }>(); |
| 1277 | + expect(oldLeft?.n).toBe(0); |
| 1278 | + const newRows = await env.DB.prepare(`select count(*) as n from ${table} where ${col} = ?`).bind(NEW).first<{ n: number }>(); |
| 1279 | + expect(newRows?.n).toBe(1); |
| 1280 | + }); |
| 1281 | + }); |
| 1282 | + } |
| 1283 | + |
| 1284 | + describe("renameRepositoryIdentity completeness (drift guard, #9650)", () => { |
| 1285 | + // Every schema.ts table carrying a repo_full_name / repository_full_name column must be either renamed by |
| 1286 | + // this module or explicitly exempt — so the list (which has drifted before) can never silently regress. |
| 1287 | + // Mirrors retention.test.ts's RETENTION_POLICY completeness guard. |
| 1288 | + const REPO_IDENTITY_COLUMNS = new Set(["repo_full_name", "repository_full_name"]); |
| 1289 | + |
| 1290 | + /** Map each schema.ts table that carries a repo-identity column to its Drizzle EXPORT variable name (the |
| 1291 | + * name the rename module references it by, e.g. `pull_requests` -> `pullRequests`). */ |
| 1292 | + async function repoIdentityTables(): Promise<Array<{ sqlName: string; varName: string }>> { |
| 1293 | + const { getTableColumns, getTableName, isTable } = await import("drizzle-orm"); |
| 1294 | + const schema = await import("../../src/db/schema"); |
| 1295 | + const out: Array<{ sqlName: string; varName: string }> = []; |
| 1296 | + for (const [varName, value] of Object.entries(schema)) { |
| 1297 | + if (!isTable(value)) continue; |
| 1298 | + const columns = getTableColumns(value); |
| 1299 | + if (Object.values(columns).some((c) => REPO_IDENTITY_COLUMNS.has((c as { name: string }).name))) { |
| 1300 | + out.push({ sqlName: getTableName(value), varName }); |
| 1301 | + } |
| 1302 | + } |
| 1303 | + return out; |
| 1304 | + } |
| 1305 | + |
| 1306 | + it("every schema.ts table with a repo-identity column is renamed here or explicitly exempt", async () => { |
| 1307 | + const source = readFileSync("src/db/repo-identity-rename.ts", "utf8"); |
| 1308 | + const unaccounted: string[] = []; |
| 1309 | + for (const { sqlName, varName } of await repoIdentityTables()) { |
| 1310 | + // Referenced either by its Drizzle variable (the fold-style blocks) or by its raw SQL name (the plain |
| 1311 | + // UPDATE blocks). Word-boundary on the var name so `pullRequests` doesn't also satisfy `pullRequestFiles`. |
| 1312 | + const referenced = new RegExp(`\\b${varName}\\b`).test(source) || source.includes(sqlName); |
| 1313 | + if (!referenced && !RENAME_OUT_OF_SCOPE_TABLES.has(sqlName)) unaccounted.push(sqlName); |
| 1314 | + } |
| 1315 | + expect(unaccounted).toEqual([]); |
| 1316 | + }); |
| 1317 | + |
| 1318 | + it("no table is both renamed here and listed as out-of-scope", async () => { |
| 1319 | + const source = readFileSync("src/db/repo-identity-rename.ts", "utf8"); |
| 1320 | + const tablesByName = new Map((await repoIdentityTables()).map((t) => [t.sqlName, t.varName])); |
| 1321 | + const both = [...RENAME_OUT_OF_SCOPE_TABLES].filter((sqlName) => { |
| 1322 | + const varName = tablesByName.get(sqlName); |
| 1323 | + // "Renamed here" means an actual UPDATE, not merely a mention in the out-of-scope prose. The prose names |
| 1324 | + // the exempt tables in comments only; a real rename references the Drizzle var or `<sql_name> SET`. |
| 1325 | + return source.includes(`${sqlName} SET`) || (varName !== undefined && new RegExp(`\\.update\\(${varName}\\)`).test(source)); |
| 1326 | + }); |
| 1327 | + expect(both).toEqual([]); |
| 1328 | + }); |
| 1329 | + |
| 1330 | + it("no RENAME_OUT_OF_SCOPE_TABLES entry is dead (still exists in schema.ts or migrations/)", async () => { |
| 1331 | + const schemaSqlNames = new Set((await repoIdentityTables()).map((t) => t.sqlName)); |
| 1332 | + const migrationsSql = readdirSync("migrations") |
| 1333 | + .filter((f) => f.endsWith(".sql")) |
| 1334 | + .map((f) => readFileSync(`migrations/${f}`, "utf8")) |
| 1335 | + .join("\n") |
| 1336 | + .toLowerCase(); |
| 1337 | + const dead = [...RENAME_OUT_OF_SCOPE_TABLES].filter((table) => !schemaSqlNames.has(table) && !migrationsSql.includes(table.toLowerCase())); |
| 1338 | + expect(dead).toEqual([]); |
| 1339 | + }); |
| 1340 | + }); |
1227 | 1341 | }); |
0 commit comments