import { dropCodeIndex } from "./code/search-index"; import { forgetRepoAttachmentBytes } from "./attachment-bytes"; import { retractScopeGauges } from "./metering/scope-gauges"; import { dropRepoState } from "./repo-state"; import { purgeDeletedRepoFromAccounts } from "./repo-account-cleanup"; import { waitUntil } from "cloudflare:workers"; import { clearWebhookLog } from "./webhook-log"; import { kickRepoInsights } from "./insights/repo-insights-do"; import { purgeRepoCaches } from "./git/cache-purge"; import { siteTeardownFor } from "./sites/sync"; import { assertPublicHttpsUrl } from "./git/relay"; import { assertResolvesPublic } from "./net/resolve-guard"; import { batch, first, run, stmt } from "./db"; import { randomId } from "./crypto"; import { artifactsNameFor, isArtifactsError, openArtifactsRepo, rememberInitialToken } from "./git/artifacts"; import { artifactsCall } from "./git/artifacts-call"; import { artifactsBinding, nameInShard, shardOfName } from "./git/artifacts-shards"; import { invalidateRefs, refreshRefs } from "./git/refs"; import { linkForkObjects, purgeRepoLfs } from "./lfs/objects"; import { withStore } from "./git/store"; import { commitChanges } from "./git/writer"; import { signatureNow } from "./git/objects"; import { assertOwnerNotDisabled, hasPrivateForkAncestor, loadRepoById } from "./repos"; import { badRequest, conflict } from "./errors"; import { encryptRepoSecret } from "./secrets"; import { describeProvisioningError, markProvisioningFailed, startProvisioning } from "./repo-provisioning"; import { emitWebhook, publicUrl, recordActivity } from "./events/emit"; import { repoDeletionStatements } from "./integrations/ownership"; import type { RepoRow, Viewer, Visibility } from "~/lib/types"; import { captureServerEvent } from "./analytics"; const DEFAULT_BRANCH = "main"; const PENDING_REPO_TIMEOUT_MS = 30 * 60 * 1000; /** Artifacts still reports progress: allow very large imports and forks to keep going. */ const MAX_IN_PROGRESS_MS = 6 * 60 * 60 * 1000; const encoder = new TextEncoder(); async function ensureNameFree(ownerId: string, name: string): Promise { const existing = await first<{ id: string }>("SELECT id FROM repos WHERE owner_id = ? AND name = ?", ownerId, name); if (existing) throw conflict("A repository with that name already exists"); } async function insertRepo(input: { id: string; ownerId: string; name: string; artifactsName: string; remote: string; description: string | null; visibility: Visibility; defaultBranch: string; status: RepoRow["status"]; forkOfId: string | null; ownerName: string; hasActions?: boolean; }): Promise { const now = Date.now(); await batch([ stmt( `INSERT INTO repos (id, owner_id, name, artifacts_name, artifacts_remote, description, visibility, default_branch, status, fork_of_id, has_actions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, input.id, input.ownerId, input.name, input.artifactsName, input.remote, input.description, input.visibility, input.defaultBranch, input.status, input.forkOfId, input.hasActions === false ? 0 : 1, now, now, ), stmt( "INSERT INTO repos_fts (repo_id, full_name, description) VALUES (?, ?, ?)", input.id, `${input.ownerName}/${input.name}`, input.description ?? "", ), ]); } export async function createRepository(input: { owner: { id: string; name: string }; name: string; description: string | null; visibility: Visibility; initReadme: boolean; /** Defaults to enabled; scratch repositories pass `false` so anonymous pushes can never schedule CI. */ hasActions?: boolean; actor: Viewer; }): Promise { await ensureNameFree(input.owner.id, input.name); const id = randomId(); const artifactsName = artifactsNameFor(id); const created = await artifactsCall("create the repository", artifactsName, () => artifactsBinding(artifactsName).create(artifactsName, { description: input.description ?? undefined, setDefaultBranch: DEFAULT_BRANCH, }), ); await rememberInitialToken(artifactsName, created.token); await insertRepo({ id, ownerId: input.owner.id, ownerName: input.owner.name, name: input.name, artifactsName, remote: created.remote, description: input.description, visibility: input.visibility, defaultBranch: created.defaultBranch || DEFAULT_BRANCH, status: "ready", forkOfId: null, hasActions: input.hasActions, }); const repo = await loadRepoById(id); if (!repo) throw new Error("Repository creation failed"); if (input.initReadme) { const readme = `# ${input.name}\n\n${input.description ?? ""}\n`; await withStore(artifactsName, (store) => commitChanges({ repo, store, branch: repo.default_branch, parentSha: null, changes: [{ path: "README.md", content: encoder.encode(readme) }], message: "Initial commit", author: signatureNow(input.actor.displayName ?? input.actor.name, input.actor.email), }), ); await run("UPDATE repos SET pushed_at = ? WHERE id = ?", Date.now(), id); } await recordActivity(input.actor.id, id, "repo_created", { name: `${input.owner.name}/${input.name}` }); captureServerEvent( "repo_created", input.actor.id, { visibility: input.visibility, owner_kind: input.owner.id === input.actor.id ? "user" : "org", init_readme: input.initReadme, }, { ownerAccountId: input.owner.id }, ); return repo; } /** Credentials pasted into the URL become the token, so they never land in import_source_url or activity. */ function splitSourceCredentials(raw: string, token: string | null): { url: string; token: string | null } { let parsed: URL; try { parsed = new URL(raw); } catch { throw badRequest("Enter a valid https:// git URL"); } if (parsed.protocol !== "https:") throw badRequest("Only https:// git URLs can be imported"); if (parsed.search || parsed.hash) throw badRequest("Import URLs can't carry a query string or fragment"); const embedded = parsed.password || parsed.username; parsed.username = ""; parsed.password = ""; return { url: parsed.toString(), token: token?.trim() || embedded || null }; } /** Where an import comes from, as a fixed set of forge kinds; other hosts are never named. */ function importSourceKind(sourceUrl: string): string { const host = new URL(sourceUrl).hostname.toLowerCase(); const known: Record = { "github.com": "github", "gitlab.com": "gitlab", "codeberg.org": "codeberg", "bitbucket.org": "bitbucket", "gitea.com": "gitea", }; return known[host] ?? "other"; } export async function importRepository(input: { owner: { id: string; name: string }; name: string; description: string | null; visibility: Visibility; sourceUrl: string; branch: string | null; /** Access token for a private source; it is encrypted for the Workflow and never stored on the repo. */ token: string | null; actor: Viewer; }): Promise { const { url: sourceUrl, token } = splitSourceCredentials(input.sourceUrl, input.token); assertPublicHttpsUrl(sourceUrl); await assertResolvesPublic(sourceUrl); const auth = token ? await encryptRepoSecret(token) : null; // A token means a private source; publishing its history needs a deliberate visibility change afterwards. const visibility: Visibility = token ? "private" : input.visibility; await ensureNameFree(input.owner.id, input.name); const id = randomId(); await insertRepo({ id, ownerId: input.owner.id, ownerName: input.owner.name, name: input.name, artifactsName: artifactsNameFor(id), remote: "", description: input.description, visibility, defaultBranch: input.branch ?? DEFAULT_BRANCH, status: "importing", forkOfId: null, }); await run("UPDATE repos SET import_source_url = ? WHERE id = ?", sourceUrl, id); await startProvisioning({ repoId: id, kind: "import", sourceUrl, branch: input.branch, auth }); await recordActivity(input.actor.id, id, "repo_imported", { source: sourceUrl }); captureServerEvent( "repo_imported", input.actor.id, { source: importSourceKind(sourceUrl), with_token: token !== null, private_repo: visibility !== "public" }, { ownerAccountId: input.owner.id }, ); const repo = await loadRepoById(id); if (!repo) throw new Error("Repository import failed"); return repo; } export async function forkRepository(input: { source: RepoRow; owner: { id: string; name: string }; name: string; actor: Viewer; }): Promise { await ensureNameFree(input.owner.id, input.name); const id = randomId(); await insertRepo({ id, ownerId: input.owner.id, ownerName: input.owner.name, name: input.name, // Artifacts forks within a namespace, so a fork lives in its source's shard. artifactsName: nameInShard(id, shardOfName(input.source.artifacts_name)), remote: "", description: input.source.description, visibility: input.source.visibility, defaultBranch: input.source.default_branch, status: "forking", forkOfId: input.source.id, }); await linkForkObjects({ id, owner_id: input.owner.id }, input.source.id); await startProvisioning({ repoId: id, kind: "fork", sourceRepoId: input.source.id }); await run("UPDATE repos SET forks_count = forks_count + 1 WHERE id = ?", input.source.id); await recordActivity(input.actor.id, id, "repo_forked", { source: `${input.source.owner_name}/${input.source.name}`, }); captureServerEvent( "repo_forked", input.actor.id, { private_repo: input.source.visibility !== "public" }, { ownerAccountId: input.owner.id }, ); await emitWebhook(input.source.id, "fork", { forkee: { full_name: `${input.owner.name}/${input.name}`, html_url: publicUrl(`/${input.owner.name}/${input.name}`), }, sender: { login: input.actor.name, id: input.actor.id }, }); const repo = await loadRepoById(id); if (!repo) throw new Error("Fork failed"); return repo; } /** Imports and forks complete asynchronously in Artifacts; promote the repo once its handle is available. */ export async function refreshRepoStatus(repo: RepoRow): Promise { if (repo.status === "ready" || repo.status === "failed") return repo; if (repo.status === "importing" && repo.import_source_url) return repo; try { const handle = await openArtifactsRepo(repo.artifacts_name); let info: Awaited>; try { info = await artifactsCall("read repository status", repo.artifacts_name, () => handle.info()); } finally { handle[Symbol.dispose](); } const ready = { ...repo, artifacts_remote: info.remote }; const refs = await refreshRefs(ready); const defaultBranch = refs.head ?? info.defaultBranch ?? repo.default_branch; await run( "UPDATE repos SET status = 'ready', artifacts_remote = ?, default_branch = ?, pushed_at = ?, updated_at = ? WHERE id = ?", info.remote, defaultBranch, Date.now(), Date.now(), repo.id, ); await kickRepoInsights(repo.id); return { ...ready, status: "ready", default_branch: defaultBranch }; } catch (error) { const age = Date.now() - repo.created_at; const inProgress = isArtifactsError(error) && error.code.endsWith("_IN_PROGRESS"); const notFound = isArtifactsError(error) && error.code === "NOT_FOUND"; if (inProgress && age <= MAX_IN_PROGRESS_MS) return repo; if (notFound && age <= PENDING_REPO_TIMEOUT_MS) return repo; if (isArtifactsError(error) || age > PENDING_REPO_TIMEOUT_MS) { console.error("Repository import/fork failed", repo.id, error); await markProvisioningFailed(repo.id, describeProvisioningError(error)); return { ...repo, status: "failed" }; } console.warn("Repository not ready yet", repo.id, error); return repo; } } /** * Deletes the D1 rows before the Artifacts repo (GIT-P12): a failed Artifacts delete leaves only a dangling * Artifacts repo, which the orphan sweep finds, never a row pointing at a repository that is gone. */ export async function deleteRepository(repo: RepoRow, actor: Viewer): Promise { assertOwnerNotDisabled(repo, actor); const teardownSite = await siteTeardownFor(repo.id); const privateNetwork = repo.visibility !== "public" || (await hasPrivateForkAncestor(repo)); await batch([ stmt("UPDATE repos SET private_network = 1 WHERE fork_of_id = ? AND ?", repo.id, privateNetwork ? 1 : 0), stmt("DELETE FROM repos_fts WHERE repo_id = ?", repo.id), stmt("UPDATE repos SET forks_count = MAX(forks_count - 1, 0) WHERE id = ?", repo.fork_of_id ?? ""), ...repoDeletionStatements(repo.id), stmt("DELETE FROM repos WHERE id = ?", repo.id), ]); await purgeRepoLfs(repo); try { await artifactsCall("delete the repository", repo.artifacts_name, () => artifactsBinding(repo.artifacts_name).delete(repo.artifacts_name), ); } catch (error) { if (!isArtifactsError(error) || error.code !== "NOT_FOUND") { console.error("artifacts delete failed; the orphan sweep will collect it", repo.artifacts_name, error); } } waitUntil(purgeRepoCaches(repo).catch((error: unknown) => console.warn("cache purge failed", repo.id, error))); await teardownSite(); await invalidateRefs(repo.id); await forgetRepoAttachmentBytes(repo.id); await retractScopeGauges(repo.id).catch((error: unknown) => console.warn("Gauge retraction failed", repo.id, error)); await Promise.all([ clearWebhookLog(repo.id), kickRepoInsights(repo.id), dropCodeIndex(repo.id), dropRepoState(repo.id), ]); await purgeDeletedRepoFromAccounts(repo); await recordActivity(actor.id, null, "repo_deleted", { name: `${repo.owner_name}/${repo.name}`, visibility: repo.visibility, }); } /** Topics ride along in the description column, so repository search matches them too. */ export async function reindexRepo( repo: Pick & { topics?: readonly string[] }, ): Promise { await batch([ stmt("DELETE FROM repos_fts WHERE repo_id = ?", repo.id), stmt( "INSERT INTO repos_fts (repo_id, full_name, description) VALUES (?, ?, ?)", repo.id, `${repo.owner_name}/${repo.name}`, [repo.description ?? "", ...(repo.topics ?? [])].join(" ").trim(), ), ]); }