dev-loops 0.6.0 → 0.7.1
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 +2 -1
- package/.claude/agents/review.md +2 -1
- package/.claude/commands/{auto.md → loop-auto.md} +1 -1
- package/.claude/commands/loop-continue.md +15 -0
- package/.claude/commands/loop-enqueue.md +22 -0
- package/.claude/commands/loop-grill.md +17 -0
- package/.claude/commands/{info.md → loop-info.md} +2 -2
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/{start-spike.md → loop-start-spike.md} +1 -1
- package/.claude/commands/{start.md → loop-start.md} +1 -1
- package/.claude/commands/{status.md → loop-status.md} +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
- package/.claude/skills/dev-loop/SKILL.md +15 -11
- package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
- package/.claude/skills/docs/anti-patterns.md +2 -1
- package/.claude/skills/docs/artifact-authority-contract.md +22 -4
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/issue-intake-procedure.md +1 -0
- package/.claude/skills/docs/local-planning-flow.md +1 -1
- package/.claude/skills/docs/local-planning-worked-example.md +1 -1
- package/.claude/skills/docs/merge-preconditions.md +17 -1
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +25 -14
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +95 -0
- package/README.md +9 -9
- package/agents/dev-loop.agent.md +3 -2
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +1 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +1 -1
- package/agents/review.agent.md +3 -2
- package/cli/index.mjs +35 -42
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +6 -0
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +7 -19
- package/package.json +9 -10
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +2 -9
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
- package/scripts/github/detect-linked-issue-pr.mjs +22 -6
- package/scripts/github/edit-pr.mjs +259 -0
- package/scripts/github/fetch-ci-logs.mjs +2 -9
- package/scripts/github/list-issues.mjs +2 -9
- package/scripts/github/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +14 -2
- package/scripts/github/probe-ci-status.mjs +8 -2
- package/scripts/github/probe-copilot-review.mjs +21 -14
- package/scripts/github/ready-for-review.mjs +26 -8
- package/scripts/github/reconcile-draft-gate.mjs +7 -3
- package/scripts/github/reply-resolve-review-thread.mjs +16 -5
- package/scripts/github/reply-resolve-review-threads.mjs +69 -7
- package/scripts/github/request-copilot-review.mjs +8 -2
- package/scripts/github/resolve-handoff-candidates.mjs +7 -3
- package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
- package/scripts/github/stage-reviewer-draft.mjs +10 -2
- package/scripts/github/tick-verified-checkboxes.mjs +202 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
- package/scripts/github/verify-fresh-review-context.mjs +150 -31
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/write-gate-context.mjs +248 -61
- package/scripts/github/write-gate-findings-log.mjs +75 -1
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_post-convergence-change.mjs +211 -0
- package/scripts/loop/_pr-runner-coordination.mjs +70 -0
- package/scripts/loop/_repo-root-resolver.mjs +47 -0
- package/scripts/loop/build-handoff-envelope.mjs +14 -4
- package/scripts/loop/check-retro-tooling.mjs +14 -3
- package/scripts/loop/checkpoint-contract.mjs +7 -4
- package/scripts/loop/cleanup-worktree.mjs +12 -3
- package/scripts/loop/conductor-monitor.mjs +12 -18
- package/scripts/loop/copilot-pr-handoff.mjs +135 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +36 -7
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
- package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
- package/scripts/loop/detect-internal-only-pr.mjs +8 -2
- package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +149 -66
- package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
- package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
- package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
- package/scripts/loop/ensure-worktree.mjs +8 -3
- package/scripts/loop/info.mjs +12 -4
- package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
- package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
- package/scripts/loop/inspect-run-viewer.mjs +67 -4
- package/scripts/loop/inspect-run.mjs +8 -2
- package/scripts/loop/outer-loop.mjs +8 -4
- package/scripts/loop/pr-runner-coordination.mjs +2 -9
- package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
- package/scripts/loop/pre-flight-gate.mjs +9 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
- package/scripts/loop/provision-worktree.mjs +74 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
- package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
- package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
- package/scripts/loop/run-conductor-cycle.mjs +8 -2
- package/scripts/loop/run-queue.mjs +18 -9
- package/scripts/loop/run-refinement-audit.mjs +8 -9
- package/scripts/loop/run-watch-cycle.mjs +2 -9
- package/scripts/loop/sanctioned-commands.mjs +104 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +207 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
- package/scripts/pages/build-site.mjs +59 -8
- package/scripts/pages/build-state-atlas.mjs +441 -0
- package/scripts/projects/_resolve-project.mjs +148 -0
- package/scripts/projects/add-queue-item.mjs +60 -54
- package/scripts/projects/archive-done-items.mjs +49 -84
- package/scripts/projects/ensure-queue-board.mjs +10 -36
- package/scripts/projects/list-queue-items.mjs +116 -65
- package/scripts/projects/move-queue-item.mjs +28 -49
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +41 -47
- package/scripts/projects/resolve-active-board-item.mjs +108 -46
- package/scripts/projects/sync-item-status.mjs +15 -10
- package/scripts/refine/_refine-helpers.mjs +21 -5
- package/scripts/refine/exit-spike.mjs +18 -8
- package/scripts/refine/promote-plan.mjs +22 -16
- package/scripts/refine/prose-linkage-detector.mjs +1 -1
- package/scripts/refine/refine-plan-file.mjs +13 -4
- package/scripts/refine/refinement-completeness-checker.mjs +1 -1
- package/scripts/refine/scaffold-spike-file.mjs +4 -8
- package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
- package/scripts/refine/tree-integrity-validator.mjs +1 -1
- package/scripts/refine/validate-plan-file.mjs +1 -1
- package/scripts/refine/validate-spike-file.mjs +1 -1
- package/scripts/refine/verify.mjs +11 -6
- package/scripts/release/assert-core-dependency-version.mjs +123 -0
- package/scripts/release/extract-changelog-section.mjs +16 -2
- package/skills/copilot-pr-followup/SKILL.md +24 -12
- package/skills/dev-loop/SKILL.md +10 -6
- package/skills/docs/acceptance-criteria-verification.md +6 -2
- package/skills/docs/anti-patterns.md +2 -1
- package/skills/docs/artifact-authority-contract.md +22 -4
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/issue-intake-procedure.md +1 -0
- package/skills/docs/local-planning-flow.md +1 -1
- package/skills/docs/local-planning-worked-example.md +1 -1
- package/skills/docs/merge-preconditions.md +17 -1
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +1 -1
- package/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +25 -14
- package/skills/loop-grill/SKILL.md +165 -0
- package/.claude/commands/continue.md +0 -15
- package/scripts/loop/conductor.mjs +0 -233
- package/scripts/loop/detect-stale-runner.mjs +0 -265
- package/scripts/loop/pre-push-main-guard.mjs +0 -117
|
@@ -9,15 +9,19 @@ import {
|
|
|
9
9
|
summarizeGateReviewCommentMarkers,
|
|
10
10
|
summarizeGateReviewComments,
|
|
11
11
|
} from "../_core-helpers.mjs";
|
|
12
|
-
import { access } from "node:fs/promises";
|
|
12
|
+
import { access, readFile } from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
15
15
|
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
16
16
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
17
|
-
import { loadDevLoopConfig, resolveGateConfig, resolveRequireFanoutEvidence } from "@dev-loops/core/config";
|
|
17
|
+
import { FANOUT_PROVENANCE_MIN_REVIEWERS, GATE_FULL_LABEL, loadDevLoopConfig, resolveGateConfig, resolveLightMode, resolveRequireFanoutEvidence, resolveRequireFanoutProvenance } from "@dev-loops/core/config";
|
|
18
|
+
import { FANOUT_UNAVAILABLE_MESSAGE, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
|
|
19
|
+
import { detectMergeBaseScope, isEligibleForLightMode } from "../loop/detect-change-scope.mjs";
|
|
18
20
|
import { buildLogPath } from "./write-gate-findings-log.mjs";
|
|
19
21
|
import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
|
|
20
22
|
import { detectStaleRunner } from "../loop/_stale-runner-detection.mjs";
|
|
23
|
+
import { resolveLedgerCheckouts, resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
|
|
24
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
21
25
|
const USAGE = `Usage: detect-checkpoint-evidence.mjs --repo <owner/name> --pr <number>
|
|
22
26
|
Fetch the live PR head SHA and visible PR issue comments, then summarize the
|
|
23
27
|
latest valid draft-gate and pre-approval checkpoint verdict comments. Always fail
|
|
@@ -84,9 +88,11 @@ Output (stdout, JSON; always includes preMergeGateCheck):
|
|
|
84
88
|
Error output (stderr, JSON):
|
|
85
89
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
86
90
|
{ "ok": false, "error": "..." }
|
|
91
|
+
${JQ_OUTPUT_USAGE}
|
|
87
92
|
Exit codes:
|
|
88
93
|
0 Success (gate evidence is valid)
|
|
89
|
-
1 Argument error, gh failure, malformed gh JSON, or missing required pre-merge gate evidence
|
|
94
|
+
1 Argument error, gh failure, malformed gh JSON, or missing required pre-merge gate evidence.
|
|
95
|
+
2 Invalid --jq filter.`.trim();
|
|
90
96
|
const parseError = buildParseError(USAGE);
|
|
91
97
|
export function parseDetectCheckpointEvidenceCliArgs(argv) {
|
|
92
98
|
const { tokens } = parseArgs({
|
|
@@ -96,6 +102,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
|
|
|
96
102
|
repo: { type: "string" },
|
|
97
103
|
pr: { type: "string" },
|
|
98
104
|
"require-before-merge": { type: "boolean" },
|
|
105
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
99
106
|
},
|
|
100
107
|
allowPositionals: true,
|
|
101
108
|
strict: false,
|
|
@@ -125,6 +132,7 @@ export function parseDetectCheckpointEvidenceCliArgs(argv) {
|
|
|
125
132
|
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
126
133
|
continue;
|
|
127
134
|
}
|
|
135
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
128
136
|
if (token.name === "require-before-merge") {
|
|
129
137
|
throw parseError(`--require-before-merge has been removed: gate evidence enforcement is now always-on by default. Omit the flag.`);
|
|
130
138
|
}
|
|
@@ -249,7 +257,24 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
249
257
|
// { required: false, gates: [] } so the `.required` guard skips this block.
|
|
250
258
|
if (fanoutEnforcement && fanoutEnforcement.required) {
|
|
251
259
|
for (const gate of fanoutEnforcement.gates) {
|
|
252
|
-
|
|
260
|
+
// Light-mode acceptance (#1174): a genuinely under-threshold micro-PR
|
|
261
|
+
// collapses the gate fan-out to a single inline check (#1043). Accept that
|
|
262
|
+
// inline verdict ONLY when ALL hold, fail CLOSED otherwise:
|
|
263
|
+
// - lightMode is enabled in config, AND
|
|
264
|
+
// - the reviewed head's merge-base scope was RE-DERIVED under threshold
|
|
265
|
+
// (scopeUnderThreshold; false whenever scope could not be derived), AND
|
|
266
|
+
// - the PR carries no gate:full label (which always forces fan-out), AND
|
|
267
|
+
// - the verdict records a non-empty inline reason.
|
|
268
|
+
// Any non-light inline verdict (over threshold / label / lightMode off /
|
|
269
|
+
// scope underivable) falls through to the byte-identical rejection below.
|
|
270
|
+
const lightAccepted =
|
|
271
|
+
gate.executionMode === "inline_single_agent"
|
|
272
|
+
&& fanoutEnforcement.lightMode === true
|
|
273
|
+
&& fanoutEnforcement.hasFullLabel !== true
|
|
274
|
+
&& gate.scopeUnderThreshold === true
|
|
275
|
+
&& typeof gate.inlineReason === "string"
|
|
276
|
+
&& gate.inlineReason.trim().length > 0;
|
|
277
|
+
if (gate.executionMode !== "fanout_fanin" && !lightAccepted) {
|
|
253
278
|
failures.push(
|
|
254
279
|
`${gate.name}: requireFanoutEvidence is enabled but executionMode is "${gate.executionMode ?? "unset"}" (expected "fanout_fanin"); inline gate verdicts are not accepted`,
|
|
255
280
|
);
|
|
@@ -259,6 +284,31 @@ export function buildPreMergeGateCheck(evidence, unresolvedThreadCount = null, s
|
|
|
259
284
|
failures.push(
|
|
260
285
|
`${gate.name}: requireFanoutEvidence is enabled but no findings-log ledger exists for the reviewed head (${gate.ledgerPath})`,
|
|
261
286
|
);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
// Opt-in provenance enforcement (gates.requireFanoutProvenance), layered on
|
|
290
|
+
// top of fan-out evidence. When off (default) requireProvenance is falsy so
|
|
291
|
+
// NO new failure is added — behavior is byte-identical to today. When on, a
|
|
292
|
+
// fanout_fanin ledger must record INTERNALLY-CONSISTENT provenance (checked
|
|
293
|
+
// the same way the write path validates it — a hand-edited or shadow ledger
|
|
294
|
+
// is re-validated here, not trusted) with distinctReviewers >= the floor.
|
|
295
|
+
// Provenance is enforced ONLY for fanout_fanin verdicts (#1174): a
|
|
296
|
+
// light-accepted inline verdict is already scope-bounded and has no
|
|
297
|
+
// multi-reviewer provenance to record, so requiring it would make the
|
|
298
|
+
// light path unmergeable — the inverse of this issue's fix.
|
|
299
|
+
if (fanoutEnforcement.requireProvenance && gate.executionMode === "fanout_fanin") {
|
|
300
|
+
const prov = gate.provenance;
|
|
301
|
+
const consistencyErr = provenanceConsistencyError(prov);
|
|
302
|
+
const reviewers = prov && Number.isInteger(prov.distinctReviewers) ? prov.distinctReviewers : null;
|
|
303
|
+
if (consistencyErr) {
|
|
304
|
+
failures.push(
|
|
305
|
+
`${gate.name}: requireFanoutProvenance is enabled but the findings-log ledger lacks valid fan-out provenance (${consistencyErr}); ${FANOUT_UNAVAILABLE_MESSAGE}`,
|
|
306
|
+
);
|
|
307
|
+
} else if (reviewers === null || reviewers < FANOUT_PROVENANCE_MIN_REVIEWERS) {
|
|
308
|
+
failures.push(
|
|
309
|
+
`${gate.name}: requireFanoutProvenance is enabled but the findings-log ledger lacks valid fan-out provenance (need provenance.distinctReviewers >= ${FANOUT_PROVENANCE_MIN_REVIEWERS}, got ${reviewers === null ? "none" : reviewers}); ${FANOUT_UNAVAILABLE_MESSAGE}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
262
312
|
}
|
|
263
313
|
}
|
|
264
314
|
}
|
|
@@ -287,24 +337,88 @@ async function ledgerExists(fullPath) {
|
|
|
287
337
|
return false;
|
|
288
338
|
}
|
|
289
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* True if the ledger (relative path) exists under ANY enumerated checkout
|
|
342
|
+
* (main + every worktree). Fixes #1050: a ledger written in the PR worktree is
|
|
343
|
+
* found even when the check runs from a different checkout's git-toplevel.
|
|
344
|
+
*/
|
|
345
|
+
async function ledgerExistsInAny(checkouts, ledgerPath) {
|
|
346
|
+
for (const root of checkouts) {
|
|
347
|
+
if (await ledgerExists(path.resolve(root, ledgerPath))) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Read the recorded fan-out `provenance` object from a ledger across the
|
|
355
|
+
* enumerated checkouts. Mirrors ledgerExistsInAny's "ANY checkout satisfies"
|
|
356
|
+
* semantics: prefers the FIRST checkout whose ledger provenance actually
|
|
357
|
+
* SATISFIES enforcement (internally consistent AND distinctReviewers >= floor),
|
|
358
|
+
* so a below-floor or provenance-less ledger in an earlier-enumerated checkout
|
|
359
|
+
* cannot SHADOW a valid one in the PR worktree (which would falsely fail closed).
|
|
360
|
+
* Falls back to the first non-null provenance (for a useful diagnostic message)
|
|
361
|
+
* only when NO checkout satisfies, and null only when none is present. Only
|
|
362
|
+
* called when requireFanoutProvenance is enabled so the default path pays no I/O.
|
|
363
|
+
*/
|
|
364
|
+
async function readLedgerProvenanceInAny(checkouts, ledgerPath) {
|
|
365
|
+
let firstNonNull = null;
|
|
366
|
+
for (const root of checkouts) {
|
|
367
|
+
const full = path.resolve(root, ledgerPath);
|
|
368
|
+
try {
|
|
369
|
+
const parsed = JSON.parse(await readFile(full, "utf8"));
|
|
370
|
+
const prov = parsed && typeof parsed === "object" ? parsed.provenance : null;
|
|
371
|
+
if (prov == null) continue; // ledger present but no provenance — keep scanning.
|
|
372
|
+
if (provenanceConsistencyError(prov) === null && prov.distinctReviewers >= FANOUT_PROVENANCE_MIN_REVIEWERS) {
|
|
373
|
+
return prov; // satisfying ledger — prefer it over any earlier below-floor one.
|
|
374
|
+
}
|
|
375
|
+
if (firstNonNull === null) firstNonNull = prov; // remember for diagnostics.
|
|
376
|
+
} catch {
|
|
377
|
+
// Missing/unreadable/malformed ledger in this checkout — try the next.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return firstNonNull;
|
|
381
|
+
}
|
|
290
382
|
/**
|
|
291
383
|
* Build the fan-out evidence enforcement descriptor.
|
|
292
384
|
*
|
|
293
385
|
* Enforcement is ON by default (opt-out via gates.requireFanoutEvidence: false).
|
|
294
386
|
* Returns { required: false } when enforcement is disabled OR when config is
|
|
295
387
|
* unavailable (config == null — null or undefined — after a failed load) — config-unavailable must
|
|
296
|
-
* fail open and never enable enforcement. When enabled,
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
388
|
+
* fail open and never enable enforcement. When enabled, returns
|
|
389
|
+
* { required: true, requireProvenance, lightMode, hasFullLabel, gates } where
|
|
390
|
+
* each per-required-gate entry records executionMode, inlineReason,
|
|
391
|
+
* scopeUnderThreshold, and whether the deterministic findings-log ledger exists
|
|
392
|
+
* for the reviewed head SHA, so the pre-merge check can fail closed on inline
|
|
393
|
+
* verdicts or missing ledgers.
|
|
394
|
+
*
|
|
395
|
+
* Light mode (#1174): when gates.lightMode is configured, `hasFullLabel`
|
|
396
|
+
* (gate:full PR label) and `baseRef` feed a fail-closed merge-base scope
|
|
397
|
+
* re-derivation for inline verdicts. A gate's scopeUnderThreshold is true only
|
|
398
|
+
* when light mode is on, the PR has no gate:full label, a base ref is known,
|
|
399
|
+
* and the reviewed head's merge-base diff is genuinely under threshold — which
|
|
400
|
+
* lets the pre-merge check accept an inline single-agent verdict for a
|
|
401
|
+
* small-scope PR instead of always rejecting inline evidence.
|
|
300
402
|
*/
|
|
301
|
-
async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd }) {
|
|
403
|
+
export async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarker, preApprovalGateMarker, config, cwd, hasFullLabel = false, baseRef = null }) {
|
|
302
404
|
// Fail open when config could not be loaded/validated. `== null` covers both
|
|
303
405
|
// null and undefined; the loader only ever yields null on failure, but the
|
|
304
406
|
// loose check defensively treats an absent config as unavailable.
|
|
305
407
|
if (config == null || !resolveRequireFanoutEvidence(config)) {
|
|
408
|
+
// Disabled/unavailable return is intentionally byte-identical to before
|
|
409
|
+
// (no requireProvenance key): buildPreMergeGateCheck only reads it inside
|
|
410
|
+
// the `required` block, so this preserves the exact existing shape.
|
|
306
411
|
return { required: false, gates: [] };
|
|
307
412
|
}
|
|
413
|
+
// Provenance enforcement is opt-in and layered ON TOP of fan-out evidence: it
|
|
414
|
+
// only takes effect while evidence enforcement (above) is active.
|
|
415
|
+
const requireProvenance = resolveRequireFanoutProvenance(config);
|
|
416
|
+
// Light-mode facts (#1174): the threshold that a re-derived merge-base scope
|
|
417
|
+
// must fall under for an inline verdict to be accepted. null when lightMode is
|
|
418
|
+
// disabled → no inline verdict can ever be accepted (scopeUnderThreshold stays
|
|
419
|
+
// false), preserving today's rejection.
|
|
420
|
+
const lightThreshold = resolveLightMode(config);
|
|
421
|
+
const lightMode = lightThreshold != null;
|
|
308
422
|
const draftRequired = resolveGateConfig(config, "draft").required;
|
|
309
423
|
const preApprovalRequired = resolveGateConfig(config, "preApproval").required;
|
|
310
424
|
const gateSpecs = [
|
|
@@ -312,18 +426,35 @@ async function buildFanoutEnforcement({ repo, pr, currentHeadSha, draftGateMarke
|
|
|
312
426
|
{ name: "pre_approval_gate", marker: preApprovalGateMarker, required: preApprovalRequired },
|
|
313
427
|
].filter((spec) => spec.required && spec.marker.visible);
|
|
314
428
|
const gates = [];
|
|
429
|
+
const checkouts = resolveLedgerCheckouts(cwd);
|
|
430
|
+
// checkouts[0] is always resolveRepoRoot(cwd) (resolveLedgerCheckouts adds it
|
|
431
|
+
// first, unconditionally, and never throws — it falls back to cwd on git
|
|
432
|
+
// failure) — reuse it instead of a second `git rev-parse --show-toplevel`.
|
|
433
|
+
const repoRoot = checkouts[0];
|
|
315
434
|
for (const spec of gateSpecs) {
|
|
316
435
|
const headSha = spec.marker.headSha ?? currentHeadSha;
|
|
317
436
|
const ledgerPath = buildLogPath({ repo, pr, gate: spec.name, headSha, tmpRoot: "tmp" });
|
|
318
|
-
|
|
437
|
+
// Re-derive scope FAIL-CLOSED for inline verdicts only (the fan-out default
|
|
438
|
+
// path pays no git I/O). scopeUnderThreshold is true ONLY when lightMode is
|
|
439
|
+
// on, the PR has no gate:full label, a base ref is known, and the merge-base
|
|
440
|
+
// diff for the reviewed head is genuinely under threshold. Any git/scope
|
|
441
|
+
// failure leaves it false, so the inline verdict is rejected exactly as today.
|
|
442
|
+
let scopeUnderThreshold = false;
|
|
443
|
+
if (lightMode && !hasFullLabel && baseRef && spec.marker.executionMode === "inline_single_agent") {
|
|
444
|
+
const scope = detectMergeBaseScope({ base: baseRef, head: headSha, cwd: repoRoot });
|
|
445
|
+
scopeUnderThreshold = scope.ok === true && isEligibleForLightMode(scope, lightThreshold);
|
|
446
|
+
}
|
|
319
447
|
gates.push({
|
|
320
448
|
name: spec.name,
|
|
321
449
|
executionMode: spec.marker.executionMode ?? null,
|
|
450
|
+
inlineReason: spec.marker.inlineReason ?? null,
|
|
451
|
+
scopeUnderThreshold,
|
|
322
452
|
ledgerPath,
|
|
323
|
-
ledgerExists: await
|
|
453
|
+
ledgerExists: await ledgerExistsInAny(checkouts, ledgerPath),
|
|
454
|
+
provenance: requireProvenance ? await readLedgerProvenanceInAny(checkouts, ledgerPath) : null,
|
|
324
455
|
});
|
|
325
456
|
}
|
|
326
|
-
return { required: true, gates };
|
|
457
|
+
return { required: true, requireProvenance, lightMode, hasFullLabel, gates };
|
|
327
458
|
}
|
|
328
459
|
export async function detectCheckpointEvidence(options, { env = process.env, ghCommand = "gh", cwd = process.cwd() } = {}) {
|
|
329
460
|
const runnerOwnership = await ensureAsyncRunnerOwnership({
|
|
@@ -378,8 +509,31 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
378
509
|
// treat it as config-unavailable and leave fan-out enforcement disabled
|
|
379
510
|
// (preserves default behavior). Other gate checks remain unaffected.
|
|
380
511
|
let config = null;
|
|
381
|
-
const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: cwd });
|
|
512
|
+
const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: resolveRepoRoot(cwd) });
|
|
382
513
|
config = Array.isArray(configErrors) && configErrors.length > 0 ? null : loadedConfig;
|
|
514
|
+
// Light-mode pre-merge facts (#1174): the base commit for the merge-base scope
|
|
515
|
+
// re-derivation and whether the PR forces full fan-out via the gate:full label.
|
|
516
|
+
// Fetched LAZILY — only when fan-out enforcement is active AND lightMode is on
|
|
517
|
+
// AND a gate actually recorded an inline verdict — so the common fan-out path
|
|
518
|
+
// (and every existing caller/test) makes NO extra gh call and stays unchanged.
|
|
519
|
+
let baseRef = null;
|
|
520
|
+
let hasFullLabel = false;
|
|
521
|
+
const anyInlineVerdict = [draftGateMarker, preApprovalGateMarker].some(
|
|
522
|
+
(marker) => marker.visible && marker.executionMode === "inline_single_agent",
|
|
523
|
+
);
|
|
524
|
+
if (config != null && resolveRequireFanoutEvidence(config) && resolveLightMode(config) != null && anyInlineVerdict) {
|
|
525
|
+
try {
|
|
526
|
+
const lightFacts = await runGhJson(["pr", "view", String(options.pr), "--repo", options.repo, "--json", "baseRefOid,labels"], { env, ghCommand });
|
|
527
|
+
baseRef = typeof lightFacts?.baseRefOid === "string" && lightFacts.baseRefOid.trim().length > 0
|
|
528
|
+
? lightFacts.baseRefOid.trim()
|
|
529
|
+
: null;
|
|
530
|
+
hasFullLabel = Array.isArray(lightFacts?.labels)
|
|
531
|
+
&& lightFacts.labels.some((label) => (typeof label === "string" ? label : label?.name) === GATE_FULL_LABEL);
|
|
532
|
+
} catch {
|
|
533
|
+
// Fail CLOSED: without the label/base facts we cannot safely accept an
|
|
534
|
+
// inline verdict, so leave baseRef null (scope underivable → rejected).
|
|
535
|
+
}
|
|
536
|
+
}
|
|
383
537
|
const fanoutEnforcement = await buildFanoutEnforcement({
|
|
384
538
|
repo: options.repo,
|
|
385
539
|
pr: options.pr,
|
|
@@ -388,6 +542,8 @@ export async function detectCheckpointEvidence(options, { env = process.env, ghC
|
|
|
388
542
|
preApprovalGateMarker,
|
|
389
543
|
config,
|
|
390
544
|
cwd,
|
|
545
|
+
hasFullLabel,
|
|
546
|
+
baseRef,
|
|
391
547
|
});
|
|
392
548
|
return {
|
|
393
549
|
ok: true,
|
|
@@ -458,7 +614,7 @@ async function main() {
|
|
|
458
614
|
process.exitCode = 1;
|
|
459
615
|
return;
|
|
460
616
|
}
|
|
461
|
-
process.
|
|
617
|
+
process.exitCode = emitResult(output, { jq: options.jq, silent: options.silent });
|
|
462
618
|
} catch (error) {
|
|
463
619
|
if (error && typeof error === "object" && "staleRunner" in error && error.staleRunner) {
|
|
464
620
|
const staleRunnerCheck = {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
|
-
import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseIssueNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
5
5
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
6
7
|
export const LINKED_ISSUE_PR_QUERY = [
|
|
7
8
|
"query($owner:String!, $name:String!, $issue:Int!, $after:String) {",
|
|
8
9
|
" repository(owner:$owner, name:$name) {",
|
|
@@ -28,6 +29,7 @@ export const LINKED_ISSUE_PR_QUERY = [
|
|
|
28
29
|
" }",
|
|
29
30
|
" ... on CrossReferencedEvent {",
|
|
30
31
|
" createdAt",
|
|
32
|
+
" willCloseTarget",
|
|
31
33
|
" source {",
|
|
32
34
|
" __typename",
|
|
33
35
|
" ... on PullRequest {",
|
|
@@ -73,12 +75,18 @@ Error output (stderr, JSON):
|
|
|
73
75
|
Argument/usage errors:
|
|
74
76
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
75
77
|
gh/runtime failures:
|
|
76
|
-
{ "ok": false, "error": "..." }
|
|
78
|
+
{ "ok": false, "error": "..." }
|
|
79
|
+
${JQ_OUTPUT_USAGE}`.trim();
|
|
77
80
|
const parseError = buildParseError(USAGE);
|
|
78
81
|
export function parseDetectLinkedIssuePrCliArgs(argv) {
|
|
79
82
|
const { tokens } = parseArgs({
|
|
80
83
|
args: [...argv],
|
|
81
|
-
options: {
|
|
84
|
+
options: {
|
|
85
|
+
help: { type: "boolean", short: "h" },
|
|
86
|
+
repo: { type: "string" },
|
|
87
|
+
issue: { type: "string" },
|
|
88
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
89
|
+
},
|
|
82
90
|
allowPositionals: true,
|
|
83
91
|
strict: false,
|
|
84
92
|
tokens: true,
|
|
@@ -107,6 +115,7 @@ export function parseDetectLinkedIssuePrCliArgs(argv) {
|
|
|
107
115
|
options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
|
|
108
116
|
continue;
|
|
109
117
|
}
|
|
118
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
110
119
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
111
120
|
}
|
|
112
121
|
if (options.repo === undefined || options.issue === undefined) {
|
|
@@ -162,6 +171,13 @@ function normalizeLinkedPrNode(node) {
|
|
|
162
171
|
};
|
|
163
172
|
}
|
|
164
173
|
if (node.__typename === "CrossReferencedEvent") {
|
|
174
|
+
// Only a cross-reference that will CLOSE this issue owns its board status.
|
|
175
|
+
// A bare body-mention (willCloseTarget:false, e.g. "part of #X") must not
|
|
176
|
+
// create board-ownership linkage — otherwise every sibling a PR mentions
|
|
177
|
+
// gets dragged to In Progress and the resolver fails closed (#1130).
|
|
178
|
+
if (node.willCloseTarget !== true) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
165
181
|
return {
|
|
166
182
|
eventType: "CROSS_REFERENCED_EVENT",
|
|
167
183
|
eventCreatedAt: node.createdAt,
|
|
@@ -253,7 +269,7 @@ export function selectLinkedIssuePr(candidates) {
|
|
|
253
269
|
});
|
|
254
270
|
return sorted[0] ?? null;
|
|
255
271
|
}
|
|
256
|
-
export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
272
|
+
export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
257
273
|
const { owner, name } = parseRepoSlug(repo);
|
|
258
274
|
const candidates = [];
|
|
259
275
|
const closedUnmergedCandidates = [];
|
|
@@ -322,7 +338,7 @@ export async function detectLinkedIssuePr({ repo, issue }, { env = process.env,
|
|
|
322
338
|
}
|
|
323
339
|
export async function runCli(
|
|
324
340
|
argv = process.argv.slice(2),
|
|
325
|
-
{ stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
|
|
341
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
|
|
326
342
|
) {
|
|
327
343
|
const options = parseDetectLinkedIssuePrCliArgs(argv);
|
|
328
344
|
if (options.help) {
|
|
@@ -333,7 +349,7 @@ export async function runCli(
|
|
|
333
349
|
{ repo: options.repo, issue: options.issue },
|
|
334
350
|
{ env, ghCommand },
|
|
335
351
|
);
|
|
336
|
-
|
|
352
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
337
353
|
}
|
|
338
354
|
if (isDirectCliRun(import.meta.url)) {
|
|
339
355
|
runCli().catch((error) => {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
5
|
+
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
6
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
9
|
+
|
|
10
|
+
const USAGE = `Usage: edit-pr.mjs --repo <owner/name> --pr <number> [--title <t>] [--body <b> | --body-file <path>] [--add-assignee <u>] [--remove-assignee <u>] [--milestone <m>]
|
|
11
|
+
Edit PR title/body/assignees/milestone. Thin wrapper over \`gh pr edit\` — use this
|
|
12
|
+
instead of an agent-level raw \`gh pr edit\` so the loop's internal-tooling record
|
|
13
|
+
stays clean (siblings: view-pr.mjs, comment-issue.mjs; #1057).
|
|
14
|
+
Required:
|
|
15
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
16
|
+
--pr <number> Pull request number
|
|
17
|
+
At least one edit:
|
|
18
|
+
--title <t> New PR title
|
|
19
|
+
--body <b> New PR body as a single argument
|
|
20
|
+
--body-file <path> Read the new body from a file (- reads stdin)
|
|
21
|
+
--add-assignee <u> Assignee to add (repeatable)
|
|
22
|
+
--remove-assignee <u> Assignee to remove (repeatable)
|
|
23
|
+
--milestone <m> Milestone to set (empty string clears it)
|
|
24
|
+
(--title/--body/--body-file reject empty or
|
|
25
|
+
whitespace-only values; use --milestone "" only
|
|
26
|
+
to clear the milestone)
|
|
27
|
+
Output (stdout, JSON):
|
|
28
|
+
{ "ok": true, "repo": "owner/repo", "pr": 17, "edited": ["title", "body", ...] }
|
|
29
|
+
Error output (stderr, JSON):
|
|
30
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
31
|
+
${JQ_OUTPUT_USAGE}
|
|
32
|
+
Exit codes:
|
|
33
|
+
0 Success
|
|
34
|
+
1 Argument error or gh failure
|
|
35
|
+
2 Invalid --jq filter`.trim();
|
|
36
|
+
const parseError = buildParseError(USAGE);
|
|
37
|
+
|
|
38
|
+
export function parseEditPrCliArgs(argv) {
|
|
39
|
+
const { tokens } = parseArgs({
|
|
40
|
+
args: [...argv],
|
|
41
|
+
options: {
|
|
42
|
+
help: { type: "boolean", short: "h" },
|
|
43
|
+
repo: { type: "string" },
|
|
44
|
+
pr: { type: "string" },
|
|
45
|
+
title: { type: "string" },
|
|
46
|
+
body: { type: "string" },
|
|
47
|
+
"body-file": { type: "string" },
|
|
48
|
+
"add-assignee": { type: "string", multiple: true },
|
|
49
|
+
"remove-assignee": { type: "string", multiple: true },
|
|
50
|
+
milestone: { type: "string" },
|
|
51
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
52
|
+
},
|
|
53
|
+
allowPositionals: true,
|
|
54
|
+
strict: false,
|
|
55
|
+
tokens: true,
|
|
56
|
+
});
|
|
57
|
+
const options = {
|
|
58
|
+
help: false,
|
|
59
|
+
repo: undefined,
|
|
60
|
+
pr: undefined,
|
|
61
|
+
title: undefined,
|
|
62
|
+
body: undefined,
|
|
63
|
+
bodyFile: undefined,
|
|
64
|
+
addAssignees: [],
|
|
65
|
+
removeAssignees: [],
|
|
66
|
+
milestone: undefined,
|
|
67
|
+
jq: undefined,
|
|
68
|
+
silent: false,
|
|
69
|
+
};
|
|
70
|
+
for (const token of tokens) {
|
|
71
|
+
if (token.kind === "positional") {
|
|
72
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
73
|
+
}
|
|
74
|
+
if (token.kind !== "option") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (token.name === "help") {
|
|
78
|
+
options.help = true;
|
|
79
|
+
return options;
|
|
80
|
+
}
|
|
81
|
+
if (token.name === "repo") {
|
|
82
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.name === "pr") {
|
|
86
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (token.name === "title") {
|
|
90
|
+
const title = requireTokenValue(token, parseError);
|
|
91
|
+
if (title.trim().length === 0) {
|
|
92
|
+
throw parseError("--title must not be empty or whitespace-only");
|
|
93
|
+
}
|
|
94
|
+
options.title = title;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (token.name === "body") {
|
|
98
|
+
const body = requireTokenValue(token, parseError);
|
|
99
|
+
if (body.trim().length === 0) {
|
|
100
|
+
throw parseError("--body must not be empty or whitespace-only");
|
|
101
|
+
}
|
|
102
|
+
options.body = body;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (token.name === "body-file") {
|
|
106
|
+
const rawPath = requireTokenValue(token, parseError).trim();
|
|
107
|
+
if (rawPath.length === 0) {
|
|
108
|
+
throw parseError("--body-file must be a non-empty path");
|
|
109
|
+
}
|
|
110
|
+
options.bodyFile = rawPath;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (token.name === "add-assignee") {
|
|
114
|
+
const u = requireTokenValue(token, parseError).trim();
|
|
115
|
+
if (u.length === 0) throw parseError("--add-assignee must be a non-empty login");
|
|
116
|
+
options.addAssignees.push(u);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (token.name === "remove-assignee") {
|
|
120
|
+
const u = requireTokenValue(token, parseError).trim();
|
|
121
|
+
if (u.length === 0) throw parseError("--remove-assignee must be a non-empty login");
|
|
122
|
+
options.removeAssignees.push(u);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (token.name === "milestone") {
|
|
126
|
+
// Read the raw token value: an empty string is a valid milestone value
|
|
127
|
+
// (`gh pr edit --milestone ""` clears it), so this deliberately does NOT
|
|
128
|
+
// go through requireTokenValue (which rejects empty). Guard only a truly
|
|
129
|
+
// missing value (`--milestone` with no following token). A whitespace-only
|
|
130
|
+
// value is neither a clear nor a real milestone name — fail closed rather
|
|
131
|
+
// than forwarding it to gh for a less actionable error.
|
|
132
|
+
if (typeof token.value !== "string") {
|
|
133
|
+
throw parseError("--milestone requires a value (use an empty string to clear)");
|
|
134
|
+
}
|
|
135
|
+
if (token.value.length > 0 && token.value.trim().length === 0) {
|
|
136
|
+
throw parseError('--milestone must be a milestone name or "" to clear (whitespace-only is not allowed)');
|
|
137
|
+
}
|
|
138
|
+
options.milestone = token.value;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
142
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
143
|
+
}
|
|
144
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
145
|
+
throw parseError("Editing a PR requires both --repo <owner/name> and --pr <number>");
|
|
146
|
+
}
|
|
147
|
+
if (options.body !== undefined && options.bodyFile !== undefined) {
|
|
148
|
+
throw parseError("--body and --body-file are mutually exclusive; pass only one");
|
|
149
|
+
}
|
|
150
|
+
const hasEdit =
|
|
151
|
+
options.title !== undefined ||
|
|
152
|
+
options.body !== undefined ||
|
|
153
|
+
options.bodyFile !== undefined ||
|
|
154
|
+
options.addAssignees.length > 0 ||
|
|
155
|
+
options.removeAssignees.length > 0 ||
|
|
156
|
+
options.milestone !== undefined;
|
|
157
|
+
if (!hasEdit) {
|
|
158
|
+
throw parseError("Editing a PR requires at least one of --title/--body/--body-file/--add-assignee/--remove-assignee/--milestone");
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
parseRepoSlug(options.repo);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
164
|
+
}
|
|
165
|
+
return options;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function resolveBody(options) {
|
|
169
|
+
if (options.bodyFile === undefined) return options.body;
|
|
170
|
+
// Stdin (fd 0): the fs/promises readFile does NOT accept an integer fd, so read
|
|
171
|
+
// it synchronously via the callback-style API (which does). A real path stays on
|
|
172
|
+
// the async promise read.
|
|
173
|
+
const body =
|
|
174
|
+
options.bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(options.bodyFile, "utf8");
|
|
175
|
+
// Fail closed on an empty / whitespace-only file so a blank --body-file cannot
|
|
176
|
+
// silently clear the PR body (USAGE promises --body/--title reject empties).
|
|
177
|
+
if (body.trim().length === 0) {
|
|
178
|
+
throw new Error(`--body-file ${options.bodyFile} is empty`);
|
|
179
|
+
}
|
|
180
|
+
return body;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Build the `gh pr edit` args and the parallel `edited` list (which fields were
|
|
184
|
+
// touched) so callers get a stable summary without re-reading the PR.
|
|
185
|
+
async function buildEditArgs(options) {
|
|
186
|
+
const args = ["pr", "edit", String(options.pr), "--repo", options.repo];
|
|
187
|
+
const edited = [];
|
|
188
|
+
if (options.title !== undefined) {
|
|
189
|
+
args.push("--title", options.title);
|
|
190
|
+
edited.push("title");
|
|
191
|
+
}
|
|
192
|
+
// resolveBody still runs for validation (reads the file, throws on empty /
|
|
193
|
+
// whitespace-only). A REAL --body-file path is handed straight to gh so large
|
|
194
|
+
// bodies avoid command-length limits. But `--body-file -` (stdin) was already
|
|
195
|
+
// consumed by resolveBody reading fd 0; re-emitting `--body-file -` makes gh
|
|
196
|
+
// re-read an exhausted stdin and clear the body, so pass the resolved string
|
|
197
|
+
// inline via --body instead.
|
|
198
|
+
const body = await resolveBody(options);
|
|
199
|
+
if (body !== undefined) {
|
|
200
|
+
if (options.bodyFile !== undefined && options.bodyFile !== "-") {
|
|
201
|
+
args.push("--body-file", options.bodyFile);
|
|
202
|
+
} else {
|
|
203
|
+
args.push("--body", body);
|
|
204
|
+
}
|
|
205
|
+
edited.push("body");
|
|
206
|
+
}
|
|
207
|
+
for (const u of options.addAssignees) {
|
|
208
|
+
args.push("--add-assignee", u);
|
|
209
|
+
}
|
|
210
|
+
if (options.addAssignees.length > 0) edited.push("add-assignee");
|
|
211
|
+
for (const u of options.removeAssignees) {
|
|
212
|
+
args.push("--remove-assignee", u);
|
|
213
|
+
}
|
|
214
|
+
if (options.removeAssignees.length > 0) edited.push("remove-assignee");
|
|
215
|
+
if (options.milestone !== undefined) {
|
|
216
|
+
args.push("--milestone", options.milestone);
|
|
217
|
+
edited.push("milestone");
|
|
218
|
+
}
|
|
219
|
+
return { args, edited };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function editPr(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
|
|
223
|
+
const { args, edited } = await buildEditArgs(options);
|
|
224
|
+
const result = await run(ghCommand, args, env);
|
|
225
|
+
if (result.code !== 0) {
|
|
226
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
227
|
+
throw new Error(`gh pr edit failed: ${detail}`);
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, repo: options.repo, pr: options.pr, edited };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function runCli(
|
|
233
|
+
argv = process.argv.slice(2),
|
|
234
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
|
|
235
|
+
) {
|
|
236
|
+
let options;
|
|
237
|
+
try {
|
|
238
|
+
options = parseEditPrCliArgs(argv);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
if (options.help) {
|
|
244
|
+
stdout.write(`${USAGE}\n`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
let result;
|
|
248
|
+
try {
|
|
249
|
+
result = await editPr(options, { env, ghCommand, run });
|
|
250
|
+
} catch (error) {
|
|
251
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
258
|
+
runCli().then((code) => { process.exitCode = code; });
|
|
259
|
+
}
|
|
@@ -3,7 +3,7 @@ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "
|
|
|
3
3
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
4
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
7
7
|
|
|
8
8
|
const DEFAULT_TAIL_LINES = 200;
|
|
9
9
|
|
|
@@ -91,14 +91,7 @@ export function parseFetchCiLogsCliArgs(argv) {
|
|
|
91
91
|
options.tail = value;
|
|
92
92
|
continue;
|
|
93
93
|
}
|
|
94
|
-
if (token
|
|
95
|
-
options.jq = requireTokenValue(token, parseError);
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (token.name === "silent") {
|
|
99
|
-
options.silent = true;
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
94
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
102
95
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
103
96
|
}
|
|
104
97
|
if (options.repo === undefined || options.pr === undefined) {
|