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
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { render } from "ink";
|
|
3
|
-
import { getPull, createPullRequest } from "../api-client.js";
|
|
3
|
+
import { getPull, createPullRequest, authMe } from "../api-client.js";
|
|
4
4
|
import { githubPostIssueComment } from "../github-pr-social.js";
|
|
5
|
-
import {
|
|
6
|
-
readStackFile,
|
|
7
|
-
writeStackFile,
|
|
8
|
-
validateStackDoc,
|
|
9
|
-
stackEntryFromGithubPull
|
|
10
|
-
} from "../nugit-stack.js";
|
|
11
|
-
import { appendStackHistory } from "../stack-graph.js";
|
|
12
5
|
import {
|
|
13
6
|
assertCleanWorkingTree,
|
|
14
7
|
gitExec,
|
|
@@ -27,6 +20,7 @@ import { SplitInkApp } from "./split-ink.js";
|
|
|
27
20
|
* @param {number} ctx.prNumber
|
|
28
21
|
* @param {boolean} [ctx.dryRun]
|
|
29
22
|
* @param {string} [ctx.remote]
|
|
23
|
+
* @returns {Promise<{ newPrNumbers: number[], newBranches: string[] } | null>}
|
|
30
24
|
*/
|
|
31
25
|
export async function runSplitCommand(ctx) {
|
|
32
26
|
const { root, owner, repo, prNumber, dryRun = false, remote = "origin" } = ctx;
|
|
@@ -59,12 +53,8 @@ export async function runSplitCommand(ctx) {
|
|
|
59
53
|
const next = exitPayload.next;
|
|
60
54
|
if (!next || next.type !== "confirm") {
|
|
61
55
|
console.error("Split cancelled.");
|
|
62
|
-
try {
|
|
63
|
-
|
|
64
|
-
} catch {
|
|
65
|
-
/* ignore */
|
|
66
|
-
}
|
|
67
|
-
return;
|
|
56
|
+
try { gitExec(root, ["checkout", baseBranch]); } catch { /* ignore */ }
|
|
57
|
+
return null;
|
|
68
58
|
}
|
|
69
59
|
const { byLayer, layerCount } = next;
|
|
70
60
|
for (let L = 0; L < layerCount; L++) {
|
|
@@ -80,17 +70,10 @@ export async function runSplitCommand(ctx) {
|
|
|
80
70
|
for (let i = 0; i < layerCount; i++) {
|
|
81
71
|
const b = `${prefix}-L${i}`;
|
|
82
72
|
const did = commitLayerFromPaths(
|
|
83
|
-
root,
|
|
84
|
-
remote,
|
|
85
|
-
b,
|
|
86
|
-
startRef,
|
|
87
|
-
headRef,
|
|
88
|
-
byLayer[i],
|
|
73
|
+
root, remote, b, startRef, headRef, byLayer[i],
|
|
89
74
|
`nugit split: PR #${prNumber} layer ${i + 1}/${layerCount}`
|
|
90
75
|
);
|
|
91
|
-
if (!did) {
|
|
92
|
-
throw new Error(`No commit produced for layer ${i}`);
|
|
93
|
-
}
|
|
76
|
+
if (!did) throw new Error(`No commit produced for layer ${i}`);
|
|
94
77
|
newBranches.push(b);
|
|
95
78
|
startRef = b;
|
|
96
79
|
}
|
|
@@ -98,7 +81,7 @@ export async function runSplitCommand(ctx) {
|
|
|
98
81
|
if (dryRun) {
|
|
99
82
|
console.error("Dry-run: branches (not pushed):", newBranches.join(", "));
|
|
100
83
|
gitExec(root, ["checkout", baseBranch]);
|
|
101
|
-
return;
|
|
84
|
+
return null;
|
|
102
85
|
}
|
|
103
86
|
|
|
104
87
|
for (const b of newBranches) {
|
|
@@ -109,10 +92,9 @@ export async function runSplitCommand(ctx) {
|
|
|
109
92
|
const newPrNumbers = [];
|
|
110
93
|
let prevBase = baseBranch;
|
|
111
94
|
for (let i = 0; i < newBranches.length; i++) {
|
|
112
|
-
const title =
|
|
113
|
-
pull.title
|
|
114
|
-
|
|
115
|
-
: `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})`;
|
|
116
98
|
const created = await createPullRequest(owner, repo, {
|
|
117
99
|
title,
|
|
118
100
|
head: newBranches[i],
|
|
@@ -120,62 +102,18 @@ export async function runSplitCommand(ctx) {
|
|
|
120
102
|
body: `Split from #${prNumber} (nugit split layer ${i + 1}).\n\nOriginal: ${pull.html_url || ""}`
|
|
121
103
|
});
|
|
122
104
|
const num = /** @type {{ number?: number }} */ (created).number;
|
|
123
|
-
if (typeof num !== "number")
|
|
124
|
-
throw new Error("GitHub did not return PR number");
|
|
125
|
-
}
|
|
105
|
+
if (typeof num !== "number") throw new Error("GitHub did not return PR number");
|
|
126
106
|
newPrNumbers.push(num);
|
|
127
107
|
prevBase = newBranches[i];
|
|
128
108
|
}
|
|
129
109
|
|
|
130
|
-
/** @type {Record<string, unknown> | null} */
|
|
131
|
-
let docForHistory = null;
|
|
132
|
-
const doc = readStackFile(root);
|
|
133
|
-
if (doc) {
|
|
134
|
-
validateStackDoc(doc);
|
|
135
|
-
const idx = doc.prs.findIndex((p) => p.pr_number === prNumber);
|
|
136
|
-
if (idx >= 0) {
|
|
137
|
-
doc.prs.splice(idx, 1);
|
|
138
|
-
const insertAt = idx;
|
|
139
|
-
for (let i = 0; i < newPrNumbers.length; i++) {
|
|
140
|
-
const p2 = await getPull(owner, repo, newPrNumbers[i]);
|
|
141
|
-
doc.prs.splice(insertAt + i, 0, stackEntryFromGithubPull(p2, insertAt + i));
|
|
142
|
-
}
|
|
143
|
-
for (let j = 0; j < doc.prs.length; j++) {
|
|
144
|
-
doc.prs[j].position = j;
|
|
145
|
-
}
|
|
146
|
-
writeStackFile(root, doc);
|
|
147
|
-
docForHistory = doc;
|
|
148
|
-
} else {
|
|
149
|
-
console.error(
|
|
150
|
-
`Warning: PR #${prNumber} not in .nugit/stack.json — local stack file left unchanged.`
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
} else {
|
|
154
|
-
console.error("No .nugit/stack.json — skipped local stack file update.");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
appendStackHistory(root, {
|
|
158
|
-
action: "split",
|
|
159
|
-
repo_full_name: `${owner}/${repo}`,
|
|
160
|
-
tip_pr_number: newPrNumbers[newPrNumbers.length - 1],
|
|
161
|
-
head_branch: newBranches[newBranches.length - 1],
|
|
162
|
-
...(docForHistory ? { snapshot: docForHistory } : {}),
|
|
163
|
-
from_pr: prNumber,
|
|
164
|
-
new_prs: newPrNumbers
|
|
165
|
-
});
|
|
166
|
-
|
|
167
110
|
await githubPostIssueComment(
|
|
168
|
-
owner,
|
|
169
|
-
repo,
|
|
170
|
-
prNumber,
|
|
111
|
+
owner, repo, prNumber,
|
|
171
112
|
`This PR was split into: ${newPrNumbers.map((n) => `#${n}`).join(", ")}. You can close this PR when the new stack is ready.`
|
|
172
113
|
);
|
|
173
114
|
|
|
174
|
-
try {
|
|
175
|
-
gitExec(root, ["checkout", baseBranch]);
|
|
176
|
-
} catch {
|
|
177
|
-
/* ignore */
|
|
178
|
-
}
|
|
115
|
+
try { gitExec(root, ["checkout", baseBranch]); } catch { /* ignore */ }
|
|
179
116
|
|
|
180
117
|
console.error(`Split complete. New PRs: ${newPrNumbers.join(", ")}`);
|
|
118
|
+
return { newPrNumbers, newBranches };
|
|
181
119
|
}
|
|
@@ -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) =>
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
githubGetPull,
|
|
3
|
+
githubListPullFiles,
|
|
4
|
+
githubRestJson,
|
|
5
|
+
githubCompareRefs
|
|
6
|
+
} from "../github-rest.js";
|
|
7
|
+
import { githubListPullReviewsAll } from "../github-pr-social.js";
|
|
8
|
+
import { compareLooksLikeMainMergesOnly } from "../review-hub/review-autoapprove.js";
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
11
|
* @param {unknown[]} items
|
|
@@ -80,35 +87,127 @@ function summarizeReviewState(reviews) {
|
|
|
80
87
|
return "none";
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} owner
|
|
92
|
+
* @param {string} repo
|
|
93
|
+
* @param {unknown[]} reviews
|
|
94
|
+
* @param {string} viewerLogin
|
|
95
|
+
* @param {string} headSha
|
|
96
|
+
* @param {string} defaultBranch
|
|
97
|
+
*/
|
|
98
|
+
async function buildViewerReviewMeta(owner, repo, reviews, viewerLogin, headSha, defaultBranch) {
|
|
99
|
+
/** @type {{ staleApproval: boolean, lastApprovalCommit: string | null, headSha: string, dismissedReview: boolean, riskyChangeAfterApproval: boolean | null, commitsSinceApproval: number }} */
|
|
100
|
+
const meta = {
|
|
101
|
+
staleApproval: false,
|
|
102
|
+
lastApprovalCommit: null,
|
|
103
|
+
headSha,
|
|
104
|
+
dismissedReview: false,
|
|
105
|
+
riskyChangeAfterApproval: null,
|
|
106
|
+
commitsSinceApproval: 0
|
|
107
|
+
};
|
|
108
|
+
if (!viewerLogin || !headSha) {
|
|
109
|
+
return meta;
|
|
110
|
+
}
|
|
111
|
+
for (const rv of reviews) {
|
|
112
|
+
if (!rv || typeof rv !== "object") continue;
|
|
113
|
+
const r = /** @type {Record<string, unknown>} */ (rv);
|
|
114
|
+
const user = r.user && typeof r.user === "object" ? /** @type {Record<string, unknown>} */ (r.user) : {};
|
|
115
|
+
const login = typeof user.login === "string" ? user.login : "";
|
|
116
|
+
if (login !== viewerLogin) continue;
|
|
117
|
+
const state = typeof r.state === "string" ? r.state.toUpperCase() : "";
|
|
118
|
+
if (state === "DISMISSED") {
|
|
119
|
+
meta.dismissedReview = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
let lastApprovalCommit = null;
|
|
123
|
+
for (let i = reviews.length - 1; i >= 0; i--) {
|
|
124
|
+
const rv = reviews[i];
|
|
125
|
+
if (!rv || typeof rv !== "object") continue;
|
|
126
|
+
const r = /** @type {Record<string, unknown>} */ (rv);
|
|
127
|
+
const user = r.user && typeof r.user === "object" ? /** @type {Record<string, unknown>} */ (r.user) : {};
|
|
128
|
+
const login = typeof user.login === "string" ? user.login : "";
|
|
129
|
+
if (login !== viewerLogin) continue;
|
|
130
|
+
const state = typeof r.state === "string" ? r.state.toUpperCase() : "";
|
|
131
|
+
if (state === "APPROVED" && typeof r.commit_id === "string") {
|
|
132
|
+
lastApprovalCommit = r.commit_id;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
meta.lastApprovalCommit = lastApprovalCommit;
|
|
137
|
+
if (lastApprovalCommit && lastApprovalCommit !== headSha) {
|
|
138
|
+
meta.staleApproval = true;
|
|
139
|
+
try {
|
|
140
|
+
const cmp = await githubCompareRefs(owner, repo, lastApprovalCommit, headSha);
|
|
141
|
+
const total = typeof cmp.total_commits === "number" ? cmp.total_commits : 0;
|
|
142
|
+
meta.commitsSinceApproval = total;
|
|
143
|
+
const mergeOnly = compareLooksLikeMainMergesOnly(
|
|
144
|
+
/** @type {Record<string, unknown>} */ (cmp),
|
|
145
|
+
defaultBranch
|
|
146
|
+
);
|
|
147
|
+
meta.riskyChangeAfterApproval = !mergeOnly;
|
|
148
|
+
} catch {
|
|
149
|
+
meta.riskyChangeAfterApproval = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return meta;
|
|
153
|
+
}
|
|
154
|
+
|
|
83
155
|
/**
|
|
84
156
|
* @param {string} owner
|
|
85
157
|
* @param {string} repo
|
|
86
158
|
* @param {Array<{ pr_number: number, position: number, head_branch?: string, base_branch?: string }>} prEntries
|
|
87
159
|
* @param {number} [concurrency]
|
|
160
|
+
* @param {{ viewerLogin?: string, defaultBranch?: string, fullReviewFetch?: boolean }} [fetchOpts]
|
|
88
161
|
*/
|
|
89
|
-
export async function fetchStackPrDetails(owner, repo, prEntries, concurrency = 6) {
|
|
162
|
+
export async function fetchStackPrDetails(owner, repo, prEntries, concurrency = 6, fetchOpts = {}) {
|
|
163
|
+
const viewerLogin = fetchOpts.viewerLogin || "";
|
|
164
|
+
const defaultBranch = fetchOpts.defaultBranch || "main";
|
|
165
|
+
const fullFetch = !!fetchOpts.fullReviewFetch || !!viewerLogin;
|
|
166
|
+
|
|
90
167
|
const sorted = [...prEntries].sort((a, b) => a.position - b.position);
|
|
91
168
|
return mapInBatches(sorted, concurrency, async (entry) => {
|
|
92
169
|
const n = entry.pr_number;
|
|
93
170
|
try {
|
|
94
171
|
const pull = await githubGetPull(owner, repo, n);
|
|
172
|
+
const headSha =
|
|
173
|
+
pull && typeof pull === "object" && pull.head && typeof pull.head === "object"
|
|
174
|
+
? String(/** @type {Record<string, unknown>} */ (pull.head).sha || "")
|
|
175
|
+
: "";
|
|
176
|
+
|
|
95
177
|
const [issueRes, reviewRes, filesRes, reviewsRes] = await Promise.allSettled([
|
|
96
178
|
githubListIssueCommentsFirstPage(owner, repo, n),
|
|
97
179
|
githubListPullReviewCommentsFirstPage(owner, repo, n),
|
|
98
180
|
githubListPullFiles(owner, repo, n),
|
|
99
|
-
|
|
181
|
+
fullFetch
|
|
182
|
+
? githubListPullReviewsAll(owner, repo, n)
|
|
183
|
+
: githubListPullReviewsFirstPage(owner, repo, n)
|
|
100
184
|
]);
|
|
101
185
|
const issueComments = issueRes.status === "fulfilled" ? issueRes.value : [];
|
|
102
186
|
const reviewComments = reviewRes.status === "fulfilled" ? reviewRes.value : [];
|
|
103
187
|
const files = filesRes.status === "fulfilled" ? filesRes.value : [];
|
|
104
188
|
const reviews = reviewsRes.status === "fulfilled" ? reviewsRes.value : [];
|
|
189
|
+
const reviewList = Array.isArray(reviews) ? reviews : [];
|
|
190
|
+
|
|
191
|
+
let viewerReviewMeta = null;
|
|
192
|
+
if (viewerLogin && headSha) {
|
|
193
|
+
viewerReviewMeta = await buildViewerReviewMeta(
|
|
194
|
+
owner,
|
|
195
|
+
repo,
|
|
196
|
+
reviewList,
|
|
197
|
+
viewerLogin,
|
|
198
|
+
headSha,
|
|
199
|
+
defaultBranch
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
105
203
|
return {
|
|
106
204
|
entry,
|
|
107
205
|
pull,
|
|
108
206
|
issueComments,
|
|
109
207
|
reviewComments,
|
|
110
208
|
files: Array.isArray(files) ? files : [],
|
|
111
|
-
reviewSummary: summarizeReviewState(
|
|
209
|
+
reviewSummary: summarizeReviewState(reviewList),
|
|
210
|
+
viewerReviewMeta,
|
|
112
211
|
error: null
|
|
113
212
|
};
|
|
114
213
|
} catch (e) {
|
|
@@ -119,6 +218,7 @@ export async function fetchStackPrDetails(owner, repo, prEntries, concurrency =
|
|
|
119
218
|
reviewComments: [],
|
|
120
219
|
files: [],
|
|
121
220
|
reviewSummary: "none",
|
|
221
|
+
viewerReviewMeta: null,
|
|
122
222
|
error: String(/** @type {Error} */ (e).message || e)
|
|
123
223
|
};
|
|
124
224
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build StackPickInk-shaped entries from inferred PR chains (bottom → tip) and GitHub pull payloads.
|
|
3
|
+
*
|
|
4
|
+
* @param {number[][]} chains
|
|
5
|
+
* @param {unknown[]} pulls
|
|
6
|
+
* @returns {object[]}
|
|
7
|
+
*/
|
|
8
|
+
export function inferChainsToPickStacks(chains, pulls) {
|
|
9
|
+
/** @type {Map<number, Record<string, unknown>>} */
|
|
10
|
+
const byNum = new Map();
|
|
11
|
+
for (const raw of pulls) {
|
|
12
|
+
if (!raw || typeof raw !== "object") continue;
|
|
13
|
+
const p = /** @type {Record<string, unknown>} */ (raw);
|
|
14
|
+
const num = p.number;
|
|
15
|
+
if (typeof num !== "number") continue;
|
|
16
|
+
byNum.set(num, p);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return chains.map((chain, inferChainIndex) => {
|
|
20
|
+
/** @type {{ pr_number: number, position: number, head_branch?: string, title?: string, additions?: number, deletions?: number }[]} */
|
|
21
|
+
const prRows = [];
|
|
22
|
+
let sumAdd = 0;
|
|
23
|
+
let sumDel = 0;
|
|
24
|
+
for (let i = 0; i < chain.length; i++) {
|
|
25
|
+
const prn = chain[i];
|
|
26
|
+
const pull = byNum.get(prn);
|
|
27
|
+
const head =
|
|
28
|
+
pull && pull.head && typeof pull.head === "object"
|
|
29
|
+
? /** @type {Record<string, unknown>} */ (pull.head)
|
|
30
|
+
: {};
|
|
31
|
+
const ref = typeof head.ref === "string" ? head.ref : "";
|
|
32
|
+
const add = pull && typeof pull.additions === "number" ? pull.additions : 0;
|
|
33
|
+
const del = pull && typeof pull.deletions === "number" ? pull.deletions : 0;
|
|
34
|
+
sumAdd += add;
|
|
35
|
+
sumDel += del;
|
|
36
|
+
prRows.push({
|
|
37
|
+
pr_number: prn,
|
|
38
|
+
position: i,
|
|
39
|
+
head_branch: ref,
|
|
40
|
+
title: pull && typeof pull.title === "string" ? pull.title : undefined,
|
|
41
|
+
additions: add,
|
|
42
|
+
deletions: del
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tipPr = chain[chain.length - 1];
|
|
47
|
+
const tipPull = tipPr ? byNum.get(tipPr) : undefined;
|
|
48
|
+
const tipHead = prRows.length ? String(prRows[prRows.length - 1].head_branch || "") : "";
|
|
49
|
+
const tip_updated_at =
|
|
50
|
+
tipPull && typeof tipPull.updated_at === "string" ? tipPull.updated_at : "";
|
|
51
|
+
const user =
|
|
52
|
+
tipPull && tipPull.user && typeof tipPull.user === "object"
|
|
53
|
+
? /** @type {Record<string, unknown>} */ (tipPull.user)
|
|
54
|
+
: {};
|
|
55
|
+
const login = typeof user.login === "string" ? user.login : "";
|
|
56
|
+
|
|
57
|
+
const hasLineStats = sumAdd > 0 || sumDel > 0;
|
|
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
|
+
|
|
68
|
+
return {
|
|
69
|
+
tip_pr_number: tipPr,
|
|
70
|
+
tip_head_branch: tipHead,
|
|
71
|
+
pr_count: chain.length,
|
|
72
|
+
created_by: login,
|
|
73
|
+
prs: prRows,
|
|
74
|
+
tip_updated_at,
|
|
75
|
+
inferChainIndex,
|
|
76
|
+
base_ref,
|
|
77
|
+
...(hasLineStats ? { inferDiffAdd: sumAdd, inferDiffDel: sumDel } : {})
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|