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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
276 lines · 9.1 KB Raw History
// A pull's way from its first push to the deploy that shipped it, read
// from the appview through `knotless-mcp --read-only`, as the Change video
// tells it: each push and its checks, each review, the queue, the commit it
// landed as and the deploy of that commit. Written to out/stories/<rkey>.json.
//
//   node change/story.mjs https://knotless.dev/<handle>/<name>/pull/<did>/<rkey>
//   node change/story.mjs <handle>/<name> <pull at-uri> [--deploy <workflow>]
//
// Nothing here is made up: a stage the pull never reached (unreviewed, merged
// by hand, never deployed) is left out, and the video skips it.

import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const host = new URL(process.env.KNOTLESS_APPVIEW || 'https://knotless.dev').host;

const args = process.argv.slice(2);
const flag = (name, otherwise) => {
	const i = args.indexOf(name);
	if (i < 0) return otherwise;
	const [, value] = args.splice(i, 2);
	return value;
};
const deployWorkflow = flag('--deploy', 'deploy');
const [repo, uri] = (() => {
	if (args[0]?.startsWith('http')) {
		const [handle, name, kind, did, rkey] = new URL(args[0]).pathname.split('/').filter(Boolean);
		if (kind !== 'pull') throw new Error(`not a pull's page: ${args[0]}`);
		return [`${handle}/${name}`, `at://${did}/dev.knotless.pull/${rkey}`];
	}
	if (args.length !== 2) {
		console.error('usage: story.mjs <pull page url> | <handle>/<name> <pull at-uri>');
		process.exit(2);
	}
	return args;
})();

/// The tools of a read-only knotless-mcp, called in turn over its stdio.
const mcp = () => {
	const child = spawn('knotless-mcp', ['--read-only'], { stdio: ['pipe', 'pipe', 'inherit'] });
	const waiting = new Map();
	let id = 0;
	let buf = '';
	child.stdout.on('data', (d) => {
		buf += d;
		for (let i = buf.indexOf('\n'); i >= 0; i = buf.indexOf('\n')) {
			const line = buf.slice(0, i);
			buf = buf.slice(i + 1);
			if (!line.trim()) continue;
			const m = JSON.parse(line);
			waiting.get(m.id)?.(m);
			waiting.delete(m.id);
		}
	});
	const ask = (method, params) =>
		new Promise((resolve, reject) => {
			const n = ++id;
			waiting.set(n, (m) => (m.error ? reject(new Error(m.error.message)) : resolve(m.result)));
			child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: n, method, params })}\n`);
		});
	const ready = ask('initialize', {
		protocolVersion: '2025-06-18',
		capabilities: {},
		clientInfo: { name: 'knotless-video', version: '0' }
	}).then(() =>
		child.stdin.write(
			`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`
		)
	);
	return {
		/// The tool's text, as JSON when `json`.
		call: async (name, args, json = true) => {
			await ready;
			const r = await ask('tools/call', { name, arguments: args });
			const text = r.content.find((c) => c.type === 'text')?.text ?? '';
			if (r.isError) throw new Error(`${name}: ${text}`);
			return json ? JSON.parse(text) : text;
		},
		close: () => child.kill()
	};
};

/// The diff's files, each with its lines, from pull_diff's numbered form:
/// `old new | ±text` under each `diff --git` and `@@` head.
const parseDiff = (text) => {
	const files = [];
	let file;
	let hunk;
	for (const line of text.split('\n')) {
		if (line.startsWith('diff --git ')) {
			file = { path: line.split(' b/').pop(), added: 0, removed: 0, hunks: [] };
			files.push(file);
			continue;
		}
		if (!file) continue;
		if (line.startsWith('@@')) {
			hunk = { head: line, lines: [] };
			file.hunks.push(hunk);
			continue;
		}
		const m = /^\s*(\d*)\s+(\d*) \| ([ +-])(.*)$/.exec(line);
		if (!m || !hunk) continue;
		const [, old, now, mark, body] = m;
		if (mark === '+') file.added++;
		if (mark === '-') file.removed++;
		hunk.lines.push({ mark, n: Number(now || old), text: body });
	}
	return files;
};

// What a reader of the video learns least from: words about the change,
// rather than the change, and then its tests.
const prose = /(^docs\/|\.md$|^\.changeset\/|CHANGELOG|\.lock$|package-lock\.json$|fixtures)/;
const tests = /(\/tests?\/|_test\.|\.test\.|\.spec\.)/;

/// The stretch of the diff that says most: of the code (not its tests, if
/// it has any other), the hunk adding the most lines, cut to `most` lines
/// from just before its first change.
const excerpt = (files, most = 14) => {
	const code = files.filter((f) => !prose.test(f.path));
	const source = code.filter((f) => !tests.test(f.path));
	const pool = source.length ? source : code.length ? code : files;
	const pick = pool
		.flatMap((f) => f.hunks.map((h) => ({ path: f.path, h })))
		.map((x) => ({ ...x, added: x.h.lines.filter((l) => l.mark === '+').length }))
		.sort((a, b) => b.added - a.added)[0];
	if (!pick) return null;
	const lines = pick.h.lines;
	const first = lines.findIndex((l) => l.mark !== ' ');
	const start = Math.max(0, Math.min(first - 2, lines.length - most));
	return {
		path: pick.path,
		lines: lines.slice(start, start + most).map((l) => ({
			...l,
			text: l.text.replace(/\t/g, '    ').trimEnd()
		}))
	};
};

/// A body's opening sentences, as many whole ones as fit in `most`
/// characters (the first cut short if it alone does not), as plain text.
const firstSaid = (body, most = 200) => {
	const plain = body
		.replace(/```[\s\S]*?```/g, '')
		.replace(/\*\*|`|^#+\s*|^>\s*/gm, '')
		.replace(/\s+/g, ' ')
		.trim();
	const sentences = plain.split(/(?<=[.!?])\s+/);
	let said = '';
	for (const s of sentences) {
		if (`${said} ${s}`.trim().length > most) break;
		said = `${said} ${s}`.trim();
	}
	if (said) return said;
	return `${plain.slice(0, most - 1).trimEnd()}…`;
};

const at = (t) => new Date(t).getTime();

const tools = mcp();
try {
	const shown = await tools.call('pull_show', { repo, uri });
	const pull = shown.item;
	const files = parseDiff(await tools.call('pull_diff', { repo, uri }, false));
	const runs = await tools.call('run_list', { repo, pull: uri }).catch(() => []);

	// A push is the commit the pull's checks ran at, first seen when the first
	// of them started; its checks are the latest of each workflow there.
	const byCommit = new Map();
	for (const r of runs) {
		const list = byCommit.get(r.commit) ?? [];
		if (!list.some((s) => s.workflow === r.workflow)) list.push(r);
		byCommit.set(r.commit, list);
	}
	const pushes = [...byCommit.entries()]
		.map(([commit, checks]) => ({
			commit,
			at: checks.map((c) => c.startedAt).sort()[0],
			checks: checks
				.map((c) => ({
					workflow: c.workflow,
					status: c.status,
					skipped: Boolean(c.skipped),
					started: c.startedAt,
					finished: c.finishedAt ?? null
				}))
				.sort((a, b) => at(a.finished ?? a.started) - at(b.finished ?? b.started))
		}))
		.sort((a, b) => at(a.at) - at(b.at));

	// Each review, with the first of the line comments it left.
	const reviews = shown.comments
		.filter((c) => c.verdict)
		.map((c) => {
			const line = shown.comments.find((l) => l.review === c.uri && l.line);
			return {
				by: c.handle,
				verdict: c.verdict,
				at: c.createdAt,
				commit: c.commit,
				said: firstSaid(c.body),
				line: line
					? { path: line.line.path, line: line.line.line, said: firstSaid(line.body, 140) }
					: null
			};
		});

	// Landed: the commit on the base the merge made, dated when it was made.
	const merged = pull.merged ?? null;
	const landedCommit = merged && shown.compare?.commits?.find((c) => c.sha === merged);
	const landed = merged
		? {
				commit: merged,
				at: landedCommit?.committer?.date ?? null,
				batch: Boolean(pull.mergedBatch)
			}
		: null;

	// Deployed: the deploy workflow's run at the landed commit, on the base.
	let deploy = null;
	if (merged) {
		const onBase = await tools
			.call('run_list', { repo, ref: pull.base, commit: merged, workflow: deployWorkflow })
			.catch(() => []);
		const run = onBase[0];
		if (run)
			deploy = {
				workflow: run.workflow,
				status: run.status,
				started: run.startedAt,
				finished: run.finishedAt ?? null,
				steps: run.steps
					.filter((s) => s.durationMs >= 1000)
					.map((s) => ({ name: s.name, ms: s.durationMs, status: s.status }))
			};
	}

	const story = {
		host,
		repo,
		pull: {
			uri,
			url: `https://${host}/${repo}/pull/${uri.split('/')[2]}/${uri.split('/').pop()}`,
			title: pull.title,
			author: pull.handle,
			branch: pull.head.branch,
			base: pull.base,
			opened: pull.createdAt,
			state: pull.state
		},
		diff: {
			files: files.map(({ path, added, removed }) => ({ path, added, removed })),
			excerpt: excerpt(files)
		},
		pushes,
		reviews,
		queued: pull.queue?.createdAt ?? null,
		landed,
		deploy
	};
	const out = join(root, 'out/stories');
	mkdirSync(out, { recursive: true });
	const file = join(out, `${uri.split('/').pop()}.json`);
	writeFileSync(file, `${JSON.stringify(story, null, 1)}\n`);
	const span = deploy?.finished ?? landed?.at ?? pushes.at(-1)?.at;
	const minutes = span ? Math.round((at(span) - at(pull.createdAt)) / 60000) : 0;
	console.log(
		`${file}: ${files.length} files, ${pushes.length} pushes, ${reviews.length} reviews, ` +
			`${landed ? 'landed' : 'not landed'}, ${deploy ? 'deployed' : 'no deploy'}; ${minutes} min`
	);
} finally {
	tools.close();
}