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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
465 lines · 17.2 KB Raw History
// 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}`);