@dev-loops/core 1.0.0-rc.3 → 1.0.0-rc.5
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/package.json +4 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +549 -44
- package/src/config/extension-defaults.yaml +38 -5
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +77 -5
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/copilot-ci-status.mjs +46 -18
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +28 -1
- package/src/loop/gate-fanin.mjs +723 -29
- package/src/loop/handoff-envelope.mjs +37 -16
- package/src/loop/issue-refinement-artifact.mjs +60 -18
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +194 -13
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/public-dev-loop-routing.mjs +12 -0
- package/src/loop/retrospective-checkpoint.mjs +77 -13
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- package/src/projects/resolve-project.mjs +1 -1
|
@@ -125,19 +125,52 @@ gates:
|
|
|
125
125
|
Scan PR comments for unresolved issues before declaring the gate clean. Check all PR comments and review threads for: - Comments from the repository owner or collaborators that point out
|
|
126
126
|
implementation bugs, logic errors, contract violations, or security
|
|
127
127
|
issues
|
|
128
|
-
- Unresolved review threads that raise implementation concerns Flag any unresolved comment that identifies a concrete implementation problem as a blocking finding (severity:
|
|
128
|
+
- Unresolved review threads that raise implementation concerns Flag any unresolved comment that identifies a concrete implementation problem as a blocking finding (severity: high or medium). Do not flag: - Resolved threads - Style nits, formatting suggestions, or cosmetic feedback - Comments from non-collaborators or bots - Outdated comments that were already addressed in a later commit If no unresolved implementation concerns exist, return clean.
|
|
129
129
|
- contradiction-lens
|
|
130
130
|
- code-conformance
|
|
131
131
|
- semantic-drift
|
|
132
132
|
- name: pr-description
|
|
133
133
|
mandatory: true
|
|
134
134
|
persona: review
|
|
135
|
-
prompt: 'Review the PR description for completeness, contract fitness, and checkbox formatting before this PR is marked ready for review. The PR body is the implementation contract — it must have: - A Summary section explaining what changed and why - A Scope and context section defining the boundary of the change - An Acceptance criteria section with the linked issue acceptance criteria - A Definition of done section - A Non-goals section - A Validation command section describing exactly how to verify the change - The "Closes #N" line must match the linked issue; flag changes that alter or remove the operator-intended close target Checkboxes (`- [ ]` / `- [x]`, or `* [ ]` / `* [x]`) must appear inside genuine Markdown list items. Flag any checkbox marker used outside a list item (including table cells) as a
|
|
135
|
+
prompt: 'Review the PR description for completeness, contract fitness, and checkbox formatting before this PR is marked ready for review. The PR body is the implementation contract — it must have: - A Summary section explaining what changed and why - A Scope and context section defining the boundary of the change - An Acceptance criteria section with the linked issue acceptance criteria - A Definition of done section - A Non-goals section - A Validation command section describing exactly how to verify the change - The "Closes #N" line must match the linked issue; flag changes that alter or remove the operator-intended close target Checkboxes (`- [ ]` / `- [x]`, or `* [ ]` / `* [x]`) must appear inside genuine Markdown list items. Flag any checkbox marker used outside a list item (including table cells) as a medium finding. Flag any checkbox marker wrapped in backticks (e.g. `` `[x]` ``) as a medium finding. Flag PRs where the body is a single sentence or lacks any of these sections. Do not block on formatting preferences other than checkbox correctness.'
|
|
136
136
|
required: true
|
|
137
137
|
requireCi: true
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
# Diff-class angle tiers (opt-in, ordered, first match wins). A matching tier
|
|
139
|
+
# replaces this gate's resolved angle set with its own for that round's
|
|
140
|
+
# fan-out (mandatory angles are always kept); it never changes execution
|
|
141
|
+
# mode. A tiered round is still a normal fanout_fanin round:
|
|
142
|
+
# tiers:
|
|
143
|
+
# - name: docs-only
|
|
144
|
+
# match: { kinds: [docs] }
|
|
145
|
+
# angles: [pr-description, link-check, gate-evidence]
|
|
146
|
+
# The gate round's verdict review already carries every finding, so the
|
|
147
|
+
# consolidated findings comment is opt-in duplication — keep it off.
|
|
148
|
+
postFindingsComments: false
|
|
149
|
+
# Grouped fan-out dispatch (AC6, default mode — see resolveFanoutGroups):
|
|
150
|
+
# angles that read the same surface batch onto one reviewer. Angles not
|
|
151
|
+
# named below join the auto-chunked leftover pool. Set `mode: per-angle`
|
|
152
|
+
# to restore full one-reviewer-per-angle fan-out repo-wide (bypasses
|
|
153
|
+
# maxAnglesPerGroup: 1 honors configured groups; the two match in unit size only when no configured multi-angle group matches). `gate:full` forces the full angle set but no
|
|
154
|
+
# longer restores per-angle dispatch (ADR 0047 superseded by 0048) — it
|
|
155
|
+
# dispatches grouped.
|
|
156
|
+
#
|
|
157
|
+
# Two orthogonal dispatch bounds (#1601):
|
|
158
|
+
# maxAnglesPerGroup (N, default 3, min 1) — leftover ungrouped angles
|
|
159
|
+
# auto-chunk into dispatch units of ≤N instead of singletons.
|
|
160
|
+
# maxConcurrent (M, default 4, min 1) — at most M dispatch units per wave.
|
|
161
|
+
# Both count dispatch units (groups), not angles.
|
|
162
|
+
fanout:
|
|
163
|
+
maxAnglesPerGroup: 3
|
|
164
|
+
maxConcurrent: 4
|
|
165
|
+
groups:
|
|
166
|
+
- name: docs-surface
|
|
167
|
+
angles: [docs, link-check, config-drift, contract-surface]
|
|
168
|
+
- name: process
|
|
169
|
+
angles: [scope, pr-description, gate-evidence, pr-checklist-matrix]
|
|
170
|
+
- name: correctness-input
|
|
171
|
+
angles: [correctness, input-validation]
|
|
172
|
+
- name: determinism-state
|
|
173
|
+
angles: [determinism, state-concurrency]
|
|
141
174
|
preApproval:
|
|
142
175
|
angles:
|
|
143
176
|
- name: dry
|
|
@@ -6,11 +6,76 @@
|
|
|
6
6
|
* scripts and other packages/core modules.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Exported so anything deciding "is there a real prior review" uses the same
|
|
10
|
+
// whitelist as the loop-state reader — two copies could drift, and a guard
|
|
11
|
+
// acting on the gate's behalf must agree with the gate about what a submitted
|
|
12
|
+
// review is.
|
|
13
|
+
export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
10
14
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
11
15
|
const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
|
|
12
16
|
const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
|
|
13
17
|
|
|
18
|
+
// The literal header line the gate review body always emits first
|
|
19
|
+
// (upsert-checkpoint-verdict.mjs's renderGateReviewCommentBody, re-exported
|
|
20
|
+
// from there). Owned here so the machine-artifact filter below and every
|
|
21
|
+
// consumer that needs to recognize "is this a real gate verdict surface" read
|
|
22
|
+
// the same producer-owned literal instead of restating it. Line-start anchored
|
|
23
|
+
// (`m`) so a quoted header in a reply/blockquote can't match.
|
|
24
|
+
export const GATE_REVIEW_COMMENT_HEADER_RE = /^###\s+Gate review:\s*`(draft_gate|pre_approval_gate)`\s*$/m;
|
|
25
|
+
|
|
26
|
+
/** Returns the matched gate name when `body` carries a genuine gate verdict header, else null. */
|
|
27
|
+
export function matchGateReviewCommentHeader(body) {
|
|
28
|
+
if (typeof body !== "string") return null;
|
|
29
|
+
const match = body.match(GATE_REVIEW_COMMENT_HEADER_RE);
|
|
30
|
+
return match ? match[1] : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Machine-authored gate artifacts that must never win the newest-gate-marker
|
|
34
|
+
// tie-break in summarizeGateReviewComments/summarizeGateReviewCommentMarkers:
|
|
35
|
+
// a historical standalone findings review always embedded this gate's name in
|
|
36
|
+
// its header line and could quote the current head sha inside a finding's own
|
|
37
|
+
// free text (the lenient gate-name+hex-token fallback in
|
|
38
|
+
// parseGateReviewCommentFields would otherwise happily match that), and the
|
|
39
|
+
// historical deferred-summary PR comment quoted a gate name plus a sha-shaped
|
|
40
|
+
// id in its table rows the same way. Both are excluded HERE, inside the two
|
|
41
|
+
// shared summarizers, because this module is the true merge point: every
|
|
42
|
+
// consumer (detect-checkpoint-evidence.mjs, pre-pr-ready-gate.mjs,
|
|
43
|
+
// ready-for-review.mjs, request-copilot-review.mjs) calls
|
|
44
|
+
// summarizeGateReviewComments/summarizeGateReviewCommentMarkers to turn a raw
|
|
45
|
+
// comment/review list into a gate verdict, so filtering here — rather than
|
|
46
|
+
// per-caller — covers all of them by construction.
|
|
47
|
+
//
|
|
48
|
+
// Anchored to the start of a line (`^` with `m`) so only a marker rendered as
|
|
49
|
+
// the first character of its own line is excluded — a genuine verdict
|
|
50
|
+
// comment whose findings summary merely QUOTES the marker text mid-line (for
|
|
51
|
+
// example, describing this very mechanism) still counts as evidence. Both
|
|
52
|
+
// producers render their marker at column 0, so the anchor costs nothing
|
|
53
|
+
// against genuine artifacts.
|
|
54
|
+
// The set covers exactly three marker tokens: the per-round review round
|
|
55
|
+
// marker (gate-findings-review), post-gate-findings.mjs's opt-in findings
|
|
56
|
+
// COMMENT marker (gate-findings gate=...), and the historical
|
|
57
|
+
// deferred-summary comment. Without the findings-comment marker, that comment
|
|
58
|
+
// parses as a verdict marker candidate (its "Gate fan-out findings:"/
|
|
59
|
+
// "Reviewed head:" lines yield gate+headSha) and the verdict upsert claims
|
|
60
|
+
// and overwrites it in place, silently destroying the round's visible
|
|
61
|
+
// findings record. Every branch is delimiter-anchored — the token must be
|
|
62
|
+
// followed by whitespace or the closing `-->` — so no suffixed `<token>-<x>`
|
|
63
|
+
// variant ever matches.
|
|
64
|
+
const GATE_MACHINE_ARTIFACT_MARKER_RE = /^<!--\s*dev-loops:(?:gate-findings-review|gate-findings|deferred-summary)(?=\s|-->)/mu;
|
|
65
|
+
|
|
66
|
+
export function isGateMachineArtifactBody(body) {
|
|
67
|
+
if (typeof body !== "string" || !GATE_MACHINE_ARTIFACT_MARKER_RE.test(body)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
// A gate round now posts ONE PR review carrying BOTH the verdict header and
|
|
71
|
+
// the gate-findings-review marker (the findings it files live on that same
|
|
72
|
+
// surface). Such a body IS the verdict, not a separate machine artifact, so
|
|
73
|
+
// the producer-owned verdict header wins over the artifact marker. Only a
|
|
74
|
+
// marker-bearing body with NO genuine verdict header (a historical standalone
|
|
75
|
+
// findings review or deferred-summary comment) stays excluded.
|
|
76
|
+
return matchGateReviewCommentHeader(body) === null;
|
|
77
|
+
}
|
|
78
|
+
|
|
14
79
|
export function isCopilotLogin(login) {
|
|
15
80
|
return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
|
|
16
81
|
}
|
|
@@ -234,50 +299,86 @@ function parseGateReviewCommentFields(body) {
|
|
|
234
299
|
}
|
|
235
300
|
const line = stripped;
|
|
236
301
|
|
|
302
|
+
// First-NON-EMPTY-wins per field: a genuine comment renders its structured
|
|
303
|
+
// block first, so the first column-0 match for each field is normally the
|
|
304
|
+
// real one. A free-text field (findings summary, next action) rendered
|
|
305
|
+
// later in the SAME comment can embed a newline plus a spoofed
|
|
306
|
+
// "Verdict: clean" (or any other field label) at column 0; capturing only
|
|
307
|
+
// the first match (rather than the last) stops that later line from
|
|
308
|
+
// winning and flipping/nulling the field. But the label regex's
|
|
309
|
+
// `\s*(.+)$` also matches a label followed by nothing but whitespace,
|
|
310
|
+
// capturing an empty string — for the enum fields (gate/headSha/verdict/
|
|
311
|
+
// executionMode) an empty capture normalizes to null already, so the
|
|
312
|
+
// `=== null` guard below naturally stays open for a later, genuine line.
|
|
313
|
+
// The two free-text fields (findingsSummary, nextAction) do NOT normalize
|
|
314
|
+
// through an enum, so an empty capture must be checked for explicitly:
|
|
315
|
+
// treat it as no-capture (leave the field open) rather than locking it to
|
|
316
|
+
// "" and hiding a real line that renders after it.
|
|
237
317
|
let match = line.match(/^(?:[-*]\s*)?(?:gate(?:\s+name)?|gate\s+review)\s*:\s*(.+)$/iu);
|
|
238
318
|
if (match) {
|
|
239
|
-
fields.gate
|
|
319
|
+
if (fields.gate === null) {
|
|
320
|
+
fields.gate = normalizeGateReviewName(match[1]);
|
|
321
|
+
}
|
|
240
322
|
continue;
|
|
241
323
|
}
|
|
242
324
|
|
|
243
325
|
match = line.match(/^(?:[-*]\s*)?(?:head\s+sha(?:\s+reviewed)?|reviewed\s+head\s+sha)\s*:\s*(.+)$/iu);
|
|
244
326
|
if (match) {
|
|
245
|
-
fields.headSha
|
|
327
|
+
if (fields.headSha === null) {
|
|
328
|
+
fields.headSha = normalizeGateReviewHeadSha(match[1]);
|
|
329
|
+
}
|
|
246
330
|
continue;
|
|
247
331
|
}
|
|
248
332
|
|
|
249
333
|
match = line.match(/^(?:[-*]\s*)?verdict\s*:\s*(.+)$/iu);
|
|
250
334
|
if (match) {
|
|
251
|
-
fields.verdict
|
|
335
|
+
if (fields.verdict === null) {
|
|
336
|
+
fields.verdict = normalizeGateReviewVerdict(match[1]);
|
|
337
|
+
}
|
|
252
338
|
continue;
|
|
253
339
|
}
|
|
254
340
|
|
|
255
341
|
match = line.match(/^(?:[-*]\s*)?(?:findings(?:\s+summary)?|summary)\s*:\s*(.+)$/iu);
|
|
256
342
|
if (match) {
|
|
257
|
-
fields.findingsSummary
|
|
343
|
+
if (fields.findingsSummary === null) {
|
|
344
|
+
const candidate = match[1].trim();
|
|
345
|
+
// An empty capture (label followed only by whitespace) is treated as
|
|
346
|
+
// no-capture: leave the field open so a later, genuine line can still
|
|
347
|
+
// win instead of first-wins locking it to "".
|
|
348
|
+
if (candidate.length > 0) {
|
|
349
|
+
fields.findingsSummary = candidate;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
258
352
|
continue;
|
|
259
353
|
}
|
|
260
354
|
|
|
261
355
|
match = line.match(/^(?:[-*]\s*)?next\s+action\s*:\s*(.+)$/iu);
|
|
262
356
|
if (match) {
|
|
263
|
-
fields.nextAction
|
|
357
|
+
if (fields.nextAction === null) {
|
|
358
|
+
const candidate = match[1].trim();
|
|
359
|
+
if (candidate.length > 0) {
|
|
360
|
+
fields.nextAction = candidate;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
264
363
|
continue;
|
|
265
364
|
}
|
|
266
365
|
|
|
267
366
|
match = line.match(/^(?:[-*]\s*)?execution\s+mode\s*:\s*(.+)$/iu);
|
|
268
367
|
if (match) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
fields.
|
|
368
|
+
if (fields.executionMode === null) {
|
|
369
|
+
const rest = match[1].trim();
|
|
370
|
+
// Split on the first em-dash / en-dash / " - " separator to recover an
|
|
371
|
+
// optional inline reason: "inline_single_agent — <reason>".
|
|
372
|
+
const sepMatch = rest.match(/^(.*?)\s*(?:[—–]|\s-\s)\s*(.*)$/u);
|
|
373
|
+
const modeToken = sepMatch ? sepMatch[1].trim() : rest;
|
|
374
|
+
const reasonToken = sepMatch ? sepMatch[2].trim() : "";
|
|
375
|
+
fields.executionMode = normalizeGateExecutionMode(modeToken);
|
|
376
|
+
// Only record an inline reason for inline_single_agent. A trailing
|
|
377
|
+
// "— text" on a fanout_fanin (or invalid) mode line must not surface an
|
|
378
|
+
// inconsistent mode/reason pair, so leave inlineReason null otherwise.
|
|
379
|
+
if (reasonToken.length > 0 && fields.executionMode === "inline_single_agent") {
|
|
380
|
+
fields.inlineReason = reasonToken;
|
|
381
|
+
}
|
|
281
382
|
}
|
|
282
383
|
continue;
|
|
283
384
|
}
|
|
@@ -353,6 +454,18 @@ export function parseGateReviewCommentMarkerBody(body) {
|
|
|
353
454
|
};
|
|
354
455
|
}
|
|
355
456
|
|
|
457
|
+
// Which GitHub surface carries a gate verdict. The poster needs it to pick the
|
|
458
|
+
// right in-place correction endpoint on a same-head rerun (a PR review is PUT
|
|
459
|
+
// to pulls/{pr}/reviews/{id}; a legacy verdict issue comment is PATCHed to
|
|
460
|
+
// issues/comments/{id}). Anything that is not the review surface — including a
|
|
461
|
+
// raw issue-comment payload with no `surface` field — is issue_comment, so the
|
|
462
|
+
// historical shape survives untouched. SINGLE definition: a restatement that
|
|
463
|
+
// misses a future third surface would silently route its body to the
|
|
464
|
+
// issue-comment endpoint, where it does not live.
|
|
465
|
+
export function normalizeVerdictSurface(value) {
|
|
466
|
+
return value === "review" ? "review" : "issue_comment";
|
|
467
|
+
}
|
|
468
|
+
|
|
356
469
|
export function summarizeGateReviewComments(comments) {
|
|
357
470
|
const summary = {
|
|
358
471
|
draft_gate: null,
|
|
@@ -363,6 +476,9 @@ export function summarizeGateReviewComments(comments) {
|
|
|
363
476
|
|
|
364
477
|
for (let index = 0; index < entries.length; index += 1) {
|
|
365
478
|
const comment = entries[index];
|
|
479
|
+
if (isGateMachineArtifactBody(comment?.body)) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
366
482
|
const parsed = parseGateReviewCommentBody(comment?.body);
|
|
367
483
|
if (!parsed) {
|
|
368
484
|
continue;
|
|
@@ -378,6 +494,7 @@ export function summarizeGateReviewComments(comments) {
|
|
|
378
494
|
nextAction: parsed.nextAction,
|
|
379
495
|
executionMode: parsed.executionMode ?? null,
|
|
380
496
|
inlineReason: parsed.inlineReason ?? null,
|
|
497
|
+
surface: normalizeVerdictSurface(comment?.surface),
|
|
381
498
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
382
499
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
383
500
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
@@ -409,6 +526,9 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
409
526
|
|
|
410
527
|
for (let index = 0; index < entries.length; index += 1) {
|
|
411
528
|
const comment = entries[index];
|
|
529
|
+
if (isGateMachineArtifactBody(comment?.body)) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
412
532
|
const parsed = parseGateReviewCommentMarkerBody(comment?.body);
|
|
413
533
|
if (!parsed) {
|
|
414
534
|
continue;
|
|
@@ -429,6 +549,7 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
429
549
|
executionMode: parsed.executionMode ?? null,
|
|
430
550
|
inlineReason: parsed.inlineReason ?? null,
|
|
431
551
|
contractComplete: parsed.contractComplete,
|
|
552
|
+
surface: normalizeVerdictSurface(comment?.surface),
|
|
432
553
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
433
554
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
434
555
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
package/src/github/issue-ops.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { runChild as defaultRunChild } from "../cli/primitives.mjs";
|
|
4
4
|
import { parseJsonText } from "./review-threads.mjs";
|
|
5
5
|
import { parseRepoSlug } from "./repo-slug.mjs";
|
|
@@ -62,7 +62,36 @@ export function buildCreateArgs(options) {
|
|
|
62
62
|
return args;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Reject a --body-file path that does not RESOLVE (following symlinks) to a
|
|
66
|
+
// regular file. The CLI layer's literal-string rejections (`-`, `/dev/stdin`,
|
|
67
|
+
// `/dev/fd/N`, ...) only catch known stdin-device spellings; a symlink to one
|
|
68
|
+
// of those devices (or to /dev/null, a FIFO, etc.) dodges that regex yet still
|
|
69
|
+
// reads as empty/non-file when `gh` re-reads the same path with stdin ignored.
|
|
70
|
+
// `statSync` follows symlinks, so this closes that gap regardless of path shape.
|
|
71
|
+
function assertRegularFilePath(path) {
|
|
72
|
+
if (!statSync(path).isFile()) {
|
|
73
|
+
throw new Error(`--body-file must be a regular file: ${path}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Read (for validation only — the actual gh call still forwards the path, see
|
|
78
|
+
// buildCreateArgs) and reject a --body-file that isn't a regular file or whose
|
|
79
|
+
// content is empty/whitespace-only. This is the real guard behind the CLI's
|
|
80
|
+
// stdin-device rejection: `gh` is spawned with stdin ignored, so it re-reads
|
|
81
|
+
// the same path fresh — this validates what gh will actually see, not just the
|
|
82
|
+
// path's literal spelling.
|
|
83
|
+
export async function resolveCreateBody(options) {
|
|
84
|
+
if (options.bodyFile === undefined) return options.body;
|
|
85
|
+
assertRegularFilePath(options.bodyFile);
|
|
86
|
+
return await readFile(options.bodyFile, "utf8");
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
export async function createIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
90
|
+
const body = await resolveCreateBody(options);
|
|
91
|
+
if (typeof body !== "string" || body.trim().length === 0) {
|
|
92
|
+
const source = options.bodyFile !== undefined ? `--body-file ${options.bodyFile}` : "--body";
|
|
93
|
+
throw new Error(`issue body resolved empty from ${source} — refusing to create a bodyless issue`);
|
|
94
|
+
}
|
|
66
95
|
const args = buildCreateArgs(options);
|
|
67
96
|
const result = await run(ghCommand, args, env);
|
|
68
97
|
if (result.code !== 0) {
|
|
@@ -126,12 +155,55 @@ export async function buildEditArgs(options) {
|
|
|
126
155
|
return { args, edited };
|
|
127
156
|
}
|
|
128
157
|
|
|
158
|
+
// gh's own --reason values are space-separated ("not planned"), but the
|
|
159
|
+
// CLI-facing flag value stays the underscore form (`not_planned`) since it's
|
|
160
|
+
// stable and shell-friendly without quoting; map it here at the gh-args
|
|
161
|
+
// boundary rather than changing the public flag value.
|
|
162
|
+
const REASON_ARG_BY_CLI_VALUE = { not_planned: "not planned" };
|
|
163
|
+
|
|
164
|
+
// Build the `gh issue close`/`gh issue reopen` args for a --state change. Kept
|
|
165
|
+
// as a separate `gh` call from `gh issue edit` — that command has no --state
|
|
166
|
+
// flag, so a state change is its own invocation, run after the edit call.
|
|
167
|
+
export function buildStateChangeArgs(options) {
|
|
168
|
+
if (options.state === "closed") {
|
|
169
|
+
const args = ["issue", "close", String(options.issue), "--repo", options.repo];
|
|
170
|
+
if (options.reason !== undefined) {
|
|
171
|
+
args.push("--reason", REASON_ARG_BY_CLI_VALUE[options.reason] ?? options.reason);
|
|
172
|
+
}
|
|
173
|
+
return args;
|
|
174
|
+
}
|
|
175
|
+
if (options.state !== "open") {
|
|
176
|
+
// Fail closed: this is an exported seam, so an unexpected state must never
|
|
177
|
+
// silently degrade into a reopen.
|
|
178
|
+
throw new Error(`invalid state ${JSON.stringify(options.state)} — expected "open" or "closed"`);
|
|
179
|
+
}
|
|
180
|
+
return ["issue", "reopen", String(options.issue), "--repo", options.repo];
|
|
181
|
+
}
|
|
182
|
+
|
|
129
183
|
export async function editIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
130
184
|
const { args, edited } = await buildEditArgs(options);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
185
|
+
// Skip the edit call entirely when --state is the only change requested —
|
|
186
|
+
// `gh issue edit` with no field flags errors ("no changed fields").
|
|
187
|
+
if (edited.length > 0) {
|
|
188
|
+
const result = await run(ghCommand, args, env);
|
|
189
|
+
if (result.code !== 0) {
|
|
190
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
191
|
+
throw new Error(`gh issue edit failed: ${detail}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (options.state !== undefined) {
|
|
195
|
+
const stateArgs = buildStateChangeArgs(options);
|
|
196
|
+
const result = await run(ghCommand, stateArgs, env);
|
|
197
|
+
if (result.code !== 0) {
|
|
198
|
+
const verb = options.state === "closed" ? "close" : "reopen";
|
|
199
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
200
|
+
// Surface the edits that DID land before the state change failed, so a
|
|
201
|
+
// caller (or a human reading the error) knows the field edits are not
|
|
202
|
+
// rolled back — only the state change itself failed.
|
|
203
|
+
const landed = edited.length > 0 ? ` after edits were applied: ${edited.join(", ")}` : "";
|
|
204
|
+
throw new Error(`state change failed${landed} — gh issue ${verb} failed: ${detail}`);
|
|
205
|
+
}
|
|
206
|
+
edited.push("state");
|
|
135
207
|
}
|
|
136
208
|
return { ok: true, repo: options.repo, issue: options.issue, edited };
|
|
137
209
|
}
|
|
@@ -166,6 +166,34 @@ export function parseReviewThreads(payload) {
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The fix-loop's re-entry working set: only unresolved threads, each with its
|
|
171
|
+
* comment bodies joined in thread order. Location fields come from the thread
|
|
172
|
+
* node when the payload carries them (`path`/`line`/`isOutdated`); snapshots
|
|
173
|
+
* without them yield `path: null`, `line: null`, `isOutdated: false`.
|
|
174
|
+
*
|
|
175
|
+
* @returns {{ summary: object, threads: Array<{ threadId: string, path: string|null, line: number|null, isOutdated: boolean, bodies: string[] }> }}
|
|
176
|
+
*/
|
|
177
|
+
export function parseUnresolvedThreadBodies(payload) {
|
|
178
|
+
const rawThreads = extractRawThreads(payload);
|
|
179
|
+
const { summary } = parseReviewThreads(payload);
|
|
180
|
+
const threads = rawThreads
|
|
181
|
+
.map((thread, threadIndex) => ({ thread, threadIndex }))
|
|
182
|
+
.filter(({ thread }) => !thread?.isResolved)
|
|
183
|
+
.map(({ thread, threadIndex }) => ({
|
|
184
|
+
threadId: normalizeId(thread?.id ?? thread?.databaseId, `thread-${threadIndex + 1}`),
|
|
185
|
+
path: typeof thread?.path === "string" && thread.path.length > 0 ? thread.path : null,
|
|
186
|
+
line: Number.isInteger(thread?.line) ? thread.line : null,
|
|
187
|
+
isOutdated: Boolean(thread?.isOutdated),
|
|
188
|
+
bodies: extractRawComments(thread).map((comment) =>
|
|
189
|
+
normalizeBody(comment?.body ?? comment?.bodyText ?? comment?.bodyHTML ?? ""),
|
|
190
|
+
),
|
|
191
|
+
}))
|
|
192
|
+
.sort((left, right) => compareIds(left.threadId, right.threadId));
|
|
193
|
+
|
|
194
|
+
return { summary, threads };
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
// ── Signal classification heuristics ──────────────────────────────────────
|
|
170
198
|
|
|
171
199
|
const HIGH_SIGNAL_PATTERNS = [
|
|
@@ -303,10 +331,23 @@ export function parseJsonText(text) {
|
|
|
303
331
|
}
|
|
304
332
|
}
|
|
305
333
|
|
|
306
|
-
|
|
334
|
+
// Renders the one shared { ok: false, error, hint? } envelope every
|
|
335
|
+
// JSON-emitting gate CLI's main() catch block prints to stderr. `usage` is
|
|
336
|
+
// accepted only as a PRESENCE check for a fallback usage string (a caller
|
|
337
|
+
// passing its own `USAGE` constant when the error itself might not already
|
|
338
|
+
// carry one) — the fallback's actual TEXT, like `error.usage`'s, is never
|
|
339
|
+
// embedded here. Argument errors (and any error a caller marks with a usage
|
|
340
|
+
// string) used to inline that string's full multi-KB text into this JSON
|
|
341
|
+
// payload, which every calling agent then paid to read back out of its own
|
|
342
|
+
// tool result on every mistyped flag. A one-line `hint` pointing at --help
|
|
343
|
+
// carries the same "usage exists, go look" signal at a fraction of the size;
|
|
344
|
+
// --help itself is unaffected (it prints the full USAGE text directly, never
|
|
345
|
+
// through this function).
|
|
346
|
+
export function formatCliError(error, { usage } = {}) {
|
|
307
347
|
const payload = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
308
|
-
|
|
309
|
-
|
|
348
|
+
const hasUsage = (error instanceof Error && typeof error.usage === "string") || typeof usage === "string";
|
|
349
|
+
if (hasUsage) {
|
|
350
|
+
payload.hint = "run with --help for usage";
|
|
310
351
|
}
|
|
311
352
|
return JSON.stringify(payload);
|
|
312
353
|
}
|
|
@@ -6,16 +6,31 @@ const STATUS_CONTEXT_PENDING_STATES = new Set(["PENDING", "EXPECTED"]);
|
|
|
6
6
|
const STATUS_CONTEXT_SUCCESS_STATES = new Set(["SUCCESS"]);
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Name of the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* Name of the explicit commit STATUS dev-loops posts on its own pull requests
|
|
10
|
+
* from `.github/workflows/gate-evidence.yml`. Its conclusion is DERIVED from
|
|
11
|
+
* the loop's own progress (a clean current-head pre_approval_gate verdict),
|
|
12
|
+
* not an independent build/test signal — so the loop must exclude it when
|
|
13
|
+
* deriving the CI status that gates its own pre_approval step. Otherwise the
|
|
14
|
+
* loop could never post the very verdict that would turn this check green
|
|
15
|
+
* (#1358). This constant names ONLY the status context; the workflow also
|
|
16
|
+
* surfaces as a check run, so anything partitioning check runs must use
|
|
17
|
+
* `LOOP_DERIVED_CI_CHECK_NAMES` below. It remains the label reported in
|
|
18
|
+
* `excludedFailureDetails` for either shape, since both name one workflow.
|
|
16
19
|
*/
|
|
17
20
|
export const LOOP_DERIVED_CI_CHECK_NAME = "gate-evidence";
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The same workflow ALSO surfaces as a check run under its job id
|
|
24
|
+
* (`gate-evidence-runner`) beside the commit status named above, and both are
|
|
25
|
+
* the loop's own derived signal. Excluding only the status context left the
|
|
26
|
+
* runner's conclusion gating the loop's own pre_approval step: once the
|
|
27
|
+
* workflow gained job-level concurrency, a superseded run is cancelled as
|
|
28
|
+
* normal operation, and a cancelled run is deliberately NOT treated as green
|
|
29
|
+
* (see normalizeStatusCheckRollupStatus) — so one routine cancellation made
|
|
30
|
+
* the whole head read "none" and the loop waited on CI forever.
|
|
31
|
+
*/
|
|
32
|
+
export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([LOOP_DERIVED_CI_CHECK_NAME, "gate-evidence-runner"]);
|
|
33
|
+
|
|
19
34
|
function checkEntryName(entry) {
|
|
20
35
|
if (typeof entry?.name === "string" && entry.name.length > 0) return entry.name;
|
|
21
36
|
if (typeof entry?.context === "string" && entry.context.length > 0) return entry.context;
|
|
@@ -28,15 +43,16 @@ function checkEntryName(entry) {
|
|
|
28
43
|
* both use `.name` (check-runs also use `.context` for legacy StatusContext).
|
|
29
44
|
*
|
|
30
45
|
* @param {Array<object>} entries
|
|
31
|
-
* @param {string} targetName
|
|
46
|
+
* @param {string|Array<string>} targetName One name, or several to match.
|
|
32
47
|
* @returns {{ matched: Array<object>, rest: Array<object> }}
|
|
33
48
|
*/
|
|
34
49
|
export function partitionEntriesByCheckName(entries, targetName) {
|
|
35
50
|
const list = Array.isArray(entries) ? entries : [];
|
|
51
|
+
const targets = new Set(Array.isArray(targetName) ? targetName : [targetName]);
|
|
36
52
|
const matched = [];
|
|
37
53
|
const rest = [];
|
|
38
54
|
for (const entry of list) {
|
|
39
|
-
(checkEntryName(entry)
|
|
55
|
+
(targets.has(checkEntryName(entry)) ? matched : rest).push(entry);
|
|
40
56
|
}
|
|
41
57
|
return { matched, rest };
|
|
42
58
|
}
|
|
@@ -128,25 +144,36 @@ export function normalizeStatusCheckRollupStatus(rollup) {
|
|
|
128
144
|
/**
|
|
129
145
|
* Summarize the GitHub check-runs API payload for one head SHA.
|
|
130
146
|
*
|
|
147
|
+
* `allQueued` is the zero-allocation stall signal (#1631): true when at least one
|
|
148
|
+
* check-run is present AND every one is still in the `queued` status — i.e. no
|
|
149
|
+
* runner has been allocated to any job (no job picked up / in_progress / completed).
|
|
150
|
+
* The CI watcher uses it to bail early on a stuck GitHub Actions queue instead
|
|
151
|
+
* of burning the full watch budget.
|
|
152
|
+
*
|
|
131
153
|
* @param {object} payload
|
|
132
|
-
* @returns {{ status: "success"|"failure"|"pending"|"none", unsupportedCompleted: boolean, failureDetails?: Array<string> }}
|
|
154
|
+
* @returns {{ status: "success"|"failure"|"pending"|"none", unsupportedCompleted: boolean, allQueued: boolean, failureDetails?: Array<string> }}
|
|
133
155
|
*/
|
|
134
156
|
export function summarizeHeadScopedCheckRunsSignal(payload) {
|
|
135
157
|
const runs = Array.isArray(payload?.check_runs) ? payload.check_runs : [];
|
|
136
158
|
if (runs.length === 0) {
|
|
137
|
-
return { status: "none", unsupportedCompleted: false };
|
|
159
|
+
return { status: "none", unsupportedCompleted: false, allQueued: false };
|
|
138
160
|
}
|
|
139
161
|
|
|
140
162
|
let hasPending = false;
|
|
141
163
|
let hasFailure = false;
|
|
142
164
|
let hasSuccess = false;
|
|
143
165
|
let hasUnsupportedCompleted = false;
|
|
166
|
+
let allQueued = true; // every run is status "queued" (zero runner allocation)
|
|
144
167
|
const failureDetails = [];
|
|
145
168
|
|
|
146
169
|
for (const run of runs) {
|
|
147
170
|
const status = typeof run?.status === "string" ? run.status.toUpperCase() : "";
|
|
148
171
|
const conclusion = typeof run?.conclusion === "string" ? run.conclusion.toUpperCase() : "";
|
|
149
172
|
|
|
173
|
+
if (status !== "QUEUED") {
|
|
174
|
+
allQueued = false;
|
|
175
|
+
}
|
|
176
|
+
|
|
150
177
|
if (status !== "COMPLETED") {
|
|
151
178
|
hasPending = true;
|
|
152
179
|
continue;
|
|
@@ -167,11 +194,11 @@ export function summarizeHeadScopedCheckRunsSignal(payload) {
|
|
|
167
194
|
hasUnsupportedCompleted = true;
|
|
168
195
|
}
|
|
169
196
|
|
|
170
|
-
if (hasFailure) return { status: "failure", unsupportedCompleted: hasUnsupportedCompleted, failureDetails };
|
|
171
|
-
if (hasPending) return { status: "pending", unsupportedCompleted: hasUnsupportedCompleted, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
|
|
172
|
-
if (hasUnsupportedCompleted) return { status: "none", unsupportedCompleted: true, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
|
|
173
|
-
if (hasSuccess) return { status: "success", unsupportedCompleted: false, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
|
|
174
|
-
return { status: "none", unsupportedCompleted: false, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
|
|
197
|
+
if (hasFailure) return { status: "failure", unsupportedCompleted: hasUnsupportedCompleted, allQueued, failureDetails };
|
|
198
|
+
if (hasPending) return { status: "pending", unsupportedCompleted: hasUnsupportedCompleted, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
|
|
199
|
+
if (hasUnsupportedCompleted) return { status: "none", unsupportedCompleted: true, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
|
|
200
|
+
if (hasSuccess) return { status: "success", unsupportedCompleted: false, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
|
|
201
|
+
return { status: "none", unsupportedCompleted: false, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
|
|
175
202
|
}
|
|
176
203
|
|
|
177
204
|
/**
|
|
@@ -292,7 +319,8 @@ export function normalizeHeadScopedCiContract({
|
|
|
292
319
|
|
|
293
320
|
/**
|
|
294
321
|
* Derive a loop-safe CI status from a PR `statusCheckRollup` snapshot: the
|
|
295
|
-
* `
|
|
322
|
+
* `LOOP_DERIVED_CI_CHECK_NAMES` entries (the `gate-evidence` status and the
|
|
323
|
+
* workflow's own `gate-evidence-runner` check run) are excluded from the
|
|
296
324
|
* status computation before it can block, and surfaced separately so a
|
|
297
325
|
* genuinely failing check right beside it can never be masked. Every reason
|
|
298
326
|
* gate-evidence can be red (missing draft_gate/pre_approval evidence,
|
|
@@ -305,7 +333,7 @@ export function normalizeHeadScopedCiContract({
|
|
|
305
333
|
* @returns {{ status: "success"|"failure"|"pending"|"none", excludedFailureDetails: Array<string> }}
|
|
306
334
|
*/
|
|
307
335
|
export function deriveLoopCiStatusFromRollup(rollup) {
|
|
308
|
-
const { matched, rest } = partitionEntriesByCheckName(rollup,
|
|
336
|
+
const { matched, rest } = partitionEntriesByCheckName(rollup, LOOP_DERIVED_CI_CHECK_NAMES);
|
|
309
337
|
const status = normalizeStatusCheckRollupStatus(rest);
|
|
310
338
|
const excludedFailureDetails = matched.length > 0 && normalizeStatusCheckRollupStatus(matched) === "failure"
|
|
311
339
|
? [LOOP_DERIVED_CI_CHECK_NAME]
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
|
|
1
|
+
import { SUBMITTED_REVIEW_STATES, isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
|
|
2
2
|
|
|
3
3
|
const ACTIVE_COPILOT_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
|
|
4
|
-
const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
5
4
|
|
|
6
5
|
function normalizeReviewRequestEvents(events) {
|
|
7
6
|
if (!Array.isArray(events)) {
|