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,421 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import chalk from "chalk";
4
+ import { openUrl } from "./open-url.js";
5
+
6
+ /** @returns {{ next: null | Record<string, unknown> }} */
7
+ export function createExitPayload() {
8
+ return { next: null };
9
+ }
10
+
11
+ /**
12
+ * @param {object} props
13
+ * @param {Awaited<ReturnType<import('./fetch-pr-data.js').fetchStackPrDetails>>} props.rows
14
+ * @param {{ next: null | Record<string, unknown> }} props.exitPayload
15
+ */
16
+ export function StackInkApp({ rows, exitPayload }) {
17
+ const { exit } = useApp();
18
+ const [prIndex, setPrIndex] = useState(0);
19
+ const [tab, setTab] = useState(0);
20
+ const [lineIndex, setLineIndex] = useState(0);
21
+ const [fileIndex, setFileIndex] = useState(0);
22
+ const [filePatchOffset, setFilePatchOffset] = useState(0);
23
+ const [fileCommentIndex, setFileCommentIndex] = useState(0);
24
+
25
+ const len = rows?.length ?? 0;
26
+ const safePr = len === 0 ? 0 : Math.min(prIndex, len - 1);
27
+ const row = len ? rows[safePr] : null;
28
+ const issueList = row?.issueComments || [];
29
+ const reviewList = row?.reviewComments || [];
30
+ const fileList = row?.files || [];
31
+
32
+ const listLen = useMemo(() => {
33
+ if (tab === 1) {
34
+ return issueList.length;
35
+ }
36
+ if (tab === 2) {
37
+ return reviewList.length;
38
+ }
39
+ if (tab === 3) {
40
+ return fileList.length;
41
+ }
42
+ return 0;
43
+ }, [tab, issueList.length, reviewList.length, fileList.length]);
44
+
45
+ const safeLine = Math.min(lineIndex, Math.max(0, listLen - 1));
46
+ const safeFile = fileList.length === 0 ? 0 : Math.min(fileIndex, fileList.length - 1);
47
+ const selectedFile = fileList[safeFile] || null;
48
+ const patchLines =
49
+ selectedFile && typeof selectedFile.patch === "string"
50
+ ? String(selectedFile.patch).split("\n")
51
+ : [];
52
+ const patchPageSize = 12;
53
+ const maxPatchOffset = Math.max(0, patchLines.length - patchPageSize);
54
+ const fileComments = useMemo(() => {
55
+ if (!selectedFile) return [];
56
+ const fileName = String(selectedFile.filename || "");
57
+ return reviewList.filter((c) => String(c?.path || "") === fileName);
58
+ }, [selectedFile, reviewList]);
59
+ const safeFileComment = fileComments.length
60
+ ? Math.min(fileCommentIndex, fileComments.length - 1)
61
+ : 0;
62
+
63
+ /**
64
+ * Best-effort patch scroll target for a code line.
65
+ * @param {string[]} lines
66
+ * @param {number} lineNo
67
+ */
68
+ const patchOffsetForLine = (lines, lineNo) => {
69
+ if (!lineNo || !Number.isInteger(lineNo) || lineNo < 1) return 0;
70
+ let newLine = 0;
71
+ for (let i = 0; i < lines.length; i++) {
72
+ const t = lines[i];
73
+ if (t.startsWith("@@")) {
74
+ const m = t.match(/\+(\d+)/);
75
+ if (m) newLine = Number.parseInt(m[1], 10) - 1;
76
+ continue;
77
+ }
78
+ if (t.startsWith("+") || t.startsWith(" ")) {
79
+ newLine += 1;
80
+ }
81
+ if (newLine >= lineNo) {
82
+ return Math.max(0, i - 2);
83
+ }
84
+ }
85
+ return 0;
86
+ };
87
+
88
+ useInput((input, key) => {
89
+ if (input === "q" || key.escape) {
90
+ exitPayload.next = { type: "quit" };
91
+ exit();
92
+ return;
93
+ }
94
+ if (input === "u") {
95
+ exitPayload.next = { type: "refresh" };
96
+ exit();
97
+ return;
98
+ }
99
+ if (key.tab) {
100
+ setTab((t) => (t + 1) % 4);
101
+ setLineIndex(0);
102
+ setFilePatchOffset(0);
103
+ setFileCommentIndex(0);
104
+ return;
105
+ }
106
+ if (input === "[") {
107
+ setTab((t) => (t + 3) % 4);
108
+ setLineIndex(0);
109
+ setFilePatchOffset(0);
110
+ setFileCommentIndex(0);
111
+ return;
112
+ }
113
+ if (input === "]") {
114
+ setTab((t) => (t + 1) % 4);
115
+ setLineIndex(0);
116
+ setFilePatchOffset(0);
117
+ setFileCommentIndex(0);
118
+ return;
119
+ }
120
+
121
+ if (input === "j" || key.downArrow) {
122
+ if (tab === 0 && len > 0) {
123
+ setPrIndex((i) => Math.min(i + 1, len - 1));
124
+ } else if (tab === 3) {
125
+ setFilePatchOffset((i) => Math.min(i + 1, maxPatchOffset));
126
+ } else {
127
+ setLineIndex((i) => Math.min(i + 1, Math.max(0, listLen - 1)));
128
+ }
129
+ return;
130
+ }
131
+ if (input === "k" || key.upArrow) {
132
+ if (tab === 0 && len > 0) {
133
+ setPrIndex((i) => Math.max(i - 1, 0));
134
+ } else if (tab === 3) {
135
+ setFilePatchOffset((i) => Math.max(i - 1, 0));
136
+ } else {
137
+ setLineIndex((i) => Math.max(i - 1, 0));
138
+ }
139
+ return;
140
+ }
141
+
142
+ if (input === "o" && row && !row.error) {
143
+ setTab(3);
144
+ setFileIndex(0);
145
+ setFilePatchOffset(0);
146
+ setFileCommentIndex(0);
147
+ return;
148
+ }
149
+
150
+ if (tab === 3 && input === "n") {
151
+ setFileIndex((i) => Math.min(i + 1, Math.max(0, fileList.length - 1)));
152
+ setFilePatchOffset(0);
153
+ setFileCommentIndex(0);
154
+ return;
155
+ }
156
+ if (tab === 3 && input === "p") {
157
+ setFileIndex((i) => Math.max(i - 1, 0));
158
+ setFilePatchOffset(0);
159
+ setFileCommentIndex(0);
160
+ return;
161
+ }
162
+ if (tab === 3 && input === "m" && fileComments.length) {
163
+ setFileCommentIndex((i) => (i + 1) % fileComments.length);
164
+ const c = fileComments[(safeFileComment + 1) % fileComments.length];
165
+ const lineNo = c?.line ?? c?.original_line ?? 0;
166
+ setFilePatchOffset(Math.min(maxPatchOffset, patchOffsetForLine(patchLines, Number(lineNo) || 0)));
167
+ return;
168
+ }
169
+ if (tab === 3 && input === "M" && fileComments.length) {
170
+ const next = (safeFileComment - 1 + fileComments.length) % fileComments.length;
171
+ setFileCommentIndex(next);
172
+ const c = fileComments[next];
173
+ const lineNo = c?.line ?? c?.original_line ?? 0;
174
+ setFilePatchOffset(Math.min(maxPatchOffset, patchOffsetForLine(patchLines, Number(lineNo) || 0)));
175
+ return;
176
+ }
177
+
178
+ if (input === "l" && tab === 2) {
179
+ const c = reviewList[safeLine];
180
+ if (c?.html_url) {
181
+ openUrl(c.html_url);
182
+ }
183
+ return;
184
+ }
185
+ if (input === "g" && tab === 2 && row && !row.error) {
186
+ const c = reviewList[safeLine];
187
+ const targetPath = String(c?.path || "");
188
+ if (targetPath) {
189
+ const idx = fileList.findIndex((f) => String(f?.filename || "") === targetPath);
190
+ if (idx >= 0) {
191
+ setTab(3);
192
+ setFileIndex(idx);
193
+ const selected = fileList[idx];
194
+ const lines = typeof selected?.patch === "string" ? String(selected.patch).split("\n") : [];
195
+ const lineNo = c?.line ?? c?.original_line ?? 0;
196
+ const off = patchOffsetForLine(lines, Number(lineNo) || 0);
197
+ setFilePatchOffset(off);
198
+ const fcIdx = reviewList
199
+ .filter((rc) => String(rc?.path || "") === targetPath)
200
+ .findIndex((rc) => rc?.id === c?.id);
201
+ setFileCommentIndex(fcIdx >= 0 ? fcIdx : 0);
202
+ }
203
+ }
204
+ return;
205
+ }
206
+
207
+ if (input === "S" && tab === 0 && row && !row.error) {
208
+ exitPayload.next = {
209
+ type: "split",
210
+ prNumber: row.entry.pr_number
211
+ };
212
+ exit();
213
+ return;
214
+ }
215
+
216
+ if (input === "r" && row && !row.error) {
217
+ exitPayload.next = {
218
+ type: "issue_comment",
219
+ prNumber: row.entry.pr_number
220
+ };
221
+ exit();
222
+ return;
223
+ }
224
+
225
+ if (input === "R" && row && !row.error) {
226
+ exitPayload.next = {
227
+ type: "request_reviewers",
228
+ prNumber: row.entry.pr_number
229
+ };
230
+ exit();
231
+ return;
232
+ }
233
+
234
+ if (input === "t" && tab === 2 && row && !row.error) {
235
+ const c = reviewList[safeLine];
236
+ if (c?.id != null) {
237
+ exitPayload.next = {
238
+ type: "review_reply",
239
+ commentId: c.id
240
+ };
241
+ exit();
242
+ }
243
+ }
244
+ });
245
+
246
+ if (len === 0) {
247
+ return React.createElement(
248
+ Box,
249
+ { flexDirection: "column", padding: 1 },
250
+ React.createElement(Text, { color: "red" }, "No PRs in stack."),
251
+ React.createElement(Text, { dimColor: true }, "Press q to quit.")
252
+ );
253
+ }
254
+
255
+ const ladder = rows.map((r, i) => {
256
+ const mark = i === safePr ? "▶" : " ";
257
+ const err = r.error ? ` ${r.error}` : "";
258
+ const title = r.pull?.title || err || "(loading)";
259
+ const num = r.entry.pr_number;
260
+ const st = r.pull?.draft ? "draft" : r.pull?.state || "?";
261
+ const reviewState = String(r.reviewSummary || "none");
262
+ const badge =
263
+ reviewState === "approved"
264
+ ? chalk.green("A")
265
+ : reviewState === "changes_requested"
266
+ ? chalk.red("CR")
267
+ : reviewState === "commented" || (r.reviewComments?.length || 0) > 0
268
+ ? chalk.yellow("C")
269
+ : chalk.dim("-");
270
+ return `${mark} #${num} [${st}] ${badge} ${title.slice(0, 56)}`;
271
+ });
272
+
273
+ const tabName = ["overview", "conversation", "review", "files"][tab];
274
+ let bodyLines = [];
275
+ if (row?.error) {
276
+ bodyLines = [row.error];
277
+ } else if (tab === 0 && row?.pull) {
278
+ const p = row.pull;
279
+ bodyLines = [
280
+ `Title: ${p.title || ""}`,
281
+ `Head: ${p.head?.ref || ""} Base: ${p.base?.ref || ""}`,
282
+ `Comments: ${issueList.length} conv / ${reviewList.length} review (line)`
283
+ ];
284
+ const rs = String(row.reviewSummary || "none");
285
+ bodyLines.push(
286
+ `Review: ${
287
+ rs === "approved"
288
+ ? chalk.green("approved")
289
+ : rs === "changes_requested"
290
+ ? chalk.red("changes requested")
291
+ : rs === "commented"
292
+ ? chalk.yellow("commented")
293
+ : chalk.dim("no review state")
294
+ }`
295
+ );
296
+ } else if (tab === 1) {
297
+ issueList.forEach((c, i) => {
298
+ const mark = i === safeLine ? ">" : " ";
299
+ const who = c.user?.login || "?";
300
+ const one = (c.body || "").split("\n")[0].slice(0, 70);
301
+ bodyLines.push(`${mark} @${who}: ${one}`);
302
+ });
303
+ if (bodyLines.length === 0) {
304
+ bodyLines.push("(no issue comments)");
305
+ }
306
+ } else if (tab === 2) {
307
+ reviewList.forEach((c, i) => {
308
+ const mark = i === safeLine ? ">" : " ";
309
+ const path = c.path || "?";
310
+ const ln = c.line ?? c.original_line ?? "?";
311
+ const one = (c.body || "").split("\n")[0].slice(0, 50);
312
+ bodyLines.push(`${mark} ${path}:${ln} ${one}`);
313
+ });
314
+ if (bodyLines.length === 0) {
315
+ bodyLines.push("(no review comments)");
316
+ bodyLines.push(chalk.dim("Tip: press g on a review comment to jump to file diff"));
317
+ }
318
+ } else if (tab === 3) {
319
+ const start = Math.max(0, safeFile - 2);
320
+ const end = Math.min(fileList.length, start + 5);
321
+ for (let i = start; i < end; i++) {
322
+ const f = fileList[i];
323
+ const mark = i === safeFile ? ">" : " ";
324
+ const name = String(f.filename || "?");
325
+ const st = String(f.status || "?");
326
+ const statusColor =
327
+ st === "added"
328
+ ? chalk.green
329
+ : st === "removed"
330
+ ? chalk.red
331
+ : st === "renamed"
332
+ ? chalk.yellow
333
+ : chalk.cyan;
334
+ const ch = `${chalk.green("+" + String(f.additions ?? 0))} ${chalk.red("-" + String(f.deletions ?? 0))}`;
335
+ bodyLines.push(`${mark} ${statusColor(name)} ${chalk.dim("[" + st + "]")} ${ch}`);
336
+ }
337
+ if (fileList.length === 0) {
338
+ bodyLines.push("(no changed files)");
339
+ } else if (selectedFile) {
340
+ bodyLines.push("");
341
+ bodyLines.push(
342
+ `${chalk.bold("Patch:")} ${String(selectedFile.filename || "?")} ` +
343
+ chalk.dim(`(${filePatchOffset + 1}-${Math.min(filePatchOffset + patchPageSize, patchLines.length)} / ${patchLines.length || 0})`)
344
+ );
345
+ const patch = typeof selectedFile.patch === "string" ? selectedFile.patch : "";
346
+ if (!patch) {
347
+ bodyLines.push("(patch not available from GitHub API for this file)");
348
+ } else {
349
+ const page = patchLines.slice(filePatchOffset, filePatchOffset + patchPageSize);
350
+ for (const p of page) {
351
+ let line = p;
352
+ if (line.startsWith("+++ ") || line.startsWith("--- ")) {
353
+ line = chalk.bold(line);
354
+ } else if (line.startsWith("@@")) {
355
+ line = chalk.cyan(line);
356
+ } else if (line.startsWith("+")) {
357
+ line = chalk.green(line);
358
+ } else if (line.startsWith("-")) {
359
+ line = chalk.red(line);
360
+ } else {
361
+ line = chalk.dim(line);
362
+ }
363
+ bodyLines.push(line.slice(0, 130));
364
+ }
365
+ if (filePatchOffset + patchPageSize < patchLines.length) {
366
+ bodyLines.push(chalk.dim("... more below (j/k scroll)"));
367
+ }
368
+ }
369
+ if (fileComments.length) {
370
+ bodyLines.push("");
371
+ const c = fileComments[safeFileComment];
372
+ bodyLines.push(
373
+ `${chalk.bold("Comment")} ${safeFileComment + 1}/${fileComments.length} ` +
374
+ chalk.dim(
375
+ `line ${c?.line ?? c?.original_line ?? "?"} by @${c?.user?.login || "?"}`
376
+ )
377
+ );
378
+ bodyLines.push(String(c?.body || "").split("\n")[0].slice(0, 100));
379
+ }
380
+ }
381
+ }
382
+
383
+ const selectedReview = tab === 2 ? reviewList[safeLine] : null;
384
+ const helpParts = ["Tab/[] tabs", "o files view"];
385
+ if (tab === 0 || tab === 1 || tab === 2) {
386
+ helpParts.unshift("j/k PR or line");
387
+ }
388
+ if (tab === 3) {
389
+ helpParts.unshift("j/k scroll patch", "n/p file", "m/M comment");
390
+ }
391
+ if (row && !row.error) {
392
+ if (tab === 0) {
393
+ helpParts.push("S split PR");
394
+ }
395
+ helpParts.push("r comment", "R Assign Reviewers");
396
+ }
397
+ if (tab === 2 && selectedReview?.html_url) {
398
+ helpParts.push("l open line");
399
+ }
400
+ if (tab === 2 && selectedReview?.id != null && row && !row.error) {
401
+ helpParts.push("t reply thread", "g jump to file");
402
+ }
403
+ helpParts.push("u refresh", "q quit");
404
+ const help = helpParts.join(" | ");
405
+
406
+ return React.createElement(
407
+ Box,
408
+ { flexDirection: "column", padding: 1 },
409
+ React.createElement(Text, { color: "cyan", bold: true }, "nugit stack view"),
410
+ React.createElement(Text, { dimColor: true }, ladder.join("\n")),
411
+ React.createElement(Text, { color: "magenta" }, `Tab: ${tabName}`),
412
+ React.createElement(
413
+ Box,
414
+ { marginTop: 1, flexDirection: "column" },
415
+ ...bodyLines.slice(0, 14).map((line, idx) =>
416
+ React.createElement(Text, { key: String(idx) }, line)
417
+ )
418
+ ),
419
+ React.createElement(Text, { dimColor: true, marginTop: 1 }, help)
420
+ );
421
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "fs";
2
+ import { decodeGithubFileContent } from "../api-client.js";
3
+ import { githubGetContents } from "../github-rest.js";
4
+ import {
5
+ findGitRoot,
6
+ parseRepoFullName,
7
+ readStackFile,
8
+ stackJsonPath,
9
+ validateStackDoc
10
+ } from "../nugit-stack.js";
11
+
12
+ /**
13
+ * Load stack.json from GitHub Contents API.
14
+ * @param {string} repoFull owner/repo
15
+ * @param {string} ref branch or sha
16
+ */
17
+ export async function fetchStackDocFromGithub(repoFull, ref) {
18
+ const { owner, repo } = parseRepoFullName(repoFull);
19
+ const item = await githubGetContents(owner, repo, ".nugit/stack.json", ref);
20
+ const text = decodeGithubFileContent(item);
21
+ if (!text) {
22
+ throw new Error("Could not decode .nugit/stack.json from GitHub");
23
+ }
24
+ const doc = JSON.parse(text);
25
+ validateStackDoc(doc);
26
+ return doc;
27
+ }
28
+
29
+ /**
30
+ * If document is a propagated prefix, fetch full stack from layer.tip.head_branch.
31
+ * @param {Record<string, unknown>} doc
32
+ */
33
+ export async function expandStackDocIfPrefix(doc) {
34
+ const layer = doc.layer;
35
+ if (!layer || typeof layer !== "object" || !layer.tip || typeof layer.tip !== "object") {
36
+ return doc;
37
+ }
38
+ const tip = /** @type {{ head_branch?: string }} */ (layer.tip);
39
+ const stackSize = layer.stack_size;
40
+ const prs = doc.prs;
41
+ if (
42
+ typeof stackSize !== "number" ||
43
+ !Array.isArray(prs) ||
44
+ prs.length >= stackSize ||
45
+ typeof tip.head_branch !== "string" ||
46
+ !tip.head_branch.trim()
47
+ ) {
48
+ return doc;
49
+ }
50
+ const { owner, repo } = parseRepoFullName(doc.repo_full_name);
51
+ const item = await githubGetContents(
52
+ owner,
53
+ repo,
54
+ ".nugit/stack.json",
55
+ tip.head_branch.trim()
56
+ );
57
+ const text = decodeGithubFileContent(item);
58
+ if (!text) {
59
+ return doc;
60
+ }
61
+ try {
62
+ const full = JSON.parse(text);
63
+ validateStackDoc(full);
64
+ return full;
65
+ } catch {
66
+ return doc;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * @param {{ root?: string | null, repo?: string, ref?: string, file?: string }} opts
72
+ */
73
+ export async function loadStackDocForView(opts) {
74
+ let doc = null;
75
+ const root = opts.root ?? findGitRoot();
76
+
77
+ if (opts.file) {
78
+ const raw = fs.readFileSync(opts.file, "utf8");
79
+ doc = JSON.parse(raw);
80
+ validateStackDoc(doc);
81
+ } else if (opts.repo && opts.ref) {
82
+ doc = await fetchStackDocFromGithub(opts.repo, opts.ref);
83
+ } else if (root) {
84
+ const p = stackJsonPath(root);
85
+ if (!fs.existsSync(p)) {
86
+ throw new Error(`No ${p}; run nugit init or pass --repo OWNER/REPO --ref BRANCH`);
87
+ }
88
+ doc = readStackFile(root);
89
+ if (!doc) {
90
+ throw new Error("Empty stack file");
91
+ }
92
+ validateStackDoc(doc);
93
+ } else {
94
+ throw new Error(
95
+ "Not in a git repo: pass --file path/to/stack.json or --repo owner/repo --ref branch"
96
+ );
97
+ }
98
+
99
+ doc = await expandStackDocIfPrefix(doc);
100
+ return { doc, root: root || null };
101
+ }
@@ -0,0 +1,18 @@
1
+ import { spawn } from "child_process";
2
+
3
+ /**
4
+ * @param {string | undefined} href
5
+ */
6
+ export function openUrl(href) {
7
+ if (!href || typeof href !== "string") {
8
+ return;
9
+ }
10
+ const plat = process.platform;
11
+ if (plat === "darwin") {
12
+ spawn("open", [href], { detached: true, stdio: "ignore" }).unref();
13
+ } else if (plat === "win32") {
14
+ spawn("cmd", ["/c", "start", "", href], { detached: true, stdio: "ignore" }).unref();
15
+ } else {
16
+ spawn("xdg-open", [href], { detached: true, stdio: "ignore" }).unref();
17
+ }
18
+ }
@@ -0,0 +1,47 @@
1
+ import fs from "fs";
2
+ import readline from "readline";
3
+
4
+ /**
5
+ * @param {string} prompt
6
+ */
7
+ export function questionLine(prompt) {
8
+ return new Promise((resolve) => {
9
+ let input = process.stdin;
10
+ let output = process.stdout;
11
+ let closeTty = null;
12
+ // Prefer direct TTY streams so prompts remain stable after Ink unmount/mode switches.
13
+ try {
14
+ if (process.stdin.isTTY) {
15
+ const inFd = fs.openSync("/dev/tty", "r");
16
+ const outFd = fs.openSync("/dev/tty", "w");
17
+ input = fs.createReadStream("", { fd: inFd, autoClose: true });
18
+ output = fs.createWriteStream("", { fd: outFd, autoClose: true });
19
+ closeTty = () => {
20
+ try {
21
+ input.destroy();
22
+ } catch {}
23
+ try {
24
+ output.end();
25
+ } catch {}
26
+ };
27
+ }
28
+ } catch {
29
+ // Fallback to process stdio.
30
+ }
31
+ const rl = readline.createInterface({
32
+ input,
33
+ output,
34
+ terminal: true
35
+ });
36
+ if (input && typeof input.resume === "function") {
37
+ input.resume();
38
+ }
39
+ rl.question(prompt, (ans) => {
40
+ rl.close();
41
+ if (closeTty) {
42
+ closeTty();
43
+ }
44
+ resolve(ans);
45
+ });
46
+ });
47
+ }