// Repository settings, written as the owner. // // The settings live in two records. The description and the default branch // are fields of the repository record, which every push rewrites. The // protected branches, the check runner and the linked packages are in the // config record beside it, which no push touches. One request carries both, // and a field the request leaves out keeps the value it has, so a form saves // the group it edits alone. // // A write runs as the owner, on the owner's own PDS, under the owner's OAuth // session. The server accepts it only from a token whose account is the // site's, so nobody else's session configures a repository here. import { XrpcError } from '@pdsjs/git/client'; import { NPM_PACKAGE_COLLECTION } from '@pdsjs/npm/lexicon'; import { OCI_REPO_COLLECTION } from '@pdsjs/oci/lexicon'; import { account, forgetConfig } from '#/lib/git.js'; import { authedFetch } from '#/lib/oauth.js'; /** * One package a repository builds, in the pair that names it. A name repeats * across registries, so neither half identifies one on its own. * @typedef {Object} LinkedPackage * @property {string} registry - npm or oci * @property {string} name */ /** * What one save carries. Every field is optional and an absent one keeps the * value it has. * @typedef {Object} SettingsChange * @property {string} [description] - empty clears it * @property {string} [defaultBranch] - empty clears it * @property {string[]} [protectedBranches] * @property {string|null} [runner] - null clears it * @property {LinkedPackage[]} [packages] - an empty list clears them */ /** What one linked package is keyed by: the pair, since names repeat. */ export const packageKey = (/** @type {LinkedPackage} */ entry) => `${entry.registry}:${entry.name}`; /** Record pages one registry walk reads before it stops. */ const MAX_PAGES = 10; /** * Write repository settings. * @param {import('#/lib/oauth.js').Session} session - the owner's own * @param {string} repo - repository name on this site * @param {string} space - the space holding a private repository, '' for one * in the account's own repo * @param {SettingsChange} changes * @returns {Promise} */ export async function updateSettings(session, repo, space, changes) { const response = await authedFetch( session, `${account.pds}/xrpc/dev.pdsjs.git.updateSettings`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: repo, ...(space ? { space } : {}), ...changes, }), }, ); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new XrpcError( response.status, body.error ?? 'Unknown', body.message ?? `The save failed (${response.status}).`, ); } // The tabs read the config record to decide which sections a repository // has, and this write changed it. forgetConfig(repo); } /** * Rename a repository. The record key is its identity, so every issue, pull * request and check still finds it; what moves is the address, which a clone * remote and this page's own path carry. * @param {import('#/lib/oauth.js').Session} session - the owner's own * @param {string} repo - repository name on this site * @param {string} to - the name to give it * @returns {Promise} */ export async function renameRepo(session, repo, to) { const response = await authedFetch( session, `${account.pds}/xrpc/dev.pdsjs.git.renameRepo`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: repo, newName: to }), }, ); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new XrpcError( response.status, body.error ?? 'Unknown', body.message ?? `The rename failed (${response.status}).`, ); } forgetConfig(repo); } /** * Delete a repository: the record, the config record beside it, and the * bundle blobs neither of them holds any more. A repository holding a * protected branch refuses, and the server says so. * @param {import('#/lib/oauth.js').Session} session - the owner's own * @param {string} repo - repository name on this site * @param {string} space - the space holding a private repository, '' for one * in the account's own repo * @returns {Promise} */ export async function deleteRepo(session, repo, space) { const response = await authedFetch( session, `${account.pds}/xrpc/dev.pdsjs.git.deleteRepo`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: repo, ...(space ? { space } : {}) }), }, ); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new XrpcError( response.status, body.error ?? 'Unknown', body.message ?? `The delete failed (${response.status}).`, ); } forgetConfig(repo); } /** * The names one collection in the account's repo holds. Reading is anonymous * because these records are public, and a form offers them before anything * is written. * @param {string} collection * @returns {Promise} */ async function publishedNames(collection) { /** @type {string[]} */ const names = []; let cursor = ''; for (let page = 0; page < MAX_PAGES; page += 1) { const params = new URLSearchParams({ repo: account.did, collection, limit: '100', }); if (cursor) params.set('cursor', cursor); const response = await fetch( `${account.pds}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (!response.ok) return names; const body = /** @type {{records?: Array<{value?: {name?: unknown}}>, cursor?: string}} */ ( await response.json() ); for (const record of body.records ?? []) { const name = record.value?.name; if (typeof name === 'string' && name) names.push(name); } if (!body.cursor) break; cursor = body.cursor; } return names; } /** * Every package this account publishes, which is what a repository may link * to. A registry the server does not serve holds no records, so it reads the * same as a registry nobody has published to: nothing to link. * @returns {Promise} */ export async function publishedPackages() { const [npm, oci] = await Promise.all([ publishedNames(NPM_PACKAGE_COLLECTION).catch(() => []), publishedNames(OCI_REPO_COLLECTION).catch(() => []), ]); return [ ...npm.map((name) => ({ registry: 'npm', name })), ...oci.map((name) => ({ registry: 'oci', name })), ]; }