import { rename, rm, mkdir } from "node:fs/promises"; import path from "node:path"; import { count, db, filter, findById, findFirst, isUniqueViolation, orNull, type RecordModel } from "./db"; import { cloneBare, defaultBranch, GitError, initBareRepo, isValidName, lastActivity, normalizeRepoName, pathExists, repoPath, wikiPath, } from "./git"; import { commitFiles } from "./git-write"; import { deleteRepoImages } from "./registry"; import { deleteRepoReleaseFiles } from "./releases"; import { readStoredImage, uploadedImage } from "./uploaded-images"; import type { AgentPush } from "./branch-rules"; import { orgRole, repoAccessOfRole } from "./orgs"; import { getPersonByUsername, type User } from "./users"; export type Visibility = "public" | "private"; export type Access = "admin" | "write" | "read" | null; /** The repository a fork was made from. */ export type ForkParent = { id: string; ownerId: string; owner: string; name: string; visibility: Visibility }; export type RepoRecord = { id: string; ownerId: string; owner: string; /** Owned by an organization, whose members' roles apply to it (see lib/orgs.ts). */ ownerIsOrg: boolean; name: string; description: string; visibility: Visibility; createdAt: number; pushedAt: number | null; /** When a custom image was uploaded (used to bust caches), or null to show the owner's avatar. */ imageVersion: number | null; /** The owning organization's avatar version, shown in place of a missing image (see lib/users.ts). */ ownerAvatarVersion: number | null; wikiEnabled: boolean; /** Where agents may push (see lib/branch-rules.ts). */ agentPush: AgentPush; /** Set for forks, unless the repository they were made from was deleted. */ forkOf: ForkParent | null; dir: string; /** The wiki's git repository; it only exists once the first page is written. */ wikiDir: string; }; const EXPAND = "owner,fork_of.owner"; function toRepo(r: RecordModel): RepoRecord { const ownerRecord = r.expand?.owner as RecordModel; const owner = ownerRecord.username as string; const parent = r.expand?.fork_of as RecordModel | undefined; return { id: r.id, ownerId: r.owner, owner, ownerIsOrg: ownerRecord.kind === "org", name: r.name, description: r.description, visibility: r.visibility, createdAt: r.created_at, pushedAt: orNull(r.pushed_at), imageVersion: r.image ? orNull(r.image_updated_at) : null, ownerAvatarVersion: ownerRecord.kind === "org" && ownerRecord.avatar ? orNull(ownerRecord.avatar_updated_at) : null, wikiEnabled: !r.wiki_disabled, agentPush: r.agent_push || "any", forkOf: parent ? { id: parent.id, ownerId: parent.owner, owner: (parent.expand?.owner as RecordModel).username, name: parent.name, visibility: parent.visibility } : null, dir: repoPath(owner, r.name), wikiDir: wikiPath(r.id), }; } export class RepoError extends Error {} const repos = async () => (await db()).collection("repos"); export async function findRepo(owner: string, name: string) { const row = await findFirst( "repos", filter("owner.username:lower = {:owner} && name:lower = {:name}", { owner: owner.toLowerCase(), name: name.toLowerCase() }), { expand: EXPAND }, ); return row ? toRepo(row) : null; } export async function findRepoById(id: string) { const row = await findById("repos", id, { expand: EXPAND }); return row ? toRepo(row) : null; } /** * What a git URL names: a repository, or the wiki of one as .wiki.git (like GitHub). A * repository that is itself named ".wiki" (from before such names were refused) wins. */ export async function findGitTarget(owner: string, repoParam: string) { const name = normalizeRepoName(repoParam); const repo = await findRepo(owner, name); if (repo) return { repo, dir: repo.dir, wiki: false }; if (!name.toLowerCase().endsWith(".wiki")) return null; const parent = await findRepo(owner, name.slice(0, -5)); return parent?.wikiEnabled ? { repo: parent, dir: parent.wikiDir, wiki: true } : null; } /** Creates the wiki's git repository if nothing has been written to it yet. */ export async function ensureWikiRepo(repo: RepoRecord) { if (await pathExists(repo.wikiDir)) return; await mkdir(path.dirname(repo.wikiDir), { recursive: true }); await initBareRepo(repo.wikiDir); } const ACCESS_LEVELS: Access[] = [null, "read", "write", "admin"]; const highest = (...levels: Access[]) => levels.reduce((a, b) => (ACCESS_LEVELS.indexOf(b) > ACCESS_LEVELS.indexOf(a) ? b : a), null); /** * What a user may do in a repository: its owner administers it; collaborators push to it; members * of the organization that owns it get what their role gives them (see lib/orgs.ts). */ export async function repoAccess(repo: Pick, userId: string | null | undefined): Promise { const everyone: Access = repo.visibility === "public" ? "read" : null; if (!userId) return everyone; if (repo.ownerId === userId) return "admin"; const [collaborator, role] = await Promise.all([ findFirst("collaborators", filter("repo = {:repo} && user = {:userId}", { repo: repo.id, userId }), { fields: "id" }), orgRole(repo.ownerId, userId), ]); return highest(everyone, collaborator ? "write" : null, role && repoAccessOfRole(role)); } export const canWrite = (access: Access) => access === "write" || access === "admin"; /** The repository a fork was made from, if the viewer may see it (a fork of a private repository could outlive their access to it). */ export async function visibleParent(repo: RepoRecord, viewerId: string | null | undefined) { return repo.forkOf && (await repoAccess(repo.forkOf, viewerId)) ? repo.forkOf : null; } /** Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'. */ const visibleTo = (viewerId: string | null) => viewerId ? filter( "(visibility = 'public' || owner = {:viewer} || collaborators_via_repo.user ?= {:viewer} || owner.org_members_via_org.user ?= {:viewer})", { viewer: viewerId }, ) : "visibility = 'public'"; async function list(where: string, limit?: number) { const options = { filter: where, sort: "-activity_at", expand: EXPAND }; const rows = limit ? (await (await repos()).getList(1, limit, { ...options, skipTotal: true })).items : await (await repos()).getFullList(options); return rows.map(toRepo); } export async function listReposByOwner(ownerId: string, viewerId: string | null) { return list(`${filter("owner = {:owner}", { owner: ownerId })} && ${visibleTo(viewerId)}`); } /** Repositories the user owns or collaborates on, and those of their organizations. */ export async function listReposForUser(userId: string) { return list(filter("owner = {:u} || collaborators_via_repo.user ?= {:u} || owner.org_members_via_org.user ?= {:u}", { u: userId })); } export async function searchRepos(query: string, viewerId: string | null, limit = 50) { return list(`${visibleTo(viewerId)} && ${filter("(name ~ {:q} || description ~ {:q} || owner.username ~ {:q})", { q: query })}`, limit); } // .wiki.git is the clone URL of a repository's wiki. const isWikiName = (name: string) => name.toLowerCase().endsWith(".wiki"); const alreadyHas = (owner: Pick, name: string) => `${owner.isOrg ? `${owner.username} already has` : "You already have"} a repository named ${name}.`; /** Checks a name for a new repository of `owner` and returns where it goes on disk. */ async function newRepoDir(owner: User, rawName: string) { const name = rawName.trim(); if (!isValidName(name)) { throw new RepoError("Repository names may only contain letters, digits, '.', '-' and '_', and may not end in .git."); } if (isWikiName(name)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from."); if (await findRepo(owner.username, name)) throw new RepoError(alreadyHas(owner, name)); const dir = repoPath(owner.username, name); if (await pathExists(dir)) throw new RepoError(`A repository directory for ${name} already exists on disk.`); await mkdir(path.dirname(dir), { recursive: true }); return { name, dir }; } /** Saves the record for a repository whose directory was just made, removing the directory if that fails. */ async function createRecord(owner: User, dir: string, data: Record & { name: string }) { const now = Date.now(); try { return await (await repos()).create({ owner: owner.id, created_at: now, activity_at: now, ...data }); } catch (e) { await rm(dir, { recursive: true, force: true }); if (isUniqueViolation(e)) throw new RepoError(alreadyHas(owner, data.name)); throw e; } } export async function createRepo( owner: User, input: { name: string; description: string; visibility: Visibility; readme: boolean }, by: { /** Who writes the README: the owner, or the person creating a repository for their organization. */ author?: User; /** Who commits it, if not the author: an agent creating the repository for them. */ committer?: { name: string; email: string }; } = {}, ) { const author = by.author ?? owner; const { name, dir } = await newRepoDir(owner, input.name); await initBareRepo(dir); const record = await createRecord(owner, dir, { name, description: input.description.trim().slice(0, 350), visibility: input.visibility }); if (input.readme) { const body = `# ${name}\n${input.description ? `\n${input.description.trim()}\n` : ""}`; await commitFiles(dir, { branch: "main", parent: null, changes: [{ path: "README.md", content: Buffer.from(body) }], message: "Initial commit", author: { name: author.name || author.username, email: author.email }, committer: by.committer, }); await markPushed(record.id); } return (await findRepoById(record.id))!; } /** * Forks `source` for `owner`: a copy of its branches (or only its default branch) and tags, linked * to it. Issues, pull requests, releases and the wiki stay with the source. A fork of a private * repository is private too. */ export async function forkRepo(owner: User, source: RepoRecord, input: { name: string; description: string; defaultBranchOnly: boolean }) { const { name, dir } = await newRepoDir(owner, input.name); const branch = input.defaultBranchOnly ? await defaultBranch(source.dir) : null; try { await cloneBare(source.dir, dir, branch); } catch (e) { await rm(dir, { recursive: true, force: true }); throw e; } const empty = (await lastActivity(dir)) === null; const record = await createRecord(owner, dir, { name, description: input.description.trim().slice(0, 350), visibility: source.visibility, fork_of: source.id, ...(empty ? {} : { pushed_at: Date.now() }), }); return (await findRepoById(record.id))!; } /** Forks of a repository the viewer may see, most recently active first. */ export async function listForks(repoId: string, viewerId: string | null) { return list(`${filter("fork_of = {:repoId}", { repoId })} && ${visibleTo(viewerId)}`); } export async function countForks(repoId: string, viewerId: string | null) { return count("repos", `${filter("fork_of = {:repoId}", { repoId })} && ${visibleTo(viewerId)}`); } /** * The repositories a pull request can come from or go to: everything forked from the same * original, directly or not, that the viewer may see. The original comes first. */ export async function forkNetwork(repo: RepoRecord, viewerId: string | null, limit = 100) { let root = repo; for (let i = 0; i < 20 && root.forkOf; i++) { const parent = await findRepoById(root.forkOf.id); if (!parent) break; root = parent; } const out: RepoRecord[] = []; const queue = [root]; const seen = new Set(); while (queue.length && out.length < limit) { const next = queue.shift()!; if (seen.has(next.id)) continue; seen.add(next.id); if (await repoAccess(next, viewerId)) out.push(next); // Forks of a repository the viewer can't see may still be visible to them. queue.push(...(await list(filter("fork_of = {:id}", { id: next.id })))); } return out; } /** Whether two repositories are forks of the same original (or one of the other). */ export async function sameNetwork(a: RepoRecord, b: RepoRecord) { const root = async (r: RepoRecord) => { for (let i = 0; i < 20 && r.forkOf; i++) { const parent = await findRepoById(r.forkOf.id); if (!parent) break; r = parent; } return r.id; }; return a.id === b.id || (await root(a)) === (await root(b)); } export async function markPushed(repoId: string) { const now = Date.now(); await (await repos()).update(repoId, { pushed_at: now, activity_at: now }); } export async function updateRepo(repoId: string, data: { description: string; visibility: Visibility }) { const repo = await findRepoById(repoId); if (repo?.forkOf?.visibility === "private" && data.visibility === "public") { throw new RepoError(`This is a fork of ${repo.forkOf.owner}/${repo.forkOf.name}, which is private, so it stays private too.`); } await (await repos()).update(repoId, { description: data.description.trim().slice(0, 350), visibility: data.visibility }); } export async function setAgentPush(repoId: string, agentPush: AgentPush) { await (await repos()).update(repoId, { agent_push: agentPush === "any" ? "" : agentPush }); } /** Turning the wiki off hides it and refuses git access to it, but keeps its pages. */ export async function setWikiEnabled(repoId: string, enabled: boolean) { await (await repos()).update(repoId, { wiki_disabled: !enabled }); } export async function renameRepo(repo: RepoRecord, newName: string) { newName = newName.trim(); if (!isValidName(newName)) throw new RepoError("That repository name is invalid."); if (isWikiName(newName)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from."); if (newName === repo.name) return repo; const existing = await findRepo(repo.owner, newName); if (existing && existing.id !== repo.id) throw new RepoError(`You already have a repository named ${newName}.`); const newDir = repoPath(repo.owner, newName); if (newDir !== repo.dir) await rename(repo.dir, newDir); try { await (await repos()).update(repo.id, { name: newName }); } catch (e) { if (newDir !== repo.dir) await rename(newDir, repo.dir); if (isUniqueViolation(e)) throw new RepoError(`You already have a repository named ${newName}.`); throw e; } return (await findRepoById(repo.id))!; } /** Deletes the repository and its wiki; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */ export async function deleteRepo(repo: RepoRecord) { // Pull requests from it to other repositories can't change anymore. Loaded when needed: lib/pulls.ts needs this module. const { closePullsFromRepo } = await import("./pulls"); await closePullsFromRepo(repo); // Its forks stay in the network: they become forks of what it was forked from (or stand alone). for (const fork of await (await repos()).getFullList({ filter: filter("fork_of = {:id}", { id: repo.id }), fields: "id" })) { await (await repos()).update(fork.id, { fork_of: repo.forkOf?.id ?? "" }); } await (await repos()).delete(repo.id); // Frees the container images and the blobs no other repository uses. deleteRepoImages(repo.id); await deleteRepoReleaseFiles(repo.id); await rm(repo.dir, { recursive: true, force: true }); await rm(repo.wikiDir, { recursive: true, force: true }); } // --------------------------------------------------------------------------- // Repository image export async function setRepoImage(repoId: string, data: Uint8Array) { const image = uploadedImage(data, "image"); if ("error" in image) throw new RepoError(image.error); await (await repos()).update(repoId, { image: image.file, image_type: image.type, image_updated_at: Date.now() }); } export async function removeRepoImage(repoId: string) { await (await repos()).update(repoId, { image: null, image_type: "", image_updated_at: 0 }); } export async function getRepoImage(repoId: string) { const row = await findById("repos", repoId); return row?.image ? readStoredImage(row, row.image, row.image_type) : null; } // --------------------------------------------------------------------------- // Collaborators export async function listCollaborators(repoId: string) { const rows = await (await db()) .collection("collaborators") .getFullList({ filter: filter("repo = {:repoId}", { repoId }), expand: "user" }); return rows .map((r) => { const user = r.expand?.user as RecordModel; return { id: user.id as string, username: user.username as string, name: user.name as string, addedAt: r.created_at as number }; }) .sort((a, b) => a.username.localeCompare(b.username)); } export async function addCollaborator(repo: RepoRecord, username: string) { const user = await getPersonByUsername(username.trim()); if (!user) throw new RepoError(`No user named ${username}.`); if (user.id === repo.ownerId) throw new RepoError("The owner already has full access."); try { await (await db()).collection("collaborators").create({ repo: repo.id, user: user.id, created_at: Date.now() }); } catch (e) { // Already a collaborator. if (!isUniqueViolation(e)) throw e; } } export async function removeCollaborator(repoId: string, userId: string) { const row = await findFirst("collaborators", filter("repo = {:repoId} && user = {:userId}", { repoId, userId }), { fields: "id" }); if (row) await (await db()).collection("collaborators").delete(row.id); } /** Last commit time for display, falling back to the creation time. */ export async function repoUpdatedAt(repo: RepoRecord) { return (await lastActivity(repo.dir)) ?? repo.createdAt; } export { GitError };