@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 +8 -0
- package/dist/index.js +121 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
464
|
+
const models = [...summed.totals].map(([model, t7]) => ({
|
|
464
465
|
model,
|
|
465
|
-
input_tokens:
|
|
466
|
-
output_tokens:
|
|
467
|
-
cache_read_tokens:
|
|
468
|
-
cache_write_tokens:
|
|
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((
|
|
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
|
|
902
|
-
if (!/^\d+$/.test(
|
|
903
|
-
const n = Number.parseInt(
|
|
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
|
|
1059
|
-
`repos/${repo}/commits/${headSha}/pulls`,
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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((
|
|
1319
|
-
const cnodes =
|
|
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,
|