nugit-cli 0.0.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 +30 -0
- package/src/api-client.js +182 -0
- package/src/auth-token.js +14 -0
- package/src/cli-output.js +228 -0
- package/src/git-info.js +60 -0
- package/src/github-device-flow.js +64 -0
- package/src/github-pr-social.js +126 -0
- package/src/github-rest.js +212 -0
- package/src/nugit-stack.js +289 -0
- package/src/nugit-start.js +211 -0
- package/src/nugit.js +829 -0
- package/src/open-browser.js +21 -0
- package/src/split-view/run-split.js +181 -0
- package/src/split-view/split-git.js +88 -0
- package/src/split-view/split-ink.js +104 -0
- package/src/stack-discover.js +284 -0
- package/src/stack-discovery-config.js +91 -0
- package/src/stack-extra-commands.js +353 -0
- package/src/stack-graph.js +214 -0
- package/src/stack-helpers.js +58 -0
- package/src/stack-propagate.js +422 -0
- package/src/stack-view/fetch-pr-data.js +126 -0
- package/src/stack-view/ink-app.js +421 -0
- package/src/stack-view/loader.js +101 -0
- package/src/stack-view/open-url.js +18 -0
- package/src/stack-view/prompt-line.js +47 -0
- package/src/stack-view/run-stack-view.js +366 -0
- package/src/stack-view/static-render.js +98 -0
- package/src/token-store.js +45 -0
- package/src/user-config.js +169 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readStackFile, parseRepoFullName, validateStackDoc, findGitRoot } from "./nugit-stack.js";
|
|
2
|
+
import { sortStackPrs } from "./stack-propagate.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {string | null} repoOpt owner/repo override
|
|
6
|
+
*/
|
|
7
|
+
export function loadStackContext(repoOpt) {
|
|
8
|
+
const root = findGitRoot();
|
|
9
|
+
if (!root) {
|
|
10
|
+
throw new Error("Not inside a git repository");
|
|
11
|
+
}
|
|
12
|
+
const doc = readStackFile(root);
|
|
13
|
+
if (!doc) {
|
|
14
|
+
throw new Error("No .nugit/stack.json — run nugit init first");
|
|
15
|
+
}
|
|
16
|
+
validateStackDoc(doc);
|
|
17
|
+
const repoFull = repoOpt || doc.repo_full_name;
|
|
18
|
+
if (!repoFull) {
|
|
19
|
+
throw new Error("Missing repo_full_name in stack file; pass --repo owner/repo");
|
|
20
|
+
}
|
|
21
|
+
const { owner, repo } = parseRepoFullName(repoFull);
|
|
22
|
+
const sorted = sortStackPrs(doc.prs);
|
|
23
|
+
return { root, doc, owner, repo, sorted };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {ReturnType<typeof sortStackPrs>} sorted
|
|
28
|
+
* @param {number} fromPr
|
|
29
|
+
* @param {number} toPr
|
|
30
|
+
*/
|
|
31
|
+
export function assertFromBelowTo(sorted, fromPr, toPr) {
|
|
32
|
+
const idxFrom = sorted.findIndex((p) => p.pr_number === fromPr);
|
|
33
|
+
const idxTo = sorted.findIndex((p) => p.pr_number === toPr);
|
|
34
|
+
if (idxFrom < 0) {
|
|
35
|
+
throw new Error(`PR #${fromPr} is not in the stack`);
|
|
36
|
+
}
|
|
37
|
+
if (idxTo < 0) {
|
|
38
|
+
throw new Error(`PR #${toPr} is not in the stack`);
|
|
39
|
+
}
|
|
40
|
+
if (idxFrom >= idxTo) {
|
|
41
|
+
throw new Error(`--from-pr (#${fromPr}) must be below --to-pr (#${toPr}) in stack order`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {ReturnType<typeof sortStackPrs>} sorted
|
|
47
|
+
* @param {number} fromPr
|
|
48
|
+
*/
|
|
49
|
+
export function defaultFixPr(sorted, fromPr) {
|
|
50
|
+
const idx = sorted.findIndex((p) => p.pr_number === fromPr);
|
|
51
|
+
if (idx < 0) {
|
|
52
|
+
throw new Error(`PR #${fromPr} is not in the stack`);
|
|
53
|
+
}
|
|
54
|
+
if (idx + 1 >= sorted.length) {
|
|
55
|
+
throw new Error(`PR #${fromPr} is the stack tip; there is no upper PR for the fix`);
|
|
56
|
+
}
|
|
57
|
+
return sorted[idx + 1].pr_number;
|
|
58
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { execFileSync, execSync } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { stackJsonPath, validateStackDoc } from "./nugit-stack.js";
|
|
5
|
+
|
|
6
|
+
/** @param {unknown[]} prs */
|
|
7
|
+
export function sortStackPrs(prs) {
|
|
8
|
+
if (!Array.isArray(prs)) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
return [...prs].sort((a, b) => {
|
|
12
|
+
const pa = a && typeof a === "object" ? /** @type {{ position?: number }} */ (a).position : 0;
|
|
13
|
+
const pb = b && typeof b === "object" ? /** @type {{ position?: number }} */ (b).position : 0;
|
|
14
|
+
return (pa ?? 0) - (pb ?? 0);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build layer metadata for the PR at `position` in an ordered stack.
|
|
20
|
+
* @param {Array<{ position: number, pr_number: number, head_branch?: string, base_branch?: string }>} sortedPrs
|
|
21
|
+
* @param {number} position
|
|
22
|
+
*/
|
|
23
|
+
export function buildLayer(sortedPrs, position) {
|
|
24
|
+
const idx = sortedPrs.findIndex((p) => p.position === position);
|
|
25
|
+
if (idx < 0) {
|
|
26
|
+
throw new Error(`No PR with position ${position} in stack`);
|
|
27
|
+
}
|
|
28
|
+
const self = sortedPrs[idx];
|
|
29
|
+
const stack_size = sortedPrs.length;
|
|
30
|
+
|
|
31
|
+
/** @type {{ type: 'branch', ref: string } | { type: 'stack_pr', pr_number: number, head_branch: string }} */
|
|
32
|
+
let below;
|
|
33
|
+
if (idx === 0) {
|
|
34
|
+
below = { type: "branch", ref: self.base_branch || "" };
|
|
35
|
+
} else {
|
|
36
|
+
const low = sortedPrs[idx - 1];
|
|
37
|
+
below = {
|
|
38
|
+
type: "stack_pr",
|
|
39
|
+
pr_number: low.pr_number,
|
|
40
|
+
head_branch: low.head_branch || ""
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @type {{ type: 'stack_pr', pr_number: number, head_branch: string } | null} */
|
|
45
|
+
let above = null;
|
|
46
|
+
if (idx + 1 < sortedPrs.length) {
|
|
47
|
+
const high = sortedPrs[idx + 1];
|
|
48
|
+
above = {
|
|
49
|
+
type: "stack_pr",
|
|
50
|
+
pr_number: high.pr_number,
|
|
51
|
+
head_branch: high.head_branch || ""
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tipPr = sortedPrs[sortedPrs.length - 1];
|
|
56
|
+
const tip = {
|
|
57
|
+
pr_number: tipPr.pr_number,
|
|
58
|
+
head_branch: tipPr.head_branch || ""
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return { position, stack_size, below, above, tip };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Record<string, unknown>} doc validated stack doc
|
|
66
|
+
* @param {ReturnType<typeof sortStackPrs>} sortedPrs
|
|
67
|
+
* @param {number} position
|
|
68
|
+
*/
|
|
69
|
+
export function documentForHeadBranch(doc, sortedPrs, position) {
|
|
70
|
+
const idx = sortedPrs.findIndex((p) => p.position === position);
|
|
71
|
+
if (idx < 0) {
|
|
72
|
+
throw new Error(`No PR with position ${position} in stack`);
|
|
73
|
+
}
|
|
74
|
+
/** Prefix: this branch only includes PRs from bottom through this layer. */
|
|
75
|
+
const prsCopy = sortedPrs.slice(0, idx + 1).map((p) => ({ ...p }));
|
|
76
|
+
const layer = buildLayer(sortedPrs, position);
|
|
77
|
+
const { layer: _drop, ...rest } = doc;
|
|
78
|
+
return {
|
|
79
|
+
...rest,
|
|
80
|
+
prs: prsCopy,
|
|
81
|
+
layer
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function execGit(root, args, dryRun) {
|
|
86
|
+
if (dryRun) {
|
|
87
|
+
console.error(`[dry-run] git ${args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")}`);
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Clear stuck merge/rebase state so a later `git checkout` can run.
|
|
95
|
+
* @param {string} root
|
|
96
|
+
*/
|
|
97
|
+
export function abortStaleGitOperations(root) {
|
|
98
|
+
for (const args of /** @type {const} */ ([["merge", "--abort"], ["rebase", "--abort"]])) {
|
|
99
|
+
try {
|
|
100
|
+
execFileSync("git", args, { cwd: root, stdio: "ignore" });
|
|
101
|
+
} catch {
|
|
102
|
+
/* not in merge/rebase */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Relative path always with `/` for git and comparisons */
|
|
108
|
+
export const STACK_JSON_REL = path.join(".nugit", "stack.json").replace(/\\/g, "/");
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} root
|
|
112
|
+
*/
|
|
113
|
+
function listUnmergedPaths(root) {
|
|
114
|
+
const out = execSync("git diff --name-only --diff-filter=U", { cwd: root, encoding: "utf8" })
|
|
115
|
+
.trim()
|
|
116
|
+
.split("\n")
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.map((p) => p.replace(/\\/g, "/"));
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* When merging the lower head into the upper, `.nugit/stack.json` often conflicts (different prefixes).
|
|
124
|
+
* We take the incoming branch's version and complete the merge; propagate immediately overwrites the file.
|
|
125
|
+
* @param {string} root
|
|
126
|
+
* @param {string} lowerHeadBranch
|
|
127
|
+
* @returns {boolean} true if merge was completed
|
|
128
|
+
*/
|
|
129
|
+
function resolveStackJsonMergeWithTheirs(root, lowerHeadBranch) {
|
|
130
|
+
const um = listUnmergedPaths(root);
|
|
131
|
+
if (um.length !== 1 || um[0] !== STACK_JSON_REL) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
execGit(root, ["checkout", "--theirs", STACK_JSON_REL], false);
|
|
135
|
+
execGit(root, ["add", STACK_JSON_REL], false);
|
|
136
|
+
execGit(root, ["commit", "--no-edit"], false);
|
|
137
|
+
console.error(
|
|
138
|
+
`Auto-resolved ${STACK_JSON_REL} merge (used ${lowerHeadBranch}); replacing with propagated metadata next.`
|
|
139
|
+
);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Merge the stacked branch below into the current branch so upper heads include
|
|
145
|
+
* the latest `.nugit/stack.json` (and any other commits) from the layer under them.
|
|
146
|
+
* Without this, committing on test0 first leaves test1/test2 missing that commit → GitHub PR conflicts.
|
|
147
|
+
* @param {string} root
|
|
148
|
+
* @param {string} lowerHeadBranch local branch name (e.g. test-stack0)
|
|
149
|
+
* @param {boolean} dryRun
|
|
150
|
+
*/
|
|
151
|
+
export function mergeLowerStackHead(root, lowerHeadBranch, dryRun) {
|
|
152
|
+
if (!lowerHeadBranch) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (dryRun) {
|
|
156
|
+
console.error(`[dry-run] git merge --no-edit ${lowerHeadBranch} # absorb lower stacked head`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const out = execGit(root, ["merge", "--no-edit", lowerHeadBranch], false);
|
|
161
|
+
if (out) {
|
|
162
|
+
console.error(out);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (resolveStackJsonMergeWithTheirs(root, lowerHeadBranch)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const msg = err && typeof err === "object" && "stderr" in err ? String(/** @type {{ stderr?: Buffer }} */ (err).stderr) : String(err);
|
|
169
|
+
throw new Error(
|
|
170
|
+
`git merge ${lowerHeadBranch} failed while propagating (upper branch must include the lower head). ` +
|
|
171
|
+
`Resolve conflicts, commit the merge, then re-run \`nugit stack propagate\`. Underlying: ${msg.trim() || err}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string} root
|
|
178
|
+
*/
|
|
179
|
+
export function assertCleanWorkingTree(root) {
|
|
180
|
+
const out = execSync("git status --porcelain", { cwd: root, encoding: "utf8" });
|
|
181
|
+
if (out.trim()) {
|
|
182
|
+
throw new Error("Working tree is not clean; commit or stash before nugit stack propagate");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const BOOTSTRAP_COMMIT_MESSAGE = "Nugit stack creation";
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* If the only dirty path is `.nugit/stack.json`, commit it so propagate can run.
|
|
190
|
+
* @param {string} root
|
|
191
|
+
* @param {boolean} dryRun
|
|
192
|
+
* @param {string} [message]
|
|
193
|
+
* @returns {boolean} whether a commit was made (or would be made in dry-run)
|
|
194
|
+
*/
|
|
195
|
+
export function tryBootstrapCommitStackJson(root, dryRun, message = BOOTSTRAP_COMMIT_MESSAGE) {
|
|
196
|
+
const full = execSync("git status --porcelain", { cwd: root, encoding: "utf8" }).trim();
|
|
197
|
+
if (!full) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const besidesStack = execFileSync(
|
|
202
|
+
"git",
|
|
203
|
+
["status", "--porcelain", "--", ".", ":(exclude).nugit/stack.json"],
|
|
204
|
+
{ cwd: root, encoding: "utf8" }
|
|
205
|
+
).trim();
|
|
206
|
+
|
|
207
|
+
if (besidesStack) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
"Working tree has changes outside `.nugit/stack.json`; commit or stash them before propagate.\n" + besidesStack
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const stackDirty = execFileSync("git", ["status", "--porcelain", "--", ".nugit/stack.json"], {
|
|
214
|
+
cwd: root,
|
|
215
|
+
encoding: "utf8"
|
|
216
|
+
}).trim();
|
|
217
|
+
|
|
218
|
+
if (!stackDirty) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
"Working tree is not clean but `.nugit/stack.json` is not among the changes; commit or stash manually.\n" + full
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (dryRun) {
|
|
225
|
+
console.error(`[dry-run] git add ${STACK_JSON_REL} && git commit -m ${JSON.stringify(message)}`);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
execGit(root, ["add", STACK_JSON_REL], false);
|
|
230
|
+
execGit(root, ["commit", "-m", message], false);
|
|
231
|
+
console.error(`Committed ${STACK_JSON_REL} (${message})`);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {string} root
|
|
237
|
+
* @returns {{ kind: 'branch' | 'detached', ref: string }}
|
|
238
|
+
*/
|
|
239
|
+
export function getCurrentHead(root) {
|
|
240
|
+
try {
|
|
241
|
+
const sym = execSync("git symbolic-ref -q --short HEAD", {
|
|
242
|
+
cwd: root,
|
|
243
|
+
encoding: "utf8"
|
|
244
|
+
}).trim();
|
|
245
|
+
if (sym) {
|
|
246
|
+
return { kind: "branch", ref: sym };
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
/* detached */
|
|
250
|
+
}
|
|
251
|
+
const sha = execSync("git rev-parse HEAD", { cwd: root, encoding: "utf8" }).trim();
|
|
252
|
+
return { kind: "detached", ref: sha };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {string} root
|
|
257
|
+
* @param {string} remote
|
|
258
|
+
* @param {string} branch
|
|
259
|
+
* @param {boolean} dryRun
|
|
260
|
+
*/
|
|
261
|
+
export function checkoutStackHead(root, remote, branch, dryRun) {
|
|
262
|
+
execGit(root, ["fetch", remote, branch], dryRun);
|
|
263
|
+
if (dryRun) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// After `git merge`, the index can still block `git checkout` to the next head ("resolve your
|
|
267
|
+
// index first"). Sync to HEAD before switching branches.
|
|
268
|
+
abortStaleGitOperations(root);
|
|
269
|
+
execGit(root, ["reset", "--hard", "HEAD"], false);
|
|
270
|
+
try {
|
|
271
|
+
execGit(root, ["checkout", branch], false);
|
|
272
|
+
} catch {
|
|
273
|
+
execGit(root, ["checkout", "-B", branch, `${remote}/${branch}`], false);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @param {string} root
|
|
279
|
+
* @param {string} ref
|
|
280
|
+
* @param {boolean} dryRun
|
|
281
|
+
*/
|
|
282
|
+
export function checkoutRef(root, ref, dryRun) {
|
|
283
|
+
if (dryRun) {
|
|
284
|
+
execGit(root, ["checkout", ref], dryRun);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
execGit(root, ["checkout", ref], false);
|
|
289
|
+
} catch {
|
|
290
|
+
abortStaleGitOperations(root);
|
|
291
|
+
execGit(root, ["reset", "--hard", "HEAD"], false);
|
|
292
|
+
execGit(root, ["checkout", ref], false);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {string} root
|
|
298
|
+
* @param {string} fileRel path relative to root
|
|
299
|
+
*/
|
|
300
|
+
function fileContentAtHead(root, fileRel) {
|
|
301
|
+
try {
|
|
302
|
+
return execFileSync("git", ["show", `HEAD:${fileRel}`], {
|
|
303
|
+
cwd: root,
|
|
304
|
+
encoding: "utf8",
|
|
305
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
306
|
+
});
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* @param {object} opts
|
|
314
|
+
* @param {string} opts.root
|
|
315
|
+
* @param {string} [opts.message]
|
|
316
|
+
* @param {boolean} [opts.push]
|
|
317
|
+
* @param {boolean} [opts.dryRun]
|
|
318
|
+
* @param {string} [opts.remote]
|
|
319
|
+
* @param {boolean} [opts.noMergeLower] if true, skip merging the branch below into each upper head (not recommended)
|
|
320
|
+
* @param {boolean} [opts.bootstrapCommit] if false, skip auto-commit of dirty `.nugit/stack.json` before propagating
|
|
321
|
+
*/
|
|
322
|
+
export async function runStackPropagate(opts) {
|
|
323
|
+
const root = opts.root;
|
|
324
|
+
const message = opts.message || "nugit: propagate stack metadata";
|
|
325
|
+
const push = Boolean(opts.push);
|
|
326
|
+
const dryRun = Boolean(opts.dryRun);
|
|
327
|
+
const remote = opts.remote || "origin";
|
|
328
|
+
const noMergeLower = Boolean(opts.noMergeLower);
|
|
329
|
+
const bootstrapCommit = opts.bootstrapCommit !== false;
|
|
330
|
+
|
|
331
|
+
const raw = JSON.parse(fs.readFileSync(stackJsonPath(root), "utf8"));
|
|
332
|
+
validateStackDoc(raw);
|
|
333
|
+
/** @type {Record<string, unknown>} */
|
|
334
|
+
const doc = raw;
|
|
335
|
+
const sorted = sortStackPrs(doc.prs);
|
|
336
|
+
if (sorted.length === 0) {
|
|
337
|
+
throw new Error("Stack has no PRs; nothing to propagate");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** @type {boolean} */
|
|
341
|
+
let bootstrappedDry = false;
|
|
342
|
+
if (!dryRun && bootstrapCommit) {
|
|
343
|
+
tryBootstrapCommitStackJson(root, false);
|
|
344
|
+
}
|
|
345
|
+
if (dryRun && bootstrapCommit) {
|
|
346
|
+
bootstrappedDry = tryBootstrapCommitStackJson(root, true);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!dryRun) {
|
|
350
|
+
assertCleanWorkingTree(root);
|
|
351
|
+
} else if (!bootstrappedDry) {
|
|
352
|
+
assertCleanWorkingTree(root);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const start = getCurrentHead(root);
|
|
356
|
+
|
|
357
|
+
/** @type {string | null} */
|
|
358
|
+
let prevHeadBranch = null;
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
for (const entry of sorted) {
|
|
362
|
+
const headBranch =
|
|
363
|
+
entry && typeof entry === "object" ? String(/** @type {{ head_branch?: string }} */ (entry).head_branch || "").trim() : "";
|
|
364
|
+
if (!headBranch) {
|
|
365
|
+
console.error(`Skipping position ${entry?.position}: missing head_branch`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const pos = /** @type {{ position: number }} */ (entry).position;
|
|
369
|
+
const toWrite = documentForHeadBranch(doc, sorted, pos);
|
|
370
|
+
validateStackDoc(toWrite);
|
|
371
|
+
const json = JSON.stringify(toWrite, null, 2) + "\n";
|
|
372
|
+
|
|
373
|
+
if (dryRun) {
|
|
374
|
+
console.error(`[dry-run] checkout ${headBranch}`);
|
|
375
|
+
if (!noMergeLower && prevHeadBranch) {
|
|
376
|
+
mergeLowerStackHead(root, prevHeadBranch, true);
|
|
377
|
+
}
|
|
378
|
+
console.error(
|
|
379
|
+
`[dry-run] write ${STACK_JSON_REL} (${pos + 1} prs prefix of ${sorted.length}, layer position ${pos})`
|
|
380
|
+
);
|
|
381
|
+
prevHeadBranch = headBranch;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
checkoutStackHead(root, remote, headBranch, false);
|
|
386
|
+
|
|
387
|
+
if (!noMergeLower && prevHeadBranch) {
|
|
388
|
+
mergeLowerStackHead(root, prevHeadBranch, false);
|
|
389
|
+
console.error(`Merged ${prevHeadBranch} into ${headBranch} before writing stack metadata`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const existing = fileContentAtHead(root, STACK_JSON_REL);
|
|
393
|
+
if (existing === json) {
|
|
394
|
+
console.error(`Skip ${headBranch}: .nugit/stack.json already matches`);
|
|
395
|
+
prevHeadBranch = headBranch;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const dir = path.join(root, ".nugit");
|
|
400
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
401
|
+
fs.writeFileSync(stackJsonPath(root), json);
|
|
402
|
+
execGit(root, ["add", STACK_JSON_REL], false);
|
|
403
|
+
execGit(root, ["commit", "-m", message], false);
|
|
404
|
+
console.error(`Committed ${STACK_JSON_REL} on ${headBranch}`);
|
|
405
|
+
|
|
406
|
+
if (push) {
|
|
407
|
+
execGit(root, ["push", remote, headBranch], false);
|
|
408
|
+
console.error(`Pushed ${remote}/${headBranch}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
prevHeadBranch = headBranch;
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
if (!dryRun) {
|
|
415
|
+
checkoutRef(root, start.ref, false);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (dryRun) {
|
|
420
|
+
console.error(`[dry-run] would restore checkout ${start.ref}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { githubGetPull, githubListPullFiles, githubRestJson } from "../github-rest.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {unknown[]} items
|
|
5
|
+
* @param {number} batchSize
|
|
6
|
+
* @param {(item: unknown, index: number) => Promise<unknown>} fn
|
|
7
|
+
*/
|
|
8
|
+
async function mapInBatches(items, batchSize, fn) {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
11
|
+
const batch = items.slice(i, i + batchSize);
|
|
12
|
+
const part = await Promise.all(batch.map((item, j) => fn(item, i + j)));
|
|
13
|
+
out.push(...part);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Faster view-oriented comment fetch: first page only.
|
|
20
|
+
* @param {string} owner
|
|
21
|
+
* @param {string} repo
|
|
22
|
+
* @param {number} issueNumber
|
|
23
|
+
*/
|
|
24
|
+
async function githubListIssueCommentsFirstPage(owner, repo, issueNumber) {
|
|
25
|
+
const o = encodeURIComponent(owner);
|
|
26
|
+
const r = encodeURIComponent(repo);
|
|
27
|
+
const n = encodeURIComponent(String(issueNumber));
|
|
28
|
+
const out = await githubRestJson("GET", `/repos/${o}/${r}/issues/${n}/comments?per_page=50&page=1`);
|
|
29
|
+
return Array.isArray(out) ? out : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Faster view-oriented review comment fetch: first page only.
|
|
34
|
+
* @param {string} owner
|
|
35
|
+
* @param {string} repo
|
|
36
|
+
* @param {number} pullNumber
|
|
37
|
+
*/
|
|
38
|
+
async function githubListPullReviewCommentsFirstPage(owner, repo, pullNumber) {
|
|
39
|
+
const o = encodeURIComponent(owner);
|
|
40
|
+
const r = encodeURIComponent(repo);
|
|
41
|
+
const n = encodeURIComponent(String(pullNumber));
|
|
42
|
+
const out = await githubRestJson("GET", `/repos/${o}/${r}/pulls/${n}/comments?per_page=50&page=1`);
|
|
43
|
+
return Array.isArray(out) ? out : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pull reviews first page (for approval/changes requested signal in TUI).
|
|
48
|
+
* @param {string} owner
|
|
49
|
+
* @param {string} repo
|
|
50
|
+
* @param {number} pullNumber
|
|
51
|
+
*/
|
|
52
|
+
async function githubListPullReviewsFirstPage(owner, repo, pullNumber) {
|
|
53
|
+
const o = encodeURIComponent(owner);
|
|
54
|
+
const r = encodeURIComponent(repo);
|
|
55
|
+
const n = encodeURIComponent(String(pullNumber));
|
|
56
|
+
const out = await githubRestJson("GET", `/repos/${o}/${r}/pulls/${n}/reviews?per_page=50&page=1`);
|
|
57
|
+
return Array.isArray(out) ? out : [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reduce reviews to a single state for UI badges.
|
|
62
|
+
* @param {unknown[]} reviews
|
|
63
|
+
*/
|
|
64
|
+
function summarizeReviewState(reviews) {
|
|
65
|
+
/** @type {Map<string, string>} */
|
|
66
|
+
const byUser = new Map();
|
|
67
|
+
for (const rv of reviews) {
|
|
68
|
+
if (!rv || typeof rv !== "object") continue;
|
|
69
|
+
const r = /** @type {Record<string, unknown>} */ (rv);
|
|
70
|
+
const user = r.user && typeof r.user === "object" ? /** @type {Record<string, unknown>} */ (r.user) : {};
|
|
71
|
+
const login = typeof user.login === "string" ? user.login : "";
|
|
72
|
+
const state = typeof r.state === "string" ? r.state.toUpperCase() : "";
|
|
73
|
+
if (!login || !state) continue;
|
|
74
|
+
byUser.set(login, state);
|
|
75
|
+
}
|
|
76
|
+
const states = [...byUser.values()];
|
|
77
|
+
if (states.includes("CHANGES_REQUESTED")) return "changes_requested";
|
|
78
|
+
if (states.includes("APPROVED")) return "approved";
|
|
79
|
+
if (states.includes("COMMENTED")) return "commented";
|
|
80
|
+
return "none";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} owner
|
|
85
|
+
* @param {string} repo
|
|
86
|
+
* @param {Array<{ pr_number: number, position: number, head_branch?: string, base_branch?: string }>} prEntries
|
|
87
|
+
* @param {number} [concurrency]
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchStackPrDetails(owner, repo, prEntries, concurrency = 6) {
|
|
90
|
+
const sorted = [...prEntries].sort((a, b) => a.position - b.position);
|
|
91
|
+
return mapInBatches(sorted, concurrency, async (entry) => {
|
|
92
|
+
const n = entry.pr_number;
|
|
93
|
+
try {
|
|
94
|
+
const pull = await githubGetPull(owner, repo, n);
|
|
95
|
+
const [issueRes, reviewRes, filesRes, reviewsRes] = await Promise.allSettled([
|
|
96
|
+
githubListIssueCommentsFirstPage(owner, repo, n),
|
|
97
|
+
githubListPullReviewCommentsFirstPage(owner, repo, n),
|
|
98
|
+
githubListPullFiles(owner, repo, n),
|
|
99
|
+
githubListPullReviewsFirstPage(owner, repo, n)
|
|
100
|
+
]);
|
|
101
|
+
const issueComments = issueRes.status === "fulfilled" ? issueRes.value : [];
|
|
102
|
+
const reviewComments = reviewRes.status === "fulfilled" ? reviewRes.value : [];
|
|
103
|
+
const files = filesRes.status === "fulfilled" ? filesRes.value : [];
|
|
104
|
+
const reviews = reviewsRes.status === "fulfilled" ? reviewsRes.value : [];
|
|
105
|
+
return {
|
|
106
|
+
entry,
|
|
107
|
+
pull,
|
|
108
|
+
issueComments,
|
|
109
|
+
reviewComments,
|
|
110
|
+
files: Array.isArray(files) ? files : [],
|
|
111
|
+
reviewSummary: summarizeReviewState(reviews),
|
|
112
|
+
error: null
|
|
113
|
+
};
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return {
|
|
116
|
+
entry,
|
|
117
|
+
pull: null,
|
|
118
|
+
issueComments: [],
|
|
119
|
+
reviewComments: [],
|
|
120
|
+
files: [],
|
|
121
|
+
reviewSummary: "none",
|
|
122
|
+
error: String(/** @type {Error} */ (e).message || e)
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|