@fullstackhouse/cerber 0.27.0 → 0.27.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 (2) hide show
  1. package/dist/core/gh.js +81 -1
  2. package/package.json +1 -1
package/dist/core/gh.js CHANGED
@@ -90,8 +90,88 @@ async function membershipCheck(args) {
90
90
  throw err;
91
91
  }
92
92
  }
93
+ /**
94
+ * GitHub refuses to render a diff past 300 changed files: `gh pr diff` comes
95
+ * back 406 and the review dies before it starts. The files API answers for ten
96
+ * times as many, so a too-large diff is re-assembled from it rather than
97
+ * reported as a broken review — see {@link assembleDiff}.
98
+ */
93
99
  export async function fetchPrDiff(ref) {
94
- return gh(["pr", "diff", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`]);
100
+ try {
101
+ return await gh(["pr", "diff", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`]);
102
+ }
103
+ catch (err) {
104
+ // Matched on GitHub's own `too_large` signature rather than the bare 406:
105
+ // 406 is "Not Acceptable" generally, while `too_large` is emitted for this
106
+ // refusal and nothing else. Matching the meaning survives a status change;
107
+ // matching the status would fall back on some unrelated 406 one day.
108
+ const message = err instanceof Error ? err.message : String(err);
109
+ if (!/too_large|exceeded the maximum number of files/i.test(message))
110
+ throw err;
111
+ const files = await fetchPrFiles(ref);
112
+ const diff = assembleDiff(files);
113
+ // The files API stops at 3000 too, and says nothing when it does. Hitting
114
+ // the cap exactly is not proof of truncation — a PR can change exactly
115
+ // 3000 files — and there is nothing here to tell the two apart, so the
116
+ // note reports the doubt rather than asserting a loss that may not exist.
117
+ return files.length < FILES_API_CAP
118
+ ? diff
119
+ : `${diff}[GitHub's files API returns at most ${FILES_API_CAP} files and returned exactly that many — if this PR changes more, the rest is missing from this diff. Read the checkout.]\n`;
120
+ }
121
+ }
122
+ const FILES_API_CAP = 3000;
123
+ async function fetchPrFiles(ref) {
124
+ const out = await gh([
125
+ "api",
126
+ "--paginate",
127
+ `repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/files?per_page=100`,
128
+ "--jq",
129
+ ".[] | {filename, status, previous_filename, additions, deletions, patch}",
130
+ ]);
131
+ // --jq streams one object per line rather than a JSON array.
132
+ return out
133
+ .split("\n")
134
+ .filter((line) => line.trim() !== "")
135
+ .map((line) => JSON.parse(line));
136
+ }
137
+ /**
138
+ * Put the `diff --git` / `---` / `+++` headers back on the per-file hunks the
139
+ * files API returns, so the result parses exactly like `gh pr diff` output —
140
+ * `splitDiffByFile` and the line anchoring read both the same way.
141
+ *
142
+ * A file whose patch GitHub withheld still gets its headers and a line saying
143
+ * so: an unexplained gap would read as "nothing changed here".
144
+ */
145
+ export function assembleDiff(files) {
146
+ const out = [];
147
+ for (const file of files) {
148
+ const renamed = file.status === "renamed" && file.previous_filename;
149
+ const oldPath = file.status === "added" ? null : renamed ? file.previous_filename : file.filename;
150
+ const newPath = file.status === "removed" ? null : file.filename;
151
+ // The side a file does not have is /dev/null, in the headers and in the
152
+ // binary line alike — that is how git names it.
153
+ const a = oldPath ? `a/${oldPath}` : "/dev/null";
154
+ const b = newPath ? `b/${newPath}` : "/dev/null";
155
+ out.push(`diff --git a/${oldPath ?? file.filename} b/${newPath ?? file.filename}`);
156
+ if (renamed)
157
+ out.push(`rename from ${oldPath}`, `rename to ${newPath}`);
158
+ if (file.patch) {
159
+ out.push(`--- ${a}`, `+++ ${b}`, file.patch.replace(/\n$/, ""));
160
+ }
161
+ else if (file.additions > 0 || file.deletions > 0) {
162
+ // GitHub counted the lines and then withheld the patch: a text file it
163
+ // decided was too big. Say so — an empty file block reads as "unchanged".
164
+ out.push(`--- ${a}`, `+++ ${b}`, `[GitHub withheld this file's patch — ${file.additions} addition(s), ${file.deletions} deletion(s). Read the file in the checkout.]`);
165
+ }
166
+ else if (!renamed) {
167
+ out.push(`Binary files ${a} and ${b} differ`);
168
+ }
169
+ // A pure rename ends here, with no hunks and no ---/+++ pair: nothing about
170
+ // the content changed, and `rename from`/`rename to` have already said
171
+ // everything. Calling it binary — which counting lines alone would — is
172
+ // both wrong and the more alarming of the two ways to be wrong.
173
+ }
174
+ return out.length === 0 ? "" : out.join("\n") + "\n";
95
175
  }
96
176
  /**
97
177
  * Map `gh search prs` JSON output to PR refs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/cerber",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
5
5
  "license": "MIT",
6
6
  "author": "Full Stack House (https://fullstack.house)",