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

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,9 @@ 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) |
44
+ | `await-ci` | Wait for a PR head's CI run to conclude and emit its real conclusion + run id — so an on-demand comment review routes on the CI result (success → full, failure → mechanic) instead of reviewing blind |
42
45
  | `render` | Render the sticky-comment markdown from findings + usage + prices |
43
46
  | `inline` | Build the GitHub reviews `comments[]` payload from findings + diff (in-diff validation; strays demote to the summary) |
44
47
  | `adapt` | Map a native agent-CLI result envelope onto the abstract result envelope (`src/schema.ts`) |
@@ -69,6 +72,13 @@ in [templates/](templates/). See [docs/adapters.md](docs/adapters.md) for the ad
69
72
  introducing PR won't review itself — then open a test PR.
70
73
  5. First run: consider `egress-policy: audit` to discover the real allowlist, then switch to `block`
71
74
  ([SPEC Appendix A](SPEC.md#appendix-a--reference-realization-github-actions-non-normative)).
75
+ 6. Optional — add on-demand reviews too: copy
76
+ [examples/workflows/review-on-comment.yaml](examples/workflows/review-on-comment.yaml) so a
77
+ write-access user can comment `/code-review [24m] [$1.00] <instructions>` on a PR to review it
78
+ now, with an optional per-run budget and focus. Comment before CI finishes and it waits for CI and
79
+ routes on the real result (success → full review, failure → mechanic), same as the CI trigger. See the
80
+ [ChatOps section](examples/workflows/README.md#comment--chatops-trigger-on-demand-reviews) for the
81
+ security model.
72
82
 
73
83
  Every model knob is committed step `env` on the workflow's triage and review steps — models,
74
84
  efforts, the subagent model, and the tier aliases, right where each is consumed — edited and
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';
@@ -8,6 +9,7 @@ import { Ajv2020 } from 'ajv/dist/2020.js';
8
9
  import _addFormats from 'ajv-formats';
9
10
  import * as t from 'io-ts';
10
11
  import { execFile } from 'child_process';
12
+ import { PathReporter } from 'io-ts/lib/PathReporter.js';
11
13
 
12
14
  // src/cost.ts
13
15
  var defaultWarn = (message) => {
@@ -459,12 +461,12 @@ var sumTranscriptUsage = (entries) => {
459
461
  },
460
462
  { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
461
463
  );
462
- const models = [...summed.totals].map(([model, t4]) => ({
464
+ const models = [...summed.totals].map(([model, t7]) => ({
463
465
  model,
464
- input_tokens: t4.input,
465
- output_tokens: t4.output,
466
- cache_read_tokens: t4.cacheRead,
467
- cache_write_tokens: t4.cacheWrite
466
+ input_tokens: t7.input,
467
+ output_tokens: t7.output,
468
+ cache_read_tokens: t7.cacheRead,
469
+ cache_write_tokens: t7.cacheWrite
468
470
  }));
469
471
  const bounds = entries.reduce(
470
472
  (acc, entry) => {
@@ -801,7 +803,7 @@ var writesToDraft = (toolName, toolInput, draftPath) => {
801
803
  const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
802
804
  if (WRITE_TOOLS.has(toolName)) {
803
805
  const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
804
- if (typeof fp === "string" && targets.some((t4) => fp === t4 || basename(fp) === basename(t4)))
806
+ if (typeof fp === "string" && targets.some((t7) => fp === t7 || basename(fp) === basename(t7)))
805
807
  return true;
806
808
  }
807
809
  if (toolName === "Bash") {
@@ -897,9 +899,9 @@ var parseWallMs = (raw) => {
897
899
  };
898
900
  var parseEpochSecMs = (raw) => {
899
901
  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);
902
+ const t7 = raw.trim();
903
+ if (!/^\d+$/.test(t7)) return null;
904
+ const n = Number.parseInt(t7, 10);
903
905
  return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
904
906
  };
905
907
  var anchoredElapsedMs = (src) => {
@@ -1314,8 +1316,8 @@ var priorBotCommentIds = (raw, botLogin) => {
1314
1316
  const nodes = conn?.nodes;
1315
1317
  if (!Array.isArray(nodes)) return { ids: [], truncated };
1316
1318
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1317
- const ids = nodes.flatMap((t4) => {
1318
- const cnodes = t4.comments?.nodes;
1319
+ const ids = nodes.flatMap((t7) => {
1320
+ const cnodes = t7.comments?.nodes;
1319
1321
  return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1320
1322
  });
1321
1323
  return { ids, truncated };
@@ -1579,6 +1581,238 @@ var post = async (input, ghApi = runGhApi) => {
1579
1581
  }
1580
1582
  }
1581
1583
  };
1584
+ var DURATION_RE = /^(\d+)(h|m|s)$/;
1585
+ var USD_RE = /^\$(\d+(?:\.\d+)?)$/;
1586
+ var toSeconds = (n, unit) => unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
1587
+ var stripTrigger = (body, trigger) => {
1588
+ const trimmed = body.replace(/^\s+/, "");
1589
+ if (!trimmed.startsWith(trigger)) return null;
1590
+ const after = trimmed.slice(trigger.length);
1591
+ return after === "" || /^\s/.test(after) ? after : null;
1592
+ };
1593
+ var scanLeading = (s, acc) => {
1594
+ const m = /^(\s*)(\S+)([\s\S]*)$/.exec(s);
1595
+ if (m === null) return { ...acc, rest: "" };
1596
+ const [, , token = "", tail = ""] = m;
1597
+ const dm = DURATION_RE.exec(token);
1598
+ if (dm && acc.durationSec === null)
1599
+ return scanLeading(tail, {
1600
+ ...acc,
1601
+ durationSec: toSeconds(Number.parseInt(dm[1] ?? "", 10), dm[2] ?? "s")
1602
+ });
1603
+ const um = USD_RE.exec(token);
1604
+ if (um && acc.usd === null)
1605
+ return scanLeading(tail, { ...acc, usd: Number.parseFloat(um[1] ?? "") });
1606
+ return { ...acc, rest: s };
1607
+ };
1608
+ var clampDuration = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
1609
+ value: ceiling,
1610
+ notes: [
1611
+ `requested duration ${String(requested)}s exceeds the ${String(ceiling)}s ceiling \u2014 clamped to ${String(ceiling)}s`
1612
+ ]
1613
+ } : { value: requested, notes: [] };
1614
+ var clampUsd = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
1615
+ value: ceiling,
1616
+ notes: [
1617
+ `requested $${requested.toFixed(2)} exceeds the $${ceiling.toFixed(2)} ceiling \u2014 clamped to $${ceiling.toFixed(2)}`
1618
+ ]
1619
+ } : { value: requested, notes: [] };
1620
+ var capInstructions = (text, maxLen) => text.length > maxLen ? {
1621
+ value: text.slice(0, maxLen),
1622
+ notes: [
1623
+ `instructions truncated from ${String(text.length)} to ${String(maxLen)} characters`
1624
+ ]
1625
+ } : { value: text, notes: [] };
1626
+ var parseCommandArgs = (body, options) => {
1627
+ const afterTrigger = stripTrigger(body, options.trigger);
1628
+ if (afterTrigger === null) return { kind: "not-a-command" };
1629
+ const scan = scanLeading(afterTrigger, { durationSec: null, usd: null });
1630
+ const duration = clampDuration(scan.durationSec, options.maxDurationSec);
1631
+ const usd = clampUsd(scan.usd, options.maxUsd);
1632
+ const instructions = capInstructions(scan.rest.trim(), options.maxInstructionsLen);
1633
+ return {
1634
+ kind: "command",
1635
+ args: {
1636
+ durationSec: duration.value,
1637
+ usd: usd.value,
1638
+ instructions: instructions.value,
1639
+ notes: [...duration.notes, ...usd.notes, ...instructions.notes]
1640
+ }
1641
+ };
1642
+ };
1643
+ var PrHeadCodec = t.type({
1644
+ head_sha: t.string,
1645
+ head_ref: t.string,
1646
+ head_repo: t.union([t.string, t.null]),
1647
+ state: t.string
1648
+ });
1649
+ var resolvePrHead = async (repo, prNumber, ghApi) => {
1650
+ const stdout = await ghApi([
1651
+ `repos/${repo}/pulls/${String(prNumber)}`,
1652
+ "--jq",
1653
+ "{head_sha: .head.sha, head_ref: .head.ref, head_repo: .head.repo.full_name, state: .state}"
1654
+ ]);
1655
+ const decoded = PrHeadCodec.decode(JSON.parse(stdout));
1656
+ if (decoded._tag === "Left") {
1657
+ throw new Error(`PR head for #${String(prNumber)} did not match the expected shape`);
1658
+ }
1659
+ return decoded.right;
1660
+ };
1661
+ var parseCommand = async (input, ghApi = runGhApi) => {
1662
+ const parse = parseCommandArgs(input.body, input.options);
1663
+ if (parse.kind === "not-a-command") {
1664
+ return {
1665
+ kind: "skip",
1666
+ reason: `comment does not begin with the trigger "${input.options.trigger}"`
1667
+ };
1668
+ }
1669
+ const head = await resolvePrHead(input.repo, input.prNumber, ghApi).catch(
1670
+ (err) => err instanceof Error ? err : new Error(String(err))
1671
+ );
1672
+ if (head instanceof Error) {
1673
+ return {
1674
+ kind: "skip",
1675
+ reason: `could not resolve PR #${String(input.prNumber)}: ${head.message}`
1676
+ };
1677
+ }
1678
+ if (head.state !== "open") {
1679
+ return {
1680
+ kind: "skip",
1681
+ reason: `PR #${String(input.prNumber)} is not open (state: ${head.state})`
1682
+ };
1683
+ }
1684
+ return {
1685
+ kind: "run",
1686
+ headSha: head.head_sha,
1687
+ headBranch: head.head_ref,
1688
+ headRepo: head.head_repo ?? input.repo,
1689
+ args: parse.args
1690
+ };
1691
+ };
1692
+ var safeHeredocDelim = (instructions, randomHex, attemptsLeft = 8) => {
1693
+ const candidate = `GHOUT_${randomHex()}`;
1694
+ if (!instructions.split("\n").includes(candidate)) return candidate;
1695
+ if (attemptsLeft <= 0) throw new Error("could not derive a collision-free heredoc delimiter");
1696
+ return safeHeredocDelim(instructions, randomHex, attemptsLeft - 1);
1697
+ };
1698
+ var renderCommandOutputs = (result, delim) => {
1699
+ if (result.kind === "skip") return "should_run=false\n";
1700
+ const { headSha, headBranch, headRepo, args } = result;
1701
+ return `${[
1702
+ "should_run=true",
1703
+ `head_sha=${headSha}`,
1704
+ `head_branch=${headBranch}`,
1705
+ `head_repo=${headRepo}`,
1706
+ `duration=${args.durationSec === null ? "" : `${String(args.durationSec)}s`}`,
1707
+ `usd=${args.usd === null ? "" : args.usd.toFixed(2)}`,
1708
+ `instructions<<${delim}`,
1709
+ args.instructions,
1710
+ delim
1711
+ ].join("\n")}
1712
+ `;
1713
+ };
1714
+ var REACTIONS = [
1715
+ "+1",
1716
+ "-1",
1717
+ "laugh",
1718
+ "confused",
1719
+ "heart",
1720
+ "hooray",
1721
+ "rocket",
1722
+ "eyes"
1723
+ ];
1724
+ var isReaction = (s) => REACTIONS.includes(s);
1725
+ var ReactionCodec = t.type({ id: t.number, content: t.string });
1726
+ var reactionsPath = (repo, commentId) => `repos/${repo}/issues/comments/${String(commentId)}/reactions`;
1727
+ var removeReactions = async (repo, commentId, content, ghApi) => {
1728
+ const stdout = await ghApi([
1729
+ reactionsPath(repo, commentId),
1730
+ "--paginate",
1731
+ "--jq",
1732
+ ".[] | {id, content}"
1733
+ ]);
1734
+ for (const line of stdout.split("\n").filter((l) => l.trim() !== "")) {
1735
+ const parsed = tryParseJson(line);
1736
+ const decoded = parsed.ok ? ReactionCodec.decode(parsed.value) : void 0;
1737
+ if (decoded === void 0 || decoded._tag === "Left") {
1738
+ process.stderr.write("code-review react: could not decode a reaction entry \u2014 skipping\n");
1739
+ continue;
1740
+ }
1741
+ if (decoded.right.content !== content) continue;
1742
+ await ghApi([
1743
+ "--method",
1744
+ "DELETE",
1745
+ `${reactionsPath(repo, commentId)}/${String(decoded.right.id)}`
1746
+ ]).catch(
1747
+ (err) => process.stderr.write(
1748
+ `code-review react: could not remove reaction ${String(decoded.right.id)} (${errMsg(err)}) \u2014 skipping
1749
+ `
1750
+ )
1751
+ );
1752
+ }
1753
+ };
1754
+ var react = async (input, ghApi = runGhApi) => {
1755
+ if (input.add !== void 0) {
1756
+ await ghApi([
1757
+ "--method",
1758
+ "POST",
1759
+ reactionsPath(input.repo, input.commentId),
1760
+ "-f",
1761
+ `content=${input.add}`
1762
+ ]);
1763
+ }
1764
+ if (input.remove !== void 0) {
1765
+ await removeReactions(input.repo, input.commentId, input.remove, ghApi);
1766
+ }
1767
+ };
1768
+ var RunCodec = t.type({
1769
+ id: t.number,
1770
+ name: t.union([t.string, t.null]),
1771
+ status: t.union([t.string, t.null]),
1772
+ conclusion: t.union([t.string, t.null]),
1773
+ run_number: t.number
1774
+ });
1775
+ var RunsCodec = t.type({ workflow_runs: t.array(RunCodec) });
1776
+ var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
1777
+ const stdout = await ghApi([`repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`]);
1778
+ const decoded = RunsCodec.decode(JSON.parse(stdout));
1779
+ if (decoded._tag === "Left")
1780
+ throw new Error(
1781
+ `workflow runs for ${headSha} did not match the expected shape: ${PathReporter.report(decoded).join("; ")}`
1782
+ );
1783
+ const runs = decoded.right.workflow_runs;
1784
+ const latest = runs.filter((r) => r.name === workflowName).reduce(
1785
+ (best, r) => best === null || r.run_number > best.run_number ? r : best,
1786
+ null
1787
+ );
1788
+ return {
1789
+ run: latest === null ? null : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
1790
+ seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
1791
+ };
1792
+ };
1793
+ var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi, sleep: defaultSleep, elapsedMs: monotonicElapsed() }) => {
1794
+ const poll = async () => {
1795
+ const { run, seenNames } = await resolveCiRun(repo, headSha, options.workflowName, deps.ghApi);
1796
+ if (run !== null && run.status === "completed")
1797
+ return { kind: "concluded", conclusion: run.conclusion ?? "unknown", runId: run.id };
1798
+ if (deps.elapsedMs() >= options.timeoutMs)
1799
+ return { kind: "timed-out", runId: run === null ? null : run.id, seenNames };
1800
+ await deps.sleep(options.pollIntervalMs);
1801
+ return poll();
1802
+ };
1803
+ return poll();
1804
+ };
1805
+ var defaultSleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
1806
+ var monotonicElapsed = () => {
1807
+ const start = Date.now();
1808
+ return () => Date.now() - start;
1809
+ };
1810
+ var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
1811
+ ci_conclusion=${outcome.conclusion}
1812
+ ci_run_id=${String(outcome.runId)}
1813
+ ` : `ci_settled=false
1814
+ ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
1815
+ `;
1582
1816
  var renderOutputs = (result) => {
1583
1817
  switch (result.kind) {
1584
1818
  case "skip":
@@ -3095,6 +3329,164 @@ var postCmd = defineCommand({
3095
3329
  });
3096
3330
  }
3097
3331
  });
3332
+ var requireCeilingSec = (raw) => {
3333
+ if (raw === void 0) return null;
3334
+ const ms = parseWallMs(raw);
3335
+ if (ms === null)
3336
+ return fail(`--max-duration must be a duration like 60m, 3600s, or 1h (got "${raw}")`);
3337
+ return Math.floor(ms / 1e3);
3338
+ };
3339
+ var requireCeilingUsd = (raw) => {
3340
+ if (raw === void 0) return null;
3341
+ const n = Number.parseFloat(raw.replace(/^\$/, ""));
3342
+ if (!Number.isFinite(n) || n < 0) fail(`--max-usd must be a non-negative number (got "${raw}")`);
3343
+ return n;
3344
+ };
3345
+ var requireMaxInstructions = (raw) => {
3346
+ if (raw === void 0) return 4e3;
3347
+ if (!/^\d+$/.test(raw)) fail(`--max-instructions must be a non-negative integer (got "${raw}")`);
3348
+ return Number.parseInt(raw, 10);
3349
+ };
3350
+ var requirePositiveInt = (raw, flag) => {
3351
+ const n = Number.parseInt(raw, 10);
3352
+ return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
3353
+ };
3354
+ var requireWallMs = (raw, flag, fallback) => {
3355
+ const ms = parseWallMs(raw || fallback);
3356
+ return ms === null ? fail(`${flag} must be a duration like 30m, 15s, or 1h (got "${raw ?? ""}")`) : ms;
3357
+ };
3358
+ var parseCommandCmd = defineCommand({
3359
+ meta: {
3360
+ name: "parse-command",
3361
+ 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.`
3362
+ },
3363
+ args: {
3364
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3365
+ pr: {
3366
+ type: "string",
3367
+ description: "PR number (from github.event.issue.number \u2014 trusted event data)",
3368
+ required: true
3369
+ },
3370
+ "comment-body": {
3371
+ type: "string",
3372
+ 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)"
3373
+ },
3374
+ trigger: {
3375
+ type: "string",
3376
+ description: 'Trigger token the comment must begin with (default: "/code-review")'
3377
+ },
3378
+ "max-duration": {
3379
+ type: "string",
3380
+ 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"
3381
+ },
3382
+ "max-usd": {
3383
+ type: "string",
3384
+ description: "Ceiling the requested USD budget is clamped to (e.g. 5); omit for no clamp"
3385
+ },
3386
+ "max-instructions": {
3387
+ type: "string",
3388
+ description: "Max characters of free-form instructions kept (default: 4000)"
3389
+ }
3390
+ },
3391
+ run: async ({ args }) => {
3392
+ const body = args["comment-body"] || process.env["CODE_REVIEW_COMMENT_BODY"] || "";
3393
+ const result = await parseCommand({
3394
+ repo: args.repo,
3395
+ prNumber: requirePositiveInt(args.pr, "--pr"),
3396
+ body,
3397
+ options: {
3398
+ trigger: args.trigger || "/code-review",
3399
+ maxDurationSec: requireCeilingSec(args["max-duration"]),
3400
+ maxUsd: requireCeilingUsd(args["max-usd"]),
3401
+ maxInstructionsLen: requireMaxInstructions(args["max-instructions"])
3402
+ }
3403
+ });
3404
+ if (result.kind === "skip") {
3405
+ process.stderr.write(`code-review parse-command: not running \u2014 ${result.reason}
3406
+ `);
3407
+ process.stdout.write(renderCommandOutputs(result, "UNUSED"));
3408
+ return;
3409
+ }
3410
+ for (const note of result.args.notes)
3411
+ process.stderr.write(`code-review parse-command: ${note}
3412
+ `);
3413
+ const delim = safeHeredocDelim(result.args.instructions, () => randomBytes(16).toString("hex"));
3414
+ process.stdout.write(renderCommandOutputs(result, delim));
3415
+ }
3416
+ });
3417
+ var requireReaction = (name) => isReaction(name) ? name : fail(`Unknown reaction "${name}" \u2014 one of: ${REACTIONS.join(", ")}`);
3418
+ var reactCmd = defineCommand({
3419
+ meta: {
3420
+ name: "react",
3421
+ 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."
3422
+ },
3423
+ args: {
3424
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3425
+ "comment-id": {
3426
+ type: "string",
3427
+ description: "Comment id to react on (github.event.comment.id)",
3428
+ required: true
3429
+ },
3430
+ add: { type: "string", description: `Reaction to add: ${REACTIONS.join(" | ")}` },
3431
+ remove: {
3432
+ type: "string",
3433
+ description: "Reaction (of the token owner) to remove after adding \u2014 for the \u{1F440}\u2192\u{1F680} swap"
3434
+ }
3435
+ },
3436
+ run: async ({ args }) => {
3437
+ const commentId = requirePositiveInt(args["comment-id"], "--comment-id");
3438
+ const add = args.add ? requireReaction(args.add) : void 0;
3439
+ const remove = args.remove ? requireReaction(args.remove) : void 0;
3440
+ if (add === void 0 && remove === void 0)
3441
+ fail("react: nothing to do \u2014 pass --add and/or --remove");
3442
+ await react({ repo: args.repo, commentId, add, remove }).catch(
3443
+ (err) => process.stderr.write(
3444
+ `code-review react: reaction update failed (${errMsg(err)}) \u2014 continuing (reactions are cosmetic)
3445
+ `
3446
+ )
3447
+ );
3448
+ }
3449
+ });
3450
+ var awaitCiCmd = defineCommand({
3451
+ meta: {
3452
+ name: "await-ci",
3453
+ description: "Wait for the PR head's CI workflow run to conclude, then emit its REAL conclusion + run id to $GITHUB_OUTPUT (ci_settled, ci_conclusion, ci_run_id). The on-demand comment trigger uses this so it routes on the same CI result the CI-completion trigger would \u2014 success \u2192 full review, failure \u2192 mechanic with that run's logs \u2014 instead of reviewing blind. Polls until the run completes or the timeout elapses; ci_settled=false \u21D2 no conclusive result (caller should decline to review, not guess)."
3454
+ },
3455
+ args: {
3456
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3457
+ "head-sha": {
3458
+ type: "string",
3459
+ description: "PR head SHA to find the CI run for (resolved from the trusted PR number)",
3460
+ required: true
3461
+ },
3462
+ "ci-workflow": {
3463
+ type: "string",
3464
+ description: 'CI workflow name to wait for \u2014 the name: of your CI workflow (default: "CI")'
3465
+ },
3466
+ timeout: {
3467
+ type: "string",
3468
+ description: "Give up waiting after this wall (default: 30m)"
3469
+ },
3470
+ "poll-interval": {
3471
+ type: "string",
3472
+ description: "How often to re-check the run status (default: 15s)"
3473
+ }
3474
+ },
3475
+ run: async ({ args }) => {
3476
+ const workflowName = args["ci-workflow"] || "CI";
3477
+ const outcome = await awaitCiConclusion(args.repo, args["head-sha"], {
3478
+ workflowName,
3479
+ pollIntervalMs: requireWallMs(args["poll-interval"], "--poll-interval", "15s"),
3480
+ timeoutMs: requireWallMs(args.timeout, "--timeout", "30m")
3481
+ });
3482
+ process.stderr.write(
3483
+ outcome.kind === "concluded" ? `code-review await-ci: CI run ${String(outcome.runId)} ("${workflowName}") concluded "${outcome.conclusion}"
3484
+ ` : `code-review await-ci: no run named "${workflowName}" concluded before the timeout \u2014 not reviewing.${outcome.seenNames.length > 0 ? ` Workflow names seen for this head SHA: ${outcome.seenNames.join(", ")} \u2014 check --ci-workflow matches one.` : " No workflow runs were seen for this head SHA at all."}
3485
+ `
3486
+ );
3487
+ process.stdout.write(renderCiOutputs(outcome));
3488
+ }
3489
+ });
3098
3490
  var main = defineCommand({
3099
3491
  meta: {
3100
3492
  name: "code-review",
@@ -3103,6 +3495,9 @@ var main = defineCommand({
3103
3495
  },
3104
3496
  subCommands: {
3105
3497
  gather: gatherCmd,
3498
+ "parse-command": parseCommandCmd,
3499
+ react: reactCmd,
3500
+ "await-ci": awaitCiCmd,
3106
3501
  render: renderCmd,
3107
3502
  inline: inlineCmd,
3108
3503
  post: postCmd,