/** * A client for the dev.pdsjs.git methods, for a page or a tool reading a * repository's server. One call per method, each answering the view its * lexicon in api-lexicon.js describes; a refusal throws an XrpcError * carrying the name the server gave. * * describe() asks community.lexicon.service.describe once and keeps the * answer, so serves() tells a server that predates a method, or runs with * the file browser off, from one that answers it. * * This module must stay free of node-only imports; it runs in the browser. */ import { GIT_METHODS } from './api-lexicon.js'; import { XrpcError } from './xrpc-error.js'; export { XrpcError }; /** * @typedef {Object} RepoView * @property {string} name * @property {string} uri * @property {string} cid - the record version, which a read of the repository names to be answered immutably * @property {string|null} space * @property {boolean} spaceOwned * @property {string|null} description * @property {string|null} defaultBranch * @property {Array<{name: string, sha: string}>} refs * @property {number} branches * @property {number} tags * @property {number} bundles * @property {number} parts * @property {number} size * @property {string|null} createdAt * @property {string|null} updatedAt * @property {string[]} protectedBranches * @property {string|null} runner * @property {Array<{registry: string, name: string}>} packages * @property {string} cloneUrl * @property {string|null} httpCloneUrl * @property {number} [openPulls] - open pull requests, present only when the listing asked for a summary * @property {number} [openIssues] - open issues, present only when the listing asked for a summary * @property {number[]} [activity] - recent commit dates for the gauge, present only when the listing asked for a summary */ /** * @typedef {Object} TreeEntry * @property {string} name * @property {'dir'|'file'|'symlink'|'submodule'} type * @property {string} mode * @property {string} sha * @property {number|null} size */ /** * @typedef {Object} TreeView * @property {string} ref * @property {string} sha * @property {string} path * @property {TreeEntry[]} entries */ /** * @typedef {Object} FileView * @property {string} ref * @property {string} path * @property {number} size * @property {string} mode * @property {string|null} mediaType * @property {boolean} binary * @property {boolean} truncated * @property {string|null} text */ /** * @typedef {Object} CommitView * @property {string} sha * @property {string[]} parents * @property {string} author * @property {number} authoredAt * @property {number} committedAt * @property {string} message */ /** * @typedef {Object} DiffLine * @property {'context'|'add'|'delete'} type * @property {string} text * @property {number|null} oldLine * @property {number|null} newLine */ /** * @typedef {Object} DiffHunk * @property {number} oldStart * @property {number} oldLines * @property {number} newStart * @property {number} newLines * @property {DiffLine[]} lines */ /** * @typedef {Object} FileDiff * @property {string} path * @property {'added'|'deleted'|'modified'} status * @property {string|null} oldMode * @property {string|null} newMode * @property {number|null} oldSize * @property {number|null} newSize * @property {boolean} binary * @property {string|null} mediaType * @property {DiffHunk[]|null} hunks * @property {string|null} oldText * @property {string|null} newText * @property {number} additions * @property {number} deletions */ /** * @typedef {CommitView & {files: FileDiff[]}} CommitDetail */ /** * @typedef {Object} CheckView * @property {string} ref * @property {string} workflow * @property {{uri: string, cid: string}} [subject] * @property {{uri: string, cid: string}} [check] * @property {string} sha * @property {'running'|'success'|'failure'|'error'} status * @property {string} startedAt * @property {string} [finishedAt] */ /** * @typedef {Object} ProfileView * @property {string} did * @property {string} handle * @property {string} displayName * @property {string} avatar */ /** * @typedef {Object} ClaimView * @property {string} name * @property {string} email * @property {string} label * @property {string} did * @property {string} avatar * @property {ProfileView|null} profile */ /** * The parameters a read of one repository takes. `cid` names the record * version the caller read: current, the answer caches immutably; stale, the * server answers RepoNotFound. * @typedef {Object} RepoParams * @property {string} repo - repository name, the record key * @property {string} [space] - the space holding it; needs the account session * @property {string} [cid] * @property {string} [from] - the account whose copy to read, for a branch in a collaborator's or a fork's copy */ /** @typedef {RepoParams & {ref?: string}} RefParams */ /** @typedef {RefParams & {path?: string}} PathParams */ /** @typedef {import('./pull-requests.js').Contributor} ContributorView */ /** @typedef {import('./pull-requests.js').PullRequest} PullRequestView */ const DESCRIBE = 'community.lexicon.service.describe'; /** * @param {Response} res * @returns {Promise} */ async function refused(res) { /** @type {{error?: string, message?: string}} */ let body = {}; try { body = /** @type {{error?: string, message?: string}} */ (await res.json()); } catch { // A body that is not JSON carries no name. } throw new XrpcError( res.status, body.error ?? (res.status === 501 ? 'MethodNotImplemented' : 'Unknown'), body.message ?? `request failed with status ${res.status}`, ); } export class GitClient { /** * @param {Object} options * @param {string} options.service - the PDS base URL * @param {typeof fetch} [options.fetch] - what carries the request; the global by default * @param {Record} [options.headers] - sent with every request, an Authorization say */ constructor({ service, fetch: carrier, headers = {} }) { this.service = service.replace(/\/+$/, ''); this.headers = headers; /** @type {typeof fetch} */ this.fetch = carrier ?? ((input, init) => globalThis.fetch(input, init)); /** @type {Promise>|null} */ this.methods = null; } /** * The methods the server serves, asked once. A server that cannot answer * describe reads as serving none, which sends a caller to its fallback * rather than to a failure. * @returns {Promise>} */ describe() { this.methods ??= (async () => { try { const res = await this.fetch(`${this.service}/xrpc/${DESCRIBE}`, { headers: this.headers, }); if (!res.ok) return new Set(); const { methods } = /** @type {{methods?: Array<{value?: unknown}>}} */ ( await res.json() ); return new Set( (methods ?? []) .map((entry) => entry.value) .filter((value) => typeof value === 'string'), ); } catch { return new Set(); } })(); return this.methods; } /** * Whether the server answers a method, by its short name (`getTree`) or * its NSID. * @param {string} method */ async serves(method) { const nsid = method.includes('.') ? method : `dev.pdsjs.git.${method}`; return (await this.describe()).has(nsid); } /** * Whether the server answers every read of the bundle chain. * @returns {Promise} */ async servesReads() { const served = await this.describe(); return GIT_METHODS.every( (nsid) => !READS.has(nsid.slice('dev.pdsjs.git.'.length)) || served.has(nsid), ); } /** * @param {string} method - short name * @param {Record} [params] * @returns {Promise} */ async get(method, params = {}) { const query = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== '') query.set(key, String(value)); } const suffix = query.size ? `?${query}` : ''; const res = await this.fetch( `${this.service}/xrpc/dev.pdsjs.git.${method}${suffix}`, { headers: this.headers }, ); if (!res.ok) await refused(res); return res; } /** * @param {string} method - short name * @param {Record} [params] * @returns {Promise} */ async query(method, params) { return (await this.get(method, params)).json(); } /** * @param {string} method - short name * @param {unknown} body * @returns {Promise} */ async procedure(method, body) { const res = await this.fetch( `${this.service}/xrpc/dev.pdsjs.git.${method}`, { method: 'POST', headers: { ...this.headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}), }, ); if (!res.ok) await refused(res); return res.json(); } /** * @param {{summary?: boolean}} [params] - summary asks the server for each * public repository's open pull request and issue counts and its recent * commit dates * @returns {Promise<{httpEnabled: boolean, repos: RepoView[]}>} */ listRepos(params) { return this.query( 'listRepos', params?.summary ? { summary: 'true' } : undefined, ); } /** @returns {Promise<{owner: ProfileView|null, claims: ClaimView[]}>} */ getAuthors() { return this.query('getAuthors'); } /** * @param {{repo: string, space?: string}} params * @returns {Promise} */ getRepo(params) { return this.query('getRepo', params); } /** * @param {PathParams} params * @returns {Promise} */ getTree(params) { return this.query('getTree', params); } /** * @param {RefParams} params * @returns {Promise<{ref: string, files: Array<{path: string, size: number}>, truncated: boolean}>} */ listFiles(params) { return this.query('listFiles', params); } /** * @param {PathParams & {path: string}} params * @returns {Promise} */ getFile(params) { return this.query('getFile', params); } /** * The file's bytes, as a Response whose Content-Type names an image's * media type, text/plain for text, and an octet stream otherwise. * @param {PathParams & {path: string}} params * @returns {Promise} */ getRaw(params) { return this.get('getRaw', params); } /** * @param {RefParams & {limit?: number, range?: string, path?: string}} params - `range` as base..tip lists the commits across it in place of a ref; `path` narrows the history to the commits that changed it * @returns {Promise<{ref: string, commits: CommitView[], truncated: boolean}>} */ listCommits(params) { return this.query('listCommits', params); } /** * @param {RepoParams & {sha: string}} params * @returns {Promise} */ getCommit(params) { return this.query('getCommit', params); } /** * @param {PathParams} params * @returns {Promise<{commit: CommitView|null}>} */ getLastCommit(params) { return this.query('getLastCommit', params); } /** * @param {{repo: string, space?: string}} params * @returns {Promise<{runner: string|null, service?: string, unreachable?: boolean, checks: CheckView[]}>} */ listChecks(params) { return this.query('listChecks', params); } /** * @param {{repo: string, space?: string}} params * @returns {Promise<{runner: string|null, service?: string, unreachable?: boolean, runs: Array & {rkey?: string, sha: string, status: string, startedAt: string}>}>} */ listCheckRuns(params) { return this.query('listCheckRuns', params); } /** * @param {{repo: string, space?: string}} params * @returns {Promise<{open: PullRequestView[], merged: PullRequestView[], collaborators: ContributorView[], reviews: Array<[string, Array>]>, checks: Array<[string, Array>]>, configured: boolean}>} */ listPulls(params) { return this.query('listPulls', params); } /** * @param {{repo: string, space?: string}} params * @returns {Promise<{statements: Array & {uri: string, cid: string, by: ContributorView, indexed?: boolean}>}>} */ listIssues(params) { return this.query('listIssues', params); } /** * @param {{name: string, description?: string, space?: string, type?: string, skey?: string}} body * @returns {Promise<{name: string, space: string|null}>} */ createRepo(body) { return this.procedure('createRepo', body); } /** * @param {{name: string, space?: string, description?: string, defaultBranch?: string, protectedBranches?: string[], runner?: string|null, packages?: Array<{registry: string, name: string}>}} body * @returns {Promise<{name: string, space: string|null, protectedBranches?: string[], runner?: string|null, packages?: Array<{registry: string, name: string}>}>} */ updateSettings(body) { return this.procedure('updateSettings', body); } /** * @param {{name: string, ref: string}} body * @returns {Promise<{runner: string, ref: string}>} */ requestCheck(body) { return this.procedure('requestCheck', body); } /** * @param {{name: string, did: string, ref: string, mode?: 'merge'|'squash', * handle?: string, subject?: string, body?: string}} body * @returns {Promise<{sha: string, ref: string, mode: string, objects: number}>} */ mergePull(body) { return this.procedure('mergePull', body); } } /** The methods that read the bundle chain, registered only with a browser. */ const READS = new Set([ 'getTree', 'listFiles', 'getFile', 'getRaw', 'listCommits', 'getCommit', 'getLastCommit', ]);