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 +25 -7
- package/package.json +1 -1
- package/src/commands/submit.ts +10 -7
- package/src/commands/update-prs.ts +76 -8
- package/src/lib/github.ts +12 -0
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,
|
|
89
|
-
|
|
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`
|
|
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
|
-
┣━
|
|
310
|
+
┣━ #123 feat: backend models (1/2)
|
|
293
311
|
┃
|
|
294
|
-
┗━
|
|
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
package/src/commands/submit.ts
CHANGED
|
@@ -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:
|
|
357
|
-
//
|
|
358
|
-
|
|
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 {
|
|
20
|
-
|
|
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}
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
lines.push(`${tree} ${
|
|
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
|
*/
|