diff --git a/.github/scripts/stack-breadcrumb.cjs b/.github/scripts/stack-breadcrumb.cjs new file mode 100644 index 00000000..d7e7e67a --- /dev/null +++ b/.github/scripts/stack-breadcrumb.cjs @@ -0,0 +1,343 @@ +"use strict"; + +/** + * Stack breadcrumb — pure, zero-dependency logic. + * + * A portable port of the `@taskless/stack-breadcrumb` package's core: it derives + * a stacked-PR forest from `base`/`head` relationships, renders a breadcrumb + * region, and plans the minimal body edits to keep every PR in a stack pointing + * at its siblings. There is NO GitHub I/O here — the workflow supplies the PR + * list (via `actions/github-script`'s octokit) and a `writeBody` callback, so + * this file needs no npm install, no build step, and stays unit-testable with + * `node --test`. + * + * The two-stage workflow keeps reconciliation serialized per stack root: + * stage 1 (dispatch) — on PR shape events, resolve the affected root(s) and + * fire a `repository_dispatch`. + * stage 2 (reconcile) — on that dispatch, render and propagate the breadcrumb. + */ + +// --------------------------------------------------------------------------- +// Marker-region surgery +// +// Each managed PR body carries at most one region delimited by +// `` … ``. Replacing a region in place +// leaves every other byte untouched, so a git-town breadcrumb or any other +// content is never disturbed. Appending trims the body's trailing whitespace +// before adding the region after a blank line; removing collapses the blank +// lines the region left behind and trims trailing whitespace. +// --------------------------------------------------------------------------- + +/** Matches the whole region, opening marker through ``. */ +const REGION_PATTERN = /[\S\s]*?/; + +/** Matches just the opening marker, capturing `root` and the `pr=` list. */ +const OPEN_MARKER_PATTERN = //; + +/** + * Parse the opening marker's `root` and ordered `pr=` membership list — but only + * inside a COMPLETE region (both markers present), so a stray or pasted opening + * marker without its closing tag is not mistaken for a managed region. + */ +function parseStackComment(body) { + const region = getRegion(body); + if (region === undefined) { + return undefined; + } + const match = OPEN_MARKER_PATTERN.exec(region); + if (!match) { + return undefined; + } + const root = Number(match[1]); + const members = match[2] + .split(",") + .filter((part) => part.length > 0) + .map(Number); + return { root, members }; +} + +/** Return the existing region (including both markers), or `undefined` if absent. */ +function getRegion(body) { + const match = REGION_PATTERN.exec(body); + return match ? match[0] : undefined; +} + +/** + * Splice `region` into `body`: + * - an empty `region` removes an existing region (non-stacked PR); + * - an existing region is replaced in place; + * - otherwise the region is appended after a blank line. + * Content outside the markers is preserved. + */ +function spliceRegion(body, region) { + const existing = getRegion(body); + + if (region.length === 0) { + if (!existing) { + return body; + } + // Remove the region and only the blank lines hugging it, rejoining the + // surrounding text with a single blank line. Spacing elsewhere in the body + // is left exactly as-is. + const start = body.indexOf(existing); + const before = body.slice(0, start).replace(/\n+$/, ""); + const after = body.slice(start + existing.length).replace(/^\n+/, ""); + if (before.length === 0) { + return after.trimEnd(); + } + if (after.length === 0) { + return before.trimEnd(); + } + return `${before}\n\n${after}`; + } + + if (existing) { + // Function replacer so `$` sequences in a PR title are not treated as + // `String.replace` special patterns ($&, $1, …). + return body.replace(REGION_PATTERN, () => region); + } + + const trimmed = body.trimEnd(); + return trimmed.length === 0 ? region : `${trimmed}\n\n${region}`; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +const HERE_PREFIX = "➡️ "; // ➡️ +const HERE_SUFFIX = " (you are here)"; +const STACK_HEADING = "**Stack** (root → tip):"; // root → tip + +/** Render the full `` … `` region, or `''` for a non-stacked PR. */ +function renderRegion(tree, currentNumber) { + if (tree.members.length <= 1) { + return ""; + } + + const open = ``; + const lines = [open, STACK_HEADING, ""]; + + const renderNode = (number_, depth) => { + const indent = " ".repeat(depth); + lines.push( + number_ === currentNumber + ? `${indent}- ${HERE_PREFIX}#${number_}${HERE_SUFFIX}` + : `${indent}- #${number_}` + ); + for (const child of tree.childrenOf.get(number_) ?? []) { + renderNode(child, depth + 1); + } + }; + + renderNode(tree.root, 0); + lines.push(""); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Tree derivation (pure, from the open-PR list) +// --------------------------------------------------------------------------- + +function indexPullRequests(pullRequests) { + const byNumber = new Map(); + const byHead = new Map(); + for (const pullRequest of pullRequests) { + byNumber.set(pullRequest.number, pullRequest); + byHead.set(pullRequest.headRefName, pullRequest); + } + return { byNumber, byHead }; +} + +function parentOf(pullRequest, byHead, defaultBranch) { + if (pullRequest.baseRefName === defaultBranch) { + return undefined; + } + return byHead.get(pullRequest.baseRefName); +} + +/** Walk `base` pointers up from `startNumber` to the root of its stack. */ +function findRoot(startNumber, pullRequests, defaultBranch) { + const { byNumber, byHead } = indexPullRequests(pullRequests); + let current = byNumber.get(startNumber); + if (!current) { + return undefined; + } + const seen = new Set(); + let parent = parentOf(current, byHead, defaultBranch); + while (parent && !seen.has(current.number)) { + seen.add(current.number); + current = parent; + parent = parentOf(current, byHead, defaultBranch); + } + return current.number; +} + +/** Build the tree rooted at `rootNumber`: DFS pre-order members + sorted child map. */ +function buildTree(rootNumber, pullRequests) { + const { byNumber } = indexPullRequests(pullRequests); + + const childrenByHead = new Map(); + for (const pullRequest of pullRequests) { + const siblings = childrenByHead.get(pullRequest.baseRefName) ?? []; + siblings.push(pullRequest.number); + childrenByHead.set(pullRequest.baseRefName, siblings); + } + + const childrenOf = new Map(); + const members = []; + const visited = new Set(); + + const visit = (number_) => { + const node = byNumber.get(number_); + // Guard against a base/head cycle (A←B and B←A): never visit a PR twice. + if (!node || visited.has(number_)) { + return; + } + visited.add(number_); + members.push(number_); + const childNumbers = (childrenByHead.get(node.headRefName) ?? []) + .filter((candidate) => candidate !== number_) + .toSorted((a, b) => a - b); + childrenOf.set(number_, childNumbers); + for (const child of childNumbers) { + visit(child); + } + }; + + visit(rootNumber); + return { root: rootNumber, members, childrenOf }; +} + +/** Find the root of `triggerNumber`'s stack, then build the tree. */ +function resolveTree(triggerNumber, pullRequests, defaultBranch) { + const root = findRoot(triggerNumber, pullRequests, defaultBranch); + if (root === undefined) { + return undefined; + } + return buildTree(root, pullRequests); +} + +/** + * Resolve the root(s) of the stack(s) a PR event affects. + * - An OPEN trigger affects exactly its own root. + * - A CLOSED/merged trigger's stack still needs a redraw: union the survivors + * found via the parent route (open PR headed at the closed PR's base) and the + * marker route (open PRs whose last breadcrumb listed the closed PR), then map + * each surviving member to its (possibly new) root. + */ +function resolveAffectedRoots(trigger, pullRequests, defaultBranch) { + const isOpen = pullRequests.some( + (pullRequest) => pullRequest.number === trigger.number + ); + if (isOpen) { + const root = findRoot(trigger.number, pullRequests, defaultBranch); + return root === undefined ? [] : [root]; + } + + const affected = new Set(); + for (const candidate of pullRequests) { + const isParentOfClosed = candidate.headRefName === trigger.baseRefName; + const marker = parseStackComment(candidate.body); + const markerNamedClosed = + marker !== undefined && + (marker.root === trigger.number || + marker.members.includes(trigger.number)); + if (isParentOfClosed || markerNamedClosed) { + affected.add(candidate.number); + } + } + + const roots = new Set(); + for (const number_ of affected) { + const root = findRoot(number_, pullRequests, defaultBranch); + if (root !== undefined) { + roots.add(root); + } + } + return [...roots].toSorted((a, b) => a - b); +} + +/** Every distinct stack root among the open PRs (deduped, ascending). */ +function findAllRoots(pullRequests, defaultBranch) { + const roots = new Set(); + for (const pullRequest of pullRequests) { + const root = findRoot(pullRequest.number, pullRequests, defaultBranch); + if (root !== undefined) { + roots.add(root); + } + } + return [...roots].toSorted((a, b) => a - b); +} + +// --------------------------------------------------------------------------- +// Reconcile orchestration +// --------------------------------------------------------------------------- + +/** Compute the new body for one PR given its desired region (`''` removes it). */ +function planUpdate(pullRequest, region) { + const body = spliceRegion(pullRequest.body, region); + return { + number: pullRequest.number, + changed: body !== pullRequest.body, + body, + }; +} + +/** + * Reconcile the tree rooted at `root`: render the breadcrumb for each member and + * write only changed bodies via `deps.writeBody`. A write that throws is logged + * and the PR is recorded as failed; the rest of the tree still reconciles. + */ +async function reconcile(root, deps) { + const { pullRequests, writeBody } = deps; + const byNumber = new Map( + pullRequests.map((pullRequest) => [pullRequest.number, pullRequest]) + ); + const tree = buildTree(root, pullRequests); + + const updated = []; + const skipped = []; + const failed = []; + + for (const number_ of tree.members) { + const pullRequest = byNumber.get(number_); + if (!pullRequest) { + continue; + } + const region = renderRegion(tree, number_); + const plan = planUpdate(pullRequest, region); + if (!plan.changed) { + skipped.push(number_); + continue; + } + try { + await writeBody(number_, plan.body); + updated.push(number_); + } catch (error) { + console.error( + `stack-breadcrumb: could not update PR #${number_}: ${String(error)}` + ); + failed.push(number_); + } + } + + return { root, updated, skipped, failed }; +} + +module.exports = { + parseStackComment, + getRegion, + spliceRegion, + renderRegion, + findRoot, + buildTree, + resolveTree, + resolveAffectedRoots, + findAllRoots, + planUpdate, + reconcile, + HERE_PREFIX, + HERE_SUFFIX, + STACK_HEADING, +}; diff --git a/.github/scripts/stack-breadcrumb.test.cjs b/.github/scripts/stack-breadcrumb.test.cjs new file mode 100644 index 00000000..15c42292 --- /dev/null +++ b/.github/scripts/stack-breadcrumb.test.cjs @@ -0,0 +1,278 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + parseStackComment, + getRegion, + spliceRegion, + renderRegion, + findRoot, + buildTree, + resolveTree, + resolveAffectedRoots, + findAllRoots, + planUpdate, + reconcile, +} = require("./stack-breadcrumb.cjs"); + +const DEFAULT_BRANCH = "main"; + +function pr(number, head, base, body = "") { + return { number, title: `PR ${number}`, headRefName: head, baseRefName: base, body }; +} + +const REGION = [ + "", + "**Stack** (root → tip):", + "", + "- #10", + " - ➡️ #11 (you are here)", + " - #12", + "", +].join("\n"); + +test("parseStackComment: parses root and ordered membership", () => { + assert.deepEqual(parseStackComment(`intro\n\n${REGION}`), { + root: 10, + members: [10, 11, 12], + }); +}); + +test("parseStackComment: parses an empty membership list", () => { + assert.deepEqual( + parseStackComment("\nx\n"), + { root: 10, members: [] } + ); +}); + +test("parseStackComment: undefined when no marker", () => { + assert.equal(parseStackComment("just a normal PR body"), undefined); +}); + +test("parseStackComment: undefined for a bare opening marker without a closing tag", () => { + // A pasted/stray opening marker must not be treated as a managed region. + assert.equal( + parseStackComment("intro\n\nno closing tag"), + undefined + ); +}); + +test("getRegion: extracts the full region", () => { + assert.equal(getRegion(`before\n${REGION}\nafter`), REGION); +}); + +test("getRegion: undefined when absent", () => { + assert.equal(getRegion("no region here"), undefined); +}); + +test("spliceRegion: appends to a body with none", () => { + assert.equal(spliceRegion("Original body.", REGION), `Original body.\n\n${REGION}`); +}); + +test("spliceRegion: returns just the region for an empty body", () => { + assert.equal(spliceRegion("", REGION), REGION); +}); + +test("spliceRegion: replaces an existing region in place", () => { + const oldRegion = "\nold\n"; + const body = `top\n\n${oldRegion}\n\nbottom`; + assert.equal(spliceRegion(body, REGION), `top\n\n${REGION}\n\nbottom`); +}); + +test("spliceRegion: removes the region when given an empty region", () => { + assert.equal(spliceRegion(`keep this\n\n${REGION}`, ""), "keep this"); +}); + +test("spliceRegion: no-op removing when no region exists", () => { + assert.equal(spliceRegion("nothing to remove", ""), "nothing to remove"); +}); + +test("spliceRegion: removing preserves unrelated blank runs elsewhere", () => { + // A 3+ newline run unrelated to the region must survive removal. + const body = `line1\n\n\n\nline2\n\n${REGION}`; + assert.equal(spliceRegion(body, ""), "line1\n\n\n\nline2"); +}); + +test("spliceRegion: removing a middle region rejoins with one blank line", () => { + assert.equal(spliceRegion(`top\n\n${REGION}\n\nbottom`, ""), "top\n\nbottom"); +}); + +test("spliceRegion: skip-when-equal is a no-op", () => { + const body = `intro\n\n${REGION}\n\noutro`; + assert.equal(spliceRegion(body, REGION), body); +}); + +test("spliceRegion: does not treat $ sequences as replacement patterns", () => { + const dollarRegion = + "\ncost is $1 and $& too\n"; + const body = "\nold\n"; + assert.equal(spliceRegion(body, dollarRegion), dollarRegion); +}); + +const chain = [pr(10, "a", DEFAULT_BRANCH), pr(11, "b", "a"), pr(12, "c", "b")]; +const branching = [ + pr(10, "a", DEFAULT_BRANCH), + pr(11, "b", "a"), + pr(12, "c", "a"), + pr(13, "d", "b"), +]; + +test("renderRegion: nested list with current PR marked", () => { + assert.equal(renderRegion(buildTree(10, chain), 11), REGION); +}); + +test("renderRegion: empty for a non-stacked single-member tree", () => { + assert.equal(renderRegion(buildTree(10, [pr(10, "a", "main")]), 10), ""); +}); + +test("findRoot: walks a chain up to the root", () => { + assert.equal(findRoot(12, chain, DEFAULT_BRANCH), 10); + assert.equal(findRoot(11, chain, DEFAULT_BRANCH), 10); + assert.equal(findRoot(10, chain, DEFAULT_BRANCH), 10); +}); + +test("findRoot: resolves from any branch of a branching tree", () => { + assert.equal(findRoot(13, branching, DEFAULT_BRANCH), 10); + assert.equal(findRoot(12, branching, DEFAULT_BRANCH), 10); +}); + +test("findRoot: a PR whose base has no open PR is its own root", () => { + assert.equal(findRoot(11, [pr(11, "b", "deleted-parent")], DEFAULT_BRANCH), 11); +}); + +test("findRoot: undefined for a PR that is not open", () => { + assert.equal(findRoot(99, chain, DEFAULT_BRANCH), undefined); +}); + +test("buildTree: DFS pre-order for a chain", () => { + assert.deepEqual(buildTree(10, chain).members, [10, 11, 12]); +}); + +test("buildTree: DFS pre-order with sorted children for a branching tree", () => { + assert.deepEqual(buildTree(10, branching).members, [10, 11, 13, 12]); +}); + +test("buildTree: records sorted children per node", () => { + const tree = buildTree(10, branching); + assert.deepEqual(tree.childrenOf.get(10), [11, 12]); + assert.deepEqual(tree.childrenOf.get(11), [13]); + assert.deepEqual(tree.childrenOf.get(13), []); +}); + +test("buildTree: terminates on a base/head cycle, visiting each once", () => { + const cycle = [pr(10, "a", "b"), pr(11, "b", "a")]; + assert.deepEqual(buildTree(10, cycle).members, [10, 11]); +}); + +test("resolveTree: finds root and builds tree in one step", () => { + const tree = resolveTree(12, chain, DEFAULT_BRANCH); + assert.equal(tree.root, 10); + assert.deepEqual(tree.members, [10, 11, 12]); +}); + +test("resolveTree: undefined when the trigger is not open", () => { + assert.equal(resolveTree(99, chain, DEFAULT_BRANCH), undefined); +}); + +test("findAllRoots: each distinct root once, ascending", () => { + const twoStacks = [...chain, pr(20, "x", DEFAULT_BRANCH), pr(21, "y", "x")]; + assert.deepEqual(findAllRoots(twoStacks, DEFAULT_BRANCH), [10, 20]); +}); + +test("findAllRoots: empty when there are no open PRs", () => { + assert.deepEqual(findAllRoots([], DEFAULT_BRANCH), []); +}); + +test("resolveAffectedRoots: open trigger returns its own root", () => { + assert.deepEqual( + resolveAffectedRoots({ number: 12, baseRefName: "b" }, chain, DEFAULT_BRANCH), + [10] + ); +}); + +test("resolveAffectedRoots: parent route when a leaf closes", () => { + const open = [pr(10, "a", DEFAULT_BRANCH), pr(11, "b", "a")]; + assert.deepEqual( + resolveAffectedRoots({ number: 12, baseRefName: "b" }, open, DEFAULT_BRANCH), + [10] + ); +}); + +test("resolveAffectedRoots: marker route when the root merges and children retarget", () => { + const marker = "\nx\n"; + const open = [pr(11, "b", DEFAULT_BRANCH, marker), pr(12, "c", "b", marker)]; + assert.deepEqual( + resolveAffectedRoots( + { number: 10, baseRefName: DEFAULT_BRANCH }, + open, + DEFAULT_BRANCH + ), + [11] + ); +}); + +test("resolveAffectedRoots: nothing when a closed PR affects no open stack", () => { + assert.deepEqual( + resolveAffectedRoots( + { number: 99, baseRefName: "gone" }, + [pr(10, "a", DEFAULT_BRANCH)], + DEFAULT_BRANCH + ), + [] + ); +}); + +test("planUpdate: appends the region and flags a change", () => { + const plan = planUpdate(pr(10, "a", "main", "intro"), REGION); + assert.equal(plan.changed, true); + assert.ok(plan.body.includes(REGION)); +}); + +test("planUpdate: skips when the body already has the region", () => { + const plan = planUpdate(pr(10, "a", "main", `intro\n\n${REGION}`), REGION); + assert.equal(plan.changed, false); +}); + +test("planUpdate: removes the region when the desired region is empty", () => { + const plan = planUpdate(pr(10, "a", "main", `x\n\n${REGION}`), ""); + assert.equal(plan.changed, true); + assert.ok(!plan.body.includes("