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

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
@@ -41,6 +41,7 @@ npx @jphutchins/code-review <subcommand>
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
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
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 |
44
45
  | `render` | Render the sticky-comment markdown from findings + usage + prices |
45
46
  | `inline` | Build the GitHub reviews `comments[]` payload from findings + diff (in-diff validation; strays demote to the summary) |
46
47
  | `adapt` | Map a native agent-CLI result envelope onto the abstract result envelope (`src/schema.ts`) |
@@ -71,6 +72,13 @@ in [templates/](templates/). See [docs/adapters.md](docs/adapters.md) for the ad
71
72
  introducing PR won't review itself — then open a test PR.
72
73
  5. First run: consider `egress-policy: audit` to discover the real allowlist, then switch to `block`
73
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.
74
82
 
75
83
  Every model knob is committed step `env` on the workflow's triage and review steps — models,
76
84
  efforts, the subagent model, and the tier aliases, right where each is consumed — edited and
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { Ajv2020 } from 'ajv/dist/2020.js';
9
9
  import _addFormats from 'ajv-formats';
10
10
  import * as t from 'io-ts';
11
11
  import { execFile } from 'child_process';
12
+ import { PathReporter } from 'io-ts/lib/PathReporter.js';
12
13
 
13
14
  // src/cost.ts
14
15
  var defaultWarn = (message) => {
@@ -460,12 +461,12 @@ var sumTranscriptUsage = (entries) => {
460
461
  },
461
462
  { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
462
463
  );
463
- const models = [...summed.totals].map(([model, t6]) => ({
464
+ const models = [...summed.totals].map(([model, t7]) => ({
464
465
  model,
465
- input_tokens: t6.input,
466
- output_tokens: t6.output,
467
- cache_read_tokens: t6.cacheRead,
468
- cache_write_tokens: t6.cacheWrite
466
+ input_tokens: t7.input,
467
+ output_tokens: t7.output,
468
+ cache_read_tokens: t7.cacheRead,
469
+ cache_write_tokens: t7.cacheWrite
469
470
  }));
470
471
  const bounds = entries.reduce(
471
472
  (acc, entry) => {
@@ -802,7 +803,7 @@ var writesToDraft = (toolName, toolInput, draftPath) => {
802
803
  const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
803
804
  if (WRITE_TOOLS.has(toolName)) {
804
805
  const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
805
- if (typeof fp === "string" && targets.some((t6) => fp === t6 || basename(fp) === basename(t6)))
806
+ if (typeof fp === "string" && targets.some((t7) => fp === t7 || basename(fp) === basename(t7)))
806
807
  return true;
807
808
  }
808
809
  if (toolName === "Bash") {
@@ -898,9 +899,9 @@ var parseWallMs = (raw) => {
898
899
  };
899
900
  var parseEpochSecMs = (raw) => {
900
901
  if (raw === void 0) return null;
901
- const t6 = raw.trim();
902
- if (!/^\d+$/.test(t6)) return null;
903
- const n = Number.parseInt(t6, 10);
902
+ const t7 = raw.trim();
903
+ if (!/^\d+$/.test(t7)) return null;
904
+ const n = Number.parseInt(t7, 10);
904
905
  return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
905
906
  };
906
907
  var anchoredElapsedMs = (src) => {
@@ -1054,18 +1055,27 @@ var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
1054
1055
  });
1055
1056
 
1056
1057
  // src/pr.ts
1058
+ var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha}";
1059
+ var parseCandidates = (stdout) => parseJsonl(stdout);
1057
1060
  var fetchPrCandidates = async (repo, headSha, ghApi) => {
1058
- const stdout = await ghApi([
1059
- `repos/${repo}/commits/${headSha}/pulls`,
1060
- "--jq",
1061
- ".[] | {number: .number, state: .state, headRef: .head.ref}"
1062
- ]);
1063
- return stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
1061
+ const direct = parseCandidates(
1062
+ await ghApi([`repos/${repo}/commits/${headSha}/pulls`, "--jq", CANDIDATE_JQ])
1063
+ );
1064
+ if (direct.length > 0) return direct;
1065
+ const open = parseCandidates(
1066
+ await ghApi([
1067
+ `repos/${repo}/pulls?state=open&per_page=100`,
1068
+ "--paginate",
1069
+ "--jq",
1070
+ CANDIDATE_JQ
1071
+ ])
1072
+ );
1073
+ return open.filter((c) => c.headSha === headSha);
1064
1074
  };
1065
1075
  var resolvePr = (candidates, headBranch) => {
1066
1076
  if (candidates.length === 0) return { kind: "none" };
1067
1077
  const scoped = candidates.length > 1 && headBranch ? candidates.filter((c) => c.headRef === headBranch) : candidates;
1068
- const chosen = scoped[0] ?? candidates[0];
1078
+ const chosen = scoped.find((c) => c.state === "open") ?? scoped[0] ?? candidates[0];
1069
1079
  if (chosen === void 0) return { kind: "none" };
1070
1080
  return chosen.state === "open" ? { kind: "open", prNumber: chosen.number } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
1071
1081
  };
@@ -1315,8 +1325,8 @@ var priorBotCommentIds = (raw, botLogin) => {
1315
1325
  const nodes = conn?.nodes;
1316
1326
  if (!Array.isArray(nodes)) return { ids: [], truncated };
1317
1327
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1318
- const ids = nodes.flatMap((t6) => {
1319
- const cnodes = t6.comments?.nodes;
1328
+ const ids = nodes.flatMap((t7) => {
1329
+ const cnodes = t7.comments?.nodes;
1320
1330
  return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1321
1331
  });
1322
1332
  return { ids, truncated };
@@ -1764,6 +1774,54 @@ var react = async (input, ghApi = runGhApi) => {
1764
1774
  await removeReactions(input.repo, input.commentId, input.remove, ghApi);
1765
1775
  }
1766
1776
  };
1777
+ var RunCodec = t.type({
1778
+ id: t.number,
1779
+ name: t.union([t.string, t.null]),
1780
+ status: t.union([t.string, t.null]),
1781
+ conclusion: t.union([t.string, t.null]),
1782
+ run_number: t.number
1783
+ });
1784
+ var RunsCodec = t.type({ workflow_runs: t.array(RunCodec) });
1785
+ var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
1786
+ const stdout = await ghApi([`repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`]);
1787
+ const decoded = RunsCodec.decode(JSON.parse(stdout));
1788
+ if (decoded._tag === "Left")
1789
+ throw new Error(
1790
+ `workflow runs for ${headSha} did not match the expected shape: ${PathReporter.report(decoded).join("; ")}`
1791
+ );
1792
+ const runs = decoded.right.workflow_runs;
1793
+ const latest = runs.filter((r) => r.name === workflowName).reduce(
1794
+ (best, r) => best === null || r.run_number > best.run_number ? r : best,
1795
+ null
1796
+ );
1797
+ return {
1798
+ run: latest === null ? null : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
1799
+ seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
1800
+ };
1801
+ };
1802
+ var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi, sleep: defaultSleep, elapsedMs: monotonicElapsed() }) => {
1803
+ const poll = async () => {
1804
+ const { run, seenNames } = await resolveCiRun(repo, headSha, options.workflowName, deps.ghApi);
1805
+ if (run !== null && run.status === "completed")
1806
+ return { kind: "concluded", conclusion: run.conclusion ?? "unknown", runId: run.id };
1807
+ if (deps.elapsedMs() >= options.timeoutMs)
1808
+ return { kind: "timed-out", runId: run === null ? null : run.id, seenNames };
1809
+ await deps.sleep(options.pollIntervalMs);
1810
+ return poll();
1811
+ };
1812
+ return poll();
1813
+ };
1814
+ var defaultSleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
1815
+ var monotonicElapsed = () => {
1816
+ const start = Date.now();
1817
+ return () => Date.now() - start;
1818
+ };
1819
+ var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
1820
+ ci_conclusion=${outcome.conclusion}
1821
+ ci_run_id=${String(outcome.runId)}
1822
+ ` : `ci_settled=false
1823
+ ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
1824
+ `;
1767
1825
  var renderOutputs = (result) => {
1768
1826
  switch (result.kind) {
1769
1827
  case "skip":
@@ -3302,6 +3360,10 @@ var requirePositiveInt = (raw, flag) => {
3302
3360
  const n = Number.parseInt(raw, 10);
3303
3361
  return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
3304
3362
  };
3363
+ var requireWallMs = (raw, flag, fallback) => {
3364
+ const ms = parseWallMs(raw || fallback);
3365
+ return ms === null ? fail(`${flag} must be a duration like 30m, 15s, or 1h (got "${raw ?? ""}")`) : ms;
3366
+ };
3305
3367
  var parseCommandCmd = defineCommand({
3306
3368
  meta: {
3307
3369
  name: "parse-command",
@@ -3394,6 +3456,46 @@ var reactCmd = defineCommand({
3394
3456
  );
3395
3457
  }
3396
3458
  });
3459
+ var awaitCiCmd = defineCommand({
3460
+ meta: {
3461
+ name: "await-ci",
3462
+ 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)."
3463
+ },
3464
+ args: {
3465
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3466
+ "head-sha": {
3467
+ type: "string",
3468
+ description: "PR head SHA to find the CI run for (resolved from the trusted PR number)",
3469
+ required: true
3470
+ },
3471
+ "ci-workflow": {
3472
+ type: "string",
3473
+ description: 'CI workflow name to wait for \u2014 the name: of your CI workflow (default: "CI")'
3474
+ },
3475
+ timeout: {
3476
+ type: "string",
3477
+ description: "Give up waiting after this wall (default: 30m)"
3478
+ },
3479
+ "poll-interval": {
3480
+ type: "string",
3481
+ description: "How often to re-check the run status (default: 15s)"
3482
+ }
3483
+ },
3484
+ run: async ({ args }) => {
3485
+ const workflowName = args["ci-workflow"] || "CI";
3486
+ const outcome = await awaitCiConclusion(args.repo, args["head-sha"], {
3487
+ workflowName,
3488
+ pollIntervalMs: requireWallMs(args["poll-interval"], "--poll-interval", "15s"),
3489
+ timeoutMs: requireWallMs(args.timeout, "--timeout", "30m")
3490
+ });
3491
+ process.stderr.write(
3492
+ outcome.kind === "concluded" ? `code-review await-ci: CI run ${String(outcome.runId)} ("${workflowName}") concluded "${outcome.conclusion}"
3493
+ ` : `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."}
3494
+ `
3495
+ );
3496
+ process.stdout.write(renderCiOutputs(outcome));
3497
+ }
3498
+ });
3397
3499
  var main = defineCommand({
3398
3500
  meta: {
3399
3501
  name: "code-review",
@@ -3404,6 +3506,7 @@ var main = defineCommand({
3404
3506
  gather: gatherCmd,
3405
3507
  "parse-command": parseCommandCmd,
3406
3508
  react: reactCmd,
3509
+ "await-ci": awaitCiCmd,
3407
3510
  render: renderCmd,
3408
3511
  inline: inlineCmd,
3409
3512
  post: postCmd,