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.
- package/package.json +1 -1
- package/src/api-client.js +10 -23
- package/src/github-device-flow.js +1 -1
- package/src/github-oauth-client-id.js +11 -0
- package/src/github-pr-social.js +42 -0
- package/src/github-rest.js +149 -6
- package/src/nugit-config.js +84 -0
- package/src/nugit-stack.js +40 -257
- package/src/nugit.js +104 -647
- package/src/review-hub/review-autoapprove.js +95 -0
- package/src/review-hub/review-hub-back.js +10 -0
- package/src/review-hub/review-hub-ink.js +169 -0
- package/src/review-hub/run-review-hub.js +131 -0
- package/src/services/repo-branches.js +151 -0
- package/src/services/stack-inference.js +90 -0
- package/src/split-view/run-split.js +14 -76
- package/src/split-view/split-ink.js +2 -2
- package/src/stack-infer-from-prs.js +71 -0
- package/src/stack-view/diff-line-map.js +62 -0
- package/src/stack-view/fetch-pr-data.js +104 -4
- package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
- package/src/stack-view/ink-app.js +3 -421
- package/src/stack-view/loader.js +19 -93
- package/src/stack-view/loading-ink.js +2 -0
- package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
- package/src/stack-view/patch-preview-merge.js +108 -0
- package/src/stack-view/remote-infer-doc.js +76 -0
- package/src/stack-view/repo-picker-back.js +10 -0
- package/src/stack-view/run-stack-view.js +508 -150
- package/src/stack-view/run-view-entry.js +115 -0
- package/src/stack-view/sgr-mouse.js +56 -0
- package/src/stack-view/stack-branch-graph.js +95 -0
- package/src/stack-view/stack-pick-graph.js +93 -0
- package/src/stack-view/stack-pick-ink.js +308 -0
- package/src/stack-view/stack-pick-layout.js +19 -0
- package/src/stack-view/stack-pick-sort.js +188 -0
- package/src/stack-view/stack-picker-graph-pane.js +118 -0
- package/src/stack-view/terminal-fullscreen.js +7 -0
- package/src/stack-view/tree-ascii.js +73 -0
- package/src/stack-view/view-md-plain.js +23 -0
- package/src/stack-view/view-repo-picker-ink.js +293 -0
- package/src/stack-view/view-tui-sequential.js +126 -0
- package/src/tui/pages/home.js +122 -0
- package/src/tui/pages/repo-actions.js +81 -0
- package/src/tui/pages/repo-branches.js +259 -0
- package/src/tui/pages/viewer.js +2129 -0
- package/src/tui/router.js +40 -0
- package/src/tui/run-tui.js +281 -0
- package/src/utilities/loading.js +37 -0
- package/src/utilities/terminal.js +31 -0
- package/src/cli-output.js +0 -228
- package/src/nugit-start.js +0 -211
- package/src/stack-discover.js +0 -284
- package/src/stack-discovery-config.js +0 -91
- package/src/stack-extra-commands.js +0 -353
- package/src/stack-graph.js +0 -214
- package/src/stack-helpers.js +0 -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
|
-
}
|