nugit-cli 0.0.1 → 0.1.0
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 -11
- 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 +114 -6
- package/src/nugit-stack.js +20 -0
- package/src/nugit-start.js +4 -4
- package/src/nugit.js +37 -22
- 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 +166 -0
- package/src/review-hub/run-review-hub.js +188 -0
- package/src/split-view/run-split.js +16 -3
- package/src/split-view/split-ink.js +2 -2
- package/src/stack-discover.js +9 -1
- 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 +70 -0
- package/src/stack-view/ink-app.js +1853 -156
- package/src/stack-view/loading-ink.js +44 -0
- package/src/stack-view/merge-alternate-pick-stacks.js +223 -0
- package/src/stack-view/patch-preview-merge.js +108 -0
- package/src/stack-view/remote-infer-doc.js +93 -0
- package/src/stack-view/repo-picker-back.js +10 -0
- package/src/stack-view/run-stack-view.js +685 -50
- package/src/stack-view/run-view-entry.js +119 -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 +270 -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/terminal-fullscreen.js +45 -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/package.json
CHANGED
package/src/api-client.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
githubListUserRepos,
|
|
8
8
|
githubRestJson,
|
|
9
9
|
githubSearchIssues,
|
|
10
|
+
githubSearchRepositories,
|
|
10
11
|
githubListOpenPulls
|
|
11
12
|
} from "./github-rest.js";
|
|
12
13
|
import {
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
githubDeviceFlowRequestCode
|
|
15
16
|
} from "./github-device-flow.js";
|
|
16
17
|
import { resolveGithubToken } from "./auth-token.js";
|
|
18
|
+
import { resolveGithubOAuthClientId } from "./github-oauth-client-id.js";
|
|
17
19
|
|
|
18
20
|
/** @returns {string} */
|
|
19
21
|
export function getToken() {
|
|
@@ -29,15 +31,10 @@ export function withAuthHeaders(headers = {}) {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
* Start GitHub OAuth device flow (
|
|
34
|
+
* Start GitHub OAuth device flow (bundled OAuth App client id; override with GITHUB_OAUTH_CLIENT_ID).
|
|
33
35
|
*/
|
|
34
36
|
export async function startDeviceFlow() {
|
|
35
|
-
const clientId =
|
|
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
|
-
}
|
|
37
|
+
const clientId = resolveGithubOAuthClientId();
|
|
41
38
|
return githubDeviceFlowRequestCode(clientId, "repo read:user user:email");
|
|
42
39
|
}
|
|
43
40
|
|
|
@@ -47,10 +44,7 @@ export async function startDeviceFlow() {
|
|
|
47
44
|
* @param {number} [intervalSeconds] minimum wait before next poll (from GitHub or prior slow_down)
|
|
48
45
|
*/
|
|
49
46
|
export async function pollDeviceFlow(deviceCode, intervalSeconds = 5) {
|
|
50
|
-
const clientId =
|
|
51
|
-
if (!clientId) {
|
|
52
|
-
throw new Error("Set GITHUB_OAUTH_CLIENT_ID");
|
|
53
|
-
}
|
|
47
|
+
const clientId = resolveGithubOAuthClientId();
|
|
54
48
|
const payload = await githubDeviceFlowPollAccessToken(clientId, deviceCode);
|
|
55
49
|
if (payload.access_token) {
|
|
56
50
|
return { access_token: payload.access_token, token_type: payload.token_type, scope: payload.scope };
|
|
@@ -145,6 +139,11 @@ export async function getRepoMetadata(owner, repo) {
|
|
|
145
139
|
return githubGetRepoMetadata(owner, repo);
|
|
146
140
|
}
|
|
147
141
|
|
|
142
|
+
/** @param {string} query GitHub `q` syntax for /search/repositories */
|
|
143
|
+
export async function searchRepositories(query, perPage = 20, page = 1) {
|
|
144
|
+
return githubSearchRepositories(query, perPage, page);
|
|
145
|
+
}
|
|
146
|
+
|
|
148
147
|
export async function createPullRequest(owner, repo, fields) {
|
|
149
148
|
return githubCreatePullRequest(owner, repo, fields);
|
|
150
149
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub OAuth device flow (no backend).
|
|
2
|
+
* GitHub OAuth device flow (no backend). Client id from resolveGithubOAuthClientId() (bundled default or GITHUB_OAUTH_CLIENT_ID).
|
|
3
3
|
* @see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default GitHub OAuth App for `nugit auth login` (device flow).
|
|
3
|
+
* Client IDs are public (not secrets). Override with env for a custom app or fork.
|
|
4
|
+
*/
|
|
5
|
+
export const NUGIT_BUNDLED_GITHUB_OAUTH_CLIENT_ID = "Ov23liMBQqJJvRNiO0qm";
|
|
6
|
+
|
|
7
|
+
/** @returns {string} */
|
|
8
|
+
export function resolveGithubOAuthClientId() {
|
|
9
|
+
const fromEnv = process.env.GITHUB_OAUTH_CLIENT_ID?.trim();
|
|
10
|
+
return fromEnv || NUGIT_BUNDLED_GITHUB_OAUTH_CLIENT_ID;
|
|
11
|
+
}
|
package/src/github-pr-social.js
CHANGED
|
@@ -124,3 +124,45 @@ export async function githubListAssignableUsers(owner, repo) {
|
|
|
124
124
|
const r = encodeURIComponent(repo);
|
|
125
125
|
return githubGetAllPages(`/repos/${o}/${r}/assignees`);
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* All pull reviews (paginated).
|
|
130
|
+
* @param {string} owner
|
|
131
|
+
* @param {string} repo
|
|
132
|
+
* @param {number} pullNumber
|
|
133
|
+
*/
|
|
134
|
+
export async function githubListPullReviewsAll(owner, repo, pullNumber) {
|
|
135
|
+
const o = encodeURIComponent(owner);
|
|
136
|
+
const r = encodeURIComponent(repo);
|
|
137
|
+
const n = encodeURIComponent(String(pullNumber));
|
|
138
|
+
return githubGetAllPages(`/repos/${o}/${r}/pulls/${n}/reviews`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Post a single line-linked review comment on a PR (creates a new review thread).
|
|
143
|
+
*
|
|
144
|
+
* @param {string} owner
|
|
145
|
+
* @param {string} repo
|
|
146
|
+
* @param {number} pullNumber
|
|
147
|
+
* @param {{ body: string, commit_id: string, path: string, line: number, side?: "LEFT" | "RIGHT" }} payload
|
|
148
|
+
*/
|
|
149
|
+
export async function githubPostPullReviewLineComment(owner, repo, pullNumber, payload) {
|
|
150
|
+
const o = encodeURIComponent(owner);
|
|
151
|
+
const r = encodeURIComponent(repo);
|
|
152
|
+
const n = encodeURIComponent(String(pullNumber));
|
|
153
|
+
return githubRestJson("POST", `/repos/${o}/${r}/pulls/${n}/comments`, payload);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Submit a PR review (approve, comment, request changes).
|
|
158
|
+
* @param {string} owner
|
|
159
|
+
* @param {string} repo
|
|
160
|
+
* @param {number} pullNumber
|
|
161
|
+
* @param {{ event: string, body?: string, commit_id?: string }} payload
|
|
162
|
+
*/
|
|
163
|
+
export async function githubPostPullReview(owner, repo, pullNumber, payload) {
|
|
164
|
+
const o = encodeURIComponent(owner);
|
|
165
|
+
const r = encodeURIComponent(repo);
|
|
166
|
+
const n = encodeURIComponent(String(pullNumber));
|
|
167
|
+
return githubRestJson("POST", `/repos/${o}/${r}/pulls/${n}/reviews`, payload);
|
|
168
|
+
}
|
package/src/github-rest.js
CHANGED
|
@@ -26,16 +26,30 @@ export function useDirectGithub() {
|
|
|
26
26
|
*/
|
|
27
27
|
export async function githubRestJson(method, path, jsonBody, tokenOverride) {
|
|
28
28
|
const token = tokenOverride ?? getGithubPat();
|
|
29
|
-
|
|
29
|
+
const methodUpper = String(method).toUpperCase();
|
|
30
|
+
const unauthDisabled =
|
|
31
|
+
process.env.NUGIT_GITHUB_UNAUTHENTICATED === "0" ||
|
|
32
|
+
process.env.NUGIT_GITHUB_UNAUTHENTICATED === "false";
|
|
33
|
+
const allowUnauthenticatedGet =
|
|
34
|
+
!unauthDisabled &&
|
|
35
|
+
!token &&
|
|
36
|
+
(methodUpper === "GET" || methodUpper === "HEAD");
|
|
37
|
+
|
|
38
|
+
if (!token && !allowUnauthenticatedGet) {
|
|
30
39
|
throw new Error(
|
|
31
|
-
"
|
|
40
|
+
"GitHub authentication required for this request. Run `nugit auth login` or `nugit auth pat --token …`, " +
|
|
41
|
+
"or set NUGIT_USER_TOKEN / STACKPR_USER_TOKEN. " +
|
|
42
|
+
"Some read-only public GETs work without a token (low rate limits); set NUGIT_GITHUB_UNAUTHENTICATED=0 to disable that."
|
|
32
43
|
);
|
|
33
44
|
}
|
|
34
45
|
const headers = {
|
|
35
|
-
Authorization: `Bearer ${token}`,
|
|
36
46
|
Accept: "application/vnd.github+json",
|
|
37
|
-
"X-GitHub-Api-Version": "2022-11-28"
|
|
47
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
48
|
+
"User-Agent": "nugit-cli"
|
|
38
49
|
};
|
|
50
|
+
if (token) {
|
|
51
|
+
headers.Authorization = `Bearer ${token}`;
|
|
52
|
+
}
|
|
39
53
|
if (jsonBody !== undefined) {
|
|
40
54
|
headers["Content-Type"] = "application/json";
|
|
41
55
|
}
|
|
@@ -147,19 +161,90 @@ export async function githubCreatePullRequest(owner, repo, fields) {
|
|
|
147
161
|
/**
|
|
148
162
|
* @param {number} page
|
|
149
163
|
*/
|
|
150
|
-
|
|
164
|
+
/**
|
|
165
|
+
* @param {number} [page]
|
|
166
|
+
* @param {number} [perPage] max 100
|
|
167
|
+
*/
|
|
168
|
+
export async function githubListUserRepos(page = 1, perPage = 100) {
|
|
169
|
+
const pp = Math.min(100, Math.max(1, perPage));
|
|
170
|
+
const p = Math.max(1, page);
|
|
151
171
|
return githubRestJson(
|
|
152
172
|
"GET",
|
|
153
|
-
`/user/repos?page=${encodeURIComponent(String(
|
|
173
|
+
`/user/repos?page=${encodeURIComponent(String(p))}&per_page=${encodeURIComponent(String(pp))}&sort=full_name`
|
|
154
174
|
);
|
|
155
175
|
}
|
|
156
176
|
|
|
177
|
+
/**
|
|
178
|
+
* @returns {Promise<unknown[]>}
|
|
179
|
+
*/
|
|
180
|
+
export async function githubListAllUserRepos() {
|
|
181
|
+
/** @type {unknown[]} */
|
|
182
|
+
const all = [];
|
|
183
|
+
let page = 1;
|
|
184
|
+
for (;;) {
|
|
185
|
+
const chunk = await githubListUserRepos(page, 100);
|
|
186
|
+
if (!Array.isArray(chunk) || chunk.length === 0) {
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
all.push(...chunk);
|
|
190
|
+
if (chunk.length < 100) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
page += 1;
|
|
194
|
+
}
|
|
195
|
+
return all;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string} owner
|
|
200
|
+
* @param {string} repo
|
|
201
|
+
* @param {string} base
|
|
202
|
+
* @param {string} head
|
|
203
|
+
*/
|
|
204
|
+
export async function githubCompareRefs(owner, repo, base, head) {
|
|
205
|
+
const o = encodeURIComponent(owner);
|
|
206
|
+
const r = encodeURIComponent(repo);
|
|
207
|
+
const bh = `${base}...${head}`;
|
|
208
|
+
return githubRestJson("GET", `/repos/${o}/${r}/compare/${encodeURIComponent(bh)}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string} owner
|
|
213
|
+
* @param {string} repo
|
|
214
|
+
* @param {string} treeSha commit or tree sha
|
|
215
|
+
* @param {boolean} [recursive]
|
|
216
|
+
*/
|
|
217
|
+
export async function githubGetGitTree(owner, repo, treeSha, recursive = false) {
|
|
218
|
+
const o = encodeURIComponent(owner);
|
|
219
|
+
const r = encodeURIComponent(repo);
|
|
220
|
+
const sha = encodeURIComponent(treeSha);
|
|
221
|
+
const q = recursive ? "?recursive=1" : "";
|
|
222
|
+
return githubRestJson("GET", `/repos/${o}/${r}/git/trees/${sha}${q}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
157
225
|
/**
|
|
158
226
|
* Search issues (includes PRs). `q` is GitHub search query syntax.
|
|
159
227
|
* @param {string} q
|
|
160
228
|
* @param {number} [perPage] max 100
|
|
161
229
|
* @param {number} [page] 1-based
|
|
162
230
|
*/
|
|
231
|
+
/**
|
|
232
|
+
* GitHub repository search (`q` uses GitHub search syntax, e.g. `todo language:python` or `user:orgname`).
|
|
233
|
+
* @param {string} q
|
|
234
|
+
* @param {number} [perPage]
|
|
235
|
+
* @param {number} [page]
|
|
236
|
+
*/
|
|
237
|
+
export async function githubSearchRepositories(q, perPage = 20, page = 1) {
|
|
238
|
+
const pp = Math.min(100, Math.max(1, perPage));
|
|
239
|
+
const p = Math.max(1, page);
|
|
240
|
+
const query = new URLSearchParams({
|
|
241
|
+
q,
|
|
242
|
+
per_page: String(pp),
|
|
243
|
+
page: String(p)
|
|
244
|
+
});
|
|
245
|
+
return githubRestJson("GET", `/search/repositories?${query.toString()}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
163
248
|
export async function githubSearchIssues(q, perPage = 30, page = 1) {
|
|
164
249
|
const pp = Math.min(100, Math.max(1, perPage));
|
|
165
250
|
const p = Math.max(1, page);
|
|
@@ -189,6 +274,29 @@ export async function githubListOpenPulls(owner, repo, page = 1, perPage = 30) {
|
|
|
189
274
|
);
|
|
190
275
|
}
|
|
191
276
|
|
|
277
|
+
/**
|
|
278
|
+
* @param {string} owner
|
|
279
|
+
* @param {string} repo
|
|
280
|
+
* @returns {Promise<unknown[]>}
|
|
281
|
+
*/
|
|
282
|
+
export async function githubListAllOpenPulls(owner, repo) {
|
|
283
|
+
/** @type {unknown[]} */
|
|
284
|
+
const all = [];
|
|
285
|
+
let page = 1;
|
|
286
|
+
for (;;) {
|
|
287
|
+
const chunk = await githubListOpenPulls(owner, repo, page, 100);
|
|
288
|
+
if (!Array.isArray(chunk) || chunk.length === 0) {
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
all.push(...chunk);
|
|
292
|
+
if (chunk.length < 100) {
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
page += 1;
|
|
296
|
+
}
|
|
297
|
+
return all;
|
|
298
|
+
}
|
|
299
|
+
|
|
192
300
|
/**
|
|
193
301
|
* @param {string} owner
|
|
194
302
|
* @param {string} repo
|
package/src/nugit-stack.js
CHANGED
|
@@ -204,6 +204,26 @@ export function writeStackFile(root, doc) {
|
|
|
204
204
|
fs.writeFileSync(stackJsonPath(root), JSON.stringify(doc, null, 2) + "\n");
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Minimal stack doc for viewer-only flows (inferred stacks, no .nugit on disk).
|
|
209
|
+
* @param {string} repoFullName
|
|
210
|
+
* @param {string} createdBy
|
|
211
|
+
* @param {number[]} prNumbersBottomToTop
|
|
212
|
+
*/
|
|
213
|
+
export function createInferredStackDoc(repoFullName, createdBy, prNumbersBottomToTop) {
|
|
214
|
+
const prs = prNumbersBottomToTop.map((n, i) => ({
|
|
215
|
+
pr_number: n,
|
|
216
|
+
position: i + 1
|
|
217
|
+
}));
|
|
218
|
+
return {
|
|
219
|
+
version: 1,
|
|
220
|
+
repo_full_name: repoFullName,
|
|
221
|
+
created_by: createdBy,
|
|
222
|
+
prs,
|
|
223
|
+
inferred: true
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
207
227
|
export function createInitialStackDoc(repoFullName, createdBy) {
|
|
208
228
|
return {
|
|
209
229
|
version: 1,
|
package/src/nugit-start.js
CHANGED
|
@@ -179,14 +179,14 @@ export function runStart(opts) {
|
|
|
179
179
|
*/
|
|
180
180
|
export async function runStartHub() {
|
|
181
181
|
console.error("nugit start — choose:");
|
|
182
|
-
console.error(" 1)
|
|
182
|
+
console.error(" 1) View stacks (`nugit view` — search GitHub or this directory’s remote)");
|
|
183
183
|
console.error(" 2) Split a PR");
|
|
184
|
-
console.error(" 3) Open shell");
|
|
184
|
+
console.error(" 3) Open shell (needs `nugit config init`)");
|
|
185
185
|
const ans = (await questionLine("Enter 1–3 [3]: ")).trim();
|
|
186
186
|
const choice = ans || "3";
|
|
187
187
|
if (choice === "1") {
|
|
188
|
-
const {
|
|
189
|
-
await
|
|
188
|
+
const { runNugitViewEntry } = await import("./stack-view/run-view-entry.js");
|
|
189
|
+
await runNugitViewEntry(undefined, undefined, {});
|
|
190
190
|
process.exit(0);
|
|
191
191
|
}
|
|
192
192
|
if (choice === "2") {
|
package/src/nugit.js
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
} from "./nugit-stack.js";
|
|
30
30
|
import { runStackPropagate } from "./stack-propagate.js";
|
|
31
31
|
import { registerStackExtraCommands } from "./stack-extra-commands.js";
|
|
32
|
-
import {
|
|
32
|
+
import { runNugitViewEntry } from "./stack-view/run-view-entry.js";
|
|
33
33
|
import {
|
|
34
34
|
printJson,
|
|
35
35
|
formatWhoamiHuman,
|
|
@@ -88,7 +88,9 @@ program
|
|
|
88
88
|
const me = await authMe();
|
|
89
89
|
user = me.login;
|
|
90
90
|
if (!user) {
|
|
91
|
-
throw new Error(
|
|
91
|
+
throw new Error(
|
|
92
|
+
"Could not resolve login; pass --user or run `nugit auth login` / set NUGIT_USER_TOKEN"
|
|
93
|
+
);
|
|
92
94
|
}
|
|
93
95
|
console.error(`Using GitHub login: ${user}`);
|
|
94
96
|
}
|
|
@@ -120,7 +122,7 @@ const auth = new Command("auth").description("GitHub authentication");
|
|
|
120
122
|
auth
|
|
121
123
|
.command("login")
|
|
122
124
|
.description(
|
|
123
|
-
"OAuth device flow: opens browser (pre-filled code), waits for approval, saves token to ~/.config/nugit/github-token.
|
|
125
|
+
"OAuth device flow: opens browser (pre-filled code), waits for approval, saves token to ~/.config/nugit/github-token. Uses bundled OAuth App client id unless GITHUB_OAUTH_CLIENT_ID is set. NUGIT_USER_TOKEN still overrides the saved file."
|
|
124
126
|
)
|
|
125
127
|
.option("--no-browser", "Do not launch a browser (open the printed URL yourself)", false)
|
|
126
128
|
.option(
|
|
@@ -333,7 +335,7 @@ program
|
|
|
333
335
|
)
|
|
334
336
|
.option(
|
|
335
337
|
"--shell",
|
|
336
|
-
"Open the configured shell immediately (skip the TTY hub menu:
|
|
338
|
+
"Open the configured shell immediately (skip the TTY hub menu: view / split / shell)",
|
|
337
339
|
false
|
|
338
340
|
)
|
|
339
341
|
.action(async (opts) => {
|
|
@@ -378,6 +380,37 @@ program
|
|
|
378
380
|
});
|
|
379
381
|
});
|
|
380
382
|
|
|
383
|
+
program
|
|
384
|
+
.command("view")
|
|
385
|
+
.description(
|
|
386
|
+
"Interactive stack viewer (GitHub). Pass owner/repo and optional ref, or run with no args on a TTY to search/pick a repo (and [c] for this directory’s remote)."
|
|
387
|
+
)
|
|
388
|
+
.argument("[repo]", "owner/repo")
|
|
389
|
+
.argument("[ref]", "branch or sha (default: GitHub default branch)")
|
|
390
|
+
.option("--no-tui", "Print stack + comment counts to stdout (no Ink UI)", false)
|
|
391
|
+
.option("--repo <owner/repo>", "Same as [repo] (for scripts)")
|
|
392
|
+
.option("--ref <branch>", "Same as [ref]")
|
|
393
|
+
.option("--file <path>", "Load stack.json from this path")
|
|
394
|
+
.action(async (repoArg, refArg, opts) => {
|
|
395
|
+
await runNugitViewEntry(repoArg, refArg, opts);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
program
|
|
399
|
+
.command("review")
|
|
400
|
+
.description(
|
|
401
|
+
"Review hub: repos you can access (OAuth or PAT), pending review requests first; open inferred PR stacks in the TUI. See docs/stack-view.md."
|
|
402
|
+
)
|
|
403
|
+
.option("--no-tui", "Print repo list (sorted by pending reviews) instead of Ink picker", false)
|
|
404
|
+
.option(
|
|
405
|
+
"--auto-apply",
|
|
406
|
+
"After loading a stack, auto-approve allowlisted PRs when stale approval + merge-only delta (review-autoapprove.json)",
|
|
407
|
+
false
|
|
408
|
+
)
|
|
409
|
+
.action(async (opts) => {
|
|
410
|
+
const { runReviewHub } = await import("./review-hub/run-review-hub.js");
|
|
411
|
+
await runReviewHub({ noTui: opts.noTui, autoApply: opts.autoApply });
|
|
412
|
+
});
|
|
413
|
+
|
|
381
414
|
program
|
|
382
415
|
.command("env")
|
|
383
416
|
.description(
|
|
@@ -759,24 +792,6 @@ function addPropagateOptions(cmd) {
|
|
|
759
792
|
);
|
|
760
793
|
}
|
|
761
794
|
|
|
762
|
-
stack
|
|
763
|
-
.command("view")
|
|
764
|
-
.description(
|
|
765
|
-
"Interactive stack viewer (GitHub API): PR chain, comments, open links, reply, request reviewers"
|
|
766
|
-
)
|
|
767
|
-
.option("--no-tui", "Print stack + comment counts to stdout (no Ink UI)", false)
|
|
768
|
-
.option("--repo <owner/repo>", "With --ref: load stack from GitHub instead of local file")
|
|
769
|
-
.option("--ref <branch>", "Branch/sha for .nugit/stack.json on GitHub")
|
|
770
|
-
.option("--file <path>", "Path to stack.json (skip local .nugit lookup)")
|
|
771
|
-
.action(async (opts) => {
|
|
772
|
-
await runStackViewCommand({
|
|
773
|
-
noTui: opts.noTui,
|
|
774
|
-
repo: opts.repo,
|
|
775
|
-
ref: opts.ref,
|
|
776
|
-
file: opts.file
|
|
777
|
-
});
|
|
778
|
-
});
|
|
779
|
-
|
|
780
795
|
addPropagateOptions(
|
|
781
796
|
stack
|
|
782
797
|
.command("propagate")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { getConfigPath } from "../user-config.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{ owner: string, repo: string, head_ref_glob?: string, default_branch_only?: boolean }} AutoapproveRule
|
|
7
|
+
* @typedef {{ rules?: AutoapproveRule[], version?: number }} AutoapproveConfig
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @returns {AutoapproveConfig | null}
|
|
12
|
+
*/
|
|
13
|
+
export function loadReviewAutoapproveConfig() {
|
|
14
|
+
const base = path.dirname(getConfigPath());
|
|
15
|
+
const p = path.join(base, "review-autoapprove.json");
|
|
16
|
+
if (!fs.existsSync(p)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {AutoapproveRule} rule
|
|
29
|
+
* @param {string} owner
|
|
30
|
+
* @param {string} repo
|
|
31
|
+
* @param {string} headRef
|
|
32
|
+
*/
|
|
33
|
+
function matchRule(rule, owner, repo, headRef) {
|
|
34
|
+
if (String(rule.owner) !== owner || String(rule.repo) !== repo) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const glob = rule.head_ref_glob;
|
|
38
|
+
if (glob) {
|
|
39
|
+
const re = new RegExp(
|
|
40
|
+
"^" +
|
|
41
|
+
String(glob)
|
|
42
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
43
|
+
.replace(/\*/g, ".*") +
|
|
44
|
+
"$"
|
|
45
|
+
);
|
|
46
|
+
if (!re.test(headRef)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {AutoapproveConfig | null} cfg
|
|
55
|
+
* @param {string} owner
|
|
56
|
+
* @param {string} repo
|
|
57
|
+
* @param {string} headRef
|
|
58
|
+
*/
|
|
59
|
+
export function isRepoHeadAutoapproveEligible(cfg, owner, repo, headRef) {
|
|
60
|
+
if (!cfg || !Array.isArray(cfg.rules)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return cfg.rules.some((r) => r && matchRule(r, owner, repo, headRef));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Heuristic: commits between base and head look like merge-only from default branch (best-effort).
|
|
68
|
+
* @param {Record<string, unknown>} comparePayload from githubCompareRefs
|
|
69
|
+
* @param {string} defaultBranch
|
|
70
|
+
*/
|
|
71
|
+
export function compareLooksLikeMainMergesOnly(comparePayload, defaultBranch) {
|
|
72
|
+
const commits = Array.isArray(comparePayload.commits) ? comparePayload.commits : [];
|
|
73
|
+
if (commits.length === 0) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
const safeDefault = String(defaultBranch || "main");
|
|
77
|
+
for (const c of commits) {
|
|
78
|
+
if (!c || typeof c !== "object") continue;
|
|
79
|
+
const commit = /** @type {Record<string, unknown>} */ (c).commit;
|
|
80
|
+
const msg =
|
|
81
|
+
commit && typeof commit === "object"
|
|
82
|
+
? String(/** @type {Record<string, unknown>} */ (commit).message || "").split("\n")[0]
|
|
83
|
+
: "";
|
|
84
|
+
const mergePat = new RegExp(`^Merge branch ['"]?${safeDefault}['"]?`, "i");
|
|
85
|
+
const ghMerge = /^Merge pull request #\d+ from /i;
|
|
86
|
+
if (mergePat.test(msg) || ghMerge.test(msg)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (/^Merge branch/i.test(msg) || /^Merge remote-tracking branch/i.test(msg)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when the user presses Backspace / Esc from the stack picker
|
|
3
|
+
* to return to the review-hub repository list (`nugit review` only).
|
|
4
|
+
*/
|
|
5
|
+
export class ReviewHubBackError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("Back to review hub");
|
|
8
|
+
this.name = "ReviewHubBackError";
|
|
9
|
+
}
|
|
10
|
+
}
|