@gethmy/harness 1.2.1 → 1.4.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/dist/cli.js +497 -225
- package/dist/index.js +1259 -402
- package/package.json +2 -2
- package/src/ci-failure.ts +465 -0
- package/src/cli.ts +11 -1
- package/src/confine-to-repo.test.ts +324 -1
- package/src/confine-to-repo.ts +274 -22
- package/src/error-classifier.ts +52 -1
- package/src/gate-collectors.ts +11 -3
- package/src/git-pr.ts +461 -8
- package/src/index.ts +2 -0
- package/src/model-tier.test.ts +11 -6
- package/src/model-tier.ts +4 -4
- package/src/oracle-collector.ts +244 -23
- package/src/oracle.ts +856 -108
- package/src/pm.ts +15 -5
- package/src/repair-sandbox.test.ts +116 -0
- package/src/repair-sandbox.ts +303 -0
- package/src/run-sizing.test.ts +264 -66
- package/src/run-sizing.ts +146 -26
- package/src/sdk-agent-runner.ts +22 -1
- package/src/worktree.ts +114 -2
package/src/git-pr.ts
CHANGED
|
@@ -137,15 +137,124 @@ export function upsertReviewedSha(description: string, sha: string): string {
|
|
|
137
137
|
return `${description}${sep}${line}`;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// The independent-review request ledger (card #1003). Deliberately the same
|
|
141
|
+
// shape and channel as `Reviewed-SHA` above: one line in the card description,
|
|
142
|
+
// keyed by the head it applies to.
|
|
143
|
+
//
|
|
144
|
+
// It exists to bound cost, not to record a verdict. Requesting a CI review is a
|
|
145
|
+
// remove-then-add of a label, which GitHub answers with a fresh paid Opus/Sonnet
|
|
146
|
+
// pass — and the merge monitor ticks every 60s. Without a per-head record of
|
|
147
|
+
// "already asked", a review that has not yet registered a check run would be
|
|
148
|
+
// re-requested on every tick, billing a full review a minute. Keyed by SHA
|
|
149
|
+
// rather than a bare boolean so that a branch which moves after its review
|
|
150
|
+
// (the `rereview` path) correctly asks again for the new head.
|
|
151
|
+
const CI_REVIEW_REQUESTED_SHA_RE =
|
|
152
|
+
/^CI-Review-Requested-SHA:\s*([0-9a-f]{7,40})\s*$/im;
|
|
153
|
+
|
|
154
|
+
export function extractCiReviewRequestedSha(
|
|
155
|
+
description: string | null,
|
|
156
|
+
): string | null {
|
|
157
|
+
if (!description) return null;
|
|
158
|
+
const m = description.match(CI_REVIEW_REQUESTED_SHA_RE);
|
|
159
|
+
return m ? m[1] : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function upsertCiReviewRequestedSha(
|
|
163
|
+
description: string,
|
|
164
|
+
sha: string,
|
|
165
|
+
): string {
|
|
166
|
+
const line = `CI-Review-Requested-SHA: ${sha}`;
|
|
167
|
+
if (CI_REVIEW_REQUESTED_SHA_RE.test(description)) {
|
|
168
|
+
return description.replace(CI_REVIEW_REQUESTED_SHA_RE, line);
|
|
169
|
+
}
|
|
170
|
+
const sep = description ? "\n" : "";
|
|
171
|
+
return `${description}${sep}${line}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Whether a rollup entry is the check called `wanted` — matched against the
|
|
176
|
+
* check-run name (`review`) or its workflow name (`Claude PR Review`), so an
|
|
177
|
+
* operator can configure whichever of the two their own UI shows them.
|
|
178
|
+
*
|
|
179
|
+
* `wanted` must already be trimmed and lower-cased. A blank one matches nothing
|
|
180
|
+
* rather than the first entry: the callers below all read a configured name, and
|
|
181
|
+
* a typo that silently matched *something* is the dangerous direction.
|
|
182
|
+
*
|
|
183
|
+
* Known gap, inherited from #1003 and deliberately left as it was: a real legacy
|
|
184
|
+
* `StatusContext` carries its name in `context`, not `name`, so it is never
|
|
185
|
+
* matched here — only a CheckRun is. Both readers below still have a
|
|
186
|
+
* `StatusContext` branch, reachable only for an entry carrying both fields. It
|
|
187
|
+
* costs nothing today (GitHub Actions produces CheckRuns, and #1018's verdict is
|
|
188
|
+
* created through the Checks API) and widening it would change what the shipped
|
|
189
|
+
* #1003 reader matches, which is not this card's to change.
|
|
190
|
+
*/
|
|
191
|
+
function checkMatchesName(c: Record<string, unknown>, wanted: string): boolean {
|
|
192
|
+
if (!wanted) return false;
|
|
193
|
+
const name = typeof c.name === "string" ? c.name.toLowerCase() : "";
|
|
194
|
+
const workflow =
|
|
195
|
+
typeof c.workflowName === "string" ? c.workflowName.toLowerCase() : "";
|
|
196
|
+
return name === wanted || workflow === wanted;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether a rollup entry is the CHECK RUN called `skip` — by its own name only,
|
|
201
|
+
* never by workflow name. That restriction is the whole point (#1018).
|
|
202
|
+
*
|
|
203
|
+
* One workflow now publishes two checks with opposite meanings (`review` says a
|
|
204
|
+
* pass ran, `review-verdict` says what it concluded), and `checkMatchesName`
|
|
205
|
+
* above accepts the workflow name — a spelling `.github/CLAUDE.md` documents.
|
|
206
|
+
* So an operator on `checkName: "Claude PR Review"` has BOTH entries matching
|
|
207
|
+
* the review reader, and a truncated review beside a neutral verdict read as
|
|
208
|
+
* `passed`: the failure was reported as a healthy review, with nothing on the
|
|
209
|
+
* board. Each reader therefore skips the other's check.
|
|
210
|
+
*
|
|
211
|
+
* Skipping by workflow name would be worse than not skipping: both entries
|
|
212
|
+
* carry the same workflow name, so it would erase the reader's own check too and
|
|
213
|
+
* hold every merge on `absent`.
|
|
214
|
+
*/
|
|
215
|
+
function isNamedCheck(
|
|
216
|
+
c: Record<string, unknown>,
|
|
217
|
+
skip: string | undefined,
|
|
218
|
+
): boolean {
|
|
219
|
+
if (!skip) return false;
|
|
220
|
+
const wanted = skip.trim().toLowerCase();
|
|
221
|
+
if (!wanted) return false;
|
|
222
|
+
return typeof c.name === "string" && c.name.toLowerCase() === wanted;
|
|
223
|
+
}
|
|
224
|
+
|
|
140
225
|
/** Derive a single CI verdict from a `gh pr view --json statusCheckRollup` array.
|
|
141
226
|
* Handles both CheckRun ({status, conclusion}) and legacy StatusContext ({state}).
|
|
142
|
-
* Empty/non-array (no checks configured) → unknown (conservative: never auto-merge).
|
|
143
|
-
|
|
227
|
+
* Empty/non-array (no checks configured) → unknown (conservative: never auto-merge).
|
|
228
|
+
*
|
|
229
|
+
* `excludeChecks` names checks this verdict must NOT speak for (card #1018).
|
|
230
|
+
* The review checks are the only intended members: they are read separately by
|
|
231
|
+
* the two functions below, and folding them in here made the collapsed word lie
|
|
232
|
+
* about WHOSE failure it was. A red `review` check meant "the review ran out of
|
|
233
|
+
* turns" and a red `review-verdict` check means "the reviewer found something",
|
|
234
|
+
* yet both arrived at the merge gate as an indistinguishable `failure` — which
|
|
235
|
+
* stamped `CI checks failed` on the card and, with `ciRepair.enabled`, re-ran a
|
|
236
|
+
* CI that was never broken.
|
|
237
|
+
*
|
|
238
|
+
* Excluding is not the same as ignoring: every excluded state is answered by
|
|
239
|
+
* `deriveIndependentReviewState` / `deriveReviewVerdictState`, and both HOLD a
|
|
240
|
+
* merge on anything but a pass. The rollup being empty *after* exclusion still
|
|
241
|
+
* reads `success` rather than `unknown`, because `unknown` means "this repo
|
|
242
|
+
* configures no checks at all" — a repo whose only check is the review has a
|
|
243
|
+
* green build by that repo's own definition, and the review gates it below.
|
|
244
|
+
*/
|
|
245
|
+
export function deriveCiStatus(
|
|
246
|
+
rollup: unknown,
|
|
247
|
+
excludeChecks: string[] = [],
|
|
248
|
+
): PrCiStatus {
|
|
144
249
|
if (!Array.isArray(rollup) || rollup.length === 0) return "unknown";
|
|
250
|
+
const excluded = excludeChecks
|
|
251
|
+
.map((n) => n.trim().toLowerCase())
|
|
252
|
+
.filter(Boolean);
|
|
145
253
|
let anyPending = false;
|
|
146
254
|
for (const check of rollup) {
|
|
147
255
|
if (typeof check !== "object" || check === null) continue;
|
|
148
256
|
const c = check as Record<string, unknown>;
|
|
257
|
+
if (excluded.some((name) => checkMatchesName(c, name))) continue;
|
|
149
258
|
if (typeof c.status === "string") {
|
|
150
259
|
// CheckRun
|
|
151
260
|
if (c.status.toUpperCase() !== "COMPLETED") {
|
|
@@ -171,14 +280,314 @@ export function deriveCiStatus(rollup: unknown): PrCiStatus {
|
|
|
171
280
|
return anyPending ? "pending" : "success";
|
|
172
281
|
}
|
|
173
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Whether an INDEPENDENT review — one produced outside the daemon that wrote the
|
|
285
|
+
* code — has run against the PR's current head (card #1003).
|
|
286
|
+
*
|
|
287
|
+
* `deriveCiStatus` above cannot answer this, and that is the whole bug: it folds
|
|
288
|
+
* `SKIPPED` in with `SUCCESS` (see its `["SUCCESS", "NEUTRAL", "SKIPPED"]`
|
|
289
|
+
* line). A daemon PR on an `agent/` branch has its review job skipped by the
|
|
290
|
+
* workflow's own branch rule, so the rollup carries a real check run reading
|
|
291
|
+
* `{name: "review", conclusion: "SKIPPED"}` — and the collapsed verdict for that
|
|
292
|
+
* PR is `"success"`. The merge gate could not tell "reviewed and clean" from
|
|
293
|
+
* "nobody looked".
|
|
294
|
+
*
|
|
295
|
+
* `absent` and `skipped` are kept apart on purpose. `skipped` means the workflow
|
|
296
|
+
* exists and declined this branch, which a `ci-review` request can fix. `absent`
|
|
297
|
+
* means no such check is configured on this repo at all — a request would be
|
|
298
|
+
* shouting into a void — or the head moved and the rollup has not caught up yet.
|
|
299
|
+
* Both hold the merge; only the caller can say which deserves a request.
|
|
300
|
+
*/
|
|
301
|
+
export type IndependentReviewState =
|
|
302
|
+
| "passed"
|
|
303
|
+
| "failed"
|
|
304
|
+
| "pending"
|
|
305
|
+
| "skipped"
|
|
306
|
+
| "absent";
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Read one named check out of a `statusCheckRollup` array.
|
|
310
|
+
*
|
|
311
|
+
* `checkName` matches either the check-run name (`review`) or its workflow name
|
|
312
|
+
* (`Claude PR Review`), case-insensitively, so an operator can configure
|
|
313
|
+
* whichever of the two they can see in their own UI.
|
|
314
|
+
*
|
|
315
|
+
* Precedence when several runs share the name — a re-request makes this normal —
|
|
316
|
+
* is `passed > pending > failed > skipped`. A pass anywhere means someone
|
|
317
|
+
* independent did read this head, which is the question being asked; a pending
|
|
318
|
+
* re-run outranks the failed run it is retrying, so the gate waits for the
|
|
319
|
+
* answer instead of acting on the stale one.
|
|
320
|
+
*/
|
|
321
|
+
export function deriveIndependentReviewState(
|
|
322
|
+
rollup: unknown,
|
|
323
|
+
checkName: string,
|
|
324
|
+
/**
|
|
325
|
+
* The VERDICT check's name, skipped by check-run name (#1018). Required
|
|
326
|
+
* whenever a verdict check exists: `checkName` may legitimately be the
|
|
327
|
+
* workflow name, which both checks share, and without this a truncated
|
|
328
|
+
* review sitting beside a neutral verdict reads as `passed`.
|
|
329
|
+
*/
|
|
330
|
+
skipCheckName?: string,
|
|
331
|
+
): IndependentReviewState {
|
|
332
|
+
if (!Array.isArray(rollup)) return "absent";
|
|
333
|
+
const wanted = checkName.trim().toLowerCase();
|
|
334
|
+
if (!wanted) return "absent";
|
|
335
|
+
|
|
336
|
+
let sawPending = false;
|
|
337
|
+
let sawFailed = false;
|
|
338
|
+
let sawSkipped = false;
|
|
339
|
+
|
|
340
|
+
for (const check of rollup) {
|
|
341
|
+
if (typeof check !== "object" || check === null) continue;
|
|
342
|
+
const c = check as Record<string, unknown>;
|
|
343
|
+
if (isNamedCheck(c, skipCheckName)) continue;
|
|
344
|
+
if (!checkMatchesName(c, wanted)) continue;
|
|
345
|
+
|
|
346
|
+
// CheckRun. A run that has not COMPLETED is in flight whatever else it says.
|
|
347
|
+
if (typeof c.status === "string") {
|
|
348
|
+
if (c.status.toUpperCase() !== "COMPLETED") {
|
|
349
|
+
sawPending = true;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const conclusion =
|
|
353
|
+
typeof c.conclusion === "string" ? c.conclusion.toUpperCase() : "";
|
|
354
|
+
if (conclusion === "SKIPPED") {
|
|
355
|
+
sawSkipped = true;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
// NEUTRAL counts as a pass for the same reason `deriveCiStatus` accepts
|
|
359
|
+
// it: the reviewer ran and declined to fail the PR. What matters here is
|
|
360
|
+
// that an independent pass happened, not what it concluded — which is
|
|
361
|
+
// this function's whole scope, and why `deriveReviewVerdictState` below
|
|
362
|
+
// exists to answer the other half (#1018).
|
|
363
|
+
if (conclusion === "SUCCESS" || conclusion === "NEUTRAL") return "passed";
|
|
364
|
+
sawFailed = true;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Legacy StatusContext — no notion of "skipped".
|
|
369
|
+
if (typeof c.state === "string") {
|
|
370
|
+
const state = c.state.toUpperCase();
|
|
371
|
+
if (state === "SUCCESS") return "passed";
|
|
372
|
+
if (state === "PENDING") {
|
|
373
|
+
sawPending = true;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
sawFailed = true;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (sawPending) return "pending";
|
|
381
|
+
if (sawFailed) return "failed";
|
|
382
|
+
if (sawSkipped) return "skipped";
|
|
383
|
+
return "absent";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* What the independent review CONCLUDED, as opposed to whether it ran (#1018).
|
|
388
|
+
*
|
|
389
|
+
* `deriveIndependentReviewState` above answers "did anyone outside this daemon
|
|
390
|
+
* read this head?", which is all #1003 claimed. It cannot answer "and did they
|
|
391
|
+
* object?", because a routine `claude-review.yml` pass posts its findings as PR
|
|
392
|
+
* comments and exits 0 — so a review that wrote `critical: …` on the PR arrived
|
|
393
|
+
* at the merge gate looking exactly like a clean one, and under sweep nobody
|
|
394
|
+
* read those comments before the merge.
|
|
395
|
+
*
|
|
396
|
+
* The verdict therefore needs its own signal, and the workflow now publishes one
|
|
397
|
+
* as a separate check run (`review-verdict`) whose conclusion carries the
|
|
398
|
+
* reviewer's own answer. Reading a *distinct* check rather than failing the
|
|
399
|
+
* review job is what keeps the two questions separable: the review check stays
|
|
400
|
+
* "it ran", so a truncated review and a review that found a bug remain
|
|
401
|
+
* different states on the board instead of collapsing into one red X.
|
|
402
|
+
*
|
|
403
|
+
* The five states and why each is what it is:
|
|
404
|
+
* - `clean` — SUCCESS. The reviewer says nothing blocks the merge.
|
|
405
|
+
* - `blocking` — FAILURE. It found something a person must resolve.
|
|
406
|
+
* - `unknown` — NEUTRAL or SKIPPED. The run produced no verdict to read (it
|
|
407
|
+
* truncated, or it never emitted the line). Held, not waved through: "no
|
|
408
|
+
* answer" is not "no findings".
|
|
409
|
+
* - `pending` — a run under this name is still going.
|
|
410
|
+
* - `absent` — no such check on this head. The repo's workflow predates the
|
|
411
|
+
* verdict, the head moved and the rollup has not caught up, or the API call
|
|
412
|
+
* that publishes it failed (a fork PR gets a read-only token). Also held.
|
|
413
|
+
*/
|
|
414
|
+
export type ReviewVerdictState =
|
|
415
|
+
| "clean"
|
|
416
|
+
| "blocking"
|
|
417
|
+
| "unknown"
|
|
418
|
+
| "pending"
|
|
419
|
+
| "absent";
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Read the verdict check out of a `statusCheckRollup` array.
|
|
423
|
+
*
|
|
424
|
+
* Precedence when several runs share the name — a re-request makes this normal —
|
|
425
|
+
* is the INVERSE of `deriveIndependentReviewState`'s: `blocking > pending >
|
|
426
|
+
* unknown > clean`. That reader takes the most permissive answer because a
|
|
427
|
+
* single independent read satisfies it; this one takes the most restrictive,
|
|
428
|
+
* because a finding does not stop being real when a later pass over the same
|
|
429
|
+
* head fails to repeat it. The remedy for a blocking verdict is a commit that
|
|
430
|
+
* fixes it, which moves the head and asks again with a clean slate.
|
|
431
|
+
*/
|
|
432
|
+
export function deriveReviewVerdictState(
|
|
433
|
+
rollup: unknown,
|
|
434
|
+
checkName: string,
|
|
435
|
+
/**
|
|
436
|
+
* The REVIEW-RAN check's name, skipped by check-run name — the mirror of the
|
|
437
|
+
* argument on the reader above, so the two questions stay separable whichever
|
|
438
|
+
* of the two spellings an operator configured.
|
|
439
|
+
*/
|
|
440
|
+
skipCheckName?: string,
|
|
441
|
+
): ReviewVerdictState {
|
|
442
|
+
if (!Array.isArray(rollup)) return "absent";
|
|
443
|
+
const wanted = checkName.trim().toLowerCase();
|
|
444
|
+
if (!wanted) return "absent";
|
|
445
|
+
|
|
446
|
+
let sawPending = false;
|
|
447
|
+
let sawUnknown = false;
|
|
448
|
+
let sawClean = false;
|
|
449
|
+
|
|
450
|
+
for (const check of rollup) {
|
|
451
|
+
if (typeof check !== "object" || check === null) continue;
|
|
452
|
+
const c = check as Record<string, unknown>;
|
|
453
|
+
if (isNamedCheck(c, skipCheckName)) continue;
|
|
454
|
+
if (!checkMatchesName(c, wanted)) continue;
|
|
455
|
+
|
|
456
|
+
// CheckRun. A run that has not COMPLETED has no verdict yet.
|
|
457
|
+
if (typeof c.status === "string") {
|
|
458
|
+
if (c.status.toUpperCase() !== "COMPLETED") {
|
|
459
|
+
sawPending = true;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const conclusion =
|
|
463
|
+
typeof c.conclusion === "string" ? c.conclusion.toUpperCase() : "";
|
|
464
|
+
if (conclusion === "SUCCESS") {
|
|
465
|
+
sawClean = true;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
// NEUTRAL is the workflow saying "I could not read a verdict", and
|
|
469
|
+
// SKIPPED is it declining to evaluate one. Neither is a pass here —
|
|
470
|
+
// unlike in the two readers above, where NEUTRAL means a reviewer ran and
|
|
471
|
+
// chose not to fail the PR.
|
|
472
|
+
if (conclusion === "NEUTRAL" || conclusion === "SKIPPED") {
|
|
473
|
+
sawUnknown = true;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
return "blocking";
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Legacy StatusContext — a commit status has no neutral, so the only
|
|
480
|
+
// readings available are pass, in flight, and everything else.
|
|
481
|
+
if (typeof c.state === "string") {
|
|
482
|
+
const state = c.state.toUpperCase();
|
|
483
|
+
if (state === "SUCCESS") {
|
|
484
|
+
sawClean = true;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (state === "PENDING") {
|
|
488
|
+
sawPending = true;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
return "blocking";
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (sawPending) return "pending";
|
|
496
|
+
if (sawUnknown) return "unknown";
|
|
497
|
+
if (sawClean) return "clean";
|
|
498
|
+
return "absent";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The three answers `readPrChecks` derives from one rollup. */
|
|
502
|
+
export interface PrCheckStates {
|
|
503
|
+
ciStatus: PrCiStatus;
|
|
504
|
+
independentReview: IndependentReviewState;
|
|
505
|
+
reviewVerdict: ReviewVerdictState;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Turn one `statusCheckRollup` into three orthogonal answers (#1018).
|
|
510
|
+
*
|
|
511
|
+
* Pure, and split out of `getPrStatus` deliberately: this wiring — which name
|
|
512
|
+
* goes to which reader, which names come out of the collapsed CI word, and which
|
|
513
|
+
* name each reader must skip — is where both of the review's majors lived, and a
|
|
514
|
+
* unit test on the readers alone could not reach it.
|
|
515
|
+
*
|
|
516
|
+
* The three do not overlap: `ciStatus` speaks for the build/test checks,
|
|
517
|
+
* `independentReview` for whether a review ran, and `reviewVerdict` for what it
|
|
518
|
+
* said. **Every NAMED check is excluded from `ciStatus`**, so the merge gate can
|
|
519
|
+
* never stamp `CI checks failed` for something a review gate already owns — and
|
|
520
|
+
* each reader skips the other's check by run name, so one workflow publishing
|
|
521
|
+
* both cannot make either answer for the other.
|
|
522
|
+
*
|
|
523
|
+
* Naming a check is all-or-nothing per caller: omit `independentReviewCheck` and
|
|
524
|
+
* the review's state is not reported AND its failures land back in `ciStatus` —
|
|
525
|
+
* exactly the pre-#1003 behaviour an operator with `independentReview.enabled:
|
|
526
|
+
* false` asked for. The caller therefore names the verdict check whenever the
|
|
527
|
+
* review gate is on, whether or not it GATES on the verdict, because the
|
|
528
|
+
* alternative is a check this card introduced becoming a new red build for
|
|
529
|
+
* someone who opted out of reading it.
|
|
530
|
+
*/
|
|
531
|
+
export function readPrChecks(
|
|
532
|
+
rollup: unknown,
|
|
533
|
+
checks: {
|
|
534
|
+
independentReviewCheck?: string;
|
|
535
|
+
reviewVerdictCheck?: string;
|
|
536
|
+
} = {},
|
|
537
|
+
): PrCheckStates {
|
|
538
|
+
const { independentReviewCheck, reviewVerdictCheck } = checks;
|
|
539
|
+
return {
|
|
540
|
+
ciStatus: deriveCiStatus(
|
|
541
|
+
rollup,
|
|
542
|
+
[independentReviewCheck, reviewVerdictCheck].filter(
|
|
543
|
+
(n): n is string => typeof n === "string",
|
|
544
|
+
),
|
|
545
|
+
),
|
|
546
|
+
independentReview: independentReviewCheck
|
|
547
|
+
? deriveIndependentReviewState(
|
|
548
|
+
rollup,
|
|
549
|
+
independentReviewCheck,
|
|
550
|
+
reviewVerdictCheck,
|
|
551
|
+
)
|
|
552
|
+
: "absent",
|
|
553
|
+
reviewVerdict: reviewVerdictCheck
|
|
554
|
+
? deriveReviewVerdictState(
|
|
555
|
+
rollup,
|
|
556
|
+
reviewVerdictCheck,
|
|
557
|
+
independentReviewCheck,
|
|
558
|
+
)
|
|
559
|
+
: "absent",
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* One `gh pr view` read, three orthogonal answers about the same head.
|
|
565
|
+
* See {@link readPrChecks} for what each one speaks for and what it excludes.
|
|
566
|
+
*/
|
|
174
567
|
export async function getPrStatus(
|
|
175
568
|
prUrl: string,
|
|
176
569
|
cwd: string,
|
|
177
570
|
provider: GitProvider,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
571
|
+
/**
|
|
572
|
+
* Check to read the independent-review state from. Omitted → not reported,
|
|
573
|
+
* and its result stays inside `ciStatus`.
|
|
574
|
+
*/
|
|
575
|
+
independentReviewCheck?: string,
|
|
576
|
+
/** Check to read the review VERDICT from (#1018). Same all-or-nothing rule. */
|
|
577
|
+
reviewVerdictCheck?: string,
|
|
578
|
+
): Promise<{
|
|
579
|
+
ciStatus: PrCiStatus;
|
|
580
|
+
headSha: string | null;
|
|
581
|
+
independentReview: IndependentReviewState;
|
|
582
|
+
reviewVerdict: ReviewVerdictState;
|
|
583
|
+
}> {
|
|
584
|
+
const unreadable = {
|
|
585
|
+
ciStatus: "unknown",
|
|
586
|
+
headSha: null,
|
|
587
|
+
independentReview: "absent",
|
|
588
|
+
reviewVerdict: "absent",
|
|
589
|
+
} as const;
|
|
590
|
+
if (provider !== "github" || !isValidPrUrl(prUrl)) return { ...unreadable };
|
|
182
591
|
try {
|
|
183
592
|
const { stdout } = await execFileAsync()(
|
|
184
593
|
"gh",
|
|
@@ -191,10 +600,54 @@ export async function getPrStatus(
|
|
|
191
600
|
};
|
|
192
601
|
const headSha =
|
|
193
602
|
typeof parsed.headRefOid === "string" ? parsed.headRefOid : null;
|
|
194
|
-
return {
|
|
603
|
+
return {
|
|
604
|
+
...readPrChecks(parsed.statusCheckRollup, {
|
|
605
|
+
independentReviewCheck,
|
|
606
|
+
reviewVerdictCheck,
|
|
607
|
+
}),
|
|
608
|
+
headSha,
|
|
609
|
+
};
|
|
610
|
+
} catch {
|
|
611
|
+
return { ...unreadable };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Ask CI for an independent review by (re-)applying its request label.
|
|
617
|
+
*
|
|
618
|
+
* The remove-then-add is not defensive coding, it is the API: GitHub emits
|
|
619
|
+
* `labeled` only on an absent→present transition, so an already-labelled PR
|
|
620
|
+
* needs the label taken off first or the workflow never fires. The remove is
|
|
621
|
+
* expected to fail when the label is not there, and that failure is discarded.
|
|
622
|
+
*
|
|
623
|
+
* Callers must rate-limit themselves — every successful call bills a full review
|
|
624
|
+
* pass. `extractCiReviewRequestedSha` / `upsertCiReviewRequestedSha` above are
|
|
625
|
+
* the intended ledger.
|
|
626
|
+
*/
|
|
627
|
+
export async function requestIndependentReview(
|
|
628
|
+
prUrl: string,
|
|
629
|
+
cwd: string,
|
|
630
|
+
provider: GitProvider,
|
|
631
|
+
label: string,
|
|
632
|
+
): Promise<void> {
|
|
633
|
+
if (provider !== "github") {
|
|
634
|
+
throw new Error(`independent review request unsupported for "${provider}"`);
|
|
635
|
+
}
|
|
636
|
+
const opts = { cwd, encoding: "utf-8" as const, timeout: 30_000 };
|
|
637
|
+
try {
|
|
638
|
+
await execFileAsync()(
|
|
639
|
+
"gh",
|
|
640
|
+
["pr", "edit", prUrl, "--remove-label", label],
|
|
641
|
+
opts,
|
|
642
|
+
);
|
|
195
643
|
} catch {
|
|
196
|
-
|
|
644
|
+
// Not present, so nothing to clear. The add below is the operative half.
|
|
197
645
|
}
|
|
646
|
+
await execFileAsync()(
|
|
647
|
+
"gh",
|
|
648
|
+
["pr", "edit", prUrl, "--add-label", label],
|
|
649
|
+
opts,
|
|
650
|
+
);
|
|
198
651
|
}
|
|
199
652
|
|
|
200
653
|
export async function mergePullRequest(
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
export const MOTOR_NAME = "harmony-harness";
|
|
14
14
|
|
|
15
15
|
export * from "./artifact-judge.js";
|
|
16
|
+
export * from "./ci-failure.js";
|
|
16
17
|
export * from "./command-metric.js";
|
|
17
18
|
export * from "./confine-to-repo.js";
|
|
18
19
|
export * from "./error-classifier.js";
|
|
@@ -30,6 +31,7 @@ export * from "./oracle-collector.js";
|
|
|
30
31
|
export * from "./pm.js";
|
|
31
32
|
export * from "./process-group.js";
|
|
32
33
|
export * from "./project-type.js";
|
|
34
|
+
export * from "./repair-sandbox.js";
|
|
33
35
|
export * from "./revert-guard.js";
|
|
34
36
|
export * from "./review-types.js";
|
|
35
37
|
export * from "./run-sizing.js";
|
package/src/model-tier.test.ts
CHANGED
|
@@ -14,10 +14,9 @@ const claude: ModelTierConfig = {
|
|
|
14
14
|
tiers: { simple: "haiku", advanced: "sonnet", research: "opus" },
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
function card(p: Partial<Card>): Card {
|
|
17
|
+
function card(p: Partial<Card> & Record<string, unknown>): Card {
|
|
18
18
|
return {
|
|
19
19
|
priority: "medium",
|
|
20
|
-
model_tier: null,
|
|
21
20
|
model_override: null,
|
|
22
21
|
...p,
|
|
23
22
|
} as Card;
|
|
@@ -120,12 +119,18 @@ describe("chooseImplementModel", () => {
|
|
|
120
119
|
).toEqual({ model: "claude-opus-4-8", escalated: true, source: "policy" });
|
|
121
120
|
});
|
|
122
121
|
|
|
123
|
-
it("ignores card
|
|
124
|
-
// The
|
|
125
|
-
//
|
|
122
|
+
it("ignores a stale classifier tier still riding on the card object", () => {
|
|
123
|
+
// The columns are dropped, but a cached board row — TanStack Query on web,
|
|
124
|
+
// the persisted zustand store on mobile — can still carry the retired keys
|
|
125
|
+
// for as long as that cache lives. `model_override` is the ONLY card-level
|
|
126
|
+
// model control; anything else on the row must change nothing.
|
|
126
127
|
const r = chooseImplementModel(
|
|
127
128
|
claude,
|
|
128
|
-
card({
|
|
129
|
+
card({
|
|
130
|
+
model_tier: "research",
|
|
131
|
+
complexity_score: 9,
|
|
132
|
+
intent: "implement",
|
|
133
|
+
}),
|
|
129
134
|
1,
|
|
130
135
|
undefined,
|
|
131
136
|
);
|
package/src/model-tier.ts
CHANGED
|
@@ -74,13 +74,13 @@ export interface RunSizing {
|
|
|
74
74
|
* 2. `sized.tier` — the pickup preflight. On a retry (attempts >=
|
|
75
75
|
* escalateAfterAttempts) the tier bumps one level before resolving.
|
|
76
76
|
*
|
|
77
|
-
* This replaces the retired
|
|
78
|
-
*
|
|
77
|
+
* This replaces the retired create-time classifier, which wrote a tier
|
|
78
|
+
* onto the card by guessing engineering effort from a title composed
|
|
79
79
|
* before anyone had looked at the code. The preflight runs at pickup with
|
|
80
80
|
* the repo readable, so the answer is measured rather than guessed — and
|
|
81
81
|
* it is run-scoped, so a stale value can never outlive the run it was for.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
82
|
+
* The card column that held that guess has been dropped, so there is no
|
|
83
|
+
* card-level tier left to read: `model_override` is the only one.
|
|
84
84
|
* 3. Global policy fallback — escalate on high/urgent priority or on a retry.
|
|
85
85
|
* This is also where EVERY preflight failure lands (spawn error, timeout,
|
|
86
86
|
* malformed output, or an operator who disabled it), so a broken preflight
|