// 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/.json. // // node change/story.mjs https://knotless.dev///pull// // node change/story.mjs / [--deploy ] // // 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 | / '); 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(); }