chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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<string, Promise<Contributor|null>>} */
/** @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<Record<string, *>>, 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/<sha>` 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<string, Array<Record<string, unknown>>>} reviews
* @property {Map<string, Array<Record<string, unknown>>>} 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<Pulls>}
*/
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<Array<Record<string, unknown>>>} newest first
*/
export async function pullStatements(repo, space) {
const { reviews } = await loadPulls(repo, space);
/** @type {Map<string, Record<string, unknown>>} */
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 ?? '')),
);
}