import { waitUntil } from "cloudflare:workers"; import { chunks, placeholders, stmt } from "../db"; import { accountDb, repoDb } from "../scoped-db"; import { deleteRunnerRow } from "../ci/runner-registration"; import { cancelActiveJob } from "../ci/jobs"; import { forgetLocator } from "../scoped-db/locators"; import { dropRepoRunners } from "../repo-account-cleanup"; import { dropMcpAccess, purgeInaccessibleFollowers } from "../access-revocation"; import { publishScopeGauges } from "../metering/scope-gauges"; /** * Integrations and legacy webhooks are configured by a repository's admins, so they do not survive a change * of ownership: the new owner must knowingly re-enable them. Pull mirrors would otherwise keep writing refs as * the old admin and push mirrors would keep exporting the new owner's pushes to the old admin's GitHub repo. * Deploy tokens were minted by the old admins, so they are revoked too. These are the D1 statements (batch them * with the transfer); the repository's own webhooks and deploy tokens are handled by `disableRepoConfigOnTransfer`. */ export function repoOwnershipChangeStatements(repoId: string, actorId: string | null): D1PreparedStatement[] { const now = Date.now(); return [ stmt( `INSERT INTO integration_audit (id, integration_id, actor_id, action, detail, created_at) SELECT lower(hex(randomblob(16))), id, ?, 'repo.transferred', ?, ? FROM integrations WHERE scope_type = 'repo' AND scope_id = ? AND active = 1`, actorId, JSON.stringify({ reason: "Repository transferred; integration disabled until the new owner re-enables it" }), now, repoId, ), stmt( `UPDATE integrations SET active = 0, updated_at = ?, config = json_remove(json_set(config, '$.githubMirror', 'none'), '$.githubRepo') WHERE scope_type = 'repo' AND scope_id = ?`, now, repoId, ), ]; } /** * The previous owner's runners must stop serving the repository: its repository runners are deleted (cancelling * whatever they run), and jobs of the repository that its owner-wide runners already claimed are cancelled. */ async function detachPreviousOwnerRunners(repoId: string, previousOwnerId: string): Promise { const owner = accountDb(previousOwnerId); const repoRunners = await owner.all<{ id: string }>("SELECT id FROM runners WHERE repo_id = ?", repoId); for (const runner of repoRunners) await deleteRunnerRow(runner.id); await dropRepoRunners(previousOwnerId, repoId); const ownerRunners = await owner.all<{ id: string }>("SELECT id FROM runners WHERE owner_id = ?", previousOwnerId); for (const group of chunks(ownerRunners.map((runner) => runner.id))) { const busy = await repoDb(repoId).all<{ id: string; runs_on: string }>( `SELECT id, runs_on FROM workflow_jobs WHERE status = 'in_progress' AND runner_id IN (${placeholders(group.length)})`, ...group, ); await Promise.allSettled(busy.map((job) => cancelActiveJob(job, "the repository was transferred"))); } } /** * The scoped-object half of a transfer, run after the D1 batch: legacy webhooks are paused, deploy tokens revoked * and the previous owner's runners detached. Followers who lost read access are purged in the background, since * that visits every account and must not leave a committed transfer half-finished. */ export async function disableRepoConfigOnTransfer(repoId: string, previousOwnerId: string): Promise { const db = repoDb(repoId); await db.run("UPDATE webhooks SET active = 0 WHERE repo_id = ?", repoId); const revoked = await db.all<{ token_hash: string }>( "DELETE FROM deploy_tokens WHERE repo_id = ? RETURNING token_hash", repoId, ); await Promise.all(revoked.map((row) => forgetLocator("deploy_token", row.token_hash))); await dropMcpAccess(repoId); // Storage now bills the new owner: move the repository's gauges over at once. await publishScopeGauges(repoId).catch((error: unknown) => console.error("Gauge transfer failed", repoId, error)); waitUntil( purgeInaccessibleFollowers(repoId).catch((error: unknown) => console.error("Follower purge after transfer failed", repoId, error), ), ); // Claims re-check ownership (runner-claim `stillServes`), so a failure here can't hand out jobs; log, don't abort. await detachPreviousOwnerRunners(repoId, previousOwnerId).catch((error: unknown) => console.error("Detaching the previous owner's runners failed", repoId, error), ); } /** * Repo deletion removes the integrations the repo configured (cascades cover their deliveries, jobs, audit and * digest items). Webhooks and their deliveries live in the RepoState, which deletion destroys. */ export function repoDeletionStatements(repoId: string): D1PreparedStatement[] { return [stmt("DELETE FROM integrations WHERE scope_type = 'repo' AND scope_id = ?", repoId)]; }