// The pull requests on a repository, as the page reads them. // // A pull request is a branch in one account's copy, identified by that // account's DID and the ref name. The pair survives every rewrite of the // branch, so a check or a review written against one version still names the // work after a rebase, which under agents is most pushes. // // The list comes from the repository's own server, which reads every copy // once for every visitor. What the page keeps is the addressing of a pull // request, and a reader over one contributor's server for the objects of a // commit in their copy, which are there and nowhere else. import { byPull, fileSummary, fixesOf, mergedRun, place, pullKey, replacedTips, runsForCopies, shortRef, subjectOf, } from '@pdsjs/git/pull-requests'; export { byPull, fileSummary, fixesOf, mergedRun, place, pullKey, replacedTips, runsForCopies, shortRef, subjectOf, }; import { learnProfiles } from './authors.js'; import { account, ownerClient, readerFor, serverClient } from './git.js'; /** @typedef {import('@pdsjs/git/pull-requests').Contributor} Contributor */ /** @typedef {import('@pdsjs/git/pull-requests').PullRequest} PullRequest */ /** @type {Map>} */ /** @param {Contributor} who */ /** * The way back to the pull request list, naming the tab that holds this one. * * The list is one query, so a page reached from it has it in hand and can be * exact. A reader who opened the address directly has no such read, and asking * for one to place a breadcrumb would mean gathering every collaborator's copy * to render a link. * @param {string} repo * @param {Pulls|undefined} pulls - the cached read, where there is one * @param {string} tip - the tip commit * @returns {string} */ export function pullsHref(repo, pulls, tip) { const at = `/${encodeURIComponent(repo)}/pulls`; const on = (/** @type {PullRequest[]|undefined} */ list) => (list ?? []).some((pull) => pull.sha === tip); if (on(pulls?.merged)) return `${at}/merged`; return at; } /** * How many of a pull request's commits are read at once. The timeline and the * commit listing share one cache entry, so they share this. */ export const PULL_COMMIT_LIMIT = 50; /** * The commits a pull request is made of, from whichever copy holds them: * this account's, or a collaborator's or a fork's, read through this server * either way. * @param {string} repo * @param {string|null} base - the merge base, null where there is none * @param {string} tip * @param {string} from - the DID whose copy holds it, empty for this account * @param {number} limit * @returns {Promise<{commits: Array>, truncated: boolean}>} */ export async function pullCommits(repo, base, tip, from, limit) { const copy = from && from !== account.did ? from : undefined; const log = await readerFor(repo, copy).listRange( copy ?? account.did, repo, base, tip, limit, ); // The reader answers null for a repository it cannot open. A screen that // took that for an answer would wait on a listing that never arrives. if (!log) throw new Error('That copy of the repository could not be read.'); return log; } /** * Where a pull request opens, addressed by what identifies it: the account * whose copy holds the work, and the branch. Both survive a rebase, so the * address keeps working while the shas under it move. * * `tab` is what the reader asked to see of it, past the separator: `files`, * `checks`, or `commit/` for one of its own commits. * @param {string} repo * @param {PullRequest} pull * @param {string} [tab] * @returns {string} */ export function diffHref(repo, pull, tab = '') { const branch = pull.branch.split('/').map(encodeURIComponent).join('/'); const at = `/${encodeURIComponent(repo)}/pull/${encodeURIComponent( pull.author.did, )}/${branch}`; return tab ? `${at}/${PULL_SEPARATOR}/${tab}` : at; } /** * What divides a branch from what follows it. * * A ref name may hold slashes and a pull request is addressed by its branch, * so anything past the branch would otherwise read as the branch's own last * segment. A forge addressing by number never meets this; this one has no * number to address by, so it needs a divider. * * `~` is the divider because git refuses it in a ref name, so no branch can * hold a segment that looks like one. It is also unreserved in a URL, so it * survives a path without being escaped. GitLab's `-` is the better-known * spelling and git allows a ref component of `-`, which would leave * `fix/-/checks` meaning two things. */ const PULL_SEPARATOR = '~'; /** * Read a pull request's address back: the account, the branch and the tab. * * The branch is what makes this more than a split. A ref name may hold * slashes, so the branch is every segment up to the separator. * * A `-` segment divides the branch from what follows it, so no branch name * can be mistaken for a tab: git forbids a ref component of `-`, and a DID * holds no slash, so every part of the address reads without a guess. * @param {string[]} rest - the path segments after `pull`, already decoded * @returns {{did: string, branch: string, tab: string, commit: string}} */ export function parsePullPath(rest) { const did = rest[0] ?? ''; const parts = rest.slice(1); const cut = parts.indexOf(PULL_SEPARATOR); // Everything before the separator is the branch, however many slashes it // holds. Everything after is what the reader asked to see of it. const branch = (cut === -1 ? parts : parts.slice(0, cut)).join('/'); const after = cut === -1 ? [] : parts.slice(cut + 1); // One commit of this pull request, addressed under it so a reader who // opens one is still in the section they were reading. const commit = after[0] === 'commit' ? (after[1] ?? '') : ''; const tab = commit || after.find((one) => one === 'files' || one === 'checks') || ''; return { did, branch, tab: commit ? 'commit' : tab, commit }; } /** * @typedef {Object} Pulls * @property {PullRequest[]} open - branches canonical main does not reach, * from collaborators and from forks together * @property {PullRequest[]} merged * @property {Contributor[]} collaborators * @property {Map>>} reviews * @property {Map>>} checks * @property {boolean} configured - the repository names collaborators */ /** * Every pull request on a repository, as the server lists them. The faces * and names of everybody on the list are learned alongside, so a row is drawn * whole on the first paint. A repository in a space has none to list, which * is what the server answers. * @param {string} repo - what the repository is called * @param {string} [space] * @returns {Promise} */ export async function loadPulls(repo, space) { const list = await (space ? ownerClient() : await serverClient()).listPulls({ repo, space, }); const people = new Set(list.collaborators.map((who) => who.did)); for (const pull of [...list.open, ...list.merged]) people.add(pull.author.did); await learnProfiles([...people]); return { open: list.open, merged: list.merged, collaborators: list.collaborators, reviews: new Map(list.reviews), checks: new Map(list.checks), configured: list.configured, }; } /** * Every review written about this repository's pull requests, for a page that * shows one pull request rather than all of them. The list holds each review * under two keys; this reads each once. * @param {string} repo - what the repository is called * @param {string} [space] * @returns {Promise>>} newest first */ export async function pullStatements(repo, space) { const { reviews } = await loadPulls(repo, space); /** @type {Map>} */ const once = new Map(); for (const statements of reviews.values()) { for (const statement of statements) { const uri = typeof statement.uri === 'string' ? statement.uri : ''; if (uri && !once.has(uri)) once.set(uri, statement); } } return [...once.values()].sort((a, b) => String(b.reviewedAt ?? '').localeCompare(String(a.reviewedAt ?? '')), ); }