johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// 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();
}