nugit-cli 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -23
  3. package/src/github-device-flow.js +1 -1
  4. package/src/github-oauth-client-id.js +11 -0
  5. package/src/github-pr-social.js +42 -0
  6. package/src/github-rest.js +149 -6
  7. package/src/nugit-config.js +84 -0
  8. package/src/nugit-stack.js +40 -257
  9. package/src/nugit.js +104 -647
  10. package/src/review-hub/review-autoapprove.js +95 -0
  11. package/src/review-hub/review-hub-back.js +10 -0
  12. package/src/review-hub/review-hub-ink.js +169 -0
  13. package/src/review-hub/run-review-hub.js +131 -0
  14. package/src/services/repo-branches.js +151 -0
  15. package/src/services/stack-inference.js +90 -0
  16. package/src/split-view/run-split.js +14 -76
  17. package/src/split-view/split-ink.js +2 -2
  18. package/src/stack-infer-from-prs.js +71 -0
  19. package/src/stack-view/diff-line-map.js +62 -0
  20. package/src/stack-view/fetch-pr-data.js +104 -4
  21. package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
  22. package/src/stack-view/ink-app.js +3 -421
  23. package/src/stack-view/loader.js +19 -93
  24. package/src/stack-view/loading-ink.js +2 -0
  25. package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
  26. package/src/stack-view/patch-preview-merge.js +108 -0
  27. package/src/stack-view/remote-infer-doc.js +76 -0
  28. package/src/stack-view/repo-picker-back.js +10 -0
  29. package/src/stack-view/run-stack-view.js +508 -150
  30. package/src/stack-view/run-view-entry.js +115 -0
  31. package/src/stack-view/sgr-mouse.js +56 -0
  32. package/src/stack-view/stack-branch-graph.js +95 -0
  33. package/src/stack-view/stack-pick-graph.js +93 -0
  34. package/src/stack-view/stack-pick-ink.js +308 -0
  35. package/src/stack-view/stack-pick-layout.js +19 -0
  36. package/src/stack-view/stack-pick-sort.js +188 -0
  37. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  38. package/src/stack-view/terminal-fullscreen.js +7 -0
  39. package/src/stack-view/tree-ascii.js +73 -0
  40. package/src/stack-view/view-md-plain.js +23 -0
  41. package/src/stack-view/view-repo-picker-ink.js +293 -0
  42. package/src/stack-view/view-tui-sequential.js +126 -0
  43. package/src/tui/pages/home.js +122 -0
  44. package/src/tui/pages/repo-actions.js +81 -0
  45. package/src/tui/pages/repo-branches.js +259 -0
  46. package/src/tui/pages/viewer.js +2129 -0
  47. package/src/tui/router.js +40 -0
  48. package/src/tui/run-tui.js +281 -0
  49. package/src/utilities/loading.js +37 -0
  50. package/src/utilities/terminal.js +31 -0
  51. package/src/cli-output.js +0 -228
  52. package/src/nugit-start.js +0 -211
  53. package/src/stack-discover.js +0 -284
  54. package/src/stack-discovery-config.js +0 -91
  55. package/src/stack-extra-commands.js +0 -353
  56. package/src/stack-graph.js +0 -214
  57. package/src/stack-helpers.js +0 -58
  58. package/src/stack-propagate.js +0 -422
@@ -0,0 +1,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
+ }
@@ -0,0 +1,169 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import { parseSgrMouse, enableSgrMouse, disableSgrMouse, isWheelUp, isWheelDown } from "../stack-view/sgr-mouse.js";
4
+
5
+ const VIEWPORT_ROWS = 14;
6
+
7
+ /**
8
+ * @typedef {{ kind: "header"; text: string }} HubHeader
9
+ * @typedef {{ kind: "repo"; fullName: string; pending: number; subtitle?: string }} HubRepo
10
+ * @typedef {HubHeader | HubRepo} HubLine
11
+ */
12
+
13
+ /**
14
+ * @param {object} props
15
+ * @param {HubLine[]} props.lines
16
+ * @param {(fullName: string) => void} props.onPickRepo
17
+ * @param {() => void} [props.onBack] Called when user presses Backspace/Esc to go back
18
+ */
19
+ export function ReviewHubInk({ lines, onPickRepo, onBack }) {
20
+ const { exit } = useApp();
21
+ const { stdout } = useStdout();
22
+ /** @type {React.MutableRefObject<{ firstListRow: number, viewStart: number, slice: HubLine[] } | null>} */
23
+ const layoutRef = useRef(null);
24
+ /** @type {React.MutableRefObject<{ t: number, lineIdx: number } | null>} */
25
+ const lastClickRef = useRef(null);
26
+
27
+ useEffect(() => {
28
+ enableSgrMouse(stdout);
29
+ return () => disableSgrMouse(stdout);
30
+ }, [stdout]);
31
+
32
+ const repoIndices = useMemo(
33
+ () => lines.map((l, i) => (l.kind === "repo" ? i : -1)).filter((i) => i >= 0),
34
+ [lines]
35
+ );
36
+
37
+ const [rpos, setRpos] = useState(0);
38
+ const safeRpos = repoIndices.length === 0 ? 0 : Math.min(rpos, repoIndices.length - 1);
39
+ const idx = repoIndices.length ? repoIndices[safeRpos] : 0;
40
+
41
+ const total = lines.length;
42
+ const viewStart =
43
+ total <= VIEWPORT_ROWS
44
+ ? 0
45
+ : Math.max(0, Math.min(idx - Math.floor(VIEWPORT_ROWS / 2), total - VIEWPORT_ROWS));
46
+ const slice = lines.slice(viewStart, viewStart + VIEWPORT_ROWS);
47
+
48
+ useInput((input, key) => {
49
+ const mouse = parseSgrMouse(input);
50
+ if (mouse) {
51
+ const L = layoutRef.current;
52
+ if (!L) {
53
+ return;
54
+ }
55
+ const { row, col, button, release } = mouse;
56
+ if (col < 2) {
57
+ return;
58
+ }
59
+ if (isWheelUp(button) || isWheelDown(button)) {
60
+ const down = isWheelDown(button);
61
+ if (repoIndices.length) {
62
+ if (down) {
63
+ setRpos((p) => Math.min(p + 1, repoIndices.length - 1));
64
+ } else {
65
+ setRpos((p) => Math.max(p - 1, 0));
66
+ }
67
+ }
68
+ return;
69
+ }
70
+ if (release) {
71
+ return;
72
+ }
73
+ const local = row - L.firstListRow;
74
+ if (local >= 0 && local < L.slice.length) {
75
+ const i = L.viewStart + local;
76
+ const line = lines[i];
77
+ if (line && line.kind === "repo") {
78
+ const ri = repoIndices.indexOf(i);
79
+ if (ri >= 0) {
80
+ setRpos(ri);
81
+ const now = Date.now();
82
+ const prev = lastClickRef.current;
83
+ const dbl = prev && prev.lineIdx === i && now - prev.t < 480;
84
+ lastClickRef.current = { t: now, lineIdx: i };
85
+ if (dbl) {
86
+ onPickRepo(line.fullName);
87
+ exit();
88
+ }
89
+ }
90
+ }
91
+ }
92
+ return;
93
+ }
94
+
95
+ const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
96
+ if (input === "q" || key.escape || backKey) {
97
+ onBack?.();
98
+ exit();
99
+ return;
100
+ }
101
+ if (/^[1-9]$/.test(input) && repoIndices.length) {
102
+ const n = Number.parseInt(input, 10) - 1;
103
+ if (n < repoIndices.length) {
104
+ const lineIdx = repoIndices[n];
105
+ const line = lines[lineIdx];
106
+ if (line && line.kind === "repo") {
107
+ onPickRepo(line.fullName);
108
+ exit();
109
+ }
110
+ }
111
+ return;
112
+ }
113
+ if (input === "j" || key.downArrow) {
114
+ if (repoIndices.length) {
115
+ setRpos((p) => Math.min(p + 1, repoIndices.length - 1));
116
+ }
117
+ return;
118
+ }
119
+ if (input === "k" || key.upArrow) {
120
+ setRpos((p) => Math.max(p - 1, 0));
121
+ return;
122
+ }
123
+ if (key.return || input === " ") {
124
+ const line = lines[idx];
125
+ if (line && line.kind === "repo") {
126
+ onPickRepo(line.fullName);
127
+ exit();
128
+ }
129
+ }
130
+ });
131
+
132
+ const status =
133
+ total > VIEWPORT_ROWS
134
+ ? `Lines ${viewStart + 1}–${Math.min(viewStart + VIEWPORT_ROWS, total)} of ${total} · repo ${safeRpos + 1}/${repoIndices.length}`
135
+ : repoIndices.length
136
+ ? `Repo ${safeRpos + 1}/${repoIndices.length}`
137
+ : "";
138
+
139
+ const rendered = slice.map((line, localI) => {
140
+ const i = viewStart + localI;
141
+ if (line.kind === "header") {
142
+ return React.createElement(Text, { key: `h-${i}`, color: "magenta", bold: true }, line.text);
143
+ }
144
+ const mark = i === idx ? "▶ " : " ";
145
+ const pend = line.pending > 0 ? ` · ${line.pending} review request(s)` : "";
146
+ const hot = line.pending > 0;
147
+ return React.createElement(
148
+ Text,
149
+ {
150
+ key: `r-${i}`,
151
+ color: hot ? "yellow" : "white",
152
+ bold: hot
153
+ },
154
+ `${mark}${line.fullName}${pend}`
155
+ );
156
+ });
157
+
158
+ const firstListRow = status ? 4 : 3;
159
+ layoutRef.current = { firstListRow, viewStart, slice };
160
+
161
+ return React.createElement(
162
+ Box,
163
+ { flexDirection: "column", padding: 1 },
164
+ React.createElement(Text, { color: "cyan", bold: true }, "nugit review — repositories"),
165
+ React.createElement(Text, { dimColor: true }, "j/k · 1-9 open · Enter/Space · click · wheel · Backspace/q back"),
166
+ status ? React.createElement(Text, { dimColor: true }, status) : null,
167
+ ...rendered
168
+ );
169
+ }
@@ -0,0 +1,131 @@
1
+ import { authMe } from "../api-client.js";
2
+ import { githubListAllUserRepos, githubSearchIssues } from "../github-rest.js";
3
+ import { resolveGithubToken } from "../auth-token.js";
4
+
5
+ /**
6
+ * @param {string} login
7
+ * @returns {Promise<Map<string, number>>}
8
+ */
9
+ async function pendingReviewsByRepo(login) {
10
+ /** @type {Map<string, number>} */
11
+ const map = new Map();
12
+ let page = 1;
13
+ const maxPages = 30;
14
+ while (page <= maxPages) {
15
+ const res = await githubSearchIssues(
16
+ `is:open is:pr review-requested:${login}`,
17
+ 100,
18
+ page
19
+ );
20
+ const items = Array.isArray(res.items) ? res.items : [];
21
+ if (items.length === 0) break;
22
+ for (const it of items) {
23
+ if (!it || typeof it !== "object") continue;
24
+ const u = /** @type {Record<string, unknown>} */ (it).repository_url;
25
+ if (typeof u !== "string") continue;
26
+ const m = u.match(/\/repos\/([^/]+)\/([^/]+)$/);
27
+ if (!m) continue;
28
+ const full = `${m[1]}/${m[2]}`;
29
+ map.set(full, (map.get(full) || 0) + 1);
30
+ }
31
+ if (items.length < 100) break;
32
+ page += 1;
33
+ }
34
+ return map;
35
+ }
36
+
37
+ /**
38
+ * @param {unknown} repoObj
39
+ * @param {Map<string, number>} pendingMap
40
+ */
41
+ function normalizeRepo(repoObj, pendingMap) {
42
+ if (!repoObj || typeof repoObj !== "object") return null;
43
+ const r = /** @type {Record<string, unknown>} */ (repoObj);
44
+ const fn = typeof r.full_name === "string" ? r.full_name : "";
45
+ if (!fn) return null;
46
+ const owner =
47
+ r.owner && typeof r.owner === "object"
48
+ ? /** @type {Record<string, unknown>} */ (r.owner)
49
+ : {};
50
+ const ownerType = typeof owner.type === "string" ? owner.type : "User";
51
+ const pending = pendingMap.get(fn) || 0;
52
+ return { fullName: fn, ownerType, pending };
53
+ }
54
+
55
+ /**
56
+ * @param {{ fullName: string, ownerType: string, pending: number }[]} repos
57
+ * @returns {import("./review-hub-ink.js").HubLine[]}
58
+ */
59
+ export function buildHubLines(repos) {
60
+ const sorted = [...repos].sort((a, b) => {
61
+ if (b.pending !== a.pending) return b.pending - a.pending;
62
+ return a.fullName.localeCompare(b.fullName);
63
+ });
64
+
65
+ /** @type {import("./review-hub-ink.js").HubLine[]} */
66
+ const lines = [];
67
+ let lastBucket = "";
68
+ for (const r of sorted) {
69
+ const bucket = r.ownerType === "Organization" ? "Organizations" : "Users";
70
+ const ownerLogin = r.fullName.split("/")[0] || "";
71
+ const key = `${bucket}:${ownerLogin}`;
72
+ if (key !== lastBucket) {
73
+ lastBucket = key;
74
+ lines.push({ kind: "header", text: `${bucket} — ${ownerLogin}` });
75
+ }
76
+ lines.push({ kind: "repo", fullName: r.fullName, pending: r.pending });
77
+ }
78
+ return lines;
79
+ }
80
+
81
+ /**
82
+ * Fetch all data needed for the review hub UI.
83
+ * Does NOT touch the terminal or render any UI.
84
+ * @returns {Promise<{ login: string, lines: import("./review-hub-ink.js").HubLine[] }>}
85
+ */
86
+ export async function fetchReviewHubData() {
87
+ if (!resolveGithubToken()) {
88
+ throw new Error(
89
+ "GitHub token required for the review hub. Run `nugit auth login` or set NUGIT_USER_TOKEN / STACKPR_USER_TOKEN."
90
+ );
91
+ }
92
+
93
+ const me = await authMe();
94
+ const login = me && typeof me.login === "string" ? me.login : "";
95
+ if (!login) {
96
+ throw new Error("Could not resolve GitHub login for review hub.");
97
+ }
98
+
99
+ const [rawRepos, pendingMap] = await Promise.all([
100
+ githubListAllUserRepos(),
101
+ pendingReviewsByRepo(login)
102
+ ]);
103
+
104
+ const repos = /** @type {{ fullName: string, ownerType: string, pending: number }[]} */ ([]);
105
+ for (const r of rawRepos) {
106
+ const n = normalizeRepo(r, pendingMap);
107
+ if (n) repos.push(n);
108
+ }
109
+
110
+ const lines = buildHubLines(repos);
111
+ return { login, lines };
112
+ }
113
+
114
+ /**
115
+ * Non-TUI (scripting / CI) path: print repo list to stdout.
116
+ * @param {{ noTui?: boolean, autoApply?: boolean }} opts
117
+ */
118
+ export async function runReviewHub(opts) {
119
+ const { login, lines } = await fetchReviewHubData();
120
+ void login;
121
+
122
+ const repos = lines
123
+ .filter((l) => l.kind === "repo")
124
+ .map((l) => /** @type {{ kind: "repo", fullName: string, pending: number }} */ (l));
125
+
126
+ for (const r of repos) {
127
+ const p = r.pending ? ` (${r.pending} pending)` : "";
128
+ console.log(`${r.fullName}${p}`);
129
+ }
130
+ void opts;
131
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Service functions for the "Branches / PRs" TUI page.
3
+ * Fuses local git branches with remote GitHub branches into a unified model.
4
+ */
5
+
6
+ import { execFileSync } from "child_process";
7
+ import { githubListAllBranches } from "../github-rest.js";
8
+
9
+ /**
10
+ * @typedef {{ name: string, sha: string }} LocalBranch
11
+ * @typedef {{ name: string, sha: string }} RemoteBranch
12
+ * @typedef {"local_only" | "remote_only" | "in_sync" | "local_ahead" | "remote_ahead" | "diverged"} BranchStatus
13
+ * @typedef {{ name: string, local: LocalBranch | null, remote: RemoteBranch | null, status: BranchStatus, ahead: number, behind: number }} BranchRow
14
+ */
15
+
16
+ /**
17
+ * @param {string} root git working tree root
18
+ * @returns {LocalBranch[]}
19
+ */
20
+ export function listLocalBranches(root) {
21
+ try {
22
+ const out = execFileSync(
23
+ "git",
24
+ ["for-each-ref", "--format=%(refname:short) %(objectname:short)", "refs/heads/"],
25
+ { cwd: root, encoding: "utf8", stdio: "pipe" }
26
+ ).trim();
27
+ if (!out) return [];
28
+ return out.split("\n").map((line) => {
29
+ const [name, sha] = line.trim().split(/\s+/);
30
+ return { name: name || "", sha: sha || "" };
31
+ }).filter((b) => b.name);
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ /**
38
+ * @param {string} owner
39
+ * @param {string} repo
40
+ * @returns {Promise<RemoteBranch[]>}
41
+ */
42
+ export async function listRemoteBranches(owner, repo) {
43
+ const raw = await githubListAllBranches(owner, repo);
44
+ /** @type {RemoteBranch[]} */
45
+ const result = [];
46
+ for (const b of raw) {
47
+ if (!b || typeof b !== "object") continue;
48
+ const br = /** @type {Record<string, unknown>} */ (b);
49
+ const name = typeof br.name === "string" ? br.name : "";
50
+ const commit =
51
+ br.commit && typeof br.commit === "object"
52
+ ? /** @type {Record<string, unknown>} */ (br.commit)
53
+ : {};
54
+ const sha = typeof commit.sha === "string" ? commit.sha.slice(0, 12) : "";
55
+ if (name) result.push({ name, sha });
56
+ }
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Compute ahead/behind counts between two refs using `git rev-list --left-right --count`.
62
+ * Returns { ahead, behind } where ahead = local commits not on remote, behind = remote commits not on local.
63
+ * @param {string} root
64
+ * @param {string} localRef e.g. "refs/heads/feat"
65
+ * @param {string} remoteRef e.g. "origin/feat"
66
+ * @returns {{ ahead: number, behind: number }}
67
+ */
68
+ function countAheadBehind(root, localRef, remoteRef) {
69
+ try {
70
+ const out = execFileSync(
71
+ "git",
72
+ ["rev-list", "--left-right", "--count", `${remoteRef}...${localRef}`],
73
+ { cwd: root, encoding: "utf8", stdio: "pipe" }
74
+ ).trim();
75
+ const parts = out.split(/\s+/);
76
+ const behind = Number.parseInt(parts[0], 10) || 0;
77
+ const ahead = Number.parseInt(parts[1], 10) || 0;
78
+ return { ahead, behind };
79
+ } catch {
80
+ return { ahead: 0, behind: 0 };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Merge local and remote branch lists into a unified model.
86
+ * When a branch exists in both, compute ahead/behind using git (requires local clone).
87
+ *
88
+ * @param {LocalBranch[]} localBranches
89
+ * @param {RemoteBranch[]} remoteBranches
90
+ * @param {string | null} root git root for ahead/behind computation (null = no local clone)
91
+ * @param {string} remoteName git remote name, e.g. "origin"
92
+ * @returns {Promise<BranchRow[]>}
93
+ */
94
+ export async function mergeBranchModel(localBranches, remoteBranches, root, remoteName = "origin") {
95
+ /** @type {Map<string, LocalBranch>} */
96
+ const localMap = new Map(localBranches.map((b) => [b.name, b]));
97
+ /** @type {Map<string, RemoteBranch>} */
98
+ const remoteMap = new Map(remoteBranches.map((b) => [b.name, b]));
99
+
100
+ /** @type {Set<string>} */
101
+ const allNames = new Set([...localMap.keys(), ...remoteMap.keys()]);
102
+
103
+ /** @type {BranchRow[]} */
104
+ const rows = [];
105
+
106
+ for (const name of allNames) {
107
+ const local = localMap.get(name) ?? null;
108
+ const remote = remoteMap.get(name) ?? null;
109
+
110
+ /** @type {BranchStatus} */
111
+ let status = "in_sync";
112
+ let ahead = 0;
113
+ let behind = 0;
114
+
115
+ if (local && remote) {
116
+ if (local.sha === remote.sha.slice(0, local.sha.length) || remote.sha === local.sha.slice(0, remote.sha.length)) {
117
+ status = "in_sync";
118
+ } else if (root) {
119
+ const ab = countAheadBehind(root, `refs/heads/${name}`, `${remoteName}/${name}`);
120
+ ahead = ab.ahead;
121
+ behind = ab.behind;
122
+ if (ahead > 0 && behind > 0) {
123
+ status = "diverged";
124
+ } else if (ahead > 0) {
125
+ status = "local_ahead";
126
+ } else if (behind > 0) {
127
+ status = "remote_ahead";
128
+ } else {
129
+ status = "in_sync";
130
+ }
131
+ }
132
+ } else if (local && !remote) {
133
+ status = "local_only";
134
+ } else if (!local && remote) {
135
+ status = "remote_only";
136
+ }
137
+
138
+ rows.push({ name, local, remote, status, ahead, behind });
139
+ }
140
+
141
+ // Sort: local_ahead first (actionable), then diverged, local_only, in_sync, remote_ahead, remote_only
142
+ const order = { local_ahead: 0, diverged: 1, local_only: 2, in_sync: 3, remote_ahead: 4, remote_only: 5 };
143
+ rows.sort((a, b) => {
144
+ const oa = order[a.status] ?? 99;
145
+ const ob = order[b.status] ?? 99;
146
+ if (oa !== ob) return oa - ob;
147
+ return a.name.localeCompare(b.name);
148
+ });
149
+
150
+ return rows;
151
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Inference-only stack discovery service.
3
+ * Replaces the .nugit/stack.json scanning approach with open-PR chain analysis.
4
+ */
5
+ import { githubListAllOpenPulls } from "../github-rest.js";
6
+ import { inferPrChainsFromOpenPulls } from "../stack-infer-from-prs.js";
7
+ import { inferChainsToPickStacks } from "../stack-view/infer-chains-to-pick-stacks.js";
8
+ import {
9
+ openPullNumbersFromList,
10
+ tagPickStacksMergedState
11
+ } from "../stack-view/merge-alternate-pick-stacks.js";
12
+ import { createInferredStackDoc, parseRepoFullName } from "../nugit-stack.js";
13
+ import { authMe } from "../api-client.js";
14
+
15
+ /**
16
+ * Discover stacks for a repo using open-PR chain inference (no .nugit required).
17
+ * Returns rows in the same shape as StackPickInk expects.
18
+ *
19
+ * @param {string} owner
20
+ * @param {string} repoName
21
+ * @returns {Promise<{ stacks: object[], openPullNumbers: Set<number> }>}
22
+ */
23
+ export async function discoverStacksByInference(owner, repoName) {
24
+ const pulls = await githubListAllOpenPulls(owner, repoName);
25
+ const openNums = openPullNumbersFromList(pulls);
26
+ const repoFull = `${owner}/${repoName}`;
27
+ const chains = inferPrChainsFromOpenPulls(pulls, repoFull);
28
+ const stacks = inferChainsToPickStacks(chains, pulls).map((s) => ({
29
+ ...s,
30
+ inferredOnly: true
31
+ }));
32
+ return {
33
+ stacks: tagPickStacksMergedState(stacks, openNums),
34
+ openPullNumbers: openNums
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Build an inferred stack document from open PRs, picking the best chain or
40
+ * prompting the user. Returns a viewer-ready stack doc.
41
+ *
42
+ * @param {string} repoFull
43
+ * @param {{
44
+ * preselectedChainIndex?: number,
45
+ * tuiChainPick?: (chains: number[][], pulls: unknown[]) => Promise<number>
46
+ * }} [opts]
47
+ * @returns {Promise<{ doc: Record<string, unknown>, viewerLogin: string, pulls: unknown[] }>}
48
+ */
49
+ export async function buildInferredViewerDoc(repoFull, opts = {}) {
50
+ const { owner, repo } = parseRepoFullName(repoFull);
51
+ const pulls = await githubListAllOpenPulls(owner, repo);
52
+ const chains = inferPrChainsFromOpenPulls(pulls, repoFull);
53
+
54
+ if (chains.length === 0) {
55
+ throw new Error(
56
+ `No open PR stacks found in ${repoFull}. ` +
57
+ "Open some PRs in a stacked chain (base of one PR = head of another) and try again."
58
+ );
59
+ }
60
+
61
+ const me = await authMe();
62
+ const viewerLogin = me && typeof me.login === "string" ? me.login : "viewer";
63
+
64
+ let chosen;
65
+ if (chains.length === 1) {
66
+ chosen = chains[0];
67
+ } else if (typeof opts.preselectedChainIndex === "number") {
68
+ const i = opts.preselectedChainIndex;
69
+ if (i < 0 || i >= chains.length) throw new Error("Invalid preselectedChainIndex.");
70
+ chosen = chains[i];
71
+ } else if (typeof opts.tuiChainPick === "function") {
72
+ const idx = await opts.tuiChainPick(chains, pulls);
73
+ if (idx === -2) {
74
+ const { RepoPickerBackError } = await import("../stack-view/repo-picker-back.js");
75
+ throw new RepoPickerBackError();
76
+ }
77
+ if (idx < 0 || idx >= chains.length) {
78
+ throw new Error("Stack selection cancelled.");
79
+ }
80
+ chosen = chains[idx];
81
+ } else {
82
+ chosen = chains.reduce((a, b) => (b.length > a.length ? b : a));
83
+ }
84
+
85
+ return {
86
+ doc: createInferredStackDoc(repoFull, viewerLogin, chosen),
87
+ viewerLogin,
88
+ pulls
89
+ };
90
+ }