@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
package/lib/branch-protection.js
CHANGED
|
@@ -17,14 +17,24 @@
|
|
|
17
17
|
* mergeContexts(existing, added)
|
|
18
18
|
* Pure. Dedupes context names and preserves first-seen order so a
|
|
19
19
|
* pre-existing custom check (e.g. 'lint') is preserved when Hone
|
|
20
|
-
* appends
|
|
20
|
+
* appends the checks it requires.
|
|
21
|
+
*
|
|
22
|
+
* pruneLegacyChecks(contexts, { reportedChecks, keep })
|
|
23
|
+
* Pure. Removes LEGACY_REQUIRED_CHECK ('ai-code-review'), which earlier
|
|
24
|
+
* setups required and no served workflow reports (HC-GF-348), unless a
|
|
25
|
+
* workflow in the repository reports it or the caller requires it.
|
|
26
|
+
* Removes nothing else.
|
|
21
27
|
*
|
|
22
28
|
* parseGhError(stderr, exitCode)
|
|
23
29
|
* Pure. Classifies a `gh` invocation failure into a stable kind:
|
|
24
30
|
* no-gh / no-auth / no-admin / plan-restricted / org-ruleset / unknown.
|
|
25
31
|
*
|
|
26
32
|
* installBranchProtection({ exec?, log?, opts? })
|
|
27
|
-
* Orchestrator.
|
|
33
|
+
* Orchestrator. opts.requiredChecks is the list `hone setup` reads from
|
|
34
|
+
* the installed workflow (cli/lib/required-checks.js) and may be empty;
|
|
35
|
+
* opts.reportedChecks is every check name the repository's workflows
|
|
36
|
+
* report (null = unknown, which keeps the legacy check).
|
|
37
|
+
* Returns { status, reason?, branch?, manualCommand? }
|
|
28
38
|
* where status ∈ 'installed' | 'skipped' | 'failed'. Never throws,
|
|
29
39
|
* never exits — degrades gracefully so `hone setup` always returns 0.
|
|
30
40
|
*
|
|
@@ -34,7 +44,14 @@
|
|
|
34
44
|
* invocations during testing.
|
|
35
45
|
*/
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
// HC-GF-348: no check is required by default. Setup passes the checks the repository's own workflow
|
|
48
|
+
// reports (cli/lib/required-checks.js); an empty list is valid — the PUT still requires a review and
|
|
49
|
+
// sets enforce_admins. This used to be ['ai-code-review'], a check nothing reports, which held every PR.
|
|
50
|
+
const DEFAULT_REQUIRED_CHECKS = [];
|
|
51
|
+
|
|
52
|
+
// The one context an earlier `hone setup` installed that no served workflow reports. Re-running setup
|
|
53
|
+
// removes it (pruneLegacyChecks), so a repository already held by it is released.
|
|
54
|
+
const LEGACY_REQUIRED_CHECK = 'ai-code-review';
|
|
38
55
|
|
|
39
56
|
// ─────────────────────────────────────────────────────────────────────
|
|
40
57
|
// defaultExec — production exec, wraps spawnSync
|
|
@@ -59,7 +76,8 @@ function buildPayload({ contexts = [], requireReviews } = {}) {
|
|
|
59
76
|
for (const c of contexts) {
|
|
60
77
|
if (!seen.has(c)) { seen.add(c); dedupedContexts.push(c); }
|
|
61
78
|
}
|
|
62
|
-
|
|
79
|
+
// 0 is a real answer since HC-GF-353 (a repository with one writer); anything that is not a count is 1.
|
|
80
|
+
const reviewCount = Number.isInteger(requireReviews) && requireReviews >= 0
|
|
63
81
|
? requireReviews
|
|
64
82
|
: 1;
|
|
65
83
|
return {
|
|
@@ -90,13 +108,27 @@ function mergeContexts(existing, added) {
|
|
|
90
108
|
return out;
|
|
91
109
|
}
|
|
92
110
|
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
112
|
+
// pruneLegacyChecks — pure (HC-GF-348)
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
114
|
+
function pruneLegacyChecks(contexts, { reportedChecks = [], keep = [] } = {}) {
|
|
115
|
+
const arr = Array.isArray(contexts) ? contexts : [];
|
|
116
|
+
// null: the repository's workflows could not all be read, so whether one reports it is unknown.
|
|
117
|
+
if (reportedChecks == null) return arr.slice();
|
|
118
|
+
const reported = Array.isArray(reportedChecks) ? reportedChecks : [];
|
|
119
|
+
const kept = Array.isArray(keep) ? keep : [];
|
|
120
|
+
if (reported.includes(LEGACY_REQUIRED_CHECK) || kept.includes(LEGACY_REQUIRED_CHECK)) return arr.slice();
|
|
121
|
+
return arr.filter((c) => c !== LEGACY_REQUIRED_CHECK);
|
|
122
|
+
}
|
|
123
|
+
|
|
93
124
|
// ─────────────────────────────────────────────────────────────────────
|
|
94
125
|
// parseGhError — pure
|
|
95
126
|
// ─────────────────────────────────────────────────────────────────────
|
|
96
127
|
function parseGhError(stderr = '', exitCode = 0) {
|
|
97
128
|
const s = String(stderr || '');
|
|
98
129
|
// gh binary missing entirely
|
|
99
|
-
|
|
130
|
+
// Not GitHub's own `gh: Not Found (HTTP 404)`, which the shell's wording also matched.
|
|
131
|
+
if (exitCode === 127 || /command not found/i.test(s) || (/gh:.*not found/i.test(s) && !/\(HTTP \d{3}\)/.test(s))) {
|
|
100
132
|
return { kind: 'no-gh', message: 'gh CLI not installed (or not on PATH)' };
|
|
101
133
|
}
|
|
102
134
|
// gh installed but not authenticated
|
|
@@ -128,28 +160,67 @@ function parseGhError(stderr = '', exitCode = 0) {
|
|
|
128
160
|
// ─────────────────────────────────────────────────────────────────────
|
|
129
161
|
// buildManualCommand — produces a copy-paste shell snippet for the user
|
|
130
162
|
// ─────────────────────────────────────────────────────────────────────
|
|
131
|
-
function buildManualCommand({ contexts = DEFAULT_REQUIRED_CHECKS, owner = '<owner>', repo = '<repo>', branch = 'main' } = {}) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
.join('\n');
|
|
163
|
+
function buildManualCommand({ contexts = DEFAULT_REQUIRED_CHECKS, owner = '<owner>', repo = '<repo>', branch = 'main', requireReviews } = {}) {
|
|
164
|
+
// HC-GF-348: the exact payload setup PUTs, sent as JSON. `-f` fields cannot carry an empty contexts
|
|
165
|
+
// list (now the default) or a null `restrictions` — `-f 'restrictions='` sent the empty string.
|
|
135
166
|
return [
|
|
136
|
-
`gh api --method PUT repos/${owner}/${repo}/branches/${branch}/protection
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
` -f 'enforce_admins=true' \\`,
|
|
140
|
-
` -f 'required_pull_request_reviews[required_approving_review_count]=1' \\`,
|
|
141
|
-
` -f 'restrictions='`,
|
|
167
|
+
`gh api --method PUT repos/${owner}/${repo}/branches/${branch}/protection --input - <<'JSON'`,
|
|
168
|
+
JSON.stringify(buildPayload({ contexts, requireReviews }), null, 2),
|
|
169
|
+
'JSON',
|
|
142
170
|
].join('\n');
|
|
143
171
|
}
|
|
144
172
|
|
|
173
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
174
|
+
// resolveChecks — the required list, which may depend on the branch (HC-GF-354)
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
176
|
+
// Setup's list depends on the protected branch, which only `gh repo view` names. On the paths that return
|
|
177
|
+
// before that, the printed manual command is a template, so the list is resolved with branch null. A
|
|
178
|
+
// resolver that throws requires nothing rather than failing the install.
|
|
179
|
+
function resolveChecks(requiredChecks, branch) {
|
|
180
|
+
if (typeof requiredChecks === 'function') {
|
|
181
|
+
try {
|
|
182
|
+
const r = requiredChecks(branch);
|
|
183
|
+
return Array.isArray(r) ? r : [];
|
|
184
|
+
} catch {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return Array.isArray(requiredChecks) ? requiredChecks : DEFAULT_REQUIRED_CHECKS;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
192
|
+
// countWriters — how many people can push (HC-GF-353)
|
|
193
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
194
|
+
function countWriters(exec, owner, repo) {
|
|
195
|
+
let r;
|
|
196
|
+
try {
|
|
197
|
+
r = exec('gh', ['api', '--paginate', `repos/${owner}/${repo}/collaborators?per_page=100`]);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return { count: null, reason: `gh could not be run: ${e && e.message}` };
|
|
200
|
+
}
|
|
201
|
+
if (r.status !== 0) return { count: null, reason: parseGhError(r.stderr, r.status).message };
|
|
202
|
+
let list;
|
|
203
|
+
try {
|
|
204
|
+
// --paginate concatenates one JSON array per page: `[...][...]`.
|
|
205
|
+
const pages = JSON.parse(`[${String(r.stdout || '').trim().replace(/\]\s*\[/g, '],[')}]`);
|
|
206
|
+
list = pages.length && pages.every(Array.isArray) ? pages.flat() : null;
|
|
207
|
+
} catch {
|
|
208
|
+
list = null;
|
|
209
|
+
}
|
|
210
|
+
if (!Array.isArray(list) || list.length === 0) return { count: null, reason: 'the collaborator list could not be read' };
|
|
211
|
+
const n = list.filter((c) => c && c.permissions && (c.permissions.push === true || c.permissions.admin === true)).length;
|
|
212
|
+
// Nobody with a readable push or admin permission is an answer that could not be read, not "zero
|
|
213
|
+
// writers": it must never remove the review.
|
|
214
|
+
if (n === 0) return { count: null, reason: 'no collaborator in the list has a readable push or admin permission' };
|
|
215
|
+
return { count: n };
|
|
216
|
+
}
|
|
217
|
+
|
|
145
218
|
// ─────────────────────────────────────────────────────────────────────
|
|
146
219
|
// installBranchProtection — orchestrator
|
|
147
220
|
// ─────────────────────────────────────────────────────────────────────
|
|
148
221
|
async function installBranchProtection({ exec = defaultExec, log = () => {}, opts = {} } = {}) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
: DEFAULT_REQUIRED_CHECKS;
|
|
152
|
-
const requireReviews = opts.requireReviews || 1;
|
|
222
|
+
// Not given: unknown, which keeps the legacy check. Only a caller that read the workflows can prune it.
|
|
223
|
+
const reportedChecks = opts.reportedChecks === undefined ? null : opts.reportedChecks;
|
|
153
224
|
|
|
154
225
|
// 1. Honor opt-out — short-circuit before any subprocess call
|
|
155
226
|
if (opts.skip) {
|
|
@@ -159,6 +230,12 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
159
230
|
};
|
|
160
231
|
}
|
|
161
232
|
|
|
233
|
+
// An explicit list — empty included — is what the caller requires (HC-GF-348); a function of the
|
|
234
|
+
// protected branch (HC-GF-354); none given: the default. `requiredChecks` is the answer before the
|
|
235
|
+
// branch is known: for a function that is nothing, since a check it would name might never report on
|
|
236
|
+
// the real branch, and the manual command printed on those paths is a template the user edits.
|
|
237
|
+
const requiredChecks = typeof opts.requiredChecks === 'function' ? [] : resolveChecks(opts.requiredChecks, null);
|
|
238
|
+
|
|
162
239
|
// 2. Resolve git remote
|
|
163
240
|
const remote = exec('git', ['remote', 'get-url', 'origin']);
|
|
164
241
|
if (remote.status !== 0) {
|
|
@@ -206,10 +283,23 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
206
283
|
};
|
|
207
284
|
}
|
|
208
285
|
|
|
286
|
+
const branchChecks = resolveChecks(opts.requiredChecks, branch);
|
|
287
|
+
|
|
209
288
|
// 4. GET existing protection (404 = none, 200 = merge)
|
|
210
289
|
const protPath = `repos/${owner}/${repo}/branches/${branch}/protection`;
|
|
211
290
|
const existing = exec('gh', ['api', protPath]);
|
|
212
291
|
let existingContexts = [];
|
|
292
|
+
// HC-GF-348: only "Branch not protected" (HTTP 404) means there is nothing to merge. Any other failure
|
|
293
|
+
// to read the protection must not be taken for "none": the PUT replaces the contexts, so it would drop
|
|
294
|
+
// every check the repository already requires — and with this story the computed list can be empty.
|
|
295
|
+
if (existing.status !== 0 && !/Branch not protected/i.test(`${existing.stderr || ''}${existing.stdout || ''}`)) {
|
|
296
|
+
const err = parseGhError(existing.stderr, existing.status);
|
|
297
|
+
return {
|
|
298
|
+
status: 'failed',
|
|
299
|
+
reason: `could not read the existing branch protection, so it was left unchanged: ${err.message}`,
|
|
300
|
+
manualCommand: buildManualCommand({ contexts: branchChecks, owner, repo, branch }),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
213
303
|
if (existing.status === 0) {
|
|
214
304
|
try {
|
|
215
305
|
const e = JSON.parse(existing.stdout || '{}');
|
|
@@ -220,11 +310,32 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
220
310
|
existingContexts = [];
|
|
221
311
|
}
|
|
222
312
|
}
|
|
223
|
-
// (non-zero exit is treated as "no existing protection" — the PUT below will succeed if the user has permissions)
|
|
224
313
|
|
|
225
|
-
// 5. Build merged payload
|
|
226
|
-
const mergedContexts = mergeContexts(existingContexts,
|
|
227
|
-
|
|
314
|
+
// 5. Build merged payload; drop the legacy check an earlier setup installed (HC-GF-348)
|
|
315
|
+
const mergedContexts = pruneLegacyChecks(mergeContexts(existingContexts, branchChecks), {
|
|
316
|
+
reportedChecks, keep: branchChecks,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// HC-GF-353: GitHub does not count a PR author's approval of their own PR, so one required review on a
|
|
320
|
+
// repository with a single writer makes every PR unmergeable. An explicit opts.requireReviews wins. A
|
|
321
|
+
// count that could not be established keeps 1: failing open on human review would be the regression.
|
|
322
|
+
let reviewCount = 1;
|
|
323
|
+
let reviewReason;
|
|
324
|
+
if (Number.isInteger(opts.requireReviews) && opts.requireReviews >= 0) {
|
|
325
|
+
reviewCount = opts.requireReviews;
|
|
326
|
+
reviewReason = 'set by the caller';
|
|
327
|
+
} else {
|
|
328
|
+
const w = countWriters(exec, owner, repo);
|
|
329
|
+
if (w.count == null) {
|
|
330
|
+
reviewReason = `the number of people who can push could not be counted (${w.reason}), so one review is still required`;
|
|
331
|
+
} else if (w.count === 1) {
|
|
332
|
+
reviewCount = 0;
|
|
333
|
+
reviewReason = 'one person can push, and GitHub does not count a pull request author\'s own approval; a pull request is still required';
|
|
334
|
+
} else {
|
|
335
|
+
reviewReason = `${w.count} people can push`;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const payload = buildPayload({ contexts: mergedContexts, requireReviews: reviewCount });
|
|
228
339
|
|
|
229
340
|
// 6. PUT
|
|
230
341
|
const put = exec(
|
|
@@ -233,7 +344,7 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
233
344
|
{ input: JSON.stringify(payload) }
|
|
234
345
|
);
|
|
235
346
|
if (put.status === 0) {
|
|
236
|
-
return { status: 'installed', branch };
|
|
347
|
+
return { status: 'installed', branch, requiredChecks: mergedContexts, reviewCount, reviewReason };
|
|
237
348
|
}
|
|
238
349
|
|
|
239
350
|
// 7. PUT failed — classify and return graceful failure
|
|
@@ -241,16 +352,21 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
241
352
|
return {
|
|
242
353
|
status: 'failed',
|
|
243
354
|
reason: err.message,
|
|
244
|
-
manualCommand: buildManualCommand({ contexts: mergedContexts, owner, repo, branch }),
|
|
355
|
+
manualCommand: buildManualCommand({ contexts: mergedContexts, owner, repo, branch, requireReviews: reviewCount }),
|
|
245
356
|
};
|
|
246
357
|
}
|
|
247
358
|
|
|
248
359
|
module.exports = {
|
|
249
360
|
buildPayload,
|
|
250
361
|
mergeContexts,
|
|
362
|
+
pruneLegacyChecks,
|
|
363
|
+
resolveChecks,
|
|
364
|
+
countWriters,
|
|
251
365
|
parseGhError,
|
|
252
366
|
installBranchProtection,
|
|
253
367
|
// Internal helpers exposed for inspection / future reuse
|
|
254
368
|
buildManualCommand,
|
|
369
|
+
defaultExec,
|
|
255
370
|
DEFAULT_REQUIRED_CHECKS,
|
|
371
|
+
LEGACY_REQUIRED_CHECK,
|
|
256
372
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-390: the leaf home of the bundle's per-file cap. run-context.js windows a file whose text is
|
|
4
|
+
* longer than this (bundle-paths.js `planFileExcerpt`: `text.length <= limit` is sent whole), and
|
|
5
|
+
* review-diff.js diffs exactly the files a reviewer saw only windows of, so both read it from here.
|
|
6
|
+
* run-context.js re-exports it under the same name for the tests that import it from there.
|
|
7
|
+
*/
|
|
8
|
+
const BUNDLE_MAX_PER_FILE_CHARS = 25_000;
|
|
9
|
+
|
|
10
|
+
module.exports = { BUNDLE_MAX_PER_FILE_CHARS };
|
package/lib/bundle-paths.js
CHANGED
|
@@ -256,8 +256,20 @@ function extractPathRefs(text) {
|
|
|
256
256
|
return out;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
/**
|
|
260
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Backticked spans in a story, PAIRED FIRST and length-unbounded; `storyTermsOf` applies the
|
|
261
|
+
* 3-to-200 rule to each paired span. HC-GF-188-followup-2.
|
|
262
|
+
*
|
|
263
|
+
* A BOUND INSIDE THE MATCH DECIDES WHICH BACKTICKS PAIR. At `` `x`, and `foo` `` the opening
|
|
264
|
+
* backtick of `x` could not start a 3-character match, so its CLOSING backtick did: the span ran
|
|
265
|
+
* to `foo`'s OPENING backtick, the "term" was `, and`, and `foo` was lost because no backtick was
|
|
266
|
+
* left to open it. A span over 200 characters, and an empty pair, shift the pairing the same way.
|
|
267
|
+
*
|
|
268
|
+
* The pairing is the one `maskCodeSpans` uses below: one rule for what a code span is, and the
|
|
269
|
+
* filter decides which spans can anchor. Fences are NOT masked here, which would widen the change
|
|
270
|
+
* past what the row measured.
|
|
271
|
+
*/
|
|
272
|
+
const BACKTICKED_RE = /`([^`\n]*)`/g;
|
|
261
273
|
|
|
262
274
|
/**
|
|
263
275
|
* The terms a story quotes that could locate code, longest first — one copy of the filter.
|
|
@@ -269,7 +281,8 @@ const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
|
|
|
269
281
|
function storyTermsOf(storyText) {
|
|
270
282
|
return [...String(storyText || '').matchAll(BACKTICKED_RE)]
|
|
271
283
|
.map((m) => m[1].trim())
|
|
272
|
-
|
|
284
|
+
// Both bounds read the TRIMMED span; the ceiling used to live in the regex, on the raw span.
|
|
285
|
+
.filter((c) => c.length >= 3 && c.length <= 200
|
|
273
286
|
&& !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
|
|
274
287
|
&& !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
|
|
275
288
|
.sort((a, b) => b.length - a.length);
|
|
@@ -924,6 +937,10 @@ const LABEL_RE = /\*\*([A-Z][^*\n]{0,80}?):\*\*/g;
|
|
|
924
937
|
* Fences are masked FIRST so a backtick inside a fence cannot pair with one
|
|
925
938
|
* outside it.
|
|
926
939
|
*
|
|
940
|
+
* HC-GF-188-followup-2: `BACKTICKED_RE` (read by `storyTermsOf`) pairs backticks the same way,
|
|
941
|
+
* `[^`\n]*` between two of them. Change the pairing in one place and change it in the other, or
|
|
942
|
+
* the two will disagree about what a code span is.
|
|
943
|
+
*
|
|
927
944
|
* @param {string} text
|
|
928
945
|
* @returns {string} same length as the input
|
|
929
946
|
*/
|