chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// The checks a repository's runner has published, newest per commit.
//
// Checks live in the runner's repo, not this account's. The repository's
// dev.pdsjs.git.config record names the runner DID, the runner's DID
// document names its PDS, and the runner's check collection holds the
// runs. The listing is filtered by subject, so a runner that serves many
// repositories still answers for this one. Only the runner the config
// names is read; a check any other account publishes about the same
// commit never appears.
//
// Every failure reads as "no checks": a repository with no runner, an
// unreachable runner and an empty history all render the same absent
// badge, and none of them is an error worth showing.
import { CheckIcon, CircleDotIcon, XIcon } from 'lucide-react';
import { ownerClient, repoRecord, serverClient, shortRef } from './git.js';
/**
* @typedef {Object} CheckStep
* @property {string} name
* @property {number} exitCode - negative when a signal ended the step
* @property {number} [durationMs]
*/
/**
* The fields this app reads from a dev.pdsjs.git.check record.
* @typedef {Object} CheckRun
* @property {string} sha
* @property {string} status
* @property {string} startedAt
* @property {string} [rkey] - the record key, which addresses the run page
* @property {string} [ref]
* @property {string} [workflow]
* @property {string} [finishedAt]
* @property {CheckStep[]} [steps]
* @property {{ref?: {$link?: string}}} [logs]
*/
/**
* How a whole commit reads: one status for the badge, and the count behind it.
* @typedef {Object} CheckSummary
* @property {string} status - the status the badge shows
* @property {number} passed - workflows that succeeded
* @property {number} total - workflows that reported
*/
export const CHECK_LOOKS = {
success: { label: 'passed', className: 'text-success', Icon: CheckIcon },
failure: { label: 'failed', className: 'text-destructive', Icon: XIcon },
error: { label: 'errored', className: 'text-destructive', Icon: XIcon },
running: {
label: 'running',
className: 'text-muted-foreground',
Icon: CircleDotIcon,
},
};
/**
* A status this app has words for. A runner may publish one from a lexicon
* version this build does not know, which reads as running.
* @param {string|undefined} status
*/
export const checkLook = (status) =>
CHECK_LOOKS[/** @type {keyof typeof CHECK_LOOKS} */ (status)] ??
CHECK_LOOKS.running;
/**
* What a run's workflow is called. The field is optional in the lexicon, and a
* runner that publishes one workflow may leave it out.
* @param {{workflow?: string}} check
* @returns {string}
*/
export const workflowOf = (check) => check.workflow || 'ci';
/**
* An elapsed time in the largest units that stay readable.
* @param {number} ms
* @returns {string}
*/
export function duration(ms) {
if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
/**
* How long a run took. Null while the run is unfinished, which is where a
* live elapsed time would belong and this does not attempt one.
* @param {CheckRun} check
* @returns {string|null}
*/
export function runDuration(check) {
if (!check.finishedAt) return null;
const ms = Date.parse(check.finishedAt) - Date.parse(check.startedAt);
return ms >= 0 ? duration(ms) : null;
}
/**
* @typedef {Object} CheckRunner
* @property {string} did
* @property {string} service - the runner's PDS origin
*/
/** @typedef {{runner: CheckRunner|null, runs: CheckRun[], bySha: Map<string, CheckRun[]>}} RepoChecks */
/**
* The newest run of each workflow. A workflow asked for again reports twice,
* and only the most recent of those says where that workflow stands.
* @param {CheckRun[]} runs - newest first
* @returns {CheckRun[]}
*/
export function latestPerWorkflow(runs) {
/** @type {CheckRun[]} */
const latest = [];
for (const run of runs) {
if (latest.some((seen) => workflowOf(seen) === workflowOf(run))) continue;
latest.push(run);
}
return latest;
}
/**
* The runs of each commit, newest commit first. A commit's group holds every
* run against it, re-runs included, in the order the listing gave them.
* @param {CheckRun[]} runs - newest first
* @returns {{sha: string, runs: CheckRun[]}[]}
*/
export function groupBySha(runs) {
/** @type {Map<string, {sha: string, runs: CheckRun[]}>} */
const groups = new Map();
for (const run of runs) {
const group = groups.get(run.sha);
if (group) group.runs.push(run);
else groups.set(run.sha, { sha: run.sha, runs: [run] });
}
return [...groups.values()];
}
/**
* The newest run of each workflow, per commit. One commit may be checked by
* several workflows, and each of them reports separately.
* @param {CheckRun[]} runs - newest first
* @returns {Map<string, CheckRun[]>}
*/
export function latestBySha(runs) {
/** @type {Map<string, CheckRun[]>} */
const bySha = new Map();
for (const group of groupBySha(runs)) {
bySha.set(group.sha, latestPerWorkflow(group.runs));
}
return bySha;
}
/**
* One status for a set of runs. A single failure decides the whole set: a
* commit whose test workflow failed is a failed commit, whatever its other
* workflows say. An unfinished run reads as running, and only a set where
* every workflow succeeded reads as passed.
* @param {CheckRun[]} runs - the latest run of each workflow
* @returns {CheckSummary|null} null for a commit no workflow reported on
*/
export function summarize(runs) {
if (runs.length === 0) return null;
const passed = runs.filter((run) => run.status === 'success').length;
const failed = runs.some(
(run) => run.status === 'failure' || run.status === 'error',
);
const status = failed
? (runs.find((run) => run.status === 'error')?.status ?? 'failure')
: passed === runs.length
? 'success'
: 'running';
return { status, passed, total: runs.length };
}
/**
* How a summary reads in words, for the badge's title and the commit page's
* heading. A single workflow names itself; several are counted.
* @param {CheckSummary} summary
* @param {CheckRun[]} runs - the runs the summary counts
* @returns {string}
*/
export function summaryWords(summary, runs) {
if (runs.length === 1) {
return `${workflowOf(runs[0])} ${checkLook(summary.status).label}`;
}
if (summary.status === 'running') {
return `${summary.passed} of ${summary.total} workflows passed, the rest running`;
}
return `${summary.passed} of ${summary.total} workflows passed`;
}
/**
* Every workflow that reported, in the order a reader meets them, for the
* filter above a run listing.
* @param {CheckRun[]} runs
* @returns {string[]}
*/
export function workflowNames(runs) {
return [...new Set(runs.map(workflowOf))];
}
/**
* Where a finished check's log reads from: the blob on the runner's PDS.
* @param {CheckRunner|null|undefined} runner
* @param {CheckRun} check
* @returns {string|null} null while the check is running, or has no log
*/
export function checkLogUrl(runner, check) {
const cid = check.logs?.ref?.$link;
if (!runner || !cid) return null;
const params = new URLSearchParams({ did: runner.did, cid });
return `${runner.service}/xrpc/com.atproto.sync.getBlob?${params}`;
}
/**
* Terminal escapes, which a step's output carries when the tool it ran writes
* colour whether or not it is talking to a terminal. Built from the escape's
* code point: a regex literal holding a control character is a lint error.
*/
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, 'g');
/**
* A finished run's log, as text. pds.js answers XRPC reads with
* `Access-Control-Allow-Origin: *`, so this page reads the runner's blob
* directly.
* @param {CheckRunner|null|undefined} runner
* @param {CheckRun} check
* @returns {Promise<string>}
*/
export async function readLog(runner, check) {
const url = checkLogUrl(runner, check);
if (!url) return '';
const res = await fetch(url);
if (!res.ok) throw new Error('The runner would not serve this log.');
return (await res.text()).replace(ANSI, '');
}
/**
* The full name of a ref the page names in short form. The repository record
* holds both, so this needs no round trip. A name that matches no ref reads as
* a branch, which is what a URL naming a deleted branch means.
* @param {string} repo
* @param {string} refName - short form, e.g. main
* @param {string} [space]
* @returns {string}
*/
export function fullRefName(repo, refName, space) {
const refs = /** @type {{refs?: {name: string}[]}|null} */ (
repoRecord(repo, space)
)?.refs;
const found = (refs ?? []).find((ref) => shortRef(ref.name) === refName);
return found?.name ?? `refs/heads/${refName}`;
}
/**
* The latest run of each workflow on one ref, as the server answers it from
* the runner's own record, filtered to the ref. The entries carry no steps
* and no log; each names the check record that holds both, which is what
* the rkey addresses.
* @param {string} repo - what the repository is called
* @param {string} refName - short form, e.g. main
* @param {string} [space]
* @returns {Promise<{runner: CheckRunner|null, runs: CheckRun[]}>}
*/
export async function loadRefChecks(repo, refName, space) {
try {
const answer = await (space
? ownerClient()
: await serverClient()
).listChecks({ repo, space });
const runner =
answer.runner && answer.service
? { did: answer.runner, service: answer.service }
: null;
const ref = fullRefName(repo, refName, space);
/** @type {CheckRun[]} */
const runs = [];
for (const entry of answer.checks) {
if (entry.ref !== ref) continue;
const check = /** @type {CheckRun & {check?: {uri?: unknown}}} */ (entry);
const rkey = String(check.check?.uri ?? '')
.split('/')
.pop();
runs.push(rkey ? { ...check, rkey } : check);
}
return { runner, runs };
} catch {
return { runner: null, runs: [] };
}
}
/**
* Every run the runner has published about the repository, as the server
* lists them from the runner's own server, newest first.
* @param {string} repo - what the repository is called
* @param {string} [space]
* @returns {Promise<RepoChecks>}
*/
export async function loadChecks(repo, space) {
/** @type {RepoChecks} */
const none = { runner: null, runs: [], bySha: new Map() };
try {
const answer = await (space
? ownerClient()
: await serverClient()
).listCheckRuns({ repo, space });
if (!answer.runner || !answer.service) return none;
const runs = /** @type {CheckRun[]} */ (answer.runs);
return {
runner: { did: answer.runner, service: answer.service },
runs,
bySha: latestBySha(runs),
};
} catch {
return none;
}
}