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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -11
  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 +114 -6
  7. package/src/nugit-stack.js +20 -0
  8. package/src/nugit-start.js +4 -4
  9. package/src/nugit.js +37 -22
  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 +166 -0
  13. package/src/review-hub/run-review-hub.js +188 -0
  14. package/src/split-view/run-split.js +16 -3
  15. package/src/split-view/split-ink.js +2 -2
  16. package/src/stack-discover.js +9 -1
  17. package/src/stack-infer-from-prs.js +71 -0
  18. package/src/stack-view/diff-line-map.js +62 -0
  19. package/src/stack-view/fetch-pr-data.js +104 -4
  20. package/src/stack-view/infer-chains-to-pick-stacks.js +70 -0
  21. package/src/stack-view/ink-app.js +1853 -156
  22. package/src/stack-view/loading-ink.js +44 -0
  23. package/src/stack-view/merge-alternate-pick-stacks.js +223 -0
  24. package/src/stack-view/patch-preview-merge.js +108 -0
  25. package/src/stack-view/remote-infer-doc.js +93 -0
  26. package/src/stack-view/repo-picker-back.js +10 -0
  27. package/src/stack-view/run-stack-view.js +685 -50
  28. package/src/stack-view/run-view-entry.js +119 -0
  29. package/src/stack-view/sgr-mouse.js +56 -0
  30. package/src/stack-view/stack-branch-graph.js +95 -0
  31. package/src/stack-view/stack-pick-graph.js +93 -0
  32. package/src/stack-view/stack-pick-ink.js +270 -0
  33. package/src/stack-view/stack-pick-layout.js +19 -0
  34. package/src/stack-view/stack-pick-sort.js +188 -0
  35. package/src/stack-view/terminal-fullscreen.js +45 -0
  36. package/src/stack-view/tree-ascii.js +73 -0
  37. package/src/stack-view/view-md-plain.js +23 -0
  38. package/src/stack-view/view-repo-picker-ink.js +293 -0
  39. package/src/stack-view/view-tui-sequential.js +126 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nugit-cli",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "CLI for stacked GitHub PRs (.nugit/stack.json)",
5
5
  "type": "module",
6
6
  "bin": {
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 (requires GITHUB_OAUTH_CLIENT_ID).
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 = 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
- }
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 = process.env.GITHUB_OAUTH_CLIENT_ID;
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). Requires GITHUB_OAUTH_CLIENT_ID.
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
+ }
@@ -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
+ }
@@ -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
- if (!token) {
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
- "Set NUGIT_USER_TOKEN or STACKPR_USER_TOKEN (GitHub PAT or OAuth token with repo scope)"
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
- export async function githubListUserRepos(page = 1) {
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(page))}&per_page=30`
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
@@ -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,
@@ -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) Stack view");
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 { runStackViewCommand } = await import("./stack-view/run-stack-view.js");
189
- await runStackViewCommand({});
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 { runStackViewCommand } from "./stack-view/run-stack-view.js";
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("Could not resolve login; pass --user or set NUGIT_USER_TOKEN / STACKPR_USER_TOKEN");
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. Needs GITHUB_OAUTH_CLIENT_ID. Env NUGIT_USER_TOKEN still overrides the file."
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: stack view / split / shell)",
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
+ }