nugit-cli 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api-client.js +10 -23
- package/src/github-device-flow.js +1 -1
- package/src/github-oauth-client-id.js +11 -0
- package/src/github-pr-social.js +42 -0
- package/src/github-rest.js +149 -6
- package/src/nugit-config.js +84 -0
- package/src/nugit-stack.js +40 -257
- package/src/nugit.js +104 -647
- package/src/review-hub/review-autoapprove.js +95 -0
- package/src/review-hub/review-hub-back.js +10 -0
- package/src/review-hub/review-hub-ink.js +169 -0
- package/src/review-hub/run-review-hub.js +131 -0
- package/src/services/repo-branches.js +151 -0
- package/src/services/stack-inference.js +90 -0
- package/src/split-view/run-split.js +14 -76
- package/src/split-view/split-ink.js +2 -2
- package/src/stack-infer-from-prs.js +71 -0
- package/src/stack-view/diff-line-map.js +62 -0
- package/src/stack-view/fetch-pr-data.js +104 -4
- package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
- package/src/stack-view/ink-app.js +3 -421
- package/src/stack-view/loader.js +19 -93
- package/src/stack-view/loading-ink.js +2 -0
- package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
- package/src/stack-view/patch-preview-merge.js +108 -0
- package/src/stack-view/remote-infer-doc.js +76 -0
- package/src/stack-view/repo-picker-back.js +10 -0
- package/src/stack-view/run-stack-view.js +508 -150
- package/src/stack-view/run-view-entry.js +115 -0
- package/src/stack-view/sgr-mouse.js +56 -0
- package/src/stack-view/stack-branch-graph.js +95 -0
- package/src/stack-view/stack-pick-graph.js +93 -0
- package/src/stack-view/stack-pick-ink.js +308 -0
- package/src/stack-view/stack-pick-layout.js +19 -0
- package/src/stack-view/stack-pick-sort.js +188 -0
- package/src/stack-view/stack-picker-graph-pane.js +118 -0
- package/src/stack-view/terminal-fullscreen.js +7 -0
- package/src/stack-view/tree-ascii.js +73 -0
- package/src/stack-view/view-md-plain.js +23 -0
- package/src/stack-view/view-repo-picker-ink.js +293 -0
- package/src/stack-view/view-tui-sequential.js +126 -0
- package/src/tui/pages/home.js +122 -0
- package/src/tui/pages/repo-actions.js +81 -0
- package/src/tui/pages/repo-branches.js +259 -0
- package/src/tui/pages/viewer.js +2129 -0
- package/src/tui/router.js +40 -0
- package/src/tui/run-tui.js +281 -0
- package/src/utilities/loading.js +37 -0
- package/src/utilities/terminal.js +31 -0
- package/src/cli-output.js +0 -228
- package/src/nugit-start.js +0 -211
- package/src/stack-discover.js +0 -284
- package/src/stack-discovery-config.js +0 -91
- package/src/stack-extra-commands.js +0 -353
- package/src/stack-graph.js +0 -214
- package/src/stack-helpers.js +0 -58
- package/src/stack-propagate.js +0 -422
|
@@ -0,0 +1,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
|
+
}
|