@hone-ai/cli 1.39.0 → 1.41.0
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/hone-cli.js +174 -53
- package/lib/anthropic-response.js +58 -0
- package/lib/architect-config.js +5 -0
- package/lib/batch-follow.js +253 -16
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-limits.js +10 -0
- package/lib/bundle-paths.js +20 -3
- package/lib/call-sites.js +209 -26
- package/lib/context-reserve.js +11 -8
- package/lib/judge-provider.js +20 -11
- package/lib/operator-question.js +9 -4
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/review-diff.js +188 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/run-context.js +18 -41
- package/lib/run-report.js +94 -4
- package/lib/security-scanner.js +7 -4
- package/lib/tree-artifact.js +36 -3
- package/lib/verify-patch.js +122 -12
- package/package.json +1 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-348 — the status checks `hone setup` requires are READ from the repository's own workflows.
|
|
4
|
+
*
|
|
5
|
+
* Setup used to require `ai-code-review` on every repository. No workflow Hone serves reports a check
|
|
6
|
+
* by that name (the served reviewer job is `ai-review`, with no `name:`), and since HC-GF-337g that job
|
|
7
|
+
* runs only on `--ai-reviewer`. A required check that never reports leaves every PR waiting, and
|
|
8
|
+
* `enforce_admins: true` stops admins merging around it. So the name is now taken from the installed
|
|
9
|
+
* workflow, and required only when that workflow will run the job.
|
|
10
|
+
*
|
|
11
|
+
* GitHub names an Actions check after the job: its job-level `name:` if it has one, else its key.
|
|
12
|
+
* The parser below reads exactly that and nothing else. Two near-misses it must reject:
|
|
13
|
+
* - the workflow's top-level `name:` (column 0) — that names the workflow, not a check;
|
|
14
|
+
* - a step's `- name:` (deeper, behind a dash) — that names a step.
|
|
15
|
+
* A `name:` built from an expression (`${{ matrix.x }}`) is returned as written. GitHub reports the
|
|
16
|
+
* evaluated value, and a job with a `strategy:` reports one check per matrix leg (`ai-review (x)`),
|
|
17
|
+
* never its bare name. Neither can be REQUIRED (it would never report — the failure this module
|
|
18
|
+
* exists to prevent), so requiredChecksForSetup requires nothing for such a reviewer job. For the
|
|
19
|
+
* prune they are harmless: such a name never equals the legacy one.
|
|
20
|
+
*
|
|
21
|
+
* No YAML library: the CLI bundles none, and reviewer-optin.js reads this same file with a regex.
|
|
22
|
+
*
|
|
23
|
+
* Exports
|
|
24
|
+
* workflowCheckNames(text) → string[] the check names a workflow's jobs report
|
|
25
|
+
* reviewerCheckName(text) → string|null the name the `ai-review` job reports
|
|
26
|
+
* requiredChecksForSetup({...}) → string[] what setup requires: the reviewer check only when it runs
|
|
27
|
+
* reportedChecksInDir(dir) → string[]|null every name reported under dir; null = could not tell
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const REVIEWER_JOB_KEY = 'ai-review';
|
|
31
|
+
const SECURITY_SCAN_JOB_KEY = 'security-scan';
|
|
32
|
+
|
|
33
|
+
function unquote(v) {
|
|
34
|
+
let s = String(v).replace(/\s+#.*$/, '').trim();
|
|
35
|
+
if (s.length >= 2 && ((s[0] === '"' && s.endsWith('"')) || (s[0] === "'" && s.endsWith("'")))) {
|
|
36
|
+
s = s.slice(1, -1);
|
|
37
|
+
}
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** [{ key, name, matrix }] for each job in the workflow's top-level `jobs:` block; name is null without one. */
|
|
42
|
+
function workflowJobs(text) {
|
|
43
|
+
if (typeof text !== 'string' || !text) return [];
|
|
44
|
+
const lines = text.split(/\r?\n/);
|
|
45
|
+
const start = lines.findIndex((l) => /^jobs:\s*(#.*)?$/.test(l));
|
|
46
|
+
if (start < 0) return [];
|
|
47
|
+
const jobs = [];
|
|
48
|
+
let jobIndent = null; // the column job keys sit at
|
|
49
|
+
let propIndent = null; // the column the current job's own properties sit at
|
|
50
|
+
let current = null;
|
|
51
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
54
|
+
const indent = line.match(/^ */)[0].length;
|
|
55
|
+
if (indent === 0) break; // the next top-level key ends `jobs:`
|
|
56
|
+
if (jobIndent == null) jobIndent = indent;
|
|
57
|
+
if (indent <= jobIndent) {
|
|
58
|
+
const m = /^ *([A-Za-z0-9_-]+):\s*(#.*)?$/.exec(line);
|
|
59
|
+
current = m && indent === jobIndent ? { key: m[1], name: null, matrix: false, reusable: false } : null;
|
|
60
|
+
if (current) jobs.push(current);
|
|
61
|
+
propIndent = null;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!current) continue;
|
|
65
|
+
if (propIndent == null) propIndent = indent;
|
|
66
|
+
// Only the job's own properties count; anything deeper (steps, with:, env:) is not its name.
|
|
67
|
+
if (indent !== propIndent) continue;
|
|
68
|
+
if (/^ *strategy:/.test(line)) current.matrix = true;
|
|
69
|
+
// A job that calls a reusable workflow reports as `caller / callee`, never its own name.
|
|
70
|
+
if (/^ *uses:/.test(line)) current.reusable = true;
|
|
71
|
+
const n = current.name == null && /^ *name:\s*(.+)$/.exec(line);
|
|
72
|
+
if (n) current.name = unquote(n[1]) || null;
|
|
73
|
+
}
|
|
74
|
+
return jobs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function workflowCheckNames(text) {
|
|
78
|
+
return workflowJobs(text).map((j) => j.name || j.key);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function reviewerCheckName(text) {
|
|
82
|
+
const job = workflowJobs(text).find((j) => j.key === REVIEWER_JOB_KEY);
|
|
83
|
+
return job ? (job.name || job.key) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── on: — does a workflow run on pull requests to a branch? (HC-GF-354) ────────────────────────────
|
|
87
|
+
|
|
88
|
+
const PR_EVENTS = new Set(['pull_request', 'pull_request_target']);
|
|
89
|
+
|
|
90
|
+
/** `[a, b]` or a bare scalar → list of unquoted items; null when it is neither (an anchor, a placeholder). */
|
|
91
|
+
function flowList(v) {
|
|
92
|
+
const t = String(v).replace(/\s+#.*$/, '').trim();
|
|
93
|
+
if (!t) return null;
|
|
94
|
+
if (t.startsWith('[')) {
|
|
95
|
+
if (!t.endsWith(']')) return null;
|
|
96
|
+
const inner = t.slice(1, -1).trim();
|
|
97
|
+
return inner ? inner.split(',').map((x) => unquote(x)).filter(Boolean) : [];
|
|
98
|
+
}
|
|
99
|
+
if (/^[&*{|>]/.test(t)) return null;
|
|
100
|
+
return [unquote(t)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The pull-request triggers of a workflow: [{ event, branches, branchesIgnore, types, unreadable }], each
|
|
105
|
+
* filter null when absent and 'unreadable' when present but not a list this parser can read
|
|
106
|
+
* (`{{BRANCHES}}` before setup substitutes it, an anchor, a nested mapping). `unreadable: true` marks a
|
|
107
|
+
* trigger with anything else that can skip a PR (`paths`, `paths-ignore`, an inline mapping). Reads
|
|
108
|
+
* `on: x`, `on: [x, y]` and the block form.
|
|
109
|
+
*/
|
|
110
|
+
function workflowPullRequestTriggers(text) {
|
|
111
|
+
if (typeof text !== 'string' || !text) return [];
|
|
112
|
+
const lines = text.split(/\r?\n/);
|
|
113
|
+
const start = lines.findIndex((l) => /^["']?on["']?:/.test(l));
|
|
114
|
+
if (start < 0) return [];
|
|
115
|
+
const inline = lines[start].replace(/^["']?on["']?:/, '').replace(/\s+#.*$/, '').trim();
|
|
116
|
+
if (inline) {
|
|
117
|
+
const events = flowList(inline) || [];
|
|
118
|
+
return events.filter((e) => PR_EVENTS.has(e)).map((event) => ({ event, branches: null, branchesIgnore: null, types: null, unreadable: false }));
|
|
119
|
+
}
|
|
120
|
+
const out = [];
|
|
121
|
+
let eventIndent = null;
|
|
122
|
+
let current = null; // the PR trigger being read
|
|
123
|
+
let pending = null; // { key, indent } of a `branches:` whose list is on the lines below
|
|
124
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
125
|
+
const line = lines[i];
|
|
126
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
127
|
+
const indent = line.match(/^ */)[0].length;
|
|
128
|
+
if (indent === 0) break;
|
|
129
|
+
if (eventIndent == null) eventIndent = indent;
|
|
130
|
+
if (pending && indent >= pending.indent && /^ *- /.test(line)) {
|
|
131
|
+
const item = unquote(line.replace(/^ *- /, ''));
|
|
132
|
+
if (current[pending.key] === 'unreadable') continue;
|
|
133
|
+
if (!item || /^[&*{]/.test(item)) current[pending.key] = 'unreadable';
|
|
134
|
+
else current[pending.key].push(item);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (pending && current[pending.key] && current[pending.key].length === 0) current[pending.key] = 'unreadable';
|
|
138
|
+
pending = null;
|
|
139
|
+
if (indent === eventIndent) {
|
|
140
|
+
const m = /^ *([A-Za-z_]+):\s*(.*)$/.exec(line);
|
|
141
|
+
current = m && PR_EVENTS.has(m[1]) ? { event: m[1], branches: null, branchesIgnore: null, types: null, unreadable: false } : null;
|
|
142
|
+
if (current) {
|
|
143
|
+
// `pull_request: { branches: [x] }`, an alias, any inline value: a filter not read here.
|
|
144
|
+
const v = m[2].replace(/\s+#.*$/, '').trim();
|
|
145
|
+
if (v && v !== 'null' && v !== '~') current.unreadable = true;
|
|
146
|
+
out.push(current);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!current) continue;
|
|
151
|
+
// A path filter skips some pull requests to the branch, so a check from it is not sure to report.
|
|
152
|
+
if (/^ *(paths|paths-ignore):/.test(line)) { current.unreadable = true; continue; }
|
|
153
|
+
const f = /^ *(branches|branches-ignore|types):\s*(.*)$/.exec(line);
|
|
154
|
+
if (!f) continue;
|
|
155
|
+
const key = { branches: 'branches', 'branches-ignore': 'branchesIgnore', types: 'types' }[f[1]];
|
|
156
|
+
const rest = f[2].replace(/\s+#.*$/, '').trim();
|
|
157
|
+
if (!rest) { current[key] = []; pending = { key, indent }; continue; }
|
|
158
|
+
current[key] = flowList(rest) || 'unreadable';
|
|
159
|
+
}
|
|
160
|
+
if (pending && current && current[pending.key] && current[pending.key].length === 0) current[pending.key] = 'unreadable';
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** GitHub's branch glob, as far as it is read here: `**` anything, `*` anything but `/`. 'unreadable' otherwise. */
|
|
165
|
+
function branchMatchesPattern(pattern, branch) {
|
|
166
|
+
if (typeof pattern !== 'string' || !pattern || /[!?[\]+]/.test(pattern) || pattern.startsWith('/') || pattern.includes('{{')) {
|
|
167
|
+
return 'unreadable';
|
|
168
|
+
}
|
|
169
|
+
const re = pattern.replace(/[.^$()|\\{}]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*');
|
|
170
|
+
return new RegExp(`^${re}$`).test(branch);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function filterMatches(patterns, branch) {
|
|
174
|
+
const r = patterns.map((p) => branchMatchesPattern(p, branch));
|
|
175
|
+
if (r.includes('unreadable')) return 'unreadable';
|
|
176
|
+
return r.includes(true);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* True only when the workflow is SURE to run on a pull request to `branch`. Anything it cannot read
|
|
181
|
+
* answers false: requiring nothing is safe, requiring a check that never reports holds every PR.
|
|
182
|
+
*/
|
|
183
|
+
function workflowTriggersPullRequestTo(text, branch) {
|
|
184
|
+
if (typeof branch !== 'string' || !branch) return false;
|
|
185
|
+
return workflowPullRequestTriggers(text).some((t) => {
|
|
186
|
+
if (t.unreadable || t.branches === 'unreadable' || t.branchesIgnore === 'unreadable' || t.types === 'unreadable') return false;
|
|
187
|
+
// Only a trigger that fires when a PR is opened AND when it is pushed to reports on every PR.
|
|
188
|
+
if (Array.isArray(t.types) && !(t.types.includes('opened') && t.types.includes('synchronize'))) return false;
|
|
189
|
+
if (t.branches && t.branchesIgnore) return false; // GitHub rejects a workflow that sets both
|
|
190
|
+
if (t.branches) return filterMatches(t.branches, branch) === true;
|
|
191
|
+
if (t.branchesIgnore) return filterMatches(t.branchesIgnore, branch) === false;
|
|
192
|
+
return true;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── ci.required_checks in .pipeline-config.yml (HC-GF-352) ───────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/** The names listed under `ci: required_checks:`, flow or block form; [] when absent. */
|
|
199
|
+
function configuredRequiredChecks(configText) {
|
|
200
|
+
if (typeof configText !== 'string' || !configText) return [];
|
|
201
|
+
const lines = configText.split(/\r?\n/);
|
|
202
|
+
const start = lines.findIndex((l) => /^ci:\s*(#.*)?$/.test(l));
|
|
203
|
+
if (start < 0) return [];
|
|
204
|
+
let keyIndent = null;
|
|
205
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
206
|
+
const line = lines[i];
|
|
207
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
208
|
+
const indent = line.match(/^ */)[0].length;
|
|
209
|
+
if (indent === 0) break;
|
|
210
|
+
if (keyIndent == null) keyIndent = indent;
|
|
211
|
+
if (indent !== keyIndent) continue;
|
|
212
|
+
const m = /^ *required_checks:\s*(.*)$/.exec(line);
|
|
213
|
+
if (!m) continue;
|
|
214
|
+
const rest = m[1].replace(/\s+#.*$/, '').trim();
|
|
215
|
+
if (rest) return flowList(rest) || [];
|
|
216
|
+
const items = [];
|
|
217
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
218
|
+
const l = lines[j];
|
|
219
|
+
if (!l.trim() || /^\s*#/.test(l)) continue;
|
|
220
|
+
if (!/^ *- /.test(l) || l.match(/^ */)[0].length < keyIndent) break;
|
|
221
|
+
const v = unquote(l.replace(/^ *- /, ''));
|
|
222
|
+
if (v) items.push(v);
|
|
223
|
+
}
|
|
224
|
+
return items;
|
|
225
|
+
}
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── what setup requires ──────────────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* [{ key, name }] for the jobs that report under ONE fixed name. Not a matrix job (one check per leg,
|
|
233
|
+
* `test (18)`), not a reusable-workflow call (`caller / callee`), not a name built from an expression.
|
|
234
|
+
* Requiring nothing is always safe; requiring a name that never reports holds every PR.
|
|
235
|
+
*/
|
|
236
|
+
function fixedJobs(text) {
|
|
237
|
+
return workflowJobs(text)
|
|
238
|
+
.filter((j) => !j.matrix && !j.reusable && !(j.name || j.key).includes('${{'))
|
|
239
|
+
.map((j) => ({ key: j.key, name: j.name || j.key }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The fixed check name a served job reports, or null. */
|
|
243
|
+
function fixedJobCheck(text, key) {
|
|
244
|
+
const job = fixedJobs(text).find((j) => j.key === key);
|
|
245
|
+
return job ? job.name : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What `hone setup` requires on the protected branch, and every candidate it left out with the reason.
|
|
250
|
+
*
|
|
251
|
+
* Candidates: the served reviewer check (only when the installed workflow runs that job — HC-GF-348),
|
|
252
|
+
* the served `security-scan` check (always — HC-GF-354), and the names in `ci.required_checks`
|
|
253
|
+
* (HC-GF-352). Each is required only if a workflow reports it under a fixed name and is sure to run on
|
|
254
|
+
* pull requests to `branch`. `branch` null (not known yet): no trigger test, nothing dropped for it.
|
|
255
|
+
*
|
|
256
|
+
* @returns {{ required: string[], dropped: Array<{ name: string, reason: string }> }}
|
|
257
|
+
*/
|
|
258
|
+
function requiredChecksPlan({
|
|
259
|
+
reviewerState, reviewerWorkflowText, workflowsDir, configText, branch = null,
|
|
260
|
+
fsImpl = require('fs'), pathImpl = require('path'),
|
|
261
|
+
} = {}) {
|
|
262
|
+
const required = [];
|
|
263
|
+
const dropped = [];
|
|
264
|
+
const notOnBranch = `its workflow does not run on pull requests to \`${branch}\``;
|
|
265
|
+
const served = (name) => {
|
|
266
|
+
if (!name || required.includes(name)) return;
|
|
267
|
+
if (branch != null && !workflowTriggersPullRequestTo(reviewerWorkflowText, branch)) dropped.push({ name, reason: notOnBranch });
|
|
268
|
+
else required.push(name);
|
|
269
|
+
};
|
|
270
|
+
if (reviewerState === 'on' || reviewerState === 'legacy') served(fixedJobCheck(reviewerWorkflowText, REVIEWER_JOB_KEY));
|
|
271
|
+
served(fixedJobCheck(reviewerWorkflowText, SECURITY_SCAN_JOB_KEY));
|
|
272
|
+
|
|
273
|
+
const configured = configuredRequiredChecks(configText)
|
|
274
|
+
.filter((n) => !required.includes(n) && !dropped.some((d) => d.name === n));
|
|
275
|
+
if (configured.length) {
|
|
276
|
+
const files = reportedChecksByFileInDir(workflowsDir, { fsImpl, pathImpl });
|
|
277
|
+
for (const name of configured) {
|
|
278
|
+
if (files == null) { dropped.push({ name, reason: "the repository's workflows could not all be read" }); continue; }
|
|
279
|
+
const reporting = files.filter((f) => f.names.includes(name));
|
|
280
|
+
if (!reporting.length) { dropped.push({ name, reason: 'not reported by any workflow' }); continue; }
|
|
281
|
+
const fixed = reporting.filter((f) => fixedJobs(f.text).some((j) => j.name === name));
|
|
282
|
+
if (!fixed.length) {
|
|
283
|
+
dropped.push({ name, reason: 'its job reports per matrix leg or as a reusable-workflow call, never under this name' });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (branch != null && !fixed.some((f) => workflowTriggersPullRequestTo(f.text, branch))) {
|
|
287
|
+
dropped.push({ name, reason: notOnBranch });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
required.push(name);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { required, dropped };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {{ reviewerState: 'on'|'off'|'legacy'|'absent', reviewerWorkflowText: string|null }} args
|
|
298
|
+
* reviewerState is workflowReviewerState() (cli/lib/reviewer-optin.js) of the installed ai-review.yml.
|
|
299
|
+
*/
|
|
300
|
+
function requiredChecksForSetup(args = {}) {
|
|
301
|
+
return requiredChecksPlan(args).required;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* [{ file, text, names }] for each workflow in `dir`. `[]` when there is no such directory. `null` when a
|
|
306
|
+
* workflow file exists but could not be read.
|
|
307
|
+
*/
|
|
308
|
+
function reportedChecksByFileInDir(dir, { fsImpl = require('fs'), pathImpl = require('path') } = {}) {
|
|
309
|
+
if (typeof dir !== 'string' || !dir) return [];
|
|
310
|
+
let entries;
|
|
311
|
+
try {
|
|
312
|
+
entries = fsImpl.readdirSync(dir);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
return e && (e.code === 'ENOENT' || e.code === 'ENOTDIR') ? [] : null;
|
|
315
|
+
}
|
|
316
|
+
const out = [];
|
|
317
|
+
for (const f of entries) {
|
|
318
|
+
if (!/\.ya?ml$/i.test(f)) continue;
|
|
319
|
+
let text;
|
|
320
|
+
try {
|
|
321
|
+
text = fsImpl.readFileSync(pathImpl.join(dir, f), 'utf8');
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
out.push({ file: f, text, names: workflowCheckNames(text) });
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Every check name the workflows in `dir` report. `[]` when there is no such directory (nothing reports
|
|
332
|
+
* anything). `null` when a workflow file exists but could not be read: the caller cannot tell what is
|
|
333
|
+
* reported, so it must not remove a required check on the strength of this answer.
|
|
334
|
+
*/
|
|
335
|
+
function reportedChecksInDir(dir, opts = {}) {
|
|
336
|
+
const files = reportedChecksByFileInDir(dir, opts);
|
|
337
|
+
if (files == null) return null;
|
|
338
|
+
return [...new Set(files.flatMap((f) => f.names))];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
workflowCheckNames,
|
|
343
|
+
reviewerCheckName,
|
|
344
|
+
requiredChecksForSetup,
|
|
345
|
+
requiredChecksPlan,
|
|
346
|
+
reportedChecksInDir,
|
|
347
|
+
reportedChecksByFileInDir,
|
|
348
|
+
workflowPullRequestTriggers,
|
|
349
|
+
workflowTriggersPullRequestTo,
|
|
350
|
+
branchMatchesPattern,
|
|
351
|
+
configuredRequiredChecks,
|
|
352
|
+
REVIEWER_JOB_KEY,
|
|
353
|
+
SECURITY_SCAN_JOB_KEY,
|
|
354
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* review-diff.js — HC-GF-390: the base diff a reviewer needs beside a whole emitted file.
|
|
4
|
+
*
|
|
5
|
+
* In editor mode step_4's artifact carries every changed file whole (tree-artifact.js), while the
|
|
6
|
+
* reviewers (step_5, 5d, 5e) see the ORIGINAL only through the bundle, which windows a file longer
|
|
7
|
+
* than BUNDLE_MAX_PER_FILE_CHARS. A change outside those windows was compared with nothing. This
|
|
8
|
+
* computes, for step_4 only, `git diff HEAD` of each file the render emitted whole whose HEAD version
|
|
9
|
+
* is over that cap. It is sent beside the artifact as `reviewDiff` and stored apart from it: the
|
|
10
|
+
* artifact itself is never touched.
|
|
11
|
+
*
|
|
12
|
+
* It never fails a submit. Every git call has a timeout; anything it cannot do becomes a one-line
|
|
13
|
+
* note, and a note means "unverified", exactly as an absent diff did before.
|
|
14
|
+
*
|
|
15
|
+
* Exit modes of what it consumes (HC-GF-117):
|
|
16
|
+
* rendered.files HANDLED: the only candidates. A created, deleted, renamed, binary-at-render
|
|
17
|
+
* or over-1 MB file is never in it, so it is never diffed.
|
|
18
|
+
* git rev-parse fails HANDLED: no HEAD → null, nothing sent.
|
|
19
|
+
* git diff --name-status HANDLED: a throw → one note, nothing diffed.
|
|
20
|
+
* per-file git calls HANDLED: a throw (timeout, ENOBUFS, spawn, non-zero) → a note, next file.
|
|
21
|
+
* "Binary files … differ" HANDLED: a note (a text file .gitattributes marks binary).
|
|
22
|
+
* empty diff HANDLED: a note (the file no longer differs from HEAD).
|
|
23
|
+
* post-image ≠ rendered HANDLED: a note. The diff must describe the bytes the reviewer was sent.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const crypto = require('node:crypto');
|
|
27
|
+
const { execFileSync } = require('node:child_process');
|
|
28
|
+
const { gitEnv } = require('./git-env');
|
|
29
|
+
const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits');
|
|
30
|
+
const { isSecretPath } = require('./bundle-context');
|
|
31
|
+
const { FILE_BLOCK_RE } = require('./block-grammar');
|
|
32
|
+
|
|
33
|
+
const REVIEW_DIFF_MAX_CHARS = 60_000; // diff text, summed over the files included
|
|
34
|
+
const PER_CALL_MS = 15_000;
|
|
35
|
+
const DEADLINE_MS = 60_000; // every per-file call gets min(PER_CALL_MS, what is left)
|
|
36
|
+
const MAX_NOTES = 100;
|
|
37
|
+
const MAX_NOTE_CHARS = 300;
|
|
38
|
+
// Kept equal to server/src/services/review-diff.js REVIEW_DIFF_MAX_BYTES (pinned by the HC-GF-390 tests).
|
|
39
|
+
const REVIEW_DIFF_MAX_BYTES = 256 * 1024;
|
|
40
|
+
|
|
41
|
+
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
42
|
+
|
|
43
|
+
/** The bundle windows a file whose text is longer than the cap (bundle-paths.js `text.length <= limit` is whole). */
|
|
44
|
+
const overCap = (length) => length > BUNDLE_MAX_PER_FILE_CHARS;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The pinned diff. Every option a user's git config could change is given on the command line, as a
|
|
48
|
+
* flag the `diff` subcommand takes (not a leading `-c`, so a caller can still recognise the call).
|
|
49
|
+
*/
|
|
50
|
+
function diffArgs(headSha, rel) {
|
|
51
|
+
return ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--full-index',
|
|
52
|
+
'--diff-algorithm=myers', '--no-relative', '--inter-hunk-context=0', '-U3',
|
|
53
|
+
'--src-prefix=a/', '--dst-prefix=b/', headSha, '--', rel];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultRunGit(repoRoot) {
|
|
57
|
+
return (args, timeoutMs) => execFileSync('git', args, {
|
|
58
|
+
cwd: repoRoot, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
59
|
+
timeout: Math.max(1, Math.floor(timeoutMs)), stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The contents of the first `file:` block for `rel` in a rendered artifact, as the server's
|
|
65
|
+
* carry-forward `blocksOf` reads it (same grammar, same scope, same one-newline strip), or null.
|
|
66
|
+
*/
|
|
67
|
+
function blockContents(text, rel) {
|
|
68
|
+
const t = String(text || '');
|
|
69
|
+
const sec = t.search(/^##\s+Changed Files/im);
|
|
70
|
+
const scope = t.slice(sec === -1 ? 0 : sec);
|
|
71
|
+
for (const m of scope.matchAll(FILE_BLOCK_RE)) {
|
|
72
|
+
if (m[2].trim() === rel) return m[3].replace(/\r?\n$/, '');
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** git's object id of a blob holding `str` as UTF-8, computed in memory (never `hash-object -w`). */
|
|
78
|
+
function gitBlobId(str, algo = 'sha1') {
|
|
79
|
+
const bytes = Buffer.from(String(str), 'utf8');
|
|
80
|
+
return crypto.createHash(algo).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One line, no control characters, at most `max` chars. */
|
|
84
|
+
function oneLine(s, max) {
|
|
85
|
+
const flat = String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]+/g, ' ').trim();
|
|
86
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {object} opts
|
|
91
|
+
* @param {string} opts.repoRoot
|
|
92
|
+
* @param {{text: string, files: string[]}} opts.rendered renderFromTree()'s result
|
|
93
|
+
* @param {(args: string[], timeoutMs: number) => string} [opts.runGit]
|
|
94
|
+
* @param {() => number} [opts.now]
|
|
95
|
+
* @param {(line: string) => void} [opts.warn] why nothing is sent when that is not "no candidate"
|
|
96
|
+
* @returns {null | { headSha: string, diff: string, notes: string[], stats: object }}
|
|
97
|
+
* null when there is nothing to send: no HEAD, no candidate, or a payload over its byte cap.
|
|
98
|
+
*/
|
|
99
|
+
function computeReviewDiff({ repoRoot, rendered, runGit, now = Date.now, warn = () => {} } = {}) {
|
|
100
|
+
try {
|
|
101
|
+
const git = runGit || defaultRunGit(repoRoot);
|
|
102
|
+
const emitted = new Set(rendered && Array.isArray(rendered.files) ? rendered.files : []);
|
|
103
|
+
if (!emitted.size) return null;
|
|
104
|
+
|
|
105
|
+
const deadline = now() + DEADLINE_MS;
|
|
106
|
+
let headSha;
|
|
107
|
+
try { headSha = String(git(['rev-parse', '--verify', 'HEAD^{commit}'], PER_CALL_MS)).trim(); } catch { return null; }
|
|
108
|
+
if (!SHA_RE.test(headSha)) return null;
|
|
109
|
+
const algo = headSha.length === 64 ? 'sha256' : 'sha1';
|
|
110
|
+
|
|
111
|
+
const notes = [];
|
|
112
|
+
const sections = [];
|
|
113
|
+
let used = 0;
|
|
114
|
+
const note = (rel, reason) => notes.push(oneLine(`${JSON.stringify(rel)}: ${reason}`, MAX_NOTE_CHARS));
|
|
115
|
+
|
|
116
|
+
let candidates = [];
|
|
117
|
+
try {
|
|
118
|
+
const tokens = String(git(['diff', '--name-status', '-z', '--no-renames', headSha], PER_CALL_MS)).split('\0');
|
|
119
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
120
|
+
const status = tokens[i]; const rel = tokens[i + 1];
|
|
121
|
+
if ((status === 'M' || status === 'T') && emitted.has(rel)) candidates.push(rel);
|
|
122
|
+
}
|
|
123
|
+
candidates = [...new Set(candidates)].sort();
|
|
124
|
+
} catch (e) {
|
|
125
|
+
notes.push(oneLine(`(all files): git failed listing changes against HEAD: ${e && e.message}`, MAX_NOTE_CHARS));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
129
|
+
const rel = candidates[i];
|
|
130
|
+
const left = () => Math.min(PER_CALL_MS, deadline - now());
|
|
131
|
+
let t = left();
|
|
132
|
+
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
133
|
+
try {
|
|
134
|
+
// The size first: it reads no content, and a file at or under the cap was bundled whole.
|
|
135
|
+
// UTF-8 bytes >= UTF-16 length, so a size at or under the cap cannot be a length over it.
|
|
136
|
+
const size = Number(String(git(['cat-file', '-s', `${headSha}:${rel}`], t)).trim());
|
|
137
|
+
if (!Number.isFinite(size) || !overCap(size)) continue;
|
|
138
|
+
if (isSecretPath(rel)) { note(rel, 'secret path; not diffed'); continue; }
|
|
139
|
+
t = left();
|
|
140
|
+
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
141
|
+
const head = String(git(['cat-file', 'blob', `${headSha}:${rel}`], t));
|
|
142
|
+
if (!overCap(head.length)) continue;
|
|
143
|
+
t = left();
|
|
144
|
+
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
145
|
+
const out = String(git(diffArgs(headSha, rel), t));
|
|
146
|
+
if (!out.trim()) { note(rel, 'no longer differs from HEAD; not diffed'); continue; }
|
|
147
|
+
if (/^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out)) { note(rel, 'binary to git; not diffed'); continue; }
|
|
148
|
+
if ((out.match(/^diff --git /gm) || []).length !== 1) { note(rel, 'unexpected diff shape; not included'); continue; }
|
|
149
|
+
const post = /^index [0-9a-f]+\.\.([0-9a-f]+)(?: [0-7]+)?$/m.exec(out);
|
|
150
|
+
const contents = blockContents(rendered.text, rel);
|
|
151
|
+
if (!post || contents === null || post[1] !== gitBlobId(`${contents}\n`, algo)) {
|
|
152
|
+
note(rel, 'post-image differs from the rendered bytes (an eol or clean filter, no final newline, '
|
|
153
|
+
+ 'non-UTF-8 content, or a change after render); not included');
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const section = out.endsWith('\n') ? out : `${out}\n`;
|
|
157
|
+
if (used + section.length > REVIEW_DIFF_MAX_CHARS) {
|
|
158
|
+
note(rel, `over the ${REVIEW_DIFF_MAX_CHARS.toLocaleString('en-US')}-char review-diff budget; not included`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
sections.push(section);
|
|
162
|
+
used += section.length;
|
|
163
|
+
} catch (e) {
|
|
164
|
+
note(rel, `git failed: ${oneLine(String((e && e.message) || e).split('\n')[0], 120)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const kept = notes.length > MAX_NOTES
|
|
169
|
+
? [...notes.slice(0, MAX_NOTES - 1), `${notes.length - (MAX_NOTES - 1)} more note(s) omitted`]
|
|
170
|
+
: notes;
|
|
171
|
+
if (!sections.length && !kept.length) return null;
|
|
172
|
+
const payload = { headSha, diff: sections.join(''), notes: kept };
|
|
173
|
+
const bytes = Buffer.byteLength(JSON.stringify(payload), 'utf8');
|
|
174
|
+
if (bytes > REVIEW_DIFF_MAX_BYTES) { warn(`review diff not sent: ${bytes} bytes is over the ${REVIEW_DIFF_MAX_BYTES}-byte limit`); return null; }
|
|
175
|
+
return { ...payload, stats: { candidates: candidates.length, files: sections.length, chars: used } };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
warn(`review diff not sent: ${oneLine((e && e.message) || e, 200)}`);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Exactly what `hone submit-step` posts as `reviewDiff`: the stats stay CLI-side. One mapper, so a test can post what the CLI posts. */
|
|
183
|
+
function toWire(r) { return { headSha: r.headSha, diff: r.diff, notes: r.notes }; }
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
computeReviewDiff, blockContents, gitBlobId, toWire,
|
|
187
|
+
_internal: { overCap, diffArgs, REVIEW_DIFF_MAX_CHARS, PER_CALL_MS, DEADLINE_MS, MAX_NOTES, MAX_NOTE_CHARS, REVIEW_DIFF_MAX_BYTES },
|
|
188
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-337g — the AI code reviewer is OPT-IN.
|
|
4
|
+
*
|
|
5
|
+
* `hone setup` used to install it in every adopter repository, where it runs in the adopter's CI
|
|
6
|
+
* on the adopter's own ANTHROPIC_API_KEY — so a repository that never chose it, and never set the
|
|
7
|
+
* key, got a red check on every PR. Now its scripts install only on `--ai-reviewer`, and the
|
|
8
|
+
* ai-review job runs only then. .github/workflows/ai-review.yml itself installs either way: its other
|
|
9
|
+
* gates (secret scan, tracker, metrics check, E2E healer) are not the reviewer's to take with it.
|
|
10
|
+
*
|
|
11
|
+
* Commander keeps the default `undefined` only because `--ai-reviewer` is declared BEFORE
|
|
12
|
+
* `--no-ai-reviewer` ("If you define --foo first, adding --no-foo does not change the default
|
|
13
|
+
* value"). Declared the other way round the default becomes `true`, and the always-install comes
|
|
14
|
+
* back with nothing in the diff to show it — which is why the decision is `=== true` here, and why
|
|
15
|
+
* tests/regression/hc-gf-337g-reviewer-optin-default.test.js runs the real CLI with no flag.
|
|
16
|
+
*
|
|
17
|
+
* Pure: no I/O, so it is unit-testable without the CLI.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** True only for an explicit `--ai-reviewer`. No flag, `--no-ai-reviewer`, or no opts at all: false. */
|
|
21
|
+
function shouldInstallReviewer(opts) {
|
|
22
|
+
return Boolean(opts) && opts.aiReviewer === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What an installed .github/workflows/ai-review.yml says about the reviewer job (HC-GF-337g):
|
|
27
|
+
* 'on' / 'off' (setup's AI_REVIEWER stamp), 'legacy' (written before the stamp existed — it runs the
|
|
28
|
+
* reviewer on every PR, red without a key) or 'absent' (no file; pass null).
|
|
29
|
+
*/
|
|
30
|
+
function workflowReviewerState(text) {
|
|
31
|
+
if (text == null) return 'absent';
|
|
32
|
+
const m = /AI_REVIEWER: "([^"]*)"/.exec(text);
|
|
33
|
+
if (!m) return 'legacy';
|
|
34
|
+
return m[1] === 'true' ? 'on' : 'off';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What the operator is told once the setup script has run.
|
|
39
|
+
*
|
|
40
|
+
* @param {boolean} installed the reviewer script is now in the repository (with dryRun: would be)
|
|
41
|
+
* @param {{ requested?: boolean, alreadyPresent?: boolean, dryRun?: boolean, workflow?: string }} [ctx]
|
|
42
|
+
* requested: `--ai-reviewer` was passed (so `installed: false` means the download failed)
|
|
43
|
+
* alreadyPresent: the repository already has .github/workflows/ai-review.yml from an earlier setup
|
|
44
|
+
* dryRun: `--dry-run` — nothing was written, so nothing may be reported as installed
|
|
45
|
+
* workflow: workflowReviewerState() of the repository's ai-review.yml after the script ran (before,
|
|
46
|
+
* on a dry run). An existing workflow is never regenerated, so an install is claimed only
|
|
47
|
+
* when the workflow actually runs the job.
|
|
48
|
+
* @returns {string} one or more lines, no trailing newline
|
|
49
|
+
*/
|
|
50
|
+
function reviewerNotice(installed, { requested = false, alreadyPresent = false, dryRun = false, workflow } = {}) {
|
|
51
|
+
const LEGACY = [
|
|
52
|
+
' .github/workflows/ai-review.yml predates the opt-in and was kept: it runs the reviewer on every PR,',
|
|
53
|
+
' red without ANTHROPIC_API_KEY. Delete it and re-run `hone setup --ai-reviewer` to get the version that',
|
|
54
|
+
' skips without a key.',
|
|
55
|
+
];
|
|
56
|
+
// Unknown workflow state (a caller that did not read it): the reviewer side assumes the job is on.
|
|
57
|
+
const wf = workflow || 'on';
|
|
58
|
+
if (installed && dryRun) {
|
|
59
|
+
if (wf === 'legacy') {
|
|
60
|
+
return [' DRY-RUN: would install the AI reviewer scripts, but not the job that skips without a key:', ...LEGACY].join('\n');
|
|
61
|
+
}
|
|
62
|
+
return [
|
|
63
|
+
' DRY-RUN: would install the AI code reviewer (the ai-review job of .github/workflows/ai-review.yml).',
|
|
64
|
+
' It runs in your CI on YOUR OWN key: add ANTHROPIC_API_KEY as a repository secret.',
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
if (installed && wf === 'legacy') {
|
|
68
|
+
return [' ⚠ AI reviewer scripts installed, but not the job that skips without a key:', ...LEGACY].join('\n');
|
|
69
|
+
}
|
|
70
|
+
if (installed && wf !== 'on') {
|
|
71
|
+
return [
|
|
72
|
+
` ⚠ AI reviewer scripts installed, but .github/workflows/ai-review.yml ${wf === 'off' ? 'has the reviewer stamped off' : 'is missing'},`,
|
|
73
|
+
' so the ai-review job will not run. Delete .github/workflows/ai-review.yml (if present) and re-run',
|
|
74
|
+
' `hone setup --ai-reviewer`.',
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
if (installed) {
|
|
78
|
+
return [
|
|
79
|
+
' ✓ AI code reviewer installed (the ai-review job of .github/workflows/ai-review.yml).',
|
|
80
|
+
' It runs in your CI on YOUR OWN key: add ANTHROPIC_API_KEY as a repository secret.',
|
|
81
|
+
' Without it the job is skipped (grey, never blocks a merge). The H-019 learnings gate runs inside it.',
|
|
82
|
+
].join('\n');
|
|
83
|
+
}
|
|
84
|
+
const lines = requested
|
|
85
|
+
? [' ⚠ AI code reviewer requested (--ai-reviewer) but NOT installed — its download failed or setup did not write it. Re-run setup to retry.']
|
|
86
|
+
: [' ℹ AI code reviewer not installed (opt-in: hone setup --ai-reviewer).'];
|
|
87
|
+
// What is said about the workflow's OTHER gates is read from the workflow too (re-review step_5): it is
|
|
88
|
+
// never claimed for a workflow that did not arrive, predates the opt-in, or still has the job on.
|
|
89
|
+
if (workflow === 'absent') {
|
|
90
|
+
lines.push(
|
|
91
|
+
' ⚠ .github/workflows/ai-review.yml is not installed either (its download failed), so its secret scan,',
|
|
92
|
+
' tracker and metrics check are missing too. Re-run setup to retry.',
|
|
93
|
+
);
|
|
94
|
+
} else if (workflow === 'legacy') {
|
|
95
|
+
lines.push(' .github/workflows/ai-review.yml predates the opt-in and was kept: delete it and re-run setup to get the version whose ai-review job runs only when chosen.');
|
|
96
|
+
} else if (workflow === 'on') {
|
|
97
|
+
lines.push(' .github/workflows/ai-review.yml was kept with the ai-review job switched on by an earlier setup; setup never switches it off.');
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(' .github/workflows/ai-review.yml is installed either way: its secret scan, tracker and metrics check run; its ai-review job is skipped.');
|
|
100
|
+
}
|
|
101
|
+
lines.push(
|
|
102
|
+
' It needs your own ANTHROPIC_API_KEY repository secret, and it carries the H-019 learnings gate:',
|
|
103
|
+
' without the reviewer, the learnings gate is not installed either.',
|
|
104
|
+
);
|
|
105
|
+
if (alreadyPresent) {
|
|
106
|
+
lines.push(
|
|
107
|
+
' ⚠ .github/scripts/ai-reviewer.js is still here from an earlier setup and was left in place, and an',
|
|
108
|
+
' existing .github/workflows/ai-review.yml is never regenerated. To opt out, delete both files and re-run',
|
|
109
|
+
' setup; to keep the reviewer, re-run with --ai-reviewer.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { shouldInstallReviewer, reviewerNotice, workflowReviewerState };
|