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.
@@ -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
+ }