nugit-cli 0.0.1 → 0.1.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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -11
  3. package/src/github-device-flow.js +1 -1
  4. package/src/github-oauth-client-id.js +11 -0
  5. package/src/github-pr-social.js +42 -0
  6. package/src/github-rest.js +114 -6
  7. package/src/nugit-stack.js +20 -0
  8. package/src/nugit-start.js +4 -4
  9. package/src/nugit.js +37 -22
  10. package/src/review-hub/review-autoapprove.js +95 -0
  11. package/src/review-hub/review-hub-back.js +10 -0
  12. package/src/review-hub/review-hub-ink.js +166 -0
  13. package/src/review-hub/run-review-hub.js +188 -0
  14. package/src/split-view/run-split.js +16 -3
  15. package/src/split-view/split-ink.js +2 -2
  16. package/src/stack-discover.js +9 -1
  17. package/src/stack-infer-from-prs.js +71 -0
  18. package/src/stack-view/diff-line-map.js +62 -0
  19. package/src/stack-view/fetch-pr-data.js +104 -4
  20. package/src/stack-view/infer-chains-to-pick-stacks.js +70 -0
  21. package/src/stack-view/ink-app.js +1853 -156
  22. package/src/stack-view/loading-ink.js +44 -0
  23. package/src/stack-view/merge-alternate-pick-stacks.js +223 -0
  24. package/src/stack-view/patch-preview-merge.js +108 -0
  25. package/src/stack-view/remote-infer-doc.js +93 -0
  26. package/src/stack-view/repo-picker-back.js +10 -0
  27. package/src/stack-view/run-stack-view.js +685 -50
  28. package/src/stack-view/run-view-entry.js +119 -0
  29. package/src/stack-view/sgr-mouse.js +56 -0
  30. package/src/stack-view/stack-branch-graph.js +95 -0
  31. package/src/stack-view/stack-pick-graph.js +93 -0
  32. package/src/stack-view/stack-pick-ink.js +270 -0
  33. package/src/stack-view/stack-pick-layout.js +19 -0
  34. package/src/stack-view/stack-pick-sort.js +188 -0
  35. package/src/stack-view/terminal-fullscreen.js +45 -0
  36. package/src/stack-view/tree-ascii.js +73 -0
  37. package/src/stack-view/view-md-plain.js +23 -0
  38. package/src/stack-view/view-repo-picker-ink.js +293 -0
  39. package/src/stack-view/view-tui-sequential.js +126 -0
@@ -1,23 +1,98 @@
1
+ import fs from "fs";
1
2
  import React from "react";
2
3
  import { render } from "ink";
3
4
  import chalk from "chalk";
5
+ import { resolveGithubToken } from "../auth-token.js";
4
6
  import {
5
7
  githubListAssignableUsers,
6
8
  githubPostIssueComment,
9
+ githubPostPullReview,
7
10
  githubPostPullReviewCommentReply,
8
- githubPostRequestedReviewers
11
+ githubPostRequestedReviewers,
12
+ githubPostPullReviewLineComment
9
13
  } from "../github-pr-social.js";
10
- import { findGitRoot, parseRepoFullName, readStackFile } from "../nugit-stack.js";
14
+ import {
15
+ findGitRoot,
16
+ parseRepoFullName,
17
+ readStackFile,
18
+ validateStackDoc,
19
+ createInferredStackDoc
20
+ } from "../nugit-stack.js";
11
21
  import { getRepoFullNameFromGitRoot } from "../git-info.js";
12
- import { discoverStacksInRepo } from "../stack-discover.js";
22
+ import { discoverStacksInRepo, stackTipPrNumber } from "../stack-discover.js";
13
23
  import { getStackDiscoveryOpts, effectiveMaxOpenPrs } from "../stack-discovery-config.js";
14
24
  import { tryLoadStackIndex, writeStackIndex } from "../stack-graph.js";
15
- import { formatStacksListHuman } from "../cli-output.js";
16
25
  import { fetchStackPrDetails } from "./fetch-pr-data.js";
17
26
  import { loadStackDocForView } from "./loader.js";
27
+ import { inferStackDocForRemoteView, isGithubNotFoundError } from "./remote-infer-doc.js";
28
+ import { getRepoMetadata } from "../api-client.js";
18
29
  import { StackInkApp, createExitPayload } from "./ink-app.js";
19
30
  import { renderStaticStackView } from "./static-render.js";
20
31
  import { questionLine } from "./prompt-line.js";
32
+ import {
33
+ loadReviewAutoapproveConfig,
34
+ isRepoHeadAutoapproveEligible
35
+ } from "../review-hub/review-autoapprove.js";
36
+ import {
37
+ pickStackIndexWithInk,
38
+ pickInferChainIndexWithInk,
39
+ STACK_PICK_BACK_TO_REPO
40
+ } from "./view-tui-sequential.js";
41
+ import { clearInkScreen } from "./terminal-fullscreen.js";
42
+ import { RepoPickerBackError } from "./repo-picker-back.js";
43
+ import { ReviewHubBackError } from "../review-hub/review-hub-back.js";
44
+ import {
45
+ augmentAlternatePickStacksWithInfer,
46
+ ensureDocRepresentedInPickStacks
47
+ } from "./merge-alternate-pick-stacks.js";
48
+ import { withStackLoadInkScreen } from "./loading-ink.js";
49
+
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
+ }
21
96
 
22
97
  function createSpinner(prefix) {
23
98
  const frames = [chalk.cyan("⠋"), chalk.cyan("⠙"), chalk.cyan("⠹"), chalk.cyan("⠸"), chalk.cyan("⠼"), chalk.cyan("⠴")];
@@ -145,18 +220,125 @@ async function promptReviewers(owner, repo, prNumber) {
145
220
  return [...new Set(chosen)];
146
221
  }
147
222
 
223
+ /**
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
+ * @param {string} owner
231
+ * @param {string} repoName
232
+ * @param {unknown[] | null} current
233
+ * @param {boolean} tuiSession
234
+ * @returns {Promise<{ stacks: unknown[] | null, openPullNumbers: Set<number> | null }>}
235
+ */
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 });
246
+
247
+ if (!repoFull || !resolveGithubToken()) {
248
+ return wrap(current);
249
+ }
250
+
251
+ if (current && Array.isArray(current) && current.length > 1) {
252
+ return augmentAlternatePickStacksWithInfer(owner, repoName, current);
253
+ }
254
+
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
+ }
262
+
263
+ const discovery = getStackDiscoveryOpts();
264
+ if (discovery.mode === "manual") {
265
+ return augmentAlternatePickStacksWithInfer(owner, repoName, Array.isArray(current) ? current : []);
266
+ }
267
+
268
+ const full =
269
+ process.env.NUGIT_STACK_DISCOVERY_FULL === "1" || process.env.NUGIT_STACK_DISCOVERY_FULL === "true";
270
+ const spinner = createSpinner("Scanning stacks");
271
+ if (!tuiSession) {
272
+ spinner.start();
273
+ }
274
+ 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
+ });
281
+ if (!tuiSession) {
282
+ spinner.stop(
283
+ `found ${discovered.stacks_found} stack(s) across ${discovered.scanned_open_prs} open PR(s)`
284
+ );
285
+ }
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, []);
302
+ } catch {
303
+ if (!tuiSession) {
304
+ spinner.stop("scan failed");
305
+ }
306
+ return augmentAlternatePickStacksWithInfer(owner, repoName, Array.isArray(current) ? current : []);
307
+ }
308
+ }
309
+
148
310
  /**
149
311
  * @param {object} opts
150
312
  * @param {boolean} [opts.noTui]
151
313
  * @param {string} [opts.repo]
152
314
  * @param {string} [opts.ref]
153
315
  * @param {string} [opts.file]
316
+ * @param {Record<string, unknown>} [opts.explicitDoc] skip discovery / stack.json load
317
+ * @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)
154
323
  */
155
324
  export async function runStackViewCommand(opts) {
325
+ const tuiSession = !opts.noTui && process.stdin.isTTY && process.stdout.isTTY;
326
+ /** @type {unknown[] | null} Re-open stack picker (Backspace) when length > 1 */
327
+ let discoverableStacks = null;
328
+
329
+ if (!resolveGithubToken()) {
330
+ console.error(
331
+ 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."
333
+ )
334
+ );
335
+ }
156
336
  const root = findGitRoot();
157
337
  let repo = opts.repo;
158
338
  let ref = opts.ref;
159
- if (!opts.file && !ref) {
339
+ let explicitDoc = opts.explicitDoc || null;
340
+
341
+ if (!explicitDoc && !opts.file && !ref) {
160
342
  let repoFull = repo || null;
161
343
  if (!repoFull && root) {
162
344
  try {
@@ -187,64 +369,279 @@ export async function runStackViewCommand(opts) {
187
369
  usedCache = true;
188
370
  }
189
371
  }
190
- if (!discovered) {
191
- const spinner = createSpinner("Scanning stacks");
192
- spinner.start();
193
- discovered = await discoverStacksInRepo(owner, repoName, {
194
- maxOpenPrs: effectiveMaxOpenPrs(discovery, full),
195
- enrich: false,
196
- fetchConcurrency: discovery.fetchConcurrency,
197
- onProgress: (m) => spinner.update(m)
372
+ const discoverOpts = {
373
+ maxOpenPrs: effectiveMaxOpenPrs(discovery, full),
374
+ enrich: false,
375
+ fetchConcurrency: discovery.fetchConcurrency
376
+ };
377
+ /** @type {object[]} */
378
+ let mergedForPick = [];
379
+
380
+ 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;
198
400
  });
199
- spinner.stop(
200
- `found ${discovered.stacks_found} stack(s) across ${discovered.scanned_open_prs} open PR(s)`
201
- );
202
- if (root) {
203
- try {
204
- if (discovery.mode === "eager" || discovery.mode === "lazy") {
205
- writeStackIndex(root, discovered);
401
+ } 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 */
206
419
  }
207
- } catch {
208
- /* ignore index write */
209
420
  }
421
+ } else if (usedCache) {
422
+ console.error(chalk.dim("Using .nugit/stack-index.json — set NUGIT_STACK_DISCOVERY_FULL=1 to rescan GitHub."));
210
423
  }
211
- } else if (usedCache) {
212
- console.error(chalk.dim("Using .nugit/stack-index.json set NUGIT_STACK_DISCOVERY_FULL=1 to rescan GitHub."));
424
+ const rawStacks = discovered && Array.isArray(discovered.stacks) ? discovered.stacks : [];
425
+ const aug = await augmentAlternatePickStacksWithInfer(owner, repoName, rawStacks);
426
+ mergedForPick = aug.stacks;
213
427
  }
214
- if (discovered.stacks_found > 1) {
428
+ if (mergedForPick.length > 1) {
215
429
  if (opts.noTui) {
216
- console.log(formatStacksListHuman(discovered));
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
+ );
436
+ console.log("");
437
+ for (const s of mergedForPick) {
438
+ const tag =
439
+ s && typeof s === "object" && /** @type {{ inferredOnly?: boolean }} */ (s).inferredOnly
440
+ ? chalk.dim(" (inferred)")
441
+ : "";
442
+ 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
+ );
446
+ }
447
+ console.log("");
448
+ console.log(chalk.dim("Re-run with a TTY to pick interactively, or pass --ref to a stack tip branch."));
217
449
  return;
218
450
  }
219
- const idx = await pickDiscoveredStackIndex(discovered.stacks);
220
- if (idx < 0) {
451
+ const useInkPick =
452
+ !opts.noTui && process.stdin.isTTY && process.stdout.isTTY;
453
+ 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 {
475
+ const idx = await pickDiscoveredStackIndex(
476
+ /** @type {{ tip_head_branch: string, tip_pr_number: number, pr_count: number, created_by: string, prs: unknown[] }[]} */ (
477
+ mergedForPick
478
+ )
479
+ );
480
+ if (idx < 0) {
481
+ console.error("Cancelled.");
482
+ return;
483
+ }
484
+ pickedStack = mergedForPick[idx];
485
+ }
486
+ if (!pickedStack) {
487
+ if (opts.allowBackToReviewHub) {
488
+ throw new ReviewHubBackError();
489
+ }
490
+ if (opts.allowBackToRepoPicker) {
491
+ throw new RepoPickerBackError();
492
+ }
221
493
  console.error("Cancelled.");
222
494
  return;
223
495
  }
224
496
  repo = repoFull;
225
- ref = discovered.stacks[idx].tip_head_branch;
226
- console.error(`Viewing selected stack tip: ${repo}@${ref}`);
227
- }
228
- if (discovered.stacks_found === 1) {
497
+ 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}`);
511
+ }
512
+ } else if (mergedForPick.length === 1) {
229
513
  repo = repoFull;
230
- ref = discovered.stacks[0].tip_head_branch;
231
- console.error(`Viewing discovered stack tip: ${repo}@${ref}`);
514
+ const only = /** @type {{ tip_head_branch: string, inferredOnly?: boolean, inferredFromViewerDoc?: boolean, created_by?: string, prs?: { pr_number: number, position?: number }[] }} */ (mergedForPick[0]);
515
+ ref = only.tip_head_branch;
516
+ 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}`);
530
+ }
531
+ }
532
+ }
533
+ }
534
+
535
+ if (!ref && repo && !explicitDoc) {
536
+ const { owner: dbO, repo: dbR } = parseRepoFullName(repo);
537
+ const dbMeta = await getRepoMetadata(dbO, dbR);
538
+ ref = typeof dbMeta.default_branch === "string" ? dbMeta.default_branch : "main";
539
+ }
540
+
541
+ if (explicitDoc && repo && !opts.reviewFetchOpts?.defaultBranch) {
542
+ try {
543
+ const { owner: dbO, repo: dbR } = parseRepoFullName(repo);
544
+ const dbMeta = await getRepoMetadata(dbO, dbR);
545
+ const db = typeof dbMeta.default_branch === "string" ? dbMeta.default_branch : "main";
546
+ opts = { ...opts, reviewFetchOpts: { ...(opts.reviewFetchOpts || {}), defaultBranch: db } };
547
+ } catch { /* non-critical */ }
548
+ }
549
+
550
+ let doc;
551
+ /** @type {{ viewerLogin?: string, defaultBranch?: string, fullReviewFetch?: boolean }} */
552
+ let mergedReviewFetch = { ...(opts.reviewFetchOpts || {}) };
553
+
554
+ if (explicitDoc) {
555
+ doc = explicitDoc;
556
+ validateStackDoc(doc);
557
+ } 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;
232
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
+ };
603
+ }
604
+ }
605
+
606
+ let { owner, repo: repoName } = parseRepoFullName(doc.repo_full_name);
607
+ let rows;
608
+ const loadRowsOnce = async () => {
609
+ rows = await fetchStackRowsWithAutoapply(
610
+ owner,
611
+ repoName,
612
+ doc,
613
+ mergedReviewFetch,
614
+ !!opts.reviewAutoapply
615
+ );
616
+ };
617
+ if (tuiSession) {
618
+ await withStackLoadInkScreen("Loading stack…", loadRowsOnce);
619
+ } else {
620
+ const loadSpinner = createSpinner("Loading stack");
621
+ if (!tuiSession) {
622
+ loadSpinner.start();
623
+ }
624
+ await loadRowsOnce();
625
+ if (!tuiSession) {
626
+ loadSpinner.stop(`loaded ${rows.length} PR(s)`);
233
627
  }
234
628
  }
235
629
 
236
- let { doc } = await loadStackDocForView({
630
+ const repoFullStr = String(doc.repo_full_name || "");
631
+ const hydrated = await hydrateDiscoverableStacksForAlternatePicker(
237
632
  root,
238
- repo,
633
+ repoFullStr,
634
+ owner,
635
+ repoName,
636
+ discoverableStacks,
637
+ tuiSession
638
+ );
639
+ discoverableStacks = ensureDocRepresentedInPickStacks(
640
+ hydrated.stacks,
641
+ /** @type {Record<string, unknown>} */ (doc),
239
642
  ref,
240
- file: opts.file
241
- });
242
-
243
- const { owner, repo: repoName } = parseRepoFullName(doc.repo_full_name);
244
- const loadSpinner = createSpinner("Loading stack");
245
- loadSpinner.start();
246
- let rows = await fetchStackPrDetails(owner, repoName, doc.prs);
247
- loadSpinner.stop(`loaded ${rows.length} PR(s)`);
643
+ hydrated.openPullNumbers
644
+ );
248
645
 
249
646
  if (opts.noTui) {
250
647
  renderStaticStackView(rows);
@@ -253,9 +650,21 @@ export async function runStackViewCommand(opts) {
253
650
 
254
651
  let running = true;
255
652
  while (running) {
653
+ if (tuiSession) {
654
+ clearInkScreen();
655
+ }
256
656
  const exitPayload = createExitPayload();
257
657
  const { waitUntilExit } = render(
258
- React.createElement(StackInkApp, { rows, exitPayload })
658
+ React.createElement(StackInkApp, {
659
+ rows,
660
+ exitPayload,
661
+ viewTitle: opts.viewTitle || "nugit view",
662
+ browseOwner: owner,
663
+ browseRepo: repoName,
664
+ repoFullName: String(doc.repo_full_name || ""),
665
+ shellMode: opts.shellMode !== false,
666
+ alternateStacks: discoverableStacks
667
+ })
259
668
  );
260
669
  await waitUntilExit();
261
670
  // Give terminal mode a short moment to settle before readline prompts.
@@ -280,7 +689,7 @@ export async function runStackViewCommand(opts) {
280
689
  }
281
690
  const refresh = createSpinner("Refreshing stack");
282
691
  refresh.start();
283
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
692
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
284
693
  refresh.stop(`loaded ${rows.length} PR(s)`);
285
694
  } catch (e) {
286
695
  console.error(`Action failed: ${String(e?.message || e)}`);
@@ -301,7 +710,7 @@ export async function runStackViewCommand(opts) {
301
710
  }
302
711
  const refresh = createSpinner("Refreshing stack");
303
712
  refresh.start();
304
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
713
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
305
714
  refresh.stop(`loaded ${rows.length} PR(s)`);
306
715
  } catch (e) {
307
716
  console.error(`Action failed: ${String(e?.message || e)}`);
@@ -319,7 +728,7 @@ export async function runStackViewCommand(opts) {
319
728
  }
320
729
  const refresh = createSpinner("Refreshing stack");
321
730
  refresh.start();
322
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
731
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
323
732
  refresh.stop(`loaded ${rows.length} PR(s)`);
324
733
  } catch (e) {
325
734
  console.error(`Action failed: ${String(e?.message || e)}`);
@@ -327,14 +736,240 @@ export async function runStackViewCommand(opts) {
327
736
  continue;
328
737
  }
329
738
 
739
+ if (next.type === "pull_review_line_comment") {
740
+ try {
741
+ const { diffLineToGitHub } = await import("./diff-line-map.js");
742
+ const prNum = /** @type {number} */ (next.prNumber);
743
+ const filePath = String(next.path || "");
744
+ const commitId = String(next.commitId || "");
745
+ const patchIdx = typeof next.patchLineIndex === "number" ? next.patchLineIndex : -1;
746
+
747
+ const prRow = rows.find((r) => r.entry.pr_number === prNum);
748
+ const fileObj = Array.isArray(prRow?.files) ? prRow.files.find((f) => f && String(f.filename) === filePath) : null;
749
+ const pLines = fileObj && typeof fileObj.patch === "string" ? String(fileObj.patch).split("\n") : [];
750
+ const mapped = pLines.length > 0 && patchIdx >= 0 ? diffLineToGitHub(pLines, patchIdx) : null;
751
+
752
+ if (!mapped) {
753
+ console.error(chalk.yellow("Cannot comment on this line (header or unmappable)."));
754
+ } else {
755
+ const body = await questionLine(`Comment on ${filePath}:${mapped.line} (${mapped.side}) (empty=cancel): `);
756
+ if (body.trim()) {
757
+ await githubPostPullReviewLineComment(owner, repoName, prNum, {
758
+ body: body.trim(),
759
+ commit_id: commitId,
760
+ path: filePath,
761
+ line: mapped.line,
762
+ side: mapped.side
763
+ });
764
+ }
765
+ const refresh = createSpinner("Refreshing stack");
766
+ refresh.start();
767
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
768
+ refresh.stop(`loaded ${rows.length} PR(s)`);
769
+ }
770
+ } catch (e) {
771
+ console.error(`Action failed: ${String(e?.message || e)}`);
772
+ }
773
+ continue;
774
+ }
775
+
776
+ 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
+ );
785
+ discoverableStacks = ensureDocRepresentedInPickStacks(
786
+ hydratedPick.stacks,
787
+ /** @type {Record<string, unknown>} */ (doc),
788
+ ref,
789
+ hydratedPick.openPullNumbers
790
+ );
791
+ if (!discoverableStacks || discoverableStacks.length < 2) {
792
+ continue;
793
+ }
794
+ const viewingTip = stackTipPrNumber(doc);
795
+ const viewingHeadRef = typeof ref === "string" && ref.trim() ? ref.trim() : undefined;
796
+ const hasBackFromViewer = !!(opts.allowBackToRepoPicker || opts.allowBackToReviewHub);
797
+ const picked = await pickStackIndexWithInk(
798
+ /** @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
+ }
805
+ );
806
+ 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) {
819
+ continue;
820
+ }
821
+ 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
+ }
837
+ 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 {
863
+ if (pickedDoc) {
864
+ doc = pickedDoc;
865
+ } else {
866
+ const loaded = await loadStackDocForView({
867
+ root,
868
+ repo,
869
+ ref,
870
+ file: opts.file
871
+ });
872
+ doc = loaded.doc;
873
+ }
874
+ 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)`);
885
+ owner = oPick;
886
+ repoName = rPick;
887
+ }
888
+ } catch (e) {
889
+ console.error(`Could not load stack: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
890
+ continue;
891
+ }
892
+ continue;
893
+ }
894
+
330
895
  if (next.type === "refresh") {
331
896
  const refresh = createSpinner("Refreshing stack");
332
897
  refresh.start();
333
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
898
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
334
899
  refresh.stop(`loaded ${rows.length} PR(s)`);
335
900
  continue;
336
901
  }
337
902
 
903
+ if (next.type === "submit_review") {
904
+ try {
905
+ await githubPostPullReview(owner, repoName, /** @type {number} */ (next.prNumber), {
906
+ event: String(next.event || "COMMENT"),
907
+ body: typeof next.body === "string" ? next.body : ""
908
+ });
909
+ const refresh = createSpinner("Refreshing stack");
910
+ refresh.start();
911
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
912
+ refresh.stop(`loaded ${rows.length} PR(s)`);
913
+ } catch (e) {
914
+ console.error(`Review submit failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
915
+ }
916
+ continue;
917
+ }
918
+
919
+ if (next.type === "submit_review_changes") {
920
+ try {
921
+ const body = await questionLine(`Request changes — comment (required, empty=cancel): `);
922
+ if (body.trim()) {
923
+ await githubPostPullReview(owner, repoName, /** @type {number} */ (next.prNumber), {
924
+ event: "REQUEST_CHANGES",
925
+ body: body.trim()
926
+ });
927
+ const refresh = createSpinner("Refreshing stack");
928
+ refresh.start();
929
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
930
+ refresh.stop(`loaded ${rows.length} PR(s)`);
931
+ }
932
+ } catch (e) {
933
+ console.error(`Review submit failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
934
+ }
935
+ continue;
936
+ }
937
+
938
+ if (next.type === "materialize_clone") {
939
+ try {
940
+ const { execFileSync } = await import("child_process");
941
+ const os = await import("os");
942
+ const path = await import("path");
943
+ const tok = resolveGithubToken();
944
+ const prRow = rows.find((r) => r.entry.pr_number === next.prNumber);
945
+ const pull = prRow?.pull;
946
+ const head = pull?.head && typeof pull.head === "object" ? pull.head : {};
947
+ const headRef = typeof head.ref === "string" ? head.ref : "";
948
+ const hr = head.repo && typeof head.repo === "object" ? head.repo : {};
949
+ 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
+ }
953
+ const safeName = `${owner}-${repoName}-${headRef}`.replace(/[^a-zA-Z0-9._-]+/g, "_");
954
+ 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;
959
+ if (fs.existsSync(dest)) {
960
+ console.error(chalk.yellow(`Directory exists, skipping clone: ${dest}`));
961
+ } else {
962
+ execFileSync("git", ["clone", "--depth", "1", "--branch", headRef, url, dest], {
963
+ stdio: "inherit"
964
+ });
965
+ console.error(chalk.green(`Cloned to ${dest}`));
966
+ }
967
+ } catch (e) {
968
+ console.error(`Clone failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
969
+ }
970
+ continue;
971
+ }
972
+
338
973
  if (next.type === "split") {
339
974
  try {
340
975
  const r = findGitRoot();
@@ -355,7 +990,7 @@ export async function runStackViewCommand(opts) {
355
990
  }
356
991
  const reload = createSpinner("Reloading stack");
357
992
  reload.start();
358
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
993
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
359
994
  reload.stop(`loaded ${rows.length} PR(s)`);
360
995
  } catch (e) {
361
996
  console.error(`Split failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);