@jphutchins/code-review 0.1.0-alpha.7 → 0.1.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,8 +25,9 @@ the security boundary, and the cost.
25
25
  ## The CLI
26
26
 
27
27
  The npm package is the **deterministic commenter** — the presentation and posting layer that no
28
- model should do, plus the adapter glue between an agent CLI's native output and the spec's abstract
29
- envelope ([SPEC §6.1](SPEC.md#61-result-envelope)).
28
+ model should do, plus the adapter glue between an agent CLI's native output and the abstract result
29
+ envelope defined in [`src/schema.ts`](src/schema.ts) (the deliverable of
30
+ [SPEC §3.2](SPEC.md#32-the-deliverable)).
30
31
 
31
32
  ```sh
32
33
  npm install -g @jphutchins/code-review
@@ -40,7 +41,7 @@ npx @jphutchins/code-review <subcommand>
40
41
  | `gather` | Resolve the PR from the CI head SHA and gather the review inputs (diff with git-diff fallback, PR context, prior bot review, failing-job logs) into the workspace for the agent |
41
42
  | `render` | Render the sticky-comment markdown from findings + usage + prices |
42
43
  | `inline` | Build the GitHub reviews `comments[]` payload from findings + diff (in-diff validation; strays demote to the summary) |
43
- | `adapt` | Map a native agent-CLI result envelope onto the abstract SPEC §6.1 envelope |
44
+ | `adapt` | Map a native agent-CLI result envelope onto the abstract result envelope (`src/schema.ts`) |
44
45
  | `extract` | Recover findings/triage JSON from a native envelope via the deterministic extraction ladder |
45
46
  | `validate-patches` | Validate each finding's `patch` against the real PR-head file, aligning the finding's line range to it and keeping the patch (projected into a suggestion at render time), or dropping the patch |
46
47
  | `cost` | Recompute USD cost from the envelope's per-model token counts + a price map |
@@ -63,16 +64,17 @@ in [templates/](templates/). See [docs/adapters.md](docs/adapters.md) for the ad
63
64
  letting the CLI pick where your key gets sent.
64
65
  3. Commit `.github/prices.json` (fork [schema/prices.example.json](schema/prices.example.json) and
65
66
  fill in your provider's per-token rates) — without it the cost footer renders **$0**
66
- ([SPEC §6.2](SPEC.md#62-price-map)).
67
+ ([SPEC §4.4](SPEC.md#44-required-controls-conformance)).
67
68
  4. Merge to your default branch first — `workflow_run` only fires from the default branch, so the
68
69
  introducing PR won't review itself — then open a test PR.
69
70
  5. First run: consider `egress-policy: audit` to discover the real allowlist, then switch to `block`
70
- ([SPEC §8.4](SPEC.md#84-egress-allowlist)).
71
+ ([SPEC Appendix A](SPEC.md#appendix-a--reference-realization-github-actions-non-normative)).
71
72
 
72
73
  Every model knob is committed step `env` on the workflow's triage and review steps — models,
73
74
  efforts, the subagent model, and the tier aliases, right where each is consumed — edited and
74
- PR-reviewed like the rest of the file ([SPEC §8.5](SPEC.md#85-model-backend-env)). Only the backend
75
- endpoint is a per-repo **Actions variable** (`API_BASE_URL`, required, no default); pointing it at
75
+ PR-reviewed like the rest of the file (implementation detail the spec deliberately leaves to this
76
+ repo see [SPEC Appendix A](SPEC.md#appendix-a--reference-realization-github-actions-non-normative)).
77
+ Only the backend endpoint is a per-repo **Actions variable** (`API_BASE_URL`, required, no default); pointing it at
76
78
  another provider requires adding that provider's API host to the workflow's egress allowlist in the
77
79
  same reviewed PR.
78
80
 
@@ -87,8 +89,8 @@ links to:
87
89
  downstream tool) SHOULD base64-decode and parse that marker rather than parse the comment's prose.
88
90
  Embedding in the comment (rather than only linking the artifact) keeps the pointer from expiring
89
91
  with artifact retention; when the encoded findings are too large to embed, the sticky falls back to
90
- a `<!-- code-review:findings-json <url> -->` link marker instead
91
- ([SPEC §5.1 item 7](SPEC.md#51-sticky-summary-comment)).
92
+ a `<!-- code-review:findings-json <url> -->` link marker instead — the shared serializer is
93
+ [`src/surface.ts`](src/surface.ts).
92
94
  - **`code-review-transcript`** — the full Claude Code session transcripts for the triage and review
93
95
  phases. This is advisory/auditability only: it is never read by the comment job and never affects
94
96
  what gets posted.
package/dist/index.js CHANGED
@@ -89,6 +89,10 @@ var drop = (reason) => ({
89
89
  kind: "drop",
90
90
  reason
91
91
  });
92
+ var keep = (reason) => ({
93
+ kind: "keep",
94
+ reason
95
+ });
92
96
  var parseHunk = (patch) => {
93
97
  const rawLines = patch.split("\n");
94
98
  const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
@@ -128,10 +132,12 @@ var validatePatch = (patch, fileLines) => {
128
132
  const removedCount = body.filter((l) => l.kind === "removed").length;
129
133
  const addedCount = body.filter((l) => l.kind === "added").length;
130
134
  if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
131
- if (removedCount === 0) return drop("pure insertion has no range to anchor a suggestion");
135
+ if (removedCount === 0) {
136
+ return keep("pure insertion applies cleanly but has no removed range to anchor a suggestion");
137
+ }
132
138
  const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
133
139
  if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
134
- return { startLine: firstRemoved, endLine: lastRemoved };
140
+ return { kind: "anchored", startLine: firstRemoved, endLine: lastRemoved };
135
141
  };
136
142
  var patchToSuggestion = (patch) => {
137
143
  const parsed = parseHunk(patch);
@@ -164,18 +170,28 @@ var severityEmoji = (s) => {
164
170
  };
165
171
  var EMBED_LIMIT = 4e4;
166
172
  var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
167
- var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => {
168
- const b64 = Buffer.from(JSON.stringify(findings), "utf-8").toString("base64");
173
+ var encodeMarker = (document, jsonUrl, limit) => {
174
+ const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
169
175
  const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
170
176
  return marker ? `${AGENTS_STOP_DIRECTIVE}
171
177
  ${marker}` : "";
172
178
  };
179
+ var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
180
+ var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
173
181
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
174
182
  var projectPatch = (patch) => {
175
- if (patch === null || patch === void 0) return { kind: "none" };
183
+ if (patch === void 0) return { kind: "none" };
176
184
  const lowered = patchToSuggestion(patch);
177
185
  return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
178
186
  };
187
+ var formatConfidence = (n) => n.toFixed(2);
188
+ var reviewBodyPointer = (headSha, stickyUrl, marker) => {
189
+ const sha7 = headSha.slice(0, 7);
190
+ const linkLine = stickyUrl ? `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the [summary comment](${stickyUrl}) for the verdict, walkthrough, and cost.` : `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the summary comment for the verdict, walkthrough, and cost.`;
191
+ return marker ? `${marker}
192
+
193
+ ${linkLine}` : linkLine;
194
+ };
179
195
 
180
196
  // src/render.ts
181
197
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
@@ -215,6 +231,7 @@ var render = (input) => {
215
231
  modelNames,
216
232
  testReport: input.testReport ?? null,
217
233
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
234
+ postedAt: input.postedAt ?? "",
218
235
  severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
219
236
  strays: (input.strays ?? []).map(sanitizeFinding),
220
237
  inlineDisposition: input.inlineDisposition ?? null,
@@ -243,7 +260,8 @@ var render = (input) => {
243
260
  return `\u2753 ${v}`;
244
261
  }
245
262
  },
246
- severityEmoji
263
+ severityEmoji,
264
+ formatConfidence
247
265
  });
248
266
  };
249
267
  var key = (path, line) => `${path}:${String(line)}`;
@@ -308,6 +326,7 @@ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
308
326
  ...f,
309
327
  patchProjection: projectPatch(f.patch),
310
328
  severityEmoji,
329
+ formatConfidence,
311
330
  modelsText,
312
331
  jsonUrl: jsonUrl ?? null,
313
332
  findingsPointer: pointer
@@ -319,8 +338,8 @@ var buildInlineComments = (findings, diff, context) => {
319
338
  const { inDiff, strays } = partitionFindings(findings, index);
320
339
  const eta = new Eta({ autoTrim: false });
321
340
  const modelsText = formatModels(models);
322
- const pointer = context.findingsPointer ?? (fullFindings ? findingsPointer(fullFindings, jsonUrl) : "");
323
341
  const comments = inDiff.map((f) => {
342
+ const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
324
343
  const comment = {
325
344
  path: f.path,
326
345
  line: f.end_line,
@@ -388,7 +407,7 @@ var FindingShape = t.intersection([
388
407
  code: t.string,
389
408
  code_url: t.string,
390
409
  recommendation: t.string,
391
- patch: t.union([t.string, t.null])
410
+ patch: t.string
392
411
  })
393
412
  ]);
394
413
  var EndGeStart = t.refinement(
@@ -528,6 +547,8 @@ var formatMarkdown = (md) => {
528
547
  return `${lines.join("\n").replace(/\n+$/, "")}
529
548
  `;
530
549
  };
550
+ var pad2 = (n) => String(n).padStart(2, "0");
551
+ var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
531
552
  var identity = (decoded) => decoded;
532
553
  var findingsTable = [
533
554
  {
@@ -745,11 +766,7 @@ var parseHtmlUrl = (raw) => {
745
766
  }
746
767
  };
747
768
  var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
748
- const sha7 = headSha.slice(0, 7);
749
- const linkLine = stickyUrl ? `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the [summary comment](${stickyUrl}) for the verdict, walkthrough, and cost.` : `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the summary comment for the verdict, walkthrough, and cost.`;
750
- const pointer = marker ? `${marker}
751
-
752
- ${linkLine}` : linkLine;
769
+ const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
753
770
  const body = JSON.stringify({
754
771
  body: pointer,
755
772
  commit_id: headSha,
@@ -859,6 +876,84 @@ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
859
876
  }
860
877
  }
861
878
  };
879
+ var REVIEW_THREAD_COMMENTS_QUERY = "query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage}nodes{comments(first:100){nodes{id isMinimized author{login} originalCommit{oid}}}}}}}}";
880
+ var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
881
+ var supersededCommentId = (c, headSha, logins) => {
882
+ if (typeof c !== "object" || c === null) return null;
883
+ const o = c;
884
+ const login = o.author?.login;
885
+ const oid = o.originalCommit?.oid;
886
+ return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) && typeof oid === "string" && oid !== headSha ? o.id : null;
887
+ };
888
+ var supersededBotCommentIds = (raw, headSha, botLogin) => {
889
+ let parsed;
890
+ try {
891
+ parsed = JSON.parse(raw);
892
+ } catch {
893
+ return { ids: [], truncated: false };
894
+ }
895
+ const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
896
+ const truncated = conn?.pageInfo?.hasNextPage === true;
897
+ const nodes = conn?.nodes;
898
+ if (!Array.isArray(nodes)) return { ids: [], truncated };
899
+ const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
900
+ const ids = nodes.flatMap((t4) => {
901
+ const cnodes = t4.comments?.nodes;
902
+ return Array.isArray(cnodes) ? cnodes.map((c) => supersededCommentId(c, headSha, logins)).filter((id) => id !== null) : [];
903
+ });
904
+ return { ids, truncated };
905
+ };
906
+ var minimizeSupersededComments = async (repo, prNumber, headSha, botLogin, ghApi) => {
907
+ const slash = repo.indexOf("/");
908
+ if (slash <= 0) return;
909
+ const owner = repo.slice(0, slash);
910
+ const name = repo.slice(slash + 1);
911
+ let raw;
912
+ try {
913
+ raw = await ghApi([
914
+ "graphql",
915
+ "-f",
916
+ `query=${REVIEW_THREAD_COMMENTS_QUERY}`,
917
+ "-f",
918
+ `owner=${owner}`,
919
+ "-f",
920
+ `name=${name}`,
921
+ "-F",
922
+ `pr=${String(prNumber)}`
923
+ ]);
924
+ } catch (err) {
925
+ process.stderr.write(
926
+ `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
927
+ `
928
+ );
929
+ return;
930
+ }
931
+ const { ids, truncated } = supersededBotCommentIds(raw, headSha, botLogin);
932
+ if (truncated) {
933
+ process.stderr.write(
934
+ `Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
935
+ `
936
+ );
937
+ }
938
+ let minimized = 0;
939
+ for (const id of ids) {
940
+ try {
941
+ await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
942
+ minimized += 1;
943
+ } catch (err) {
944
+ process.stderr.write(
945
+ `Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
946
+ `
947
+ );
948
+ }
949
+ }
950
+ if (minimized > 0) {
951
+ process.stderr.write(
952
+ `Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
953
+ `
954
+ );
955
+ }
956
+ };
862
957
  var post = async (input, ghApi = runGhApi) => {
863
958
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
864
959
  const resolution = resolvePr(candidates, input.headBranch);
@@ -901,7 +996,8 @@ var post = async (input, ghApi = runGhApi) => {
901
996
  reviewedSha: input.headSha,
902
997
  effort: input.effort,
903
998
  runUrl: input.runUrl,
904
- jsonUrl: input.jsonUrl
999
+ jsonUrl: input.jsonUrl,
1000
+ postedAt: input.postedAt
905
1001
  })
906
1002
  );
907
1003
  if (isEmptyDiff(diff)) {
@@ -942,7 +1038,8 @@ var post = async (input, ghApi = runGhApi) => {
942
1038
  testReport,
943
1039
  inlineDisposition: { kind: "no-envelope" },
944
1040
  runUrl: input.runUrl,
945
- jsonUrl: input.jsonUrl
1041
+ jsonUrl: input.jsonUrl,
1042
+ postedAt: input.postedAt
946
1043
  })
947
1044
  );
948
1045
  await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
@@ -955,7 +1052,8 @@ var post = async (input, ghApi = runGhApi) => {
955
1052
  const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
956
1053
  inlineTemplate,
957
1054
  models: envelope.models.map((m) => m.model),
958
- findingsPointer: findingsMarker
1055
+ findings,
1056
+ jsonUrl: input.jsonUrl
959
1057
  });
960
1058
  const { comments, longFiles } = checkLongSuggestions(rawComments);
961
1059
  for (const wf of longFiles) {
@@ -981,7 +1079,8 @@ var post = async (input, ghApi = runGhApi) => {
981
1079
  strays,
982
1080
  runUrl: input.runUrl,
983
1081
  jsonUrl: input.jsonUrl,
984
- findingsPointer: findingsMarker
1082
+ findingsPointer: findingsMarker,
1083
+ postedAt: input.postedAt
985
1084
  };
986
1085
  const longFilesNote = longFiles.length > 0 ? `
987
1086
 
@@ -1022,6 +1121,7 @@ var post = async (input, ghApi = runGhApi) => {
1022
1121
  `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
1023
1122
  `
1024
1123
  );
1124
+ await minimizeSupersededComments(input.repo, prNumber, input.headSha, input.botLogin, ghApi);
1025
1125
  if (stickyRef !== null) {
1026
1126
  const confirmedDisposition = {
1027
1127
  kind: "posted",
@@ -1597,7 +1697,8 @@ var renderCmd = defineCommand({
1597
1697
  reviewedSha: args["reviewed-sha"],
1598
1698
  route: args.route,
1599
1699
  effort: args.effort,
1600
- testReport
1700
+ testReport,
1701
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1601
1702
  });
1602
1703
  process.stdout.write(output);
1603
1704
  }
@@ -1813,7 +1914,7 @@ var readFileLines = (path) => {
1813
1914
  }
1814
1915
  };
1815
1916
  var validateFinding = (finding, repoRoot) => {
1816
- if (finding.patch === void 0 || finding.patch === null) return finding;
1917
+ if (finding.patch === void 0) return finding;
1817
1918
  const lines = readFileLines(resolve$1(repoRoot, finding.path));
1818
1919
  if (lines === null) {
1819
1920
  process.stderr.write(
@@ -1823,19 +1924,23 @@ var validateFinding = (finding, repoRoot) => {
1823
1924
  return withoutPatch(finding);
1824
1925
  }
1825
1926
  const result = validatePatch(finding.patch, lines);
1826
- if ("kind" in result) {
1827
- process.stderr.write(
1828
- `validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
1927
+ switch (result.kind) {
1928
+ case "anchored":
1929
+ return { ...finding, start_line: result.startLine, end_line: result.endLine };
1930
+ case "keep":
1931
+ return finding;
1932
+ case "drop":
1933
+ process.stderr.write(
1934
+ `validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
1829
1935
  `
1830
- );
1831
- return withoutPatch(finding);
1936
+ );
1937
+ return withoutPatch(finding);
1832
1938
  }
1833
- return { ...finding, start_line: result.startLine, end_line: result.endLine };
1834
1939
  };
1835
1940
  var validatePatchesCmd = defineCommand({
1836
1941
  meta: {
1837
1942
  name: "validate-patches",
1838
- description: "Validate each finding's patch against the real PR-head tree, aligning the finding's range to it and keeping the patch, or dropping the patch (issue #10)"
1943
+ description: "Validate each finding's patch against the real PR-head tree, aligning the finding's range and keeping the patch when it anchors, keeping it unaligned when it's a pure insertion, or dropping it when it doesn't apply (issue #10)"
1839
1944
  },
1840
1945
  args: {
1841
1946
  findings: {
@@ -2128,7 +2233,8 @@ var postCmd = defineCommand({
2128
2233
  effort: args.effort,
2129
2234
  testReportPath: args["test-report"],
2130
2235
  runUrl: args["run-url"],
2131
- jsonUrl: args["json-url"]
2236
+ jsonUrl: args["json-url"],
2237
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
2132
2238
  });
2133
2239
  }
2134
2240
  });