Skip to content

Commit c65ef2e

Browse files
theCodeDriftclaude
andcommitted
ci(stack-breadcrumb): bring forward the evolved topology-aware breadcrumb
We sent an early stack-breadcrumb workflow to the taskless/taskless team; they evolved it with a series of fixes. This brings that version (the zero-dependency script + its tests + the workflow) back into this repo, reformatted to our Prettier style and MIT-licensed with permission. Notable improvements over our current version: - Topology-aware markers: the stack marker now encodes each member's parent (`pr=82,83:82,84:83`) instead of a flat list, so the tree shape is durable. - Additive tree: a PR stays in the breadcrumb after it merges, rendered `✅ merged` (vs `⛔ closed`), instead of vanishing. - Recorded-parent resolution: when a parent merges, GitHub deletes its branch and retargets the child onto the default branch, severing the live base/head edge. The recorded parent survives that, so the merged ancestor stays in the tree rather than disappearing. - Defensive cycle guard in the region renderer; legacy flat markers self-migrate to the recorded form on the next reconcile. The logic is repo-agnostic (no taskless/taskless-specific wiring). All 54 unit tests pass via `node --test`; CI already runs `.github/scripts/*.test.cjs`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent da74920 commit c65ef2e

3 files changed

Lines changed: 413 additions & 51 deletions

File tree

‎.github/scripts/stack-breadcrumb.cjs‎

Lines changed: 247 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// SPDX-License-Identifier: MIT
2+
// Adapted from the taskless/taskless stack-breadcrumb implementation
3+
// (@taskless/stack-breadcrumb) and brought into this repository under its MIT
4+
// license, with permission.
15
"use strict";
26

37
/**
@@ -31,13 +35,23 @@
3135
/** Matches the whole region, opening marker through `<!-- /stack -->`. */
3236
const REGION_PATTERN = /<!-- stack [^>]*-->[\S\s]*?<!-- \/stack -->/;
3337

34-
/** Matches just the opening marker, capturing `root` and the `pr=` list. */
35-
const OPEN_MARKER_PATTERN = /<!-- stack root=(\d+) pr=([\d,]*) -->/;
38+
/**
39+
* Matches just the opening marker, capturing `root` and the `pr=` list. Each
40+
* `pr=` entry is either `member` or `member:parent`, so the character class
41+
* admits `:` — old flat markers (`pr=82,83,84`) still match and parse as
42+
* members with no recorded parent (structure then comes from the live graph).
43+
*/
44+
const OPEN_MARKER_PATTERN = /<!-- stack root=(\d+) pr=([\d,:]*) -->/;
3645

3746
/**
38-
* Parse the opening marker's `root` and ordered `pr=` membership list — but only
39-
* inside a COMPLETE region (both markers present), so a stray or pasted opening
40-
* marker without its closing tag is not mistaken for a managed region.
47+
* Parse the opening marker's `root`, ordered `pr=` membership list, and any
48+
* recorded `member:parent` topology — but only inside a COMPLETE region (both
49+
* markers present), so a stray or pasted opening marker without its closing tag
50+
* is not mistaken for a managed region.
51+
*
52+
* Returns `{ root, members, parents }` where `parents` is a Map of member →
53+
* recorded parent for entries that carried a `:parent` suffix. A legacy flat
54+
* marker yields an empty `parents` map — fully backward compatible.
4155
*/
4256
function parseStackComment(body) {
4357
const region = getRegion(body);
@@ -49,11 +63,47 @@ function parseStackComment(body) {
4963
return undefined;
5064
}
5165
const root = Number(match[1]);
52-
const members = match[2]
53-
.split(",")
54-
.filter((part) => part.length > 0)
55-
.map(Number);
56-
return { root, members };
66+
const members = [];
67+
const parents = new Map();
68+
for (const part of match[2].split(",")) {
69+
if (part.length === 0) {
70+
continue;
71+
}
72+
const [memberText, parentText] = part.split(":");
73+
const member = Number(memberText);
74+
if (!Number.isInteger(member)) {
75+
continue;
76+
}
77+
members.push(member);
78+
if (parentText !== undefined) {
79+
const parent = Number(parentText);
80+
if (Number.isInteger(parent)) {
81+
parents.set(member, parent);
82+
}
83+
}
84+
}
85+
return { root, members, parents };
86+
}
87+
88+
/**
89+
* Consolidate the recorded `member → parent` topology across every PR body in
90+
* the list. All PRs in a stack carry the same marker, so the union is
91+
* consistent; on the rare disagreement, last-write wins. Legacy flat markers
92+
* contribute nothing, so a stack only gains durable topology once it has been
93+
* reconciled at least once under this format.
94+
*/
95+
function gatherRecordedParents(pullRequests) {
96+
const parents = new Map();
97+
for (const pullRequest of pullRequests) {
98+
const marker = parseStackComment(pullRequest.body);
99+
if (!marker) {
100+
continue;
101+
}
102+
for (const [member, parent] of marker.parents) {
103+
parents.set(member, parent);
104+
}
105+
}
106+
return parents;
57107
}
58108

59109
/** Return the existing region (including both markers), or `undefined` if absent. */
@@ -107,24 +157,58 @@ function spliceRegion(body, region) {
107157

108158
const HERE_PREFIX = "➡️ "; // ➡️
109159
const HERE_SUFFIX = " (you are here)";
160+
const MERGED_SUFFIX = " ✅ merged";
161+
const CLOSED_SUFFIX = " ⛔ closed";
110162
const STACK_HEADING = "**Stack** (root → tip):"; // root → tip
111163

112-
/** Render the full `<!-- stack … -->` … `<!-- /stack -->` region, or `''` for a non-stacked PR. */
113-
function renderRegion(tree, currentNumber) {
164+
/**
165+
* Render the full `<!-- stack … -->` … `<!-- /stack -->` region, or `''` for a
166+
* non-stacked PR.
167+
*
168+
* `stateOf(number)` returns `'merged' | 'closed' | 'open'` (default `'open'`),
169+
* so merged/closed ancestors stay in the tree with a status marker instead of
170+
* being pruned. The `pr=` list encodes topology as `member:parent` (the root
171+
* has no `:parent`), so the structure is recorded and survives retargeting.
172+
*/
173+
function renderRegion(tree, currentNumber, stateOf = () => "open") {
114174
if (tree.members.length <= 1) {
115175
return "";
116176
}
117177

118-
const open = `<!-- stack root=${tree.root} pr=${tree.members.join(",")} -->`;
178+
const encodeMember = (number_) => {
179+
const parent = tree.parentOf.get(number_);
180+
return parent === undefined ? String(number_) : `${number_}:${parent}`;
181+
};
182+
const open = `<!-- stack root=${tree.root} pr=${tree.members
183+
.map(encodeMember)
184+
.join(",")} -->`;
119185
const lines = [open, STACK_HEADING, ""];
120186

187+
const stateSuffix = (number_) => {
188+
switch (stateOf(number_)) {
189+
case "merged":
190+
return MERGED_SUFFIX;
191+
case "closed":
192+
return CLOSED_SUFFIX;
193+
default:
194+
return "";
195+
}
196+
};
197+
198+
// `buildTree` yields an acyclic tree, but guard the recursion defensively so a
199+
// hand-built or malformed `childrenOf` can never spin the workflow forever.
200+
const rendered = new Set();
121201
const renderNode = (number_, depth) => {
202+
if (rendered.has(number_)) {
203+
return;
204+
}
205+
rendered.add(number_);
122206
const indent = " ".repeat(depth);
123-
lines.push(
207+
const label =
124208
number_ === currentNumber
125-
? `${indent}- ${HERE_PREFIX}#${number_}${HERE_SUFFIX}`
126-
: `${indent}- #${number_}`
127-
);
209+
? `${HERE_PREFIX}#${number_}${HERE_SUFFIX}`
210+
: `#${number_}${stateSuffix(number_)}`;
211+
lines.push(`${indent}- ${label}`);
128212
for (const child of tree.childrenOf.get(number_) ?? []) {
129213
renderNode(child, depth + 1);
130214
}
@@ -136,7 +220,29 @@ function renderRegion(tree, currentNumber) {
136220
}
137221

138222
// ---------------------------------------------------------------------------
139-
// Tree derivation (pure, from the open-PR list)
223+
// Tree derivation — additive topology (live edges + recorded parents)
224+
//
225+
// The tree is ADDITIVE: once a PR joins a stack it stays in the breadcrumb, even
226+
// after it merges. Two facts feed the shape, and the first that answers wins:
227+
//
228+
// 1. RECORDED parent — the marker encodes topology as `member:parent`
229+
// (`pr=82,83:82,84:83`). This is durable: it does not change when a branch
230+
// is deleted or a PR is retargeted.
231+
// 2. LIVE parent — the PR whose head branch is this PR's base. This reflects
232+
// the current GitHub graph and seeds the topology for a brand-new member
233+
// that no marker has recorded yet.
234+
//
235+
// Why recorded must win: when a parent merges, GitHub deletes its branch and
236+
// retargets the child onto the default branch — which SEVERS the live edge
237+
// (child.base becomes `main`, so `findRoot` would call the child its own root
238+
// and the merged parent would vanish). The recorded parent survives that, so
239+
// `buildTree` climbs it back to the true root and keeps the merged ancestor in
240+
// the tree (rendered `✅ merged`). Because the marker is written while the edges
241+
// are still live, the topology is captured BEFORE any retarget can sever it.
242+
//
243+
// A legacy flat marker (`pr=82,83,84`) records no parents, so such a stack
244+
// derives purely from the live graph — identical to the pre-topology behavior —
245+
// and self-migrates to the recorded form on its next reconcile.
140246
// ---------------------------------------------------------------------------
141247

142248
function indexPullRequests(pullRequests) {
@@ -173,40 +279,125 @@ function findRoot(startNumber, pullRequests, defaultBranch) {
173279
return current.number;
174280
}
175281

176-
/** Build the tree rooted at `rootNumber`: DFS pre-order members + sorted child map. */
282+
/**
283+
* Build the provenance tree for `rootNumber`'s stack.
284+
*
285+
* Each member's parent is its RECORDED parent (from the marker topology) when
286+
* one exists, else its LIVE parent (the PR whose head branch is this PR's base).
287+
* Recorded parents win so the structure survives a merged parent whose child
288+
* GitHub retargeted onto the default branch — which severs the live base/head
289+
* edge but not the recorded one. From `rootNumber` we climb to the true (top)
290+
* root, then DFS down the combined child map, so merged/closed ancestors stay
291+
* in the tree instead of being pruned.
292+
*
293+
* Legacy flat markers record no parents, so a not-yet-migrated stack derives
294+
* purely from the live graph (unchanged behavior); the first reconcile writes
295+
* the topology while the edges are still live, making it durable thereafter.
296+
*
297+
* Returns `{ root, members, childrenOf, parentOf }` — members in DFS pre-order,
298+
* `parentOf` a Map of member → parent (the root is absent).
299+
*/
177300
function buildTree(rootNumber, pullRequests) {
178-
const { byNumber } = indexPullRequests(pullRequests);
301+
const { byNumber, byHead } = indexPullRequests(pullRequests);
302+
const recordedParents = gatherRecordedParents(pullRequests);
179303

180-
const childrenByHead = new Map();
181-
for (const pullRequest of pullRequests) {
182-
const siblings = childrenByHead.get(pullRequest.baseRefName) ?? [];
183-
siblings.push(pullRequest.number);
184-
childrenByHead.set(pullRequest.baseRefName, siblings);
304+
const liveParentOf = (number_) => {
305+
const node = byNumber.get(number_);
306+
const parent = node ? byHead.get(node.baseRefName) : undefined;
307+
return parent ? parent.number : undefined;
308+
};
309+
// The LIVE edge wins — it is authoritative and not user-editable. A RECORDED
310+
// parent only fills a SEVERED edge (a PR with no live parent), and only when
311+
// it points at a KNOWN merged/closed ancestor — the sole legitimate case (a
312+
// parent that merged and had its child retargeted onto the default branch).
313+
// PR bodies are user-editable, so this containment stops a crafted marker from
314+
// re-parenting an unrelated PR: it can neither override a live edge nor attach
315+
// a PR under an open one.
316+
const parentOf = (number_) => {
317+
const live = liveParentOf(number_);
318+
if (live !== undefined) {
319+
return live;
320+
}
321+
const recorded = recordedParents.get(number_);
322+
if (recorded === undefined) {
323+
return undefined;
324+
}
325+
const recordedParent = byNumber.get(recorded);
326+
const parentIsMergedOrClosed =
327+
recordedParent !== undefined &&
328+
recordedParent.state !== undefined &&
329+
recordedParent.state !== "open";
330+
return parentIsMergedOrClosed ? recorded : undefined;
331+
};
332+
333+
// Climb to the true root (recorded topology may point above `rootNumber`
334+
// once an ancestor has merged and its child was retargeted).
335+
let trueRoot = rootNumber;
336+
const climbed = new Set();
337+
for (
338+
let parent = parentOf(trueRoot);
339+
parent !== undefined && !climbed.has(trueRoot);
340+
parent = parentOf(trueRoot)
341+
) {
342+
climbed.add(trueRoot);
343+
trueRoot = parent;
344+
}
345+
346+
// Every number we know of — live PRs plus any named only by recorded topology
347+
// (e.g. a merged root that has dropped out of the open-PR list).
348+
const known = new Set(byNumber.keys());
349+
for (const [member, parent] of recordedParents) {
350+
known.add(member);
351+
known.add(parent);
185352
}
186353

354+
// Invert parent → child, then DFS pre-order from the true root.
187355
const childrenOf = new Map();
356+
for (const number_ of known) {
357+
const parent = parentOf(number_);
358+
if (parent !== undefined && parent !== number_) {
359+
childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), number_]);
360+
}
361+
}
362+
for (const [parent, children] of childrenOf) {
363+
childrenOf.set(
364+
parent,
365+
children.toSorted((a, b) => a - b)
366+
);
367+
}
368+
188369
const members = [];
370+
const parentMap = new Map();
189371
const visited = new Set();
190-
191-
const visit = (number_) => {
192-
const node = byNumber.get(number_);
193-
// Guard against a base/head cycle (A←B and B←A): never visit a PR twice.
194-
if (!node || visited.has(number_)) {
372+
const visit = (number_, parent) => {
373+
// Guard against a topology cycle: never visit a number twice.
374+
if (visited.has(number_)) {
195375
return;
196376
}
197377
visited.add(number_);
198378
members.push(number_);
199-
const childNumbers = (childrenByHead.get(node.headRefName) ?? [])
200-
.filter((candidate) => candidate !== number_)
201-
.toSorted((a, b) => a - b);
202-
childrenOf.set(number_, childNumbers);
203-
for (const child of childNumbers) {
204-
visit(child);
379+
if (parent !== undefined) {
380+
parentMap.set(number_, parent);
381+
}
382+
for (const child of childrenOf.get(number_) ?? []) {
383+
visit(child, number_);
205384
}
206385
};
386+
visit(trueRoot, undefined);
387+
388+
// Scope the child map to this tree's members, guaranteeing an entry (possibly
389+
// empty) for every member — including leaves.
390+
const scopedChildren = new Map();
391+
for (const number_ of members) {
392+
scopedChildren.set(number_, childrenOf.get(number_) ?? []);
393+
}
207394

208-
visit(rootNumber);
209-
return { root: rootNumber, members, childrenOf };
395+
return {
396+
root: trueRoot,
397+
members,
398+
childrenOf: scopedChildren,
399+
parentOf: parentMap,
400+
};
210401
}
211402

212403
/** Find the root of `triggerNumber`'s stack, then build the tree. */
@@ -296,6 +487,20 @@ async function reconcile(root, deps) {
296487
);
297488
const tree = buildTree(root, pullRequests);
298489

490+
// Per-member status for rendering: a member with no PR object (named only by
491+
// recorded topology) or an open one is unannotated; a closed one is shown as
492+
// merged vs. plain-closed via its `merged` flag.
493+
const stateOf = (number_) => {
494+
const pullRequest = byNumber.get(number_);
495+
if (!pullRequest || pullRequest.state === undefined) {
496+
return "open";
497+
}
498+
if (pullRequest.state === "open") {
499+
return "open";
500+
}
501+
return pullRequest.merged ? "merged" : "closed";
502+
};
503+
299504
const updated = [];
300505
const skipped = [];
301506
const frozen = [];
@@ -314,7 +519,7 @@ async function reconcile(root, deps) {
314519
frozen.push(number_);
315520
continue;
316521
}
317-
const region = renderRegion(tree, number_);
522+
const region = renderRegion(tree, number_, stateOf);
318523
const plan = planUpdate(pullRequest, region);
319524
if (!plan.changed) {
320525
skipped.push(number_);
@@ -355,6 +560,7 @@ function hasFailedStackJob(jobs) {
355560

356561
module.exports = {
357562
parseStackComment,
563+
gatherRecordedParents,
358564
getRegion,
359565
spliceRegion,
360566
renderRegion,
@@ -368,5 +574,7 @@ module.exports = {
368574
hasFailedStackJob,
369575
HERE_PREFIX,
370576
HERE_SUFFIX,
577+
MERGED_SUFFIX,
578+
CLOSED_SUFFIX,
371579
STACK_HEADING,
372580
};

0 commit comments

Comments
 (0)