@jphutchins/code-review 0.1.0-alpha.24 → 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
@@ -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) => {
@@ -1315,8 +1316,8 @@ var priorBotCommentIds = (raw, botLogin) => {
1315
1316
  const nodes = conn?.nodes;
1316
1317
  if (!Array.isArray(nodes)) return { ids: [], truncated };
1317
1318
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1318
- const ids = nodes.flatMap((t6) => {
1319
- const cnodes = t6.comments?.nodes;
1319
+ const ids = nodes.flatMap((t7) => {
1320
+ const cnodes = t7.comments?.nodes;
1320
1321
  return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1321
1322
  });
1322
1323
  return { ids, truncated };
@@ -1764,6 +1765,54 @@ var react = async (input, ghApi = runGhApi) => {
1764
1765
  await removeReactions(input.repo, input.commentId, input.remove, ghApi);
1765
1766
  }
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
+ `;
1767
1816
  var renderOutputs = (result) => {
1768
1817
  switch (result.kind) {
1769
1818
  case "skip":
@@ -3302,6 +3351,10 @@ var requirePositiveInt = (raw, flag) => {
3302
3351
  const n = Number.parseInt(raw, 10);
3303
3352
  return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
3304
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
+ };
3305
3358
  var parseCommandCmd = defineCommand({
3306
3359
  meta: {
3307
3360
  name: "parse-command",
@@ -3394,6 +3447,46 @@ var reactCmd = defineCommand({
3394
3447
  );
3395
3448
  }
3396
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
+ });
3397
3490
  var main = defineCommand({
3398
3491
  meta: {
3399
3492
  name: "code-review",
@@ -3404,6 +3497,7 @@ var main = defineCommand({
3404
3497
  gather: gatherCmd,
3405
3498
  "parse-command": parseCommandCmd,
3406
3499
  react: reactCmd,
3500
+ "await-ci": awaitCiCmd,
3407
3501
  render: renderCmd,
3408
3502
  inline: inlineCmd,
3409
3503
  post: postCmd,