From e3466fd15dfdfc827a9edb1a70712a6a8ac7cd9b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:14:56 -0700 Subject: [PATCH] fix(github): extend repo-rename identity migration to analytics tables Follow-up to #5918/#5950: extends renameRepositoryIdentity to 14 more repo_full_name-keyed tables: - burden_forecasts, repo_queue_trend_snapshots, repo_sync_state: repo_full_name itself IS the primary key (single row per repo) -- same fold-then-rename shape as the anchor tables. - repo_sync_segments (unique on segment), contributor_repo_stats (unique on login), repo_labels (unique on name): each has an id that embeds the repo name and a unique index pairing repo_full_name with one other column -- folds on that column. - collision_edges: id embeds the repo name but is built in packages/loopover-engine and passed through verbatim, with no unique index -- folds on the id collision the rename would produce, same shape as pull_request_reviews. - notification_deliveries, github_agent_command_answers, repo_snapshots, repo_github_totals_snapshots, github_rate_limit_observations, product_usage_events, signal_snapshots: id is always a random UUID with no unique constraint tied to repo_full_name (several nullable) -- plain renames. notification_deliveries.deeplink and github_agent_command_answers.response_url are also rewritten, same as the anchor tables' own html_url treatment (their own canonical GitHub link, not incidental content). Deliberately excludes the request-scoped AI/LLM result caches (ai_review_cache, ai_slop_cache, linked_issue_satisfaction_cache, grounding_file_content_cache) and the RAG chunk cache (repo_chunks) -- every one is a rebuildable cache, not identity data: a miss after a rename just re-runs one LLM call or one re-index pass at the new name, which is graceful and self-healing, unlike an orphaned PR/issue/audit row a maintainer would otherwise need a GitHub API backfill to recover. Documented inline so the omission reads as deliberate, not forgotten. 24 new tests: a rename test for all 14 tables, plus a collision-fold regression test for the 7 that have a real fold path (three PK-is-repo_full_name tables, three single-column-unique-index tables, and the id-collision fold for collision_edges). --- src/db/repo-identity-rename.ts | 133 ++++++++ test/unit/repo-identity-rename.test.ts | 438 +++++++++++++++++++++++++ 2 files changed, 571 insertions(+) diff --git a/src/db/repo-identity-rename.ts b/src/db/repo-identity-rename.ts index f8178e7bb1..6069e2ad1e 100644 --- a/src/db/repo-identity-rename.ts +++ b/src/db/repo-identity-rename.ts @@ -29,16 +29,30 @@ import { activeReviewTracking, advisories, auditEvents, + burdenForecasts, checkSummaries, + collisionEdges, + contributorRepoStats, gateOutcomes, + githubAgentCommandAnswers, + githubRateLimitObservations, issues, + notificationDeliveries, + productUsageEvents, pullRequestDetailSyncState, pullRequestFiles, pullRequestReviews, pullRequests, recentMergedPullRequests, + repoGithubTotalsSnapshots, + repoLabels, + repoQueueTrendSnapshots, repositories, repositorySettings, + repoSnapshots, + repoSyncSegments, + repoSyncState, + signalSnapshots, } from "./schema"; function repoParts(fullName: string): { owner: string; name: string } { @@ -233,6 +247,125 @@ export async function renameRepositoryIdentity(env: Env, oldFullName: string, ne .set({ repoFullName: newFullName, targetKey: sql`replace(${advisories.targetKey}, ${oldFullName}, ${newFullName})` }) .where(eq(advisories.repoFullName, oldFullName)); + // burdenForecasts: repo_full_name IS the primary key (single row per repo, upsert semantics) -- same + // fold-then-rename shape as the repositories/repositorySettings anchor tables above. + await db.delete(burdenForecasts).where(eq(burdenForecasts.repoFullName, newFullName)); + await db.update(burdenForecasts).set({ repoFullName: newFullName }).where(eq(burdenForecasts.repoFullName, oldFullName)); + + // repoQueueTrendSnapshots: repo_full_name IS the primary key -- same shape. (Despite the "Snapshots" name + // this is a single-row-per-repo upsert table, not an append-only log -- each upsert overwrites the prior row.) + await db.delete(repoQueueTrendSnapshots).where(eq(repoQueueTrendSnapshots.repoFullName, newFullName)); + await db.update(repoQueueTrendSnapshots).set({ repoFullName: newFullName }).where(eq(repoQueueTrendSnapshots.repoFullName, oldFullName)); + + // repoSyncState: repo_full_name IS the primary key -- same shape. + await db.delete(repoSyncState).where(eq(repoSyncState.repoFullName, newFullName)); + await db.update(repoSyncState).set({ repoFullName: newFullName }).where(eq(repoSyncState.repoFullName, oldFullName)); + + // repoSyncSegments: unique (repo_full_name, segment), id embeds the repo name (`${repoFullName}#${segment}`) + // -- fold on the single `segment` column, same inArray shape as pullRequests/gateOutcomes above. + const collidingSegments = ( + await db.select({ segment: repoSyncSegments.segment }).from(repoSyncSegments).where(eq(repoSyncSegments.repoFullName, oldFullName)) + ).map((row) => row.segment); + if (collidingSegments.length > 0) { + await db.delete(repoSyncSegments).where(and(eq(repoSyncSegments.repoFullName, newFullName), inArray(repoSyncSegments.segment, collidingSegments))); + } + await db + .update(repoSyncSegments) + .set({ repoFullName: newFullName, id: sql`replace(${repoSyncSegments.id}, ${oldFullName}, ${newFullName})` }) + .where(eq(repoSyncSegments.repoFullName, oldFullName)); + + // contributorRepoStats: unique (login, repo_full_name), id embeds both (`${login}#${repoFullName}`) -- + // fold on the single `login` column (the OTHER half of the unique key besides repoFullName itself). + const collidingLogins = ( + await db.select({ login: contributorRepoStats.login }).from(contributorRepoStats).where(eq(contributorRepoStats.repoFullName, oldFullName)) + ).map((row) => row.login); + if (collidingLogins.length > 0) { + await db.delete(contributorRepoStats).where(and(eq(contributorRepoStats.repoFullName, newFullName), inArray(contributorRepoStats.login, collidingLogins))); + } + await db + .update(contributorRepoStats) + .set({ repoFullName: newFullName, id: sql`replace(${contributorRepoStats.id}, ${oldFullName}, ${newFullName})` }) + .where(eq(contributorRepoStats.repoFullName, oldFullName)); + + // repoLabels: unique (repo_full_name, name), id embeds the repo name (`${repoFullName}#${name.toLowerCase()}`) + // -- fold on the single `name` column. + const collidingLabelNames = ( + await db.select({ name: repoLabels.name }).from(repoLabels).where(eq(repoLabels.repoFullName, oldFullName)) + ).map((row) => row.name); + if (collidingLabelNames.length > 0) { + await db.delete(repoLabels).where(and(eq(repoLabels.repoFullName, newFullName), inArray(repoLabels.name, collidingLabelNames))); + } + await db + .update(repoLabels) + .set({ repoFullName: newFullName, id: sql`replace(${repoLabels.id}, ${oldFullName}, ${newFullName})` }) + .where(eq(repoLabels.repoFullName, oldFullName)); + + // collisionEdges: id embeds the repo name (`${repoFullName}#${cluster.id}`) but is built in + // packages/loopover-engine (buildCollisionEdges) and passed through verbatim by replaceCollisionEdges' + // delete-then-insert -- no unique index exists here, so (like pullRequestReviews above) the fold checks + // for a PK collision on the id the rename would PRODUCE rather than a business-key tuple. + const oldCollisionEdgeIds = ( + await db.select({ id: collisionEdges.id }).from(collisionEdges).where(eq(collisionEdges.repoFullName, oldFullName)) + ).map((row) => row.id); + const renamedCollisionEdgeIds = oldCollisionEdgeIds.map((id) => id.split(oldFullName).join(newFullName)); + if (renamedCollisionEdgeIds.length > 0) { + await db.delete(collisionEdges).where(inArray(collisionEdges.id, renamedCollisionEdgeIds)); + } + await db + .update(collisionEdges) + .set({ repoFullName: newFullName, id: sql`replace(${collisionEdges.id}, ${oldFullName}, ${newFullName})` }) + .where(eq(collisionEdges.repoFullName, oldFullName)); + + // notificationDeliveries: id is a random UUID (never repo-derived); the only unique constraint is + // (dedup_key, channel), columns entirely unrelated to repo_full_name, so renaming repo_full_name alone can + // never produce a collision here -- a plain rename. deeplink is this row's own canonical "go look at this" + // GitHub URL (github.com/{repoFullName}/...), the same kind of entity-owned link the anchor tables' own + // html_url gets rewritten for above -- unlike a *_json snapshot or free-text body, it is structurally the + // row's own address, not incidental content. + await db + .update(notificationDeliveries) + .set({ repoFullName: newFullName, deeplink: sql`replace(${notificationDeliveries.deeplink}, ${oldFullName}, ${newFullName})` }) + .where(eq(notificationDeliveries.repoFullName, oldFullName)); + + // githubAgentCommandAnswers: id is a random UUID; both indexes are non-unique, so a plain rename is safe. + // responseUrl mirrors deeplink above -- the posted response comment's own GitHub html_url, nullable + // (unset until a response comment is actually posted); replace() on a NULL column is a no-op NULL, not an + // error, on both SQLite and Postgres. + await db + .update(githubAgentCommandAnswers) + .set({ repoFullName: newFullName, responseUrl: sql`replace(${githubAgentCommandAnswers.responseUrl}, ${oldFullName}, ${newFullName})` }) + .where(eq(githubAgentCommandAnswers.repoFullName, oldFullName)); + + // repoSnapshots: id is a random UUID; no index at all (not even non-unique) -- an append-only history + // table where multiple rows legitimately share one repoFullName over time. Plain rename. + await db.update(repoSnapshots).set({ repoFullName: newFullName }).where(eq(repoSnapshots.repoFullName, oldFullName)); + + // repoGithubTotalsSnapshots: id is a random UUID; only a non-unique index exists. Plain rename. + await db.update(repoGithubTotalsSnapshots).set({ repoFullName: newFullName }).where(eq(repoGithubTotalsSnapshots.repoFullName, oldFullName)); + + // githubRateLimitObservations: id is a random UUID; repo_full_name is NULLABLE (null for app/installation- + // level observations not scoped to any repo) and only non-unique indexes exist. Plain rename, scoped to + // rows that actually carry the old name (a null column never matches the WHERE below). + await db.update(githubRateLimitObservations).set({ repoFullName: newFullName }).where(eq(githubRateLimitObservations.repoFullName, oldFullName)); + + // productUsageEvents: id is a random UUID; repo_full_name is NULLABLE (many product-usage events, e.g. + // MCP-surface or generic UI actions, have no associated repo) and only non-unique indexes exist. Plain rename. + await db.update(productUsageEvents).set({ repoFullName: newFullName }).where(eq(productUsageEvents.repoFullName, oldFullName)); + + // signalSnapshots: id is a random UUID; repo_full_name is NULLABLE (contributor/global-scoped signals + // 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)); + + // Deliberately OUT OF SCOPE: the request-scoped AI/LLM result caches (ai_review_cache, ai_slop_cache, + // linked_issue_satisfaction_cache, grounding_file_content_cache) and the RAG chunk/embedding cache + // (repo_chunks, a raw-SQL-only REES table). Every one of these is a rebuildable CACHE, not identity + // data: a miss after a rename just re-runs one LLM call or one re-index pass at the new name -- graceful, + // self-healing, and cheap, unlike an orphaned PR/issue/audit row a contributor or maintainer would + // otherwise need a full GitHub API backfill to recover. repo_chunks in particular stores its cache key as + // a LOWERCASED, TRUNCATED-TO-64-CHARS hash of `${ownerOnly}:${bareRepoName}` (packages layer), so a safe + // in-place string rename isn't even mechanically available without real risk of silently corrupting a + // truncated id -- letting it expire and re-index is both simpler and safer than attempting one. + // auditEvents.target_key: an append-only log with no uniqueness on target_key (many rows legitimately // share one), so a plain substring rename with no dedupe step is correct and sufficient. await db diff --git a/test/unit/repo-identity-rename.test.ts b/test/unit/repo-identity-rename.test.ts index 95080479c1..36abffbd63 100644 --- a/test/unit/repo-identity-rename.test.ts +++ b/test/unit/repo-identity-rename.test.ts @@ -1,26 +1,52 @@ import { describe, expect, it } from "vitest"; import { renameRepositoryIdentity } from "../../src/db/repo-identity-rename"; import { + getAgentCommandAnswer, + getBurdenForecast, getIssue, + getLatestRepoGithubTotalsSnapshot, + getNotificationDeliveryById, getPullRequest, getPullRequestDetailSyncState, getRepository, getRepositorySettings, + getRepoQueueTrendSnapshot, + getRepoSyncSegment, + getRepoSyncState, + insertNotificationDeliveryIfAbsent, + listCollisionEdges, + listContributorRepoStats, + listProductUsageEvents, listPullRequests, listRecentMergedPullRequests, + listRepoLabels, + listSignalSnapshots, persistAdvisory, + persistRepoGithubTotalsSnapshot, + persistRepoSnapshot, + persistSignalSnapshot, recordAuditEvent, recordGateBlockOutcome, + recordGitHubRateLimitObservation, + recordProductUsageEvent, + replaceCollisionEdges, startActiveReviewTracking, + upsertAgentCommandAnswer, + upsertBurdenForecast, upsertCheckSummary, + upsertContributorRepoStat, upsertIssueFromGitHub, upsertPullRequestDetailSyncState, upsertPullRequestFile, upsertPullRequestFromGitHub, upsertPullRequestReview, upsertRecentMergedPullRequest, + upsertRepoLabel, upsertRepositoryFromGitHub, upsertRepositorySettings, + upsertRepoQueueTrendSnapshot, + upsertRepoSyncSegment, + upsertRepoSyncState, } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; @@ -353,6 +379,418 @@ describe("renameRepositoryIdentity", () => { }); }); + describe("burden_forecasts", () => { + it("renames the forecast row's repo_full_name", async () => { + const env = createTestEnv(); + await upsertBurdenForecast(env, { repoFullName: OLD, payload: { level: "critical", summary: "original forecast" }, generatedAt: "2026-07-14T00:00:00.000Z" }); + await renameRepositoryIdentity(env, OLD, NEW); + expect(await getBurdenForecast(env, OLD)).toBeNull(); + const renamed = await getBurdenForecast(env, NEW); + expect(renamed).toMatchObject({ repoFullName: NEW, payload: { level: "critical", summary: "original forecast" } }); + }); + + it("REGRESSION (#repo-rename-migration): folds away a stray new-name forecast row, keeping the pre-existing forecast", async () => { + const env = createTestEnv(); + await upsertBurdenForecast(env, { repoFullName: OLD, payload: { level: "critical", summary: "original forecast" }, generatedAt: "2026-07-14T00:00:00.000Z" }); + await upsertBurdenForecast(env, { repoFullName: NEW, payload: { level: "low", summary: "stray fragment" }, generatedAt: "2026-07-14T00:00:00.000Z" }); // stray, should be discarded + await renameRepositoryIdentity(env, OLD, NEW); + const renamed = await getBurdenForecast(env, NEW); + expect(renamed?.payload).toMatchObject({ level: "critical", summary: "original forecast" }); + const newRowCount = await env.DB.prepare("select count(*) as n from burden_forecasts where repo_full_name = ?").bind(NEW).first<{ n: number }>(); + expect(newRowCount?.n).toBe(1); // exactly one surviving row, not two + }); + }); + + describe("repo_queue_trend_snapshots", () => { + it("renames the trend-snapshot row's repo_full_name", async () => { + const env = createTestEnv(); + await upsertRepoQueueTrendSnapshot(env, { repoFullName: OLD, payload: { trend: "rising" }, generatedAt: "2026-07-14T00:00:00.000Z" }); + await renameRepositoryIdentity(env, OLD, NEW); + expect(await getRepoQueueTrendSnapshot(env, OLD)).toBeNull(); + const renamed = await getRepoQueueTrendSnapshot(env, NEW); + expect(renamed).toMatchObject({ repoFullName: NEW, payload: { trend: "rising" } }); + }); + + it("REGRESSION (#repo-rename-migration): folds away a stray new-name trend-snapshot row, keeping the pre-existing snapshot", async () => { + const env = createTestEnv(); + await upsertRepoQueueTrendSnapshot(env, { repoFullName: OLD, payload: { trend: "rising" }, generatedAt: "2026-07-14T00:00:00.000Z" }); + await upsertRepoQueueTrendSnapshot(env, { repoFullName: NEW, payload: { trend: "stray" }, generatedAt: "2026-07-14T00:00:00.000Z" }); // stray, should be discarded + await renameRepositoryIdentity(env, OLD, NEW); + const renamed = await getRepoQueueTrendSnapshot(env, NEW); + expect(renamed?.payload).toMatchObject({ trend: "rising" }); + const newRowCount = await env.DB.prepare("select count(*) as n from repo_queue_trend_snapshots where repo_full_name = ?").bind(NEW).first<{ n: number }>(); + expect(newRowCount?.n).toBe(1); // exactly one surviving row, not two + }); + }); + + describe("repo_sync_state", () => { + it("renames the sync-state row's repo_full_name", async () => { + const env = createTestEnv(); + await upsertRepoSyncState(env, { + repoFullName: OLD, + status: "partial", + sourceKind: "github", + openIssuesCount: 3, + openPullRequestsCount: 2, + recentMergedPullRequestsCount: 1, + warnings: ["truncated"], + }); + await renameRepositoryIdentity(env, OLD, NEW); + expect(await getRepoSyncState(env, OLD)).toBeNull(); + const renamed = await getRepoSyncState(env, NEW); + expect(renamed).toMatchObject({ repoFullName: NEW, status: "partial", warnings: ["truncated"] }); + }); + + it("REGRESSION (#repo-rename-migration): folds away a stray new-name sync-state row, keeping the pre-existing state", async () => { + const env = createTestEnv(); + await upsertRepoSyncState(env, { + repoFullName: OLD, + status: "success", + sourceKind: "github", + openIssuesCount: 3, + openPullRequestsCount: 2, + recentMergedPullRequestsCount: 1, + warnings: [], + }); + await upsertRepoSyncState(env, { + repoFullName: NEW, + status: "never_synced", + sourceKind: "github", + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + warnings: [], + }); // stray, should be discarded + await renameRepositoryIdentity(env, OLD, NEW); + const renamed = await getRepoSyncState(env, NEW); + expect(renamed?.status).toBe("success"); + const newRowCount = await env.DB.prepare("select count(*) as n from repo_sync_state where repo_full_name = ?").bind(NEW).first<{ n: number }>(); + expect(newRowCount?.n).toBe(1); // exactly one surviving row, not two + }); + }); + + describe("repo_sync_segments", () => { + it("renames repo_full_name and id for a sync-segment row", async () => { + const env = createTestEnv(); + await upsertRepoSyncSegment(env, { repoFullName: OLD, segment: "labels", status: "complete", sourceKind: "github", mode: "full", fetchedCount: 5, pageCount: 1, warnings: [] }); + await renameRepositoryIdentity(env, OLD, NEW); + expect(await getRepoSyncSegment(env, OLD, "labels")).toBeNull(); + const renamed = await getRepoSyncSegment(env, NEW, "labels"); + expect(renamed).toMatchObject({ repoFullName: NEW, status: "complete" }); + const idRow = await env.DB.prepare("select id from repo_sync_segments where repo_full_name = ? and segment = ?").bind(NEW, "labels").first<{ id: string }>(); + expect(idRow?.id).toBe(`${NEW}#labels`); + }); + + it("REGRESSION (#repo-rename-migration): folds away a stray new-name row on the same segment", async () => { + const env = createTestEnv(); + await upsertRepoSyncSegment(env, { repoFullName: OLD, segment: "labels", status: "complete", sourceKind: "github", mode: "full", fetchedCount: 5, pageCount: 1, warnings: [] }); + await upsertRepoSyncSegment(env, { repoFullName: NEW, segment: "labels", status: "never_synced", sourceKind: "github", mode: "light", fetchedCount: 0, pageCount: 0, warnings: [] }); // stray, should be discarded + await renameRepositoryIdentity(env, OLD, NEW); + const renamed = await getRepoSyncSegment(env, NEW, "labels"); + expect(renamed?.status).toBe("complete"); + const rows = await env.DB.prepare("select status from repo_sync_segments where repo_full_name = ? and segment = ?").bind(NEW, "labels").all<{ status: string }>(); + expect(rows.results).toHaveLength(1); // exactly one surviving row, not two + }); + }); + + describe("contributor_repo_stats", () => { + it("renames repo_full_name and id for a contributor's stat row", async () => { + const env = createTestEnv(); + await upsertContributorRepoStat(env, { + login: "miner1", + repoFullName: OLD, + pullRequests: 2, + mergedPullRequests: 1, + openPullRequests: 1, + issues: 3, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: ["bug"], + }); + await renameRepositoryIdentity(env, OLD, NEW); + const rows = await listContributorRepoStats(env, "miner1"); + expect(rows).toMatchObject([{ repoFullName: NEW, dominantLabels: ["bug"] }]); + const idRow = await env.DB.prepare("select id from contributor_repo_stats where repo_full_name = ? and login = ?").bind(NEW, "miner1").first<{ id: string }>(); + expect(idRow?.id).toBe(`miner1#${NEW}`); + }); + + it("REGRESSION (#repo-rename-migration): folds away a stray new-name row for the same login", async () => { + const env = createTestEnv(); + await upsertContributorRepoStat(env, { + login: "miner1", + repoFullName: OLD, + pullRequests: 5, + mergedPullRequests: 4, + openPullRequests: 1, + issues: 2, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: ["bug"], + }); + await upsertContributorRepoStat(env, { + login: "miner1", + repoFullName: NEW, + pullRequests: 1, + mergedPullRequests: 0, + openPullRequests: 1, + issues: 0, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: ["stray"], + }); // stray, should be discarded + await renameRepositoryIdentity(env, OLD, NEW); + const rows = await listContributorRepoStats(env, "miner1"); + expect(rows.filter((row) => row.repoFullName === NEW)).toHaveLength(1); // exactly one surviving row, not two + expect(rows.find((row) => row.repoFullName === NEW)?.pullRequests).toBe(5); + }); + }); + + describe("repo_labels", () => { + it("renames repo_full_name and id for a label row", async () => { + const env = createTestEnv(); + await upsertRepoLabel(env, { repoFullName: OLD, name: "bug", color: "cc0000", description: "Bug", isConfigured: true, observedCount: 4, payload: { name: "bug" } }); + await renameRepositoryIdentity(env, OLD, NEW); + expect(await listRepoLabels(env, OLD)).toEqual([]); + const renamed = await listRepoLabels(env, NEW); + expect(renamed).toMatchObject([{ name: "bug", isConfigured: true, observedCount: 4 }]); + const idRow = await env.DB.prepare("select id from repo_labels where repo_full_name = ? and name = ?").bind(NEW, "bug").first<{ id: string }>(); + expect(idRow?.id).toBe(`${NEW}#bug`); + }); + + it("REGRESSION (#repo-rename-migration): folds away a stray new-name row with the same label name", async () => { + const env = createTestEnv(); + await upsertRepoLabel(env, { repoFullName: OLD, name: "bug", color: "cc0000", description: "Original", isConfigured: true, observedCount: 4, payload: {} }); + await upsertRepoLabel(env, { repoFullName: NEW, name: "bug", color: "ffffff", description: "Stray", isConfigured: false, observedCount: 0, payload: {} }); // stray, should be discarded + await renameRepositoryIdentity(env, OLD, NEW); + const rows = await listRepoLabels(env, NEW); + expect(rows).toHaveLength(1); // exactly one surviving row, not two + expect(rows[0]).toMatchObject({ description: "Original", observedCount: 4 }); + }); + }); + + describe("collision_edges", () => { + it("renames repo_full_name and id for a collision-edge row", async () => { + const env = createTestEnv(); + await replaceCollisionEdges(env, OLD, [ + { + id: `${OLD}#c1`, + repoFullName: OLD, + leftType: "issue", + leftNumber: 2, + leftTitle: "Fix index handler", + rightType: "pull_request", + rightNumber: 5, + rightTitle: "Fix index handler", + risk: "high", + reason: "Same issue.", + sharedTerms: ["index", "handler"], + }, + ]); + await renameRepositoryIdentity(env, OLD, NEW); + expect(await listCollisionEdges(env, OLD)).toEqual([]); + const renamed = await listCollisionEdges(env, NEW); + expect(renamed).toMatchObject([{ id: `${NEW}#c1`, risk: "high", sharedTerms: ["index", "handler"] }]); + }); + + it("REGRESSION (#repo-rename-migration): folds away a stray new-name row with the same computed id", async () => { + const env = createTestEnv(); + await replaceCollisionEdges(env, OLD, [ + { + id: `${OLD}#c1`, + repoFullName: OLD, + leftType: "issue", + leftNumber: 2, + leftTitle: "Original left", + rightType: "pull_request", + rightNumber: 5, + rightTitle: "Original right", + risk: "high", + reason: "Original reason.", + sharedTerms: ["index"], + }, + ]); + await replaceCollisionEdges(env, NEW, [ + { + id: `${NEW}#c1`, + repoFullName: NEW, + leftType: "issue", + leftNumber: 9, + leftTitle: "Stray left", + rightType: "pull_request", + rightNumber: 10, + rightTitle: "Stray right", + risk: "low", + reason: "Stray reason.", + sharedTerms: ["stray"], + }, + ]); // stray, should be discarded -- collides on the id the rename would PRODUCE + await renameRepositoryIdentity(env, OLD, NEW); + const rows = await listCollisionEdges(env, NEW); + expect(rows).toHaveLength(1); // exactly one surviving row, not two + expect(rows[0]).toMatchObject({ id: `${NEW}#c1`, reason: "Original reason.", risk: "high" }); + }); + }); + + describe("notification_deliveries", () => { + it("renames repo_full_name and rewrites the deeplink for a delivery row", async () => { + const env = createTestEnv(); + const { delivery } = await insertNotificationDeliveryIfAbsent(env, { + dedupKey: "dedup-1", + channel: "badge", + recipientLogin: "miner", + eventType: "pull_request_changes_requested", + repoFullName: OLD, + pullNumber: 7, + title: `Changes requested on ${OLD}#7`, + body: "A reviewer requested changes on your pull request.", + deeplink: `https://github.com/${OLD}/pull/7`, + actorLogin: "reviewer", + }); + await renameRepositoryIdentity(env, OLD, NEW); + const renamed = await getNotificationDeliveryById(env, delivery.id); + expect(renamed).toMatchObject({ repoFullName: NEW, deeplink: `https://github.com/${NEW}/pull/7` }); + }); + }); + + describe("github_agent_command_answers", () => { + it("renames repo_full_name and rewrites the response URL for a command-answer row", async () => { + const env = createTestEnv(); + await upsertAgentCommandAnswer(env, { + id: "answer-1", + repoFullName: OLD, + issueNumber: 12, + command: "preflight", + responseUrl: `https://github.com/${OLD}/issues/12#issuecomment-1`, + actorKind: "author", + metadata: {}, + }); + await renameRepositoryIdentity(env, OLD, NEW); + const renamed = await getAgentCommandAnswer(env, "answer-1"); + expect(renamed).toMatchObject({ repoFullName: NEW, responseUrl: `https://github.com/${NEW}/issues/12#issuecomment-1` }); + }); + }); + + describe("repo_snapshots", () => { + it("renames repo_full_name for a repo-snapshot row", async () => { + const env = createTestEnv(); + await persistRepoSnapshot(env, { + id: "snapshot-1", + repoFullName: OLD, + snapshotKind: "github-backfill", + sourceKind: "github", + fetchedAt: "2026-07-14T00:00:00.000Z", + primaryLanguage: "TypeScript", + defaultBranch: "main", + openIssuesCount: 3, + openPullRequestsCount: 2, + recentMergedPullRequestsCount: 1, + payload: { ok: true }, + }); + await renameRepositoryIdentity(env, OLD, NEW); + const oldRow = await env.DB.prepare("select count(*) as n from repo_snapshots where repo_full_name = ?").bind(OLD).first<{ n: number }>(); + expect(oldRow?.n).toBe(0); + const renamed = await env.DB.prepare("select repo_full_name as repoFullName from repo_snapshots where id = ?").bind("snapshot-1").first<{ repoFullName: string }>(); + expect(renamed?.repoFullName).toBe(NEW); + }); + }); + + describe("repo_github_totals_snapshots", () => { + it("renames repo_full_name for a totals-snapshot row", async () => { + const env = createTestEnv(); + await persistRepoGithubTotalsSnapshot(env, { + id: "totals-1", + repoFullName: OLD, + openIssuesTotal: 3, + openPullRequestsTotal: 2, + mergedPullRequestsTotal: 5, + closedUnmergedPullRequestsTotal: 1, + labelsTotal: 4, + sourceKind: "github", + fetchedAt: "2026-07-14T00:00:00.000Z", + payload: {}, + }); + await renameRepositoryIdentity(env, OLD, NEW); + expect(await getLatestRepoGithubTotalsSnapshot(env, OLD)).toBeNull(); + const renamed = await getLatestRepoGithubTotalsSnapshot(env, NEW); + expect(renamed).toMatchObject({ repoFullName: NEW, openIssuesTotal: 3 }); + }); + }); + + describe("github_rate_limit_observations", () => { + it("renames repo_full_name for a rate-limit observation row", async () => { + const env = createTestEnv(); + await recordGitHubRateLimitObservation(env, { + id: "obs-1", + repoFullName: OLD, + resource: "rest", + path: "/x", + statusCode: 200, + limitValue: 5000, + remaining: 10, + resetAt: "2026-07-14T01:00:00.000Z", + }); + await renameRepositoryIdentity(env, OLD, NEW); + const oldRow = await env.DB.prepare("select count(*) as n from github_rate_limit_observations where repo_full_name = ?").bind(OLD).first<{ n: number }>(); + expect(oldRow?.n).toBe(0); + const renamed = await env.DB.prepare("select repo_full_name as repoFullName from github_rate_limit_observations where id = ?").bind("obs-1").first<{ repoFullName: string }>(); + expect(renamed?.repoFullName).toBe(NEW); + }); + + it("leaves a NULL repo_full_name observation (an installation-level, not repo-scoped, event) untouched", async () => { + const env = createTestEnv(); + await recordGitHubRateLimitObservation(env, { + id: "obs-null", + repoFullName: null, + admissionKey: "installation:1", + resource: "rest", + path: "/app/installations/1", + statusCode: 200, + limitValue: 5000, + remaining: 5, + resetAt: "2026-07-14T01:00:00.000Z", + }); + await renameRepositoryIdentity(env, OLD, NEW); + const row = await env.DB.prepare("select repo_full_name as repoFullName from github_rate_limit_observations where id = ?").bind("obs-null").first<{ repoFullName: string | null }>(); + expect(row?.repoFullName).toBeNull(); + }); + }); + + describe("product_usage_events", () => { + it("renames repo_full_name for a product-usage-event row", async () => { + const env = createTestEnv(); + const recorded = await recordProductUsageEvent(env, { surface: "api", eventName: "rename.test", repoFullName: OLD, outcome: "success" }); + await renameRepositoryIdentity(env, OLD, NEW); + const events = await listProductUsageEvents(env); + expect(events.find((event) => event.id === recorded.id)?.repoFullName).toBe(NEW); + }); + + it("leaves a NULL repo_full_name event (not associated with any repo) untouched", async () => { + const env = createTestEnv(); + const recorded = await recordProductUsageEvent(env, { surface: "mcp", eventName: "generic.event", outcome: "success" }); + await renameRepositoryIdentity(env, OLD, NEW); + const events = await listProductUsageEvents(env); + expect(events.find((event) => event.id === recorded.id)?.repoFullName).toBeNull(); + }); + }); + + describe("signal_snapshots", () => { + it("renames repo_full_name for a signal-snapshot row", async () => { + const env = createTestEnv(); + await persistSignalSnapshot(env, { id: "signal-1", signalType: "queue-health", targetKey: OLD, repoFullName: OLD, payload: { ok: true } }); + await renameRepositoryIdentity(env, OLD, NEW); + const rows = await listSignalSnapshots(env, "queue-health", OLD); + expect(rows.find((row) => row.id === "signal-1")?.repoFullName).toBe(NEW); + }); + + it("leaves a NULL repo_full_name snapshot (a contributor/global-scoped signal) untouched", async () => { + const env = createTestEnv(); + await persistSignalSnapshot(env, { id: "signal-2", signalType: "contributor-trust", targetKey: "miner1", repoFullName: null, payload: {} }); + await renameRepositoryIdentity(env, OLD, NEW); + const rows = await listSignalSnapshots(env, "contributor-trust", "miner1"); + expect(rows.find((row) => row.id === "signal-2")?.repoFullName).toBeNull(); + }); + }); + 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();