@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7
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 +12 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +204 -5
- package/src/cli/primitives.mjs +51 -1
- package/src/config/config.mjs +307 -14
- package/src/config/extension-defaults.yaml +39 -1
- package/src/github/comment-id-guard.mjs +158 -0
- package/src/github/copilot-helpers.mjs +145 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +13 -0
- package/src/loop/agent-stall.mjs +196 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +35 -2
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +40 -20
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +73 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +1034 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +38 -28
- package/src/security/secret-scan.mjs +330 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISSUE/PR-ID GUARD for generated comment bodies.
|
|
3
|
+
*
|
|
4
|
+
* Mandate (#1731, operator directive): generated gate/review/verdict comment
|
|
5
|
+
* bodies must NEVER emit raw issue or PR ids. Public comment surfaces are
|
|
6
|
+
* world-readable, and a bare `#<digits>` in a comment body is auto-linked by
|
|
7
|
+
* GitHub to that issue/PR — leaking internal cross-references and violating
|
|
8
|
+
* the no-ids-in-comments rule.
|
|
9
|
+
*
|
|
10
|
+
* This helper fails CLOSED: it refuses (throws) a body that contains a raw
|
|
11
|
+
* `#<digits>` token, unless that id is explicitly allowlisted as a deliberate
|
|
12
|
+
* cross-reference (`allowedRefs`). There is deliberately NO silent stripping —
|
|
13
|
+
* a stripped id could silently drop a needed cross-ref while still posting;
|
|
14
|
+
* refusal forces the caller to make the cross-ref deliberate (or reword).
|
|
15
|
+
*
|
|
16
|
+
* Wire this into every comment/review write helper that posts a GENERATED
|
|
17
|
+
* body (verdict comments, gate findings reviews, inline finding comments,
|
|
18
|
+
* review-thread replies, and the generic comment/edit writers). Applying it at
|
|
19
|
+
* the low-level POST/PATCH write point means current AND future comment flows
|
|
20
|
+
* are guarded automatically — a future writer that routes through these
|
|
21
|
+
* helpers cannot emit an issue/PR id without an explicit allowlist entry.
|
|
22
|
+
*
|
|
23
|
+
* Deliberate cross-reference mechanism: pass the id(s) to allow as
|
|
24
|
+
* `allowedRefs: ["1670"]`. Aside from a genuine HTML numeric character
|
|
25
|
+
* reference (`&#<digits>;`, e.g. `[` for `[`) — each such OCCURRENCE is
|
|
26
|
+
* skipped; the same digit run appearing elsewhere as a bare token still
|
|
27
|
+
* refuses — this is the ONLY sanctioned way a generated comment body may
|
|
28
|
+
* carry a `#<digits>` token. Extraction is decode-aware on BOTH sides of the
|
|
29
|
+
* token: the body is also scanned after a single left-to-right decode of the
|
|
30
|
+
* entity forms GitHub's renderer resolves (numeric character references,
|
|
31
|
+
* zero-padding and hex included at cmark-gfm's 8-digit bound, plus the named
|
|
32
|
+
* hash entity),
|
|
33
|
+
* so a hash or any digit of the id smuggled as an entity — `#123`,
|
|
34
|
+
* `#123`, `#123`, any mix — still refuses. The decode is single-pass
|
|
35
|
+
* like the renderer's: a double-encoded form (`&#35;123`) renders as
|
|
36
|
+
* inert literal text and the decode pass never manufactures a refusal of its
|
|
37
|
+
* INNER id — though the raw scan still refuses the outer digit run of the
|
|
38
|
+
* numeric form as a pre-existing fail-closed near-miss (the outer entity's
|
|
39
|
+
* semicolon sits before the hash, so the well-formed-entity exclusion does
|
|
40
|
+
* not apply). Case-variants of the named hash entity are decoded too even
|
|
41
|
+
* where GitHub would not (`&NUM;`): deliberate over-refusal, keeping the
|
|
42
|
+
* guard fail-closed. Keep the allowlist small and deliberate.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
// Matches a bare GitHub auto-link issue/PR reference: `#<digits>`. Bound to
|
|
46
|
+
// 1..9 digits to avoid absurd ids while covering the full GitHub id space.
|
|
47
|
+
// A match is excluded only when it forms a well-formed HTML numeric character
|
|
48
|
+
// reference — preceded by `&` AND immediately followed by `;` (e.g. `[`,
|
|
49
|
+
// the entity-encoded form of `[`). Any other shape (a bare `#123`, an
|
|
50
|
+
// `&`-preceded run with no terminating `;`, or a `;`-followed run with no
|
|
51
|
+
// preceding `&`) is not a well-formed entity and still refuses as a genuine
|
|
52
|
+
// auto-link candidate.
|
|
53
|
+
const ISSUE_PR_ID_RE = /#(\d{1,9})/g;
|
|
54
|
+
|
|
55
|
+
function isNumericCharacterReference(body, match) {
|
|
56
|
+
// The digit bound mirrors cmark-gfm's 8-digit entity parser: a 9-digit
|
|
57
|
+
// ampersand-wrapped run is NOT decoded by the renderer, so it renders
|
|
58
|
+
// literally and must not be excluded as a spent entity.
|
|
59
|
+
return match[1].length <= 8
|
|
60
|
+
&& body[match.index - 1] === "&"
|
|
61
|
+
&& body[match.index + match[0].length] === ";";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Entity forms the renderer resolves that can participate in assembling a
|
|
65
|
+
// rendered `#<digits>` auto-link: numeric character references (any code
|
|
66
|
+
// point — the hash AND the digits themselves are smuggleable) plus the named
|
|
67
|
+
// hash entity. The digit bounds match cmark-gfm's numeric-entity parser
|
|
68
|
+
// (up to 8 digits, decimal or hex) so nothing GitHub decodes escapes the
|
|
69
|
+
// pass. Single non-rescanning replace = one decode, like the renderer, so a
|
|
70
|
+
// double-encoded form's output is never re-read as a fresh entity.
|
|
71
|
+
const DECODABLE_ENTITY_RE = /&(?:#(?:\d{1,8}|x[0-9a-f]{1,8})|num);/gi;
|
|
72
|
+
|
|
73
|
+
function decodeRenderedText(body) {
|
|
74
|
+
return body.replace(DECODABLE_ENTITY_RE, (entity) => {
|
|
75
|
+
const inner = entity.slice(1, -1).toLowerCase();
|
|
76
|
+
if (inner === "num") return "#";
|
|
77
|
+
const code = inner[1] === "x" ? Number.parseInt(inner.slice(2), 16) : Number.parseInt(inner.slice(1), 10);
|
|
78
|
+
try {
|
|
79
|
+
return String.fromCodePoint(code);
|
|
80
|
+
} catch {
|
|
81
|
+
// cmark substitutes the replacement character for a reference it cannot
|
|
82
|
+
// decode; mirroring that keeps decodable-shaped text from lingering in
|
|
83
|
+
// the decoded scan, where it could masquerade as a spent entity.
|
|
84
|
+
return "�";
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collectBareIds(text, found, { excludeEntities }) {
|
|
90
|
+
for (const m of text.matchAll(ISSUE_PR_ID_RE)) {
|
|
91
|
+
if (excludeEntities && isNumericCharacterReference(text, m)) continue;
|
|
92
|
+
found.add(m[1]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract the raw issue/PR id tokens found in a body (as strings, deduped).
|
|
98
|
+
* Scans the body as written AND after a single renderer-like entity decode,
|
|
99
|
+
* so an id assembled from entity-encoded pieces is still found. The entity
|
|
100
|
+
* exclusion applies only to the RAW scan: in decoded text the renderer's one
|
|
101
|
+
* decode is already spent, so an ampersand-then-digits-then-semicolon shape
|
|
102
|
+
* there is plain text a wrapper cannot re-protect (an ampersand-wrapped
|
|
103
|
+
* encoded hash plus digits must refuse, not hide). Returns [] for non-string
|
|
104
|
+
* input (and for a body with no `#<digits>`).
|
|
105
|
+
*/
|
|
106
|
+
export function extractIssuePrIds(body) {
|
|
107
|
+
if (typeof body !== "string" || body.length === 0) return [];
|
|
108
|
+
const found = new Set();
|
|
109
|
+
collectBareIds(body, found, { excludeEntities: true });
|
|
110
|
+
const decoded = decodeRenderedText(body);
|
|
111
|
+
if (decoded !== body) collectBareIds(decoded, found, { excludeEntities: false });
|
|
112
|
+
return [...found];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A caller-supplied allowlist is normally already an array (or other
|
|
116
|
+
// iterable) of ids. Guard the one mis-shaped input that would otherwise
|
|
117
|
+
// silently produce the wrong set: a plain CSV string. `Array.from` over a
|
|
118
|
+
// string character-splits it ("1670" -> ["1","6","7","0"]), which would
|
|
119
|
+
// spuriously allowlist single-digit refs while still refusing the id the
|
|
120
|
+
// caller meant to allow. Mirrors parseAllowedRefsCsv's comma-split (trim,
|
|
121
|
+
// drop empties) — deliberately without its numeric validation, since this is
|
|
122
|
+
// a permissive low-level guard, not the CLI arg parser.
|
|
123
|
+
function normalizeAllowedRefs(allowedRefs) {
|
|
124
|
+
if (allowedRefs == null) return [];
|
|
125
|
+
if (typeof allowedRefs === "string") {
|
|
126
|
+
return allowedRefs.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
127
|
+
}
|
|
128
|
+
return Array.from(allowedRefs, (id) => String(id));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fail-closed guard: returns `body` unchanged when it contains no raw
|
|
133
|
+
* issue/PR id (or every id it contains is explicitly allowlisted). Throws
|
|
134
|
+
* otherwise, refusing to emit the body.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} body - the generated comment body to guard.
|
|
137
|
+
* @param {object} [opts]
|
|
138
|
+
* @param {string} [opts.ref] - human label for the guarded surface (error context).
|
|
139
|
+
* @param {Iterable<number|string>|string} [opts.allowedRefs] - explicit
|
|
140
|
+
* allowlist of deliberate cross-reference ids permitted to appear in the
|
|
141
|
+
* body. A plain string is treated as a comma-separated list (like a CLI
|
|
142
|
+
* `--allowed-refs` value), never character-split.
|
|
143
|
+
* @returns {string} the (unchanged, since no stripping) body.
|
|
144
|
+
*/
|
|
145
|
+
export function guardCommentBodyNoIssuePrIds(body, { ref = "generated comment body", allowedRefs = [] } = {}) {
|
|
146
|
+
if (typeof body !== "string") return body;
|
|
147
|
+
const allow = new Set(normalizeAllowedRefs(allowedRefs));
|
|
148
|
+
const offending = extractIssuePrIds(body).filter((id) => !allow.has(id));
|
|
149
|
+
if (offending.length > 0) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`comment-id-guard refused to emit ${ref}: contains raw issue/PR id reference(s) ` +
|
|
152
|
+
`#${offending.join(", #")}. Bare #digits in generated comment bodies violate the ` +
|
|
153
|
+
`no-ids-in-comments rule (public leakage). Reword to a generic reference, or pass the ` +
|
|
154
|
+
`id(s) to allowedRefs on the guarded write to make an explicit deliberate cross-reference.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return body;
|
|
158
|
+
}
|
|
@@ -6,14 +6,32 @@
|
|
|
6
6
|
* scripts and other packages/core modules.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { GATE_REVIEW_VERDICT_SET } from "../loop/policy-constants.mjs";
|
|
10
|
+
import { trimmedOrNull } from "../loop/normalize.mjs";
|
|
11
|
+
|
|
9
12
|
// Exported so anything deciding "is there a real prior review" uses the same
|
|
10
13
|
// whitelist as the loop-state reader — two copies could drift, and a guard
|
|
11
14
|
// acting on the gate's behalf must agree with the gate about what a submitted
|
|
12
15
|
// review is.
|
|
13
16
|
export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
14
17
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
15
|
-
|
|
18
|
+
// `review` (the standalone review entrypoint, upsert-checkpoint-verdict.mjs's
|
|
19
|
+
// `--gate review`) is a RECOGNIZED gate header — it is identified, not
|
|
20
|
+
// absent — but carries no draft/pre-approval evidence by design (#1808 AC3).
|
|
21
|
+
// Recognizing it (rather than leaving it unrecognized) is what lets
|
|
22
|
+
// parseGateReviewCommentFields below short-circuit to null the instant a
|
|
23
|
+
// `review` header is seen, instead of falling through to the lenient
|
|
24
|
+
// draft_gate/pre_approval_gate token scan — the fallthrough that previously
|
|
25
|
+
// let a `review` verdict whose findings text merely MENTIONED "draft_gate"
|
|
26
|
+
// get recorded as real draft-gate evidence (a draft-gate bypass).
|
|
27
|
+
const NON_EVIDENCE_GATE_NAMES = new Set(["review"]);
|
|
28
|
+
const RECOGNIZED_GATE_NAMES = new Set([...GATE_REVIEW_NAMES, ...NON_EVIDENCE_GATE_NAMES]);
|
|
16
29
|
const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
|
|
30
|
+
// Size-budget outcome vocabulary — mirrors
|
|
31
|
+
// check-size-budget.mjs's computeSizeBudget outcome enum exactly; this file
|
|
32
|
+
// never recomputes the outcome, only round-trips it through the verdict
|
|
33
|
+
// comment.
|
|
34
|
+
const GATE_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
|
|
17
35
|
|
|
18
36
|
// The literal header line the gate review body always emits first
|
|
19
37
|
// (upsert-checkpoint-verdict.mjs's renderGateReviewCommentBody, re-exported
|
|
@@ -80,6 +98,37 @@ export function isCopilotLogin(login) {
|
|
|
80
98
|
return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
|
|
81
99
|
}
|
|
82
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Resolve whether Copilot is present as a reviewer on a PR from the REVIEW
|
|
103
|
+
* surface only — requested reviewers plus submitted reviews — never from
|
|
104
|
+
* assignees (#1670).
|
|
105
|
+
*
|
|
106
|
+
* Copilot review is configured in two ways: Copilot is either formally listed
|
|
107
|
+
* in the PR's `requested_reviewers`, or it is a configured auto-reviewer
|
|
108
|
+
* (`copilot-pull-request-reviewer[bot]`) that submits an actual review without
|
|
109
|
+
* ever appearing in `requested_reviewers`. Both are review-surface facts.
|
|
110
|
+
* Assignment is a disjoint surface and must never decide presence: on a
|
|
111
|
+
* reviewer-configured repo Copilot is never an assignee, so an assignee-based
|
|
112
|
+
* proxy would falsely report a fully-configured Copilot reviewer as absent and
|
|
113
|
+
* could let the gate skip the Copilot-convergence requirement on a false premise.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} params
|
|
116
|
+
* @param {boolean} [params.requested] - Copilot is listed in the PR's requested_reviewers
|
|
117
|
+
* @param {Array<{author?: {login?: string}}>} [params.reviews] - PR review list
|
|
118
|
+
* @returns {{ present: boolean, sources: string[] }}
|
|
119
|
+
*/
|
|
120
|
+
export function resolveCopilotReviewPresence({ requested = false, reviews = [] } = {}) {
|
|
121
|
+
const list = Array.isArray(reviews) ? reviews : [];
|
|
122
|
+
const sources = [];
|
|
123
|
+
if (requested === true) {
|
|
124
|
+
sources.push("requested_reviewer");
|
|
125
|
+
}
|
|
126
|
+
if (list.some((review) => isCopilotLogin(review?.author?.login))) {
|
|
127
|
+
sources.push("submitted_review");
|
|
128
|
+
}
|
|
129
|
+
return { present: sources.length > 0, sources };
|
|
130
|
+
}
|
|
131
|
+
|
|
83
132
|
// Anti-summon literal: bare-text `@copilot` or a `/copilot*` slash command. Both
|
|
84
133
|
// the write-side sanitizer and the read-side guard scan key off this shape so a
|
|
85
134
|
// gate-evidence comment can quote the rule (inside a code span/fenced block)
|
|
@@ -257,14 +306,19 @@ function stripGateCommentMarkdown(rawLine) {
|
|
|
257
306
|
return line.trim();
|
|
258
307
|
}
|
|
259
308
|
|
|
309
|
+
// Recognizes BOTH evidence gates (draft_gate/pre_approval_gate) and the
|
|
310
|
+
// non-evidence `review` gate — parseGateReviewCommentFields below relies on
|
|
311
|
+
// `review` coming back as an identified value (not null) so it can
|
|
312
|
+
// short-circuit to non-evidence explicitly, rather than leaving the field
|
|
313
|
+
// null and falling through to the lenient token-scan fallback.
|
|
260
314
|
function normalizeGateReviewName(value) {
|
|
261
315
|
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
262
|
-
return
|
|
316
|
+
return RECOGNIZED_GATE_NAMES.has(normalized) ? normalized : null;
|
|
263
317
|
}
|
|
264
318
|
|
|
265
319
|
function normalizeGateReviewVerdict(value) {
|
|
266
320
|
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
267
|
-
return
|
|
321
|
+
return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
|
|
268
322
|
}
|
|
269
323
|
|
|
270
324
|
function normalizeGateReviewHeadSha(value) {
|
|
@@ -277,6 +331,32 @@ function normalizeGateExecutionMode(value) {
|
|
|
277
331
|
return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
|
|
278
332
|
}
|
|
279
333
|
|
|
334
|
+
function normalizeGateSizeOutcome(value) {
|
|
335
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
336
|
+
return GATE_SIZE_OUTCOMES.has(normalized) ? normalized : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function normalizeGateSizeTouchesT1(value) {
|
|
340
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
341
|
+
if (normalized === "touched") return true;
|
|
342
|
+
if (normalized === "not touched") return false;
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// "none" | "granted" | "granted by <name>" — the approver name is free text
|
|
347
|
+
// (already sanitized by the poster via sanitizeInline before rendering), so
|
|
348
|
+
// no further normalization beyond trimming is applied here.
|
|
349
|
+
function normalizeGateSizeWaiver(value) {
|
|
350
|
+
const normalized = stripOptionalCodeTicks(value).trim();
|
|
351
|
+
if (/^none$/iu.test(normalized)) return { granted: false, approvedBy: null };
|
|
352
|
+
const grantedMatch = normalized.match(/^granted(?:\s+by\s+(.+))?$/iu);
|
|
353
|
+
if (grantedMatch) {
|
|
354
|
+
const approvedBy = grantedMatch[1]?.trim();
|
|
355
|
+
return { granted: true, approvedBy: approvedBy && approvedBy.length > 0 ? approvedBy : null };
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
280
360
|
function parseGateReviewCommentFields(body) {
|
|
281
361
|
if (typeof body !== "string" || body.trim().length === 0) {
|
|
282
362
|
return null;
|
|
@@ -290,6 +370,10 @@ function parseGateReviewCommentFields(body) {
|
|
|
290
370
|
nextAction: null,
|
|
291
371
|
executionMode: null,
|
|
292
372
|
inlineReason: null,
|
|
373
|
+
sizeOutcome: null,
|
|
374
|
+
sizeTouchesT1: null,
|
|
375
|
+
sizeWaiverGranted: null,
|
|
376
|
+
sizeWaiverApprovedBy: null,
|
|
293
377
|
};
|
|
294
378
|
|
|
295
379
|
for (const rawLine of body.split(/\r?\n/u)) {
|
|
@@ -382,6 +466,50 @@ function parseGateReviewCommentFields(body) {
|
|
|
382
466
|
}
|
|
383
467
|
continue;
|
|
384
468
|
}
|
|
469
|
+
|
|
470
|
+
match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+outcome\s*:\s*(.+)$/iu);
|
|
471
|
+
if (match) {
|
|
472
|
+
if (fields.sizeOutcome === null) {
|
|
473
|
+
fields.sizeOutcome = normalizeGateSizeOutcome(match[1]);
|
|
474
|
+
}
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+t1\s+slice\s*:\s*(.+)$/iu);
|
|
479
|
+
if (match) {
|
|
480
|
+
if (fields.sizeTouchesT1 === null) {
|
|
481
|
+
fields.sizeTouchesT1 = normalizeGateSizeTouchesT1(match[1]);
|
|
482
|
+
}
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+waiver\s*:\s*(.+)$/iu);
|
|
487
|
+
if (match) {
|
|
488
|
+
if (fields.sizeWaiverGranted === null) {
|
|
489
|
+
const parsedWaiver = normalizeGateSizeWaiver(match[1]);
|
|
490
|
+
if (parsedWaiver) {
|
|
491
|
+
fields.sizeWaiverGranted = parsedWaiver.granted;
|
|
492
|
+
fields.sizeWaiverApprovedBy = parsedWaiver.approvedBy;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// An explicit, RECOGNIZED `review` gate field is authoritative and returns
|
|
500
|
+
// null here — before the lenient token-scan fallback below ever runs. A
|
|
501
|
+
// `review` verdict comment carries no draft/pre-approval evidence by
|
|
502
|
+
// design (#1808 AC3); without this short circuit, `fields.gate` would stay
|
|
503
|
+
// "review" (not one of the two evidence gates) but the lenient fallback
|
|
504
|
+
// below only fires when `!fields.gate` — so a *recognized* `review` gate
|
|
505
|
+
// would otherwise skip the fallback yet still return non-null fields keyed
|
|
506
|
+
// to "review", which is harmless for the two summarizers here (they only
|
|
507
|
+
// read `.draft_gate`/`.pre_approval_gate`) but leaves the non-evidence
|
|
508
|
+
// contract implicit rather than explicit. Stated plainly: an identified
|
|
509
|
+
// non-evidence gate must never be treated as an unidentified body, and an
|
|
510
|
+
// unidentified body is the ONLY case the token-scan fallback exists for.
|
|
511
|
+
if (NON_EVIDENCE_GATE_NAMES.has(fields.gate)) {
|
|
512
|
+
return null;
|
|
385
513
|
}
|
|
386
514
|
|
|
387
515
|
// Lenient fallback: detect gate name and head SHA anywhere in body
|
|
@@ -450,6 +578,10 @@ export function parseGateReviewCommentMarkerBody(body) {
|
|
|
450
578
|
nextAction: fields.nextAction,
|
|
451
579
|
executionMode: fields.executionMode,
|
|
452
580
|
inlineReason: fields.inlineReason,
|
|
581
|
+
sizeOutcome: fields.sizeOutcome,
|
|
582
|
+
sizeTouchesT1: fields.sizeTouchesT1,
|
|
583
|
+
sizeWaiverGranted: fields.sizeWaiverGranted,
|
|
584
|
+
sizeWaiverApprovedBy: fields.sizeWaiverApprovedBy,
|
|
453
585
|
contractComplete: Boolean(fields.verdict && fields.findingsSummary && fields.nextAction),
|
|
454
586
|
};
|
|
455
587
|
}
|
|
@@ -494,9 +626,13 @@ export function summarizeGateReviewComments(comments) {
|
|
|
494
626
|
nextAction: parsed.nextAction,
|
|
495
627
|
executionMode: parsed.executionMode ?? null,
|
|
496
628
|
inlineReason: parsed.inlineReason ?? null,
|
|
629
|
+
sizeOutcome: parsed.sizeOutcome ?? null,
|
|
630
|
+
sizeTouchesT1: parsed.sizeTouchesT1 ?? null,
|
|
631
|
+
sizeWaiverGranted: parsed.sizeWaiverGranted ?? null,
|
|
632
|
+
sizeWaiverApprovedBy: parsed.sizeWaiverApprovedBy ?? null,
|
|
497
633
|
surface: normalizeVerdictSurface(comment?.surface),
|
|
498
634
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
499
|
-
commentUrl:
|
|
635
|
+
commentUrl: trimmedOrNull(comment?.html_url),
|
|
500
636
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
501
637
|
? (comment.updated_at ?? comment.updatedAt).trim()
|
|
502
638
|
: typeof (comment?.created_at ?? comment?.createdAt) === "string"
|
|
@@ -548,10 +684,14 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
548
684
|
nextAction: parsed.nextAction,
|
|
549
685
|
executionMode: parsed.executionMode ?? null,
|
|
550
686
|
inlineReason: parsed.inlineReason ?? null,
|
|
687
|
+
sizeOutcome: parsed.sizeOutcome ?? null,
|
|
688
|
+
sizeTouchesT1: parsed.sizeTouchesT1 ?? null,
|
|
689
|
+
sizeWaiverGranted: parsed.sizeWaiverGranted ?? null,
|
|
690
|
+
sizeWaiverApprovedBy: parsed.sizeWaiverApprovedBy ?? null,
|
|
551
691
|
contractComplete: parsed.contractComplete,
|
|
552
692
|
surface: normalizeVerdictSurface(comment?.surface),
|
|
553
693
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
554
|
-
commentUrl:
|
|
694
|
+
commentUrl: trimmedOrNull(comment?.html_url),
|
|
555
695
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
556
696
|
? (comment.updated_at ?? comment.updatedAt).trim()
|
|
557
697
|
: typeof (comment?.created_at ?? comment?.createdAt) === "string"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `gh` CLI invoke-and-parse helpers (child 6 of the simplification
|
|
3
|
+
* epic #1689). This module introduces the shared implementation only; the
|
|
4
|
+
* ~19 call-site migrations are the follow-up children (projects: #1696;
|
|
5
|
+
* github/loop/refine: #1697).
|
|
6
|
+
*
|
|
7
|
+
* `ghJson` is the composed SUPERSET the callers share (per #1695's AC): a
|
|
8
|
+
* label-conditional non-zero-exit message — `gh command failed: <detail>` with
|
|
9
|
+
* no label (probe-ci-status shape) or `<label> failed: <detail>` with one
|
|
10
|
+
* (fetch-ci-logs shape) — always carrying `code: "GH_API_ERROR"`, plus the
|
|
11
|
+
* inline `Invalid JSON from gh: <stdout|<empty>>` malformed-stdout shape
|
|
12
|
+
* (probe-ci-status). `ghGraphql` reproduces scripts/projects/add-queue-item.mjs's
|
|
13
|
+
* superset (`gh api graphql failed` / `GraphQL errors:` with GH_API_ERROR /
|
|
14
|
+
* GRAPHQL_ERROR; `parseJsonText` → `Invalid JSON input`).
|
|
15
|
+
*
|
|
16
|
+
* Because `ghJson` is a composed superset, NO current caller matches it exactly
|
|
17
|
+
* — migrating each is a deliberate behavior harmonization, not a blind swap:
|
|
18
|
+
* - probe-ci-status.mjs: `gh command failed:` / `Invalid JSON from gh:` already
|
|
19
|
+
* match; it gains the `GH_API_ERROR` code on non-zero exit.
|
|
20
|
+
* - fetch-ci-logs.mjs: `<label> failed:` matches (pass its label); it gains the
|
|
21
|
+
* `GH_API_ERROR` code and its malformed-JSON message becomes
|
|
22
|
+
* `Invalid JSON from gh:` (was `parseJsonText` → `Invalid JSON input`).
|
|
23
|
+
* - upsert-checkpoint-verdict.mjs / post-gate-findings.mjs: gain the code and
|
|
24
|
+
* their malformed-JSON message becomes `Invalid JSON from gh:` (was
|
|
25
|
+
* `Invalid JSON input`).
|
|
26
|
+
* The follow-up children (projects: #1696; github/loop/refine: #1697) migrate
|
|
27
|
+
* each caller and update its pinned test messages accordingly.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { runChild as defaultRunChild } from "../cli/primitives.mjs";
|
|
31
|
+
import { parseJsonText } from "./review-threads.mjs";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run a `gh` subcommand and parse its stdout as JSON. Fails loudly on a
|
|
35
|
+
* non-zero exit (naming the command's stderr) and on malformed JSON stdout.
|
|
36
|
+
*
|
|
37
|
+
* @param {string[]} args - argv passed to `ghCommand`.
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
40
|
+
* @param {string} [opts.ghCommand] - defaults to `"gh"`.
|
|
41
|
+
* @param {typeof defaultRunChild} [opts.runChild] - injectable child-exec seam.
|
|
42
|
+
* @param {string} [opts.label] - controls the non-zero-exit message: when set,
|
|
43
|
+
* the thrown error reads `<label> failed: <detail>` (the fetch-ci-logs shape);
|
|
44
|
+
* when omitted it reads `gh command failed: <detail>` (the probe-ci-status
|
|
45
|
+
* shape). Either way the non-zero-exit error carries `code: "GH_API_ERROR"`.
|
|
46
|
+
*/
|
|
47
|
+
export async function ghJson(args, { env, ghCommand = "gh", runChild = defaultRunChild, label } = {}) {
|
|
48
|
+
const result = await runChild(ghCommand, args, env);
|
|
49
|
+
if (result.code !== 0) {
|
|
50
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
51
|
+
const prefix = label ? `${label} failed` : "gh command failed";
|
|
52
|
+
throw Object.assign(new Error(`${prefix}: ${detail}`), { code: "GH_API_ERROR" });
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(result.stdout);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(`Invalid JSON from gh: ${result.stdout.trim() || "<empty>"}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run a `gh api graphql` query and parse its response.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} query - the GraphQL document.
|
|
65
|
+
* @param {Record<string, string>} vars - `--field key=value` variables.
|
|
66
|
+
* @param {NodeJS.ProcessEnv} env
|
|
67
|
+
* @param {typeof defaultRunChild} [runChild] - injectable child-exec seam.
|
|
68
|
+
* @param {object} [opts]
|
|
69
|
+
* @param {boolean} [opts.allowErrors] - when true, a GraphQL `errors` array
|
|
70
|
+
* in the response is returned instead of thrown.
|
|
71
|
+
*/
|
|
72
|
+
export async function ghGraphql(query, vars, env, runChild = defaultRunChild, { allowErrors = false } = {}) {
|
|
73
|
+
const fieldArgs = [];
|
|
74
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
75
|
+
fieldArgs.push("--field", `${key}=${value}`);
|
|
76
|
+
}
|
|
77
|
+
const result = await runChild(
|
|
78
|
+
"gh",
|
|
79
|
+
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
80
|
+
env,
|
|
81
|
+
);
|
|
82
|
+
if (result.code !== 0) {
|
|
83
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
84
|
+
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
85
|
+
}
|
|
86
|
+
const payload = parseJsonText(result.stdout);
|
|
87
|
+
if (!allowErrors && payload.errors && payload.errors.length > 0) {
|
|
88
|
+
throw Object.assign(
|
|
89
|
+
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
90
|
+
{ code: "GRAPHQL_ERROR" },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return payload;
|
|
94
|
+
}
|
package/src/github/issue-ops.mjs
CHANGED
|
@@ -3,6 +3,7 @@ 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";
|
|
6
|
+
import { guardCommentBodyNoIssuePrIds } from "./comment-id-guard.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Core `gh issue` operations, extracted from the thin CLI wrappers under
|
|
@@ -227,6 +228,11 @@ export async function resolveCommentBody(options) {
|
|
|
227
228
|
|
|
228
229
|
export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
229
230
|
const body = await resolveCommentBody(options);
|
|
231
|
+
// ISSUE/PR-ID GUARD (#1731): a generated comment body must never emit a raw
|
|
232
|
+
// issue/PR id (fail-closed unless explicitly allowlisted). `allowedRefs` is
|
|
233
|
+
// the ONLY sanctioned escape for a deliberate cross-reference, threaded from
|
|
234
|
+
// the generic CLI writers' --allowed-refs option.
|
|
235
|
+
guardCommentBodyNoIssuePrIds(body, { ref: "issue comment body", allowedRefs: options.allowedRefs });
|
|
230
236
|
const result = await run(
|
|
231
237
|
ghCommand,
|
|
232
238
|
["issue", "comment", String(options.issue), "--repo", options.repo, "--body", body],
|
|
@@ -282,6 +288,13 @@ export async function listIssues(options, { env = process.env, ghCommand = "gh",
|
|
|
282
288
|
for (const label of options.labels ?? []) {
|
|
283
289
|
args.push("--label", label);
|
|
284
290
|
}
|
|
291
|
+
// `--search` narrows the result set to gh's own full-text search (title,
|
|
292
|
+
// body, comments) rather than the bare paged listing — needed by a caller
|
|
293
|
+
// that must find one specific issue by title without trusting that it falls
|
|
294
|
+
// within the default 30-issue page.
|
|
295
|
+
if (typeof options.search === "string" && options.search.length > 0) {
|
|
296
|
+
args.push("--search", options.search);
|
|
297
|
+
}
|
|
285
298
|
const result = await run(ghCommand, args, env);
|
|
286
299
|
if (result.code !== 0) {
|
|
287
300
|
const detail = result.stderr.trim() || `exit code ${result.code}`;
|