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
87 changes: 87 additions & 0 deletions src/db/repo-identity-rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -48,8 +52,11 @@ import {
repoLabels,
repoQueueTrendSnapshots,
repositories,
repositoryAiKeys,
repositoryLinearKeys,
repositorySettings,
repoSnapshots,
reviewSuppression,
repoSyncSegments,
repoSyncState,
signalSnapshots,
Expand Down Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions test/unit/repo-identity-rename.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
listCollisionEdges,
listContributorRepoStats,
listProductUsageEvents,
listIssueWatchersForRepo,
listPullRequests,
listRecentMergedPullRequests,
listRepoLabels,
Expand All @@ -36,6 +37,7 @@ import {
upsertCheckSummary,
upsertContributorRepoStat,
upsertIssueFromGitHub,
upsertIssueWatchSubscription,
upsertPullRequestDetailSyncState,
upsertPullRequestFile,
upsertPullRequestFromGitHub,
Expand Down Expand Up @@ -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();
Expand Down