nugit-cli 0.0.1 → 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 (58) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -23
  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 +149 -6
  7. package/src/nugit-config.js +84 -0
  8. package/src/nugit-stack.js +40 -257
  9. package/src/nugit.js +104 -647
  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 +169 -0
  13. package/src/review-hub/run-review-hub.js +131 -0
  14. package/src/services/repo-branches.js +151 -0
  15. package/src/services/stack-inference.js +90 -0
  16. package/src/split-view/run-split.js +14 -76
  17. package/src/split-view/split-ink.js +2 -2
  18. package/src/stack-infer-from-prs.js +71 -0
  19. package/src/stack-view/diff-line-map.js +62 -0
  20. package/src/stack-view/fetch-pr-data.js +104 -4
  21. package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
  22. package/src/stack-view/ink-app.js +3 -421
  23. package/src/stack-view/loader.js +19 -93
  24. package/src/stack-view/loading-ink.js +2 -0
  25. package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
  26. package/src/stack-view/patch-preview-merge.js +108 -0
  27. package/src/stack-view/remote-infer-doc.js +76 -0
  28. package/src/stack-view/repo-picker-back.js +10 -0
  29. package/src/stack-view/run-stack-view.js +508 -150
  30. package/src/stack-view/run-view-entry.js +115 -0
  31. package/src/stack-view/sgr-mouse.js +56 -0
  32. package/src/stack-view/stack-branch-graph.js +95 -0
  33. package/src/stack-view/stack-pick-graph.js +93 -0
  34. package/src/stack-view/stack-pick-ink.js +308 -0
  35. package/src/stack-view/stack-pick-layout.js +19 -0
  36. package/src/stack-view/stack-pick-sort.js +188 -0
  37. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  38. package/src/stack-view/terminal-fullscreen.js +7 -0
  39. package/src/stack-view/tree-ascii.js +73 -0
  40. package/src/stack-view/view-md-plain.js +23 -0
  41. package/src/stack-view/view-repo-picker-ink.js +293 -0
  42. package/src/stack-view/view-tui-sequential.js +126 -0
  43. package/src/tui/pages/home.js +122 -0
  44. package/src/tui/pages/repo-actions.js +81 -0
  45. package/src/tui/pages/repo-branches.js +259 -0
  46. package/src/tui/pages/viewer.js +2129 -0
  47. package/src/tui/router.js +40 -0
  48. package/src/tui/run-tui.js +281 -0
  49. package/src/utilities/loading.js +37 -0
  50. package/src/utilities/terminal.js +31 -0
  51. package/src/cli-output.js +0 -228
  52. package/src/nugit-start.js +0 -211
  53. package/src/stack-discover.js +0 -284
  54. package/src/stack-discovery-config.js +0 -91
  55. package/src/stack-extra-commands.js +0 -353
  56. package/src/stack-graph.js +0 -214
  57. package/src/stack-helpers.js +0 -58
  58. package/src/stack-propagate.js +0 -422
@@ -1,23 +1,52 @@
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
+ createInferredStackDoc
18
+ } from "../nugit-stack.js";
11
19
  import { getRepoFullNameFromGitRoot } from "../git-info.js";
12
- import { discoverStacksInRepo } from "../stack-discover.js";
13
- import { getStackDiscoveryOpts, effectiveMaxOpenPrs } from "../stack-discovery-config.js";
14
- import { tryLoadStackIndex, writeStackIndex } from "../stack-graph.js";
15
- import { formatStacksListHuman } from "../cli-output.js";
16
20
  import { fetchStackPrDetails } from "./fetch-pr-data.js";
17
- import { loadStackDocForView } from "./loader.js";
21
+ import { inferStackDocForRemoteView } from "./remote-infer-doc.js";
22
+ import { getRepoMetadata } from "../api-client.js";
18
23
  import { StackInkApp, createExitPayload } from "./ink-app.js";
19
24
  import { renderStaticStackView } from "./static-render.js";
20
25
  import { questionLine } from "./prompt-line.js";
26
+ import {
27
+ loadReviewAutoapproveConfig,
28
+ isRepoHeadAutoapproveEligible
29
+ } from "../review-hub/review-autoapprove.js";
30
+ import {
31
+ pickStackIndexWithInk,
32
+ pickInferChainIndexWithInk,
33
+ STACK_PICK_BACK_TO_REPO
34
+ } from "./view-tui-sequential.js";
35
+ import { clearInkScreen } from "./terminal-fullscreen.js";
36
+ import { RepoPickerBackError } from "./repo-picker-back.js";
37
+ import { ReviewHubBackError } from "../review-hub/review-hub-back.js";
38
+ import {
39
+ augmentAlternatePickStacksWithInfer,
40
+ ensureDocRepresentedInPickStacks,
41
+ stackDocToPickStackRow
42
+ } from "./merge-alternate-pick-stacks.js";
43
+ import { stackTipPrNumber } from "./merge-alternate-pick-stacks.js";
44
+ import { withStackLoadInkScreen } from "./loading-ink.js";
45
+ import { discoverStacksByInference } from "../services/stack-inference.js";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Spinner helper (stderr, non-TUI)
49
+ // ---------------------------------------------------------------------------
21
50
 
22
51
  function createSpinner(prefix) {
23
52
  const frames = [chalk.cyan("⠋"), chalk.cyan("⠙"), chalk.cyan("⠹"), chalk.cyan("⠸"), chalk.cyan("⠼"), chalk.cyan("⠴")];
@@ -25,13 +54,9 @@ function createSpinner(prefix) {
25
54
  let msg = "starting...";
26
55
  let timer = null;
27
56
  return {
28
- update(nextMsg) {
29
- msg = nextMsg || msg;
30
- },
57
+ update(nextMsg) { msg = nextMsg || msg; },
31
58
  start() {
32
- if (!process.stderr.isTTY) {
33
- return;
34
- }
59
+ if (!process.stderr.isTTY) return;
35
60
  timer = setInterval(() => {
36
61
  const frame = frames[i % frames.length];
37
62
  i += 1;
@@ -40,24 +65,20 @@ function createSpinner(prefix) {
40
65
  },
41
66
  stop(finalMsg) {
42
67
  if (!process.stderr.isTTY) {
43
- if (finalMsg) {
44
- console.error(`${prefix} ${finalMsg}`);
45
- }
68
+ if (finalMsg) console.error(`${prefix} ${finalMsg}`);
46
69
  return;
47
70
  }
48
- if (timer) {
49
- clearInterval(timer);
50
- }
71
+ if (timer) clearInterval(timer);
51
72
  const done = finalMsg ? `${chalk.bold(prefix)} ${chalk.green(finalMsg)}` : "";
52
73
  process.stderr.write(`\r${done}${" ".repeat(30)}\n`);
53
74
  }
54
75
  };
55
76
  }
56
77
 
57
- /**
58
- * @param {{ tip_pr_number: number, tip_head_branch: string, pr_count: number, created_by: string }[]} stacks
59
- * @returns {Promise<number>}
60
- */
78
+ // ---------------------------------------------------------------------------
79
+ // Non-TUI stack picker (stdin prompt fallback)
80
+ // ---------------------------------------------------------------------------
81
+
61
82
  async function pickDiscoveredStackIndex(stacks) {
62
83
  const stackTree = (s) => {
63
84
  const prs = Array.isArray(s.prs) ? s.prs : [];
@@ -84,26 +105,18 @@ async function pickDiscoveredStackIndex(stacks) {
84
105
  );
85
106
  console.error(stackTree(s));
86
107
  }
87
- const ans = String(
88
- await questionLine(chalk.green("Select stack number (empty=cancel): "))
89
- ).trim();
90
- if (!ans) {
91
- return -1;
92
- }
108
+ const ans = String(await questionLine(chalk.green("Select stack number (empty=cancel): "))).trim();
109
+ if (!ans) return -1;
93
110
  const n = Number.parseInt(ans, 10);
94
- if (Number.isInteger(n) && n >= 1 && n <= stacks.length) {
95
- return n - 1;
96
- }
111
+ if (Number.isInteger(n) && n >= 1 && n <= stacks.length) return n - 1;
97
112
  console.error("Invalid choice.");
98
113
  }
99
114
  }
100
115
 
101
- /**
102
- * @param {string} owner
103
- * @param {string} repo
104
- * @param {number} prNumber
105
- * @returns {Promise<string[]>}
106
- */
116
+ // ---------------------------------------------------------------------------
117
+ // Reviewer prompt
118
+ // ---------------------------------------------------------------------------
119
+
107
120
  async function promptReviewers(owner, repo, prNumber) {
108
121
  let candidates = [];
109
122
  const spin = createSpinner("Loading reviewers");
@@ -111,9 +124,7 @@ async function promptReviewers(owner, repo, prNumber) {
111
124
  try {
112
125
  const users = await githubListAssignableUsers(owner, repo);
113
126
  candidates = Array.isArray(users)
114
- ? users
115
- .map((u) => (u && typeof u === "object" ? String(u.login || "") : ""))
116
- .filter(Boolean)
127
+ ? users.map((u) => (u && typeof u === "object" ? String(u.login || "") : "")).filter(Boolean)
117
128
  : [];
118
129
  } catch {
119
130
  candidates = [];
@@ -129,10 +140,7 @@ async function promptReviewers(owner, repo, prNumber) {
129
140
  console.error(chalk.yellow("No assignable users listed by GitHub. You can still type logins."));
130
141
  }
131
142
  const raw = await questionLine(chalk.green("Assign Reviewers (empty=cancel): "));
132
- const parts = raw
133
- .split(",")
134
- .map((s) => s.trim())
135
- .filter(Boolean);
143
+ const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
136
144
  const chosen = [];
137
145
  for (const p of parts) {
138
146
  const n = Number.parseInt(p, 10);
@@ -145,106 +153,292 @@ async function promptReviewers(owner, repo, prNumber) {
145
153
  return [...new Set(chosen)];
146
154
  }
147
155
 
156
+ // ---------------------------------------------------------------------------
157
+ // Auto-approve helper
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * @param {string} owner
162
+ * @param {string} repoName
163
+ * @param {Record<string, unknown>} doc
164
+ * @param {{ viewerLogin?: string, defaultBranch?: string, fullReviewFetch?: boolean }} mergedReviewFetch
165
+ * @param {boolean} [reviewAutoapply]
166
+ */
167
+ export async function fetchStackRowsWithAutoapply(owner, repoName, doc, mergedReviewFetch, reviewAutoapply) {
168
+ let rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
169
+
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
+ }
195
+ }
196
+ return rows;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Alternate stack picker hydration (inference-only — no stack-index.json)
201
+ // ---------------------------------------------------------------------------
202
+
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 });
208
+
209
+ if (!repoFull || !resolveGithubToken()) return wrap(current);
210
+ if (current && Array.isArray(current) && current.length > 1) {
211
+ return augmentAlternatePickStacksWithInfer(owner, repoName, current);
212
+ }
213
+
214
+ const spinner = createSpinner("Scanning stacks");
215
+ if (!tuiSession) spinner.start();
216
+ try {
217
+ const { stacks, openPullNumbers } = await discoverStacksByInference(owner, repoName);
218
+ if (!tuiSession) {
219
+ spinner.stop(`found ${stacks.length} stack(s)`);
220
+ }
221
+ return { stacks, openPullNumbers };
222
+ } catch {
223
+ if (!tuiSession) spinner.stop("scan failed");
224
+ return augmentAlternatePickStacksWithInfer(owner, repoName, Array.isArray(current) ? current : []);
225
+ }
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Main entry
230
+ // ---------------------------------------------------------------------------
231
+
148
232
  /**
149
233
  * @param {object} opts
150
234
  * @param {boolean} [opts.noTui]
151
235
  * @param {string} [opts.repo]
152
236
  * @param {string} [opts.ref]
153
237
  * @param {string} [opts.file]
238
+ * @param {Record<string, unknown>} [opts.explicitDoc] skip inference / load
239
+ * @param {{ viewerLogin?: string, defaultBranch?: string, fullReviewFetch?: boolean }} [opts.reviewFetchOpts]
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]
154
245
  */
155
246
  export async function runStackViewCommand(opts) {
247
+ const tuiSession = !opts.noTui && process.stdin.isTTY && process.stdout.isTTY;
248
+ /** @type {unknown[] | null} */
249
+ let discoverableStacks = null;
250
+
251
+ if (!resolveGithubToken()) {
252
+ console.error(
253
+ chalk.dim(
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."
256
+ )
257
+ );
258
+ }
259
+
156
260
  const root = findGitRoot();
157
261
  let repo = opts.repo;
158
262
  let ref = opts.ref;
159
- if (!opts.file && !ref) {
263
+ let explicitDoc = opts.explicitDoc || null;
264
+
265
+ // If no explicit doc/file and no ref, discover stacks by inference
266
+ if (!explicitDoc && !opts.file) {
160
267
  let repoFull = repo || null;
161
268
  if (!repoFull && root) {
162
- try {
163
- repoFull = getRepoFullNameFromGitRoot(root);
164
- } catch {
165
- repoFull = null;
166
- }
269
+ try { repoFull = getRepoFullNameFromGitRoot(root); } catch { repoFull = null; }
167
270
  }
168
271
  if (repoFull) {
169
- const { owner, repo: repoName } = parseRepoFullName(repoFull);
170
- const discovery = getStackDiscoveryOpts();
171
- const full =
172
- process.env.NUGIT_STACK_DISCOVERY_FULL === "1" || process.env.NUGIT_STACK_DISCOVERY_FULL === "true";
173
- let discovered = null;
174
- let usedCache = false;
175
- if (discovery.mode === "manual" && root) {
176
- discovered = tryLoadStackIndex(root, repoFull);
177
- if (!discovered) {
178
- throw new Error(
179
- 'Stack discovery mode is "manual". Run `nugit stack index` first or pass --repo/--ref explicitly.'
180
- );
181
- }
182
- usedCache = true;
183
- } else if (discovery.mode === "lazy" && root && !full) {
184
- const cached = tryLoadStackIndex(root, repoFull);
185
- if (cached) {
186
- discovered = cached;
187
- usedCache = true;
188
- }
272
+ const { owner: discO, repo: discR } = parseRepoFullName(repoFull);
273
+ /** @type {object[]} */
274
+ let mergedForPick = [];
275
+
276
+ const doDiscover = async () => {
277
+ const { stacks } = await discoverStacksByInference(discO, discR);
278
+ mergedForPick = stacks;
279
+ };
280
+
281
+ if (tuiSession) {
282
+ await withStackLoadInkScreen("Loading stacks…", doDiscover);
283
+ } else {
284
+ const sp = createSpinner("Scanning stacks");
285
+ sp.start();
286
+ await doDiscover();
287
+ sp.stop(`found ${mergedForPick.length} stack(s)`);
189
288
  }
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)
198
- });
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);
206
- }
207
- } catch {
208
- /* ignore index write */
209
- }
210
- }
211
- } else if (usedCache) {
212
- console.error(chalk.dim("Using .nugit/stack-index.json — set NUGIT_STACK_DISCOVERY_FULL=1 to rescan GitHub."));
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.`);
213
292
  }
214
- if (discovered.stacks_found > 1) {
293
+
294
+ if (mergedForPick.length > 1) {
215
295
  if (opts.noTui) {
216
- console.log(formatStacksListHuman(discovered));
296
+ console.log(chalk.bold.cyan(`Stacks in ${repoFull}`) + chalk.dim(` · ${mergedForPick.length} candidate(s) (inferred from open PR chains)`));
297
+ console.log("");
298
+ for (const s of mergedForPick) {
299
+ const row = /** @type {{ tip_pr_number: number, pr_count: number, tip_head_branch: string }} */ (s);
300
+ console.log(` ${chalk.yellow("tip #" + row.tip_pr_number)} · ${row.pr_count} PR(s) · ${chalk.magenta(row.tip_head_branch)}`);
301
+ }
302
+ console.log("");
303
+ console.log(chalk.dim("Re-run with a TTY to pick interactively, or pass --repo/--ref to open a specific stack."));
304
+ return;
305
+ }
306
+
307
+ discoverableStacks = mergedForPick;
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();
217
316
  return;
218
317
  }
219
- const idx = await pickDiscoveredStackIndex(discovered.stacks);
220
- if (idx < 0) {
318
+
319
+ let pickedStack;
320
+ if (!tuiSession) {
321
+ const idx = await pickDiscoveredStackIndex(
322
+ /** @type {{ tip_head_branch: string, tip_pr_number: number, pr_count: number, created_by: string, prs: unknown[] }[]} */ (
323
+ mergedForPick
324
+ )
325
+ );
326
+ if (idx < 0) { console.error("Cancelled."); return; }
327
+ pickedStack = mergedForPick[idx];
328
+ } else {
329
+ pickedStack = picked;
330
+ }
331
+
332
+ if (!pickedStack) {
333
+ if (opts.allowBackToReviewHub) throw new ReviewHubBackError();
334
+ if (opts.allowBackToRepoPicker) throw new RepoPickerBackError();
221
335
  console.error("Cancelled.");
222
336
  return;
223
337
  }
338
+
224
339
  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) {
340
+ ref = pickedStack.tip_head_branch;
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);
349
+ }
350
+ if (!tuiSession) console.error(`Viewing stack tip: ${repo}@${ref}`);
351
+
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]);
229
354
  repo = repoFull;
230
- ref = discovered.stacks[0].tip_head_branch;
231
- console.error(`Viewing discovered stack tip: ${repo}@${ref}`);
355
+ ref = only.tip_head_branch;
356
+ discoverableStacks = mergedForPick;
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);
365
+ }
366
+ if (!tuiSession) console.error(`Viewing stack tip: ${repo}@${ref}`);
232
367
  }
233
368
  }
234
369
  }
235
370
 
236
- let { doc } = await loadStackDocForView({
237
- root,
238
- repo,
239
- ref,
240
- file: opts.file
241
- });
371
+ // Resolve default branch if needed
372
+ if (!ref && repo && !explicitDoc) {
373
+ const { owner: dbO, repo: dbR } = parseRepoFullName(repo);
374
+ const dbMeta = await getRepoMetadata(dbO, dbR);
375
+ ref = typeof dbMeta.default_branch === "string" ? dbMeta.default_branch : "main";
376
+ }
242
377
 
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)`);
378
+ if (explicitDoc && repo && !opts.reviewFetchOpts?.defaultBranch) {
379
+ try {
380
+ const { owner: dbO, repo: dbR } = parseRepoFullName(repo);
381
+ const dbMeta = await getRepoMetadata(dbO, dbR);
382
+ const db = typeof dbMeta.default_branch === "string" ? dbMeta.default_branch : "main";
383
+ opts = { ...opts, reviewFetchOpts: { ...(opts.reviewFetchOpts || {}), defaultBranch: db } };
384
+ } catch { /* non-critical */ }
385
+ }
386
+
387
+ let doc;
388
+ /** @type {{ viewerLogin?: string, defaultBranch?: string, fullReviewFetch?: boolean }} */
389
+ let mergedReviewFetch = { ...(opts.reviewFetchOpts || {}) };
390
+
391
+ if (explicitDoc) {
392
+ doc = explicitDoc;
393
+ } else if (opts.file) {
394
+ const raw = fs.readFileSync(opts.file, "utf8");
395
+ doc = JSON.parse(raw);
396
+ } else {
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.");
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
+ };
418
+ }
419
+
420
+ let { owner, repo: repoName } = parseRepoFullName(doc.repo_full_name);
421
+ let rows;
422
+ const loadRowsOnce = async () => {
423
+ rows = await fetchStackRowsWithAutoapply(owner, repoName, doc, mergedReviewFetch, !!opts.reviewAutoapply);
424
+ };
425
+ if (tuiSession) {
426
+ await withStackLoadInkScreen("Loading stack…", loadRowsOnce);
427
+ } else {
428
+ const loadSpinner = createSpinner("Loading stack");
429
+ loadSpinner.start();
430
+ await loadRowsOnce();
431
+ loadSpinner.stop(`loaded ${rows.length} PR(s)`);
432
+ }
433
+
434
+ const repoFullStr = String(doc.repo_full_name || "");
435
+ const hydrated = await hydrateDiscoverableStacks(repoFullStr, owner, repoName, discoverableStacks, tuiSession);
436
+ discoverableStacks = ensureDocRepresentedInPickStacks(
437
+ hydrated.stacks,
438
+ /** @type {Record<string, unknown>} */ (doc),
439
+ ref,
440
+ hydrated.openPullNumbers
441
+ );
248
442
 
249
443
  if (opts.noTui) {
250
444
  renderStaticStackView(rows);
@@ -253,34 +447,51 @@ export async function runStackViewCommand(opts) {
253
447
 
254
448
  let running = true;
255
449
  while (running) {
450
+ if (tuiSession) clearInkScreen();
256
451
  const exitPayload = createExitPayload();
257
452
  const { waitUntilExit } = render(
258
- React.createElement(StackInkApp, { rows, exitPayload })
453
+ React.createElement(StackInkApp, {
454
+ rows,
455
+ exitPayload,
456
+ viewTitle: opts.viewTitle || "nugit view",
457
+ browseOwner: owner,
458
+ browseRepo: repoName,
459
+ repoFullName: String(doc.repo_full_name || ""),
460
+ shellMode: opts.shellMode !== false,
461
+ alternateStacks: discoverableStacks
462
+ })
259
463
  );
260
464
  await waitUntilExit();
261
- // Give terminal mode a short moment to settle before readline prompts.
262
465
  await new Promise((r) => setTimeout(r, 25));
263
466
 
264
467
  const next = exitPayload.next;
265
- if (!next || next.type === "quit") {
266
- running = false;
267
- break;
268
- }
468
+ if (!next || next.type === "quit") { running = false; break; }
269
469
 
270
470
  if (next.type === "issue_comment") {
271
471
  try {
272
472
  const body = await questionLine(`New issue comment on PR #${next.prNumber} (empty=cancel): `);
273
473
  if (body.trim()) {
274
- await githubPostIssueComment(
275
- owner,
276
- repoName,
277
- /** @type {number} */ (next.prNumber),
278
- body.trim()
279
- );
474
+ await githubPostIssueComment(owner, repoName, /** @type {number} */ (next.prNumber), body.trim());
280
475
  }
281
476
  const refresh = createSpinner("Refreshing stack");
282
477
  refresh.start();
283
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
478
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
479
+ refresh.stop(`loaded ${rows.length} PR(s)`);
480
+ } catch (e) {
481
+ console.error(`Action failed: ${String(e?.message || e)}`);
482
+ }
483
+ continue;
484
+ }
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);
284
495
  refresh.stop(`loaded ${rows.length} PR(s)`);
285
496
  } catch (e) {
286
497
  console.error(`Action failed: ${String(e?.message || e)}`);
@@ -290,18 +501,13 @@ export async function runStackViewCommand(opts) {
290
501
 
291
502
  if (next.type === "review_reply") {
292
503
  try {
293
- const body = await questionLine(`Reply in review thread (empty=cancel): `);
504
+ const body = await questionLine("Reply in review thread (empty=cancel): ");
294
505
  if (body.trim()) {
295
- await githubPostPullReviewCommentReply(
296
- owner,
297
- repoName,
298
- /** @type {number} */ (next.commentId),
299
- body.trim()
300
- );
506
+ await githubPostPullReviewCommentReply(owner, repoName, /** @type {number} */ (next.commentId), body.trim());
301
507
  }
302
508
  const refresh = createSpinner("Refreshing stack");
303
509
  refresh.start();
304
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
510
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
305
511
  refresh.stop(`loaded ${rows.length} PR(s)`);
306
512
  } catch (e) {
307
513
  console.error(`Action failed: ${String(e?.message || e)}`);
@@ -313,13 +519,11 @@ export async function runStackViewCommand(opts) {
313
519
  try {
314
520
  const logins = await promptReviewers(owner, repoName, /** @type {number} */ (next.prNumber));
315
521
  if (logins.length) {
316
- await githubPostRequestedReviewers(owner, repoName, /** @type {number} */ (next.prNumber), {
317
- reviewers: logins
318
- });
522
+ await githubPostRequestedReviewers(owner, repoName, /** @type {number} */ (next.prNumber), { reviewers: logins });
319
523
  }
320
524
  const refresh = createSpinner("Refreshing stack");
321
525
  refresh.start();
322
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
526
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
323
527
  refresh.stop(`loaded ${rows.length} PR(s)`);
324
528
  } catch (e) {
325
529
  console.error(`Action failed: ${String(e?.message || e)}`);
@@ -327,38 +531,192 @@ export async function runStackViewCommand(opts) {
327
531
  continue;
328
532
  }
329
533
 
534
+ if (next.type === "pull_review_line_comment") {
535
+ try {
536
+ const { diffLineToGitHub } = await import("./diff-line-map.js");
537
+ const prNum = /** @type {number} */ (next.prNumber);
538
+ const filePath = String(next.path || "");
539
+ const commitId = String(next.commitId || "");
540
+ const patchIdx = typeof next.patchLineIndex === "number" ? next.patchLineIndex : -1;
541
+ const prRow = rows.find((r) => r.entry.pr_number === prNum);
542
+ const fileObj = Array.isArray(prRow?.files) ? prRow.files.find((f) => f && String(f.filename) === filePath) : null;
543
+ const pLines = fileObj && typeof fileObj.patch === "string" ? String(fileObj.patch).split("\n") : [];
544
+ const mapped = pLines.length > 0 && patchIdx >= 0 ? diffLineToGitHub(pLines, patchIdx) : null;
545
+ if (!mapped) {
546
+ console.error(chalk.yellow("Cannot comment on this line (header or unmappable)."));
547
+ } else {
548
+ const body = await questionLine(`Comment on ${filePath}:${mapped.line} (${mapped.side}) (empty=cancel): `);
549
+ if (body.trim()) {
550
+ await githubPostPullReviewLineComment(owner, repoName, prNum, {
551
+ body: body.trim(), commit_id: commitId, path: filePath, line: mapped.line, side: mapped.side
552
+ });
553
+ }
554
+ const refresh = createSpinner("Refreshing stack");
555
+ refresh.start();
556
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
557
+ refresh.stop(`loaded ${rows.length} PR(s)`);
558
+ }
559
+ } catch (e) {
560
+ console.error(`Action failed: ${String(e?.message || e)}`);
561
+ }
562
+ continue;
563
+ }
564
+
565
+ if (next.type === "pick_stack") {
566
+ const repoFullForPick = String(doc.repo_full_name || "");
567
+ const hydratedPick = await hydrateDiscoverableStacks(repoFullForPick, owner, repoName, discoverableStacks, tuiSession);
568
+ discoverableStacks = ensureDocRepresentedInPickStacks(
569
+ hydratedPick.stacks,
570
+ /** @type {Record<string, unknown>} */ (doc),
571
+ ref,
572
+ hydratedPick.openPullNumbers
573
+ );
574
+ if (!discoverableStacks || discoverableStacks.length < 2) continue;
575
+
576
+ const viewingTip = stackTipPrNumber(doc);
577
+ const viewingHeadRef = typeof ref === "string" && ref.trim() ? ref.trim() : undefined;
578
+ const hasBackFromViewer = !!(opts.allowBackToRepoPicker || opts.allowBackToReviewHub);
579
+ const picked = await pickStackIndexWithInk(
580
+ /** @type {{ tip_head_branch: string, tip_pr_number: number }[]} */ (discoverableStacks),
581
+ { allowBackToRepo: hasBackFromViewer, escapeToRepo: hasBackFromViewer, viewingTipPrNumber: viewingTip ?? undefined, viewingHeadRef }
582
+ );
583
+ if (picked === STACK_PICK_BACK_TO_REPO) {
584
+ if (opts.allowBackToReviewHub) throw new ReviewHubBackError();
585
+ if (opts.allowBackToRepoPicker) throw new RepoPickerBackError();
586
+ continue;
587
+ }
588
+ if (!picked) continue;
589
+
590
+ ref = picked.tip_head_branch;
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
+
598
+ try {
599
+ const reloadStack = async () => {
600
+ if (pickedDoc) {
601
+ doc = pickedDoc;
602
+ } else {
603
+ const { doc: inferred } = await inferStackDocForRemoteView(`${owner}/${repoName}`, { preselectedChainIndex: 0 });
604
+ doc = inferred;
605
+ }
606
+ const { owner: oPick, repo: rPick } = parseRepoFullName(doc.repo_full_name);
607
+ rows = await fetchStackRowsWithAutoapply(oPick, rPick, doc, mergedReviewFetch, !!opts.reviewAutoapply);
608
+ owner = oPick;
609
+ repoName = rPick;
610
+ };
611
+ if (tuiSession) {
612
+ await withStackLoadInkScreen("Loading stack…", reloadStack);
613
+ } else {
614
+ await reloadStack();
615
+ }
616
+ } catch (e) {
617
+ console.error(`Could not load stack: ${String(e?.message || e)}`);
618
+ }
619
+ continue;
620
+ }
621
+
330
622
  if (next.type === "refresh") {
331
623
  const refresh = createSpinner("Refreshing stack");
332
624
  refresh.start();
333
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
625
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
334
626
  refresh.stop(`loaded ${rows.length} PR(s)`);
335
627
  continue;
336
628
  }
337
629
 
630
+ if (next.type === "submit_review") {
631
+ try {
632
+ await githubPostPullReview(owner, repoName, /** @type {number} */ (next.prNumber), {
633
+ event: String(next.event || "COMMENT"),
634
+ body: typeof next.body === "string" ? next.body : ""
635
+ });
636
+ const refresh = createSpinner("Refreshing stack");
637
+ refresh.start();
638
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
639
+ refresh.stop(`loaded ${rows.length} PR(s)`);
640
+ } catch (e) {
641
+ console.error(`Review submit failed: ${String(e?.message || e)}`);
642
+ }
643
+ continue;
644
+ }
645
+
646
+ if (next.type === "submit_review_changes") {
647
+ try {
648
+ const body = await questionLine("Request changes — comment (required, empty=cancel): ");
649
+ if (body.trim()) {
650
+ await githubPostPullReview(owner, repoName, /** @type {number} */ (next.prNumber), {
651
+ event: "REQUEST_CHANGES",
652
+ body: body.trim()
653
+ });
654
+ const refresh = createSpinner("Refreshing stack");
655
+ refresh.start();
656
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
657
+ refresh.stop(`loaded ${rows.length} PR(s)`);
658
+ }
659
+ } catch (e) {
660
+ console.error(`Review submit failed: ${String(e?.message || e)}`);
661
+ }
662
+ continue;
663
+ }
664
+
665
+ if (next.type === "materialize_clone") {
666
+ try {
667
+ const { execFileSync } = await import("child_process");
668
+ const os = await import("os");
669
+ const path = await import("path");
670
+ const tok = resolveGithubToken();
671
+ const prRow = rows.find((r) => r.entry.pr_number === next.prNumber);
672
+ const pull = prRow?.pull;
673
+ const head = pull?.head && typeof pull.head === "object" ? pull.head : {};
674
+ const headRef = typeof head.ref === "string" ? head.ref : "";
675
+ const hr = head.repo && typeof head.repo === "object" ? head.repo : {};
676
+ const cloneUrl = typeof hr.clone_url === "string" ? hr.clone_url : "";
677
+ if (!cloneUrl || !headRef) throw new Error("Missing clone URL or head ref");
678
+ const safeName = `${owner}-${repoName}-${headRef}`.replace(/[^a-zA-Z0-9._-]+/g, "_");
679
+ const dest = path.join(os.tmpdir(), `nugit-review-${safeName}`);
680
+ const url = tok && cloneUrl.startsWith("https://")
681
+ ? cloneUrl.replace("https://", `https://x-access-token:${tok}@`)
682
+ : cloneUrl;
683
+ if (fs.existsSync(dest)) {
684
+ console.error(chalk.yellow(`Directory exists, skipping clone: ${dest}`));
685
+ } else {
686
+ execFileSync("git", ["clone", "--depth", "1", "--branch", headRef, url, dest], { stdio: "inherit" });
687
+ console.error(chalk.green(`Cloned to ${dest}`));
688
+ }
689
+ } catch (e) {
690
+ console.error(`Clone failed: ${String(e?.message || e)}`);
691
+ }
692
+ continue;
693
+ }
694
+
338
695
  if (next.type === "split") {
339
696
  try {
340
697
  const r = findGitRoot();
341
- if (!r) {
342
- throw new Error("Not inside a git repository");
343
- }
698
+ if (!r) throw new Error("Not inside a git repository");
344
699
  const { runSplitCommand } = await import("../split-view/run-split.js");
345
- await runSplitCommand({
700
+ const splitResult = await runSplitCommand({
346
701
  root: r,
347
702
  owner,
348
703
  repo: repoName,
349
704
  prNumber: /** @type {number} */ (next.prNumber),
350
705
  dryRun: false
351
706
  });
352
- const refreshed = readStackFile(r);
353
- if (refreshed) {
354
- 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);
355
713
  }
356
714
  const reload = createSpinner("Reloading stack");
357
715
  reload.start();
358
- rows = await fetchStackPrDetails(owner, repoName, doc.prs);
716
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs, 6, mergedReviewFetch);
359
717
  reload.stop(`loaded ${rows.length} PR(s)`);
360
718
  } catch (e) {
361
- console.error(`Split failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
719
+ console.error(`Split failed: ${String(e?.message || e)}`);
362
720
  }
363
721
  continue;
364
722
  }