nugit-cli 0.0.1 → 0.1.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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -23
  3. package/src/github-device-flow.js +1 -1
  4. package/src/github-oauth-client-id.js +11 -0
  5. package/src/github-pr-social.js +42 -0
  6. package/src/github-rest.js +149 -6
  7. package/src/nugit-config.js +84 -0
  8. package/src/nugit-stack.js +40 -257
  9. package/src/nugit.js +104 -647
  10. package/src/review-hub/review-autoapprove.js +95 -0
  11. package/src/review-hub/review-hub-back.js +10 -0
  12. package/src/review-hub/review-hub-ink.js +169 -0
  13. package/src/review-hub/run-review-hub.js +131 -0
  14. package/src/services/repo-branches.js +151 -0
  15. package/src/services/stack-inference.js +90 -0
  16. package/src/split-view/run-split.js +14 -76
  17. package/src/split-view/split-ink.js +2 -2
  18. package/src/stack-infer-from-prs.js +71 -0
  19. package/src/stack-view/diff-line-map.js +62 -0
  20. package/src/stack-view/fetch-pr-data.js +104 -4
  21. package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
  22. package/src/stack-view/ink-app.js +3 -421
  23. package/src/stack-view/loader.js +19 -93
  24. package/src/stack-view/loading-ink.js +2 -0
  25. package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
  26. package/src/stack-view/patch-preview-merge.js +108 -0
  27. package/src/stack-view/remote-infer-doc.js +76 -0
  28. package/src/stack-view/repo-picker-back.js +10 -0
  29. package/src/stack-view/run-stack-view.js +508 -150
  30. package/src/stack-view/run-view-entry.js +115 -0
  31. package/src/stack-view/sgr-mouse.js +56 -0
  32. package/src/stack-view/stack-branch-graph.js +95 -0
  33. package/src/stack-view/stack-pick-graph.js +93 -0
  34. package/src/stack-view/stack-pick-ink.js +308 -0
  35. package/src/stack-view/stack-pick-layout.js +19 -0
  36. package/src/stack-view/stack-pick-sort.js +188 -0
  37. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  38. package/src/stack-view/terminal-fullscreen.js +7 -0
  39. package/src/stack-view/tree-ascii.js +73 -0
  40. package/src/stack-view/view-md-plain.js +23 -0
  41. package/src/stack-view/view-repo-picker-ink.js +293 -0
  42. package/src/stack-view/view-tui-sequential.js +126 -0
  43. package/src/tui/pages/home.js +122 -0
  44. package/src/tui/pages/repo-actions.js +81 -0
  45. package/src/tui/pages/repo-branches.js +259 -0
  46. package/src/tui/pages/viewer.js +2129 -0
  47. package/src/tui/router.js +40 -0
  48. package/src/tui/run-tui.js +281 -0
  49. package/src/utilities/loading.js +37 -0
  50. package/src/utilities/terminal.js +31 -0
  51. package/src/cli-output.js +0 -228
  52. package/src/nugit-start.js +0 -211
  53. package/src/stack-discover.js +0 -284
  54. package/src/stack-discovery-config.js +0 -91
  55. package/src/stack-extra-commands.js +0 -353
  56. package/src/stack-graph.js +0 -214
  57. package/src/stack-helpers.js +0 -58
  58. package/src/stack-propagate.js +0 -422
@@ -0,0 +1,40 @@
1
+ /**
2
+ * FSM router for the nugit TUI.
3
+ *
4
+ * States:
5
+ * home → opening screen: cwd view, review hub, remote search, quit
6
+ * loading → full-screen loading animation (transient between states)
7
+ * repo_search → GitHub repo search (pick a remote repo to view)
8
+ * review_hub → review hub repo list
9
+ * viewer → stack viewer (entered after a repo + stack is resolved)
10
+ * split_flow → split TUI (entered from within viewer)
11
+ *
12
+ * Transitions are driven by a simple async loop in run-tui.js. Each state
13
+ * returns a "next state" descriptor; the router applies loading + clear
14
+ * between major screen transitions automatically.
15
+ */
16
+
17
+ /**
18
+ * @typedef {"home" | "repo_search" | "review_hub" | "viewer" | "done"} TuiState
19
+ *
20
+ * @typedef {{ state: TuiState, repo?: string, ref?: string }} RouterCtx
21
+ */
22
+
23
+ /**
24
+ * Initial router context.
25
+ * @returns {RouterCtx}
26
+ */
27
+ export function createRouterCtx() {
28
+ return { state: "home" };
29
+ }
30
+
31
+ /**
32
+ * Transition to a new state, returning an updated context.
33
+ * @param {RouterCtx} ctx
34
+ * @param {TuiState} next
35
+ * @param {Partial<RouterCtx>} [extra]
36
+ * @returns {RouterCtx}
37
+ */
38
+ export function transition(ctx, next, extra = {}) {
39
+ return { ...ctx, state: next, ...extra };
40
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Main TUI entry point.
3
+ *
4
+ * Manages navigation via an explicit navStack so that Backspace always returns
5
+ * to the immediately previous page. The alternate screen is owned exclusively
6
+ * here — individual pages never enter/leave it.
7
+ *
8
+ * navStack entry shapes:
9
+ * { screen: "home" }
10
+ * { screen: "review_hub" }
11
+ * { screen: "repo_search" }
12
+ * { screen: "repo_actions", repo: string, ref?: string, viewerLogin?: string }
13
+ * { screen: "viewer", repo: string, ref?: string, viewerLogin?: string }
14
+ * { screen: "branches", repo: string }
15
+ */
16
+ import React from "react";
17
+ import { render } from "ink";
18
+ import {
19
+ enterAlternateScreen,
20
+ leaveAlternateScreen,
21
+ clearInkScreen
22
+ } from "../utilities/terminal.js";
23
+ import { withLoadingScreen } from "../utilities/loading.js";
24
+ import { runHomePage } from "./pages/home.js";
25
+
26
+ /**
27
+ * @typedef {{ screen: "home" }} HomeEntry
28
+ * @typedef {{ screen: "review_hub" }} ReviewHubEntry
29
+ * @typedef {{ screen: "repo_search" }} RepoSearchEntry
30
+ * @typedef {{ screen: "repo_actions", repo: string, ref?: string, viewerLogin?: string }} RepoActionsEntry
31
+ * @typedef {{ screen: "viewer", repo: string, ref?: string, viewerLogin?: string }} ViewerEntry
32
+ * @typedef {{ screen: "branches", repo: string }} BranchesEntry
33
+ * @typedef {HomeEntry | ReviewHubEntry | RepoSearchEntry | RepoActionsEntry | ViewerEntry | BranchesEntry} NavEntry
34
+ */
35
+
36
+ /**
37
+ * @typedef {{ startAt?: "home" | "review_hub", startRepo?: string, startRef?: string }} TuiOpts
38
+ */
39
+
40
+ /**
41
+ * Run the full TUI loop until the user quits.
42
+ * @param {TuiOpts} [opts]
43
+ */
44
+ export async function runNugitTui(opts = {}) {
45
+ enterAlternateScreen(process.stdout);
46
+ try {
47
+ await _navLoop(opts);
48
+ } finally {
49
+ leaveAlternateScreen(process.stdout);
50
+ }
51
+ }
52
+
53
+ /** @param {TuiOpts} opts */
54
+ async function _navLoop(opts) {
55
+ /** @type {NavEntry[]} */
56
+ let navStack;
57
+
58
+ if (opts.startRepo) {
59
+ navStack = [
60
+ { screen: "home" },
61
+ { screen: "repo_actions", repo: opts.startRepo, ref: opts.startRef }
62
+ ];
63
+ } else if (opts.startAt === "review_hub") {
64
+ navStack = [{ screen: "home" }, { screen: "review_hub" }];
65
+ } else {
66
+ navStack = [{ screen: "home" }];
67
+ }
68
+
69
+ while (navStack.length > 0) {
70
+ const current = navStack[navStack.length - 1];
71
+
72
+ if (current.screen === "home") {
73
+ clearInkScreen();
74
+ const action = await runHomePage();
75
+ if (action === "quit") {
76
+ navStack = [];
77
+ continue;
78
+ }
79
+ if (action === "cwd") {
80
+ // Resolve CWD repo immediately; push repo_actions if found
81
+ const repoEntry = await _resolveCwdRepo();
82
+ if (repoEntry) {
83
+ navStack.push({ screen: "repo_actions", repo: repoEntry.repo, ref: repoEntry.ref });
84
+ }
85
+ // If not found, runHomePage already showed the disabled hint; just re-show home
86
+ continue;
87
+ }
88
+ if (action === "review") {
89
+ navStack.push({ screen: "review_hub" });
90
+ continue;
91
+ }
92
+ if (action === "search") {
93
+ navStack.push({ screen: "repo_search" });
94
+ continue;
95
+ }
96
+ navStack = [];
97
+ continue;
98
+ }
99
+
100
+ if (current.screen === "review_hub") {
101
+ const result = await _runReviewHubScreen();
102
+ if (result.kind === "quit" || result.kind === "back") {
103
+ navStack.pop();
104
+ continue;
105
+ }
106
+ if (result.kind === "pick") {
107
+ navStack.push({
108
+ screen: "repo_actions",
109
+ repo: result.repo,
110
+ viewerLogin: result.viewerLogin
111
+ });
112
+ continue;
113
+ }
114
+ navStack.pop();
115
+ continue;
116
+ }
117
+
118
+ if (current.screen === "repo_search") {
119
+ clearInkScreen();
120
+ const { runRepoPickerFlow } = await import("../stack-view/view-repo-picker-ink.js");
121
+ const picked = await runRepoPickerFlow();
122
+ if (!picked) {
123
+ // Cancelled (null) → back
124
+ navStack.pop();
125
+ continue;
126
+ }
127
+ navStack.push({ screen: "repo_actions", repo: picked.repo, ref: picked.ref });
128
+ continue;
129
+ }
130
+
131
+ if (current.screen === "repo_actions") {
132
+ const { runRepoActionsPage } = await import("./pages/repo-actions.js");
133
+ const action = await runRepoActionsPage({ repo: current.repo });
134
+ if (action === "back") {
135
+ navStack.pop();
136
+ continue;
137
+ }
138
+ if (action === "stacks") {
139
+ navStack.push({
140
+ screen: "viewer",
141
+ repo: current.repo,
142
+ ref: current.ref,
143
+ viewerLogin: current.viewerLogin
144
+ });
145
+ continue;
146
+ }
147
+ if (action === "branches") {
148
+ navStack.push({ screen: "branches", repo: current.repo });
149
+ continue;
150
+ }
151
+ navStack.pop();
152
+ continue;
153
+ }
154
+
155
+ if (current.screen === "viewer") {
156
+ let repo = current.repo;
157
+
158
+ if (!repo) {
159
+ const repoEntry = await _resolveCwdRepo();
160
+ if (!repoEntry) {
161
+ navStack.pop();
162
+ continue;
163
+ }
164
+ repo = repoEntry.repo;
165
+ }
166
+
167
+ try {
168
+ const { runStackViewCommand } = await import("../stack-view/run-stack-view.js");
169
+ const { RepoPickerBackError } = await import("../stack-view/repo-picker-back.js");
170
+ try {
171
+ await runStackViewCommand({
172
+ repo,
173
+ ref: current.ref,
174
+ noTui: false,
175
+ shellMode: true,
176
+ allowBackToRepoPicker: true,
177
+ ...(current.viewerLogin
178
+ ? {
179
+ reviewFetchOpts: { viewerLogin: current.viewerLogin, fullReviewFetch: true }
180
+ }
181
+ : {})
182
+ });
183
+ } catch (e) {
184
+ if (!(e instanceof RepoPickerBackError)) throw e;
185
+ // Back from stack picker → go back to repo_actions
186
+ }
187
+ } catch (e) {
188
+ // Non-back error: surface it then pop
189
+ console.error(String(/** @type {{ message?: string }} */ (e)?.message || e));
190
+ }
191
+ navStack.pop();
192
+ continue;
193
+ }
194
+
195
+ if (current.screen === "branches") {
196
+ const { runRepoBranchesPage } = await import("./pages/repo-branches.js");
197
+ await runRepoBranchesPage({ repo: current.repo });
198
+ navStack.pop();
199
+ continue;
200
+ }
201
+
202
+ // Unknown screen — bail
203
+ navStack.pop();
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Resolve the current directory's GitHub repo (owner/repo + default branch).
209
+ * Returns null if not in a git repo or no GitHub remote found.
210
+ * @returns {Promise<{ repo: string, ref: string } | null>}
211
+ */
212
+ async function _resolveCwdRepo() {
213
+ try {
214
+ const { findGitRoot } = await import("../nugit-stack.js");
215
+ const { getRepoFullNameFromGitRoot } = await import("../git-info.js");
216
+ const { getRepoMetadata } = await import("../api-client.js");
217
+ const { parseRepoFullName } = await import("../nugit-stack.js");
218
+
219
+ const root = findGitRoot();
220
+ if (!root) return null;
221
+ const repoFull = getRepoFullNameFromGitRoot(root);
222
+ const { owner, repo } = parseRepoFullName(repoFull);
223
+ const meta = await getRepoMetadata(owner, repo);
224
+ const ref = (meta && typeof meta.default_branch === "string" ? meta.default_branch : null) || "main";
225
+ return { repo: repoFull, ref };
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Fetch review hub data and render the ReviewHubInk UI.
233
+ * @returns {Promise<{ kind: "back" | "quit" } | { kind: "pick", repo: string, viewerLogin: string }>}
234
+ */
235
+ async function _runReviewHubScreen() {
236
+ const { fetchReviewHubData } = await import("../review-hub/run-review-hub.js");
237
+ const { ReviewHubInk } = await import("../review-hub/review-hub-ink.js");
238
+
239
+ let hubData = /** @type {{ login: string, lines: import("../review-hub/review-hub-ink.js").HubLine[] } | null} */ (null);
240
+ try {
241
+ await withLoadingScreen("Loading repositories…", async () => {
242
+ hubData = await fetchReviewHubData();
243
+ });
244
+ } catch (e) {
245
+ console.error(String(/** @type {{ message?: string }} */ (e)?.message || e));
246
+ return { kind: "back" };
247
+ }
248
+
249
+ if (!hubData) return { kind: "back" };
250
+
251
+ const lines = hubData.lines;
252
+ const login = hubData.login;
253
+
254
+ if (!lines.filter((l) => l.kind === "repo").length) {
255
+ console.error("No repositories visible to this token.");
256
+ return { kind: "back" };
257
+ }
258
+
259
+ clearInkScreen();
260
+
261
+ const result = { kind: /** @type {"back" | "quit" | "pick"} */ ("back"), repo: "", viewerLogin: "" };
262
+ const { waitUntilExit } = render(
263
+ React.createElement(ReviewHubInk, {
264
+ lines,
265
+ onPickRepo: (fullName) => {
266
+ result.kind = "pick";
267
+ result.repo = fullName;
268
+ result.viewerLogin = login;
269
+ },
270
+ onBack: () => {
271
+ result.kind = "back";
272
+ }
273
+ })
274
+ );
275
+ await waitUntilExit();
276
+
277
+ if (result.kind === "pick") {
278
+ return { kind: "pick", repo: result.repo, viewerLogin: result.viewerLogin };
279
+ }
280
+ return { kind: result.kind === "quit" ? "quit" : "back" };
281
+ }
@@ -0,0 +1,37 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Box, Text, render } from "ink";
3
+ import { clearInkScreen } from "./terminal.js";
4
+
5
+ const FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
6
+
7
+ /**
8
+ * Full-screen loading animation with a braille spinner.
9
+ * Mounts, runs `work()`, then unmounts cleanly.
10
+ *
11
+ * @param {string} message
12
+ * @param {() => Promise<void>} work
13
+ */
14
+ export async function withLoadingScreen(message, work) {
15
+ clearInkScreen();
16
+ const LoadingLine = () => {
17
+ const [i, setI] = useState(0);
18
+ useEffect(() => {
19
+ const t = setInterval(() => setI((n) => (n + 1) % FRAMES.length), 80);
20
+ return () => clearInterval(t);
21
+ }, []);
22
+ return React.createElement(
23
+ Box,
24
+ { flexDirection: "row", padding: 1 },
25
+ React.createElement(Text, { color: "cyan" }, FRAMES[i]),
26
+ React.createElement(Text, null, ` ${message}`)
27
+ );
28
+ };
29
+ const inst = render(React.createElement(LoadingLine));
30
+ try {
31
+ await new Promise((r) => setImmediate(r));
32
+ await work();
33
+ } finally {
34
+ try { inst.unmount(); } catch { /* ignore */ }
35
+ try { inst.clear(); } catch { /* ignore */ }
36
+ }
37
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Terminal I/O helpers for the nugit TUI.
3
+ * Disable alternate screen with NUGIT_NO_FULLSCREEN=1 if your terminal misbehaves.
4
+ */
5
+
6
+ export function isAlternateScreenDisabled() {
7
+ const v = process.env.NUGIT_NO_FULLSCREEN;
8
+ return v === "1" || v === "true";
9
+ }
10
+
11
+ /** @param {import('node:stream').Writable | undefined} stdout */
12
+ export function enterAlternateScreen(stdout) {
13
+ if (!stdout?.isTTY || isAlternateScreenDisabled()) return;
14
+ stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
15
+ }
16
+
17
+ /** @param {import('node:stream').Writable | undefined} stdout */
18
+ export function leaveAlternateScreen(stdout) {
19
+ if (!stdout?.isTTY || isAlternateScreenDisabled()) return;
20
+ stdout.write("\x1b[?1049l");
21
+ }
22
+
23
+ /**
24
+ * Erase and home the cursor. Call before mounting a new Ink tree so previous
25
+ * content doesn't leave ghost lines.
26
+ * @param {import('node:stream').Writable} [out]
27
+ */
28
+ export function clearInkScreen(out = process.stdout) {
29
+ if (!out?.isTTY || isAlternateScreenDisabled()) return;
30
+ out.write("\x1b[2J\x1b[H");
31
+ }
package/src/cli-output.js DELETED
@@ -1,228 +0,0 @@
1
- import chalk from "chalk";
2
- import boxen from "boxen";
3
-
4
- /**
5
- * @param {unknown} data
6
- */
7
- export function printJson(data) {
8
- console.log(JSON.stringify(data, null, 2));
9
- }
10
-
11
- /**
12
- * @param {unknown} data
13
- * @param {boolean} asJson
14
- */
15
- export function out(data, asJson) {
16
- if (asJson) {
17
- printJson(data);
18
- }
19
- }
20
-
21
- /** @param {Record<string, unknown>} me */
22
- export function formatWhoamiHuman(me) {
23
- const login = me.login ?? "?";
24
- const name = me.name ? ` (${me.name})` : "";
25
- const id = me.id != null ? chalk.dim(` id ${me.id}`) : "";
26
- return `${chalk.bold(login)}${name}${id}`;
27
- }
28
-
29
- /**
30
- * @param {{ total_count?: number, items?: unknown[] }} search
31
- * @param {{ page?: number, perPage?: number }} [pag]
32
- */
33
- export function formatPrSearchHuman(search, pag) {
34
- const items = Array.isArray(search.items) ? search.items : [];
35
- const lines = [];
36
- const page = pag?.page ?? 1;
37
- const perPage = pag?.perPage ?? 30;
38
- const total = search.total_count;
39
- lines.push(
40
- chalk.bold.cyan(
41
- `Open PRs you authored (page ${page}, ${items.length} on this page${total != null ? ` · ${total} total` : ""})`
42
- )
43
- );
44
- lines.push("");
45
- for (const it of items) {
46
- const o = it && typeof it === "object" ? /** @type {Record<string, unknown>} */ (it) : {};
47
- const num = o.number;
48
- const title = String(o.title || "");
49
- const html = o.html_url ? String(o.html_url) : "";
50
- const repo = o.repository_url ? String(o.repository_url).replace("https://api.github.com/repos/", "") : "";
51
- lines.push(
52
- ` ${chalk.bold("#" + num)} ${chalk.dim(repo)} ${title.slice(0, 72)}${title.length > 72 ? "…" : ""}`
53
- );
54
- if (html) {
55
- lines.push(` ${chalk.blue.underline(html)}`);
56
- }
57
- }
58
- const mayHaveMore =
59
- total != null ? page * perPage < total : items.length >= perPage;
60
- if (mayHaveMore && items.length > 0) {
61
- lines.push("");
62
- lines.push(chalk.dim(`Next page: ${chalk.bold(`nugit prs list --mine --page ${page + 1}`)}`));
63
- }
64
- lines.push("");
65
- lines.push(chalk.dim("Use PR # with: nugit stack add --pr <n> [more #…] (bottom → top)"));
66
- return lines.join("\n");
67
- }
68
-
69
- /**
70
- * @param {{ pulls: unknown[], page: number, per_page: number, repo_full_name: string, has_more: boolean }} payload
71
- */
72
- export function formatOpenPullsHuman(payload) {
73
- const pulls = Array.isArray(payload.pulls) ? payload.pulls : [];
74
- const lines = [];
75
- lines.push(
76
- chalk.bold.cyan(
77
- `Open PRs in ${payload.repo_full_name} (page ${payload.page}, ${pulls.length} shown, ${payload.per_page}/page)`
78
- )
79
- );
80
- lines.push("");
81
- for (const pr of pulls) {
82
- const p = pr && typeof pr === "object" ? /** @type {Record<string, unknown>} */ (pr) : {};
83
- const head = p.head && typeof p.head === "object" ? /** @type {{ ref?: string }} */ (p.head) : {};
84
- const base = p.base && typeof p.base === "object" ? /** @type {{ ref?: string }} */ (p.base) : {};
85
- const user = p.user && typeof p.user === "object" ? /** @type {{ login?: string }} */ (p.user) : {};
86
- const num = p.number;
87
- const title = String(p.title || "");
88
- const branch = `${head.ref || "?"} ← ${base.ref || "?"}`;
89
- lines.push(
90
- ` ${chalk.bold("#" + num)} ${chalk.dim(branch)} ${chalk.dim(user.login || "")} ${title.slice(0, 56)}${title.length > 56 ? "…" : ""}`
91
- );
92
- if (p.html_url) {
93
- lines.push(` ${chalk.blue.underline(String(p.html_url))}`);
94
- }
95
- }
96
- if (pulls.length === 0) {
97
- lines.push(chalk.dim(" (no open PRs on this page)"));
98
- }
99
- if (payload.has_more) {
100
- lines.push("");
101
- lines.push(chalk.dim(`Next page: ${chalk.bold(`nugit prs list --page ${payload.page + 1}`)}`));
102
- }
103
- lines.push("");
104
- const nums = pulls.map((pr) => (/** @type {{ number?: number }} */ (pr).number)).filter((n) => n != null);
105
- if (nums.length) {
106
- lines.push(chalk.dim(`Stack (bottom→top): ${chalk.bold(`nugit stack add --pr ${nums.join(" ")}`)}`));
107
- }
108
- return boxen(lines.join("\n"), { padding: 1, borderStyle: "round", borderColor: "cyan" });
109
- }
110
-
111
- /**
112
- * @param {Record<string, unknown>} doc
113
- */
114
- export function formatStackDocHuman(doc) {
115
- const prs = Array.isArray(doc.prs) ? doc.prs : [];
116
- const sorted = [...prs].sort(
117
- (a, b) =>
118
- (/** @type {{ position?: number }} */ (a).position ?? 0) -
119
- (/** @type {{ position?: number }} */ (b).position ?? 0)
120
- );
121
- const lines = [];
122
- lines.push(chalk.bold.cyan(".nugit/stack.json"));
123
- lines.push(chalk.dim(`repo ${doc.repo_full_name} · by ${doc.created_by}`));
124
- lines.push("");
125
- for (let i = 0; i < sorted.length; i++) {
126
- const p = sorted[i];
127
- const e = p && typeof p === "object" ? /** @type {Record<string, unknown>} */ (p) : {};
128
- lines.push(` ${chalk.bold("#" + e.pr_number)} pos ${e.position} ${e.head_branch || ""} ← ${e.base_branch || ""}`);
129
- }
130
- if (sorted.length === 0) {
131
- lines.push(chalk.dim(" (no PRs — use nugit stack add)"));
132
- }
133
- return boxen(lines.join("\n"), { padding: 1, borderStyle: "round", borderColor: "cyan" });
134
- }
135
-
136
- /**
137
- * @param {Record<string, unknown>} doc
138
- * @param {Array<Record<string, unknown>>} enrichedPrs
139
- */
140
- export function formatStackEnrichHuman(doc, enrichedPrs) {
141
- const lines = [];
142
- lines.push(chalk.bold.cyan("Stack (with GitHub titles)"));
143
- lines.push(chalk.dim(String(doc.repo_full_name)));
144
- lines.push("");
145
- for (const row of enrichedPrs) {
146
- const err = row.error;
147
- if (err) {
148
- lines.push(` ${chalk.yellow("PR #" + row.pr_number)} ${chalk.red(String(err))}`);
149
- continue;
150
- }
151
- const title = String(row.title || "");
152
- const url = row.html_url ? String(row.html_url) : "";
153
- lines.push(` ${chalk.bold("PR #" + row.pr_number)} ${title}`);
154
- if (url) {
155
- lines.push(` ${chalk.blue.underline(url)}`);
156
- }
157
- }
158
- return boxen(lines.join("\n"), { padding: 1, borderStyle: "round", borderColor: "cyan" });
159
- }
160
-
161
- /**
162
- * @param {{
163
- * repo_full_name: string,
164
- * scanned_open_prs: number,
165
- * open_prs_truncated: boolean,
166
- * stacks_found: number,
167
- * stacks: Array<{
168
- * tip_pr_number: number,
169
- * created_by: string,
170
- * pr_count: number,
171
- * prs: Array<{ pr_number: number, position?: number, title?: string, html_url?: string }>,
172
- * tip_head_branch: string,
173
- * fetch_command: string,
174
- * view_command: string
175
- * }>
176
- * }} payload
177
- */
178
- export function formatStacksListHuman(payload) {
179
- const lines = [];
180
- lines.push(
181
- chalk.bold.cyan(`Stacks in ${payload.repo_full_name}`) +
182
- chalk.dim(
183
- ` · scanned ${payload.scanned_open_prs} open PR(s)${payload.open_prs_truncated ? " (truncated — increase --max-open-prs)" : ""}`
184
- )
185
- );
186
- lines.push(chalk.dim(`Found ${payload.stacks_found} stack(s) with .nugit/stack.json on a PR head`));
187
- lines.push("");
188
- if (payload.stacks.length === 0) {
189
- lines.push(chalk.dim(" (none — stacks appear after authors commit stack.json on stacked branches)"));
190
- return lines.join("\n");
191
- }
192
- for (const s of payload.stacks) {
193
- lines.push(chalk.bold(`Tip PR #${s.tip_pr_number}`) + chalk.dim(` · ${s.pr_count} PR(s) · by ${s.created_by}`));
194
- lines.push(chalk.dim(` branch ${s.tip_head_branch}`));
195
- for (const p of s.prs) {
196
- const raw = p.title != null ? String(p.title) : "";
197
- const tit = raw.length > 72 ? `${raw.slice(0, 71)}…` : raw;
198
- lines.push(
199
- ` ${chalk.bold("#" + p.pr_number)}${tit ? chalk.dim(" " + tit) : ""}`
200
- );
201
- if (p.html_url) {
202
- lines.push(` ${chalk.blue.underline(String(p.html_url))}`);
203
- }
204
- }
205
- lines.push(chalk.dim(` → ${s.view_command}`));
206
- lines.push("");
207
- }
208
- return lines.join("\n").trimEnd() + "\n";
209
- }
210
-
211
- /** @param {Record<string, unknown>} created */
212
- export function formatPrCreatedHuman(created) {
213
- const num = created.number;
214
- const url = created.html_url ? String(created.html_url) : "";
215
- return [
216
- chalk.green("Opened pull request"),
217
- chalk.bold(`#${num}`),
218
- url ? chalk.blue.underline(url) : ""
219
- ]
220
- .filter(Boolean)
221
- .join(" ");
222
- }
223
-
224
- /** @param {Record<string, unknown>} result pat validation */
225
- export function formatPatOkHuman(result) {
226
- const login = result.login ?? "?";
227
- return chalk.green("PAT OK — GitHub login ") + chalk.bold(String(login));
228
- }