diffninja 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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/calltree.d.ts +47 -0
  4. package/dist/calltree.js +296 -0
  5. package/dist/cli.d.ts +57 -0
  6. package/dist/cli.js +340 -0
  7. package/dist/diff.d.ts +7 -0
  8. package/dist/diff.js +114 -0
  9. package/dist/extract.d.ts +26 -0
  10. package/dist/extract.js +152 -0
  11. package/dist/git.d.ts +40 -0
  12. package/dist/git.js +288 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +8 -0
  15. package/dist/infer.d.ts +21 -0
  16. package/dist/infer.js +189 -0
  17. package/dist/languages/bash.d.ts +2 -0
  18. package/dist/languages/bash.js +208 -0
  19. package/dist/languages/c.d.ts +2 -0
  20. package/dist/languages/c.js +218 -0
  21. package/dist/languages/call-syntax.d.ts +125 -0
  22. package/dist/languages/call-syntax.js +997 -0
  23. package/dist/languages/cpp.d.ts +2 -0
  24. package/dist/languages/cpp.js +321 -0
  25. package/dist/languages/csharp.d.ts +2 -0
  26. package/dist/languages/csharp.js +324 -0
  27. package/dist/languages/elixir.d.ts +2 -0
  28. package/dist/languages/elixir.js +331 -0
  29. package/dist/languages/go.d.ts +2 -0
  30. package/dist/languages/go.js +299 -0
  31. package/dist/languages/grammars.d.ts +50 -0
  32. package/dist/languages/grammars.js +351 -0
  33. package/dist/languages/haskell.d.ts +2 -0
  34. package/dist/languages/haskell.js +250 -0
  35. package/dist/languages/java.d.ts +2 -0
  36. package/dist/languages/java.js +351 -0
  37. package/dist/languages/javascript.d.ts +4 -0
  38. package/dist/languages/javascript.js +648 -0
  39. package/dist/languages/kotlin.d.ts +2 -0
  40. package/dist/languages/kotlin.js +368 -0
  41. package/dist/languages/lua.d.ts +2 -0
  42. package/dist/languages/lua.js +212 -0
  43. package/dist/languages/ocaml.d.ts +2 -0
  44. package/dist/languages/ocaml.js +291 -0
  45. package/dist/languages/perl.d.ts +2 -0
  46. package/dist/languages/perl.js +418 -0
  47. package/dist/languages/php.d.ts +2 -0
  48. package/dist/languages/php.js +397 -0
  49. package/dist/languages/python.d.ts +2 -0
  50. package/dist/languages/python.js +376 -0
  51. package/dist/languages/registry.d.ts +7 -0
  52. package/dist/languages/registry.js +69 -0
  53. package/dist/languages/ruby.d.ts +2 -0
  54. package/dist/languages/ruby.js +391 -0
  55. package/dist/languages/rust.d.ts +2 -0
  56. package/dist/languages/rust.js +261 -0
  57. package/dist/languages/scala.d.ts +2 -0
  58. package/dist/languages/scala.js +307 -0
  59. package/dist/languages/solidity.d.ts +2 -0
  60. package/dist/languages/solidity.js +240 -0
  61. package/dist/languages/swift.d.ts +2 -0
  62. package/dist/languages/swift.js +268 -0
  63. package/dist/languages/types.d.ts +36 -0
  64. package/dist/languages/types.js +74 -0
  65. package/dist/languages/typescript-contracts.d.ts +57 -0
  66. package/dist/languages/typescript-contracts.js +528 -0
  67. package/dist/languages/typescript-dispatch.d.ts +68 -0
  68. package/dist/languages/typescript-dispatch.js +710 -0
  69. package/dist/languages/typescript.d.ts +4 -0
  70. package/dist/languages/typescript.js +722 -0
  71. package/dist/languages/zig.d.ts +2 -0
  72. package/dist/languages/zig.js +243 -0
  73. package/dist/loc.d.ts +17 -0
  74. package/dist/loc.js +34 -0
  75. package/dist/reach.d.ts +17 -0
  76. package/dist/reach.js +65 -0
  77. package/dist/render.d.ts +18 -0
  78. package/dist/render.js +83 -0
  79. package/dist/review/brand.d.ts +8 -0
  80. package/dist/review/brand.js +25 -0
  81. package/dist/review/call-context.d.ts +27 -0
  82. package/dist/review/call-context.js +446 -0
  83. package/dist/review/call-flow-html.d.ts +32 -0
  84. package/dist/review/call-flow-html.js +1870 -0
  85. package/dist/review/call-flow-nav.d.ts +151 -0
  86. package/dist/review/call-flow-nav.js +317 -0
  87. package/dist/review/call-flow.d.ts +47 -0
  88. package/dist/review/call-flow.js +229 -0
  89. package/dist/review/change-facts.d.ts +69 -0
  90. package/dist/review/change-facts.js +729 -0
  91. package/dist/review/cli.d.ts +2 -0
  92. package/dist/review/cli.js +50 -0
  93. package/dist/review/connected-analysis.d.ts +100 -0
  94. package/dist/review/connected-analysis.js +163 -0
  95. package/dist/review/connected-html.d.ts +17 -0
  96. package/dist/review/connected-html.js +2853 -0
  97. package/dist/review/connected.d.ts +23 -0
  98. package/dist/review/connected.js +141 -0
  99. package/dist/review/escape-html.d.ts +2 -0
  100. package/dist/review/escape-html.js +9 -0
  101. package/dist/review/evidence-html.d.ts +21 -0
  102. package/dist/review/evidence-html.js +521 -0
  103. package/dist/review/evidence-syntax.d.ts +132 -0
  104. package/dist/review/evidence-syntax.js +478 -0
  105. package/dist/review/evidence-types.d.ts +62 -0
  106. package/dist/review/evidence-types.js +1 -0
  107. package/dist/review/evidence.d.ts +31 -0
  108. package/dist/review/evidence.js +1603 -0
  109. package/dist/review/file-role.d.ts +9 -0
  110. package/dist/review/file-role.js +29 -0
  111. package/dist/review/github.d.ts +204 -0
  112. package/dist/review/github.js +1245 -0
  113. package/dist/review/history.d.ts +101 -0
  114. package/dist/review/history.js +412 -0
  115. package/dist/review/html.d.ts +34 -0
  116. package/dist/review/html.js +1104 -0
  117. package/dist/review/input.d.ts +10 -0
  118. package/dist/review/input.js +113 -0
  119. package/dist/review/intent.d.ts +4 -0
  120. package/dist/review/intent.js +75 -0
  121. package/dist/review/mcp-cli.d.ts +2 -0
  122. package/dist/review/mcp-cli.js +25 -0
  123. package/dist/review/mcp.d.ts +12 -0
  124. package/dist/review/mcp.js +414 -0
  125. package/dist/review/module-resolution.d.ts +2 -0
  126. package/dist/review/module-resolution.js +86 -0
  127. package/dist/review/palette.d.ts +7 -0
  128. package/dist/review/palette.js +104 -0
  129. package/dist/review/pipeline.d.ts +77 -0
  130. package/dist/review/pipeline.js +227 -0
  131. package/dist/review/pr-input.d.ts +19 -0
  132. package/dist/review/pr-input.js +130 -0
  133. package/dist/review/questions.d.ts +201 -0
  134. package/dist/review/questions.js +174 -0
  135. package/dist/review/reference-check.d.ts +7 -0
  136. package/dist/review/reference-check.js +733 -0
  137. package/dist/review/report-pages.d.ts +109 -0
  138. package/dist/review/report-pages.js +328 -0
  139. package/dist/review/service.d.ts +23 -0
  140. package/dist/review/service.js +198 -0
  141. package/dist/review/setup.d.ts +112 -0
  142. package/dist/review/setup.js +549 -0
  143. package/dist/review/source.d.ts +26 -0
  144. package/dist/review/source.js +276 -0
  145. package/dist/review/toml.d.ts +38 -0
  146. package/dist/review/toml.js +565 -0
  147. package/dist/review/types.d.ts +179 -0
  148. package/dist/review/types.js +1 -0
  149. package/dist/run.d.ts +49 -0
  150. package/dist/run.js +311 -0
  151. package/dist/types.d.ts +366 -0
  152. package/dist/types.js +83 -0
  153. package/package.json +88 -0
  154. package/scripts/ensure-native-grammar.mjs +188 -0
@@ -0,0 +1,1245 @@
1
+ /**
2
+ * Connected GitHub review.
3
+ *
4
+ * One explicit github.com pull request URL in, one canonical diff out, one
5
+ * human-authored review back. `gh` is the only GitHub client: it runs with an
6
+ * explicit argument array, no shell, no prompts, bounded time and output, and
7
+ * the credential the environment already gave it is the identity that is read.
8
+ *
9
+ * Safety invariants:
10
+ * - A snapshot (and therefore every anchor a review may use) is built solely
11
+ * from the canonical pull request diff, and only after that diff was checked
12
+ * against the paginated file list GitHub returns for the same pull request.
13
+ * An added line anchors RIGHT, a deleted line anchors LEFT, a context line
14
+ * anchors either side. Nothing else is commentable.
15
+ * - A review is submitted only for the same snapshot the caller previewed, with
16
+ * the previewed payload byte for byte (including the explicit `commit_id`),
17
+ * after re-reading the metadata and the raw diff and finding them unchanged,
18
+ * and after the effective gh account was re-checked.
19
+ * - One review per session. An ambiguous write marks the session `unknown`,
20
+ * never retried; only an exact match found on GitHub resolves it.
21
+ */
22
+ import { execFile } from "node:child_process";
23
+ import { createHash } from "node:crypto";
24
+ /* ---------------------------------------------------------------- gh process */
25
+ /** Lowest `gh` release whose `pr view --json` fields and `api --input -` this code needs. */
26
+ export const MIN_GH_VERSION = "2.45.0";
27
+ const GH_TIMEOUT_MS = 15_000;
28
+ const GH_OUTPUT_LIMIT = 32 * 1024 * 1024;
29
+ const GITHUB_HOST = "github.com";
30
+ /**
31
+ * A failed `gh` run. `gh api` prints GitHub's JSON error body to stdout and the
32
+ * HTTP status line to stderr, so both are carried for safe message extraction;
33
+ * neither is ever shown to the user verbatim.
34
+ */
35
+ export class GhCommandError extends Error {
36
+ stdout;
37
+ stderr;
38
+ constructor(message, stdout, stderr) {
39
+ super(message);
40
+ this.name = "GhCommandError";
41
+ this.stdout = stdout;
42
+ this.stderr = stderr;
43
+ }
44
+ }
45
+ /** No prompt, no pager, no update notice, and never a host other than github.com. */
46
+ function ghEnvironment() {
47
+ return { ...process.env, GH_HOST: GITHUB_HOST, GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1" };
48
+ }
49
+ /** Runs the real `gh` with an explicit argv (never a shell) and a hard deadline. */
50
+ export function ghCliRunner() {
51
+ return {
52
+ run: (invocation) => new Promise((resolve, reject) => {
53
+ let inputError;
54
+ const settle = (error, stdout, stderr) => {
55
+ if (error === null) {
56
+ if (inputError !== undefined) {
57
+ reject(new GhCommandError("gh input could not be delivered", stdout, stderr));
58
+ return;
59
+ }
60
+ resolve({ stdout, stderr });
61
+ return;
62
+ }
63
+ if (error.code === "ENOENT") {
64
+ reject(new GhCommandError("gh was not found", stdout, stderr));
65
+ return;
66
+ }
67
+ if (error.killed === true || error.code === "ETIMEDOUT") {
68
+ reject(new GhCommandError("gh timed out", stdout, stderr));
69
+ return;
70
+ }
71
+ reject(new GhCommandError(firstLine(stderr) || firstLine(stdout) || "gh failed", stdout, stderr));
72
+ };
73
+ const child = execFile("gh", [...invocation.args], {
74
+ timeout: invocation.timeoutMs,
75
+ killSignal: "SIGKILL",
76
+ maxBuffer: GH_OUTPUT_LIMIT,
77
+ encoding: "utf8",
78
+ windowsHide: true,
79
+ env: ghEnvironment(),
80
+ }, settle);
81
+ // An early exit can close stdin before the payload is delivered. Keep
82
+ // the command's diagnostic on failure; never accept a partial input on
83
+ // success, and never let a stream error crash the CLI or MCP process.
84
+ child.stdin?.on("error", (error) => { inputError = error; });
85
+ child.stdin?.end(invocation.stdin);
86
+ }),
87
+ };
88
+ }
89
+ function firstLine(text) {
90
+ const [line] = text.trim().split("\n");
91
+ return (line ?? "").trim().slice(0, 200);
92
+ }
93
+ const AUTH_FAILURE = "gh is not authenticated for github.com. Run `gh auth login --hostname github.com` and try again.";
94
+ const AUTHORIZATION_FAILURE = "The gh account is not authorized for that repository. Check its access and token scopes.";
95
+ const NOT_FOUND_FAILURE = "GitHub did not return that pull request. Check the URL, or confirm the gh account can read the repository.";
96
+ /** Drop control characters and cap length; GitHub error text is data, not a channel. */
97
+ function sanitizeText(text) {
98
+ const cleaned = text.replace(/[\p{Cc}]+/gu, " ").replace(/\s+/g, " ").trim();
99
+ return cleaned.length > 400 ? `${cleaned.slice(0, 400)}…` : cleaned;
100
+ }
101
+ /**
102
+ * GitHub's own explanation from a JSON error body: its `message` plus every
103
+ * `errors[]` entry (a plain string, or an object with a `message`). Only these
104
+ * named fields are read, so process output, tokens, and headers cannot leak.
105
+ */
106
+ function githubErrorText(stdout) {
107
+ const decoded = tryParseJson(stdout);
108
+ if (!isGhObject(decoded))
109
+ return null;
110
+ const parts = [];
111
+ const message = textField(decoded, "message");
112
+ if (message !== null) {
113
+ const cleaned = sanitizeText(message);
114
+ if (cleaned !== "")
115
+ parts.push(cleaned);
116
+ }
117
+ const errors = decoded["errors"];
118
+ if (Array.isArray(errors)) {
119
+ for (const entry of errors) {
120
+ const text = isGhObject(entry) ? textField(entry, "message") : isGhText(entry) ? entry : null;
121
+ const cleaned = text === null ? "" : sanitizeText(text);
122
+ if (cleaned !== "" && !parts.includes(cleaned))
123
+ parts.push(cleaned);
124
+ }
125
+ }
126
+ return parts.length === 0 ? null : parts.join(" — ");
127
+ }
128
+ function httpStatus(error) {
129
+ const match = /\bHTTP (\d{3})\b/.exec(`${error.message}\n${error.stderr}`);
130
+ return match === null ? null : Number(match[1]);
131
+ }
132
+ /** Normalize anything a runner threw, so every failure carries the same shape. */
133
+ function asGhCommandError(error) {
134
+ return error instanceof GhCommandError ? error : new GhCommandError(error.message, "", "");
135
+ }
136
+ function githubBodyError(message) {
137
+ const cleaned = sanitizeText(message);
138
+ if (/not found/i.test(cleaned))
139
+ return new Error(NOT_FOUND_FAILURE);
140
+ if (/bad credentials|requires authentication/i.test(cleaned))
141
+ return new Error(AUTH_FAILURE);
142
+ if (/forbidden|not accessible|not authorized/i.test(cleaned))
143
+ return new Error(AUTHORIZATION_FAILURE);
144
+ return new Error(`GitHub rejected the request: ${cleaned}`);
145
+ }
146
+ /**
147
+ * Map a raw `gh` failure to a safe, user-facing error. Read failures stay
148
+ * neutral — no failure text claims the pull request was untouched, because when
149
+ * a submission is unresolved a later read failing proves nothing about it. The
150
+ * one place that may say "nothing was submitted" is the definite rejection path
151
+ * of {@link ConnectedReview.submit}, where GitHub itself answered the write.
152
+ */
153
+ function classifyGhFailure(rawError, subject) {
154
+ const error = asGhCommandError(rawError);
155
+ const detail = error.message;
156
+ const status = httpStatus(error);
157
+ const github = githubErrorText(error.stdout);
158
+ if (status === 408 || /timed out|timeout/i.test(detail)) {
159
+ return new Error("gh did not answer in time; the request did not complete. Check the network, then try again.");
160
+ }
161
+ if (/was not found/i.test(detail) && status === null)
162
+ return new Error(`The GitHub CLI (gh) was not found on PATH. Install gh ${MIN_GH_VERSION} or newer from https://cli.github.com, then run \`gh auth login --hostname github.com\` and try again.`);
163
+ if (/unknown json field|unknown flag|unknown shorthand|unknown command/i.test(detail)) {
164
+ return new Error(`The installed gh does not support the flags diffninja needs. Update gh to ${MIN_GH_VERSION} or newer.`);
165
+ }
166
+ if (status === 401 || /bad credentials|not logged in|gh auth login|authentication required/i.test(detail))
167
+ return new Error(AUTH_FAILURE);
168
+ if (status === 403 || /resource not accessible|forbidden|must have push|not authorized|insufficient/i.test(detail)) {
169
+ // A 403 has many causes; GitHub's own text is what tells them apart.
170
+ return new Error(github === null ? AUTHORIZATION_FAILURE : `${AUTHORIZATION_FAILURE} GitHub said: ${github}.`);
171
+ }
172
+ if (status === 404 || /could not resolve to a pull ?request|pull request was not found/i.test(detail))
173
+ return new Error(NOT_FOUND_FAILURE);
174
+ if (status !== null && status >= 400 && status < 500) {
175
+ const what = subject === "review" ? "GitHub rejected the review" : "The request was rejected";
176
+ return new Error(github === null ? `${what}: ${sanitizeText(detail)}` : `${what}: ${github}`);
177
+ }
178
+ if (github !== null) {
179
+ return new Error(`GitHub returned a server error; the request did not complete: ${github}. Try again in a moment.`);
180
+ }
181
+ if (status !== null && status >= 500)
182
+ return new Error("GitHub returned a server error; the request did not complete. Try again in a moment.");
183
+ if (/connection refused|no such host|name resolution|network is unreachable|unexpected EOF|TLS handshake|proxyconnect/i.test(detail)) {
184
+ return new Error("diffninja could not reach GitHub; the request did not complete. Check the network and try again.");
185
+ }
186
+ return new Error("The gh command failed; the request did not complete.");
187
+ }
188
+ function isGhObject(value) {
189
+ // JSON.parse only produces plain objects, arrays, and primitives, so plain
190
+ // objects are exactly the values whose prototype is Object.prototype.
191
+ return value !== null && value !== undefined && Object.getPrototypeOf(value) === Object.prototype;
192
+ }
193
+ function isGhText(value) {
194
+ // Number, boolean, array, and object values all stringify to something other
195
+ // than themselves, so a value that survives String() is a text primitive.
196
+ return value !== null && value !== undefined && String(value) === value;
197
+ }
198
+ function isGhNumber(value) {
199
+ return value !== null && value !== undefined && Number.isFinite(value);
200
+ }
201
+ function isGhBoolean(value) {
202
+ return value === true || value === false;
203
+ }
204
+ function textField(object, key) {
205
+ if (object === null)
206
+ return null;
207
+ const value = object[key];
208
+ return isGhText(value) ? value : null;
209
+ }
210
+ function numberField(object, key) {
211
+ if (object === null)
212
+ return null;
213
+ const value = object[key];
214
+ return isGhNumber(value) ? value : null;
215
+ }
216
+ function booleanField(object, key) {
217
+ if (object === null)
218
+ return null;
219
+ const value = object[key];
220
+ return isGhBoolean(value) ? value : null;
221
+ }
222
+ function objectField(object, key) {
223
+ if (object === null)
224
+ return null;
225
+ const value = object[key];
226
+ return isGhObject(value) ? value : null;
227
+ }
228
+ /** Decode a `gh` stdout body into the JSON data model. */
229
+ function parseJson(text, what) {
230
+ try {
231
+ // SAFETY: JSON.parse produces the JSON data model, which GhJson describes
232
+ // exactly; every field read from it is revalidated by the guards above.
233
+ return JSON.parse(text);
234
+ }
235
+ catch {
236
+ throw new Error(`gh returned a ${what} that was not valid JSON.`);
237
+ }
238
+ }
239
+ /**
240
+ * Split concatenated JSON values. `gh api --paginate` prints one JSON document
241
+ * per page, so the stream is not a single parseable document; the scanner
242
+ * tracks strings and escapes so a page boundary inside a string cannot confuse
243
+ * it, and a stream that ends mid-value is reported as truncated.
244
+ */
245
+ function splitJsonStream(text, what) {
246
+ const chunks = [];
247
+ let depth = 0;
248
+ let start = -1;
249
+ let inString = false;
250
+ let escaped = false;
251
+ for (let index = 0; index < text.length; index++) {
252
+ const character = text[index];
253
+ if (inString) {
254
+ if (escaped)
255
+ escaped = false;
256
+ else if (character === "\\")
257
+ escaped = true;
258
+ else if (character === '"')
259
+ inString = false;
260
+ continue;
261
+ }
262
+ if (character === '"') {
263
+ inString = true;
264
+ if (start === -1)
265
+ start = index;
266
+ continue;
267
+ }
268
+ if (character === "[" || character === "{") {
269
+ if (depth === 0)
270
+ start = index;
271
+ depth++;
272
+ continue;
273
+ }
274
+ if (character === "]" || character === "}") {
275
+ depth--;
276
+ if (depth < 0)
277
+ throw new Error(`gh returned a malformed ${what}.`);
278
+ if (depth === 0) {
279
+ chunks.push(text.slice(start, index + 1));
280
+ start = -1;
281
+ }
282
+ }
283
+ }
284
+ if (inString || depth !== 0)
285
+ throw new Error(`gh returned a truncated ${what}.`);
286
+ return chunks;
287
+ }
288
+ /**
289
+ * Every row of a paginated `gh api` answer. Pages are concatenated arrays;
290
+ * `--slurp`-style nesting is flattened too. An error document is reported as
291
+ * GitHub's own message rather than as a shape mismatch.
292
+ */
293
+ function decodePages(text, what) {
294
+ const rows = [];
295
+ for (const chunk of splitJsonStream(text, what)) {
296
+ const page = parseJson(chunk, what);
297
+ if (isGhObject(page)) {
298
+ const message = textField(page, "message");
299
+ throw message === null ? new Error(`GitHub's ${what} was not a list.`) : githubBodyError(message);
300
+ }
301
+ if (!Array.isArray(page))
302
+ throw new Error(`GitHub's ${what} was not a list.`);
303
+ for (const row of page) {
304
+ if (Array.isArray(row))
305
+ rows.push(...row);
306
+ else
307
+ rows.push(row);
308
+ }
309
+ }
310
+ return rows;
311
+ }
312
+ /** Strictly an explicit github.com pull request URL; no search, no shorthand. */
313
+ function parsePullUrl(raw) {
314
+ let parsed;
315
+ try {
316
+ parsed = new URL(raw.trim());
317
+ }
318
+ catch {
319
+ throw new Error("Enter a full GitHub pull request URL, such as https://github.com/owner/repo/pull/123.");
320
+ }
321
+ if (parsed.protocol !== "https:" || parsed.hostname.toLowerCase() !== GITHUB_HOST) {
322
+ throw new Error("Only https://github.com pull request URLs are supported.");
323
+ }
324
+ if (parsed.search !== "" || parsed.hash !== "") {
325
+ throw new Error("Remove the query string or fragment from the pull request URL.");
326
+ }
327
+ const match = /^\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/pull\/([1-9]\d*)\/?$/.exec(parsed.pathname);
328
+ if (match === null) {
329
+ throw new Error("Enter a pull request URL of the form https://github.com/owner/repo/pull/123.");
330
+ }
331
+ const owner = match[1];
332
+ const repo = match[2];
333
+ if (owner === "." || owner === ".." || repo === "." || repo === "..") {
334
+ throw new Error("Enter a pull request URL of the form https://github.com/owner/repo/pull/123.");
335
+ }
336
+ const number = Number(match[3]);
337
+ if (!Number.isSafeInteger(number)) {
338
+ throw new Error("That pull request number is out of range.");
339
+ }
340
+ return { owner, repo, number, url: `https://github.com/${owner}/${repo}/pull/${number}` };
341
+ }
342
+ /* -------------------------------------------------------------- diff parsing */
343
+ const NO_NEWLINE_MARKER = "\";
344
+ /** Every anchor a comment may legitimately use. */
345
+ function anchorKey(path, line, side) {
346
+ return `${path}\u0000${side}:${line}`;
347
+ }
348
+ function unquotePath(raw) {
349
+ const value = raw.split("\t")[0] ?? "";
350
+ if (value.startsWith('"'))
351
+ return value;
352
+ return value.replace(/^[ab]\//, "");
353
+ }
354
+ const COMBINED_PROBLEM = "GitHub returned a combined merge diff, and diffninja cannot anchor review comments to one.";
355
+ const BINARY_PROBLEM = "This pull request changes binary files, so diffninja cannot place line review comments.";
356
+ const LINK_PROBLEM = "This pull request changes a submodule or symbolic link, which diffninja does not review line by line.";
357
+ const HTML_PROBLEM = "GitHub returned an HTML page instead of a diff. Check that the gh account can read this pull request.";
358
+ const EMPTY_PROBLEM = "GitHub returned an empty diff for this pull request.";
359
+ const UNPARSED_PROBLEM = "GitHub returned a diff that diffninja could not parse; refusing to review an unverified diff.";
360
+ function incompleteProblem(path) {
361
+ const where = path === "" ? "this pull request" : path;
362
+ return `GitHub returned an incomplete diff for ${where}; diffninja will not review a partial diff.`;
363
+ }
364
+ /** A diff that cannot be reviewed: no files, no lines, and no anchor to comment on. */
365
+ function failedDiff(problem) {
366
+ return { files: [], lines: [], anchors: new Set(), problem };
367
+ }
368
+ /**
369
+ * Parse the canonical diff as strictly as it can be parsed: every hunk header
370
+ * must account for exactly its line counts, and anything GitHub would not
371
+ * produce (truncation, combined diffs, stray headers) rejects the snapshot
372
+ * instead of silently dropping reviewable lines.
373
+ */
374
+ function parseCanonicalDiff(rawDiff) {
375
+ const text = rawDiff.replace(/\r\n/g, "\n");
376
+ if (text.trim() === "")
377
+ return failedDiff(EMPTY_PROBLEM);
378
+ if (text.trimStart().startsWith("<"))
379
+ return failedDiff(HTML_PROBLEM);
380
+ const rows = text.split("\n");
381
+ const files = [];
382
+ const lines = [];
383
+ const anchors = new Set();
384
+ let current = null;
385
+ for (let i = 0; i < rows.length; i++) {
386
+ const row = rows[i];
387
+ if (row === "")
388
+ continue;
389
+ if (row.startsWith("diff --cc ") || row.startsWith("diff --combined "))
390
+ return failedDiff(COMBINED_PROBLEM);
391
+ if (row.startsWith("diff --git ")) {
392
+ const names = /^diff --git (.*) (.*)$/.exec(row);
393
+ current = { path: unquotePath(names?.[2] ?? ""), oldPath: unquotePath(names?.[1] ?? ""), patch: [], hunks: 0, special: null };
394
+ files.push(current);
395
+ continue;
396
+ }
397
+ if (current === null) {
398
+ if (row.startsWith("@@"))
399
+ return failedDiff(COMBINED_PROBLEM);
400
+ return failedDiff(UNPARSED_PROBLEM);
401
+ }
402
+ if (row === "GIT binary patch" || row.startsWith("Binary files ") || row.startsWith("Binary file ")) {
403
+ current.special = "binary";
404
+ continue;
405
+ }
406
+ if (/^(new file mode|old mode|new mode|index|similarity index|dissimilarity index|rename from|rename to|copy from|copy to|deleted file mode) /.test(row)) {
407
+ if (/(^|\s)(120000|160000)(\s|$)/.test(row))
408
+ current.special = "link";
409
+ continue;
410
+ }
411
+ if (row.startsWith("--- ") && rows[i + 1]?.startsWith("+++ ")) {
412
+ const oldPath = unquotePath(row.slice(4));
413
+ const newPath = unquotePath(rows[++i].slice(4));
414
+ current.oldPath = oldPath;
415
+ current.path = newPath === "/dev/null" ? oldPath : newPath;
416
+ continue;
417
+ }
418
+ if (row.startsWith("@")) {
419
+ if (!row.startsWith("@@ "))
420
+ return failedDiff(COMBINED_PROBLEM);
421
+ const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(row);
422
+ if (header === null)
423
+ return failedDiff(UNPARSED_PROBLEM);
424
+ let oldLine = Number(header[1]);
425
+ let newLine = Number(header[3]);
426
+ let oldLeft = header[2] === undefined ? 1 : Number(header[2]);
427
+ let newLeft = header[4] === undefined ? 1 : Number(header[4]);
428
+ const body = [row];
429
+ while (oldLeft > 0 || newLeft > 0) {
430
+ const next = rows[++i];
431
+ if (next === undefined || next === "")
432
+ return failedDiff(incompleteProblem(current.path));
433
+ if (next === NO_NEWLINE_MARKER) {
434
+ body.push(next);
435
+ continue;
436
+ }
437
+ let parsed;
438
+ if (next.startsWith("+"))
439
+ parsed = { kind: "add", text: next.slice(1) };
440
+ else if (next.startsWith("-"))
441
+ parsed = { kind: "delete", text: next.slice(1) };
442
+ else if (next.startsWith(" "))
443
+ parsed = { kind: "context", text: next.slice(1) };
444
+ else
445
+ return failedDiff(incompleteProblem(current.path));
446
+ body.push(next);
447
+ if (parsed.kind === "add") {
448
+ lines.push({ path: current.path, line: newLine, side: "RIGHT", text: parsed.text, kind: "add" });
449
+ anchors.add(anchorKey(current.path, newLine, "RIGHT"));
450
+ newLine++;
451
+ newLeft--;
452
+ }
453
+ else if (parsed.kind === "delete") {
454
+ lines.push({ path: current.path, line: oldLine, side: "LEFT", text: parsed.text, kind: "delete" });
455
+ anchors.add(anchorKey(current.path, oldLine, "LEFT"));
456
+ oldLine++;
457
+ oldLeft--;
458
+ }
459
+ else {
460
+ lines.push({ path: current.path, line: newLine, side: "RIGHT", text: parsed.text, kind: "context" });
461
+ anchors.add(anchorKey(current.path, newLine, "RIGHT"));
462
+ anchors.add(anchorKey(current.path, oldLine, "LEFT"));
463
+ oldLine++;
464
+ newLine++;
465
+ oldLeft--;
466
+ newLeft--;
467
+ }
468
+ if (oldLeft < 0 || newLeft < 0)
469
+ return failedDiff(incompleteProblem(current.path));
470
+ }
471
+ if (rows[i + 1] === NO_NEWLINE_MARKER)
472
+ body.push(rows[++i]);
473
+ current.patch.push(...body);
474
+ current.hunks++;
475
+ continue;
476
+ }
477
+ return failedDiff(UNPARSED_PROBLEM);
478
+ }
479
+ if (files.length === 0)
480
+ return failedDiff(EMPTY_PROBLEM);
481
+ if (files.some((file) => file.special === "binary"))
482
+ return failedDiff(BINARY_PROBLEM);
483
+ if (files.some((file) => file.special === "link"))
484
+ return failedDiff(LINK_PROBLEM);
485
+ if (files.some((file) => file.path === ""))
486
+ return failedDiff(UNPARSED_PROBLEM);
487
+ return { files, lines, anchors, problem: null };
488
+ }
489
+ function listedFiles(listing) {
490
+ if (!Array.isArray(listing))
491
+ return null;
492
+ const entries = [];
493
+ for (const row of listing) {
494
+ if (!isGhObject(row))
495
+ return null;
496
+ const filename = textField(row, "filename");
497
+ if (filename === null)
498
+ return null;
499
+ entries.push({
500
+ filename,
501
+ previous: textField(row, "previous_filename"),
502
+ patch: textField(row, "patch"),
503
+ additions: numberField(row, "additions"),
504
+ deletions: numberField(row, "deletions"),
505
+ });
506
+ }
507
+ return entries;
508
+ }
509
+ function normalizePatch(text) {
510
+ const rows = text.replace(/\r\n/g, "\n").split("\n").filter((row) => row !== NO_NEWLINE_MARKER);
511
+ while (rows.length > 0 && rows[rows.length - 1] === "")
512
+ rows.pop();
513
+ return rows;
514
+ }
515
+ /**
516
+ * The changed lines of one file's patch and the lines it lets a review anchor to,
517
+ * read from hunk headers and rows alone. Two patches of the same change can
518
+ * differ in how GitHub splits hunks and how much context each shows; they cannot
519
+ * differ in these changed rows without describing a different change.
520
+ */
521
+ function patchChanges(path, rows) {
522
+ const changes = [];
523
+ const anchors = new Set();
524
+ let oldLine = 0;
525
+ let newLine = 0;
526
+ let inHunk = false;
527
+ for (const row of rows) {
528
+ const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(row);
529
+ if (header !== null) {
530
+ oldLine = Number(header[1]);
531
+ newLine = Number(header[2]);
532
+ inHunk = true;
533
+ continue;
534
+ }
535
+ if (!inHunk || row === NO_NEWLINE_MARKER)
536
+ continue;
537
+ if (row.startsWith("+")) {
538
+ changes.push(`+${newLine}:${row.slice(1)}`);
539
+ anchors.add(anchorKey(path, newLine++, "RIGHT"));
540
+ }
541
+ else if (row.startsWith("-")) {
542
+ changes.push(`-${oldLine}:${row.slice(1)}`);
543
+ anchors.add(anchorKey(path, oldLine++, "LEFT"));
544
+ }
545
+ else if (row.startsWith(" ")) {
546
+ anchors.add(anchorKey(path, newLine++, "RIGHT"));
547
+ anchors.add(anchorKey(path, oldLine++, "LEFT"));
548
+ }
549
+ else {
550
+ return null;
551
+ }
552
+ }
553
+ return { changes, anchors };
554
+ }
555
+ function samePatch(raw, patch) {
556
+ const rows = normalizePatch(patch);
557
+ const expected = raw.filter((row) => row !== NO_NEWLINE_MARKER);
558
+ return rows.length === expected.length && rows.every((row, index) => row === expected[index]);
559
+ }
560
+ /**
561
+ * The diff GitHub serves for the pull request must agree, file by file, with
562
+ * the paginated file list of that same pull request. Anything else means one of
563
+ * the two answers was truncated, so no report is built at all.
564
+ */
565
+ /** Whether a patch adds and removes exactly the lines GitHub counts for the file. */
566
+ function countsAgree(rows, listed) {
567
+ if (listed.additions === null || listed.deletions === null)
568
+ return false;
569
+ let added = 0;
570
+ let removed = 0;
571
+ let inHunk = false;
572
+ for (const row of rows) {
573
+ if (row.startsWith("@@"))
574
+ inHunk = true;
575
+ else if (inHunk && row.startsWith("+"))
576
+ added += 1;
577
+ else if (inHunk && row.startsWith("-"))
578
+ removed += 1;
579
+ }
580
+ return added === listed.additions && removed === listed.deletions;
581
+ }
582
+ function checkCoverage(files, listing) {
583
+ const narrowed = new Map();
584
+ const fail = (problem) => ({ problem, narrowed });
585
+ const entries = listedFiles(listing);
586
+ if (entries === null)
587
+ return fail("GitHub did not return a usable file list for this pull request.");
588
+ const used = new Set();
589
+ for (const file of files) {
590
+ const index = entries.findIndex((entry, position) => !used.has(position)
591
+ && (entry.filename === file.path || entry.filename === file.oldPath || entry.previous === file.oldPath));
592
+ if (index === -1) {
593
+ return fail(`GitHub's diff includes ${file.path}, but its file list for the same pull request does not.`);
594
+ }
595
+ used.add(index);
596
+ const patch = entries[index].patch;
597
+ if (patch === null) {
598
+ // GitHub omits the patch of a large file from its file list. The diff is
599
+ // still accepted for it when its added and removed line counts equal the
600
+ // counts GitHub reports for that file; anything else is incomplete.
601
+ if (file.patch.length > 0 && !countsAgree(file.patch, entries[index]))
602
+ return fail(incompleteProblem(file.path));
603
+ continue;
604
+ }
605
+ if (!samePatch(file.patch, patch)) {
606
+ const fromDiff = patchChanges(file.path, file.patch);
607
+ const fromList = patchChanges(file.path, normalizePatch(patch));
608
+ const sameChanges = fromDiff !== null && fromList !== null
609
+ && fromDiff.changes.length === fromList.changes.length
610
+ && fromDiff.changes.every((change, position) => change === fromList.changes[position]);
611
+ if (!sameChanges) {
612
+ return fail(`GitHub's file list and its diff disagree about ${file.path}; refusing to review an unverified diff.`);
613
+ }
614
+ narrowed.set(file.path, fromList.anchors);
615
+ }
616
+ }
617
+ for (let index = 0; index < entries.length; index++) {
618
+ if (!used.has(index)) {
619
+ return fail(`GitHub's file list includes ${entries[index].filename}, but the diff for the same pull request does not.`);
620
+ }
621
+ }
622
+ return { problem: null, narrowed };
623
+ }
624
+ function validateCanonicalDiff(rawDiff, listing) {
625
+ const parsed = parseCanonicalDiff(rawDiff);
626
+ // A rejected parse already carries no files, lines, or anchors of its own.
627
+ if (parsed.problem !== null)
628
+ return parsed;
629
+ const coverage = checkCoverage(parsed.files, listing);
630
+ if (coverage.problem !== null)
631
+ return failedDiff(coverage.problem);
632
+ if (coverage.narrowed.size === 0)
633
+ return parsed;
634
+ // Same changes, different hunk split or context: a comment may only land on a
635
+ // line GitHub's own patch for that file shows, which includes every change.
636
+ const anchors = new Set();
637
+ for (const anchor of parsed.anchors) {
638
+ const path = anchor.slice(0, anchor.indexOf("\u0000"));
639
+ const allowed = coverage.narrowed.get(path);
640
+ if (allowed === undefined || allowed.has(anchor))
641
+ anchors.add(anchor);
642
+ }
643
+ return { ...parsed, anchors };
644
+ }
645
+ /* ------------------------------------------------------------------ session */
646
+ const VIEW_FIELDS = [
647
+ "url", "id", "number", "state", "title", "body", "baseRefOid", "headRefOid", "isCrossRepository",
648
+ "headRepository", "headRepositoryOwner", "baseRefName", "headRefName",
649
+ ].join(",");
650
+ const MAX_REVIEW_CHARS = 65_536;
651
+ const MAX_COMMENT_CHARS = 65_536;
652
+ const MAX_COMMENTS = 100;
653
+ const ACCEPT_JSON = "Accept: application/vnd.github+json";
654
+ const SHA_PATTERN = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/i;
655
+ /** Control characters except TAB; CR and LF are rejected separately with their own message. */
656
+ const CONTROL_CHARACTERS = /[^\P{Cc}\t]/u;
657
+ /** Binds host, repository, number, base, head, state, and the exact diff bytes. */
658
+ function snapshotId(meta, rawDiff) {
659
+ const diffHash = createHash("sha256").update(rawDiff).digest("hex");
660
+ return createHash("sha256").update(JSON.stringify([meta.owner, meta.repo, meta.number, meta.baseSha, meta.headSha, meta.state, meta.title, meta.body, diffHash])).digest("hex");
661
+ }
662
+ function apiPath(coordinates, suffix) {
663
+ return `repos/${coordinates.owner}/${coordinates.repo}/pulls/${coordinates.number}${suffix === "" ? "" : `/${suffix}`}`;
664
+ }
665
+ /**
666
+ * A fork head still reviews fine: comments anchor to the base repository's diff
667
+ * and `commit_id` is the head commit. Say so, because the reviewer is looking
668
+ * at a commit that does not exist in the base repository.
669
+ */
670
+ function forkNote(meta) {
671
+ if (!meta.crossRepository)
672
+ return null;
673
+ return `Head branch "${meta.headRefName}" lives in ${meta.headRepository}, a fork; this review anchors to the diff in ${meta.owner}/${meta.repo}.`;
674
+ }
675
+ /**
676
+ * One connected review session. Every method serializes behind the previous
677
+ * one, so an in-flight submission can never be double-fired.
678
+ */
679
+ export class ConnectedReview {
680
+ runner;
681
+ timeoutMs;
682
+ version = null;
683
+ identity;
684
+ snapshot;
685
+ diff;
686
+ anchors = new Set();
687
+ status = "empty";
688
+ receipt;
689
+ message;
690
+ previewed;
691
+ /** Review ids that already existed before this session's write attempt. */
692
+ preexistingReviewIds = new Set();
693
+ queue = Promise.resolve();
694
+ constructor(deps = {}) {
695
+ this.runner = deps.runner ?? ghCliRunner();
696
+ this.timeoutMs = deps.timeoutMs ?? GH_TIMEOUT_MS;
697
+ }
698
+ getState() {
699
+ return {
700
+ identity: this.identity,
701
+ snapshot: this.snapshot,
702
+ status: this.status,
703
+ receipt: this.receipt,
704
+ message: this.message,
705
+ };
706
+ }
707
+ /** Canonical patch from the last successfully bound snapshot, for static export. */
708
+ getDiff() {
709
+ if (this.diff === undefined)
710
+ throw new Error("Load a pull request before exporting its diff.");
711
+ return this.diff;
712
+ }
713
+ async load(url) {
714
+ return this.serialize(() => this.runLoad(url));
715
+ }
716
+ async preview(input) {
717
+ return this.serialize(() => this.runPreview(input));
718
+ }
719
+ async submit(input) {
720
+ return this.serialize(() => this.runSubmit(input));
721
+ }
722
+ async reconcile() {
723
+ return this.serialize(() => this.runReconcile());
724
+ }
725
+ /** Run `work` after everything already queued; failures never break the chain. */
726
+ serialize(work) {
727
+ const result = this.queue.then(work, work);
728
+ this.queue = result.then(() => undefined, () => undefined);
729
+ return result;
730
+ }
731
+ async call(args, stdin) {
732
+ try {
733
+ return await this.runner.run({ args, stdin, timeoutMs: this.timeoutMs });
734
+ }
735
+ catch (error) {
736
+ throw classifyGhFailure(error instanceof Error ? error : new Error("gh failed"), "request");
737
+ }
738
+ }
739
+ async ensureGhVersion() {
740
+ if (this.version !== null)
741
+ return;
742
+ const result = await this.call(["--version"]);
743
+ const match = /gh version (\d+)\.(\d+)\.(\d+)/.exec(result.stdout);
744
+ if (match === null) {
745
+ // Fail closed: an unreadable version cannot be proven new enough for the
746
+ // flags and JSON fields every later call depends on.
747
+ throw new Error(`diffninja could not read the installed gh version. Install gh ${MIN_GH_VERSION} or newer and try again.`);
748
+ }
749
+ const version = `${match[1]}.${match[2]}.${match[3]}`;
750
+ if (compareVersions(version, MIN_GH_VERSION) < 0) {
751
+ throw new Error(`The installed gh is ${version}, older than the supported ${MIN_GH_VERSION}. Update gh and try again.`);
752
+ }
753
+ this.version = version;
754
+ }
755
+ /** The identity of the credential gh actually uses, re-read before each submit. */
756
+ async readIdentity() {
757
+ const result = await this.call(["api", "--hostname", GITHUB_HOST, "-H", ACCEPT_JSON, "user"]);
758
+ const payload = parseJson(result.stdout, "account");
759
+ if (!isGhObject(payload))
760
+ throw new Error("gh did not report a GitHub account. Run `gh auth login --hostname github.com`.");
761
+ const login = textField(payload, "login");
762
+ const id = numberField(payload, "id");
763
+ if (login === null || id === null)
764
+ throw new Error("gh did not report a GitHub account. Run `gh auth login --hostname github.com`.");
765
+ const identity = { login, id };
766
+ const bound = this.identity;
767
+ if (bound !== undefined && (bound.login.toLowerCase() !== login.toLowerCase() || bound.id !== id)) {
768
+ throw new Error(`gh is now authenticated as ${login}, but this session started as ${bound.login}. Refusing to mix accounts; restart diffninja after fixing gh auth.`);
769
+ }
770
+ return identity;
771
+ }
772
+ async readMetadata(target) {
773
+ const result = await this.call(["pr", "view", target.url, "--json", VIEW_FIELDS]);
774
+ const payload = parseJson(result.stdout, "pull request");
775
+ if (!isGhObject(payload))
776
+ throw new Error("gh returned an unreadable pull request.");
777
+ const url = textField(payload, "url");
778
+ const state = textField(payload, "state");
779
+ const number = numberField(payload, "number");
780
+ const baseSha = textField(payload, "baseRefOid");
781
+ const headSha = textField(payload, "headRefOid");
782
+ const baseRefName = textField(payload, "baseRefName");
783
+ const headRefName = textField(payload, "headRefName");
784
+ const crossRepository = booleanField(payload, "isCrossRepository");
785
+ const headRepository = textField(objectField(payload, "headRepository"), "nameWithOwner");
786
+ const headRepositoryOwner = textField(objectField(payload, "headRepositoryOwner"), "login");
787
+ if (url === null || state === null || number === null || baseSha === null || headSha === null
788
+ || baseRefName === null || headRefName === null || crossRepository === null
789
+ || headRepository === null || headRepositoryOwner === null) {
790
+ throw new Error(`gh did not return the pull request fields diffninja needs. Update gh to ${MIN_GH_VERSION} or newer.`);
791
+ }
792
+ if (!SHA_PATTERN.test(baseSha) || !SHA_PATTERN.test(headSha))
793
+ throw new Error("GitHub reported a malformed base or head commit for this pull request.");
794
+ const canonical = parsePullUrl(url);
795
+ if (canonical.number !== number)
796
+ throw new Error("GitHub's pull request metadata is inconsistent; refusing to review it.");
797
+ return {
798
+ url: canonical.url,
799
+ owner: canonical.owner,
800
+ repo: canonical.repo,
801
+ number: canonical.number,
802
+ state,
803
+ title: textField(payload, "title") ?? "",
804
+ body: textField(payload, "body") ?? "",
805
+ baseSha,
806
+ headSha,
807
+ baseRefName,
808
+ headRefName,
809
+ crossRepository,
810
+ headRepository,
811
+ headRepositoryOwner,
812
+ };
813
+ }
814
+ async readCanonicalDiff(meta) {
815
+ const result = await this.call(["api", "--hostname", GITHUB_HOST, "-H", "Accept: application/vnd.github.diff", apiPath(meta, "")]);
816
+ return result.stdout;
817
+ }
818
+ async readChangedFiles(meta) {
819
+ const result = await this.call([
820
+ "api", "--hostname", GITHUB_HOST, "--paginate", "-H", ACCEPT_JSON, `${apiPath(meta, "files")}?per_page=100`,
821
+ ]);
822
+ return decodePages(result.stdout, "file list");
823
+ }
824
+ /** Every review row of a pull request, across pages. */
825
+ async readReviews(coordinates) {
826
+ const result = await this.call([
827
+ "api", "--hostname", GITHUB_HOST, "--paginate", "-H", ACCEPT_JSON, `${apiPath(coordinates, "reviews")}?per_page=100`,
828
+ ]);
829
+ return decodePages(result.stdout, "review list");
830
+ }
831
+ async readReviewIds(coordinates) {
832
+ const ids = new Set();
833
+ for (const row of await this.readReviews(coordinates)) {
834
+ const id = isGhObject(row) ? numberField(row, "id") : null;
835
+ if (id !== null)
836
+ ids.add(id);
837
+ }
838
+ return ids;
839
+ }
840
+ /**
841
+ * Metadata plus the verified canonical diff; the only source of anchors. The
842
+ * metadata is read again after the diff and the file list, so a pull request
843
+ * that moved mid-read is rejected instead of producing a snapshot that mixes
844
+ * two revisions.
845
+ */
846
+ async readSnapshot(target) {
847
+ const meta = await this.readMetadata(target);
848
+ if (meta.owner.toLowerCase() !== target.owner.toLowerCase() || meta.repo.toLowerCase() !== target.repo.toLowerCase() || meta.number !== target.number) {
849
+ throw new Error(`gh resolved that URL to ${meta.url}, a different pull request. Load that URL explicitly.`);
850
+ }
851
+ const rawDiff = await this.readCanonicalDiff(meta);
852
+ const listing = await this.readChangedFiles(meta);
853
+ const after = await this.readMetadata(target);
854
+ if (after.baseSha !== meta.baseSha || after.headSha !== meta.headSha || after.state !== meta.state || after.title !== meta.title || after.body !== meta.body) {
855
+ throw new Error("The pull request changed while diffninja was reading it. Load it again.");
856
+ }
857
+ const canonical = validateCanonicalDiff(rawDiff, listing);
858
+ const snapshot = {
859
+ id: snapshotId(meta, rawDiff),
860
+ url: meta.url,
861
+ owner: meta.owner,
862
+ repo: meta.repo,
863
+ number: meta.number,
864
+ baseSha: meta.baseSha,
865
+ headSha: meta.headSha,
866
+ state: meta.state,
867
+ title: meta.title,
868
+ body: meta.body,
869
+ lines: canonical.lines,
870
+ };
871
+ const reason = canonical.problem ?? reviewabilityProblem(meta);
872
+ if (reason !== null)
873
+ snapshot.unavailableReason = reason;
874
+ return { snapshot, diff: rawDiff, anchors: canonical.anchors, note: reason === null ? forkNote(meta) : null };
875
+ }
876
+ /**
877
+ * One PR and one account per session: the first pull request that loads at all
878
+ * is bound, reviewable or not, because a session is about one pull request and
879
+ * switching targets silently is exactly what this mode refuses to do. Only a
880
+ * refresh of that same pull request is allowed.
881
+ */
882
+ assertLoadable(target) {
883
+ if (this.status === "submitting")
884
+ throw new Error("A submission is in flight; wait for it to finish.");
885
+ if (this.status === "unknown")
886
+ throw new Error("The previous submission is unresolved. Reconcile this session before loading anything else.");
887
+ if (this.status === "submitted")
888
+ throw new Error("A review was already submitted in this session. Restart diffninja to review another pull request.");
889
+ const bound = this.snapshot;
890
+ if (bound === undefined)
891
+ return;
892
+ if (bound.owner.toLowerCase() === target.owner.toLowerCase() && bound.repo.toLowerCase() === target.repo.toLowerCase() && bound.number === target.number)
893
+ return;
894
+ const state = bound.unavailableReason === undefined ? "" : " (not reviewable)";
895
+ throw new Error(`This session is bound to ${bound.owner}/${bound.repo}#${bound.number}${state}. Restart diffninja to review a different pull request.`);
896
+ }
897
+ async runLoad(url) {
898
+ const target = parsePullUrl(url);
899
+ this.assertLoadable(target);
900
+ await this.ensureGhVersion();
901
+ const identity = await this.readIdentity();
902
+ const read = await this.readSnapshot(target);
903
+ this.identity = identity;
904
+ this.snapshot = read.snapshot;
905
+ this.diff = read.diff;
906
+ this.anchors = read.anchors;
907
+ this.previewed = undefined;
908
+ this.message = read.snapshot.unavailableReason ?? read.note ?? undefined;
909
+ this.status = read.snapshot.unavailableReason === undefined ? "ready" : "empty";
910
+ return this.getState();
911
+ }
912
+ runPreview(input) {
913
+ const payload = this.buildPayload(input);
914
+ this.previewed = { serialized: JSON.stringify(payload) };
915
+ return Promise.resolve(payload);
916
+ }
917
+ async runSubmit(input) {
918
+ const receipt = this.receipt;
919
+ if (receipt !== undefined) {
920
+ throw new Error(`A review was already submitted in this session (${receipt.url}).`);
921
+ }
922
+ if (this.status === "unknown") {
923
+ throw new Error("The previous submission is unresolved. Reconcile before submitting anything else.");
924
+ }
925
+ const payload = this.buildPayload(input);
926
+ const previewed = this.previewed;
927
+ if (previewed === undefined)
928
+ throw new Error("Preview the review before submitting it.");
929
+ if (previewed.serialized !== JSON.stringify(payload)) {
930
+ throw new Error("The review changed after the last preview. Preview it again before submitting.");
931
+ }
932
+ const snapshot = this.snapshot;
933
+ if (snapshot === undefined)
934
+ throw new Error("Load a pull request before submitting a review.");
935
+ const target = { owner: snapshot.owner, repo: snapshot.repo, number: snapshot.number, url: snapshot.url };
936
+ const identity = await this.readIdentity();
937
+ const read = await this.readSnapshot(target);
938
+ if (read.snapshot.unavailableReason !== undefined) {
939
+ throw new Error(`This pull request cannot be reviewed any more: ${read.snapshot.unavailableReason}`);
940
+ }
941
+ if (read.snapshot.id !== snapshot.id) {
942
+ throw new Error("The pull request changed since it was loaded. Reload it, then preview the review again.");
943
+ }
944
+ this.identity = identity;
945
+ this.anchors = read.anchors;
946
+ // Read the reviews that exist BEFORE the write. Reconciliation is then able
947
+ // to tell this session's review from an identical review left by an earlier
948
+ // one, which would otherwise look like proof that this write landed.
949
+ this.preexistingReviewIds = await this.readReviewIds(snapshot);
950
+ await this.postReview(snapshot, payload);
951
+ return this.getState();
952
+ }
953
+ async postReview(snapshot, payload) {
954
+ this.status = "submitting";
955
+ this.message = "Submitting the review to GitHub.";
956
+ let stdout;
957
+ try {
958
+ const result = await this.runner.run({
959
+ args: ["api", "--hostname", GITHUB_HOST, "--input", "-", "-X", "POST", "-H", ACCEPT_JSON, apiPath(snapshot, "reviews")],
960
+ stdin: JSON.stringify(payload),
961
+ timeoutMs: this.timeoutMs,
962
+ });
963
+ stdout = result.stdout;
964
+ }
965
+ catch (error) {
966
+ const failure = asGhCommandError(error instanceof Error ? error : new Error("gh failed"));
967
+ const status = httpStatus(failure);
968
+ // A 4xx answer (except a 408 timeout) proves the write never landed, so
969
+ // the review may be fixed and retried, and GitHub's own reason (for
970
+ // example a self-approval refusal) is safe to show. Everything else
971
+ // (timeout, connection loss, 5xx) is ambiguous: the session goes unknown
972
+ // and is never retried.
973
+ const definite = (status !== null && status >= 400 && status < 500 && status !== 408)
974
+ || /was not found|unknown flag|unknown shorthand|unknown command/i.test(failure.message);
975
+ if (definite) {
976
+ this.status = "ready";
977
+ this.message = "GitHub rejected the review; nothing was submitted.";
978
+ this.previewed = undefined;
979
+ throw classifyGhFailure(failure, "review");
980
+ }
981
+ this.status = "unknown";
982
+ this.message = "The submission did not finish, so GitHub may or may not have created the review. diffninja will not resubmit it; check the pull request, then reconcile.";
983
+ throw new Error(this.message);
984
+ }
985
+ const receipt = readReceipt(stdout, payload);
986
+ if (receipt === null) {
987
+ this.status = "unknown";
988
+ this.message = "GitHub answered the submission with something unreadable, so diffninja cannot tell whether the review was created. Reconcile before doing anything else.";
989
+ throw new Error(this.message);
990
+ }
991
+ this.receipt = receipt;
992
+ this.previewed = undefined;
993
+ this.status = "submitted";
994
+ this.message = `Review submitted to ${snapshot.url}.`;
995
+ }
996
+ /**
997
+ * Resolve an ambiguous write. This never retries and never assumes: it looks
998
+ * for a review that matches this session exactly and was not already present
999
+ * before the attempt, so an identical older review cannot pass as proof.
1000
+ */
1001
+ async runReconcile() {
1002
+ const snapshot = this.snapshot;
1003
+ if (snapshot === undefined)
1004
+ throw new Error("Load a pull request before reconciling.");
1005
+ const receipt = this.receipt;
1006
+ if (receipt !== undefined) {
1007
+ this.message = `A review was already submitted in this session (${receipt.url}).`;
1008
+ return this.getState();
1009
+ }
1010
+ if (this.status !== "unknown") {
1011
+ throw new Error("There is nothing to reconcile: no submission is unresolved in this session.");
1012
+ }
1013
+ const previewed = this.previewed;
1014
+ if (previewed === undefined)
1015
+ throw new Error("This session has no prepared review to compare against GitHub.");
1016
+ const identity = await this.readIdentity();
1017
+ const meta = await this.readMetadata({ owner: snapshot.owner, repo: snapshot.repo, number: snapshot.number, url: snapshot.url });
1018
+ const payload = parsePayload(previewed.serialized);
1019
+ const found = await this.findMatchingReview(meta, payload, identity);
1020
+ if (found === null) {
1021
+ this.message = "GitHub shows no review matching this session's attempt, but that is not proof the write failed. diffninja will not resubmit; check the pull request in your browser before doing anything else.";
1022
+ return this.getState();
1023
+ }
1024
+ this.receipt = found;
1025
+ this.status = "submitted";
1026
+ this.message = `Review found on GitHub at ${found.url}; nothing else will be submitted this session.`;
1027
+ return this.getState();
1028
+ }
1029
+ async findMatchingReview(meta, payload, identity) {
1030
+ const wanted = REVIEW_STATE[payload.event];
1031
+ for (const entry of await this.readReviews(meta)) {
1032
+ if (!isGhObject(entry))
1033
+ continue;
1034
+ const id = numberField(entry, "id");
1035
+ const url = textField(entry, "html_url");
1036
+ const state = textField(entry, "state");
1037
+ const commitId = textField(entry, "commit_id");
1038
+ const login = textField(objectField(entry, "user"), "login");
1039
+ const body = textField(entry, "body") ?? "";
1040
+ if (id === null || url === null || state === null || commitId === null || login === null)
1041
+ continue;
1042
+ // A review that already existed cannot be evidence that this attempt landed.
1043
+ if (this.preexistingReviewIds.has(id))
1044
+ continue;
1045
+ if (login.toLowerCase() !== identity.login.toLowerCase() || state !== wanted || commitId !== payload.commit_id)
1046
+ continue;
1047
+ if (body.replace(/\r\n/g, "\n") !== payload.body.replace(/\r\n/g, "\n"))
1048
+ continue;
1049
+ const comments = await this.readReviewComments(meta, id);
1050
+ if (comments !== null && sameCommentSet(comments, payload.comments)) {
1051
+ return { id, url, state, commitId };
1052
+ }
1053
+ }
1054
+ return null;
1055
+ }
1056
+ async readReviewComments(meta, reviewId) {
1057
+ const result = await this.call([
1058
+ "api", "--hostname", GITHUB_HOST, "--paginate", "-H", ACCEPT_JSON, `${apiPath(meta, `reviews/${reviewId}/comments`)}?per_page=100`,
1059
+ ]);
1060
+ const comments = [];
1061
+ for (const row of decodePages(result.stdout, "review comment list")) {
1062
+ if (!isGhObject(row))
1063
+ return null;
1064
+ const path = textField(row, "path");
1065
+ const side = textField(row, "side");
1066
+ const body = textField(row, "body");
1067
+ const line = numberField(row, "line") ?? numberField(row, "original_line");
1068
+ if (path === null || body === null || line === null)
1069
+ return null;
1070
+ if (side !== "LEFT" && side !== "RIGHT")
1071
+ return null;
1072
+ comments.push({ path, line, side, body });
1073
+ }
1074
+ return comments;
1075
+ }
1076
+ /**
1077
+ * The only payload a submit can ever send is the previewed one, so both the
1078
+ * anchor check and the preview check run before anything is written.
1079
+ */
1080
+ buildPayload(input) {
1081
+ const snapshot = this.snapshot;
1082
+ if (this.status !== "ready" || snapshot === undefined || snapshot.unavailableReason !== undefined) {
1083
+ throw new Error(this.message ?? "Load a reviewable pull request before preparing a review.");
1084
+ }
1085
+ if (!isGhText(input.snapshotId) || input.snapshotId !== snapshot.id) {
1086
+ throw new Error("This review belongs to a different version of the pull request. Reload it and try again.");
1087
+ }
1088
+ const event = input.event;
1089
+ if (event !== "COMMENT" && event !== "APPROVE" && event !== "REQUEST_CHANGES") {
1090
+ throw new Error("Choose one review event: COMMENT, APPROVE, or REQUEST_CHANGES.");
1091
+ }
1092
+ const body = input.body;
1093
+ if (!isGhText(body))
1094
+ throw new Error("Send the review text as a string.");
1095
+ if (body.length > MAX_REVIEW_CHARS)
1096
+ throw new Error(`Review text is limited to ${MAX_REVIEW_CHARS} characters.`);
1097
+ if (event === "REQUEST_CHANGES" && body.trim() === "") {
1098
+ throw new Error("Requesting changes needs review text that says what must change.");
1099
+ }
1100
+ const rows = input.comments;
1101
+ if (!Array.isArray(rows))
1102
+ throw new Error("Send review comments as an array.");
1103
+ if (rows.length > MAX_COMMENTS)
1104
+ throw new Error(`A review carries at most ${MAX_COMMENTS} comments.`);
1105
+ const comments = rows.map((comment) => this.validateComment(comment));
1106
+ if (body.trim() === "" && comments.length === 0) {
1107
+ throw new Error("Add review text or at least one comment before submitting.");
1108
+ }
1109
+ return { commit_id: snapshot.headSha, event, body, comments };
1110
+ }
1111
+ /**
1112
+ * A comment is accepted only when it names a line of this pull request's diff
1113
+ * and carries plain single-line text: the transport casts the request body, so
1114
+ * shape is established here rather than assumed.
1115
+ */
1116
+ validateComment(comment) {
1117
+ if (comment === null || comment === undefined || Object.getPrototypeOf(comment) !== Object.prototype) {
1118
+ throw new Error("Every review comment must be an object with path, line, side, and body.");
1119
+ }
1120
+ const path = comment.path;
1121
+ if (!isGhText(path) || path === "")
1122
+ throw new Error("Every comment needs a file path from the pull request diff.");
1123
+ if (CONTROL_CHARACTERS.test(path))
1124
+ throw new Error(`The comment path ${sanitizeText(path)} contains control characters.`);
1125
+ const line = comment.line;
1126
+ if (!Number.isSafeInteger(line) || line <= 0)
1127
+ throw new Error(`The comment on ${path} needs a positive diff line number.`);
1128
+ const side = comment.side;
1129
+ if (side !== "LEFT" && side !== "RIGHT")
1130
+ throw new Error(`The comment on ${path}:${line} needs side LEFT or RIGHT.`);
1131
+ const body = comment.body;
1132
+ if (!isGhText(body) || body.trim() === "")
1133
+ throw new Error(`The comment on ${path}:${line} needs text.`);
1134
+ if (/[\r\n]/.test(body))
1135
+ throw new Error(`The comment on ${path}:${line} must be a single line of text.`);
1136
+ if (CONTROL_CHARACTERS.test(body))
1137
+ throw new Error(`The comment on ${path}:${line} contains control characters.`);
1138
+ if (body.length > MAX_COMMENT_CHARS)
1139
+ throw new Error(`The comment on ${path}:${line} exceeds ${MAX_COMMENT_CHARS} characters.`);
1140
+ if (this.anchors.has(anchorKey(path, line, side)))
1141
+ return { path, line, side, body };
1142
+ throw new Error(`The comment on ${path}:${line} (${side}) does not match a line of this pull request's diff. Comment on an added line on the right, a deleted line on the left, or a context line.`);
1143
+ }
1144
+ }
1145
+ function compareVersions(left, right) {
1146
+ const a = left.split(".").map(Number);
1147
+ const b = right.split(".").map(Number);
1148
+ for (let index = 0; index < Math.max(a.length, b.length); index++) {
1149
+ const difference = (a[index] ?? 0) - (b[index] ?? 0);
1150
+ if (difference !== 0)
1151
+ return difference < 0 ? -1 : 1;
1152
+ }
1153
+ return 0;
1154
+ }
1155
+ /** Why a loaded pull request cannot take a review, or null when it is reviewable. */
1156
+ function reviewabilityProblem(meta) {
1157
+ if (meta.state === "CLOSED")
1158
+ return "This pull request is closed, so GitHub will not accept a new review.";
1159
+ if (meta.state === "MERGED")
1160
+ return "This pull request is merged, so GitHub will not accept a new review.";
1161
+ if (meta.state !== "OPEN")
1162
+ return `This pull request is ${meta.state.toLowerCase()}, so GitHub will not accept a new review.`;
1163
+ if (meta.headRepository === "" || meta.headRepositoryOwner === "") {
1164
+ return "The head repository of this pull request is unavailable, so its diff cannot be reviewed.";
1165
+ }
1166
+ return null;
1167
+ }
1168
+ /** GitHub's review state for each event diffninja can send. */
1169
+ const REVIEW_STATE = {
1170
+ COMMENT: "COMMENTED",
1171
+ APPROVE: "APPROVED",
1172
+ REQUEST_CHANGES: "CHANGES_REQUESTED",
1173
+ };
1174
+ /**
1175
+ * A receipt is only trusted when GitHub echoes the commit this review was
1176
+ * submitted for and the state that event produces; anything else stays unknown.
1177
+ */
1178
+ function readReceipt(stdout, payload) {
1179
+ const decoded = tryParseJson(stdout);
1180
+ if (!isGhObject(decoded))
1181
+ return null;
1182
+ const id = numberField(decoded, "id");
1183
+ const url = textField(decoded, "html_url");
1184
+ const state = textField(decoded, "state");
1185
+ const commitId = textField(decoded, "commit_id");
1186
+ if (id === null || url === null || state === null || commitId === null)
1187
+ return null;
1188
+ if (commitId !== payload.commit_id || state !== REVIEW_STATE[payload.event])
1189
+ return null;
1190
+ return { id, url, state, commitId };
1191
+ }
1192
+ function tryParseJson(text) {
1193
+ try {
1194
+ // SAFETY: JSON.parse produces the JSON data model, which GhJson describes
1195
+ // exactly; the caller revalidates every field it reads.
1196
+ return JSON.parse(text);
1197
+ }
1198
+ catch {
1199
+ return null;
1200
+ }
1201
+ }
1202
+ /** Re-read the payload the caller previewed; the session wrote nothing else. */
1203
+ function parsePayload(serialized) {
1204
+ const decoded = tryParseJson(serialized);
1205
+ if (decoded === null || !isGhObject(decoded))
1206
+ throw new Error("The prepared review could not be read back.");
1207
+ const commitId = textField(decoded, "commit_id");
1208
+ const event = textField(decoded, "event");
1209
+ const body = textField(decoded, "body");
1210
+ const rows = decoded["comments"];
1211
+ if (commitId === null || body === null || !Array.isArray(rows))
1212
+ throw new Error("The prepared review could not be read back.");
1213
+ if (event !== "COMMENT" && event !== "APPROVE" && event !== "REQUEST_CHANGES")
1214
+ throw new Error("The prepared review could not be read back.");
1215
+ const comments = [];
1216
+ for (const row of rows) {
1217
+ if (!isGhObject(row))
1218
+ throw new Error("The prepared review could not be read back.");
1219
+ const path = textField(row, "path");
1220
+ const line = numberField(row, "line");
1221
+ const side = textField(row, "side");
1222
+ const text = textField(row, "body");
1223
+ if (path === null || line === null || text === null || (side !== "LEFT" && side !== "RIGHT")) {
1224
+ throw new Error("The prepared review could not be read back.");
1225
+ }
1226
+ comments.push({ path, line, side, body: text });
1227
+ }
1228
+ return { commit_id: commitId, event, body, comments };
1229
+ }
1230
+ /** Compare two comment multisets ignoring order and CRLF differences. */
1231
+ function sameCommentSet(left, right) {
1232
+ if (left.length !== right.length)
1233
+ return false;
1234
+ const remaining = [...right];
1235
+ for (const comment of left) {
1236
+ const index = remaining.findIndex((candidate) => candidate.path === comment.path
1237
+ && candidate.line === comment.line
1238
+ && candidate.side === comment.side
1239
+ && candidate.body.replace(/\r\n/g, "\n") === comment.body.replace(/\r\n/g, "\n"));
1240
+ if (index === -1)
1241
+ return false;
1242
+ remaining.splice(index, 1);
1243
+ }
1244
+ return true;
1245
+ }