nugit-cli 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -23
  3. package/src/github-device-flow.js +1 -1
  4. package/src/github-oauth-client-id.js +11 -0
  5. package/src/github-pr-social.js +42 -0
  6. package/src/github-rest.js +149 -6
  7. package/src/nugit-config.js +84 -0
  8. package/src/nugit-stack.js +40 -257
  9. package/src/nugit.js +104 -647
  10. package/src/review-hub/review-autoapprove.js +95 -0
  11. package/src/review-hub/review-hub-back.js +10 -0
  12. package/src/review-hub/review-hub-ink.js +169 -0
  13. package/src/review-hub/run-review-hub.js +131 -0
  14. package/src/services/repo-branches.js +151 -0
  15. package/src/services/stack-inference.js +90 -0
  16. package/src/split-view/run-split.js +14 -76
  17. package/src/split-view/split-ink.js +2 -2
  18. package/src/stack-infer-from-prs.js +71 -0
  19. package/src/stack-view/diff-line-map.js +62 -0
  20. package/src/stack-view/fetch-pr-data.js +104 -4
  21. package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
  22. package/src/stack-view/ink-app.js +3 -421
  23. package/src/stack-view/loader.js +19 -93
  24. package/src/stack-view/loading-ink.js +2 -0
  25. package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
  26. package/src/stack-view/patch-preview-merge.js +108 -0
  27. package/src/stack-view/remote-infer-doc.js +76 -0
  28. package/src/stack-view/repo-picker-back.js +10 -0
  29. package/src/stack-view/run-stack-view.js +508 -150
  30. package/src/stack-view/run-view-entry.js +115 -0
  31. package/src/stack-view/sgr-mouse.js +56 -0
  32. package/src/stack-view/stack-branch-graph.js +95 -0
  33. package/src/stack-view/stack-pick-graph.js +93 -0
  34. package/src/stack-view/stack-pick-ink.js +308 -0
  35. package/src/stack-view/stack-pick-layout.js +19 -0
  36. package/src/stack-view/stack-pick-sort.js +188 -0
  37. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  38. package/src/stack-view/terminal-fullscreen.js +7 -0
  39. package/src/stack-view/tree-ascii.js +73 -0
  40. package/src/stack-view/view-md-plain.js +23 -0
  41. package/src/stack-view/view-repo-picker-ink.js +293 -0
  42. package/src/stack-view/view-tui-sequential.js +126 -0
  43. package/src/tui/pages/home.js +122 -0
  44. package/src/tui/pages/repo-actions.js +81 -0
  45. package/src/tui/pages/repo-branches.js +259 -0
  46. package/src/tui/pages/viewer.js +2129 -0
  47. package/src/tui/router.js +40 -0
  48. package/src/tui/run-tui.js +281 -0
  49. package/src/utilities/loading.js +37 -0
  50. package/src/utilities/terminal.js +31 -0
  51. package/src/cli-output.js +0 -228
  52. package/src/nugit-start.js +0 -211
  53. package/src/stack-discover.js +0 -284
  54. package/src/stack-discovery-config.js +0 -91
  55. package/src/stack-extra-commands.js +0 -353
  56. package/src/stack-graph.js +0 -214
  57. package/src/stack-helpers.js +0 -58
  58. package/src/stack-propagate.js +0 -422
@@ -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
- gitExec(root, ["checkout", baseBranch]);
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 != null
114
- ? `[split ${i + 1}/${newBranches.length}] ${pull.title}`
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 { githubGetPull, githubListPullFiles, githubRestJson } from "../github-rest.js";
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
- githubListPullReviewsFirstPage(owner, repo, n)
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(reviews),
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
+ }