nugit-cli 0.0.1-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +30 -0
- package/src/api-client.js +182 -0
- package/src/auth-token.js +14 -0
- package/src/cli-output.js +228 -0
- package/src/git-info.js +60 -0
- package/src/github-device-flow.js +64 -0
- package/src/github-pr-social.js +126 -0
- package/src/github-rest.js +212 -0
- package/src/nugit-stack.js +289 -0
- package/src/nugit-start.js +211 -0
- package/src/nugit.js +829 -0
- package/src/open-browser.js +21 -0
- package/src/split-view/run-split.js +181 -0
- package/src/split-view/split-git.js +88 -0
- package/src/split-view/split-ink.js +104 -0
- package/src/stack-discover.js +284 -0
- package/src/stack-discovery-config.js +91 -0
- package/src/stack-extra-commands.js +353 -0
- package/src/stack-graph.js +214 -0
- package/src/stack-helpers.js +58 -0
- package/src/stack-propagate.js +422 -0
- package/src/stack-view/fetch-pr-data.js +126 -0
- package/src/stack-view/ink-app.js +421 -0
- package/src/stack-view/loader.js +101 -0
- package/src/stack-view/open-url.js +18 -0
- package/src/stack-view/prompt-line.js +47 -0
- package/src/stack-view/run-stack-view.js +366 -0
- package/src/stack-view/static-render.js +98 -0
- package/src/token-store.js +45 -0
- package/src/user-config.js +169 -0
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nugit-cli",
|
|
3
|
+
"version": "0.0.1-alpha",
|
|
4
|
+
"description": "CLI for stacked GitHub PRs (.nugit/stack.json)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nugit": "src/nugit.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/jerrying123/nugit.git",
|
|
15
|
+
"directory": "cli"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "vitest run"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"boxen": "^8.0.1",
|
|
22
|
+
"chalk": "^5.6.2",
|
|
23
|
+
"commander": "^12.1.0",
|
|
24
|
+
"ink": "^6.8.0",
|
|
25
|
+
"react": "^19.2.4"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"vitest": "^2.1.8"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
githubCreatePullRequest,
|
|
3
|
+
githubGetContents,
|
|
4
|
+
githubGetPull,
|
|
5
|
+
githubGetRepoMetadata,
|
|
6
|
+
githubGetViewer,
|
|
7
|
+
githubListUserRepos,
|
|
8
|
+
githubRestJson,
|
|
9
|
+
githubSearchIssues,
|
|
10
|
+
githubListOpenPulls
|
|
11
|
+
} from "./github-rest.js";
|
|
12
|
+
import {
|
|
13
|
+
githubDeviceFlowPollAccessToken,
|
|
14
|
+
githubDeviceFlowRequestCode
|
|
15
|
+
} from "./github-device-flow.js";
|
|
16
|
+
import { resolveGithubToken } from "./auth-token.js";
|
|
17
|
+
|
|
18
|
+
/** @returns {string} */
|
|
19
|
+
export function getToken() {
|
|
20
|
+
return resolveGithubToken();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function withAuthHeaders(headers = {}) {
|
|
24
|
+
const token = getToken();
|
|
25
|
+
if (!token) {
|
|
26
|
+
return headers;
|
|
27
|
+
}
|
|
28
|
+
return { ...headers, Authorization: `Bearer ${token}` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Start GitHub OAuth device flow (requires GITHUB_OAUTH_CLIENT_ID).
|
|
33
|
+
*/
|
|
34
|
+
export async function startDeviceFlow() {
|
|
35
|
+
const clientId = process.env.GITHUB_OAUTH_CLIENT_ID;
|
|
36
|
+
if (!clientId) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Set GITHUB_OAUTH_CLIENT_ID to your GitHub OAuth App client ID (Settings → Developer settings → OAuth Apps), or use a PAT with NUGIT_USER_TOKEN."
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return githubDeviceFlowRequestCode(clientId, "repo read:user user:email");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Single poll for device flow. Returns token, pending, or throws.
|
|
46
|
+
* @param {string} deviceCode
|
|
47
|
+
* @param {number} [intervalSeconds] minimum wait before next poll (from GitHub or prior slow_down)
|
|
48
|
+
*/
|
|
49
|
+
export async function pollDeviceFlow(deviceCode, intervalSeconds = 5) {
|
|
50
|
+
const clientId = process.env.GITHUB_OAUTH_CLIENT_ID;
|
|
51
|
+
if (!clientId) {
|
|
52
|
+
throw new Error("Set GITHUB_OAUTH_CLIENT_ID");
|
|
53
|
+
}
|
|
54
|
+
const payload = await githubDeviceFlowPollAccessToken(clientId, deviceCode);
|
|
55
|
+
if (payload.access_token) {
|
|
56
|
+
return { access_token: payload.access_token, token_type: payload.token_type, scope: payload.scope };
|
|
57
|
+
}
|
|
58
|
+
if (payload.error === "authorization_pending") {
|
|
59
|
+
return { pending: true, interval: Math.max(5, intervalSeconds) };
|
|
60
|
+
}
|
|
61
|
+
if (payload.error === "slow_down") {
|
|
62
|
+
return { pending: true, interval: Math.max(5, intervalSeconds) + 5 };
|
|
63
|
+
}
|
|
64
|
+
const msg = payload.error_description || payload.error || "Device flow failed";
|
|
65
|
+
throw new Error(typeof msg === "string" ? msg : JSON.stringify(payload));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Poll until token or fatal error (for `nugit auth poll`).
|
|
70
|
+
* @param {string} deviceCode
|
|
71
|
+
* @param {number} [initialInterval] from `device_code` response `interval`
|
|
72
|
+
*/
|
|
73
|
+
export async function pollDeviceFlowUntilComplete(deviceCode, initialInterval = 5) {
|
|
74
|
+
let wait = Math.max(5, initialInterval);
|
|
75
|
+
for (;;) {
|
|
76
|
+
const result = await pollDeviceFlow(deviceCode, wait);
|
|
77
|
+
if (result.access_token) {
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
if (result.pending) {
|
|
81
|
+
wait = result.interval ?? wait;
|
|
82
|
+
await new Promise((r) => setTimeout(r, wait * 1000));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
throw new Error("Unexpected device flow response");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate PAT via GitHub GET /user (server does not exist; token is not stored).
|
|
91
|
+
* @param {string} token
|
|
92
|
+
*/
|
|
93
|
+
export async function savePat(token) {
|
|
94
|
+
const user = await githubRestJson("GET", "/user", undefined, token);
|
|
95
|
+
return { access_token: token, login: user.login, id: user.id, name: user.name };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Open PRs authored by the current user (search API, paginated).
|
|
100
|
+
* @param {{ repo?: string, page?: number, perPage?: number }} [opts]
|
|
101
|
+
*/
|
|
102
|
+
export async function listMyPulls(opts = {}) {
|
|
103
|
+
const me = await githubGetViewer();
|
|
104
|
+
const login = me.login;
|
|
105
|
+
if (!login) {
|
|
106
|
+
throw new Error("Could not resolve GitHub login");
|
|
107
|
+
}
|
|
108
|
+
let q = `is:pr is:open author:${login}`;
|
|
109
|
+
if (opts.repo) {
|
|
110
|
+
const parts = String(opts.repo).split("/").filter(Boolean);
|
|
111
|
+
if (parts.length === 2) {
|
|
112
|
+
q = `is:pr is:open repo:${parts[0]}/${parts[1]} author:${login}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const page = opts.page != null ? Math.max(1, opts.page) : 1;
|
|
116
|
+
const perPage = opts.perPage != null ? Math.min(100, Math.max(1, opts.perPage)) : 30;
|
|
117
|
+
return githubSearchIssues(q, perPage, page);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* All open PRs in a repository (REST list, paginated). Best for picking numbers for `nugit stack add`.
|
|
122
|
+
* @param {string} owner
|
|
123
|
+
* @param {string} repo
|
|
124
|
+
* @param {{ page?: number, perPage?: number }} [opts]
|
|
125
|
+
*/
|
|
126
|
+
export async function listOpenPullsInRepo(owner, repo, opts = {}) {
|
|
127
|
+
const page = opts.page != null ? Math.max(1, opts.page) : 1;
|
|
128
|
+
const perPage = opts.perPage != null ? Math.min(100, Math.max(1, opts.perPage)) : 30;
|
|
129
|
+
const pulls = await githubListOpenPulls(owner, repo, page, perPage);
|
|
130
|
+
const arr = Array.isArray(pulls) ? pulls : [];
|
|
131
|
+
return {
|
|
132
|
+
pulls: arr,
|
|
133
|
+
page,
|
|
134
|
+
per_page: perPage,
|
|
135
|
+
repo_full_name: `${owner}/${repo}`,
|
|
136
|
+
has_more: arr.length >= perPage
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function authMe() {
|
|
141
|
+
return githubGetViewer();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function getRepoMetadata(owner, repo) {
|
|
145
|
+
return githubGetRepoMetadata(owner, repo);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function createPullRequest(owner, repo, fields) {
|
|
149
|
+
return githubCreatePullRequest(owner, repo, fields);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function getGithubContents(owner, repo, filePath, ref) {
|
|
153
|
+
return githubGetContents(owner, repo, filePath, ref);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function decodeGithubFileContent(item) {
|
|
157
|
+
if (!item || item.type !== "file" || item.encoding !== "base64" || !item.content) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return Buffer.from(item.content.replace(/\s/g, ""), "base64").toString("utf8");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function fetchRemoteStackJson(repoFullName, ref) {
|
|
164
|
+
const [owner, repo] = repoFullName.split("/");
|
|
165
|
+
if (!owner || !repo) {
|
|
166
|
+
throw new Error("repoFullName must be owner/repo");
|
|
167
|
+
}
|
|
168
|
+
const item = await getGithubContents(owner, repo, ".nugit/stack.json", ref);
|
|
169
|
+
const text = decodeGithubFileContent(item);
|
|
170
|
+
if (!text) {
|
|
171
|
+
throw new Error("Could not read .nugit/stack.json from GitHub");
|
|
172
|
+
}
|
|
173
|
+
return JSON.parse(text);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function getPull(owner, repo, number) {
|
|
177
|
+
return githubGetPull(owner, repo, number);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function listUserRepos(page = 1) {
|
|
181
|
+
return githubListUserRepos(page);
|
|
182
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readStoredGithubToken } from "./token-store.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PAT / OAuth token for GitHub API: env first, then saved device-flow file.
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
export function resolveGithubToken() {
|
|
8
|
+
return (
|
|
9
|
+
process.env.NUGIT_USER_TOKEN ||
|
|
10
|
+
process.env.STACKPR_USER_TOKEN ||
|
|
11
|
+
readStoredGithubToken() ||
|
|
12
|
+
""
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
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
|
+
}
|
package/src/git-info.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} gitRoot
|
|
5
|
+
* @param {string} [remote]
|
|
6
|
+
* @returns {string | null}
|
|
7
|
+
*/
|
|
8
|
+
export function getGitRemoteUrl(gitRoot, remote = "origin") {
|
|
9
|
+
try {
|
|
10
|
+
return execSync(`git remote get-url ${remote}`, {
|
|
11
|
+
cwd: gitRoot,
|
|
12
|
+
encoding: "utf8",
|
|
13
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
14
|
+
}).trim();
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse owner/repo from common GitHub remote URL shapes.
|
|
22
|
+
* @param {string | null | undefined} url
|
|
23
|
+
* @returns {string | null} "owner/repo"
|
|
24
|
+
*/
|
|
25
|
+
export function parseGithubRepoFromRemote(url) {
|
|
26
|
+
if (!url || typeof url !== "string") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const u = url.trim();
|
|
30
|
+
let m = u.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
31
|
+
if (m) {
|
|
32
|
+
return `${m[1]}/${m[2]}`;
|
|
33
|
+
}
|
|
34
|
+
m = u.match(/^https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/i);
|
|
35
|
+
if (m) {
|
|
36
|
+
return `${m[1]}/${m[2]}`;
|
|
37
|
+
}
|
|
38
|
+
m = u.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/i);
|
|
39
|
+
if (m) {
|
|
40
|
+
return `${m[1]}/${m[2]}`;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} gitRoot
|
|
47
|
+
* @param {string} [remote]
|
|
48
|
+
* @returns {string} owner/repo
|
|
49
|
+
*/
|
|
50
|
+
export function getRepoFullNameFromGitRoot(gitRoot, remote = "origin") {
|
|
51
|
+
const url = getGitRemoteUrl(gitRoot, remote);
|
|
52
|
+
const full = parseGithubRepoFromRemote(url);
|
|
53
|
+
if (!full) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Could not parse GitHub owner/repo from git remote "${remote}" URL: ${url || "(none)"}. ` +
|
|
56
|
+
`Use --repo owner/repo or set origin to a github.com remote.`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return full;
|
|
60
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth device flow (no backend). Requires GITHUB_OAUTH_CLIENT_ID.
|
|
3
|
+
* @see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} clientId
|
|
8
|
+
* @param {string} scope space-separated
|
|
9
|
+
*/
|
|
10
|
+
export async function githubDeviceFlowRequestCode(clientId, scope) {
|
|
11
|
+
const response = await fetch("https://github.com/login/device/code", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
Accept: "application/json",
|
|
15
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
16
|
+
},
|
|
17
|
+
body: new URLSearchParams({
|
|
18
|
+
client_id: clientId,
|
|
19
|
+
scope
|
|
20
|
+
})
|
|
21
|
+
});
|
|
22
|
+
const text = await response.text();
|
|
23
|
+
let payload = {};
|
|
24
|
+
try {
|
|
25
|
+
payload = text ? JSON.parse(text) : {};
|
|
26
|
+
} catch {
|
|
27
|
+
payload = { message: text };
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
const msg = payload.error_description || payload.error || payload.message || `HTTP ${response.status}`;
|
|
31
|
+
throw new Error(typeof msg === "string" ? msg : JSON.stringify(payload));
|
|
32
|
+
}
|
|
33
|
+
return payload;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} clientId
|
|
38
|
+
* @param {string} deviceCode
|
|
39
|
+
*/
|
|
40
|
+
export async function githubDeviceFlowPollAccessToken(clientId, deviceCode) {
|
|
41
|
+
const response = await fetch("https://github.com/login/oauth/access_token", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
Accept: "application/json",
|
|
45
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
46
|
+
},
|
|
47
|
+
body: new URLSearchParams({
|
|
48
|
+
client_id: clientId,
|
|
49
|
+
device_code: deviceCode,
|
|
50
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
const text = await response.text();
|
|
54
|
+
let payload = {};
|
|
55
|
+
try {
|
|
56
|
+
payload = text ? JSON.parse(text) : {};
|
|
57
|
+
} catch {
|
|
58
|
+
payload = { message: text, error: "parse_error" };
|
|
59
|
+
}
|
|
60
|
+
if (!response.ok && !payload.error) {
|
|
61
|
+
payload.error = `http_${response.status}`;
|
|
62
|
+
}
|
|
63
|
+
return payload;
|
|
64
|
+
}
|