gh-stack 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,8 +85,9 @@ create <branch-name> [--description <desc>]
85
85
 
86
86
  submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--dry-run]
87
87
  Push all downstack branches to GitHub, create PRs for branches
88
- that don't have them, and update all PR descriptions with stack
89
- visualization. Idempotent — safe to run repeatedly.
88
+ that don't have them, number each PR title with its stack position
89
+ `(N/M)`, and update all PR descriptions with stack visualization.
90
+ Idempotent — safe to run repeatedly.
90
91
 
91
92
  Self-healing: if the current branch isn't tracked in a stack yet,
92
93
  submit auto-detects the chain from trunk → current, registers (or
@@ -279,19 +280,36 @@ gh-stack solves this by snapshotting every branch's tip **before** any destructi
279
280
 
280
281
  Snapshots also power `gh-stack undo`, so the same data structure does double duty.
281
282
 
283
+ ### Stack Position in PR Titles
284
+
285
+ `submit` keeps each PR's **title** suffixed with its stack position as `(N/M)`,
286
+ so you can see the order from a bare "needs your review" list — where only
287
+ titles show, not descriptions — and review bottom-up:
288
+
289
+ ```
290
+ feat(paid-subs): tiers overview list [BEE-20531] (1/4)
291
+ feat(paid-subs): tier detail page [BEE-20550] (2/4)
292
+ feat(paid-subs): tier create wizard [BEE-20579] (3/4)
293
+ feat(paid-subs): advanced settings step [BEE-20587] (4/4)
294
+ ```
295
+
296
+ Parentheses (not brackets) avoid colliding with `[BEE-1234]` ticket tags. It's
297
+ self-healing and idempotent: re-running `submit` after adding or reordering a
298
+ branch renumbers the whole stack, and a no-op re-submit changes nothing. The
299
+ position is the only managed part of the title — your text (and ticket tags)
300
+ are preserved. Single-PR stacks get no suffix.
301
+
282
302
  ### Stack Visualization
283
303
 
284
- `submit` automatically adds a stack section to all PR descriptions, with each
285
- branch numbered by its position in the stack (handy for spotting "which one is
286
- PR 6 of 12"):
304
+ `submit` also adds a stack section to every PR description:
287
305
 
288
306
  ```
289
307
  ### 📚 Stacked on
290
308
  ⚫ main
291
309
  ┃
292
- ┣━ 1. #123 Backend models
310
+ ┣━ #123 feat: backend models (1/2)
293
311
  ┃
294
- ┗━ 2. #124 Frontend UI 👈
312
+ ┗━ #124 feat: frontend UI (2/2) 👈
295
313
  ```
296
314
 
297
315
  The block is rendered entirely from local metadata plus one repo-identity
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -13,7 +13,7 @@ import { isAutoYes } from "../lib/ui.ts";
13
13
  import type { StackMetadata } from "../types.ts";
14
14
  import { getPrNumber } from "../lib/github.ts";
15
15
  import { resolveOrCreateStack } from "../lib/chain.ts";
16
- import { refreshStackViz, branchToTitle } from "./update-prs.ts";
16
+ import { reconcilePrTitles, refreshStackViz, branchToTitle } from "./update-prs.ts";
17
17
 
18
18
  const HELP = `
19
19
  gh-stack submit — Push branches and create/update PRs
@@ -353,14 +353,17 @@ export default async function submit(args: string[]): Promise<void> {
353
353
  return;
354
354
  }
355
355
 
356
- // ── Phase 2: Refresh the stack visualization (renders locally, PATCHes only
357
- // the PRs whose block actually changed; see issue #16). ──
358
- p.log.info(pc.cyan("Updating stack visualization..."));
356
+ // ── Phase 2: Reconcile PR titles (stack position) then refresh the stack
357
+ // visualization. Titles first so the viz renders the numbered titles. Both
358
+ // render locally and only hit the network for PRs that actually changed
359
+ // (see issue #16), so a no-op re-submit is free. ──
360
+ p.log.info(pc.cyan("Updating PR titles & stack visualization..."));
361
+ const titles = await reconcilePrTitles(stack, ordered);
359
362
  const viz = await refreshStackViz(meta, stack, ordered);
360
363
 
361
364
  // One persist for everything we cached this run (discovered PRs, base edits,
362
- // backfilled titles, viz hashes).
363
- if (metadataChanged || viz.dirty) {
365
+ // backfilled/renumbered titles, viz hashes).
366
+ if (metadataChanged || titles.dirty || viz.dirty) {
364
367
  await writeMetadata(meta);
365
368
  }
366
369
 
@@ -368,7 +371,7 @@ export default async function submit(args: string[]): Promise<void> {
368
371
  console.log();
369
372
  p.outro(
370
373
  pc.green(
371
- `Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s)` +
374
+ `Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s), ${titles.updated} title(s)` +
372
375
  (viz.unchanged > 0 ? `, ${viz.unchanged} unchanged` : ""),
373
376
  ),
374
377
  );
@@ -16,8 +16,15 @@ import {
16
16
  writeMetadata,
17
17
  } from "../lib/metadata.ts";
18
18
  import { ensureMetadata } from "../lib/safety.ts";
19
- import { getPrInfo, getPrBody, updatePrBody, getRepoNwo, prUrlFor } from "../lib/github.ts";
20
- import type { StackMetadata, Stack } from "../types.ts";
19
+ import {
20
+ getPrInfo,
21
+ getPrBody,
22
+ updatePrBody,
23
+ updatePrTitle,
24
+ getRepoNwo,
25
+ prUrlFor,
26
+ } from "../lib/github.ts";
27
+ import type { StackMetadata, Stack, Branch } from "../types.ts";
21
28
 
22
29
  /** The ref a stack is rooted on, for rendering the base node of the viz. */
23
30
  export interface BaseRef {
@@ -103,6 +110,66 @@ function mergeViz(body: string, viz: string): string {
103
110
  return base ? `${base}\n\n${viz}` : viz;
104
111
  }
105
112
 
113
+ // ── PR-title stack numbering ──
114
+ //
115
+ // The stack position lives in the PR title as a ` (N/M)` suffix so reviewers
116
+ // can see order from a bare "needs review" list (parens, not brackets, to avoid
117
+ // colliding with `[BEE-1234]` ticket tags). The cached `prTitle` tracks exactly
118
+ // what's on GitHub; we recover the base title by stripping the suffix and
119
+ // re-applying the current position, so reordering/adding a branch renumbers the
120
+ // whole stack and a no-op re-submit touches nothing.
121
+
122
+ /** Matches a trailing ` (N/M)` stack-position suffix, e.g. " (2/4)". */
123
+ const SEQ_SUFFIX = /\s*\(\d+\/\d+\)\s*$/;
124
+
125
+ /** Strip a trailing ` (N/M)` suffix to recover the base PR title. */
126
+ export function stripSeqSuffix(title: string): string {
127
+ return title.replace(SEQ_SUFFIX, "").trimEnd();
128
+ }
129
+
130
+ /** The title a branch at `position` of `total` should have. Idempotent. */
131
+ export function numberedTitle(title: string, position: number, total: number): string {
132
+ const base = stripSeqSuffix(title);
133
+ return total >= 2 ? `${base} (${position}/${total})` : base;
134
+ }
135
+
136
+ export interface TitleResult {
137
+ updated: number; // PR titles we edited on GitHub
138
+ dirty: boolean; // metadata mutated (cached prTitle changed) — caller persists
139
+ }
140
+
141
+ /**
142
+ * Reconcile every PR title in the stack to carry its `(N/M)` stack position.
143
+ * Backfills any missing cached titles first (so we never clobber a real GitHub
144
+ * title with a branch-name guess), renders the desired title from the cached
145
+ * one, and only calls `gh pr edit --title` when it actually changed — a no-op
146
+ * re-submit makes zero edits, while adding/reordering a branch renumbers the
147
+ * whole stack. Run this BEFORE refreshStackViz so the viz shows numbered titles.
148
+ */
149
+ export async function reconcilePrTitles(stack: Stack, ordered: string[]): Promise<TitleResult> {
150
+ const dirtyBackfill = await backfillTitles(stack, ordered);
151
+ const total = ordered.length;
152
+
153
+ const plans: { pr: number; desired: string; bm: Branch }[] = [];
154
+ for (let i = 0; i < ordered.length; i++) {
155
+ const bm = stack.branches[ordered[i]!]!;
156
+ // Need a known base title — never guess from the branch name for an
157
+ // existing PR (backfill couldn't fetch it → skip rather than clobber).
158
+ if (bm.pr == null || !bm.prTitle) continue;
159
+ const desired = numberedTitle(bm.prTitle, i + 1, total);
160
+ if (desired !== bm.prTitle) plans.push({ pr: bm.pr, desired, bm });
161
+ }
162
+
163
+ const oks = await mapLimit(plans, 8, async (plan) => {
164
+ const ok = await updatePrTitle(plan.pr, plan.desired);
165
+ if (ok) plan.bm.prTitle = plan.desired;
166
+ return ok;
167
+ });
168
+
169
+ const updated = oks.filter(Boolean).length;
170
+ return { updated, dirty: dirtyBackfill || updated > 0 };
171
+ }
172
+
106
173
  export interface VizResult {
107
174
  updated: number; // PRs whose description we PATCHed
108
175
  unchanged: number; // PRs skipped because their rendered block was identical
@@ -192,16 +259,17 @@ PRs whose rendered block is unchanged are skipped; --force rewrites all.
192
259
  console.log();
193
260
 
194
261
  const s = p.spinner();
195
- s.start("Updating PR descriptions...");
262
+ s.start("Updating PR titles & descriptions...");
263
+ const titles = await reconcilePrTitles(stack, ordered);
196
264
  const result = await refreshStackViz(meta, stack, ordered, { force });
197
265
  s.stop("Done");
198
266
 
199
- if (result.dirty) await writeMetadata(meta);
267
+ if (titles.dirty || result.dirty) await writeMetadata(meta);
200
268
 
201
269
  console.log();
202
270
  p.outro(
203
271
  pc.green(
204
- `Updated ${result.updated} PR(s)` +
272
+ `Updated ${result.updated} description(s), ${titles.updated} title(s)` +
205
273
  (result.unchanged > 0 ? `, ${result.unchanged} unchanged` : "") +
206
274
  (result.skipped > 0 ? `, ${result.skipped} without PRs` : ""),
207
275
  ),
@@ -249,9 +317,9 @@ export function buildStackViz(
249
317
  // Tree character
250
318
  const tree = isLast ? "┗━" : "┣━";
251
319
 
252
- // Leading `N.` is the branch's 1-based position in the stack — makes it
253
- // easy to tell which PR is #6 in a 12-PR stack at a glance.
254
- lines.push(`${tree} ${i + 1}. ${prLink} ${info.prTitle}${marker}`);
320
+ // No leading `N.` — the stack position now lives in the PR title itself as
321
+ // a `(N/M)` suffix (see reconcilePrTitles), so the tree just shows titles.
322
+ lines.push(`${tree} ${prLink} ${info.prTitle}${marker}`);
255
323
 
256
324
  if (!isLast) {
257
325
  lines.push("┃");
package/src/lib/github.ts CHANGED
@@ -136,6 +136,18 @@ export async function updatePrBody(prNumber: number, body: string): Promise<bool
136
136
  }
137
137
  }
138
138
 
139
+ /**
140
+ * Update a PR's title.
141
+ */
142
+ export async function updatePrTitle(prNumber: number, title: string): Promise<boolean> {
143
+ try {
144
+ await $`gh pr edit ${prNumber} --title ${title}`.quiet();
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
139
151
  /**
140
152
  * Get PR body text.
141
153
  */