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,91 @@
|
|
|
1
|
+
import { readUserConfig } from "./user-config.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{
|
|
5
|
+
* mode: 'eager' | 'lazy' | 'manual',
|
|
6
|
+
* maxOpenPrs: number,
|
|
7
|
+
* fetchConcurrency: number,
|
|
8
|
+
* background: boolean,
|
|
9
|
+
* lazyFirstPassMaxPrs: number
|
|
10
|
+
* }} StackDiscoveryResolved
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve stack discovery options from ~/.config/nugit/config.json and env.
|
|
15
|
+
* Env overrides: NUGIT_STACK_DISCOVERY_MODE, NUGIT_STACK_DISCOVERY_MAX_OPEN_PRS, NUGIT_STACK_DISCOVERY_CONCURRENCY, NUGIT_STACK_DISCOVERY_BACKGROUND
|
|
16
|
+
* @param {Partial<StackDiscoveryResolved>} [cliOverrides] CLI flags override file/env for that invocation
|
|
17
|
+
* @returns {StackDiscoveryResolved}
|
|
18
|
+
*/
|
|
19
|
+
export function getStackDiscoveryOpts(cliOverrides = {}) {
|
|
20
|
+
const cfg = readUserConfig();
|
|
21
|
+
const sd =
|
|
22
|
+
cfg.stackDiscovery && typeof cfg.stackDiscovery === "object"
|
|
23
|
+
? /** @type {Record<string, unknown>} */ (cfg.stackDiscovery)
|
|
24
|
+
: {};
|
|
25
|
+
|
|
26
|
+
const envMode = process.env.NUGIT_STACK_DISCOVERY_MODE;
|
|
27
|
+
const modeRaw =
|
|
28
|
+
cliOverrides.mode ??
|
|
29
|
+
envMode ??
|
|
30
|
+
(typeof sd.mode === "string" ? sd.mode : "eager");
|
|
31
|
+
const mode =
|
|
32
|
+
modeRaw === "lazy" || modeRaw === "manual" || modeRaw === "eager" ? modeRaw : "eager";
|
|
33
|
+
|
|
34
|
+
const maxFromEnv = process.env.NUGIT_STACK_DISCOVERY_MAX_OPEN_PRS;
|
|
35
|
+
const maxOpenPrs =
|
|
36
|
+
cliOverrides.maxOpenPrs ??
|
|
37
|
+
(maxFromEnv != null && maxFromEnv !== ""
|
|
38
|
+
? Number.parseInt(maxFromEnv, 10)
|
|
39
|
+
: typeof sd.maxOpenPrs === "number"
|
|
40
|
+
? sd.maxOpenPrs
|
|
41
|
+
: mode === "lazy"
|
|
42
|
+
? 100
|
|
43
|
+
: 500);
|
|
44
|
+
|
|
45
|
+
const concFromEnv = process.env.NUGIT_STACK_DISCOVERY_CONCURRENCY;
|
|
46
|
+
const fetchConcurrency =
|
|
47
|
+
cliOverrides.fetchConcurrency ??
|
|
48
|
+
(concFromEnv != null && concFromEnv !== ""
|
|
49
|
+
? Number.parseInt(concFromEnv, 10)
|
|
50
|
+
: typeof sd.fetchConcurrency === "number"
|
|
51
|
+
? sd.fetchConcurrency
|
|
52
|
+
: 8);
|
|
53
|
+
|
|
54
|
+
const bgEnv = process.env.NUGIT_STACK_DISCOVERY_BACKGROUND;
|
|
55
|
+
const background =
|
|
56
|
+
cliOverrides.background ??
|
|
57
|
+
(bgEnv === "1" || bgEnv === "true"
|
|
58
|
+
? true
|
|
59
|
+
: bgEnv === "0" || bgEnv === "false"
|
|
60
|
+
? false
|
|
61
|
+
: typeof sd.background === "boolean"
|
|
62
|
+
? sd.background
|
|
63
|
+
: false);
|
|
64
|
+
|
|
65
|
+
const lazyFirst =
|
|
66
|
+
typeof sd.lazyFirstPassMaxPrs === "number" ? sd.lazyFirstPassMaxPrs : Math.min(50, maxOpenPrs || 50);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
mode,
|
|
70
|
+
maxOpenPrs: Number.isFinite(maxOpenPrs) && maxOpenPrs >= 0 ? maxOpenPrs : 500,
|
|
71
|
+
fetchConcurrency: Math.max(1, Math.min(32, Number.isFinite(fetchConcurrency) ? fetchConcurrency : 8)),
|
|
72
|
+
background,
|
|
73
|
+
lazyFirstPassMaxPrs: Math.max(1, lazyFirst)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* For lazy mode: first pass cap (smaller scan before optional full refresh).
|
|
79
|
+
* @param {StackDiscoveryResolved} opts
|
|
80
|
+
* @param {boolean} full If true, use maxOpenPrs
|
|
81
|
+
* @returns {number}
|
|
82
|
+
*/
|
|
83
|
+
export function effectiveMaxOpenPrs(opts, full) {
|
|
84
|
+
if (full || opts.mode === "eager") {
|
|
85
|
+
return opts.maxOpenPrs;
|
|
86
|
+
}
|
|
87
|
+
if (opts.mode === "manual") {
|
|
88
|
+
return opts.maxOpenPrs;
|
|
89
|
+
}
|
|
90
|
+
return Math.min(opts.lazyFirstPassMaxPrs, opts.maxOpenPrs || 100);
|
|
91
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { getPull, authMe } from "./api-client.js";
|
|
5
|
+
import {
|
|
6
|
+
githubGetPullReviewComment,
|
|
7
|
+
githubListIssueComments,
|
|
8
|
+
githubListPullReviewComments,
|
|
9
|
+
githubPostIssueComment,
|
|
10
|
+
githubPostPullReviewCommentReply
|
|
11
|
+
} from "./github-pr-social.js";
|
|
12
|
+
import { githubGetBlobText } from "./github-rest.js";
|
|
13
|
+
import { printJson } from "./cli-output.js";
|
|
14
|
+
import { writeStackFile, validateStackDoc, stackJsonPath } from "./nugit-stack.js";
|
|
15
|
+
import {
|
|
16
|
+
loadStackContext,
|
|
17
|
+
assertFromBelowTo,
|
|
18
|
+
defaultFixPr
|
|
19
|
+
} from "./stack-helpers.js";
|
|
20
|
+
|
|
21
|
+
const REVIEW_STATE_FILE = "review-state.json";
|
|
22
|
+
|
|
23
|
+
function reviewStatePath(root) {
|
|
24
|
+
return path.join(root, ".nugit", REVIEW_STATE_FILE);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readReviewState(root) {
|
|
28
|
+
const p = reviewStatePath(root);
|
|
29
|
+
if (!fs.existsSync(p)) {
|
|
30
|
+
return { version: 1, threads: [] };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
34
|
+
if (!raw || typeof raw !== "object") {
|
|
35
|
+
return { version: 1, threads: [] };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
version: 1,
|
|
39
|
+
threads: Array.isArray(raw.threads) ? raw.threads : []
|
|
40
|
+
};
|
|
41
|
+
} catch {
|
|
42
|
+
return { version: 1, threads: [] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeReviewState(root, state) {
|
|
47
|
+
const dir = path.join(root, ".nugit");
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
fs.writeFileSync(reviewStatePath(root), JSON.stringify(state, null, 2) + "\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clip(s, max) {
|
|
53
|
+
const t = String(s || "").replace(/\s+/g, " ").trim();
|
|
54
|
+
return t.length <= max ? t : `${t.slice(0, max - 1)}…`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {import("commander").Command} stack
|
|
59
|
+
*/
|
|
60
|
+
export function registerStackExtraCommands(stack) {
|
|
61
|
+
const comment = stack.command("comment").description("Post an issue (conversation) comment on a stack PR");
|
|
62
|
+
comment
|
|
63
|
+
.requiredOption("--pr <n>", "Pull request number")
|
|
64
|
+
.option("--body <markdown>", "Comment body")
|
|
65
|
+
.option("--body-file <path>", "Read body from file")
|
|
66
|
+
.option("--repo <owner/repo>", "Override repository")
|
|
67
|
+
.option("--json", "Print API response as JSON", false)
|
|
68
|
+
.action(async (opts) => {
|
|
69
|
+
const { owner, repo } = loadStackContext(opts.repo || null);
|
|
70
|
+
let body = opts.body || "";
|
|
71
|
+
if (opts.bodyFile) {
|
|
72
|
+
body = fs.readFileSync(opts.bodyFile, "utf8");
|
|
73
|
+
}
|
|
74
|
+
if (!body.trim()) {
|
|
75
|
+
throw new Error("Provide --body or --body-file");
|
|
76
|
+
}
|
|
77
|
+
const prNum = Number.parseInt(String(opts.pr), 10);
|
|
78
|
+
const out = await githubPostIssueComment(owner, repo, prNum, body.trim());
|
|
79
|
+
if (opts.json) {
|
|
80
|
+
printJson(out);
|
|
81
|
+
} else {
|
|
82
|
+
console.log(chalk.green("Posted issue comment on PR #" + prNum));
|
|
83
|
+
if (out.html_url) {
|
|
84
|
+
console.log(chalk.blue.underline(String(out.html_url)));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const reply = stack.command("reply").description("Reply to a pull review (line) comment thread");
|
|
90
|
+
reply
|
|
91
|
+
.requiredOption("--review-comment <id>", "Review comment id (from stack comments list)")
|
|
92
|
+
.option("--body <markdown>", "Reply body")
|
|
93
|
+
.option("--body-file <path>", "Read body from file")
|
|
94
|
+
.option("--repo <owner/repo>", "Override repository")
|
|
95
|
+
.option("--json", "Print API response as JSON", false)
|
|
96
|
+
.action(async (opts) => {
|
|
97
|
+
const { owner, repo } = loadStackContext(opts.repo || null);
|
|
98
|
+
let body = opts.body || "";
|
|
99
|
+
if (opts.bodyFile) {
|
|
100
|
+
body = fs.readFileSync(opts.bodyFile, "utf8");
|
|
101
|
+
}
|
|
102
|
+
if (!body.trim()) {
|
|
103
|
+
throw new Error("Provide --body or --body-file");
|
|
104
|
+
}
|
|
105
|
+
const id = Number.parseInt(String(opts.reviewComment), 10);
|
|
106
|
+
const out = await githubPostPullReviewCommentReply(owner, repo, id, body.trim());
|
|
107
|
+
if (opts.json) {
|
|
108
|
+
printJson(out);
|
|
109
|
+
} else {
|
|
110
|
+
console.log(chalk.green("Posted reply in review thread"));
|
|
111
|
+
if (out.html_url) {
|
|
112
|
+
console.log(chalk.blue.underline(String(out.html_url)));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const comments = stack.command("comments").description("List comments on stack PRs");
|
|
118
|
+
const commentsList = comments
|
|
119
|
+
.command("list")
|
|
120
|
+
.description("List issue + review comments for a PR")
|
|
121
|
+
.requiredOption("--pr <n>", "Pull request number")
|
|
122
|
+
.option("--repo <owner/repo>", "Override repository")
|
|
123
|
+
.option("--json", "JSON output", false)
|
|
124
|
+
.action(async (opts) => {
|
|
125
|
+
const { owner, repo } = loadStackContext(opts.repo || null);
|
|
126
|
+
const prNum = Number.parseInt(String(opts.pr), 10);
|
|
127
|
+
const [issueComments, reviewComments] = await Promise.all([
|
|
128
|
+
githubListIssueComments(owner, repo, prNum),
|
|
129
|
+
githubListPullReviewComments(owner, repo, prNum)
|
|
130
|
+
]);
|
|
131
|
+
const payload = { issue_comments: issueComments, review_comments: reviewComments };
|
|
132
|
+
if (opts.json) {
|
|
133
|
+
printJson(payload);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
console.log(chalk.bold.cyan(`PR #${prNum} — conversation`));
|
|
137
|
+
for (const c of issueComments) {
|
|
138
|
+
const u = c.user?.login || "?";
|
|
139
|
+
console.log(` ${chalk.dim("issue")} ${chalk.bold("#" + c.id)} ${chalk.dim(u)}: ${clip(c.body, 100)}`);
|
|
140
|
+
}
|
|
141
|
+
console.log(chalk.bold.cyan(`PR #${prNum} — review (line)`));
|
|
142
|
+
for (const c of reviewComments) {
|
|
143
|
+
const u = c.user?.login || "?";
|
|
144
|
+
console.log(
|
|
145
|
+
` ${chalk.dim("review")} ${chalk.bold(c.id)} ${chalk.dim(c.path + ":" + (c.line ?? "?"))} ${chalk.dim(u)}: ${clip(c.body, 80)}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const link = stack.command("link").description("Cross-link stacked PRs for reviewers and/or authors (issue comments)");
|
|
151
|
+
link
|
|
152
|
+
.requiredOption("--from-pr <a>", "Lower PR (where review was raised)")
|
|
153
|
+
.requiredOption("--to-pr <b>", "Upper PR (where fix lives)")
|
|
154
|
+
.option("--role <who>", "reviewer | author | both", "both")
|
|
155
|
+
.option("--review-comment <id>", "Include permalink to this review comment on --from-pr")
|
|
156
|
+
.option("--repo <owner/repo>", "Override repository")
|
|
157
|
+
.option("--no-save", "Do not append to stack.json cross_pr_links", false)
|
|
158
|
+
.option("--json", "Print created comments", false)
|
|
159
|
+
.action(async (opts) => {
|
|
160
|
+
const { root, doc, owner, repo, sorted } = loadStackContext(opts.repo || null);
|
|
161
|
+
const fromPr = Number.parseInt(String(opts.fromPr), 10);
|
|
162
|
+
const toPr = Number.parseInt(String(opts.toPr), 10);
|
|
163
|
+
assertFromBelowTo(sorted, fromPr, toPr);
|
|
164
|
+
const role = String(opts.role || "both").toLowerCase();
|
|
165
|
+
if (!["reviewer", "author", "both"].includes(role)) {
|
|
166
|
+
throw new Error("--role must be reviewer, author, or both");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const pullFrom = await getPull(owner, repo, fromPr);
|
|
170
|
+
const pullTo = await getPull(owner, repo, toPr);
|
|
171
|
+
const urlFrom = pullFrom.html_url || "";
|
|
172
|
+
const urlTo = pullTo.html_url || "";
|
|
173
|
+
|
|
174
|
+
let threadUrl = "";
|
|
175
|
+
if (opts.reviewComment) {
|
|
176
|
+
const rc = await githubGetPullReviewComment(
|
|
177
|
+
owner,
|
|
178
|
+
repo,
|
|
179
|
+
Number.parseInt(String(opts.reviewComment), 10)
|
|
180
|
+
);
|
|
181
|
+
threadUrl = rc.html_url ? String(rc.html_url) : "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const created = [];
|
|
185
|
+
|
|
186
|
+
if (role === "reviewer" || role === "both") {
|
|
187
|
+
const body =
|
|
188
|
+
`**For reviewers:** the stacked PR #${toPr} contains the follow-up work${threadUrl ? ` (thread: ${threadUrl})` : ""}.\n\n` +
|
|
189
|
+
(urlTo ? `Upper PR: ${urlTo}` : "");
|
|
190
|
+
const r = await githubPostIssueComment(owner, repo, fromPr, body);
|
|
191
|
+
created.push({ target: fromPr, audience: "reviewer", response: r });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (role === "author" || role === "both") {
|
|
195
|
+
const body =
|
|
196
|
+
`**Stack note:** addresses review feedback from PR #${fromPr}${threadUrl ? ` (${threadUrl})` : ""}.\n\n` +
|
|
197
|
+
(urlFrom ? `Lower PR: ${urlFrom}` : "");
|
|
198
|
+
const a = await githubPostIssueComment(owner, repo, toPr, body);
|
|
199
|
+
created.push({ target: toPr, audience: "author", response: a });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!opts.noSave) {
|
|
203
|
+
if (!Array.isArray(doc.cross_pr_links)) {
|
|
204
|
+
doc.cross_pr_links = [];
|
|
205
|
+
}
|
|
206
|
+
doc.cross_pr_links.push({
|
|
207
|
+
from_pr: fromPr,
|
|
208
|
+
to_pr: toPr,
|
|
209
|
+
review_comment_id: opts.reviewComment ? Number.parseInt(String(opts.reviewComment), 10) : undefined,
|
|
210
|
+
role,
|
|
211
|
+
created_at: new Date().toISOString()
|
|
212
|
+
});
|
|
213
|
+
validateStackDoc(doc);
|
|
214
|
+
writeStackFile(root, doc);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (opts.json) {
|
|
218
|
+
printJson(created);
|
|
219
|
+
} else {
|
|
220
|
+
console.log(chalk.green("Posted cross-link comments."));
|
|
221
|
+
for (const c of created) {
|
|
222
|
+
const url = c.response?.html_url;
|
|
223
|
+
if (url) {
|
|
224
|
+
console.log(chalk.dim(c.audience) + " " + chalk.blue.underline(String(url)));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const review = stack.command("review").description("Cross-PR review helpers (see fix on upper PR)");
|
|
231
|
+
|
|
232
|
+
review
|
|
233
|
+
.command("pick")
|
|
234
|
+
.description("List review (line) comments for a PR (pick an id for review show)")
|
|
235
|
+
.requiredOption("--pr <n>", "Pull request number")
|
|
236
|
+
.option("--repo <owner/repo>", "Override repository")
|
|
237
|
+
.option("--json", "JSON output", false)
|
|
238
|
+
.action(async (opts) => {
|
|
239
|
+
const { owner, repo } = loadStackContext(opts.repo || null);
|
|
240
|
+
const prNum = Number.parseInt(String(opts.pr), 10);
|
|
241
|
+
const reviewComments = await githubListPullReviewComments(owner, repo, prNum);
|
|
242
|
+
if (opts.json) {
|
|
243
|
+
printJson(reviewComments);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
console.log(chalk.bold.cyan(`Review comments on PR #${prNum}`));
|
|
247
|
+
for (const c of reviewComments) {
|
|
248
|
+
console.log(
|
|
249
|
+
`${chalk.bold(c.id)} ${chalk.dim(c.path + ":" + (c.line ?? "?"))} ${clip(c.body, 60)}`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
review
|
|
255
|
+
.command("show")
|
|
256
|
+
.description("Show review context (lower PR) vs file on upper PR head (fix)")
|
|
257
|
+
.requiredOption("--from-pr <n>", "PR where the review comment lives")
|
|
258
|
+
.requiredOption("--comment <id>", "Pull review comment id")
|
|
259
|
+
.option("--fix-pr <n>", "Upper PR containing fix (default: next in stack)")
|
|
260
|
+
.option("--repo <owner/repo>", "Override repository")
|
|
261
|
+
.option("--json", "Raw payloads", false)
|
|
262
|
+
.action(async (opts) => {
|
|
263
|
+
const { owner, repo, sorted } = loadStackContext(opts.repo || null);
|
|
264
|
+
const fromPr = Number.parseInt(String(opts.fromPr), 10);
|
|
265
|
+
const commentId = Number.parseInt(String(opts.comment), 10);
|
|
266
|
+
const fixPr = opts.fixPr
|
|
267
|
+
? Number.parseInt(String(opts.fixPr), 10)
|
|
268
|
+
: defaultFixPr(sorted, fromPr);
|
|
269
|
+
|
|
270
|
+
const c = await githubGetPullReviewComment(owner, repo, commentId);
|
|
271
|
+
const path = c.path || "";
|
|
272
|
+
if (!path) {
|
|
273
|
+
throw new Error("Review comment has no path");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const pullFix = await getPull(owner, repo, fixPr);
|
|
277
|
+
const headSha = pullFix.head?.sha || "";
|
|
278
|
+
if (!headSha) {
|
|
279
|
+
throw new Error("Could not resolve head sha for fix PR");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const fixText = await githubGetBlobText(owner, repo, path, headSha);
|
|
283
|
+
const leftBlock = [
|
|
284
|
+
chalk.bold.yellow(`Review on PR #${fromPr}`),
|
|
285
|
+
chalk.dim(c.html_url || ""),
|
|
286
|
+
"",
|
|
287
|
+
chalk.bold(path + ":" + (c.line ?? "")),
|
|
288
|
+
"",
|
|
289
|
+
chalk.dim("diff_hunk:"),
|
|
290
|
+
String(c.diff_hunk || "(none)").slice(0, 4000)
|
|
291
|
+
].join("\n");
|
|
292
|
+
|
|
293
|
+
const rightBlock = [
|
|
294
|
+
chalk.bold.green(`Same file @ PR #${fixPr} head (${headSha.slice(0, 7)})`),
|
|
295
|
+
chalk.dim(pullFix.html_url || ""),
|
|
296
|
+
"",
|
|
297
|
+
fixText
|
|
298
|
+
? clip(fixText, 12000)
|
|
299
|
+
: chalk.red("(file missing on upper head — renamed or new path?)")
|
|
300
|
+
].join("\n");
|
|
301
|
+
|
|
302
|
+
if (opts.json) {
|
|
303
|
+
printJson({ comment: c, fix_pr: pullFix, fix_snippet: fixText });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const cols = Math.max(40, Math.floor((process.stdout.columns || 100) / 2) - 4);
|
|
308
|
+
const leftLines = leftBlock.split("\n").map((l) => clip(l, cols));
|
|
309
|
+
const rightLines = rightBlock.split("\n").map((l) => clip(l, cols));
|
|
310
|
+
const maxL = Math.max(leftLines.length, rightLines.length, 1);
|
|
311
|
+
console.log(chalk.bold.cyan("Cross-PR review trace"));
|
|
312
|
+
for (let i = 0; i < maxL; i++) {
|
|
313
|
+
const L = leftLines[i] || "";
|
|
314
|
+
const R = rightLines[i] || "";
|
|
315
|
+
console.log(L.padEnd(cols + 2) + " │ " + R);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
review
|
|
320
|
+
.command("done")
|
|
321
|
+
.description("Mark a review comment id as reviewed locally (.nugit/review-state.json)")
|
|
322
|
+
.requiredOption("--comment <id>", "Pull review comment id")
|
|
323
|
+
.option("--reply <markdown>", "Also post this reply on the thread")
|
|
324
|
+
.option("--repo <owner/repo>", "Override repository (for optional reply)")
|
|
325
|
+
.option("--json", "Print state JSON", false)
|
|
326
|
+
.action(async (opts) => {
|
|
327
|
+
const { root, owner, repo } = loadStackContext(opts.repo || null);
|
|
328
|
+
const me = await authMe();
|
|
329
|
+
const login = me.login || "unknown";
|
|
330
|
+
const commentId = Number.parseInt(String(opts.comment), 10);
|
|
331
|
+
|
|
332
|
+
if (opts.reply) {
|
|
333
|
+
await githubPostPullReviewCommentReply(owner, repo, commentId, String(opts.reply));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const state = readReviewState(root);
|
|
337
|
+
if (!state.threads.some((t) => t.review_comment_id === commentId)) {
|
|
338
|
+
state.threads.push({
|
|
339
|
+
review_comment_id: commentId,
|
|
340
|
+
marked_at: new Date().toISOString(),
|
|
341
|
+
user_github_login: login
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
writeReviewState(root, state);
|
|
345
|
+
|
|
346
|
+
if (opts.json) {
|
|
347
|
+
printJson(state);
|
|
348
|
+
} else {
|
|
349
|
+
console.log(chalk.green(`Marked review comment ${commentId} as reviewed (local)`));
|
|
350
|
+
console.log(chalk.dim(reviewStatePath(root)));
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { stackJsonPath } from "./nugit-stack.js";
|
|
4
|
+
|
|
5
|
+
const INDEX_NAME = "stack-index.json";
|
|
6
|
+
const HISTORY_NAME = "stack-history.jsonl";
|
|
7
|
+
|
|
8
|
+
/** @param {string} root */
|
|
9
|
+
export function stackIndexPath(root) {
|
|
10
|
+
return path.join(root, ".nugit", INDEX_NAME);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @param {string} root */
|
|
14
|
+
export function stackHistoryPath(root) {
|
|
15
|
+
return path.join(root, ".nugit", HISTORY_NAME);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} root
|
|
20
|
+
* @param {string} repoFull owner/repo
|
|
21
|
+
* @returns {Record<string, unknown> | null}
|
|
22
|
+
*/
|
|
23
|
+
export function tryLoadStackIndex(root, repoFull) {
|
|
24
|
+
const p = stackIndexPath(root);
|
|
25
|
+
if (!fs.existsSync(p)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
30
|
+
const data = JSON.parse(raw);
|
|
31
|
+
if (!data || typeof data !== "object") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const o = /** @type {Record<string, unknown>} */ (data);
|
|
35
|
+
if (String(o.repo_full_name || "").toLowerCase() !== repoFull.toLowerCase()) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(o.stacks)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return /** @type {Record<string, unknown>} */ (data);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} root
|
|
49
|
+
* @param {Record<string, unknown>} discovered discoverStacksInRepo return shape
|
|
50
|
+
*/
|
|
51
|
+
export function writeStackIndex(root, discovered) {
|
|
52
|
+
const dir = path.join(root, ".nugit");
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
const payload = {
|
|
55
|
+
version: 1,
|
|
56
|
+
generated_at: new Date().toISOString(),
|
|
57
|
+
...discovered
|
|
58
|
+
};
|
|
59
|
+
fs.writeFileSync(stackIndexPath(root), JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} root
|
|
64
|
+
* @param {{
|
|
65
|
+
* action: string,
|
|
66
|
+
* repo_full_name: string,
|
|
67
|
+
* snapshot?: Record<string, unknown>,
|
|
68
|
+
* parent_record_id?: string,
|
|
69
|
+
* tip_pr_number?: number,
|
|
70
|
+
* head_branch?: string
|
|
71
|
+
* }} record
|
|
72
|
+
* @returns {string} new record id
|
|
73
|
+
*/
|
|
74
|
+
export function appendStackHistory(root, record) {
|
|
75
|
+
const dir = path.join(root, ".nugit");
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
78
|
+
const line = JSON.stringify({
|
|
79
|
+
schema_version: 1,
|
|
80
|
+
id,
|
|
81
|
+
at: new Date().toISOString(),
|
|
82
|
+
...record
|
|
83
|
+
});
|
|
84
|
+
fs.appendFileSync(stackHistoryPath(root), line + "\n", "utf8");
|
|
85
|
+
return id;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} root
|
|
90
|
+
* @returns {unknown[]}
|
|
91
|
+
*/
|
|
92
|
+
export function readStackHistoryLines(root) {
|
|
93
|
+
const p = stackHistoryPath(root);
|
|
94
|
+
if (!fs.existsSync(p)) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const lines = fs.readFileSync(p, "utf8").split("\n").filter(Boolean);
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
try {
|
|
101
|
+
out.push(JSON.parse(line));
|
|
102
|
+
} catch {
|
|
103
|
+
/* skip */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build directed graph from discovery + history (forward: bottom→top within stack; backward: history parent links).
|
|
111
|
+
* @param {Record<string, unknown> | null | undefined} discovered
|
|
112
|
+
* @param {unknown[]} [historyRecords]
|
|
113
|
+
*/
|
|
114
|
+
export function compileStackGraph(discovered, historyRecords = []) {
|
|
115
|
+
/** @type {{ id: string, type: string, tip_pr?: number, prs?: number[], meta?: Record<string, unknown> }[]} */
|
|
116
|
+
const nodes = [];
|
|
117
|
+
/** @type {{ from: string, to: string, kind: string }[]} */
|
|
118
|
+
const edges = [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
|
|
121
|
+
const stacks = discovered && Array.isArray(discovered.stacks) ? discovered.stacks : [];
|
|
122
|
+
for (const s of stacks) {
|
|
123
|
+
if (!s || typeof s !== "object") continue;
|
|
124
|
+
const st = /** @type {Record<string, unknown>} */ (s);
|
|
125
|
+
const tip = st.tip_pr_number;
|
|
126
|
+
if (typeof tip !== "number") continue;
|
|
127
|
+
const id = `stack_tip_${tip}`;
|
|
128
|
+
const prRows = Array.isArray(st.prs) ? st.prs : [];
|
|
129
|
+
const prNums = prRows
|
|
130
|
+
.map((p) => (p && typeof p === "object" ? /** @type {{ pr_number?: number }} */ (p).pr_number : undefined))
|
|
131
|
+
.filter((n) => typeof n === "number");
|
|
132
|
+
if (!seen.has(id)) {
|
|
133
|
+
seen.add(id);
|
|
134
|
+
nodes.push({
|
|
135
|
+
id,
|
|
136
|
+
type: "stack",
|
|
137
|
+
tip_pr: tip,
|
|
138
|
+
prs: prNums,
|
|
139
|
+
meta: {
|
|
140
|
+
tip_head_branch: st.tip_head_branch,
|
|
141
|
+
repo_full_name: discovered?.repo_full_name
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
for (let i = 0; i < prNums.length - 1; i++) {
|
|
146
|
+
const a = `pr_${prNums[i]}`;
|
|
147
|
+
const b = `pr_${prNums[i + 1]}`;
|
|
148
|
+
if (!seen.has(a)) {
|
|
149
|
+
seen.add(a);
|
|
150
|
+
nodes.push({ id: a, type: "pr", tip_pr: prNums[i] });
|
|
151
|
+
}
|
|
152
|
+
if (!seen.has(b)) {
|
|
153
|
+
seen.add(b);
|
|
154
|
+
nodes.push({ id: b, type: "pr", tip_pr: prNums[i + 1] });
|
|
155
|
+
}
|
|
156
|
+
edges.push({ from: a, to: b, kind: "stack_above" });
|
|
157
|
+
}
|
|
158
|
+
if (prNums.length === 1) {
|
|
159
|
+
const a = `pr_${prNums[0]}`;
|
|
160
|
+
if (!seen.has(a)) {
|
|
161
|
+
seen.add(a);
|
|
162
|
+
nodes.push({ id: a, type: "pr", tip_pr: prNums[0] });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const rec of historyRecords) {
|
|
168
|
+
if (!rec || typeof rec !== "object") continue;
|
|
169
|
+
const r = /** @type {Record<string, unknown>} */ (rec);
|
|
170
|
+
const hid = typeof r.id === "string" ? r.id : null;
|
|
171
|
+
if (!hid) continue;
|
|
172
|
+
const nid = `hist_${hid}`;
|
|
173
|
+
if (!seen.has(nid)) {
|
|
174
|
+
seen.add(nid);
|
|
175
|
+
nodes.push({
|
|
176
|
+
id: nid,
|
|
177
|
+
type: "history",
|
|
178
|
+
meta: { action: r.action, at: r.at }
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const parent = typeof r.parent_record_id === "string" ? r.parent_record_id : null;
|
|
182
|
+
if (parent) {
|
|
183
|
+
edges.push({ from: `hist_${parent}`, to: nid, kind: "history_next" });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { nodes, edges, generated_at: new Date().toISOString() };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Snapshot current stack.json from disk into history (optional helper).
|
|
192
|
+
* @param {string} root
|
|
193
|
+
* @param {string} action
|
|
194
|
+
* @param {string} [parentId]
|
|
195
|
+
*/
|
|
196
|
+
export function snapshotStackFileToHistory(root, action, parentId) {
|
|
197
|
+
const p = stackJsonPath(root);
|
|
198
|
+
if (!fs.existsSync(p)) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
let doc = null;
|
|
202
|
+
try {
|
|
203
|
+
doc = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const repo = doc && typeof doc.repo_full_name === "string" ? doc.repo_full_name : "";
|
|
208
|
+
return appendStackHistory(root, {
|
|
209
|
+
action,
|
|
210
|
+
repo_full_name: repo,
|
|
211
|
+
snapshot: doc,
|
|
212
|
+
parent_record_id: parentId
|
|
213
|
+
});
|
|
214
|
+
}
|