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.
- package/package.json +1 -1
- package/src/api-client.js +0 -12
- package/src/github-rest.js +35 -0
- package/src/nugit-config.js +84 -0
- package/src/nugit-stack.js +29 -266
- package/src/nugit.js +103 -661
- package/src/review-hub/review-hub-ink.js +6 -3
- package/src/review-hub/run-review-hub.js +34 -91
- package/src/services/repo-branches.js +151 -0
- package/src/services/stack-inference.js +90 -0
- package/src/split-view/run-split.js +14 -89
- package/src/stack-view/infer-chains-to-pick-stacks.js +10 -0
- package/src/stack-view/ink-app.js +3 -2118
- package/src/stack-view/loader.js +19 -93
- package/src/stack-view/loading-ink.js +2 -44
- package/src/stack-view/merge-alternate-pick-stacks.js +23 -1
- package/src/stack-view/remote-infer-doc.js +28 -45
- package/src/stack-view/run-stack-view.js +249 -526
- package/src/stack-view/run-view-entry.js +14 -18
- package/src/stack-view/stack-pick-ink.js +169 -131
- package/src/stack-view/stack-picker-graph-pane.js +118 -0
- package/src/stack-view/terminal-fullscreen.js +7 -45
- package/src/tui/pages/home.js +122 -0
- package/src/tui/pages/repo-actions.js +81 -0
- package/src/tui/pages/repo-branches.js +259 -0
- package/src/tui/pages/viewer.js +2129 -0
- package/src/tui/router.js +40 -0
- package/src/tui/run-tui.js +281 -0
- package/src/utilities/loading.js +37 -0
- package/src/utilities/terminal.js +31 -0
- package/src/cli-output.js +0 -228
- package/src/nugit-start.js +0 -211
- package/src/stack-discover.js +0 -292
- package/src/stack-discovery-config.js +0 -91
- package/src/stack-extra-commands.js +0 -353
- package/src/stack-graph.js +0 -214
- package/src/stack-helpers.js +0 -58
- 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 {
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
233
|
-
* @param {boolean}
|
|
234
|
-
* @
|
|
163
|
+
* @param {Record<string, unknown>} doc
|
|
164
|
+
* @param {{ viewerLogin?: string, defaultBranch?: string, fullReviewFetch?: boolean }} mergedReviewFetch
|
|
165
|
+
* @param {boolean} [reviewAutoapply]
|
|
235
166
|
*/
|
|
236
|
-
async function
|
|
237
|
-
|
|
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 (
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Alternate stack picker hydration (inference-only — no stack-index.json)
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
254
202
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
264
|
-
if (
|
|
265
|
-
return augmentAlternatePickStacksWithInfer(owner, repoName,
|
|
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
|
|
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
|
-
|
|
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
|
|
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]
|
|
319
|
-
* @param {boolean} [opts.reviewAutoapply]
|
|
320
|
-
* @param {boolean} [opts.shellMode]
|
|
321
|
-
* @param {boolean} [opts.allowBackToRepoPicker]
|
|
322
|
-
* @param {boolean} [opts.allowBackToReviewHub]
|
|
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}
|
|
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
|
|
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
|
-
|
|
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:
|
|
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…",
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
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
|
-
|
|
452
|
-
!opts.noTui && process.stdin.isTTY && process.stdout.isTTY;
|
|
306
|
+
|
|
453
307
|
discoverableStacks = mergedForPick;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
393
|
+
} else if (opts.file) {
|
|
394
|
+
const raw = fs.readFileSync(opts.file, "utf8");
|
|
395
|
+
doc = JSON.parse(raw);
|
|
557
396
|
} else {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
622
|
-
loadSpinner.start();
|
|
623
|
-
}
|
|
429
|
+
loadSpinner.start();
|
|
624
430
|
await loadRowsOnce();
|
|
625
|
-
|
|
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
|
|
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(
|
|
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
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
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
|
|
867
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
957
|
-
|
|
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(
|
|
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
|
-
|
|
988
|
-
if (
|
|
989
|
-
|
|
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(
|
|
719
|
+
console.error(`Split failed: ${String(e?.message || e)}`);
|
|
997
720
|
}
|
|
998
721
|
continue;
|
|
999
722
|
}
|