From f03cb2892bc9a8641a94a1f55af3d1d3542d2f3e Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Fri, 24 Jul 2026 20:50:07 +0800 Subject: [PATCH] fix(db): fold seven Drizzle-schema identity tables into renameRepositoryIdentity (#8379) --- src/db/repo-identity-rename.ts | 87 +++++++++++++++ test/unit/repo-identity-rename.test.ts | 146 +++++++++++++++++++++++++ 2 files changed, 233 insertions(+) diff --git a/src/db/repo-identity-rename.ts b/src/db/repo-identity-rename.ts index 2e4222637..446b75d0c 100644 --- a/src/db/repo-identity-rename.ts +++ b/src/db/repo-identity-rename.ts @@ -28,15 +28,19 @@ import { getDb } from "./client"; import { activeReviewTracking, advisories, + agentPendingActions, auditEvents, + bounties, burdenForecasts, checkSummaries, collisionEdges, contributorRepoStats, gateOutcomes, githubAgentCommandAnswers, + githubAgentCommandFeedback, githubRateLimitObservations, issues, + issueWatchSubscriptions, notificationDeliveries, productUsageEvents, pullRequestDetailSyncState, @@ -48,8 +52,11 @@ import { repoLabels, repoQueueTrendSnapshots, repositories, + repositoryAiKeys, + repositoryLinearKeys, repositorySettings, repoSnapshots, + reviewSuppression, repoSyncSegments, repoSyncState, signalSnapshots, @@ -356,6 +363,86 @@ export async function renameRepositoryIdentity(env: Env, oldFullName: string, ne // carry no repo at all) and there is no index of any kind on this table. Plain rename. await db.update(signalSnapshots).set({ repoFullName: newFullName }).where(eq(signalSnapshots.repoFullName, oldFullName)); + // Seven more Drizzle-schema tables carrying a repo_full_name identity column with live writers (#8379), + // each folded by its REAL constraint (verified in schema.ts), following the shapes already used above: + + // repositoryAiKeys / repositoryLinearKeys: `repo_full_name` is itself the PRIMARY KEY -- same + // fold-then-rename anchor shape as repositories/repositorySettings above. Only the identity column moves; + // the encrypted key material rides along untouched. + await db.delete(repositoryAiKeys).where(eq(repositoryAiKeys.repoFullName, newFullName)); + await db.update(repositoryAiKeys).set({ repoFullName: newFullName }).where(eq(repositoryAiKeys.repoFullName, oldFullName)); + + await db.delete(repositoryLinearKeys).where(eq(repositoryLinearKeys.repoFullName, newFullName)); + await db.update(repositoryLinearKeys).set({ repoFullName: newFullName }).where(eq(repositoryLinearKeys.repoFullName, oldFullName)); + + // bounties: unique (repo_full_name, issue_number) -- fold on the other half, issue_number. `id` is an + // EXTERNAL bounty id (String(issue.id) from the Gitt snapshot, src/bounties/ingest.ts), never derived from + // the repo name, so unlike pullRequests/issues there is no id substring to rewrite. + const collidingBountyIssues = ( + await db.select({ issueNumber: bounties.issueNumber }).from(bounties).where(eq(bounties.repoFullName, oldFullName)) + ).map((row) => row.issueNumber); + if (collidingBountyIssues.length > 0) { + await db.delete(bounties).where(and(eq(bounties.repoFullName, newFullName), inArray(bounties.issueNumber, collidingBountyIssues))); + } + await db.update(bounties).set({ repoFullName: newFullName }).where(eq(bounties.repoFullName, oldFullName)); + + // reviewSuppression: unique (repo_full_name, category, path_glob, pattern_hash) -- per-tuple fold like + // checkSummaries above, but every fold column is NOT NULL (schema.ts), so a plain eq() per column suffices + // with no isNull branching. + const collidingSuppressionKeys = await db + .select({ category: reviewSuppression.category, pathGlob: reviewSuppression.pathGlob, patternHash: reviewSuppression.patternHash }) + .from(reviewSuppression) + .where(eq(reviewSuppression.repoFullName, oldFullName)); + for (const key of collidingSuppressionKeys) { + await db + .delete(reviewSuppression) + .where( + and( + eq(reviewSuppression.repoFullName, newFullName), + eq(reviewSuppression.category, key.category), + eq(reviewSuppression.pathGlob, key.pathGlob), + eq(reviewSuppression.patternHash, key.patternHash), + ), + ); + } + await db.update(reviewSuppression).set({ repoFullName: newFullName }).where(eq(reviewSuppression.repoFullName, oldFullName)); + + // agentPendingActions: unique (repo_full_name, pull_number, action_class) -- same per-tuple fold, both + // fold columns NOT NULL. A decided row is sticky, so folding in favor of the pre-existing oldFullName row + // preserves the maintainer's actual accept/reject decision rather than a fresh post-rename `pending`. + const collidingPendingActionKeys = await db + .select({ pullNumber: agentPendingActions.pullNumber, actionClass: agentPendingActions.actionClass }) + .from(agentPendingActions) + .where(eq(agentPendingActions.repoFullName, oldFullName)); + for (const key of collidingPendingActionKeys) { + await db + .delete(agentPendingActions) + .where( + and( + eq(agentPendingActions.repoFullName, newFullName), + eq(agentPendingActions.pullNumber, key.pullNumber), + eq(agentPendingActions.actionClass, key.actionClass), + ), + ); + } + await db.update(agentPendingActions).set({ repoFullName: newFullName }).where(eq(agentPendingActions.repoFullName, oldFullName)); + + // issueWatchSubscriptions: unique (login, repo_full_name) -- fold on the single `login` column, the same + // shape as contributorRepoStats above. `id` is a random UUID, so no id rewrite. + const collidingWatchLogins = ( + await db.select({ login: issueWatchSubscriptions.login }).from(issueWatchSubscriptions).where(eq(issueWatchSubscriptions.repoFullName, oldFullName)) + ).map((row) => row.login); + if (collidingWatchLogins.length > 0) { + await db + .delete(issueWatchSubscriptions) + .where(and(eq(issueWatchSubscriptions.repoFullName, newFullName), inArray(issueWatchSubscriptions.login, collidingWatchLogins))); + } + await db.update(issueWatchSubscriptions).set({ repoFullName: newFullName }).where(eq(issueWatchSubscriptions.repoFullName, oldFullName)); + + // githubAgentCommandFeedback: its only unique index is (answer_id, actor_hash) -- repo_full_name appears + // solely in a NON-unique index, so nothing can collide. Plain rename, same as repoSnapshots above. + await db.update(githubAgentCommandFeedback).set({ repoFullName: newFullName }).where(eq(githubAgentCommandFeedback.repoFullName, oldFullName)); + // REES/parity tables below (review_audit, contributor_gate_history, submitter_stats) are raw-SQL-only -- // deliberately NOT added to the Drizzle schema (see each table's own migration header) -- so these three // blocks use env.DB.prepare() directly instead of the query builder, matching how every other writer of diff --git a/test/unit/repo-identity-rename.test.ts b/test/unit/repo-identity-rename.test.ts index 6f30bee2a..225b13075 100644 --- a/test/unit/repo-identity-rename.test.ts +++ b/test/unit/repo-identity-rename.test.ts @@ -17,6 +17,7 @@ import { listCollisionEdges, listContributorRepoStats, listProductUsageEvents, + listIssueWatchersForRepo, listPullRequests, listRecentMergedPullRequests, listRepoLabels, @@ -36,6 +37,7 @@ import { upsertCheckSummary, upsertContributorRepoStat, upsertIssueFromGitHub, + upsertIssueWatchSubscription, upsertPullRequestDetailSyncState, upsertPullRequestFile, upsertPullRequestFromGitHub, @@ -1058,6 +1060,150 @@ describe("renameRepositoryIdentity", () => { }); }); + // #8379: seven more Drizzle-schema tables carrying a repo_full_name identity column with live writers. + describe("repository_ai_keys / repository_linear_keys (#8379)", () => { + const insertAiKey = (env: Env, repo: string, ciphertext: string) => + env.DB.prepare("INSERT INTO repository_ai_keys (repo_full_name, provider, ciphertext, iv, last4) VALUES (?, 'openai', ?, 'iv', '1234')").bind(repo, ciphertext).run(); + // repository_linear_keys is the one of the two whose timestamp columns carry no SQL default (Drizzle + // supplies them at runtime), so they are passed explicitly here. + const insertLinearKey = (env: Env, repo: string, ciphertext: string) => + env.DB.prepare( + "INSERT INTO repository_linear_keys (repo_full_name, ciphertext, iv, last4, created_at, updated_at) VALUES (?, ?, 'iv', '5678', '2026-07-01T00:00:00Z', '2026-07-01T00:00:00Z')", + ) + .bind(repo, ciphertext) + .run(); + + it("folds a stray new-name key row in both stores, carrying the old row's key material forward", async () => { + const env = createTestEnv(); + await insertAiKey(env, OLD, "the-real-openai-key"); + await insertAiKey(env, NEW, "stray-fragment"); // the collision on the repo_full_name PK + await insertLinearKey(env, OLD, "the-real-linear-key"); + await insertLinearKey(env, NEW, "stray-fragment"); + + await renameRepositoryIdentity(env, OLD, NEW); + + const ai = await env.DB.prepare("select ciphertext from repository_ai_keys where repo_full_name = ?").bind(NEW).all<{ ciphertext: string }>(); + expect(ai.results).toEqual([{ ciphertext: "the-real-openai-key" }]); + const linear = await env.DB.prepare("select ciphertext from repository_linear_keys where repo_full_name = ?").bind(NEW).all<{ ciphertext: string }>(); + expect(linear.results).toEqual([{ ciphertext: "the-real-linear-key" }]); + const staleAi = await env.DB.prepare("select count(*) as n from repository_ai_keys where repo_full_name = ?").bind(OLD).first<{ n: number }>(); + expect(staleAi?.n).toBe(0); + }); + }); + + describe("bounties (#8379)", () => { + const insert = (env: Env, id: string, repo: string, issueNumber: number, status: string) => + env.DB.prepare("INSERT INTO bounties (id, repo_full_name, issue_number, status) VALUES (?, ?, ?, ?)").bind(id, repo, issueNumber, status).run(); + + it("moves bounties forward, folds a colliding issue_number, and leaves the external id untouched", async () => { + const env = createTestEnv(); + await insert(env, "ext-1", OLD, 10, "open"); + await insert(env, "ext-2", NEW, 10, "closed"); // collides on (repo, issue_number) + await insert(env, "ext-3", NEW, 11, "open"); // a different issue under the new name survives + + await renameRepositoryIdentity(env, OLD, NEW); + + const rows = await env.DB.prepare("select id, issue_number, status from bounties where repo_full_name = ? order by issue_number").bind(NEW).all<{ id: string; issue_number: number; status: string }>(); + expect(rows.results).toEqual([ + { id: "ext-1", issue_number: 10, status: "open" }, // the old row won the fold, id unchanged + { id: "ext-3", issue_number: 11, status: "open" }, + ]); + const stale = await env.DB.prepare("select count(*) as n from bounties where repo_full_name = ?").bind(OLD).first<{ n: number }>(); + expect(stale?.n).toBe(0); + }); + }); + + describe("review_suppression (#8379)", () => { + const insert = (env: Env, id: string, repo: string, category: string, pathGlob: string, patternHash: string) => + env.DB.prepare("INSERT INTO review_suppression (id, repo_full_name, category, path_glob, pattern_hash) VALUES (?, ?, ?, ?, ?)").bind(id, repo, category, pathGlob, patternHash).run(); + + it("folds only the exact colliding 4-column tuple, leaving other suppression rules intact", async () => { + const env = createTestEnv(); + await insert(env, "s-old", OLD, "style", "src/**", "hash-a"); + await insert(env, "s-collide", NEW, "style", "src/**", "hash-a"); // same tuple: folded away + await insert(env, "s-other-hash", NEW, "style", "src/**", "hash-b"); // differs only by hash: survives + await insert(env, "s-other-cat", NEW, "security", "src/**", "hash-a"); // differs only by category: survives + + await renameRepositoryIdentity(env, OLD, NEW); + + const rows = await env.DB.prepare("select id from review_suppression where repo_full_name = ? order by id").bind(NEW).all<{ id: string }>(); + expect(rows.results.map((r) => r.id)).toEqual(["s-old", "s-other-cat", "s-other-hash"]); + }); + }); + + describe("agent_pending_actions (#8379)", () => { + const insert = (env: Env, id: string, repo: string, pullNumber: number, actionClass: string, status: string) => + env.DB.prepare( + "INSERT INTO agent_pending_actions (id, repo_full_name, pull_number, installation_id, action_class, autonomy_level, status) VALUES (?, ?, ?, 1, ?, 'auto_with_approval', ?)", + ) + .bind(id, repo, pullNumber, actionClass, status) + .run(); + + it("folds only the colliding (pull_number, action_class) tuple, preserving the maintainer's decided row", async () => { + const env = createTestEnv(); + await insert(env, "a-old", OLD, 3, "merge", "accepted"); // a real maintainer decision + await insert(env, "a-collide", NEW, 3, "merge", "pending"); // the post-rename fragment: folded away + await insert(env, "a-other-class", NEW, 3, "label", "pending"); // same pull, different class: survives + await insert(env, "a-other-pull", NEW, 4, "merge", "pending"); // different pull: survives + + await renameRepositoryIdentity(env, OLD, NEW); + + const rows = await env.DB.prepare("select id, status from agent_pending_actions where repo_full_name = ? order by id").bind(NEW).all<{ id: string; status: string }>(); + expect(rows.results).toEqual([ + { id: "a-old", status: "accepted" }, + { id: "a-other-class", status: "pending" }, + { id: "a-other-pull", status: "pending" }, + ]); + }); + }); + + describe("issue_watch_subscriptions (#8379)", () => { + it("folds a colliding login's stray row and keeps a different watcher's subscription", async () => { + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: OLD, labels: ["bug"] }); + await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: NEW }); // collides on (login, repo) + await upsertIssueWatchSubscription(env, { login: "bob", repoFullName: NEW }); + + await renameRepositoryIdentity(env, OLD, NEW); + + const watchers = await listIssueWatchersForRepo(env, NEW); + expect(watchers.map((w) => w.login).sort()).toEqual(["alice", "bob"]); + expect(watchers.find((w) => w.login === "alice")?.labels).toEqual(["bug"]); // the old row's labels won + expect(await listIssueWatchersForRepo(env, OLD)).toEqual([]); + }); + }); + + describe("github_agent_command_feedback (#8379)", () => { + it("renames every feedback row (no unique index on repo_full_name, so nothing folds)", async () => { + const env = createTestEnv(); + await upsertAgentCommandAnswer(env, { + id: "answer-fb", + repoFullName: OLD, + issueNumber: 12, + command: "preflight", + responseUrl: "https://github.com/owner/gittensory/issues/12", + actorKind: "author", + metadata: {}, + }); + const insert = (id: string, repo: string, actorHash: string) => + env.DB.prepare( + "INSERT INTO github_agent_command_feedback (id, answer_id, repo_full_name, issue_number, command, actor_hash, vote, source, actor_kind) VALUES (?, 'answer-fb', ?, 12, 'preflight', ?, 'up', 'reaction', 'author')", + ) + .bind(id, repo, actorHash) + .run(); + await insert("fb-1", OLD, "hash-1"); + await insert("fb-2", OLD, "hash-2"); + await insert("fb-3", "some/other-repo", "hash-3"); + + await renameRepositoryIdentity(env, OLD, NEW); + + const renamed = await env.DB.prepare("select count(*) as n from github_agent_command_feedback where repo_full_name = ?").bind(NEW).first<{ n: number }>(); + expect(renamed?.n).toBe(2); // both survive: no unique constraint on this column + const unrelated = await env.DB.prepare("select count(*) as n from github_agent_command_feedback where repo_full_name = ?").bind("some/other-repo").first<{ n: number }>(); + expect(unrelated?.n).toBe(1); + }); + }); + describe("audit_events", () => { it("renames every target_key containing the old full name, including composite repo#number keys, leaving unrelated keys untouched", async () => { const env = createTestEnv();