chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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<string>} */
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<string, number>} */
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<Array<Record<string, unknown>>>} 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<Record<string, unknown>>} 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<string, unknown>} */ (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<string, unknown>} 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<Record<string, unknown>>} statements - newest first
* @returns {Map<string, Array<Record<string, unknown>>>}
*/
export function byPull(statements) {
/** @type {Map<string, Array<Record<string, unknown>>>} */
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<Record<string, unknown>>} statements - about this pull request
* @returns {Set<string>} commit ids, the tip excluded
*/
export function replacedTips(pull, statements) {
/** @type {Set<string>} */
const tips = new Set();
for (const statement of statements) {
if (typeof statement.sha === 'string') tips.add(statement.sha);
}
tips.delete(pull.sha);
return tips;
}