nugit-cli 0.1.0 → 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 +0 -12
- package/src/github-rest.js +35 -0
- package/src/nugit-config.js +84 -0
- package/src/nugit-stack.js +29 -266
- package/src/nugit.js +103 -661
- package/src/review-hub/review-hub-ink.js +6 -3
- package/src/review-hub/run-review-hub.js +34 -91
- package/src/services/repo-branches.js +151 -0
- package/src/services/stack-inference.js +90 -0
- package/src/split-view/run-split.js +14 -89
- package/src/stack-view/infer-chains-to-pick-stacks.js +10 -0
- package/src/stack-view/ink-app.js +3 -2118
- package/src/stack-view/loader.js +19 -93
- package/src/stack-view/loading-ink.js +2 -44
- package/src/stack-view/merge-alternate-pick-stacks.js +23 -1
- package/src/stack-view/remote-infer-doc.js +28 -45
- package/src/stack-view/run-stack-view.js +249 -526
- package/src/stack-view/run-view-entry.js +14 -18
- package/src/stack-view/stack-pick-ink.js +169 -131
- package/src/stack-view/stack-picker-graph-pane.js +118 -0
- package/src/stack-view/terminal-fullscreen.js +7 -45
- 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 -292
- 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
|
@@ -14,8 +14,9 @@ const VIEWPORT_ROWS = 14;
|
|
|
14
14
|
* @param {object} props
|
|
15
15
|
* @param {HubLine[]} props.lines
|
|
16
16
|
* @param {(fullName: string) => void} props.onPickRepo
|
|
17
|
+
* @param {() => void} [props.onBack] Called when user presses Backspace/Esc to go back
|
|
17
18
|
*/
|
|
18
|
-
export function ReviewHubInk({ lines, onPickRepo }) {
|
|
19
|
+
export function ReviewHubInk({ lines, onPickRepo, onBack }) {
|
|
19
20
|
const { exit } = useApp();
|
|
20
21
|
const { stdout } = useStdout();
|
|
21
22
|
/** @type {React.MutableRefObject<{ firstListRow: number, viewStart: number, slice: HubLine[] } | null>} */
|
|
@@ -91,7 +92,9 @@ export function ReviewHubInk({ lines, onPickRepo }) {
|
|
|
91
92
|
return;
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
|
|
96
|
+
if (input === "q" || key.escape || backKey) {
|
|
97
|
+
onBack?.();
|
|
95
98
|
exit();
|
|
96
99
|
return;
|
|
97
100
|
}
|
|
@@ -159,7 +162,7 @@ export function ReviewHubInk({ lines, onPickRepo }) {
|
|
|
159
162
|
Box,
|
|
160
163
|
{ flexDirection: "column", padding: 1 },
|
|
161
164
|
React.createElement(Text, { color: "cyan", bold: true }, "nugit review — repositories"),
|
|
162
|
-
React.createElement(Text, { dimColor: true }, "j/k · 1-9 open · Enter/Space · click · wheel · q
|
|
165
|
+
React.createElement(Text, { dimColor: true }, "j/k · 1-9 open · Enter/Space · click · wheel · Backspace/q back"),
|
|
163
166
|
status ? React.createElement(Text, { dimColor: true }, status) : null,
|
|
164
167
|
...rendered
|
|
165
168
|
);
|
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { render } from "ink";
|
|
3
1
|
import { authMe } from "../api-client.js";
|
|
4
|
-
import {
|
|
5
|
-
githubListAllUserRepos,
|
|
6
|
-
githubSearchIssues
|
|
7
|
-
} from "../github-rest.js";
|
|
2
|
+
import { githubListAllUserRepos, githubSearchIssues } from "../github-rest.js";
|
|
8
3
|
import { resolveGithubToken } from "../auth-token.js";
|
|
9
|
-
import { runStackViewCommand } from "../stack-view/run-stack-view.js";
|
|
10
|
-
import { ReviewHubInk } from "./review-hub-ink.js";
|
|
11
|
-
import { ReviewHubBackError } from "./review-hub-back.js";
|
|
12
|
-
import {
|
|
13
|
-
enterAlternateScreen,
|
|
14
|
-
leaveAlternateScreen
|
|
15
|
-
} from "../stack-view/terminal-fullscreen.js";
|
|
16
4
|
|
|
17
5
|
/**
|
|
18
6
|
* @param {string} login
|
|
@@ -30,9 +18,7 @@ async function pendingReviewsByRepo(login) {
|
|
|
30
18
|
page
|
|
31
19
|
);
|
|
32
20
|
const items = Array.isArray(res.items) ? res.items : [];
|
|
33
|
-
if (items.length === 0)
|
|
34
|
-
break;
|
|
35
|
-
}
|
|
21
|
+
if (items.length === 0) break;
|
|
36
22
|
for (const it of items) {
|
|
37
23
|
if (!it || typeof it !== "object") continue;
|
|
38
24
|
const u = /** @type {Record<string, unknown>} */ (it).repository_url;
|
|
@@ -42,9 +28,7 @@ async function pendingReviewsByRepo(login) {
|
|
|
42
28
|
const full = `${m[1]}/${m[2]}`;
|
|
43
29
|
map.set(full, (map.get(full) || 0) + 1);
|
|
44
30
|
}
|
|
45
|
-
if (items.length < 100)
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
31
|
+
if (items.length < 100) break;
|
|
48
32
|
page += 1;
|
|
49
33
|
}
|
|
50
34
|
return map;
|
|
@@ -59,7 +43,10 @@ function normalizeRepo(repoObj, pendingMap) {
|
|
|
59
43
|
const r = /** @type {Record<string, unknown>} */ (repoObj);
|
|
60
44
|
const fn = typeof r.full_name === "string" ? r.full_name : "";
|
|
61
45
|
if (!fn) return null;
|
|
62
|
-
const owner =
|
|
46
|
+
const owner =
|
|
47
|
+
r.owner && typeof r.owner === "object"
|
|
48
|
+
? /** @type {Record<string, unknown>} */ (r.owner)
|
|
49
|
+
: {};
|
|
63
50
|
const ownerType = typeof owner.type === "string" ? owner.type : "User";
|
|
64
51
|
const pending = pendingMap.get(fn) || 0;
|
|
65
52
|
return { fullName: fn, ownerType, pending };
|
|
@@ -67,8 +54,9 @@ function normalizeRepo(repoObj, pendingMap) {
|
|
|
67
54
|
|
|
68
55
|
/**
|
|
69
56
|
* @param {{ fullName: string, ownerType: string, pending: number }[]} repos
|
|
57
|
+
* @returns {import("./review-hub-ink.js").HubLine[]}
|
|
70
58
|
*/
|
|
71
|
-
function buildHubLines(repos) {
|
|
59
|
+
export function buildHubLines(repos) {
|
|
72
60
|
const sorted = [...repos].sort((a, b) => {
|
|
73
61
|
if (b.pending !== a.pending) return b.pending - a.pending;
|
|
74
62
|
return a.fullName.localeCompare(b.fullName);
|
|
@@ -83,28 +71,22 @@ function buildHubLines(repos) {
|
|
|
83
71
|
const key = `${bucket}:${ownerLogin}`;
|
|
84
72
|
if (key !== lastBucket) {
|
|
85
73
|
lastBucket = key;
|
|
86
|
-
lines.push({
|
|
87
|
-
kind: "header",
|
|
88
|
-
text: `${bucket} — ${ownerLogin}`
|
|
89
|
-
});
|
|
74
|
+
lines.push({ kind: "header", text: `${bucket} — ${ownerLogin}` });
|
|
90
75
|
}
|
|
91
|
-
lines.push({
|
|
92
|
-
kind: "repo",
|
|
93
|
-
fullName: r.fullName,
|
|
94
|
-
pending: r.pending
|
|
95
|
-
});
|
|
76
|
+
lines.push({ kind: "repo", fullName: r.fullName, pending: r.pending });
|
|
96
77
|
}
|
|
97
78
|
return lines;
|
|
98
79
|
}
|
|
99
80
|
|
|
100
81
|
/**
|
|
101
|
-
*
|
|
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[] }>}
|
|
102
85
|
*/
|
|
103
|
-
export async function
|
|
86
|
+
export async function fetchReviewHubData() {
|
|
104
87
|
if (!resolveGithubToken()) {
|
|
105
88
|
throw new Error(
|
|
106
|
-
"GitHub token required for
|
|
107
|
-
"(PAT must allow repo access and search for review-requested PRs)."
|
|
89
|
+
"GitHub token required for the review hub. Run `nugit auth login` or set NUGIT_USER_TOKEN / STACKPR_USER_TOKEN."
|
|
108
90
|
);
|
|
109
91
|
}
|
|
110
92
|
|
|
@@ -119,70 +101,31 @@ export async function runReviewHub(opts) {
|
|
|
119
101
|
pendingReviewsByRepo(login)
|
|
120
102
|
]);
|
|
121
103
|
|
|
122
|
-
const repos = [];
|
|
104
|
+
const repos = /** @type {{ fullName: string, ownerType: string, pending: number }[]} */ ([]);
|
|
123
105
|
for (const r of rawRepos) {
|
|
124
106
|
const n = normalizeRepo(r, pendingMap);
|
|
125
107
|
if (n) repos.push(n);
|
|
126
108
|
}
|
|
127
109
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
console.log(`${r.fullName}${p}`);
|
|
132
|
-
}
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
110
|
+
const lines = buildHubLines(repos);
|
|
111
|
+
return { login, lines };
|
|
112
|
+
}
|
|
135
113
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (lines.filter((l) => l.kind === "repo").length === 0) {
|
|
144
|
-
console.error("No repositories visible to this token.");
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
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;
|
|
147
121
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
lines,
|
|
152
|
-
onPickRepo: (fn) => {
|
|
153
|
-
picked = fn;
|
|
154
|
-
}
|
|
155
|
-
})
|
|
156
|
-
);
|
|
157
|
-
await waitUntilExit();
|
|
158
|
-
if (!picked) {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
122
|
+
const repos = lines
|
|
123
|
+
.filter((l) => l.kind === "repo")
|
|
124
|
+
.map((l) => /** @type {{ kind: "repo", fullName: string, pending: number }} */ (l));
|
|
161
125
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
noTui: false,
|
|
166
|
-
allowBackToReviewHub: true,
|
|
167
|
-
reviewAutoapply: !!opts.autoApply,
|
|
168
|
-
reviewFetchOpts: {
|
|
169
|
-
viewerLogin: login,
|
|
170
|
-
fullReviewFetch: true
|
|
171
|
-
},
|
|
172
|
-
viewTitle: "nugit review",
|
|
173
|
-
shellMode: false
|
|
174
|
-
});
|
|
175
|
-
break;
|
|
176
|
-
} catch (e) {
|
|
177
|
-
if (e instanceof ReviewHubBackError) {
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
throw e;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
} finally {
|
|
184
|
-
if (tty) {
|
|
185
|
-
leaveAlternateScreen(process.stdout);
|
|
186
|
-
}
|
|
126
|
+
for (const r of repos) {
|
|
127
|
+
const p = r.pending ? ` (${r.pending} pending)` : "";
|
|
128
|
+
console.log(`${r.fullName}${p}`);
|
|
187
129
|
}
|
|
130
|
+
void opts;
|
|
188
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
|
+
}
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { render } from "ink";
|
|
3
|
-
import {
|
|
3
|
+
import { getPull, createPullRequest, authMe } from "../api-client.js";
|
|
4
4
|
import { githubPostIssueComment } from "../github-pr-social.js";
|
|
5
|
-
import {
|
|
6
|
-
createInitialStackDoc,
|
|
7
|
-
readStackFile,
|
|
8
|
-
writeStackFile,
|
|
9
|
-
validateStackDoc,
|
|
10
|
-
stackEntryFromGithubPull
|
|
11
|
-
} from "../nugit-stack.js";
|
|
12
|
-
import { appendStackHistory } from "../stack-graph.js";
|
|
13
5
|
import {
|
|
14
6
|
assertCleanWorkingTree,
|
|
15
7
|
gitExec,
|
|
@@ -28,6 +20,7 @@ import { SplitInkApp } from "./split-ink.js";
|
|
|
28
20
|
* @param {number} ctx.prNumber
|
|
29
21
|
* @param {boolean} [ctx.dryRun]
|
|
30
22
|
* @param {string} [ctx.remote]
|
|
23
|
+
* @returns {Promise<{ newPrNumbers: number[], newBranches: string[] } | null>}
|
|
31
24
|
*/
|
|
32
25
|
export async function runSplitCommand(ctx) {
|
|
33
26
|
const { root, owner, repo, prNumber, dryRun = false, remote = "origin" } = ctx;
|
|
@@ -60,12 +53,8 @@ export async function runSplitCommand(ctx) {
|
|
|
60
53
|
const next = exitPayload.next;
|
|
61
54
|
if (!next || next.type !== "confirm") {
|
|
62
55
|
console.error("Split cancelled.");
|
|
63
|
-
try {
|
|
64
|
-
|
|
65
|
-
} catch {
|
|
66
|
-
/* ignore */
|
|
67
|
-
}
|
|
68
|
-
return;
|
|
56
|
+
try { gitExec(root, ["checkout", baseBranch]); } catch { /* ignore */ }
|
|
57
|
+
return null;
|
|
69
58
|
}
|
|
70
59
|
const { byLayer, layerCount } = next;
|
|
71
60
|
for (let L = 0; L < layerCount; L++) {
|
|
@@ -81,17 +70,10 @@ export async function runSplitCommand(ctx) {
|
|
|
81
70
|
for (let i = 0; i < layerCount; i++) {
|
|
82
71
|
const b = `${prefix}-L${i}`;
|
|
83
72
|
const did = commitLayerFromPaths(
|
|
84
|
-
root,
|
|
85
|
-
remote,
|
|
86
|
-
b,
|
|
87
|
-
startRef,
|
|
88
|
-
headRef,
|
|
89
|
-
byLayer[i],
|
|
73
|
+
root, remote, b, startRef, headRef, byLayer[i],
|
|
90
74
|
`nugit split: PR #${prNumber} layer ${i + 1}/${layerCount}`
|
|
91
75
|
);
|
|
92
|
-
if (!did) {
|
|
93
|
-
throw new Error(`No commit produced for layer ${i}`);
|
|
94
|
-
}
|
|
76
|
+
if (!did) throw new Error(`No commit produced for layer ${i}`);
|
|
95
77
|
newBranches.push(b);
|
|
96
78
|
startRef = b;
|
|
97
79
|
}
|
|
@@ -99,7 +81,7 @@ export async function runSplitCommand(ctx) {
|
|
|
99
81
|
if (dryRun) {
|
|
100
82
|
console.error("Dry-run: branches (not pushed):", newBranches.join(", "));
|
|
101
83
|
gitExec(root, ["checkout", baseBranch]);
|
|
102
|
-
return;
|
|
84
|
+
return null;
|
|
103
85
|
}
|
|
104
86
|
|
|
105
87
|
for (const b of newBranches) {
|
|
@@ -110,10 +92,9 @@ export async function runSplitCommand(ctx) {
|
|
|
110
92
|
const newPrNumbers = [];
|
|
111
93
|
let prevBase = baseBranch;
|
|
112
94
|
for (let i = 0; i < newBranches.length; i++) {
|
|
113
|
-
const title =
|
|
114
|
-
pull.title
|
|
115
|
-
|
|
116
|
-
: `Split of #${prNumber} (${i + 1}/${newBranches.length})`;
|
|
95
|
+
const title = pull.title != null
|
|
96
|
+
? `[split ${i + 1}/${newBranches.length}] ${pull.title}`
|
|
97
|
+
: `Split of #${prNumber} (${i + 1}/${newBranches.length})`;
|
|
117
98
|
const created = await createPullRequest(owner, repo, {
|
|
118
99
|
title,
|
|
119
100
|
head: newBranches[i],
|
|
@@ -121,74 +102,18 @@ export async function runSplitCommand(ctx) {
|
|
|
121
102
|
body: `Split from #${prNumber} (nugit split layer ${i + 1}).\n\nOriginal: ${pull.html_url || ""}`
|
|
122
103
|
});
|
|
123
104
|
const num = /** @type {{ number?: number }} */ (created).number;
|
|
124
|
-
if (typeof num !== "number")
|
|
125
|
-
throw new Error("GitHub did not return PR number");
|
|
126
|
-
}
|
|
105
|
+
if (typeof num !== "number") throw new Error("GitHub did not return PR number");
|
|
127
106
|
newPrNumbers.push(num);
|
|
128
107
|
prevBase = newBranches[i];
|
|
129
108
|
}
|
|
130
109
|
|
|
131
|
-
/** @type {Record<string, unknown> | null} */
|
|
132
|
-
let docForHistory = null;
|
|
133
|
-
let doc = readStackFile(root);
|
|
134
|
-
if (doc) {
|
|
135
|
-
validateStackDoc(doc);
|
|
136
|
-
const idx = doc.prs.findIndex((p) => p.pr_number === prNumber);
|
|
137
|
-
if (idx >= 0) {
|
|
138
|
-
doc.prs.splice(idx, 1);
|
|
139
|
-
const insertAt = idx;
|
|
140
|
-
for (let i = 0; i < newPrNumbers.length; i++) {
|
|
141
|
-
const p2 = await getPull(owner, repo, newPrNumbers[i]);
|
|
142
|
-
doc.prs.splice(insertAt + i, 0, stackEntryFromGithubPull(p2, insertAt + i));
|
|
143
|
-
}
|
|
144
|
-
for (let j = 0; j < doc.prs.length; j++) {
|
|
145
|
-
doc.prs[j].position = j;
|
|
146
|
-
}
|
|
147
|
-
writeStackFile(root, doc);
|
|
148
|
-
docForHistory = doc;
|
|
149
|
-
} else {
|
|
150
|
-
console.error(
|
|
151
|
-
`Warning: PR #${prNumber} not in .nugit/stack.json — local stack file left unchanged.`
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
} else {
|
|
155
|
-
const me = await authMe();
|
|
156
|
-
const login = me && typeof me.login === "string" ? me.login : "unknown";
|
|
157
|
-
doc = createInitialStackDoc(`${owner}/${repo}`, login);
|
|
158
|
-
doc.prs = [];
|
|
159
|
-
for (let i = 0; i < newPrNumbers.length; i++) {
|
|
160
|
-
const p2 = await getPull(owner, repo, newPrNumbers[i]);
|
|
161
|
-
doc.prs.push(stackEntryFromGithubPull(p2, i));
|
|
162
|
-
}
|
|
163
|
-
writeStackFile(root, doc);
|
|
164
|
-
docForHistory = doc;
|
|
165
|
-
console.error(
|
|
166
|
-
`Created .nugit/stack.json with ${newPrNumbers.length} PR(s) from this split (repo had no stack file).`
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
appendStackHistory(root, {
|
|
171
|
-
action: "split",
|
|
172
|
-
repo_full_name: `${owner}/${repo}`,
|
|
173
|
-
tip_pr_number: newPrNumbers[newPrNumbers.length - 1],
|
|
174
|
-
head_branch: newBranches[newBranches.length - 1],
|
|
175
|
-
...(docForHistory ? { snapshot: docForHistory } : {}),
|
|
176
|
-
from_pr: prNumber,
|
|
177
|
-
new_prs: newPrNumbers
|
|
178
|
-
});
|
|
179
|
-
|
|
180
110
|
await githubPostIssueComment(
|
|
181
|
-
owner,
|
|
182
|
-
repo,
|
|
183
|
-
prNumber,
|
|
111
|
+
owner, repo, prNumber,
|
|
184
112
|
`This PR was split into: ${newPrNumbers.map((n) => `#${n}`).join(", ")}. You can close this PR when the new stack is ready.`
|
|
185
113
|
);
|
|
186
114
|
|
|
187
|
-
try {
|
|
188
|
-
gitExec(root, ["checkout", baseBranch]);
|
|
189
|
-
} catch {
|
|
190
|
-
/* ignore */
|
|
191
|
-
}
|
|
115
|
+
try { gitExec(root, ["checkout", baseBranch]); } catch { /* ignore */ }
|
|
192
116
|
|
|
193
117
|
console.error(`Split complete. New PRs: ${newPrNumbers.join(", ")}`);
|
|
118
|
+
return { newPrNumbers, newBranches };
|
|
194
119
|
}
|
|
@@ -56,6 +56,15 @@ export function inferChainsToPickStacks(chains, pulls) {
|
|
|
56
56
|
|
|
57
57
|
const hasLineStats = sumAdd > 0 || sumDel > 0;
|
|
58
58
|
|
|
59
|
+
// base_ref: the branch that the bottom PR (chain[0]) targets
|
|
60
|
+
const bottomPr = chain[0];
|
|
61
|
+
const bottomPull = bottomPr ? byNum.get(bottomPr) : undefined;
|
|
62
|
+
const bottomBase =
|
|
63
|
+
bottomPull && bottomPull.base && typeof bottomPull.base === "object"
|
|
64
|
+
? /** @type {Record<string, unknown>} */ (bottomPull.base)
|
|
65
|
+
: {};
|
|
66
|
+
const base_ref = typeof bottomBase.ref === "string" ? bottomBase.ref : "";
|
|
67
|
+
|
|
59
68
|
return {
|
|
60
69
|
tip_pr_number: tipPr,
|
|
61
70
|
tip_head_branch: tipHead,
|
|
@@ -64,6 +73,7 @@ export function inferChainsToPickStacks(chains, pulls) {
|
|
|
64
73
|
prs: prRows,
|
|
65
74
|
tip_updated_at,
|
|
66
75
|
inferChainIndex,
|
|
76
|
+
base_ref,
|
|
67
77
|
...(hasLineStats ? { inferDiffAdd: sumAdd, inferDiffDel: sumDel } : {})
|
|
68
78
|
};
|
|
69
79
|
});
|