nugit-cli 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api-client.js +10 -11
- package/src/github-device-flow.js +1 -1
- package/src/github-oauth-client-id.js +11 -0
- package/src/github-pr-social.js +42 -0
- package/src/github-rest.js +114 -6
- package/src/nugit-stack.js +20 -0
- package/src/nugit-start.js +4 -4
- package/src/nugit.js +37 -22
- package/src/review-hub/review-autoapprove.js +95 -0
- package/src/review-hub/review-hub-back.js +10 -0
- package/src/review-hub/review-hub-ink.js +166 -0
- package/src/review-hub/run-review-hub.js +188 -0
- package/src/split-view/run-split.js +16 -3
- package/src/split-view/split-ink.js +2 -2
- package/src/stack-discover.js +9 -1
- package/src/stack-infer-from-prs.js +71 -0
- package/src/stack-view/diff-line-map.js +62 -0
- package/src/stack-view/fetch-pr-data.js +104 -4
- package/src/stack-view/infer-chains-to-pick-stacks.js +70 -0
- package/src/stack-view/ink-app.js +1853 -156
- package/src/stack-view/loading-ink.js +44 -0
- package/src/stack-view/merge-alternate-pick-stacks.js +223 -0
- package/src/stack-view/patch-preview-merge.js +108 -0
- package/src/stack-view/remote-infer-doc.js +93 -0
- package/src/stack-view/repo-picker-back.js +10 -0
- package/src/stack-view/run-stack-view.js +685 -50
- package/src/stack-view/run-view-entry.js +119 -0
- package/src/stack-view/sgr-mouse.js +56 -0
- package/src/stack-view/stack-branch-graph.js +95 -0
- package/src/stack-view/stack-pick-graph.js +93 -0
- package/src/stack-view/stack-pick-ink.js +270 -0
- package/src/stack-view/stack-pick-layout.js +19 -0
- package/src/stack-view/stack-pick-sort.js +188 -0
- package/src/stack-view/terminal-fullscreen.js +45 -0
- package/src/stack-view/tree-ascii.js +73 -0
- package/src/stack-view/view-md-plain.js +23 -0
- package/src/stack-view/view-repo-picker-ink.js +293 -0
- package/src/stack-view/view-tui-sequential.js +126 -0
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
*/
|
|
18
|
+
export function ReviewHubInk({ lines, onPickRepo }) {
|
|
19
|
+
const { exit } = useApp();
|
|
20
|
+
const { stdout } = useStdout();
|
|
21
|
+
/** @type {React.MutableRefObject<{ firstListRow: number, viewStart: number, slice: HubLine[] } | null>} */
|
|
22
|
+
const layoutRef = useRef(null);
|
|
23
|
+
/** @type {React.MutableRefObject<{ t: number, lineIdx: number } | null>} */
|
|
24
|
+
const lastClickRef = useRef(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
enableSgrMouse(stdout);
|
|
28
|
+
return () => disableSgrMouse(stdout);
|
|
29
|
+
}, [stdout]);
|
|
30
|
+
|
|
31
|
+
const repoIndices = useMemo(
|
|
32
|
+
() => lines.map((l, i) => (l.kind === "repo" ? i : -1)).filter((i) => i >= 0),
|
|
33
|
+
[lines]
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const [rpos, setRpos] = useState(0);
|
|
37
|
+
const safeRpos = repoIndices.length === 0 ? 0 : Math.min(rpos, repoIndices.length - 1);
|
|
38
|
+
const idx = repoIndices.length ? repoIndices[safeRpos] : 0;
|
|
39
|
+
|
|
40
|
+
const total = lines.length;
|
|
41
|
+
const viewStart =
|
|
42
|
+
total <= VIEWPORT_ROWS
|
|
43
|
+
? 0
|
|
44
|
+
: Math.max(0, Math.min(idx - Math.floor(VIEWPORT_ROWS / 2), total - VIEWPORT_ROWS));
|
|
45
|
+
const slice = lines.slice(viewStart, viewStart + VIEWPORT_ROWS);
|
|
46
|
+
|
|
47
|
+
useInput((input, key) => {
|
|
48
|
+
const mouse = parseSgrMouse(input);
|
|
49
|
+
if (mouse) {
|
|
50
|
+
const L = layoutRef.current;
|
|
51
|
+
if (!L) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const { row, col, button, release } = mouse;
|
|
55
|
+
if (col < 2) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (isWheelUp(button) || isWheelDown(button)) {
|
|
59
|
+
const down = isWheelDown(button);
|
|
60
|
+
if (repoIndices.length) {
|
|
61
|
+
if (down) {
|
|
62
|
+
setRpos((p) => Math.min(p + 1, repoIndices.length - 1));
|
|
63
|
+
} else {
|
|
64
|
+
setRpos((p) => Math.max(p - 1, 0));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (release) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const local = row - L.firstListRow;
|
|
73
|
+
if (local >= 0 && local < L.slice.length) {
|
|
74
|
+
const i = L.viewStart + local;
|
|
75
|
+
const line = lines[i];
|
|
76
|
+
if (line && line.kind === "repo") {
|
|
77
|
+
const ri = repoIndices.indexOf(i);
|
|
78
|
+
if (ri >= 0) {
|
|
79
|
+
setRpos(ri);
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const prev = lastClickRef.current;
|
|
82
|
+
const dbl = prev && prev.lineIdx === i && now - prev.t < 480;
|
|
83
|
+
lastClickRef.current = { t: now, lineIdx: i };
|
|
84
|
+
if (dbl) {
|
|
85
|
+
onPickRepo(line.fullName);
|
|
86
|
+
exit();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (input === "q" || key.escape) {
|
|
95
|
+
exit();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (/^[1-9]$/.test(input) && repoIndices.length) {
|
|
99
|
+
const n = Number.parseInt(input, 10) - 1;
|
|
100
|
+
if (n < repoIndices.length) {
|
|
101
|
+
const lineIdx = repoIndices[n];
|
|
102
|
+
const line = lines[lineIdx];
|
|
103
|
+
if (line && line.kind === "repo") {
|
|
104
|
+
onPickRepo(line.fullName);
|
|
105
|
+
exit();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (input === "j" || key.downArrow) {
|
|
111
|
+
if (repoIndices.length) {
|
|
112
|
+
setRpos((p) => Math.min(p + 1, repoIndices.length - 1));
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (input === "k" || key.upArrow) {
|
|
117
|
+
setRpos((p) => Math.max(p - 1, 0));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (key.return || input === " ") {
|
|
121
|
+
const line = lines[idx];
|
|
122
|
+
if (line && line.kind === "repo") {
|
|
123
|
+
onPickRepo(line.fullName);
|
|
124
|
+
exit();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const status =
|
|
130
|
+
total > VIEWPORT_ROWS
|
|
131
|
+
? `Lines ${viewStart + 1}–${Math.min(viewStart + VIEWPORT_ROWS, total)} of ${total} · repo ${safeRpos + 1}/${repoIndices.length}`
|
|
132
|
+
: repoIndices.length
|
|
133
|
+
? `Repo ${safeRpos + 1}/${repoIndices.length}`
|
|
134
|
+
: "";
|
|
135
|
+
|
|
136
|
+
const rendered = slice.map((line, localI) => {
|
|
137
|
+
const i = viewStart + localI;
|
|
138
|
+
if (line.kind === "header") {
|
|
139
|
+
return React.createElement(Text, { key: `h-${i}`, color: "magenta", bold: true }, line.text);
|
|
140
|
+
}
|
|
141
|
+
const mark = i === idx ? "▶ " : " ";
|
|
142
|
+
const pend = line.pending > 0 ? ` · ${line.pending} review request(s)` : "";
|
|
143
|
+
const hot = line.pending > 0;
|
|
144
|
+
return React.createElement(
|
|
145
|
+
Text,
|
|
146
|
+
{
|
|
147
|
+
key: `r-${i}`,
|
|
148
|
+
color: hot ? "yellow" : "white",
|
|
149
|
+
bold: hot
|
|
150
|
+
},
|
|
151
|
+
`${mark}${line.fullName}${pend}`
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const firstListRow = status ? 4 : 3;
|
|
156
|
+
layoutRef.current = { firstListRow, viewStart, slice };
|
|
157
|
+
|
|
158
|
+
return React.createElement(
|
|
159
|
+
Box,
|
|
160
|
+
{ flexDirection: "column", padding: 1 },
|
|
161
|
+
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 quit"),
|
|
163
|
+
status ? React.createElement(Text, { dimColor: true }, status) : null,
|
|
164
|
+
...rendered
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { authMe } from "../api-client.js";
|
|
4
|
+
import {
|
|
5
|
+
githubListAllUserRepos,
|
|
6
|
+
githubSearchIssues
|
|
7
|
+
} from "../github-rest.js";
|
|
8
|
+
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
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} login
|
|
19
|
+
* @returns {Promise<Map<string, number>>}
|
|
20
|
+
*/
|
|
21
|
+
async function pendingReviewsByRepo(login) {
|
|
22
|
+
/** @type {Map<string, number>} */
|
|
23
|
+
const map = new Map();
|
|
24
|
+
let page = 1;
|
|
25
|
+
const maxPages = 30;
|
|
26
|
+
while (page <= maxPages) {
|
|
27
|
+
const res = await githubSearchIssues(
|
|
28
|
+
`is:open is:pr review-requested:${login}`,
|
|
29
|
+
100,
|
|
30
|
+
page
|
|
31
|
+
);
|
|
32
|
+
const items = Array.isArray(res.items) ? res.items : [];
|
|
33
|
+
if (items.length === 0) {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
for (const it of items) {
|
|
37
|
+
if (!it || typeof it !== "object") continue;
|
|
38
|
+
const u = /** @type {Record<string, unknown>} */ (it).repository_url;
|
|
39
|
+
if (typeof u !== "string") continue;
|
|
40
|
+
const m = u.match(/\/repos\/([^/]+)\/([^/]+)$/);
|
|
41
|
+
if (!m) continue;
|
|
42
|
+
const full = `${m[1]}/${m[2]}`;
|
|
43
|
+
map.set(full, (map.get(full) || 0) + 1);
|
|
44
|
+
}
|
|
45
|
+
if (items.length < 100) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
page += 1;
|
|
49
|
+
}
|
|
50
|
+
return map;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {unknown} repoObj
|
|
55
|
+
* @param {Map<string, number>} pendingMap
|
|
56
|
+
*/
|
|
57
|
+
function normalizeRepo(repoObj, pendingMap) {
|
|
58
|
+
if (!repoObj || typeof repoObj !== "object") return null;
|
|
59
|
+
const r = /** @type {Record<string, unknown>} */ (repoObj);
|
|
60
|
+
const fn = typeof r.full_name === "string" ? r.full_name : "";
|
|
61
|
+
if (!fn) return null;
|
|
62
|
+
const owner = r.owner && typeof r.owner === "object" ? /** @type {Record<string, unknown>} */ (r.owner) : {};
|
|
63
|
+
const ownerType = typeof owner.type === "string" ? owner.type : "User";
|
|
64
|
+
const pending = pendingMap.get(fn) || 0;
|
|
65
|
+
return { fullName: fn, ownerType, pending };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {{ fullName: string, ownerType: string, pending: number }[]} repos
|
|
70
|
+
*/
|
|
71
|
+
function buildHubLines(repos) {
|
|
72
|
+
const sorted = [...repos].sort((a, b) => {
|
|
73
|
+
if (b.pending !== a.pending) return b.pending - a.pending;
|
|
74
|
+
return a.fullName.localeCompare(b.fullName);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** @type {import("./review-hub-ink.js").HubLine[]} */
|
|
78
|
+
const lines = [];
|
|
79
|
+
let lastBucket = "";
|
|
80
|
+
for (const r of sorted) {
|
|
81
|
+
const bucket = r.ownerType === "Organization" ? "Organizations" : "Users";
|
|
82
|
+
const ownerLogin = r.fullName.split("/")[0] || "";
|
|
83
|
+
const key = `${bucket}:${ownerLogin}`;
|
|
84
|
+
if (key !== lastBucket) {
|
|
85
|
+
lastBucket = key;
|
|
86
|
+
lines.push({
|
|
87
|
+
kind: "header",
|
|
88
|
+
text: `${bucket} — ${ownerLogin}`
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
lines.push({
|
|
92
|
+
kind: "repo",
|
|
93
|
+
fullName: r.fullName,
|
|
94
|
+
pending: r.pending
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {{ noTui?: boolean, autoApply?: boolean }} opts
|
|
102
|
+
*/
|
|
103
|
+
export async function runReviewHub(opts) {
|
|
104
|
+
if (!resolveGithubToken()) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"GitHub token required for nugit review. Run `nugit auth login` or set NUGIT_USER_TOKEN / STACKPR_USER_TOKEN " +
|
|
107
|
+
"(PAT must allow repo access and search for review-requested PRs)."
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const me = await authMe();
|
|
112
|
+
const login = me && typeof me.login === "string" ? me.login : "";
|
|
113
|
+
if (!login) {
|
|
114
|
+
throw new Error("Could not resolve GitHub login for review hub.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [rawRepos, pendingMap] = await Promise.all([
|
|
118
|
+
githubListAllUserRepos(),
|
|
119
|
+
pendingReviewsByRepo(login)
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
const repos = [];
|
|
123
|
+
for (const r of rawRepos) {
|
|
124
|
+
const n = normalizeRepo(r, pendingMap);
|
|
125
|
+
if (n) repos.push(n);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (opts.noTui || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
129
|
+
for (const r of [...repos].sort((a, b) => b.pending - a.pending || a.fullName.localeCompare(b.fullName))) {
|
|
130
|
+
const p = r.pending ? ` (${r.pending} pending)` : "";
|
|
131
|
+
console.log(`${r.fullName}${p}`);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
137
|
+
if (tty) {
|
|
138
|
+
enterAlternateScreen(process.stdout);
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
for (;;) {
|
|
142
|
+
const lines = buildHubLines(repos);
|
|
143
|
+
if (lines.filter((l) => l.kind === "repo").length === 0) {
|
|
144
|
+
console.error("No repositories visible to this token.");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let picked = /** @type {string | null} */ (null);
|
|
149
|
+
const { waitUntilExit } = render(
|
|
150
|
+
React.createElement(ReviewHubInk, {
|
|
151
|
+
lines,
|
|
152
|
+
onPickRepo: (fn) => {
|
|
153
|
+
picked = fn;
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
await waitUntilExit();
|
|
158
|
+
if (!picked) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await runStackViewCommand({
|
|
164
|
+
repo: picked,
|
|
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
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { render } from "ink";
|
|
3
|
-
import { getPull, createPullRequest } from "../api-client.js";
|
|
3
|
+
import { authMe, getPull, createPullRequest } from "../api-client.js";
|
|
4
4
|
import { githubPostIssueComment } from "../github-pr-social.js";
|
|
5
5
|
import {
|
|
6
|
+
createInitialStackDoc,
|
|
6
7
|
readStackFile,
|
|
7
8
|
writeStackFile,
|
|
8
9
|
validateStackDoc,
|
|
@@ -129,7 +130,7 @@ export async function runSplitCommand(ctx) {
|
|
|
129
130
|
|
|
130
131
|
/** @type {Record<string, unknown> | null} */
|
|
131
132
|
let docForHistory = null;
|
|
132
|
-
|
|
133
|
+
let doc = readStackFile(root);
|
|
133
134
|
if (doc) {
|
|
134
135
|
validateStackDoc(doc);
|
|
135
136
|
const idx = doc.prs.findIndex((p) => p.pr_number === prNumber);
|
|
@@ -151,7 +152,19 @@ export async function runSplitCommand(ctx) {
|
|
|
151
152
|
);
|
|
152
153
|
}
|
|
153
154
|
} else {
|
|
154
|
-
|
|
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
|
+
);
|
|
155
168
|
}
|
|
156
169
|
|
|
157
170
|
appendStackHistory(root, {
|
|
@@ -39,7 +39,7 @@ export function SplitInkApp({ files, exitPayload }) {
|
|
|
39
39
|
exit();
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
|
-
if (input === "c") {
|
|
42
|
+
if (input === "c" || key.return || input === " ") {
|
|
43
43
|
exitPayload.next = {
|
|
44
44
|
type: "confirm",
|
|
45
45
|
layerCount,
|
|
@@ -82,7 +82,7 @@ export function SplitInkApp({ files, exitPayload }) {
|
|
|
82
82
|
React.createElement(
|
|
83
83
|
Text,
|
|
84
84
|
{ dimColor: true },
|
|
85
|
-
`Layers: ${layerCount} (+/-) | assign file to layer 0–${layerCount - 1} (digit) | c confirm | q cancel`
|
|
85
|
+
`Layers: ${layerCount} (+/-) | assign file to layer 0–${layerCount - 1} (digit) | c / Enter / Space confirm | q cancel`
|
|
86
86
|
),
|
|
87
87
|
React.createElement(Text, { marginTop: 1 }, chalk.bold("Files:")),
|
|
88
88
|
...files.slice(0, 18).map((f, i) =>
|
package/src/stack-discover.js
CHANGED
|
@@ -262,6 +262,13 @@ export async function discoverStacksInRepo(owner, repo, opts = {}) {
|
|
|
262
262
|
? tipObj.head_branch
|
|
263
263
|
: meta.headRef;
|
|
264
264
|
|
|
265
|
+
const tipPull = allPulls.find((pull) => {
|
|
266
|
+
const p = pull && typeof pull === "object" ? /** @type {Record<string, unknown>} */ (pull) : {};
|
|
267
|
+
return p.number === tipPr;
|
|
268
|
+
});
|
|
269
|
+
const tipPu = tipPull && typeof tipPull === "object" ? /** @type {Record<string, unknown>} */ (tipPull) : {};
|
|
270
|
+
const tip_updated_at = typeof tipPu.updated_at === "string" ? tipPu.updated_at : "";
|
|
271
|
+
|
|
265
272
|
stacks.push({
|
|
266
273
|
tip_pr_number: tipPr,
|
|
267
274
|
created_by: String(doc.created_by || ""),
|
|
@@ -269,8 +276,9 @@ export async function discoverStacksInRepo(owner, repo, opts = {}) {
|
|
|
269
276
|
pr_count: prRows.length,
|
|
270
277
|
prs: prRows,
|
|
271
278
|
tip_head_branch: tipHeadBranch,
|
|
279
|
+
tip_updated_at,
|
|
272
280
|
fetch_command: `nugit stack fetch --repo ${repoFull} --ref ${tipHeadBranch}`,
|
|
273
|
-
view_command: `nugit
|
|
281
|
+
view_command: `nugit view --repo ${repoFull} --ref ${tipHeadBranch}`
|
|
274
282
|
});
|
|
275
283
|
}
|
|
276
284
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infer linear stacked PR chains from open pulls using base.ref === other.head.ref (same-repo v1).
|
|
3
|
+
* @param {unknown[]} pulls GitHub pull objects (open).
|
|
4
|
+
* @param {string} repoFullName e.g. "owner/repo" — heads must match this repo (fork PRs skipped).
|
|
5
|
+
* @returns {number[][]} Chains ordered bottom → top (PR numbers).
|
|
6
|
+
*/
|
|
7
|
+
export function inferPrChainsFromOpenPulls(pulls, repoFullName) {
|
|
8
|
+
/** @type {{ number: number, headRef: string, baseRef: string }[]} */
|
|
9
|
+
const prs = [];
|
|
10
|
+
for (const raw of pulls) {
|
|
11
|
+
if (!raw || typeof raw !== "object") continue;
|
|
12
|
+
const p = /** @type {Record<string, unknown>} */ (raw);
|
|
13
|
+
const num = p.number;
|
|
14
|
+
const head = p.head && typeof p.head === "object" ? /** @type {Record<string, unknown>} */ (p.head) : {};
|
|
15
|
+
const base = p.base && typeof p.base === "object" ? /** @type {Record<string, unknown>} */ (p.base) : {};
|
|
16
|
+
const headRef = typeof head.ref === "string" ? head.ref : "";
|
|
17
|
+
const baseRef = typeof base.ref === "string" ? base.ref : "";
|
|
18
|
+
const headRepo =
|
|
19
|
+
head.repo && typeof head.repo === "object"
|
|
20
|
+
? String(/** @type {Record<string, unknown>} */ (head.repo).full_name || "")
|
|
21
|
+
: "";
|
|
22
|
+
if (typeof num !== "number" || !headRef) continue;
|
|
23
|
+
if (headRepo && headRepo !== repoFullName) continue;
|
|
24
|
+
prs.push({ number: num, headRef, baseRef });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** child PR# -> parent PR# below (parent.head === child.base) */
|
|
28
|
+
const parentBelow = new Map();
|
|
29
|
+
/** parent PR# -> child PR# above */
|
|
30
|
+
const childAbove = new Map();
|
|
31
|
+
for (const x of prs) {
|
|
32
|
+
const below = prs.find((p) => p.headRef === x.baseRef);
|
|
33
|
+
if (below && below.number !== x.number) {
|
|
34
|
+
parentBelow.set(x.number, below.number);
|
|
35
|
+
childAbove.set(below.number, x.number);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const bottoms = prs.filter((p) => !parentBelow.has(p.number));
|
|
40
|
+
/** @type {number[][]} */
|
|
41
|
+
const chains = [];
|
|
42
|
+
for (const b of bottoms) {
|
|
43
|
+
const chain = [];
|
|
44
|
+
let cur = /** @type {number | null} */ (b.number);
|
|
45
|
+
const vis = new Set();
|
|
46
|
+
while (cur != null && !vis.has(cur)) {
|
|
47
|
+
vis.add(cur);
|
|
48
|
+
chain.push(cur);
|
|
49
|
+
const next = childAbove.get(cur);
|
|
50
|
+
cur = next !== undefined ? next : null;
|
|
51
|
+
}
|
|
52
|
+
if (chain.length) {
|
|
53
|
+
chains.push(chain);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const inChain = new Set(chains.flat());
|
|
58
|
+
for (const p of prs) {
|
|
59
|
+
if (!inChain.has(p.number)) {
|
|
60
|
+
chains.push([p.number]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
chains.sort((a, b) => {
|
|
65
|
+
const ta = a[a.length - 1] ?? 0;
|
|
66
|
+
const tb = b[b.length - 1] ?? 0;
|
|
67
|
+
if (ta !== tb) return ta - tb;
|
|
68
|
+
return (a[0] ?? 0) - (b[0] ?? 0);
|
|
69
|
+
});
|
|
70
|
+
return chains;
|
|
71
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map a 0-based index in a unified diff (patch) line array to the GitHub
|
|
3
|
+
* pull-request review comment `line` and `side` fields.
|
|
4
|
+
*
|
|
5
|
+
* GitHub's `line` is the **1-based** line number in the *final* (RIGHT) or
|
|
6
|
+
* *original* (LEFT) file at that position in the hunk. Context lines map to
|
|
7
|
+
* RIGHT/line in the new file. `+` lines map to RIGHT. `-` lines map to LEFT.
|
|
8
|
+
*
|
|
9
|
+
* Header lines (`@@`, `---`, `+++`) are not commentable — returns null.
|
|
10
|
+
*
|
|
11
|
+
* @param {string[]} patchLines the `patch` string split by `\n`
|
|
12
|
+
* @param {number} idx 0-based index into `patchLines`
|
|
13
|
+
* @returns {{ line: number, side: "LEFT" | "RIGHT" } | null}
|
|
14
|
+
*/
|
|
15
|
+
export function diffLineToGitHub(patchLines, idx) {
|
|
16
|
+
if (idx < 0 || idx >= patchLines.length) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const target = patchLines[idx];
|
|
20
|
+
if (
|
|
21
|
+
target.startsWith("@@") ||
|
|
22
|
+
target.startsWith("--- ") ||
|
|
23
|
+
target.startsWith("+++ ") ||
|
|
24
|
+
target.startsWith("diff ")
|
|
25
|
+
) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let oldLine = 0;
|
|
30
|
+
let newLine = 0;
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i <= idx; i++) {
|
|
33
|
+
const ln = patchLines[i];
|
|
34
|
+
if (ln.startsWith("@@")) {
|
|
35
|
+
const m = ln.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/);
|
|
36
|
+
if (m) {
|
|
37
|
+
oldLine = Number(m[1]);
|
|
38
|
+
newLine = Number(m[2]);
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (ln.startsWith("--- ") || ln.startsWith("+++ ") || ln.startsWith("diff ")) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (i === idx) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (ln.startsWith("+")) {
|
|
49
|
+
newLine++;
|
|
50
|
+
} else if (ln.startsWith("-")) {
|
|
51
|
+
oldLine++;
|
|
52
|
+
} else {
|
|
53
|
+
oldLine++;
|
|
54
|
+
newLine++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (target.startsWith("-")) {
|
|
59
|
+
return { line: oldLine, side: "LEFT" };
|
|
60
|
+
}
|
|
61
|
+
return { line: newLine, side: "RIGHT" };
|
|
62
|
+
}
|