@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 +10 -0
- package/dist/index.js +406 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
464
|
+
const models = [...summed.totals].map(([model, t7]) => ({
|
|
463
465
|
model,
|
|
464
|
-
input_tokens:
|
|
465
|
-
output_tokens:
|
|
466
|
-
cache_read_tokens:
|
|
467
|
-
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
|
|
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((
|
|
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
|
|
901
|
-
if (!/^\d+$/.test(
|
|
902
|
-
const n = Number.parseInt(
|
|
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((
|
|
1318
|
-
const cnodes =
|
|
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,
|