~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
321 lines · 10.8 KB Raw History
// 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;
  }
}