nugit-cli 0.1.0 → 0.1.1

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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +0 -12
  3. package/src/github-rest.js +35 -0
  4. package/src/nugit-config.js +84 -0
  5. package/src/nugit-stack.js +29 -266
  6. package/src/nugit.js +103 -661
  7. package/src/review-hub/review-hub-ink.js +6 -3
  8. package/src/review-hub/run-review-hub.js +34 -91
  9. package/src/services/repo-branches.js +151 -0
  10. package/src/services/stack-inference.js +90 -0
  11. package/src/split-view/run-split.js +14 -89
  12. package/src/stack-view/infer-chains-to-pick-stacks.js +10 -0
  13. package/src/stack-view/ink-app.js +3 -2118
  14. package/src/stack-view/loader.js +19 -93
  15. package/src/stack-view/loading-ink.js +2 -44
  16. package/src/stack-view/merge-alternate-pick-stacks.js +23 -1
  17. package/src/stack-view/remote-infer-doc.js +28 -45
  18. package/src/stack-view/run-stack-view.js +249 -526
  19. package/src/stack-view/run-view-entry.js +14 -18
  20. package/src/stack-view/stack-pick-ink.js +169 -131
  21. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  22. package/src/stack-view/terminal-fullscreen.js +7 -45
  23. package/src/tui/pages/home.js +122 -0
  24. package/src/tui/pages/repo-actions.js +81 -0
  25. package/src/tui/pages/repo-branches.js +259 -0
  26. package/src/tui/pages/viewer.js +2129 -0
  27. package/src/tui/router.js +40 -0
  28. package/src/tui/run-tui.js +281 -0
  29. package/src/utilities/loading.js +37 -0
  30. package/src/utilities/terminal.js +31 -0
  31. package/src/cli-output.js +0 -228
  32. package/src/nugit-start.js +0 -211
  33. package/src/stack-discover.js +0 -292
  34. package/src/stack-discovery-config.js +0 -91
  35. package/src/stack-extra-commands.js +0 -353
  36. package/src/stack-graph.js +0 -214
  37. package/src/stack-helpers.js +0 -58
  38. package/src/stack-propagate.js +0 -422
@@ -14,17 +14,11 @@ import {
14
14
  import {
15
15
  findGitRoot,
16
16
  parseRepoFullName,
17
- readStackFile,
18
- validateStackDoc,
19
17
  createInferredStackDoc
20
18
  } from "../nugit-stack.js";
21
19
  import { getRepoFullNameFromGitRoot } from "../git-info.js";
22
- import { discoverStacksInRepo, stackTipPrNumber } from "../stack-discover.js";
23
- import { getStackDiscoveryOpts, effectiveMaxOpenPrs } from "../stack-discovery-config.js";
24
- import { tryLoadStackIndex, writeStackIndex } from "../stack-graph.js";
25
20
  import { fetchStackPrDetails } from "./fetch-pr-data.js";
26
- import { loadStackDocForView } from "./loader.js";
27
- import { inferStackDocForRemoteView, isGithubNotFoundError } from "./remote-infer-doc.js";
21
+ import { inferStackDocForRemoteView } from "./remote-infer-doc.js";
28
22
  import { getRepoMetadata } from "../api-client.js";
29
23
  import { StackInkApp, createExitPayload } from "./ink-app.js";
30
24
  import { renderStaticStackView } from "./static-render.js";
@@ -43,56 +37,16 @@ import { RepoPickerBackError } from "./repo-picker-back.js";
43
37
  import { ReviewHubBackError } from "../review-hub/review-hub-back.js";
44
38
  import {
45
39
  augmentAlternatePickStacksWithInfer,
46
- ensureDocRepresentedInPickStacks
40
+ ensureDocRepresentedInPickStacks,
41
+ stackDocToPickStackRow
47
42
  } from "./merge-alternate-pick-stacks.js";
43
+ import { stackTipPrNumber } from "./merge-alternate-pick-stacks.js";
48
44
  import { withStackLoadInkScreen } from "./loading-ink.js";
45
+ import { discoverStacksByInference } from "../services/stack-inference.js";
49
46
 
50
- /**
51
- * @param {string} owner
52
- * @param {string} repoName
53
- * @param {Record<string, unknown>} doc
54
- * @param {{ viewerLogin?: string, defaultBranch?: string, fullReviewFetch?: boolean }} mergedReviewFetch
55
- * @param {boolean} [reviewAutoapply]
56
- */
57
- export async function fetchStackRowsWithAutoapply(
58
- owner,
59
- repoName,
60
- doc,
61
- mergedReviewFetch,
62
- reviewAutoapply
63
- ) {
64
- let rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
65
-
66
- if (reviewAutoapply && resolveGithubToken() && mergedReviewFetch.viewerLogin) {
67
- const cfg = loadReviewAutoapproveConfig();
68
- if (cfg) {
69
- let approvedAny = false;
70
- for (const row of rows) {
71
- if (row.error || !row.pull) continue;
72
- const head = row.pull.head && typeof row.pull.head === "object" ? row.pull.head : {};
73
- const headRef = typeof head.ref === "string" ? head.ref : "";
74
- if (!isRepoHeadAutoapproveEligible(cfg, owner, repoName, headRef)) continue;
75
- const m = row.viewerReviewMeta;
76
- if (!m?.staleApproval || m.riskyChangeAfterApproval) continue;
77
- const ms = row.pull.mergeable_state;
78
- if (ms && ms !== "clean" && ms !== "unstable") continue;
79
- try {
80
- await githubPostPullReview(owner, repoName, row.entry.pr_number, {
81
- event: "APPROVE",
82
- body: "nugit auto-approve: merge-only delta (review-autoapprove.json)"
83
- });
84
- approvedAny = true;
85
- } catch {
86
- /* ignore per-PR */
87
- }
88
- }
89
- if (approvedAny) {
90
- rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
91
- }
92
- }
93
- }
94
- return rows;
95
- }
47
+ // ---------------------------------------------------------------------------
48
+ // Spinner helper (stderr, non-TUI)
49
+ // ---------------------------------------------------------------------------
96
50
 
97
51
  function createSpinner(prefix) {
98
52
  const frames = [chalk.cyan("⠋"), chalk.cyan("⠙"), chalk.cyan("⠹"), chalk.cyan("⠸"), chalk.cyan("⠼"), chalk.cyan("⠴")];
@@ -100,13 +54,9 @@ function createSpinner(prefix) {
100
54
  let msg = "starting...";
101
55
  let timer = null;
102
56
  return {
103
- update(nextMsg) {
104
- msg = nextMsg || msg;
105
- },
57
+ update(nextMsg) { msg = nextMsg || msg; },
106
58
  start() {
107
- if (!process.stderr.isTTY) {
108
- return;
109
- }
59
+ if (!process.stderr.isTTY) return;
110
60
  timer = setInterval(() => {
111
61
  const frame = frames[i % frames.length];
112
62
  i += 1;
@@ -115,24 +65,20 @@ function createSpinner(prefix) {
115
65
  },
116
66
  stop(finalMsg) {
117
67
  if (!process.stderr.isTTY) {
118
- if (finalMsg) {
119
- console.error(`${prefix} ${finalMsg}`);
120
- }
68
+ if (finalMsg) console.error(`${prefix} ${finalMsg}`);
121
69
  return;
122
70
  }
123
- if (timer) {
124
- clearInterval(timer);
125
- }
71
+ if (timer) clearInterval(timer);
126
72
  const done = finalMsg ? `${chalk.bold(prefix)} ${chalk.green(finalMsg)}` : "";
127
73
  process.stderr.write(`\r${done}${" ".repeat(30)}\n`);
128
74
  }
129
75
  };
130
76
  }
131
77
 
132
- /**
133
- * @param {{ tip_pr_number: number, tip_head_branch: string, pr_count: number, created_by: string }[]} stacks
134
- * @returns {Promise<number>}
135
- */
78
+ // ---------------------------------------------------------------------------
79
+ // Non-TUI stack picker (stdin prompt fallback)
80
+ // ---------------------------------------------------------------------------
81
+
136
82
  async function pickDiscoveredStackIndex(stacks) {
137
83
  const stackTree = (s) => {
138
84
  const prs = Array.isArray(s.prs) ? s.prs : [];
@@ -159,26 +105,18 @@ async function pickDiscoveredStackIndex(stacks) {
159
105
  );
160
106
  console.error(stackTree(s));
161
107
  }
162
- const ans = String(
163
- await questionLine(chalk.green("Select stack number (empty=cancel): "))
164
- ).trim();
165
- if (!ans) {
166
- return -1;
167
- }
108
+ const ans = String(await questionLine(chalk.green("Select stack number (empty=cancel): "))).trim();
109
+ if (!ans) return -1;
168
110
  const n = Number.parseInt(ans, 10);
169
- if (Number.isInteger(n) && n >= 1 && n <= stacks.length) {
170
- return n - 1;
171
- }
111
+ if (Number.isInteger(n) && n >= 1 && n <= stacks.length) return n - 1;
172
112
  console.error("Invalid choice.");
173
113
  }
174
114
  }
175
115
 
176
- /**
177
- * @param {string} owner
178
- * @param {string} repo
179
- * @param {number} prNumber
180
- * @returns {Promise<string[]>}
181
- */
116
+ // ---------------------------------------------------------------------------
117
+ // Reviewer prompt
118
+ // ---------------------------------------------------------------------------
119
+
182
120
  async function promptReviewers(owner, repo, prNumber) {
183
121
  let candidates = [];
184
122
  const spin = createSpinner("Loading reviewers");
@@ -186,9 +124,7 @@ async function promptReviewers(owner, repo, prNumber) {
186
124
  try {
187
125
  const users = await githubListAssignableUsers(owner, repo);
188
126
  candidates = Array.isArray(users)
189
- ? users
190
- .map((u) => (u && typeof u === "object" ? String(u.login || "") : ""))
191
- .filter(Boolean)
127
+ ? users.map((u) => (u && typeof u === "object" ? String(u.login || "") : "")).filter(Boolean)
192
128
  : [];
193
129
  } catch {
194
130
  candidates = [];
@@ -204,10 +140,7 @@ async function promptReviewers(owner, repo, prNumber) {
204
140
  console.error(chalk.yellow("No assignable users listed by GitHub. You can still type logins."));
205
141
  }
206
142
  const raw = await questionLine(chalk.green("Assign Reviewers (empty=cancel): "));
207
- const parts = raw
208
- .split(",")
209
- .map((s) => s.trim())
210
- .filter(Boolean);
143
+ const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
211
144
  const chosen = [];
212
145
  for (const p of parts) {
213
146
  const n = Number.parseInt(p, 10);
@@ -220,318 +153,222 @@ async function promptReviewers(owner, repo, prNumber) {
220
153
  return [...new Set(chosen)];
221
154
  }
222
155
 
156
+ // ---------------------------------------------------------------------------
157
+ // Auto-approve helper
158
+ // ---------------------------------------------------------------------------
159
+
223
160
  /**
224
- * When the viewer opens with an explicit ref (repo picker, `nugit view owner/repo`, etc.),
225
- * `discoverableStacks` is never set. Populate from `.nugit/stack-index.json` or a scan so
226
- * Backspace can reopen the multi-stack Ink picker.
227
- *
228
- * @param {string | null} root
229
- * @param {string} repoFull
230
161
  * @param {string} owner
231
162
  * @param {string} repoName
232
- * @param {unknown[] | null} current
233
- * @param {boolean} tuiSession
234
- * @returns {Promise<{ stacks: unknown[] | null, openPullNumbers: Set<number> | null }>}
163
+ * @param {Record<string, unknown>} doc
164
+ * @param {{ viewerLogin?: string, defaultBranch?: string, fullReviewFetch?: boolean }} mergedReviewFetch
165
+ * @param {boolean} [reviewAutoapply]
235
166
  */
236
- async function hydrateDiscoverableStacksForAlternatePicker(
237
- root,
238
- repoFull,
239
- owner,
240
- repoName,
241
- current,
242
- tuiSession
243
- ) {
244
- /** @type {{ stacks: unknown[] | null, openPullNumbers: Set<number> | null }} */
245
- const wrap = (stacks, openPullNumbers = null) => ({ stacks, openPullNumbers });
167
+ export async function fetchStackRowsWithAutoapply(owner, repoName, doc, mergedReviewFetch, reviewAutoapply) {
168
+ let rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
246
169
 
247
- if (!repoFull || !resolveGithubToken()) {
248
- return wrap(current);
170
+ if (reviewAutoapply && resolveGithubToken() && mergedReviewFetch.viewerLogin) {
171
+ const cfg = loadReviewAutoapproveConfig();
172
+ if (cfg) {
173
+ let approvedAny = false;
174
+ for (const row of rows) {
175
+ if (row.error || !row.pull) continue;
176
+ const head = row.pull.head && typeof row.pull.head === "object" ? row.pull.head : {};
177
+ const headRef = typeof head.ref === "string" ? head.ref : "";
178
+ if (!isRepoHeadAutoapproveEligible(cfg, owner, repoName, headRef)) continue;
179
+ const m = row.viewerReviewMeta;
180
+ if (!m?.staleApproval || m.riskyChangeAfterApproval) continue;
181
+ const ms = row.pull.mergeable_state;
182
+ if (ms && ms !== "clean" && ms !== "unstable") continue;
183
+ try {
184
+ await githubPostPullReview(owner, repoName, row.entry.pr_number, {
185
+ event: "APPROVE",
186
+ body: "nugit auto-approve: merge-only delta (review-autoapprove.json)"
187
+ });
188
+ approvedAny = true;
189
+ } catch { /* ignore per-PR */ }
190
+ }
191
+ if (approvedAny) {
192
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
193
+ }
194
+ }
249
195
  }
196
+ return rows;
197
+ }
250
198
 
251
- if (current && Array.isArray(current) && current.length > 1) {
252
- return augmentAlternatePickStacksWithInfer(owner, repoName, current);
253
- }
199
+ // ---------------------------------------------------------------------------
200
+ // Alternate stack picker hydration (inference-only no stack-index.json)
201
+ // ---------------------------------------------------------------------------
254
202
 
255
- if (root) {
256
- const cached = tryLoadStackIndex(root, repoFull);
257
- const stacks = cached && Array.isArray(cached.stacks) ? cached.stacks : null;
258
- if (stacks && stacks.length > 1) {
259
- return augmentAlternatePickStacksWithInfer(owner, repoName, stacks);
260
- }
261
- }
203
+ /**
204
+ * Ensure the alternate picker has discoverable stacks; uses inference only.
205
+ */
206
+ async function hydrateDiscoverableStacks(repoFull, owner, repoName, current, tuiSession) {
207
+ const wrap = (stacks, openPullNumbers = null) => ({ stacks, openPullNumbers });
262
208
 
263
- const discovery = getStackDiscoveryOpts();
264
- if (discovery.mode === "manual") {
265
- return augmentAlternatePickStacksWithInfer(owner, repoName, Array.isArray(current) ? current : []);
209
+ if (!repoFull || !resolveGithubToken()) return wrap(current);
210
+ if (current && Array.isArray(current) && current.length > 1) {
211
+ return augmentAlternatePickStacksWithInfer(owner, repoName, current);
266
212
  }
267
213
 
268
- const full =
269
- process.env.NUGIT_STACK_DISCOVERY_FULL === "1" || process.env.NUGIT_STACK_DISCOVERY_FULL === "true";
270
214
  const spinner = createSpinner("Scanning stacks");
271
- if (!tuiSession) {
272
- spinner.start();
273
- }
215
+ if (!tuiSession) spinner.start();
274
216
  try {
275
- const discovered = await discoverStacksInRepo(owner, repoName, {
276
- maxOpenPrs: effectiveMaxOpenPrs(discovery, full),
277
- enrich: false,
278
- fetchConcurrency: discovery.fetchConcurrency,
279
- onProgress: (m) => (!tuiSession ? spinner.update(m) : undefined)
280
- });
217
+ const { stacks, openPullNumbers } = await discoverStacksByInference(owner, repoName);
281
218
  if (!tuiSession) {
282
- spinner.stop(
283
- `found ${discovered.stacks_found} stack(s) across ${discovered.scanned_open_prs} open PR(s)`
284
- );
219
+ spinner.stop(`found ${stacks.length} stack(s)`);
285
220
  }
286
- if (root) {
287
- try {
288
- if (discovery.mode === "eager" || discovery.mode === "lazy") {
289
- writeStackIndex(root, discovered);
290
- }
291
- } catch {
292
- /* ignore index write */
293
- }
294
- }
295
- if (discovered.stacks_found > 1) {
296
- return augmentAlternatePickStacksWithInfer(owner, repoName, discovered.stacks);
297
- }
298
- if (discovered.stacks_found === 1) {
299
- return augmentAlternatePickStacksWithInfer(owner, repoName, discovered.stacks);
300
- }
301
- return augmentAlternatePickStacksWithInfer(owner, repoName, []);
221
+ return { stacks, openPullNumbers };
302
222
  } catch {
303
- if (!tuiSession) {
304
- spinner.stop("scan failed");
305
- }
223
+ if (!tuiSession) spinner.stop("scan failed");
306
224
  return augmentAlternatePickStacksWithInfer(owner, repoName, Array.isArray(current) ? current : []);
307
225
  }
308
226
  }
309
227
 
228
+ // ---------------------------------------------------------------------------
229
+ // Main entry
230
+ // ---------------------------------------------------------------------------
231
+
310
232
  /**
311
233
  * @param {object} opts
312
234
  * @param {boolean} [opts.noTui]
313
235
  * @param {string} [opts.repo]
314
236
  * @param {string} [opts.ref]
315
237
  * @param {string} [opts.file]
316
- * @param {Record<string, unknown>} [opts.explicitDoc] skip discovery / stack.json load
238
+ * @param {Record<string, unknown>} [opts.explicitDoc] skip inference / load
317
239
  * @param {{ viewerLogin?: string, defaultBranch?: string, fullReviewFetch?: boolean }} [opts.reviewFetchOpts]
318
- * @param {string} [opts.viewTitle] Ink header
319
- * @param {boolean} [opts.reviewAutoapply] apply review-autoapprove.json rules once after load
320
- * @param {boolean} [opts.shellMode] multi-page nugit view layout (default true for TUI)
321
- * @param {boolean} [opts.allowBackToRepoPicker] inferred PR-group Ink picker: Backspace throws RepoPickerBackError (repo-picker loop only)
322
- * @param {boolean} [opts.allowBackToReviewHub] stack picker Backspace throws ReviewHubBackError (review hub loop only)
240
+ * @param {string} [opts.viewTitle]
241
+ * @param {boolean} [opts.reviewAutoapply]
242
+ * @param {boolean} [opts.shellMode]
243
+ * @param {boolean} [opts.allowBackToRepoPicker]
244
+ * @param {boolean} [opts.allowBackToReviewHub]
323
245
  */
324
246
  export async function runStackViewCommand(opts) {
325
247
  const tuiSession = !opts.noTui && process.stdin.isTTY && process.stdout.isTTY;
326
- /** @type {unknown[] | null} Re-open stack picker (Backspace) when length > 1 */
248
+ /** @type {unknown[] | null} */
327
249
  let discoverableStacks = null;
328
250
 
329
251
  if (!resolveGithubToken()) {
330
252
  console.error(
331
253
  chalk.dim(
332
- "No NUGIT_USER_TOKEN: using unauthenticated GitHub reads (low rate limit; public repos only for API data). Set a PAT for private repos, higher limits, or posting comments."
254
+ "No NUGIT_USER_TOKEN: using unauthenticated GitHub reads (low rate limit; public repos only). " +
255
+ "Set a PAT or run `nugit auth login` for private repos and higher limits."
333
256
  )
334
257
  );
335
258
  }
259
+
336
260
  const root = findGitRoot();
337
261
  let repo = opts.repo;
338
262
  let ref = opts.ref;
339
263
  let explicitDoc = opts.explicitDoc || null;
340
264
 
341
- if (!explicitDoc && !opts.file && !ref) {
265
+ // If no explicit doc/file and no ref, discover stacks by inference
266
+ if (!explicitDoc && !opts.file) {
342
267
  let repoFull = repo || null;
343
268
  if (!repoFull && root) {
344
- try {
345
- repoFull = getRepoFullNameFromGitRoot(root);
346
- } catch {
347
- repoFull = null;
348
- }
269
+ try { repoFull = getRepoFullNameFromGitRoot(root); } catch { repoFull = null; }
349
270
  }
350
271
  if (repoFull) {
351
- const { owner, repo: repoName } = parseRepoFullName(repoFull);
352
- const discovery = getStackDiscoveryOpts();
353
- const full =
354
- process.env.NUGIT_STACK_DISCOVERY_FULL === "1" || process.env.NUGIT_STACK_DISCOVERY_FULL === "true";
355
- let discovered = null;
356
- let usedCache = false;
357
- if (discovery.mode === "manual" && root) {
358
- discovered = tryLoadStackIndex(root, repoFull);
359
- if (!discovered) {
360
- throw new Error(
361
- 'Stack discovery mode is "manual". Run `nugit stack index` first or pass --repo/--ref explicitly.'
362
- );
363
- }
364
- usedCache = true;
365
- } else if (discovery.mode === "lazy" && root && !full) {
366
- const cached = tryLoadStackIndex(root, repoFull);
367
- if (cached) {
368
- discovered = cached;
369
- usedCache = true;
370
- }
371
- }
372
- const discoverOpts = {
373
- maxOpenPrs: effectiveMaxOpenPrs(discovery, full),
374
- enrich: false,
375
- fetchConcurrency: discovery.fetchConcurrency
376
- };
272
+ const { owner: discO, repo: discR } = parseRepoFullName(repoFull);
377
273
  /** @type {object[]} */
378
274
  let mergedForPick = [];
379
275
 
276
+ const doDiscover = async () => {
277
+ const { stacks } = await discoverStacksByInference(discO, discR);
278
+ mergedForPick = stacks;
279
+ };
280
+
380
281
  if (tuiSession) {
381
- await withStackLoadInkScreen("Loading stacks…", async () => {
382
- if (!discovered) {
383
- discovered = await discoverStacksInRepo(owner, repoName, {
384
- ...discoverOpts,
385
- onProgress: undefined
386
- });
387
- if (root) {
388
- try {
389
- if (discovery.mode === "eager" || discovery.mode === "lazy") {
390
- writeStackIndex(root, discovered);
391
- }
392
- } catch {
393
- /* ignore index write */
394
- }
395
- }
396
- }
397
- const rawStacks = discovered && Array.isArray(discovered.stacks) ? discovered.stacks : [];
398
- const aug = await augmentAlternatePickStacksWithInfer(owner, repoName, rawStacks);
399
- mergedForPick = aug.stacks;
400
- });
282
+ await withStackLoadInkScreen("Loading stacks…", doDiscover);
401
283
  } else {
402
- if (!discovered) {
403
- const spinner = createSpinner("Scanning stacks");
404
- spinner.start();
405
- discovered = await discoverStacksInRepo(owner, repoName, {
406
- ...discoverOpts,
407
- onProgress: (m) => spinner.update(m)
408
- });
409
- spinner.stop(
410
- `found ${discovered.stacks_found} stack(s) across ${discovered.scanned_open_prs} open PR(s)`
411
- );
412
- if (root) {
413
- try {
414
- if (discovery.mode === "eager" || discovery.mode === "lazy") {
415
- writeStackIndex(root, discovered);
416
- }
417
- } catch {
418
- /* ignore index write */
419
- }
420
- }
421
- } else if (usedCache) {
422
- console.error(chalk.dim("Using .nugit/stack-index.json — set NUGIT_STACK_DISCOVERY_FULL=1 to rescan GitHub."));
423
- }
424
- const rawStacks = discovered && Array.isArray(discovered.stacks) ? discovered.stacks : [];
425
- const aug = await augmentAlternatePickStacksWithInfer(owner, repoName, rawStacks);
426
- mergedForPick = aug.stacks;
284
+ const sp = createSpinner("Scanning stacks");
285
+ sp.start();
286
+ await doDiscover();
287
+ sp.stop(`found ${mergedForPick.length} stack(s)`);
288
+ }
289
+
290
+ if (mergedForPick.length === 0 && !ref) {
291
+ throw new Error(`No open PR stacks found in ${repoFull}. Open some stacked PRs and try again.`);
427
292
  }
293
+
428
294
  if (mergedForPick.length > 1) {
429
295
  if (opts.noTui) {
430
- console.log(
431
- chalk.bold.cyan(`Stacks in ${repoFull}`) +
432
- chalk.dim(
433
- ` · ${mergedForPick.length} candidate(s) (PR heads with .nugit/stack.json + inferred same-repo chains)`
434
- )
435
- );
296
+ console.log(chalk.bold.cyan(`Stacks in ${repoFull}`) + chalk.dim(` · ${mergedForPick.length} candidate(s) (inferred from open PR chains)`));
436
297
  console.log("");
437
298
  for (const s of mergedForPick) {
438
- const tag =
439
- s && typeof s === "object" && /** @type {{ inferredOnly?: boolean }} */ (s).inferredOnly
440
- ? chalk.dim(" (inferred)")
441
- : "";
442
299
  const row = /** @type {{ tip_pr_number: number, pr_count: number, tip_head_branch: string }} */ (s);
443
- console.log(
444
- ` ${chalk.yellow("tip #" + row.tip_pr_number)} · ${row.pr_count} PR(s) · ${chalk.magenta(row.tip_head_branch)}${tag}`
445
- );
300
+ console.log(` ${chalk.yellow("tip #" + row.tip_pr_number)} · ${row.pr_count} PR(s) · ${chalk.magenta(row.tip_head_branch)}`);
446
301
  }
447
302
  console.log("");
448
- console.log(chalk.dim("Re-run with a TTY to pick interactively, or pass --ref to a stack tip branch."));
303
+ console.log(chalk.dim("Re-run with a TTY to pick interactively, or pass --repo/--ref to open a specific stack."));
449
304
  return;
450
305
  }
451
- const useInkPick =
452
- !opts.noTui && process.stdin.isTTY && process.stdout.isTTY;
306
+
453
307
  discoverableStacks = mergedForPick;
454
- /** @type {{ tip_head_branch: string } | null} */
455
- let pickedStack = null;
456
- if (useInkPick) {
457
- const hasBack = !!(opts.allowBackToRepoPicker || opts.allowBackToReviewHub);
458
- pickedStack = await pickStackIndexWithInk(mergedForPick, {
459
- allowBackToRepo: hasBack,
460
- escapeToRepo: hasBack
461
- });
462
- if (pickedStack === STACK_PICK_BACK_TO_REPO) {
463
- if (opts.allowBackToReviewHub) {
464
- throw new ReviewHubBackError();
465
- }
466
- if (opts.allowBackToRepoPicker) {
467
- throw new RepoPickerBackError();
468
- }
469
- console.error(
470
- chalk.dim("Backspace: repo list is only available when you start with `nugit view` (no args) from a TTY.")
471
- );
472
- return;
473
- }
474
- } else {
308
+ const hasBack = !!(opts.allowBackToRepoPicker || opts.allowBackToReviewHub);
309
+ const picked = tuiSession
310
+ ? await pickStackIndexWithInk(mergedForPick, { allowBackToRepo: hasBack, escapeToRepo: hasBack })
311
+ : null;
312
+
313
+ if (picked === STACK_PICK_BACK_TO_REPO) {
314
+ if (opts.allowBackToReviewHub) throw new ReviewHubBackError();
315
+ if (opts.allowBackToRepoPicker) throw new RepoPickerBackError();
316
+ return;
317
+ }
318
+
319
+ let pickedStack;
320
+ if (!tuiSession) {
475
321
  const idx = await pickDiscoveredStackIndex(
476
322
  /** @type {{ tip_head_branch: string, tip_pr_number: number, pr_count: number, created_by: string, prs: unknown[] }[]} */ (
477
323
  mergedForPick
478
324
  )
479
325
  );
480
- if (idx < 0) {
481
- console.error("Cancelled.");
482
- return;
483
- }
326
+ if (idx < 0) { console.error("Cancelled."); return; }
484
327
  pickedStack = mergedForPick[idx];
328
+ } else {
329
+ pickedStack = picked;
485
330
  }
331
+
486
332
  if (!pickedStack) {
487
- if (opts.allowBackToReviewHub) {
488
- throw new ReviewHubBackError();
489
- }
490
- if (opts.allowBackToRepoPicker) {
491
- throw new RepoPickerBackError();
492
- }
333
+ if (opts.allowBackToReviewHub) throw new ReviewHubBackError();
334
+ if (opts.allowBackToRepoPicker) throw new RepoPickerBackError();
493
335
  console.error("Cancelled.");
494
336
  return;
495
337
  }
338
+
496
339
  repo = repoFull;
497
340
  ref = pickedStack.tip_head_branch;
498
- if (pickedStack.inferredOnly || pickedStack.inferredFromViewerDoc) {
499
- const prNums = Array.isArray(pickedStack.prs)
500
- ? pickedStack.prs
501
- .filter((p) => p && typeof p === "object" && typeof p.pr_number === "number")
502
- .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
503
- .map((p) => p.pr_number)
504
- : [];
505
- if (prNums.length > 0) {
506
- explicitDoc = createInferredStackDoc(repoFull, String(pickedStack.created_by || ""), prNums);
507
- }
508
- }
509
- if (!tuiSession) {
510
- console.error(`Viewing selected stack tip: ${repo}@${ref}`);
341
+ const prNums = Array.isArray(pickedStack.prs)
342
+ ? pickedStack.prs
343
+ .filter((p) => p && typeof p === "object" && typeof p.pr_number === "number")
344
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
345
+ .map((p) => p.pr_number)
346
+ : [];
347
+ if (prNums.length > 0) {
348
+ explicitDoc = createInferredStackDoc(repoFull, String(pickedStack.created_by || ""), prNums);
511
349
  }
350
+ if (!tuiSession) console.error(`Viewing stack tip: ${repo}@${ref}`);
351
+
512
352
  } else if (mergedForPick.length === 1) {
353
+ const only = /** @type {{ tip_head_branch: string, created_by?: string, prs?: { pr_number: number, position?: number }[] }} */ (mergedForPick[0]);
513
354
  repo = repoFull;
514
- const only = /** @type {{ tip_head_branch: string, inferredOnly?: boolean, inferredFromViewerDoc?: boolean, created_by?: string, prs?: { pr_number: number, position?: number }[] }} */ (mergedForPick[0]);
515
355
  ref = only.tip_head_branch;
516
356
  discoverableStacks = mergedForPick;
517
- if (only.inferredOnly || only.inferredFromViewerDoc) {
518
- const prNums = Array.isArray(only.prs)
519
- ? only.prs
520
- .filter((p) => p && typeof p === "object" && typeof p.pr_number === "number")
521
- .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
522
- .map((p) => p.pr_number)
523
- : [];
524
- if (prNums.length > 0) {
525
- explicitDoc = createInferredStackDoc(repoFull, String(only.created_by || ""), prNums);
526
- }
527
- }
528
- if (!tuiSession) {
529
- console.error(`Viewing stack tip: ${repo}@${ref}`);
357
+ const prNums = Array.isArray(only.prs)
358
+ ? only.prs
359
+ .filter((p) => p && typeof p === "object" && typeof p.pr_number === "number")
360
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
361
+ .map((p) => p.pr_number)
362
+ : [];
363
+ if (prNums.length > 0) {
364
+ explicitDoc = createInferredStackDoc(repoFull, String(only.created_by || ""), prNums);
530
365
  }
366
+ if (!tuiSession) console.error(`Viewing stack tip: ${repo}@${ref}`);
531
367
  }
532
368
  }
533
369
  }
534
370
 
371
+ // Resolve default branch if needed
535
372
  if (!ref && repo && !explicitDoc) {
536
373
  const { owner: dbO, repo: dbR } = parseRepoFullName(repo);
537
374
  const dbMeta = await getRepoMetadata(dbO, dbR);
@@ -553,89 +390,49 @@ export async function runStackViewCommand(opts) {
553
390
 
554
391
  if (explicitDoc) {
555
392
  doc = explicitDoc;
556
- validateStackDoc(doc);
393
+ } else if (opts.file) {
394
+ const raw = fs.readFileSync(opts.file, "utf8");
395
+ doc = JSON.parse(raw);
557
396
  } else {
558
- try {
559
- const loaded = await loadStackDocForView({
560
- root,
561
- repo,
562
- ref,
563
- file: opts.file
564
- });
565
- doc = loaded.doc;
566
- } catch (e) {
567
- const canInfer =
568
- repo &&
569
- ref &&
570
- !opts.file &&
571
- resolveGithubToken() &&
572
- isGithubNotFoundError(e);
573
- if (!canInfer) {
574
- throw e;
575
- }
576
- if (!tuiSession) {
577
- console.error(
578
- chalk.dim(
579
- "No .nugit/stack.json on that ref — inferring stack from open PRs (same-repo). " +
580
- "Use an explicit --ref to a branch that has stack.json if this is wrong."
581
- )
582
- );
583
- }
584
- const useInkInfer =
585
- !opts.noTui && process.stdin.isTTY && process.stdout.isTTY;
586
- const { doc: inferred, viewerLogin } = await inferStackDocForRemoteView(repo, {
587
- interactivePick: !opts.noTui,
588
- tuiChainPick: useInkInfer
589
- ? (ch, pl) =>
590
- pickInferChainIndexWithInk(ch, pl, { allowBackToRepo: !!(opts.allowBackToRepoPicker || opts.allowBackToReviewHub) })
591
- : undefined
592
- });
593
- doc = inferred;
594
- const { owner: oInf, repo: rInf } = parseRepoFullName(doc.repo_full_name);
595
- const meta = await getRepoMetadata(oInf, rInf);
596
- const defaultBranch = typeof meta.default_branch === "string" ? meta.default_branch : "main";
597
- mergedReviewFetch = {
598
- ...mergedReviewFetch,
599
- viewerLogin: mergedReviewFetch?.viewerLogin || viewerLogin,
600
- defaultBranch: mergedReviewFetch?.defaultBranch || defaultBranch,
601
- fullReviewFetch: mergedReviewFetch?.fullReviewFetch ?? true
602
- };
397
+ // Inference: no explicit doc, repo must be set
398
+ if (!repo) {
399
+ throw new Error("Cannot load stack: no repo specified. Pass --repo owner/repo or run inside a git clone.");
603
400
  }
401
+ const useInkInfer = !opts.noTui && process.stdin.isTTY && process.stdout.isTTY;
402
+ const { doc: inferred, viewerLogin } = await inferStackDocForRemoteView(repo, {
403
+ interactivePick: !opts.noTui,
404
+ tuiChainPick: useInkInfer
405
+ ? (ch, pl) => pickInferChainIndexWithInk(ch, pl, { allowBackToRepo: !!(opts.allowBackToRepoPicker || opts.allowBackToReviewHub) })
406
+ : undefined
407
+ });
408
+ doc = inferred;
409
+ const { owner: oInf, repo: rInf } = parseRepoFullName(doc.repo_full_name);
410
+ const meta = await getRepoMetadata(oInf, rInf);
411
+ const defaultBranch = typeof meta.default_branch === "string" ? meta.default_branch : "main";
412
+ mergedReviewFetch = {
413
+ ...mergedReviewFetch,
414
+ viewerLogin: mergedReviewFetch?.viewerLogin || viewerLogin,
415
+ defaultBranch: mergedReviewFetch?.defaultBranch || defaultBranch,
416
+ fullReviewFetch: mergedReviewFetch?.fullReviewFetch ?? true
417
+ };
604
418
  }
605
419
 
606
420
  let { owner, repo: repoName } = parseRepoFullName(doc.repo_full_name);
607
421
  let rows;
608
422
  const loadRowsOnce = async () => {
609
- rows = await fetchStackRowsWithAutoapply(
610
- owner,
611
- repoName,
612
- doc,
613
- mergedReviewFetch,
614
- !!opts.reviewAutoapply
615
- );
423
+ rows = await fetchStackRowsWithAutoapply(owner, repoName, doc, mergedReviewFetch, !!opts.reviewAutoapply);
616
424
  };
617
425
  if (tuiSession) {
618
426
  await withStackLoadInkScreen("Loading stack…", loadRowsOnce);
619
427
  } else {
620
428
  const loadSpinner = createSpinner("Loading stack");
621
- if (!tuiSession) {
622
- loadSpinner.start();
623
- }
429
+ loadSpinner.start();
624
430
  await loadRowsOnce();
625
- if (!tuiSession) {
626
- loadSpinner.stop(`loaded ${rows.length} PR(s)`);
627
- }
431
+ loadSpinner.stop(`loaded ${rows.length} PR(s)`);
628
432
  }
629
433
 
630
434
  const repoFullStr = String(doc.repo_full_name || "");
631
- const hydrated = await hydrateDiscoverableStacksForAlternatePicker(
632
- root,
633
- repoFullStr,
634
- owner,
635
- repoName,
636
- discoverableStacks,
637
- tuiSession
638
- );
435
+ const hydrated = await hydrateDiscoverableStacks(repoFullStr, owner, repoName, discoverableStacks, tuiSession);
639
436
  discoverableStacks = ensureDocRepresentedInPickStacks(
640
437
  hydrated.stacks,
641
438
  /** @type {Record<string, unknown>} */ (doc),
@@ -650,9 +447,7 @@ export async function runStackViewCommand(opts) {
650
447
 
651
448
  let running = true;
652
449
  while (running) {
653
- if (tuiSession) {
654
- clearInkScreen();
655
- }
450
+ if (tuiSession) clearInkScreen();
656
451
  const exitPayload = createExitPayload();
657
452
  const { waitUntilExit } = render(
658
453
  React.createElement(StackInkApp, {
@@ -667,25 +462,16 @@ export async function runStackViewCommand(opts) {
667
462
  })
668
463
  );
669
464
  await waitUntilExit();
670
- // Give terminal mode a short moment to settle before readline prompts.
671
465
  await new Promise((r) => setTimeout(r, 25));
672
466
 
673
467
  const next = exitPayload.next;
674
- if (!next || next.type === "quit") {
675
- running = false;
676
- break;
677
- }
468
+ if (!next || next.type === "quit") { running = false; break; }
678
469
 
679
470
  if (next.type === "issue_comment") {
680
471
  try {
681
472
  const body = await questionLine(`New issue comment on PR #${next.prNumber} (empty=cancel): `);
682
473
  if (body.trim()) {
683
- await githubPostIssueComment(
684
- owner,
685
- repoName,
686
- /** @type {number} */ (next.prNumber),
687
- body.trim()
688
- );
474
+ await githubPostIssueComment(owner, repoName, /** @type {number} */ (next.prNumber), body.trim());
689
475
  }
690
476
  const refresh = createSpinner("Refreshing stack");
691
477
  refresh.start();
@@ -697,16 +483,27 @@ export async function runStackViewCommand(opts) {
697
483
  continue;
698
484
  }
699
485
 
486
+ if (next.type === "review_approve") {
487
+ try {
488
+ await githubPostPullReview(owner, repoName, /** @type {number} */ (next.prNumber), {
489
+ event: "APPROVE",
490
+ body: typeof next.body === "string" ? next.body : ""
491
+ });
492
+ const refresh = createSpinner("Refreshing stack");
493
+ refresh.start();
494
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
495
+ refresh.stop(`loaded ${rows.length} PR(s)`);
496
+ } catch (e) {
497
+ console.error(`Action failed: ${String(e?.message || e)}`);
498
+ }
499
+ continue;
500
+ }
501
+
700
502
  if (next.type === "review_reply") {
701
503
  try {
702
- const body = await questionLine(`Reply in review thread (empty=cancel): `);
504
+ const body = await questionLine("Reply in review thread (empty=cancel): ");
703
505
  if (body.trim()) {
704
- await githubPostPullReviewCommentReply(
705
- owner,
706
- repoName,
707
- /** @type {number} */ (next.commentId),
708
- body.trim()
709
- );
506
+ await githubPostPullReviewCommentReply(owner, repoName, /** @type {number} */ (next.commentId), body.trim());
710
507
  }
711
508
  const refresh = createSpinner("Refreshing stack");
712
509
  refresh.start();
@@ -722,9 +519,7 @@ export async function runStackViewCommand(opts) {
722
519
  try {
723
520
  const logins = await promptReviewers(owner, repoName, /** @type {number} */ (next.prNumber));
724
521
  if (logins.length) {
725
- await githubPostRequestedReviewers(owner, repoName, /** @type {number} */ (next.prNumber), {
726
- reviewers: logins
727
- });
522
+ await githubPostRequestedReviewers(owner, repoName, /** @type {number} */ (next.prNumber), { reviewers: logins });
728
523
  }
729
524
  const refresh = createSpinner("Refreshing stack");
730
525
  refresh.start();
@@ -743,23 +538,17 @@ export async function runStackViewCommand(opts) {
743
538
  const filePath = String(next.path || "");
744
539
  const commitId = String(next.commitId || "");
745
540
  const patchIdx = typeof next.patchLineIndex === "number" ? next.patchLineIndex : -1;
746
-
747
541
  const prRow = rows.find((r) => r.entry.pr_number === prNum);
748
542
  const fileObj = Array.isArray(prRow?.files) ? prRow.files.find((f) => f && String(f.filename) === filePath) : null;
749
543
  const pLines = fileObj && typeof fileObj.patch === "string" ? String(fileObj.patch).split("\n") : [];
750
544
  const mapped = pLines.length > 0 && patchIdx >= 0 ? diffLineToGitHub(pLines, patchIdx) : null;
751
-
752
545
  if (!mapped) {
753
546
  console.error(chalk.yellow("Cannot comment on this line (header or unmappable)."));
754
547
  } else {
755
548
  const body = await questionLine(`Comment on ${filePath}:${mapped.line} (${mapped.side}) (empty=cancel): `);
756
549
  if (body.trim()) {
757
550
  await githubPostPullReviewLineComment(owner, repoName, prNum, {
758
- body: body.trim(),
759
- commit_id: commitId,
760
- path: filePath,
761
- line: mapped.line,
762
- side: mapped.side
551
+ body: body.trim(), commit_id: commitId, path: filePath, line: mapped.line, side: mapped.side
763
552
  });
764
553
  }
765
554
  const refresh = createSpinner("Refreshing stack");
@@ -774,120 +563,58 @@ export async function runStackViewCommand(opts) {
774
563
  }
775
564
 
776
565
  if (next.type === "pick_stack") {
777
- const hydratedPick = await hydrateDiscoverableStacksForAlternatePicker(
778
- root,
779
- String(doc.repo_full_name || ""),
780
- owner,
781
- repoName,
782
- discoverableStacks,
783
- tuiSession
784
- );
566
+ const repoFullForPick = String(doc.repo_full_name || "");
567
+ const hydratedPick = await hydrateDiscoverableStacks(repoFullForPick, owner, repoName, discoverableStacks, tuiSession);
785
568
  discoverableStacks = ensureDocRepresentedInPickStacks(
786
569
  hydratedPick.stacks,
787
570
  /** @type {Record<string, unknown>} */ (doc),
788
571
  ref,
789
572
  hydratedPick.openPullNumbers
790
573
  );
791
- if (!discoverableStacks || discoverableStacks.length < 2) {
792
- continue;
793
- }
574
+ if (!discoverableStacks || discoverableStacks.length < 2) continue;
575
+
794
576
  const viewingTip = stackTipPrNumber(doc);
795
577
  const viewingHeadRef = typeof ref === "string" && ref.trim() ? ref.trim() : undefined;
796
578
  const hasBackFromViewer = !!(opts.allowBackToRepoPicker || opts.allowBackToReviewHub);
797
579
  const picked = await pickStackIndexWithInk(
798
580
  /** @type {{ tip_head_branch: string, tip_pr_number: number }[]} */ (discoverableStacks),
799
- {
800
- allowBackToRepo: hasBackFromViewer,
801
- escapeToRepo: hasBackFromViewer,
802
- viewingTipPrNumber: viewingTip != null ? viewingTip : undefined,
803
- viewingHeadRef
804
- }
581
+ { allowBackToRepo: hasBackFromViewer, escapeToRepo: hasBackFromViewer, viewingTipPrNumber: viewingTip ?? undefined, viewingHeadRef }
805
582
  );
806
583
  if (picked === STACK_PICK_BACK_TO_REPO) {
807
- if (opts.allowBackToReviewHub) {
808
- throw new ReviewHubBackError();
809
- }
810
- if (opts.allowBackToRepoPicker) {
811
- throw new RepoPickerBackError();
812
- }
813
- console.error(
814
- chalk.dim("Backspace: repo list is only available when you start with `nugit view` (no args) from a TTY.")
815
- );
816
- continue;
817
- }
818
- if (!picked) {
584
+ if (opts.allowBackToReviewHub) throw new ReviewHubBackError();
585
+ if (opts.allowBackToRepoPicker) throw new RepoPickerBackError();
819
586
  continue;
820
587
  }
588
+ if (!picked) continue;
589
+
821
590
  ref = picked.tip_head_branch;
822
- const pickedIsInferred = !!(picked.inferredOnly || picked.inferredFromViewerDoc);
823
- let pickedDoc = null;
824
- if (pickedIsInferred && Array.isArray(picked.prs)) {
825
- const prNums = picked.prs
826
- .filter((p) => p && typeof p === "object" && typeof p.pr_number === "number")
827
- .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
828
- .map((p) => p.pr_number);
829
- if (prNums.length > 0) {
830
- pickedDoc = createInferredStackDoc(
831
- String(doc.repo_full_name || `${owner}/${repoName}`),
832
- String(picked.created_by || ""),
833
- prNums
834
- );
835
- }
836
- }
591
+ const prNums = Array.isArray(picked.prs)
592
+ ? picked.prs.filter((p) => p && typeof p === "object" && typeof p.pr_number === "number").sort((a, b) => (a.position ?? 0) - (b.position ?? 0)).map((p) => p.pr_number)
593
+ : [];
594
+ const pickedDoc = prNums.length > 0
595
+ ? createInferredStackDoc(String(doc.repo_full_name || `${owner}/${repoName}`), String(picked.created_by || ""), prNums)
596
+ : null;
597
+
837
598
  try {
838
- if (tuiSession) {
839
- await withStackLoadInkScreen("Loading stack…", async () => {
840
- if (pickedDoc) {
841
- doc = pickedDoc;
842
- } else {
843
- const loaded = await loadStackDocForView({
844
- root,
845
- repo,
846
- ref,
847
- file: opts.file
848
- });
849
- doc = loaded.doc;
850
- }
851
- const { owner: oPick, repo: rPick } = parseRepoFullName(doc.repo_full_name);
852
- rows = await fetchStackRowsWithAutoapply(
853
- oPick,
854
- rPick,
855
- doc,
856
- mergedReviewFetch,
857
- !!opts.reviewAutoapply
858
- );
859
- owner = oPick;
860
- repoName = rPick;
861
- });
862
- } else {
599
+ const reloadStack = async () => {
863
600
  if (pickedDoc) {
864
601
  doc = pickedDoc;
865
602
  } else {
866
- const loaded = await loadStackDocForView({
867
- root,
868
- repo,
869
- ref,
870
- file: opts.file
871
- });
872
- doc = loaded.doc;
603
+ const { doc: inferred } = await inferStackDocForRemoteView(`${owner}/${repoName}`, { preselectedChainIndex: 0 });
604
+ doc = inferred;
873
605
  }
874
606
  const { owner: oPick, repo: rPick } = parseRepoFullName(doc.repo_full_name);
875
- const reloadPick = createSpinner("Loading stack");
876
- reloadPick.start();
877
- rows = await fetchStackRowsWithAutoapply(
878
- oPick,
879
- rPick,
880
- doc,
881
- mergedReviewFetch,
882
- !!opts.reviewAutoapply
883
- );
884
- reloadPick.stop(`loaded ${rows.length} PR(s)`);
607
+ rows = await fetchStackRowsWithAutoapply(oPick, rPick, doc, mergedReviewFetch, !!opts.reviewAutoapply);
885
608
  owner = oPick;
886
609
  repoName = rPick;
610
+ };
611
+ if (tuiSession) {
612
+ await withStackLoadInkScreen("Loading stack…", reloadStack);
613
+ } else {
614
+ await reloadStack();
887
615
  }
888
616
  } catch (e) {
889
- console.error(`Could not load stack: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
890
- continue;
617
+ console.error(`Could not load stack: ${String(e?.message || e)}`);
891
618
  }
892
619
  continue;
893
620
  }
@@ -911,14 +638,14 @@ export async function runStackViewCommand(opts) {
911
638
  rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
912
639
  refresh.stop(`loaded ${rows.length} PR(s)`);
913
640
  } catch (e) {
914
- console.error(`Review submit failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
641
+ console.error(`Review submit failed: ${String(e?.message || e)}`);
915
642
  }
916
643
  continue;
917
644
  }
918
645
 
919
646
  if (next.type === "submit_review_changes") {
920
647
  try {
921
- const body = await questionLine(`Request changes — comment (required, empty=cancel): `);
648
+ const body = await questionLine("Request changes — comment (required, empty=cancel): ");
922
649
  if (body.trim()) {
923
650
  await githubPostPullReview(owner, repoName, /** @type {number} */ (next.prNumber), {
924
651
  event: "REQUEST_CHANGES",
@@ -930,7 +657,7 @@ export async function runStackViewCommand(opts) {
930
657
  refresh.stop(`loaded ${rows.length} PR(s)`);
931
658
  }
932
659
  } catch (e) {
933
- console.error(`Review submit failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
660
+ console.error(`Review submit failed: ${String(e?.message || e)}`);
934
661
  }
935
662
  continue;
936
663
  }
@@ -947,25 +674,20 @@ export async function runStackViewCommand(opts) {
947
674
  const headRef = typeof head.ref === "string" ? head.ref : "";
948
675
  const hr = head.repo && typeof head.repo === "object" ? head.repo : {};
949
676
  const cloneUrl = typeof hr.clone_url === "string" ? hr.clone_url : "";
950
- if (!cloneUrl || !headRef) {
951
- throw new Error("Missing clone URL or head ref");
952
- }
677
+ if (!cloneUrl || !headRef) throw new Error("Missing clone URL or head ref");
953
678
  const safeName = `${owner}-${repoName}-${headRef}`.replace(/[^a-zA-Z0-9._-]+/g, "_");
954
679
  const dest = path.join(os.tmpdir(), `nugit-review-${safeName}`);
955
- const url =
956
- tok && cloneUrl.startsWith("https://")
957
- ? cloneUrl.replace("https://", `https://x-access-token:${tok}@`)
958
- : cloneUrl;
680
+ const url = tok && cloneUrl.startsWith("https://")
681
+ ? cloneUrl.replace("https://", `https://x-access-token:${tok}@`)
682
+ : cloneUrl;
959
683
  if (fs.existsSync(dest)) {
960
684
  console.error(chalk.yellow(`Directory exists, skipping clone: ${dest}`));
961
685
  } else {
962
- execFileSync("git", ["clone", "--depth", "1", "--branch", headRef, url, dest], {
963
- stdio: "inherit"
964
- });
686
+ execFileSync("git", ["clone", "--depth", "1", "--branch", headRef, url, dest], { stdio: "inherit" });
965
687
  console.error(chalk.green(`Cloned to ${dest}`));
966
688
  }
967
689
  } catch (e) {
968
- console.error(`Clone failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
690
+ console.error(`Clone failed: ${String(e?.message || e)}`);
969
691
  }
970
692
  continue;
971
693
  }
@@ -973,27 +695,28 @@ export async function runStackViewCommand(opts) {
973
695
  if (next.type === "split") {
974
696
  try {
975
697
  const r = findGitRoot();
976
- if (!r) {
977
- throw new Error("Not inside a git repository");
978
- }
698
+ if (!r) throw new Error("Not inside a git repository");
979
699
  const { runSplitCommand } = await import("../split-view/run-split.js");
980
- await runSplitCommand({
700
+ const splitResult = await runSplitCommand({
981
701
  root: r,
982
702
  owner,
983
703
  repo: repoName,
984
704
  prNumber: /** @type {number} */ (next.prNumber),
985
705
  dryRun: false
986
706
  });
987
- const refreshed = readStackFile(r);
988
- if (refreshed) {
989
- doc = refreshed;
707
+ // Refresh viewer doc with the new inferred stack (post-split)
708
+ if (splitResult?.newPrNumbers?.length) {
709
+ const { authMe } = await import("../api-client.js");
710
+ const me = await authMe();
711
+ const login = me && typeof me.login === "string" ? me.login : "viewer";
712
+ doc = createInferredStackDoc(`${owner}/${repoName}`, login, splitResult.newPrNumbers);
990
713
  }
991
714
  const reload = createSpinner("Reloading stack");
992
715
  reload.start();
993
716
  rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
994
717
  reload.stop(`loaded ${rows.length} PR(s)`);
995
718
  } catch (e) {
996
- console.error(`Split failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
719
+ console.error(`Split failed: ${String(e?.message || e)}`);
997
720
  }
998
721
  continue;
999
722
  }