@jphutchins/code-review 0.1.0-alpha.23 → 0.1.0-alpha.24

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
@@ -39,6 +39,8 @@ npx @jphutchins/code-review <subcommand>
39
39
  | --- | --- |
40
40
  | `post` | Post a complete review (inline comments + sticky summary) from findings + envelope + diff — the one-call path |
41
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 |
42
+ | `parse-command` | Resolve a PR's head from its number and parse a ChatOps trigger comment (`/code-review [24m] [$1.00] <instructions>`) into review overrides — the on-demand comment trigger |
43
+ | `react` | Add/remove a GitHub comment reaction — the ChatOps acknowledgement (👀 on receipt, 🚀 on completion) |
42
44
  | `render` | Render the sticky-comment markdown from findings + usage + prices |
43
45
  | `inline` | Build the GitHub reviews `comments[]` payload from findings + diff (in-diff validation; strays demote to the summary) |
44
46
  | `adapt` | Map a native agent-CLI result envelope onto the abstract result envelope (`src/schema.ts`) |
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from 'citty';
3
3
  import { readFileSync, writeFileSync, statSync, copyFileSync, readdirSync } from 'fs';
4
+ import { randomBytes } from 'crypto';
4
5
  import { resolve as resolve$1, join, dirname, basename, extname } from 'path';
5
6
  import { Eta } from 'eta';
6
7
  import parseDiff from 'parse-diff';
@@ -459,12 +460,12 @@ var sumTranscriptUsage = (entries) => {
459
460
  },
460
461
  { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
461
462
  );
462
- const models = [...summed.totals].map(([model, t4]) => ({
463
+ const models = [...summed.totals].map(([model, t6]) => ({
463
464
  model,
464
- input_tokens: t4.input,
465
- output_tokens: t4.output,
466
- cache_read_tokens: t4.cacheRead,
467
- cache_write_tokens: t4.cacheWrite
465
+ input_tokens: t6.input,
466
+ output_tokens: t6.output,
467
+ cache_read_tokens: t6.cacheRead,
468
+ cache_write_tokens: t6.cacheWrite
468
469
  }));
469
470
  const bounds = entries.reduce(
470
471
  (acc, entry) => {
@@ -801,7 +802,7 @@ var writesToDraft = (toolName, toolInput, draftPath) => {
801
802
  const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
802
803
  if (WRITE_TOOLS.has(toolName)) {
803
804
  const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
804
- if (typeof fp === "string" && targets.some((t4) => fp === t4 || basename(fp) === basename(t4)))
805
+ if (typeof fp === "string" && targets.some((t6) => fp === t6 || basename(fp) === basename(t6)))
805
806
  return true;
806
807
  }
807
808
  if (toolName === "Bash") {
@@ -897,9 +898,9 @@ var parseWallMs = (raw) => {
897
898
  };
898
899
  var parseEpochSecMs = (raw) => {
899
900
  if (raw === void 0) return null;
900
- const t4 = raw.trim();
901
- if (!/^\d+$/.test(t4)) return null;
902
- const n = Number.parseInt(t4, 10);
901
+ const t6 = raw.trim();
902
+ if (!/^\d+$/.test(t6)) return null;
903
+ const n = Number.parseInt(t6, 10);
903
904
  return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
904
905
  };
905
906
  var anchoredElapsedMs = (src) => {
@@ -1314,8 +1315,8 @@ var priorBotCommentIds = (raw, botLogin) => {
1314
1315
  const nodes = conn?.nodes;
1315
1316
  if (!Array.isArray(nodes)) return { ids: [], truncated };
1316
1317
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1317
- const ids = nodes.flatMap((t4) => {
1318
- const cnodes = t4.comments?.nodes;
1318
+ const ids = nodes.flatMap((t6) => {
1319
+ const cnodes = t6.comments?.nodes;
1319
1320
  return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1320
1321
  });
1321
1322
  return { ids, truncated };
@@ -1579,6 +1580,190 @@ var post = async (input, ghApi = runGhApi) => {
1579
1580
  }
1580
1581
  }
1581
1582
  };
1583
+ var DURATION_RE = /^(\d+)(h|m|s)$/;
1584
+ var USD_RE = /^\$(\d+(?:\.\d+)?)$/;
1585
+ var toSeconds = (n, unit) => unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
1586
+ var stripTrigger = (body, trigger) => {
1587
+ const trimmed = body.replace(/^\s+/, "");
1588
+ if (!trimmed.startsWith(trigger)) return null;
1589
+ const after = trimmed.slice(trigger.length);
1590
+ return after === "" || /^\s/.test(after) ? after : null;
1591
+ };
1592
+ var scanLeading = (s, acc) => {
1593
+ const m = /^(\s*)(\S+)([\s\S]*)$/.exec(s);
1594
+ if (m === null) return { ...acc, rest: "" };
1595
+ const [, , token = "", tail = ""] = m;
1596
+ const dm = DURATION_RE.exec(token);
1597
+ if (dm && acc.durationSec === null)
1598
+ return scanLeading(tail, {
1599
+ ...acc,
1600
+ durationSec: toSeconds(Number.parseInt(dm[1] ?? "", 10), dm[2] ?? "s")
1601
+ });
1602
+ const um = USD_RE.exec(token);
1603
+ if (um && acc.usd === null)
1604
+ return scanLeading(tail, { ...acc, usd: Number.parseFloat(um[1] ?? "") });
1605
+ return { ...acc, rest: s };
1606
+ };
1607
+ var clampDuration = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
1608
+ value: ceiling,
1609
+ notes: [
1610
+ `requested duration ${String(requested)}s exceeds the ${String(ceiling)}s ceiling \u2014 clamped to ${String(ceiling)}s`
1611
+ ]
1612
+ } : { value: requested, notes: [] };
1613
+ var clampUsd = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
1614
+ value: ceiling,
1615
+ notes: [
1616
+ `requested $${requested.toFixed(2)} exceeds the $${ceiling.toFixed(2)} ceiling \u2014 clamped to $${ceiling.toFixed(2)}`
1617
+ ]
1618
+ } : { value: requested, notes: [] };
1619
+ var capInstructions = (text, maxLen) => text.length > maxLen ? {
1620
+ value: text.slice(0, maxLen),
1621
+ notes: [
1622
+ `instructions truncated from ${String(text.length)} to ${String(maxLen)} characters`
1623
+ ]
1624
+ } : { value: text, notes: [] };
1625
+ var parseCommandArgs = (body, options) => {
1626
+ const afterTrigger = stripTrigger(body, options.trigger);
1627
+ if (afterTrigger === null) return { kind: "not-a-command" };
1628
+ const scan = scanLeading(afterTrigger, { durationSec: null, usd: null });
1629
+ const duration = clampDuration(scan.durationSec, options.maxDurationSec);
1630
+ const usd = clampUsd(scan.usd, options.maxUsd);
1631
+ const instructions = capInstructions(scan.rest.trim(), options.maxInstructionsLen);
1632
+ return {
1633
+ kind: "command",
1634
+ args: {
1635
+ durationSec: duration.value,
1636
+ usd: usd.value,
1637
+ instructions: instructions.value,
1638
+ notes: [...duration.notes, ...usd.notes, ...instructions.notes]
1639
+ }
1640
+ };
1641
+ };
1642
+ var PrHeadCodec = t.type({
1643
+ head_sha: t.string,
1644
+ head_ref: t.string,
1645
+ head_repo: t.union([t.string, t.null]),
1646
+ state: t.string
1647
+ });
1648
+ var resolvePrHead = async (repo, prNumber, ghApi) => {
1649
+ const stdout = await ghApi([
1650
+ `repos/${repo}/pulls/${String(prNumber)}`,
1651
+ "--jq",
1652
+ "{head_sha: .head.sha, head_ref: .head.ref, head_repo: .head.repo.full_name, state: .state}"
1653
+ ]);
1654
+ const decoded = PrHeadCodec.decode(JSON.parse(stdout));
1655
+ if (decoded._tag === "Left") {
1656
+ throw new Error(`PR head for #${String(prNumber)} did not match the expected shape`);
1657
+ }
1658
+ return decoded.right;
1659
+ };
1660
+ var parseCommand = async (input, ghApi = runGhApi) => {
1661
+ const parse = parseCommandArgs(input.body, input.options);
1662
+ if (parse.kind === "not-a-command") {
1663
+ return {
1664
+ kind: "skip",
1665
+ reason: `comment does not begin with the trigger "${input.options.trigger}"`
1666
+ };
1667
+ }
1668
+ const head = await resolvePrHead(input.repo, input.prNumber, ghApi).catch(
1669
+ (err) => err instanceof Error ? err : new Error(String(err))
1670
+ );
1671
+ if (head instanceof Error) {
1672
+ return {
1673
+ kind: "skip",
1674
+ reason: `could not resolve PR #${String(input.prNumber)}: ${head.message}`
1675
+ };
1676
+ }
1677
+ if (head.state !== "open") {
1678
+ return {
1679
+ kind: "skip",
1680
+ reason: `PR #${String(input.prNumber)} is not open (state: ${head.state})`
1681
+ };
1682
+ }
1683
+ return {
1684
+ kind: "run",
1685
+ headSha: head.head_sha,
1686
+ headBranch: head.head_ref,
1687
+ headRepo: head.head_repo ?? input.repo,
1688
+ args: parse.args
1689
+ };
1690
+ };
1691
+ var safeHeredocDelim = (instructions, randomHex, attemptsLeft = 8) => {
1692
+ const candidate = `GHOUT_${randomHex()}`;
1693
+ if (!instructions.split("\n").includes(candidate)) return candidate;
1694
+ if (attemptsLeft <= 0) throw new Error("could not derive a collision-free heredoc delimiter");
1695
+ return safeHeredocDelim(instructions, randomHex, attemptsLeft - 1);
1696
+ };
1697
+ var renderCommandOutputs = (result, delim) => {
1698
+ if (result.kind === "skip") return "should_run=false\n";
1699
+ const { headSha, headBranch, headRepo, args } = result;
1700
+ return `${[
1701
+ "should_run=true",
1702
+ `head_sha=${headSha}`,
1703
+ `head_branch=${headBranch}`,
1704
+ `head_repo=${headRepo}`,
1705
+ `duration=${args.durationSec === null ? "" : `${String(args.durationSec)}s`}`,
1706
+ `usd=${args.usd === null ? "" : args.usd.toFixed(2)}`,
1707
+ `instructions<<${delim}`,
1708
+ args.instructions,
1709
+ delim
1710
+ ].join("\n")}
1711
+ `;
1712
+ };
1713
+ var REACTIONS = [
1714
+ "+1",
1715
+ "-1",
1716
+ "laugh",
1717
+ "confused",
1718
+ "heart",
1719
+ "hooray",
1720
+ "rocket",
1721
+ "eyes"
1722
+ ];
1723
+ var isReaction = (s) => REACTIONS.includes(s);
1724
+ var ReactionCodec = t.type({ id: t.number, content: t.string });
1725
+ var reactionsPath = (repo, commentId) => `repos/${repo}/issues/comments/${String(commentId)}/reactions`;
1726
+ var removeReactions = async (repo, commentId, content, ghApi) => {
1727
+ const stdout = await ghApi([
1728
+ reactionsPath(repo, commentId),
1729
+ "--paginate",
1730
+ "--jq",
1731
+ ".[] | {id, content}"
1732
+ ]);
1733
+ for (const line of stdout.split("\n").filter((l) => l.trim() !== "")) {
1734
+ const parsed = tryParseJson(line);
1735
+ const decoded = parsed.ok ? ReactionCodec.decode(parsed.value) : void 0;
1736
+ if (decoded === void 0 || decoded._tag === "Left") {
1737
+ process.stderr.write("code-review react: could not decode a reaction entry \u2014 skipping\n");
1738
+ continue;
1739
+ }
1740
+ if (decoded.right.content !== content) continue;
1741
+ await ghApi([
1742
+ "--method",
1743
+ "DELETE",
1744
+ `${reactionsPath(repo, commentId)}/${String(decoded.right.id)}`
1745
+ ]).catch(
1746
+ (err) => process.stderr.write(
1747
+ `code-review react: could not remove reaction ${String(decoded.right.id)} (${errMsg(err)}) \u2014 skipping
1748
+ `
1749
+ )
1750
+ );
1751
+ }
1752
+ };
1753
+ var react = async (input, ghApi = runGhApi) => {
1754
+ if (input.add !== void 0) {
1755
+ await ghApi([
1756
+ "--method",
1757
+ "POST",
1758
+ reactionsPath(input.repo, input.commentId),
1759
+ "-f",
1760
+ `content=${input.add}`
1761
+ ]);
1762
+ }
1763
+ if (input.remove !== void 0) {
1764
+ await removeReactions(input.repo, input.commentId, input.remove, ghApi);
1765
+ }
1766
+ };
1582
1767
  var renderOutputs = (result) => {
1583
1768
  switch (result.kind) {
1584
1769
  case "skip":
@@ -3095,6 +3280,120 @@ var postCmd = defineCommand({
3095
3280
  });
3096
3281
  }
3097
3282
  });
3283
+ var requireCeilingSec = (raw) => {
3284
+ if (raw === void 0) return null;
3285
+ const ms = parseWallMs(raw);
3286
+ if (ms === null)
3287
+ return fail(`--max-duration must be a duration like 60m, 3600s, or 1h (got "${raw}")`);
3288
+ return Math.floor(ms / 1e3);
3289
+ };
3290
+ var requireCeilingUsd = (raw) => {
3291
+ if (raw === void 0) return null;
3292
+ const n = Number.parseFloat(raw.replace(/^\$/, ""));
3293
+ if (!Number.isFinite(n) || n < 0) fail(`--max-usd must be a non-negative number (got "${raw}")`);
3294
+ return n;
3295
+ };
3296
+ var requireMaxInstructions = (raw) => {
3297
+ if (raw === void 0) return 4e3;
3298
+ if (!/^\d+$/.test(raw)) fail(`--max-instructions must be a non-negative integer (got "${raw}")`);
3299
+ return Number.parseInt(raw, 10);
3300
+ };
3301
+ var requirePositiveInt = (raw, flag) => {
3302
+ const n = Number.parseInt(raw, 10);
3303
+ return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
3304
+ };
3305
+ var parseCommandCmd = defineCommand({
3306
+ meta: {
3307
+ name: "parse-command",
3308
+ description: `Resolve a PR's head (SHA/branch/repo) from its NUMBER via the API and parse a ChatOps trigger comment ("/code-review [24m] [$1.00] <instructions>") into $GITHUB_OUTPUT lines. The untrusted comment is parsed here in type-safe code, never in workflow bash, and the head is resolved from the trusted PR number, never from the comment text. Emits should_run=false (and nothing else) when the comment is not the trigger, the PR is closed, or resolution fails.`
3309
+ },
3310
+ args: {
3311
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3312
+ pr: {
3313
+ type: "string",
3314
+ description: "PR number (from github.event.issue.number \u2014 trusted event data)",
3315
+ required: true
3316
+ },
3317
+ "comment-body": {
3318
+ type: "string",
3319
+ description: "The comment body to parse (default: the CODE_REVIEW_COMMENT_BODY env var \u2014 the safe way to pass untrusted text without shell interpolation)"
3320
+ },
3321
+ trigger: {
3322
+ type: "string",
3323
+ description: 'Trigger token the comment must begin with (default: "/code-review")'
3324
+ },
3325
+ "max-duration": {
3326
+ type: "string",
3327
+ description: "Ceiling the requested duration is clamped to (e.g. 60m); omit for no clamp \u2014 a comment could then request an unbounded wall, so set this"
3328
+ },
3329
+ "max-usd": {
3330
+ type: "string",
3331
+ description: "Ceiling the requested USD budget is clamped to (e.g. 5); omit for no clamp"
3332
+ },
3333
+ "max-instructions": {
3334
+ type: "string",
3335
+ description: "Max characters of free-form instructions kept (default: 4000)"
3336
+ }
3337
+ },
3338
+ run: async ({ args }) => {
3339
+ const body = args["comment-body"] || process.env["CODE_REVIEW_COMMENT_BODY"] || "";
3340
+ const result = await parseCommand({
3341
+ repo: args.repo,
3342
+ prNumber: requirePositiveInt(args.pr, "--pr"),
3343
+ body,
3344
+ options: {
3345
+ trigger: args.trigger || "/code-review",
3346
+ maxDurationSec: requireCeilingSec(args["max-duration"]),
3347
+ maxUsd: requireCeilingUsd(args["max-usd"]),
3348
+ maxInstructionsLen: requireMaxInstructions(args["max-instructions"])
3349
+ }
3350
+ });
3351
+ if (result.kind === "skip") {
3352
+ process.stderr.write(`code-review parse-command: not running \u2014 ${result.reason}
3353
+ `);
3354
+ process.stdout.write(renderCommandOutputs(result, "UNUSED"));
3355
+ return;
3356
+ }
3357
+ for (const note of result.args.notes)
3358
+ process.stderr.write(`code-review parse-command: ${note}
3359
+ `);
3360
+ const delim = safeHeredocDelim(result.args.instructions, () => randomBytes(16).toString("hex"));
3361
+ process.stdout.write(renderCommandOutputs(result, delim));
3362
+ }
3363
+ });
3364
+ var requireReaction = (name) => isReaction(name) ? name : fail(`Unknown reaction "${name}" \u2014 one of: ${REACTIONS.join(", ")}`);
3365
+ var reactCmd = defineCommand({
3366
+ meta: {
3367
+ name: "react",
3368
+ description: "Add and/or remove a GitHub reaction on a PR/issue comment \u2014 the ChatOps acknowledgement (\u{1F440} on receipt, swapped to \u{1F680} on completion). Cosmetic: warns and exits 0 on any API error so a reaction never fails the job."
3369
+ },
3370
+ args: {
3371
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3372
+ "comment-id": {
3373
+ type: "string",
3374
+ description: "Comment id to react on (github.event.comment.id)",
3375
+ required: true
3376
+ },
3377
+ add: { type: "string", description: `Reaction to add: ${REACTIONS.join(" | ")}` },
3378
+ remove: {
3379
+ type: "string",
3380
+ description: "Reaction (of the token owner) to remove after adding \u2014 for the \u{1F440}\u2192\u{1F680} swap"
3381
+ }
3382
+ },
3383
+ run: async ({ args }) => {
3384
+ const commentId = requirePositiveInt(args["comment-id"], "--comment-id");
3385
+ const add = args.add ? requireReaction(args.add) : void 0;
3386
+ const remove = args.remove ? requireReaction(args.remove) : void 0;
3387
+ if (add === void 0 && remove === void 0)
3388
+ fail("react: nothing to do \u2014 pass --add and/or --remove");
3389
+ await react({ repo: args.repo, commentId, add, remove }).catch(
3390
+ (err) => process.stderr.write(
3391
+ `code-review react: reaction update failed (${errMsg(err)}) \u2014 continuing (reactions are cosmetic)
3392
+ `
3393
+ )
3394
+ );
3395
+ }
3396
+ });
3098
3397
  var main = defineCommand({
3099
3398
  meta: {
3100
3399
  name: "code-review",
@@ -3103,6 +3402,8 @@ var main = defineCommand({
3103
3402
  },
3104
3403
  subCommands: {
3105
3404
  gather: gatherCmd,
3405
+ "parse-command": parseCommandCmd,
3406
+ react: reactCmd,
3106
3407
  render: renderCmd,
3107
3408
  inline: inlineCmd,
3108
3409
  post: postCmd,