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/**
3135/** Matches the whole region, opening marker through `<!-- /stack -->`. */
3236const REGION_PATTERN = / < ! - - s t a c k [ ^ > ] * - - > [ \S \s ] * ?< ! - - \/ s t a c k - - > / ;
3337
34- /** Matches just the opening marker, capturing `root` and the `pr=` list. */
35- const OPEN_MARKER_PATTERN = / < ! - - s t a c k r o o t = ( \d + ) p r = ( [ \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 = / < ! - - s t a c k r o o t = ( \d + ) p r = ( [ \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 */
4256function 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
108158const HERE_PREFIX = "➡️ " ; // ➡️
109159const HERE_SUFFIX = " (you are here)" ;
160+ const MERGED_SUFFIX = " ✅ merged" ;
161+ const CLOSED_SUFFIX = " ⛔ closed" ;
110162const 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
142248function 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+ */
177300function 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
356561module . 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