johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// How the pages put things into words.
import type { LiveStep } from './live.ts';
import { zone } from './zone.svelte.ts';
const encoder = new TextEncoder();
/** A path for a URL: each segment percent-encoded, the slashes kept. */
export function urlPath(path: string): string {
let out = '';
for (const b of encoder.encode(path)) {
const c = String.fromCharCode(b);
out += /[A-Za-z0-9\-._~/:@]/.test(c) ? c : `%${b.toString(16).toUpperCase().padStart(2, '0')}`;
}
return out;
}
/** The page of `owner`'s repository `name`, and what follows. */
export const repoHref = (owner: string, name: string, tail = '') =>
`/${urlPath(owner)}/${urlPath(name)}${tail}`;
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const LONG_MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
/** How each zone's clock is read, made once per zone. */
const clocks = new Map<string, Intl.DateTimeFormat>();
const clockIn = (tz: string) => {
let f = clocks.get(tz);
if (!f) {
const n = 'numeric';
f = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: n,
month: n,
day: n,
hour: n,
minute: n,
hourCycle: 'h23'
});
clocks.set(tz, f);
}
return f;
};
/** `t` on the clock where it is read (see zone.svelte.ts): the viewer's zone, or UTC until it is known. */
function on(t: Date) {
const part: Record<string, number> = {};
for (const p of clockIn(zone.tz ?? 'UTC').formatToParts(t)) part[p.type] = Number(p.value);
const m = part.month - 1;
const w = new Date(Date.UTC(part.year, m, part.day)).getUTCDay();
return { y: part.year, m, d: part.day, w, h: part.hour, min: part.minute };
}
/** `iso` on the clock where it is read, or null if it is no time. */
function when(iso: string) {
const t = new Date(iso);
return isNaN(+t) ? null : on(t);
}
const two = (n: number) => String(n).padStart(2, '0');
/** `n` with its thousands apart, as `1,640`, the same wherever it is drawn. */
export function number(n: number): string {
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/** `2026-10-20T…` as `Oct 20, 2026`; anything else as it is. */
export function date(iso: string): string {
const t = when(iso);
return t ? `${MONTHS[t.m]} ${t.d}, ${t.y}` : iso;
}
/** `2026-10-09T…` as `Friday, October 9`, or as `Today` or `Yesterday`: a heading. */
export function weekday(iso: string): string {
const t = when(iso);
if (!t) return iso;
const back = daysBack(iso);
if (back === 0) return 'Today';
if (back === 1) return 'Yesterday';
return `${WEEKDAYS[t.w]}, ${LONG_MONTHS[t.m]} ${t.d}`;
}
/**
* How long before now `iso` was, as `just now`, `3 minutes ago`, `2 hours
* ago`, `yesterday`, `4 days ago`, `last week` or `3 weeks ago`, and from a
* month back its date, after `before`: as a sentence says when.
*/
export function ago(iso: string, before = ''): string {
return relative(iso, false) ?? `${before}${date(iso)}`;
}
/**
* When a queued pull `q` should land, as it is `now`, by `typical`, how
* long its merge queue takes (`dev.knotless.defs#queueTypicalView`): in a
* batch that started at `batchStartedAt`, a batch after that; in none,
* once its pre-flights would pass, or now if they have, and a batch after
* that or after the batch ahead of it, started at `aheadStartedAt`,
* whichever is later. Null while landing, or with nothing to go by.
*/
export function landsAt(
q: { stage?: string; checks?: { status: string; startedAt: string }[] },
typical: { batchSeconds: number; preflightSeconds: number } | undefined,
batchStartedAt: string | undefined,
aheadStartedAt: string | undefined,
now: number
): number | null {
if (!typical || !q.stage || q.stage === 'landing' || q.stage === 'failed') return null;
const batch = typical.batchSeconds * 1000;
if (q.stage === 'checking')
return batchStartedAt ? Math.max(Date.parse(batchStartedAt) + batch, now) : null;
const going = (q.checks ?? [])
.filter((c) => c.status === 'running' || c.status === 'queued')
.map((c) => Date.parse(c.startedAt));
const flight = typical.preflightSeconds * 1000;
const ready =
q.stage === 'ready'
? now
: going.length
? Math.max(Math.min(...going) + flight, now)
: now + flight;
const ahead = aheadStartedAt ? Date.parse(aheadStartedAt) + batch : ready;
return Math.max(ready, ahead) + batch;
}
/** When `at`, a time to come, is from now, as a queued pull lands: `lands in about 8 minutes`, `lands in about 2 hours`, or `lands any minute now` once it is a minute off or past. */
export function landsIn(at: number): string {
const minutes = Math.round((at - zone.now) / 60_000);
if (minutes <= 1) return 'lands any minute now';
if (minutes < 60) return `lands in about ${plural(minutes, 'minute', 'minutes')}`;
return `lands in about ${plural(Math.round(minutes / 60), 'hour', 'hours')}`;
}
/**
* How long before now `iso` was, as `ago` puts it, and from a month back as
* `last month`, `5 months ago`, `last year` or `2 years ago`: as a column
* of them, a directory's, says how old each is.
*/
export function age(iso: string): string {
return relative(iso, true) ?? date(iso);
}
/** How fresh `iso` is, from 1 (now) to 10 (three weeks back), as its age is coloured; null past that. */
export function heat(iso: string): number | null {
const back = zone.now - Date.parse(iso);
if (isNaN(back) || back >= HEAT_SPAN) return null;
return Math.max(1, Math.ceil((back / HEAT_SPAN) * 10));
}
/** How long an age keeps any heat: about three weeks. */
const HEAT_SPAN = 21 * 24 * 60 * 60_000;
/** `ago` and `age`'s words, months and years only when `far`; null past them, or for no time. */
function relative(iso: string, far: boolean): string | null {
const t = Date.parse(iso);
if (isNaN(t)) return null;
const minutes = Math.floor((zone.now - t) / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${plural(minutes, 'minute', 'minutes')} ago`;
if (minutes < 24 * 60) return `${plural(Math.floor(minutes / 60), 'hour', 'hours')} ago`;
const days = daysBack(iso) ?? 0;
if (days <= 1) return 'yesterday';
if (days < 7) return `${days} days ago`;
const weeks = Math.floor(days / 7);
if (days < 30) return weeks === 1 ? 'last week' : `${weeks} weeks ago`;
if (!far) return null;
const months = Math.floor(days / 30);
if (months < 12) return months === 1 ? 'last month' : `${months} months ago`;
const years = Math.floor(days / 365) || 1;
return years === 1 ? 'last year' : `${years} years ago`;
}
/** How many of the viewer's days before today `iso` fell on. */
/** How far back `iso` is, as notifications are put together: `Today`, `Yesterday`, `This week` or `Earlier`. */
export function since(iso: string): string {
const back = daysBack(iso) ?? Infinity;
return back <= 0 ? 'Today' : back === 1 ? 'Yesterday' : back < 7 ? 'This week' : 'Earlier';
}
function daysBack(iso: string): number | null {
const then = when(iso);
if (!then) return null;
const today = on(new Date(zone.now));
return Math.round(
(Date.UTC(today.y, today.m, today.d) - Date.UTC(then.y, then.m, then.d)) / 86_400_000
);
}
/**
* When a commit was committed, or, from a server that does not say so,
* when its author wrote it.
*/
export function whenCommitted(c: { date: string; committedAt?: string }): string {
return c.committedAt ?? c.date;
}
/** The day `iso` falls on, as `2026-10-09`: what times are put together by. */
export function day(iso: string): string {
const t = when(iso);
return t ? `${t.y}-${two(t.m + 1)}-${two(t.d)}` : iso;
}
/** `iso`'s hour and minute, as `09:31`. */
export function clock(iso: string): string {
const t = when(iso);
return t ? `${two(t.h)}:${two(t.min)}` : iso;
}
/** `iso` in full, as `Oct 9, 2026, 09:31`, saying so when it is UTC's. */
export const moment = (iso: string) =>
when(iso) ? `${date(iso)}, ${clock(iso)}${zone.tz ? '' : ' UTC'}` : iso;
/** A commit message's first line. */
export const subject = (message: string) => message.split('\n')[0];
/** The largest file `dev.knotless.getBlame` blames, in bytes. */
export const BLAME_MAX = 256 * 1024;
/** Whether `path` names a Markdown file, which the pages show made into HTML, as knotless-serve knows one. */
export const isMarkdown = (path: string) => /\.(md|markdown|mdown|mkd)$/i.test(path);
export function size(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
export const capitalized = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** A link as a reader names it: its host and path, without the scheme, a `www.` or a trailing slash. */
export function siteLabel(url: string): string {
try {
const u = new URL(url);
return (u.host.replace(/^www\./, '') + u.pathname + u.search).replace(/\/$/, '');
} catch {
return url;
}
}
/** A branch or tag as it is called, without the `refs/…/` before it. */
export const shortRef = (ref: string) => ref.replace(/^refs\/(heads|tags)\//, '');
/** A hue from 0 to 359 that `name` picks, the same each time. */
export function hue(name: string): number {
let h = 7;
for (const b of encoder.encode(name)) h = (Math.imul(h, 31) + b) >>> 0;
return h % 360;
}
/**
* The cells of a 5×5 pattern that `seed` picks, each as its column and
* row, mirrored left to right as GitHub's identicons are: the same each
* time, and seldom another's.
*/
export function identicon(seed: string): [number, number][] {
let h = 0x811c9dc5;
for (const b of encoder.encode(seed)) h = Math.imul(h ^ b, 0x01000193) >>> 0;
const cells: [number, number][] = [];
for (let i = 0; i < 15; i++) {
if (!((h >>> i) & 1)) continue;
const [x, y] = [Math.floor(i / 5), i % 5];
cells.push([x, y]);
if (x < 2) cells.push([4 - x, y]);
}
return cells;
}
/** The first paragraph of Markdown, as plain text, cut short. */
export function excerpt(text: string, max: number): string {
const plain = text.split('\n\n')[0].replaceAll('\n', ' ').replace(/[`*]/g, '');
const chars = [...plain];
return chars.length > max ? `${chars.slice(0, max).join('').trimEnd()}…` : plain;
}
/** `text` on one line, cut at `max` characters, for whatever unfurls a link. */
export function summary(text: string, max: number): string {
const flat = text.split(/\s+/).filter(Boolean).join(' ');
const chars = [...flat];
return chars.length > max ? `${chars.slice(0, max).join('').trimEnd()}…` : flat;
}
/**
* The author, collection and rkey of the record at `uri`, public
* (`at://<author>/<collection>/<rkey>`) or in a private repository's space
* (`at://<space did>/space/<type>/<key>/<author>/<collection>/<rkey>`).
*/
export function recordOf(uri: string): { author: string; collection: string; rkey: string } {
const parts = uri.replace(/^at:\/\//, '').split('/');
const [author = '', collection = '', rkey = ''] = parts[1] === 'space' ? parts.slice(4) : parts;
return { author, collection, rkey };
}
/** The page of the check at `uri`, of the repository `repo` names. */
export function checkHref(repo: { handle: string; name: string }, uri: string): string {
const { author, rkey } = recordOf(uri);
return repoHref(repo.handle, repo.name, `/check/${author}/${rkey}`);
}
/** The page of the pull or issue at `uri`, on the repository at `repo`. */
export function itemHref(repo: string, uri: string): string {
const { author, rkey } = recordOf(uri);
return `${repo}/${kindOf(uri)}/${author}/${rkey}`;
}
/** Commits a push is walked for: a push that brought this many brought at least as many. */
const WALKED = 100;
/** How many commits a push brought, in words. */
export function commits(n: number): string {
if (n === 0) return 'nothing new';
if (n === 1) return '1 commit';
return n >= WALKED ? `${WALKED}+ commits` : `${n} commits`;
}
/** What a comment with `verdict` did. */
export function said(verdict?: string | null): string {
if (verdict === 'approve') return 'approved';
if (verdict === 'changes') return 'asked for changes on';
return 'commented on';
}
/** What moving to `state` did. */
export const changed = (state: string) => (state === 'open' ? 'reopened' : state);
/** `pull` or `issue`, for the record at `uri`. */
export const kindOf = (uri: string) =>
recordOf(uri).collection === 'dev.knotless.pull' ? 'pull' : 'issue';
/** Where a set of checks stands as one: failing if any failed, else running if any runs, else queued if any waits for a runner. */
export function overall(statuses: string[]): string {
if (statuses.some((s) => s === 'failure' || s === 'error')) return 'failure';
if (statuses.includes('running')) return 'running';
if (statuses.includes('queued')) return 'queued';
if (statuses.includes('cancelled')) return 'cancelled';
return 'success';
}
/** How long `ms` milliseconds took, as `1m 23s`, `4s` or `0s`. */
export function took(ms: number): string {
const s = Math.round(ms / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
/** How long a run from `started` to `finished` took, if it has finished. */
export function ran(started: string, finished?: string | null): string | null {
const ms = finished ? Date.parse(finished) - Date.parse(started) : NaN;
return Number.isNaN(ms) ? null : took(ms);
}
/** A check's row, in words: a skip says only that, its note on the row's tooltip and its page. */
export function checkSaid(c: {
status: string;
startedAt: string;
finishedAt?: string;
error?: string;
skipped?: string;
}): string {
if (c.skipped) return 'Skipped';
const took = ran(c.startedAt, c.finishedAt);
return `${c.error ?? capitalized(c.status)}${took ? ` in ${took}` : ''}`;
}
/** What a pull's author asks, by its `state`, between their name and its base: still wanting it while it is open, and once it is merged or closed, by whoever did that, what they asked. */
export function mergeSaid(state: string): string {
return state === 'open' ? ' wants to merge into ' : ' asked to merge into ';
}
/** How a check's row stands for its mark: a skip as skipped, whatever status it filed. */
export function standing(c: { status: string; skipped?: string }): string {
return c.skipped ? 'skipped' : c.status;
}
/** `checks` in the order GitHub lists them: failed, running, waiting, cancelled, passed, then skipped, by workflow name within each. */
export function byStanding<T extends { workflow: string; status: string; skipped?: string }>(
checks: T[]
): T[] {
const rank = (c: T) =>
['failure', 'error', 'running', 'queued', 'cancelled', 'success', 'skipped'].indexOf(
standing(c)
) >>> 0;
return [...checks].sort((a, b) => rank(a) - rank(b) || a.workflow.localeCompare(b.workflow));
}
/**
* A check's log cut into what each of `steps` printed, by the `==> <name>`
* line the runner writes before each. Lines before the first such line
* are the end of the step before it, whose start the log's tail lost, or
* of the last step if the tail holds none; before the first step's, they
* go with it.
*/
export function stepLogs(log: string, steps: string[]): string[] {
const out = steps.map(() => '');
const lines = log.split('\n');
const heads = steps.map((s) => `==> ${s}`);
const first = heads.findIndex((h) => lines.includes(h));
let next = first === -1 ? steps.length : first;
let at = Math.max(next - 1, 0);
for (const line of lines) {
if (next < steps.length && line === heads[next]) {
at = next++;
continue;
}
if (at < out.length) out[at] += `${line}\n`;
}
return out.map((text) => text.replace(/\n+$/, ''));
}
/** A running check's steps as its output shows them, each with what it printed: those that ended, then the one going, as its runner names it or by its `==> name` line past theirs; none goes after one that failed. */
export function runningSteps(
text: string,
ended: LiveStep[],
named?: string
): { step: LiveStep; log: string }[] {
const lines = text.split('\n');
let at = 0;
for (let seen = 0; at < lines.length && seen < ended.length; at++) {
if (lines[at] === `==> ${ended[seen].name}`) seen++;
}
const failed = ended.some((s) => s.status !== 'success');
const header = lines
.slice(at)
.find((line) => line.startsWith('==> '))
?.slice(4);
const going = failed ? undefined : (named ?? header);
const steps = going ? [...ended, { name: going, status: 'running' }] : ended;
const logs = stepLogs(
text,
steps.map((s) => s.name)
);
return steps.map((step, i) => ({ step, log: logs[i] }));
}
/**
* Why a pull was ejected from its merge queue, as the page says it: an
* older runner ended the reason with a command to run, the one that reads
* the failed checks' logs, which the page links to as the checks
* themselves, or the one that approves it, which the review box beside it
* does.
*/
export const ejectedWhy = (reason?: string | null) =>
(reason ?? 'no reason given')
.replace(/[.;] [Tt]heir logs: `knotless run list [^`]*`.*$/s, '')
.replace(
/\. Anyone but its author who may write approves it with `knotless comment add .*$/s,
''
);
/** Where the files of the repository `name` that `owner` has, at `rev`, download as one archive of `kind`. */
export const archiveHref = (owner: string, name: string, rev: string, kind: 'zip' | 'tar.gz') =>
repoHref(owner, name, `/archive/${urlPath(rev)}.${kind}`);