dev-loops 0.2.7 → 0.3.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/skills/copilot-pr-followup/SKILL.md +57 -3
- package/.claude/skills/dev-loop/SKILL.md +5 -5
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +2 -2
- package/.claude/skills/local-implementation/SKILL.md +17 -3
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +43 -0
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +39 -6
- package/package.json +2 -2
- package/scripts/README.md +6 -5
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +69 -8
- package/scripts/github/upsert-checkpoint-verdict.mjs +597 -15
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/run-queue.mjs +87 -15
- package/scripts/projects/add-queue-item.mjs +60 -43
- package/scripts/projects/archive-done-items.mjs +135 -39
- package/scripts/projects/ensure-queue-board.mjs +65 -64
- package/scripts/projects/list-queue-items.mjs +57 -56
- package/scripts/projects/move-queue-item.mjs +123 -124
- package/scripts/projects/reorder-queue-item.mjs +62 -44
- package/scripts/projects/sync-item-status.mjs +198 -0
- package/skills/copilot-pr-followup/SKILL.md +57 -3
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +2 -2
- package/skills/local-implementation/SKILL.md +17 -3
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
5
|
-
const USAGE = `Usage: create-
|
|
6
|
-
|
|
5
|
+
const USAGE = `Usage: create-pr.mjs [gh pr create args...]
|
|
6
|
+
Canonical PR-creation wrapper around \`gh pr create\`. Every PR opened through this
|
|
7
|
+
tool is ALWAYS a draft and is self-assigned by default. Never call raw \`gh pr create\`.
|
|
7
8
|
Behavior:
|
|
8
|
-
- injects exactly one \`--draft\` when absent
|
|
9
|
+
- injects exactly one \`--draft\` when absent (draft is the only mode)
|
|
10
|
+
- defaults \`--assignee @me\` when no assignee is given (self-assigned by default)
|
|
11
|
+
- honors an explicit \`--assignee <login>\` / \`-a <login>\` when supplied (no default injected)
|
|
9
12
|
- rejects \`--ready\` before invoking \`gh\`
|
|
10
13
|
- detects missing \`Closes #N\` / \`Fixes #N\` in \`--body\` or \`--body-file\` content (non-fatal stderr warning)
|
|
11
14
|
- forwards every other argument to \`gh pr create\` unchanged
|
|
12
15
|
- preserves the underlying \`gh pr create\` stdout, stderr, and exit code
|
|
13
16
|
Examples:
|
|
14
|
-
node scripts/github/create-
|
|
15
|
-
node <resolved-skill-scripts>/github/create-
|
|
17
|
+
node scripts/github/create-pr.mjs --repo owner/repo --base main --head feature --title "..." --body-file pr.md
|
|
18
|
+
node <resolved-skill-scripts>/github/create-pr.mjs --repo owner/repo --base main --head feature --title "..." --body-file pr.md
|
|
16
19
|
Notes:
|
|
17
20
|
- Use \`gh pr ready\` later to leave draft state; this wrapper never opens a ready PR.
|
|
18
21
|
- Wrapper-owned validation is limited to \`--ready\`; all other argument validation is left to \`gh pr create\`.
|
|
@@ -25,6 +28,12 @@ const parseError = buildParseError(USAGE);
|
|
|
25
28
|
const READY_FLAG_PATTERN = /^--ready(?:$|=)/u;
|
|
26
29
|
const DRAFT_FLAG_PATTERN = /^--draft(?:=(.*))?$/iu;
|
|
27
30
|
const DRAFT_TRUE_VALUE_PATTERN = /^(?:true|1)$/iu;
|
|
31
|
+
// Detect both the long `--assignee`/`--assignee=<login>` forms and the `-a`
|
|
32
|
+
// short flag that `gh pr create` documents, so an explicit assignee in either
|
|
33
|
+
// form suppresses the `--assignee @me` default (otherwise a caller passing
|
|
34
|
+
// `-a <login>` would get a conflicting `--assignee @me` injected). (#894)
|
|
35
|
+
const ASSIGNEE_FLAG_PATTERN = /^(?:--assignee(?:$|=)|-a$)/u;
|
|
36
|
+
const DEFAULT_ASSIGNEE = "@me";
|
|
28
37
|
const CLOSING_KEYWORD_PATTERN = /Closes\s+#\d+|Fixes\s+#\d+/i;
|
|
29
38
|
const MAX_BODY_SCAN_BYTES = 16 * 1024;
|
|
30
39
|
export function detectClosingKeyword(body) {
|
|
@@ -52,12 +61,12 @@ async function warnMissingClosingKeyword(args) {
|
|
|
52
61
|
if (body === null) return; // no --body or --body-file, skip
|
|
53
62
|
if (!detectClosingKeyword(body)) {
|
|
54
63
|
process.stderr.write(
|
|
55
|
-
"[create-
|
|
64
|
+
"[create-pr] Warning: PR body missing `Closes #N` or `Fixes #N`. " +
|
|
56
65
|
"GitHub will not auto-close the linked issue on merge.\n",
|
|
57
66
|
);
|
|
58
67
|
}
|
|
59
68
|
}
|
|
60
|
-
export function
|
|
69
|
+
export function buildCreatePrArgs(argv) {
|
|
61
70
|
const args = [...argv];
|
|
62
71
|
if (args.includes("--help") || args.includes("-h")) {
|
|
63
72
|
return {
|
|
@@ -66,17 +75,24 @@ export function buildCreateDraftPrArgs(argv) {
|
|
|
66
75
|
};
|
|
67
76
|
}
|
|
68
77
|
if (args.some((token) => READY_FLAG_PATTERN.test(token))) {
|
|
69
|
-
throw parseError("create-
|
|
78
|
+
throw parseError("create-pr rejects --ready; open the PR as draft first, then run `gh pr ready` after the draft gate is satisfied");
|
|
70
79
|
}
|
|
71
80
|
const draftTokens = args.filter((token) => DRAFT_FLAG_PATTERN.test(token));
|
|
72
81
|
const lastDraftToken = draftTokens.length > 0 ? draftTokens.at(-1) : null;
|
|
73
82
|
const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" && DRAFT_TRUE_VALUE_PATTERN.test(lastDraftToken.slice("--draft=".length)));
|
|
83
|
+
const hasAssignee = args.some((token) => ASSIGNEE_FLAG_PATTERN.test(token));
|
|
74
84
|
return {
|
|
75
85
|
help: false,
|
|
76
|
-
ghArgs: [
|
|
86
|
+
ghArgs: [
|
|
87
|
+
"pr",
|
|
88
|
+
"create",
|
|
89
|
+
...args,
|
|
90
|
+
...(hasAssignee ? [] : ["--assignee", DEFAULT_ASSIGNEE]),
|
|
91
|
+
...(lastDraftSuppliesDraft ? [] : ["--draft"]),
|
|
92
|
+
],
|
|
77
93
|
};
|
|
78
94
|
}
|
|
79
|
-
export function
|
|
95
|
+
export function spawnCreatePr(ghArgs, { ghCommand = "gh", env = process.env } = {}) {
|
|
80
96
|
return new Promise((resolve, reject) => {
|
|
81
97
|
const child = spawn(ghCommand, ghArgs, {
|
|
82
98
|
env,
|
|
@@ -89,13 +105,13 @@ export function spawnCreateDraftPr(ghArgs, { ghCommand = "gh", env = process.env
|
|
|
89
105
|
});
|
|
90
106
|
}
|
|
91
107
|
export async function main(argv = process.argv.slice(2), runtime = {}) {
|
|
92
|
-
const { help, ghArgs } =
|
|
108
|
+
const { help, ghArgs } = buildCreatePrArgs(argv);
|
|
93
109
|
if (help) {
|
|
94
110
|
process.stdout.write(`${USAGE}\n`);
|
|
95
111
|
return 0;
|
|
96
112
|
}
|
|
97
113
|
await warnMissingClosingKeyword(argv);
|
|
98
|
-
return
|
|
114
|
+
return spawnCreatePr(ghArgs, runtime);
|
|
99
115
|
}
|
|
100
116
|
if (isDirectCliRun(import.meta.url)) {
|
|
101
117
|
try {
|
|
@@ -9,9 +9,13 @@ import {
|
|
|
9
9
|
summarizeGateReviewCommentMarkers,
|
|
10
10
|
summarizeGateReviewComments,
|
|
11
11
|
} from "../_core-helpers.mjs";
|
|
12
|
+
import { access } from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
12
14
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
13
15
|
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
14
16
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
17
|
+
import { loadDevLoopConfig, resolveGateConfig, resolveRequireFanoutEvidence } from "@dev-loops/core/config";
|
|
18
|
+
import { buildLogPath } from "./write-gate-findings-log.mjs";
|
|
15
19
|
import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
|
|
16
20
|
import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
|
|
17
21
|
const USAGE = `Usage: detect-checkpoint-evidence.mjs --repo <owner/name> --pr <number>
|
|
@@ -200,6 +204,8 @@ function emptyGateMarkerSummary() {
|
|
|
200
204
|
verdict: null,
|
|
201
205
|
findingsSummary: null,
|
|
202
206
|
nextAction: null,
|
|
207
|
+
executionMode: null,
|
|
208
|
+
inlineReason: null,
|
|
203
209
|
contractComplete: false,
|
|
204
210
|
commentId: null,
|
|
205
211
|
commentUrl: null,
|
|
@@ -216,13 +222,15 @@ function normalizeGateMarkerSummary(summary) {
|
|
|
216
222
|
verdict: summary.verdict,
|
|
217
223
|
findingsSummary: summary.findingsSummary,
|
|
218
224
|
nextAction: summary.nextAction,
|
|
225
|
+
executionMode: summary.executionMode ?? null,
|
|
226
|
+
inlineReason: summary.inlineReason ?? null,
|
|
219
227
|
contractComplete: summary.contractComplete === true,
|
|
220
228
|
commentId: summary.commentId,
|
|
221
229
|
commentUrl: summary.commentUrl,
|
|
222
230
|
updatedAt: summary.updatedAt,
|
|
223
231
|
};
|
|
224
232
|
}
|
|
225
|
-
export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null) {
|
|
233
|
+
export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, staleRunnerCheck = null, fanoutEnforcement = null) {
|
|
226
234
|
const failures = [];
|
|
227
235
|
if (!(evidence.draftGate.visible && evidence.draftGate.verdict === "clean")) {
|
|
228
236
|
failures.push("missing visible clean draft_gate comment");
|
|
@@ -236,6 +244,24 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
236
244
|
)) {
|
|
237
245
|
failures.push("missing visible clean current-head pre_approval_gate comment");
|
|
238
246
|
}
|
|
247
|
+
// Fail-closed fan-out evidence enforcement (gates.requireFanoutEvidence, ON by
|
|
248
|
+
// default / opt-out). When disabled or config-unavailable, fanoutEnforcement is
|
|
249
|
+
// { required: false, gates: [] } so the `.required` guard skips this block.
|
|
250
|
+
if (fanoutEnforcement && fanoutEnforcement.required) {
|
|
251
|
+
for (const gate of fanoutEnforcement.gates) {
|
|
252
|
+
if (gate.executionMode !== "fanout_fanin") {
|
|
253
|
+
failures.push(
|
|
254
|
+
`${gate.name}: requireFanoutEvidence is enabled but executionMode is "${gate.executionMode ?? "unset"}" (expected "fanout_fanin"); inline gate verdicts are not accepted`,
|
|
255
|
+
);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!gate.ledgerExists) {
|
|
259
|
+
failures.push(
|
|
260
|
+
`${gate.name}: requireFanoutEvidence is enabled but no findings-log ledger exists for the reviewed head (${gate.ledgerPath})`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
239
265
|
if (typeof unresolvedThreadCount === "number" && unresolvedThreadCount !== 0) {
|
|
240
266
|
if (unresolvedThreadCount === -1) {
|
|
241
267
|
failures.push("could not fetch review thread state from GitHub API; re-run gate evidence check when API connectivity is restored");
|
|
@@ -253,6 +279,52 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
253
279
|
failures,
|
|
254
280
|
};
|
|
255
281
|
}
|
|
282
|
+
async function ledgerExists(fullPath) {
|
|
283
|
+
try {
|
|
284
|
+
await access(fullPath);
|
|
285
|
+
return true;
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Build the fan-out evidence enforcement descriptor.
|
|
292
|
+
*
|
|
293
|
+
* Enforcement is ON by default (opt-out via gates.requireFanoutEvidence: false).
|
|
294
|
+
* Returns { required: false } when enforcement is disabled OR when config is
|
|
295
|
+
* unavailable (config == null — null or undefined — after a failed load) — config-unavailable must
|
|
296
|
+
* fail open and never enable enforcement. When enabled, records per-required-gate
|
|
297
|
+
* executionMode and whether the deterministic findings-log ledger exists for the
|
|
298
|
+
* reviewed head SHA so the pre-merge check can fail closed on inline verdicts or
|
|
299
|
+
* missing ledgers.
|
|
300
|
+
*/
|
|
301
|
+
async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd }) {
|
|
302
|
+
// Fail open when config could not be loaded/validated. `== null` covers both
|
|
303
|
+
// null and undefined; the loader only ever yields null on failure, but the
|
|
304
|
+
// loose check defensively treats an absent config as unavailable.
|
|
305
|
+
if (config == null || !resolveRequireFanoutEvidence(config)) {
|
|
306
|
+
return { required: false, gates: [] };
|
|
307
|
+
}
|
|
308
|
+
const draftRequired = resolveGateConfig(config, "draft").required;
|
|
309
|
+
const preApprovalRequired = resolveGateConfig(config, "preApproval").required;
|
|
310
|
+
const gateSpecs = [
|
|
311
|
+
{ name: "draft_gate", marker: draftGateMarker, required: draftRequired },
|
|
312
|
+
{ name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalRequired },
|
|
313
|
+
].filter((spec) => spec.required && spec.marker.visible);
|
|
314
|
+
const gates = [];
|
|
315
|
+
for (const spec of gateSpecs) {
|
|
316
|
+
const headSha = spec.marker.headSha ?? currentHeadSha;
|
|
317
|
+
const ledgerPath = buildLogPath({ repo, pr, gate: spec.name, headSha, tmpRoot: "tmp" });
|
|
318
|
+
const fullPath = path.resolve(cwd, ledgerPath);
|
|
319
|
+
gates.push({
|
|
320
|
+
name: spec.name,
|
|
321
|
+
executionMode: spec.marker.executionMode ?? null,
|
|
322
|
+
ledgerPath,
|
|
323
|
+
ledgerExists: await ledgerExists(fullPath),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return { required: true, gates };
|
|
327
|
+
}
|
|
256
328
|
export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
|
|
257
329
|
const runnerOwnership = await ensureAsyncRunnerOwnership({
|
|
258
330
|
repo: options.repo,
|
|
@@ -299,6 +371,24 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
299
371
|
const allComments = [...commentsPayload, ...prReviews];
|
|
300
372
|
const commentSummary = summarizeGateReviewComments(allComments);
|
|
301
373
|
const markerSummary = summarizeGateReviewCommentMarkers(allComments, { headSha: currentHeadSha });
|
|
374
|
+
const draftGateMarker = normalizeGateMarkerSummary(markerSummary.draft_gate);
|
|
375
|
+
const preApprovalGateMarker = normalizeGateMarkerSummary(markerSummary.pre_approval_gate);
|
|
376
|
+
// loadDevLoopConfig never throws: it returns { config, warnings, errors }.
|
|
377
|
+
// A non-empty errors array means the config could not be loaded/validated, so
|
|
378
|
+
// treat it as config-unavailable and leave fan-out enforcement disabled
|
|
379
|
+
// (preserves default behavior). Other gate checks remain unaffected.
|
|
380
|
+
let config = null;
|
|
381
|
+
const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: cwd });
|
|
382
|
+
config = Array.isArray(configErrors) && configErrors.length > 0 ? null : loadedConfig;
|
|
383
|
+
const fanoutEnforcement = await buildFanoutEnforcement({
|
|
384
|
+
repo: options.repo,
|
|
385
|
+
pr: options.pr,
|
|
386
|
+
currentHeadSha,
|
|
387
|
+
draftGateMarker,
|
|
388
|
+
preApprovalGateMarker,
|
|
389
|
+
config,
|
|
390
|
+
cwd,
|
|
391
|
+
});
|
|
302
392
|
return {
|
|
303
393
|
ok: true,
|
|
304
394
|
repo: options.repo,
|
|
@@ -306,9 +396,10 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
306
396
|
currentHeadSha,
|
|
307
397
|
draftGate: normalizeGateSummary(commentSummary.draft_gate),
|
|
308
398
|
preApprovalGate: normalizeGateSummary(commentSummary.pre_approval_gate),
|
|
309
|
-
draftGateMarker
|
|
310
|
-
preApprovalGateMarker
|
|
399
|
+
draftGateMarker,
|
|
400
|
+
preApprovalGateMarker,
|
|
311
401
|
draftGateSatisfied: commentSummary.draft_gate?.verdict === "clean" && typeof commentSummary.draft_gate?.headSha === "string",
|
|
402
|
+
fanoutEnforcement,
|
|
312
403
|
...(runnerOwnership.status !== "skipped_no_async_run_id" ? { runnerOwnership } : {}),
|
|
313
404
|
staleRunner: {
|
|
314
405
|
status: staleRunnerDetection.status,
|
|
@@ -351,7 +442,7 @@ async function main() {
|
|
|
351
442
|
? [`exit signal recorded for run ${result.staleRunner.activeRun?.runId}: refuse to merge`]
|
|
352
443
|
: [],
|
|
353
444
|
};
|
|
354
|
-
const preMergeGateCheck = buildPreMergeGateCheck(result, unresolvedThreadCount, staleRunnerCheck);
|
|
445
|
+
const preMergeGateCheck = buildPreMergeGateCheck(result, unresolvedThreadCount, staleRunnerCheck, result.fanoutEnforcement);
|
|
355
446
|
const output = { ...result, preMergeGateCheck, staleRunnerCheck };
|
|
356
447
|
if (!preMergeGateCheck.ok) {
|
|
357
448
|
process.stderr.write(`${JSON.stringify({
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
3
|
+
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
|
+
import { loadDevLoopConfig, resolveGatePostFindingsComments } from "@dev-loops/core/config";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage: post-gate-findings.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --findings <json>
|
|
8
|
+
Post (or idempotently update) a visible, marker-tagged PR issue comment that lists the
|
|
9
|
+
consolidated gate fan-out findings, grouped by severity. The comment is idempotent
|
|
10
|
+
per gate: there is exactly one comment per gate, updated in place on each run
|
|
11
|
+
(the reviewed head is shown in the body) instead of duplicating it.
|
|
12
|
+
|
|
13
|
+
The disposition ledger (write-gate-findings-log.mjs) is the durable source of truth and is
|
|
14
|
+
written regardless of this comment. This helper only posts the auditable PR summary, and
|
|
15
|
+
no-ops when gates.postFindingsComments is set to false in config.
|
|
16
|
+
|
|
17
|
+
Required:
|
|
18
|
+
--repo <owner/name>
|
|
19
|
+
--pr <number>
|
|
20
|
+
--gate <draft_gate|pre_approval_gate>
|
|
21
|
+
--head-sha <sha> Current head SHA or hexadecimal prefix
|
|
22
|
+
--findings <json> JSON array of findings in the findings-log shape
|
|
23
|
+
([{severity, angle, summary, disposition?, files?}])
|
|
24
|
+
Output (stdout, JSON):
|
|
25
|
+
{ "ok": true, "action": "created"|"updated"|"noop"|"skipped", ... }
|
|
26
|
+
Exit codes:
|
|
27
|
+
0 Success
|
|
28
|
+
1 Argument error or gh failure`.trim();
|
|
29
|
+
|
|
30
|
+
const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
|
|
31
|
+
// Severity ordering for grouped rendering (most-blocking first).
|
|
32
|
+
const SEVERITY_ORDER = ["must-fix", "worth-fixing-now", "defer"];
|
|
33
|
+
const SEVERITY_LABELS = {
|
|
34
|
+
"must-fix": "Must fix",
|
|
35
|
+
"worth-fixing-now": "Worth fixing now",
|
|
36
|
+
"defer": "Defer",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function parseError(message) {
|
|
40
|
+
return Object.assign(new Error(message), { usage: USAGE });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeGate(value) {
|
|
44
|
+
const gates = new Set(["draft_gate", "pre_approval_gate"]);
|
|
45
|
+
const normalized = String(value).trim().toLowerCase();
|
|
46
|
+
return gates.has(normalized) ? normalized : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeHeadSha(value) {
|
|
50
|
+
const normalized = String(value).trim().toLowerCase();
|
|
51
|
+
return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Validate via the centralized repo-slug validator shared by sibling GitHub
|
|
55
|
+
// scripts (parseRepoSlug). It enforces owner/name structure and rejects unsafe
|
|
56
|
+
// segments (".", "..", slashes, whitespace); we re-throw as a parseError so the
|
|
57
|
+
// CLI usage banner is preserved.
|
|
58
|
+
function validateRepo(repo) {
|
|
59
|
+
try {
|
|
60
|
+
parseRepoSlug(repo);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
63
|
+
}
|
|
64
|
+
return repo;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseFindings(raw) {
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(raw);
|
|
71
|
+
} catch {
|
|
72
|
+
throw parseError("--findings must be valid JSON");
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(parsed)) {
|
|
75
|
+
throw parseError("--findings must be a JSON array");
|
|
76
|
+
}
|
|
77
|
+
return parsed.map((f, i) => {
|
|
78
|
+
if (!f || typeof f !== "object") {
|
|
79
|
+
throw parseError(`--findings[${i}] must be an object`);
|
|
80
|
+
}
|
|
81
|
+
if (!f.severity || !VALID_SEVERITIES.has(f.severity)) {
|
|
82
|
+
throw parseError(`--findings[${i}].severity must be one of: must-fix, worth-fixing-now, defer`);
|
|
83
|
+
}
|
|
84
|
+
if (!f.angle || typeof f.angle !== "string" || f.angle.trim().length === 0) {
|
|
85
|
+
throw parseError(`--findings[${i}].angle is required`);
|
|
86
|
+
}
|
|
87
|
+
if (!f.summary || typeof f.summary !== "string" || f.summary.trim().length === 0) {
|
|
88
|
+
throw parseError(`--findings[${i}].summary is required`);
|
|
89
|
+
}
|
|
90
|
+
const entry = {
|
|
91
|
+
severity: f.severity,
|
|
92
|
+
angle: f.angle.trim(),
|
|
93
|
+
summary: f.summary.trim(),
|
|
94
|
+
};
|
|
95
|
+
if ("disposition" in f && typeof f.disposition === "string" && f.disposition.trim().length > 0) {
|
|
96
|
+
entry.disposition = f.disposition.trim();
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(f.files)) {
|
|
99
|
+
entry.files = f.files.filter(x => typeof x === "string" && x.trim().length > 0).map(x => x.trim());
|
|
100
|
+
}
|
|
101
|
+
return entry;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parsePostGateFindingsCliArgs(argv) {
|
|
106
|
+
const args = [...argv];
|
|
107
|
+
const options = {
|
|
108
|
+
help: false,
|
|
109
|
+
repo: undefined,
|
|
110
|
+
pr: undefined,
|
|
111
|
+
gate: undefined,
|
|
112
|
+
headSha: undefined,
|
|
113
|
+
findings: undefined,
|
|
114
|
+
};
|
|
115
|
+
while (args.length > 0) {
|
|
116
|
+
const token = args.shift();
|
|
117
|
+
if (token === "--help" || token === "-h") {
|
|
118
|
+
options.help = true;
|
|
119
|
+
return options;
|
|
120
|
+
}
|
|
121
|
+
if (token === "--repo") {
|
|
122
|
+
options.repo = validateRepo(requireOptionValue(args, "--repo", parseError).trim());
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (token === "--pr") {
|
|
126
|
+
options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (token === "--gate") {
|
|
130
|
+
const gate = normalizeGate(requireOptionValue(args, "--gate", parseError));
|
|
131
|
+
if (!gate) throw parseError("--gate must be draft_gate or pre_approval_gate");
|
|
132
|
+
options.gate = gate;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (token === "--head-sha") {
|
|
136
|
+
const sha = normalizeHeadSha(requireOptionValue(args, "--head-sha", parseError));
|
|
137
|
+
if (!sha) throw parseError("--head-sha must be a 7-64 character hex SHA");
|
|
138
|
+
options.headSha = sha;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (token === "--findings") {
|
|
142
|
+
options.findings = requireOptionValue(args, "--findings", parseError);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
146
|
+
}
|
|
147
|
+
const missing = ["repo", "pr", "gate", "headSha", "findings"]
|
|
148
|
+
.filter(k => options[k] === undefined);
|
|
149
|
+
if (missing.length > 0) {
|
|
150
|
+
throw parseError(`Missing required arguments: ${missing.join(", ")}`);
|
|
151
|
+
}
|
|
152
|
+
return options;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Hidden marker keyed by GATE ONLY. There is exactly one findings comment per
|
|
156
|
+
// gate, updated in place each run. The marker deliberately does NOT include the
|
|
157
|
+
// head SHA: --head-sha accepts any 7-64 hex prefix, so keying on its literal
|
|
158
|
+
// value would let a different prefix length (or the full SHA) for the same head
|
|
159
|
+
// miss the marker and post a duplicate. The reviewed head is still shown in the
|
|
160
|
+
// comment body for context. The HTML comment is not rendered by GitHub but is
|
|
161
|
+
// matched on the comment body.
|
|
162
|
+
export function buildFindingsMarker({ gate }) {
|
|
163
|
+
return `<!-- dev-loops:gate-findings gate=${gate} -->`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Collapse any run of whitespace (newlines, tabs, repeated spaces) to a single
|
|
167
|
+
// space and trim. LLM-generated free text often carries embedded newlines, which
|
|
168
|
+
// would otherwise break a single Markdown list item across lines.
|
|
169
|
+
//
|
|
170
|
+
// Additionally neutralize any embedded HTML-comment delimiters (`<!--` / `-->`).
|
|
171
|
+
// The findings comment is keyed by a hidden marker that IS an HTML comment
|
|
172
|
+
// (buildFindingsMarker), and free text comes from scoped-review agents. Without
|
|
173
|
+
// this, a finding field could inject a second `<!-- dev-loops:gate-findings ... -->`
|
|
174
|
+
// marker (breaking idempotent comment matching) or otherwise smuggle an HTML
|
|
175
|
+
// comment into the rendered body. We escape the opening/closing angle brackets so
|
|
176
|
+
// the delimiter renders as visible literal text and cannot form a real comment.
|
|
177
|
+
function sanitizeInline(value) {
|
|
178
|
+
return String(value)
|
|
179
|
+
.replace(/\s+/g, " ")
|
|
180
|
+
.replace(/<!--/g, "<!--")
|
|
181
|
+
.replace(/-->/g, "-->")
|
|
182
|
+
.trim();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Sanitize free text that is rendered INSIDE an inline backtick code span
|
|
186
|
+
// (`angle`, file refs). On top of sanitizeInline, strip any literal backtick:
|
|
187
|
+
// a backtick inside the span would prematurely close it, breaking out into raw
|
|
188
|
+
// Markdown (injection) for the remainder of the list item. Backticks are never
|
|
189
|
+
// meaningful in an angle label or a file path, so dropping them is safe.
|
|
190
|
+
function sanitizeCodeSpan(value) {
|
|
191
|
+
return sanitizeInline(String(value).replace(/`/g, ""));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function renderFindingsCommentBody({ gate, headSha, findings }) {
|
|
195
|
+
const marker = buildFindingsMarker({ gate });
|
|
196
|
+
const lines = [
|
|
197
|
+
marker,
|
|
198
|
+
`### Gate fan-out findings: ${gate}`,
|
|
199
|
+
"",
|
|
200
|
+
// Plain text head SHA (no backticks) so GitHub autolinks the commit.
|
|
201
|
+
`Reviewed head: ${headSha}`,
|
|
202
|
+
"",
|
|
203
|
+
];
|
|
204
|
+
if (findings.length === 0) {
|
|
205
|
+
lines.push("No findings. All review angles passed for this head.");
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
const grouped = new Map();
|
|
209
|
+
for (const sev of SEVERITY_ORDER) {
|
|
210
|
+
grouped.set(sev, []);
|
|
211
|
+
}
|
|
212
|
+
for (const finding of findings) {
|
|
213
|
+
grouped.get(finding.severity).push(finding);
|
|
214
|
+
}
|
|
215
|
+
for (const sev of SEVERITY_ORDER) {
|
|
216
|
+
const group = grouped.get(sev);
|
|
217
|
+
if (group.length === 0) continue;
|
|
218
|
+
lines.push(`#### ${SEVERITY_LABELS[sev]} (${group.length})`);
|
|
219
|
+
for (const finding of group) {
|
|
220
|
+
// Sanitize free-text fields so embedded newlines/whitespace don't break
|
|
221
|
+
// the single-line Markdown list item.
|
|
222
|
+
const summary = sanitizeInline(finding.summary);
|
|
223
|
+
const dispositionSuffix = finding.disposition ? ` — _${sanitizeInline(finding.disposition)}_` : "";
|
|
224
|
+
// angle is a code/label literal → backticks; summary is prose. angle is
|
|
225
|
+
// free text from a scoped-review agent and is rendered inside an inline
|
|
226
|
+
// code span, so it must be sanitized too: an embedded backtick or newline
|
|
227
|
+
// would otherwise break the code span (markdown injection) or split the
|
|
228
|
+
// list item. Use sanitizeCodeSpan (backtick-stripping) since it lives
|
|
229
|
+
// inside backticks, consistent with the file refs below.
|
|
230
|
+
const angle = sanitizeCodeSpan(finding.angle);
|
|
231
|
+
lines.push(`- \`${angle}\`: ${summary}${dispositionSuffix}`);
|
|
232
|
+
if (Array.isArray(finding.files) && finding.files.length > 0) {
|
|
233
|
+
// File refs go inside backticks; sanitize each so embedded whitespace,
|
|
234
|
+
// newlines, or backticks can't break the single Markdown list item /
|
|
235
|
+
// code span, and drop any that sanitize to empty.
|
|
236
|
+
const refs = finding.files
|
|
237
|
+
.map(f => sanitizeCodeSpan(f))
|
|
238
|
+
.filter(f => f.length > 0)
|
|
239
|
+
.map(f => `\`${f}\``)
|
|
240
|
+
.join(", ");
|
|
241
|
+
if (refs.length > 0) {
|
|
242
|
+
lines.push(` - files: ${refs}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
lines.push("");
|
|
247
|
+
}
|
|
248
|
+
// Drop trailing blank line.
|
|
249
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
250
|
+
lines.pop();
|
|
251
|
+
}
|
|
252
|
+
return lines.join("\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function runGhJson(args, { env, ghCommand }) {
|
|
256
|
+
const result = await runChild(ghCommand, args, env);
|
|
257
|
+
if (result.code !== 0) {
|
|
258
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
259
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
260
|
+
}
|
|
261
|
+
return parseJsonText(result.stdout, { label: `gh ${args.slice(0, 3).join(" ")}` });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function listIssueComments({ repo, pr }, { env, ghCommand }) {
|
|
265
|
+
const payload = await runGhJson(
|
|
266
|
+
["api", "--paginate", "--slurp", `repos/${repo}/issues/${pr}/comments?per_page=100`],
|
|
267
|
+
{ env, ghCommand },
|
|
268
|
+
);
|
|
269
|
+
// --slurp returns an array of pages; flatten to a single comment list.
|
|
270
|
+
if (Array.isArray(payload) && payload.every(p => Array.isArray(p))) {
|
|
271
|
+
return payload.flat();
|
|
272
|
+
}
|
|
273
|
+
return Array.isArray(payload) ? payload : [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function findMarkedComment(comments, marker) {
|
|
277
|
+
for (const comment of comments) {
|
|
278
|
+
if (comment && typeof comment.body === "string" && comment.body.includes(marker)) {
|
|
279
|
+
return comment;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseCommentMutationResponse(payload) {
|
|
286
|
+
const commentId = Number.isInteger(payload?.id) ? payload.id : null;
|
|
287
|
+
const commentUrl = typeof payload?.html_url === "string" && payload.html_url.trim().length > 0
|
|
288
|
+
? payload.html_url.trim()
|
|
289
|
+
: null;
|
|
290
|
+
if (commentId === null || commentUrl === null) {
|
|
291
|
+
throw new Error("Gate findings comment mutation did not return a comment id and html_url");
|
|
292
|
+
}
|
|
293
|
+
return { commentId, commentUrl };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function createComment({ repo, pr, body }, { env, ghCommand }) {
|
|
297
|
+
const payload = await runGhJson(
|
|
298
|
+
["api", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`],
|
|
299
|
+
{ env, ghCommand },
|
|
300
|
+
);
|
|
301
|
+
return parseCommentMutationResponse(payload);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function updateComment({ repo, commentId, body }, { env, ghCommand }) {
|
|
305
|
+
const payload = await runGhJson(
|
|
306
|
+
["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`],
|
|
307
|
+
{ env, ghCommand },
|
|
308
|
+
);
|
|
309
|
+
return parseCommentMutationResponse(payload);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function postGateFindings(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
|
|
313
|
+
const findings = parseFindings(options.findings);
|
|
314
|
+
// loadDevLoopConfig never throws: it returns { config, warnings, errors }.
|
|
315
|
+
// A non-empty errors array means the config could not be loaded/validated, so
|
|
316
|
+
// log it (stderr) and fall back to default behavior (config-unavailable →
|
|
317
|
+
// null → resolveGatePostFindingsComments defaults on → proceed to post),
|
|
318
|
+
// rather than trusting a malformed/partial config object. Mirrors how
|
|
319
|
+
// detect-checkpoint-evidence treats config-unavailable.
|
|
320
|
+
const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot });
|
|
321
|
+
let config = loadedConfig;
|
|
322
|
+
if (Array.isArray(configErrors) && configErrors.length > 0) {
|
|
323
|
+
process.stderr.write(
|
|
324
|
+
`post-gate-findings: dev-loop config could not be loaded/validated; using default behavior. errors=${JSON.stringify(configErrors)}\n`,
|
|
325
|
+
);
|
|
326
|
+
config = null;
|
|
327
|
+
}
|
|
328
|
+
if (!resolveGatePostFindingsComments(config)) {
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
action: "skipped",
|
|
332
|
+
reason: "gates.postFindingsComments is false",
|
|
333
|
+
repo: options.repo,
|
|
334
|
+
pr: options.pr,
|
|
335
|
+
gate: options.gate,
|
|
336
|
+
headSha: options.headSha,
|
|
337
|
+
findingsCount: findings.length,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const marker = buildFindingsMarker({ gate: options.gate });
|
|
341
|
+
const desiredBody = renderFindingsCommentBody({ gate: options.gate, headSha: options.headSha, findings });
|
|
342
|
+
const comments = await listIssueComments({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
343
|
+
const existing = findMarkedComment(comments, marker);
|
|
344
|
+
const base = {
|
|
345
|
+
ok: true,
|
|
346
|
+
repo: options.repo,
|
|
347
|
+
pr: options.pr,
|
|
348
|
+
gate: options.gate,
|
|
349
|
+
headSha: options.headSha,
|
|
350
|
+
findingsCount: findings.length,
|
|
351
|
+
};
|
|
352
|
+
if (existing) {
|
|
353
|
+
if (typeof existing.body === "string" && existing.body === desiredBody) {
|
|
354
|
+
return {
|
|
355
|
+
...base,
|
|
356
|
+
action: "noop",
|
|
357
|
+
commentId: Number.isInteger(existing.id) ? existing.id : null,
|
|
358
|
+
commentUrl: typeof existing.html_url === "string" ? existing.html_url : null,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const updated = await updateComment({ repo: options.repo, commentId: existing.id, body: desiredBody }, { env, ghCommand });
|
|
362
|
+
return { ...base, action: "updated", ...updated };
|
|
363
|
+
}
|
|
364
|
+
const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, { env, ghCommand });
|
|
365
|
+
return { ...base, action: "created", ...created };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function main() {
|
|
369
|
+
let options;
|
|
370
|
+
try {
|
|
371
|
+
options = parsePostGateFindingsCliArgs(process.argv.slice(2));
|
|
372
|
+
} catch (error) {
|
|
373
|
+
process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (options.help) {
|
|
378
|
+
process.stdout.write(`${USAGE}\n`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
const result = await postGateFindings(options);
|
|
383
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
386
|
+
process.exitCode = 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
391
|
+
await main();
|
|
392
|
+
}
|
|
@@ -124,7 +124,7 @@ async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
|
|
|
124
124
|
}
|
|
125
125
|
return { id: prData.id, isDraft: prData.isDraft };
|
|
126
126
|
}
|
|
127
|
-
async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
|
|
127
|
+
export async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
|
|
128
128
|
const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand });
|
|
129
129
|
if (resolvedPr.isDraft === true) {
|
|
130
130
|
return {
|
|
@@ -154,7 +154,7 @@ async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
|
|
|
154
154
|
alreadyDraft: false,
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
|
-
async function markPrReady({ repo, pr }, { env, ghCommand }) {
|
|
157
|
+
export async function markPrReady({ repo, pr }, { env, ghCommand }) {
|
|
158
158
|
const result = await runChild(ghCommand, [
|
|
159
159
|
"pr", "ready", String(pr),
|
|
160
160
|
"--repo", repo,
|