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.
- package/package.json +1 -1
- package/src/api-client.js +10 -11
- package/src/github-device-flow.js +1 -1
- package/src/github-oauth-client-id.js +11 -0
- package/src/github-pr-social.js +42 -0
- package/src/github-rest.js +114 -6
- package/src/nugit-stack.js +20 -0
- package/src/nugit-start.js +4 -4
- package/src/nugit.js +37 -22
- package/src/review-hub/review-autoapprove.js +95 -0
- package/src/review-hub/review-hub-back.js +10 -0
- package/src/review-hub/review-hub-ink.js +166 -0
- package/src/review-hub/run-review-hub.js +188 -0
- package/src/split-view/run-split.js +16 -3
- package/src/split-view/split-ink.js +2 -2
- package/src/stack-discover.js +9 -1
- package/src/stack-infer-from-prs.js +71 -0
- package/src/stack-view/diff-line-map.js +62 -0
- package/src/stack-view/fetch-pr-data.js +104 -4
- package/src/stack-view/infer-chains-to-pick-stacks.js +70 -0
- package/src/stack-view/ink-app.js +1853 -156
- package/src/stack-view/loading-ink.js +44 -0
- package/src/stack-view/merge-alternate-pick-stacks.js +223 -0
- package/src/stack-view/patch-preview-merge.js +108 -0
- package/src/stack-view/remote-infer-doc.js +93 -0
- package/src/stack-view/repo-picker-back.js +10 -0
- package/src/stack-view/run-stack-view.js +685 -50
- package/src/stack-view/run-view-entry.js +119 -0
- package/src/stack-view/sgr-mouse.js +56 -0
- package/src/stack-view/stack-branch-graph.js +95 -0
- package/src/stack-view/stack-pick-graph.js +93 -0
- package/src/stack-view/stack-pick-ink.js +270 -0
- package/src/stack-view/stack-pick-layout.js +19 -0
- package/src/stack-view/stack-pick-sort.js +188 -0
- package/src/stack-view/terminal-fullscreen.js +45 -0
- package/src/stack-view/tree-ascii.js +73 -0
- package/src/stack-view/view-md-plain.js +23 -0
- package/src/stack-view/view-repo-picker-ink.js +293 -0
- package/src/stack-view/view-tui-sequential.js +126 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Box, Text, render } from "ink";
|
|
3
|
+
import { clearInkScreen } from "./terminal-fullscreen.js";
|
|
4
|
+
|
|
5
|
+
const FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Full-screen loading line with a small terminal spinner (Ink has no Spinner export in v6).
|
|
9
|
+
*
|
|
10
|
+
* @param {string} message
|
|
11
|
+
* @param {() => Promise<void>} work
|
|
12
|
+
*/
|
|
13
|
+
export async function withStackLoadInkScreen(message, work) {
|
|
14
|
+
clearInkScreen();
|
|
15
|
+
const LoadingLine = () => {
|
|
16
|
+
const [i, setI] = useState(0);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const t = setInterval(() => setI((n) => (n + 1) % FRAMES.length), 80);
|
|
19
|
+
return () => clearInterval(t);
|
|
20
|
+
}, []);
|
|
21
|
+
return React.createElement(
|
|
22
|
+
Box,
|
|
23
|
+
{ flexDirection: "row", padding: 1 },
|
|
24
|
+
React.createElement(Text, { color: "cyan" }, FRAMES[i]),
|
|
25
|
+
React.createElement(Text, null, ` ${message}`)
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
const inst = render(React.createElement(LoadingLine));
|
|
29
|
+
try {
|
|
30
|
+
await new Promise((r) => setImmediate(r));
|
|
31
|
+
await work();
|
|
32
|
+
} finally {
|
|
33
|
+
try {
|
|
34
|
+
inst.unmount();
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
inst.clear();
|
|
40
|
+
} catch {
|
|
41
|
+
/* ignore */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { githubListAllOpenPulls } from "../github-rest.js";
|
|
2
|
+
import { inferPrChainsFromOpenPulls } from "../stack-infer-from-prs.js";
|
|
3
|
+
import { stackTipPrNumber } from "../stack-discover.js";
|
|
4
|
+
import { inferChainsToPickStacks } from "./infer-chains-to-pick-stacks.js";
|
|
5
|
+
import { matchesPickerViewingStack } from "./stack-pick-sort.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PR numbers from GitHub open-pull payloads (see {@link githubListAllOpenPulls}).
|
|
9
|
+
*
|
|
10
|
+
* @param {unknown[]} pulls
|
|
11
|
+
* @returns {Set<number>}
|
|
12
|
+
*/
|
|
13
|
+
export function openPullNumbersFromList(pulls) {
|
|
14
|
+
/** @type {Set<number>} */
|
|
15
|
+
const set = new Set();
|
|
16
|
+
if (!Array.isArray(pulls)) {
|
|
17
|
+
return set;
|
|
18
|
+
}
|
|
19
|
+
for (const raw of pulls) {
|
|
20
|
+
if (!raw || typeof raw !== "object") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const n = /** @type {{ number?: number }} */ (raw).number;
|
|
24
|
+
if (typeof n === "number") {
|
|
25
|
+
set.add(n);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return set;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Remove picker rows whose tip PR is not open (tests / callers that need open-only slice).
|
|
33
|
+
*
|
|
34
|
+
* @param {unknown[]} stacks
|
|
35
|
+
* @param {Set<number>} openPullNumbers
|
|
36
|
+
* @returns {object[]}
|
|
37
|
+
*/
|
|
38
|
+
export function filterPickStacksToOpenTipPRs(stacks, openPullNumbers) {
|
|
39
|
+
if (!Array.isArray(stacks) || !(openPullNumbers instanceof Set)) {
|
|
40
|
+
return Array.isArray(stacks) ? /** @type {object[]} */ (stacks) : [];
|
|
41
|
+
}
|
|
42
|
+
return stacks.filter((s) => {
|
|
43
|
+
if (!s || typeof s !== "object") {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const tip = /** @type {{ tip_pr_number?: number }} */ (s).tip_pr_number;
|
|
47
|
+
return typeof tip === "number" && openPullNumbers.has(tip);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Set `picker_merged_stack` on each row: true when tip PR is not in the open-pull set.
|
|
53
|
+
*
|
|
54
|
+
* @param {object[]} merged
|
|
55
|
+
* @param {Set<number>} openPullNumbers
|
|
56
|
+
* @returns {object[]}
|
|
57
|
+
*/
|
|
58
|
+
export function tagPickStacksMergedState(merged, openPullNumbers) {
|
|
59
|
+
if (!Array.isArray(merged) || !(openPullNumbers instanceof Set)) {
|
|
60
|
+
return Array.isArray(merged) ? merged : [];
|
|
61
|
+
}
|
|
62
|
+
return merged.map((s) => {
|
|
63
|
+
if (!s || typeof s !== "object") {
|
|
64
|
+
return s;
|
|
65
|
+
}
|
|
66
|
+
const tip = /** @type {{ tip_pr_number?: number }} */ (s).tip_pr_number;
|
|
67
|
+
const mergedClosed = typeof tip === "number" && !openPullNumbers.has(tip);
|
|
68
|
+
return { ...s, picker_merged_stack: mergedClosed };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* When GitHub list failed, show stacks as open-tab candidates (unknown state).
|
|
74
|
+
*
|
|
75
|
+
* @param {object[]} base
|
|
76
|
+
* @returns {object[]}
|
|
77
|
+
*/
|
|
78
|
+
export function tagPickStacksUnknownOpen(base) {
|
|
79
|
+
if (!Array.isArray(base)) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
return base.map((s) => (s && typeof s === "object" ? { ...s, picker_merged_stack: false } : s));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Prefer discovery rows (stack.json); add infer-only chains whose tip is not already listed.
|
|
87
|
+
*
|
|
88
|
+
* @param {object[]} discoveryStacks
|
|
89
|
+
* @param {object[]} inferStacks from {@link inferChainsToPickStacks}
|
|
90
|
+
*/
|
|
91
|
+
export function mergeDiscoveryAndInferPickStacks(discoveryStacks, inferStacks) {
|
|
92
|
+
/** @type {Map<number, object>} */
|
|
93
|
+
const byTip = new Map();
|
|
94
|
+
for (const s of discoveryStacks) {
|
|
95
|
+
if (s && typeof s === "object" && typeof /** @type {{ tip_pr_number?: number }} */ (s).tip_pr_number === "number") {
|
|
96
|
+
byTip.set(s.tip_pr_number, s);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const s of inferStacks) {
|
|
100
|
+
if (!s || typeof s !== "object" || typeof s.tip_pr_number !== "number") {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!byTip.has(s.tip_pr_number)) {
|
|
104
|
+
byTip.set(s.tip_pr_number, { ...s, inferredOnly: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [...byTip.values()];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fetch open PRs, infer chains, and merge with discovery-shaped stack rows.
|
|
112
|
+
* Rows include `picker_merged_stack` for the stack picker (Tab: open vs merged/closed).
|
|
113
|
+
*
|
|
114
|
+
* @param {string} owner
|
|
115
|
+
* @param {string} repoName
|
|
116
|
+
* @param {unknown[] | null | undefined} baseStacks
|
|
117
|
+
* @returns {Promise<{ stacks: object[], openPullNumbers: Set<number> | null }>}
|
|
118
|
+
*/
|
|
119
|
+
export async function augmentAlternatePickStacksWithInfer(owner, repoName, baseStacks) {
|
|
120
|
+
const base = Array.isArray(baseStacks) ? /** @type {object[]} */ (baseStacks) : [];
|
|
121
|
+
try {
|
|
122
|
+
const pulls = await githubListAllOpenPulls(owner, repoName);
|
|
123
|
+
const openNums = openPullNumbersFromList(pulls);
|
|
124
|
+
const repoFull = `${owner}/${repoName}`;
|
|
125
|
+
const chains = inferPrChainsFromOpenPulls(pulls, repoFull);
|
|
126
|
+
const inferStacks = inferChainsToPickStacks(chains, pulls);
|
|
127
|
+
const merged = mergeDiscoveryAndInferPickStacks(base, inferStacks);
|
|
128
|
+
return {
|
|
129
|
+
stacks: tagPickStacksMergedState(merged, openNums),
|
|
130
|
+
openPullNumbers: openNums
|
|
131
|
+
};
|
|
132
|
+
} catch {
|
|
133
|
+
return { stacks: tagPickStacksUnknownOpen(base), openPullNumbers: null };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build a stack-picker row from the loaded viewer doc when discovery/infer omitted it.
|
|
139
|
+
*
|
|
140
|
+
* @param {Record<string, unknown>} doc
|
|
141
|
+
* @param {string | undefined} viewingHeadRef branch/ref used to load the doc
|
|
142
|
+
* @param {Set<number> | null | undefined} [openPullNumbers] when set, sets `picker_merged_stack`
|
|
143
|
+
*/
|
|
144
|
+
export function stackDocToPickStackRow(doc, viewingHeadRef, openPullNumbers) {
|
|
145
|
+
const rawPrs = Array.isArray(doc.prs) ? doc.prs : [];
|
|
146
|
+
const sorted = [...rawPrs]
|
|
147
|
+
.filter((e) => e && typeof e === "object")
|
|
148
|
+
.sort(
|
|
149
|
+
(a, b) =>
|
|
150
|
+
(/** @type {{ position?: number }} */ (a).position ?? 0) -
|
|
151
|
+
(/** @type {{ position?: number }} */ (b).position ?? 0)
|
|
152
|
+
);
|
|
153
|
+
/** @type {{ pr_number: number, position: number, head_branch?: string, title?: string }[]} */
|
|
154
|
+
const prRows = [];
|
|
155
|
+
for (const e of sorted) {
|
|
156
|
+
const o = /** @type {Record<string, unknown>} */ (e);
|
|
157
|
+
const n = o.pr_number;
|
|
158
|
+
if (typeof n !== "number") {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
prRows.push({
|
|
162
|
+
pr_number: n,
|
|
163
|
+
position: typeof o.position === "number" ? o.position : prRows.length + 1,
|
|
164
|
+
head_branch: typeof o.head_branch === "string" ? o.head_branch : undefined,
|
|
165
|
+
title: typeof o.title === "string" ? o.title : undefined
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const tip = stackTipPrNumber(doc);
|
|
169
|
+
const last = prRows.length ? prRows[prRows.length - 1] : null;
|
|
170
|
+
const tipNum = tip ?? (last ? last.pr_number : null);
|
|
171
|
+
if (tipNum == null) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const href = typeof viewingHeadRef === "string" ? viewingHeadRef.trim() : "";
|
|
175
|
+
const tipHead =
|
|
176
|
+
href ||
|
|
177
|
+
(last && typeof last.head_branch === "string" && last.head_branch.trim()
|
|
178
|
+
? last.head_branch.trim()
|
|
179
|
+
: "");
|
|
180
|
+
/** @type {Record<string, unknown>} */
|
|
181
|
+
const row = {
|
|
182
|
+
tip_pr_number: tipNum,
|
|
183
|
+
tip_head_branch: tipHead,
|
|
184
|
+
pr_count: prRows.length || 1,
|
|
185
|
+
created_by: String(doc.created_by || ""),
|
|
186
|
+
prs: prRows.length ? prRows : [{ pr_number: tipNum, position: 1 }],
|
|
187
|
+
tip_updated_at: "",
|
|
188
|
+
inferredFromViewerDoc: doc.inferred === true
|
|
189
|
+
};
|
|
190
|
+
if (openPullNumbers instanceof Set && typeof tipNum === "number") {
|
|
191
|
+
row.picker_merged_stack = !openPullNumbers.has(tipNum);
|
|
192
|
+
}
|
|
193
|
+
return row;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* If no picker row matches the open doc, append a synthetic row so Backspace → picker can re-select it.
|
|
198
|
+
*
|
|
199
|
+
* @param {unknown[] | null} stacks
|
|
200
|
+
* @param {Record<string, unknown>} doc
|
|
201
|
+
* @param {string | undefined} ref
|
|
202
|
+
* @param {Set<number> | null | undefined} [openPullNumbers] for `picker_merged_stack` on appended row
|
|
203
|
+
* @returns {unknown[] | null}
|
|
204
|
+
*/
|
|
205
|
+
export function ensureDocRepresentedInPickStacks(stacks, doc, ref, openPullNumbers) {
|
|
206
|
+
if (!doc || typeof doc !== "object") {
|
|
207
|
+
return stacks;
|
|
208
|
+
}
|
|
209
|
+
const arr = Array.isArray(stacks) ? [...stacks] : [];
|
|
210
|
+
const tip = stackTipPrNumber(doc);
|
|
211
|
+
const href = typeof ref === "string" && ref.trim() ? ref.trim() : undefined;
|
|
212
|
+
if (
|
|
213
|
+
arr.some((s) => s && typeof s === "object" && matchesPickerViewingStack(s, tip, href, arr))
|
|
214
|
+
) {
|
|
215
|
+
return arr.length ? arr : stacks;
|
|
216
|
+
}
|
|
217
|
+
const row = stackDocToPickStackRow(doc, href, openPullNumbers);
|
|
218
|
+
if (!row) {
|
|
219
|
+
return stacks;
|
|
220
|
+
}
|
|
221
|
+
arr.push(row);
|
|
222
|
+
return arr;
|
|
223
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a single scrollable view: file text with deletions (from patch) and
|
|
3
|
+
* additions/context aligned to the PR blob, so we do not duplicate a separate diff pane.
|
|
4
|
+
*
|
|
5
|
+
* @param {string} blobText
|
|
6
|
+
* @param {string} patchText unified diff (may contain multiple files)
|
|
7
|
+
* @param {string} filePath path in repo (e.g. src/a.ts)
|
|
8
|
+
* @returns {{ text: string, kind: "add" | "del" | "ctx" }[] | null}
|
|
9
|
+
*/
|
|
10
|
+
export function buildMergedPreviewFromPatch(blobText, patchText, filePath) {
|
|
11
|
+
if (!blobText || !patchText || !filePath) return null;
|
|
12
|
+
const section = extractPatchSectionForFile(patchText, filePath);
|
|
13
|
+
if (!section) return null;
|
|
14
|
+
|
|
15
|
+
const blobLines = blobText.split(/\r\n|\r|\n/);
|
|
16
|
+
let bi = 0;
|
|
17
|
+
/** @type {{ text: string, kind: "add" | "del" | "ctx" }[]} */
|
|
18
|
+
const out = [];
|
|
19
|
+
const lines = section.split(/\n/);
|
|
20
|
+
let inHunk = false;
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
if (line.startsWith("@@")) {
|
|
24
|
+
inHunk = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (line.startsWith("\\")) continue;
|
|
28
|
+
if (!inHunk) continue;
|
|
29
|
+
if (line.length === 0) continue;
|
|
30
|
+
const c = line[0];
|
|
31
|
+
const rest = line.slice(1);
|
|
32
|
+
if (c === "+") {
|
|
33
|
+
const text = blobLines[bi] !== undefined ? blobLines[bi] : rest;
|
|
34
|
+
out.push({ text, kind: "add" });
|
|
35
|
+
bi += 1;
|
|
36
|
+
} else if (c === " ") {
|
|
37
|
+
const text = blobLines[bi] !== undefined ? blobLines[bi] : rest;
|
|
38
|
+
out.push({ text, kind: "ctx" });
|
|
39
|
+
bi += 1;
|
|
40
|
+
} else if (c === "-") {
|
|
41
|
+
out.push({ text: rest, kind: "del" });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
while (bi < blobLines.length) {
|
|
46
|
+
out.push({ text: blobLines[bi], kind: "ctx" });
|
|
47
|
+
bi += 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return out.length > 0 ? out : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} patchText
|
|
55
|
+
* @param {string} filePath
|
|
56
|
+
* @returns {string | null}
|
|
57
|
+
*/
|
|
58
|
+
function extractPatchSectionForFile(patchText, filePath) {
|
|
59
|
+
const norm = normalizePath(filePath);
|
|
60
|
+
const blocks = patchText.split(/^diff --git /m);
|
|
61
|
+
for (let b = 1; b < blocks.length; b++) {
|
|
62
|
+
const block = "diff --git " + blocks[b];
|
|
63
|
+
const head = block.slice(0, Math.min(block.length, 800));
|
|
64
|
+
if (blockIncludesPath(head, norm)) {
|
|
65
|
+
return block.trimEnd();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (blocks.length === 1 && patchText.includes("@@")) {
|
|
69
|
+
if (patchTextIncludesFilePath(patchText, norm)) {
|
|
70
|
+
return patchText.trimEnd();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} p
|
|
78
|
+
*/
|
|
79
|
+
function normalizePath(p) {
|
|
80
|
+
return p.replace(/^a\//, "").replace(/^b\//, "").replace(/\\/g, "/");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} head start of diff block
|
|
85
|
+
* @param {string} norm
|
|
86
|
+
*/
|
|
87
|
+
function blockIncludesPath(head, norm) {
|
|
88
|
+
const patterns = [
|
|
89
|
+
`b/${norm}`,
|
|
90
|
+
`b/${norm}\t`,
|
|
91
|
+
`b/${norm}\n`,
|
|
92
|
+
`/${norm}`,
|
|
93
|
+
` ${norm}`
|
|
94
|
+
];
|
|
95
|
+
return patterns.some((x) => head.includes(x));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} patchText
|
|
100
|
+
* @param {string} norm
|
|
101
|
+
*/
|
|
102
|
+
function patchTextIncludesFilePath(patchText, norm) {
|
|
103
|
+
return (
|
|
104
|
+
patchText.includes(`+++ b/${norm}`) ||
|
|
105
|
+
patchText.includes(`--- a/${norm}`) ||
|
|
106
|
+
patchText.includes(`+++ b/${norm}\t`)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { authMe } from "../api-client.js";
|
|
3
|
+
import { githubListAllOpenPulls } from "../github-rest.js";
|
|
4
|
+
import { parseRepoFullName, createInferredStackDoc } from "../nugit-stack.js";
|
|
5
|
+
import { inferPrChainsFromOpenPulls } from "../stack-infer-from-prs.js";
|
|
6
|
+
import { questionLine } from "./prompt-line.js";
|
|
7
|
+
import { RepoPickerBackError } from "./repo-picker-back.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Open PR groups inferrable as a stack (for TUI pickers).
|
|
11
|
+
* @param {string} repoFull
|
|
12
|
+
* @returns {Promise<number[][]>}
|
|
13
|
+
*/
|
|
14
|
+
export async function listOpenPrChainGroups(repoFull) {
|
|
15
|
+
const { owner, repo } = parseRepoFullName(repoFull);
|
|
16
|
+
const pulls = await githubListAllOpenPulls(owner, repo);
|
|
17
|
+
return inferPrChainsFromOpenPulls(pulls, repoFull);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} e
|
|
22
|
+
*/
|
|
23
|
+
export function isGithubNotFoundError(e) {
|
|
24
|
+
const s = String(/** @type {{ message?: string }} */ (e)?.message || e);
|
|
25
|
+
return /not found/i.test(s) || /\b404\b/.test(s);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* When `.nugit/stack.json` is missing on the remote ref, build a viewer doc from open PRs (same-repo inference).
|
|
30
|
+
* @param {string} repoFull
|
|
31
|
+
* @param {{ interactivePick?: boolean, preselectedChainIndex?: number, tuiChainPick?: (chains: number[][], pulls: unknown[]) => Promise<number> }} [opts]
|
|
32
|
+
*/
|
|
33
|
+
export async function inferStackDocForRemoteView(repoFull, opts = {}) {
|
|
34
|
+
const interactive = opts.interactivePick !== false && process.stdin.isTTY;
|
|
35
|
+
const me = await authMe();
|
|
36
|
+
const login = me && typeof me.login === "string" ? me.login : "viewer";
|
|
37
|
+
const { owner, repo } = parseRepoFullName(repoFull);
|
|
38
|
+
const pulls = await githubListAllOpenPulls(owner, repo);
|
|
39
|
+
const chains = inferPrChainsFromOpenPulls(pulls, repoFull);
|
|
40
|
+
|
|
41
|
+
if (chains.length === 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`No .nugit/stack.json on that ref, and no inferrable same-repo open PR stack in ${repoFull}. ` +
|
|
44
|
+
`Try a branch that contains .nugit/stack.json (often the stack tip), or use \`nugit review\`.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @type {number[]} */
|
|
49
|
+
let chosen;
|
|
50
|
+
if (chains.length === 1) {
|
|
51
|
+
chosen = chains[0];
|
|
52
|
+
} else if (typeof opts.preselectedChainIndex === "number" && Number.isInteger(opts.preselectedChainIndex)) {
|
|
53
|
+
const i = opts.preselectedChainIndex;
|
|
54
|
+
if (i < 0 || i >= chains.length) {
|
|
55
|
+
throw new Error("Invalid preselectedChainIndex for inferred stack.");
|
|
56
|
+
}
|
|
57
|
+
chosen = chains[i];
|
|
58
|
+
} else if (typeof opts.tuiChainPick === "function") {
|
|
59
|
+
const idx = await opts.tuiChainPick(chains, pulls);
|
|
60
|
+
if (idx === -2) {
|
|
61
|
+
throw new RepoPickerBackError();
|
|
62
|
+
}
|
|
63
|
+
if (idx === -1) {
|
|
64
|
+
throw new Error("Stack group selection cancelled.");
|
|
65
|
+
}
|
|
66
|
+
if (typeof idx === "number" && idx >= 0 && idx < chains.length) {
|
|
67
|
+
chosen = chains[idx];
|
|
68
|
+
} else {
|
|
69
|
+
throw new Error("Invalid stack group selection.");
|
|
70
|
+
}
|
|
71
|
+
} else if (interactive) {
|
|
72
|
+
console.error(chalk.bold.cyan(`No stack.json on default ref — inferred ${chains.length} open PR group(s):`));
|
|
73
|
+
for (let i = 0; i < chains.length; i++) {
|
|
74
|
+
const c = chains[i];
|
|
75
|
+
const label = c.length > 1 ? `stack ${c.join(" → ")}` : `PR #${c[0]}`;
|
|
76
|
+
console.error(` ${chalk.yellow("[" + (i + 1) + "]")} ${label}`);
|
|
77
|
+
}
|
|
78
|
+
const ans = String(await questionLine(chalk.green("Select group (empty = use largest stack): "))).trim();
|
|
79
|
+
if (ans) {
|
|
80
|
+
const n = Number.parseInt(ans, 10);
|
|
81
|
+
if (!Number.isInteger(n) || n < 1 || n > chains.length) {
|
|
82
|
+
throw new Error("Invalid selection.");
|
|
83
|
+
}
|
|
84
|
+
chosen = chains[n - 1];
|
|
85
|
+
} else {
|
|
86
|
+
chosen = chains.reduce((a, b) => (b.length > a.length ? b : a));
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
chosen = chains.reduce((a, b) => (b.length > a.length ? b : a));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { doc: createInferredStackDoc(repoFull, login, chosen), viewerLogin: login };
|
|
93
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when the user chooses Backspace from the inferred PR-group picker
|
|
3
|
+
* to return to the repository selection TUI (`nugit view` repo picker flow only).
|
|
4
|
+
*/
|
|
5
|
+
export class RepoPickerBackError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("Back to repository picker");
|
|
8
|
+
this.name = "RepoPickerBackError";
|
|
9
|
+
}
|
|
10
|
+
}
|