/** * Pull requests, read from what the object graph and the records say. * * A pull request is a branch in one account's copy, named by the account and * the branch. Nothing here stores a state: open and merged are where a tip * stands against canonical main, and every other fact is derived from the * commits, the statements written against them, and the runner's runs. * * These are the parts of that reading that touch no network: the page and * the server both read pull requests, and each brings its own fetching. * * Workers-clean: no node imports. */ /** * @typedef {Object} Contributor * @property {string} did * @property {string} handle * @property {string} pds */ /** * @typedef {Object} PullRequest * @property {string} id - the author's DID and the branch, which is what * identifies a pull request * @property {Contributor} author * @property {string} branch - short ref name in the author's own copy * @property {string} title - the author's title, or the tip commit's subject * @property {string} sha - the tip commit * @property {number} when - the tip commit's commit date, epoch ms * @property {number} ahead - commits main does not have * @property {number} behind - commits main has that this does not * @property {string|null} base - the merge base with main * @property {boolean} merged - main already reaches this tip * @property {boolean} rooted - a merge base was found inside the walk * @property {string[]} files - paths the pull request touches * @property {string[]} fixes - issue at-uris its commits claim to fix * @property {string[]} [drift] - paths main has changed since it branched * @property {string[]} [conflicts] - paths where the branch and main changed * the same thing differently, so a merge cannot put them together. Empty * where a merge is clean, and where main has not moved. * @property {boolean} [conflictsUnknown] - the branch drifts in more files * than the list read, and none of those it read conflict, so whether the * rest do is unread. The merge itself reads them all. * @property {boolean} [fork] - from an account the repository has not named * @property {string} [status] - the author's word from their own record, such * as draft or withdrawn * @property {string} [note] - the author's note from the same record * @property {string} [noteAt] - when they wrote it, where that is known */ /** * What identifies a pull request: the account whose copy holds the work, and * the branch holding it. A space appears in neither a DID nor a ref name, so * the two parts cannot run together. * @param {string} did * @param {string} branch - short ref name * @returns {string} */ export const pullKey = (did, branch) => `${did} ${branch}`; /** * The short form of a ref name, which is how a branch is named on screen. * @param {unknown} ref * @returns {string} */ export const shortRef = (ref) => String(ref || '').replace(/^refs\/heads\//, ''); /** * The DID an AT-URI names. * @param {unknown} uri * @returns {string|null} */ const didOf = (uri) => /^at:\/\/([^/]+)\//.exec(String(uri || ''))?.[1] ?? null; /** * The issues a commit message claims to fix: each a `Fixes:` trailer naming * an issue record's at-uri. The fixer writes it in the fix. An issue here has * no number, so its name is its address. * @param {string} message * @returns {string[]} */ export function fixesOf(message) { return [ ...String(message || '').matchAll(/^Fixes:\s*(at:\/\/\S+)\s*$/gm), ].map((match) => match[1]); } /** * The first line of a commit message. * @param {string|undefined} message */ export const subjectOf = (message) => String(message || '') .split('\n')[0] .trim(); /** * Place a branch against main by sha alone. Both walks come from different * servers and neither holds the other's objects, but a commit id is the same * id everywhere, so the intersection of the two walks is the merge base. * @param {Array<{sha: string}>} branch - the branch's commits, newest first * @param {string[]} mainShas - main's commits, newest first * @returns {{ahead: number, base: string|null, behind: number, * merged: boolean, rooted: boolean}} */ export function place(branch, mainShas) { const inMain = new Set(mainShas); const here = branch[0]?.sha; if (!here || inMain.has(here)) { return { ahead: 0, base: here ?? null, behind: 0, merged: true, rooted: true, }; } let ahead = 0; let base = null; for (const entry of branch) { if (inMain.has(entry.sha)) { base = entry.sha; break; } ahead++; } // No shared commit inside either walk. The branch is not placed against // main, and counting it as ahead of everything would be a guess. if (!base) return { ahead, base: null, behind: 0, merged: false, rooted: false }; return { ahead, base, behind: mainShas.indexOf(base), merged: false, rooted: true, }; } /** * Where a merged branch's work began, read from the merge that brought it in. * * Once main reaches a branch there is no divergence left to measure: the * branch's commits are main's commits. A merge commit still says where they * came from. Its second parent is the tip that was merged, and its first * parents are main's own trunk, so the branch's earliest commit outside that * trunk is where the work began. * * A branch fast-forwarded or squashed into main leaves no such commit. The * honest answer from that history is the tip alone. * @param {Array<{sha: string, parents: string[]}>} commits - the branch's, * newest first * @param {Array<{sha: string, parents: string[]}>} mainCommits - newest first * @returns {{ahead: number, base: string|null}} */ export function mergedRun(commits, mainCommits) { const tip = commits[0].sha; const merge = mainCommits.find((entry) => (entry.parents ?? []).slice(1).includes(tip), ); if (!merge) return { ahead: 1, base: commits[0].parents[0] ?? null }; const byMain = new Map(mainCommits.map((entry) => [entry.sha, entry])); /** @type {Set} */ const trunk = new Set(); /** @type {string|undefined} */ let at = merge.parents[0]; while (at && !trunk.has(at)) { trunk.add(at); at = byMain.get(at)?.parents?.[0]; } let ahead = 0; for (const entry of commits) { if (trunk.has(entry.sha)) { return { ahead: Math.max(ahead, 1), base: entry.sha }; } ahead++; } return { ahead, base: commits[commits.length - 1].parents[0] ?? null }; } /** * A file list too long to read, said in one line. An agent's pull request * touches tens of files, and printing them all buries everything else on the * card. * @param {string[]} paths * @returns {{count: number, dirs: Array<{dir: string, count: number}>}} * directories by how many of the paths are in them, most first */ export function fileSummary(paths) { /** @type {Map} */ const byDir = new Map(); for (const path of paths) { const cut = path.lastIndexOf('/'); const dir = cut === -1 ? '' : path.slice(0, cut); byDir.set(dir, (byDir.get(dir) ?? 0) + 1); } const dirs = [...byDir.entries()] .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .map(([dir, count]) => ({ dir, count })); return { count: paths.length, dirs }; } /** Characters a record key may hold; anything else becomes `_`. */ const RKEY_SAFE = /[^A-Za-z0-9._~:-]/g; /** * The record key one branch's pull request lives at: the canonical * repository's own key and the full ref, which together name the pull * request. The CLI writes it with `putRecord` and the reader reads it back, * so both must agree or an update is never seen. * * The canonical key is the one value both sides of a pull request hold, and * it stands through a rename. * @param {string} repoKey - the canonical repository's record key * @param {string} ref - full ref name * @returns {string} */ export const pullRkey = (repoKey, ref) => `${repoKey}:${ref}`.replace(RKEY_SAFE, '_'); /** * Every reviewer's records about this repository, indexed by what they name. * A reviewer keys reviews by TID in one collection, so the listing is filtered * here rather than by the server. * @param {unknown} config * @returns {Promise>>} newest first */ /** * The runner's runs about any copy of one repository. The runner checks the * branch a collaborator pushed to that collaborator's own server, so a run * names their record rather than the canonical one. * @param {unknown[]} records - listRecords entries * @param {string[]} copyUris - the record of every copy that counts, each * under its own holder's key * @returns {Array>} newest first */ export function runsForCopies(records, copyUris) { const copies = new Set(copyUris); const runs = []; for (const entry of records) { const row = /** @type {{value?: unknown, uri?: unknown}|null} */ (entry); const run = /** @type {Record} */ (row?.value ?? {}); const subject = /** @type {{uri?: unknown}} */ (run.subject ?? {}); if (typeof subject.uri !== 'string' || !copies.has(subject.uri)) continue; if (typeof run.sha !== 'string') continue; // The record key, which is what addresses the run's own page. Without it // a run reads here and cannot be opened. const rkey = String(row?.uri ?? '') .split('/') .pop(); runs.push(rkey ? { ...run, rkey } : run); } return runs.sort((a, b) => String(b.startedAt ?? '').localeCompare(String(a.startedAt ?? '')), ); } /** * The pull request one statement names: its author DID and ref, or the DID in * the repository record it read and its ref. A check names the copy it ran * against rather than an author, and both forms carry the branch. * @param {Record} statement * @returns {string|null} */ function statementPull(statement) { const author = typeof statement.author === 'string' ? statement.author : didOf(/** @type {{uri?: string}} */ (statement.subject ?? {}).uri); const ref = statement.ref; if (!author || typeof ref !== 'string') return null; return pullKey(author, shortRef(ref)); } /** * Index statements by what they name. A statement reaches its pull request * under two keys, the author and branch pair and the sha it was written * against, so a reader finds it whether or not the writer named the branch. * @param {Array>} statements - newest first * @returns {Map>>} */ export function byPull(statements) { /** @type {Map>>} */ const index = new Map(); for (const statement of statements) { for (const key of [statementPull(statement), statement.sha]) { if (typeof key !== 'string') continue; const list = index.get(key) ?? []; if (!list.includes(statement)) list.push(statement); index.set(key, list); } } return index; } /** * The tips one branch has carried that are not the tip now, newest first. * * A force-push puts the commit before it out of reach: nothing in the graph * says it existed, and a review or a check written against it names a sha * nothing points at. What remembers is the checks and the reviews, each of * which names the tip it answered. * * What this is for is telling an old tip from an unrelated one, so a comment * on a rewritten commit reads as outdated rather than as lost. * @param {PullRequest} pull * @param {Array>} statements - about this pull request * @returns {Set} commit ids, the tip excluded */ export function replacedTips(pull, statements) { /** @type {Set} */ const tips = new Set(); for (const statement of statements) { if (typeof statement.sha === 'string') tips.add(statement.sha); } tips.delete(pull.sha); return tips; }