dev-loops 0.7.1 → 0.8.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 +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +4 -4
- package/.claude/agents/review.md +10 -13
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +9 -2
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
- package/.claude/skills/dev-loop/SKILL.md +8 -8
- package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
- package/.claude/skills/docs/anti-patterns.md +6 -5
- package/.claude/skills/docs/artifact-authority-contract.md +17 -14
- package/.claude/skills/docs/confirmation-rules.md +2 -1
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +21 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +37 -22
- package/.claude/skills/docs/merge-preconditions.md +17 -13
- package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
- package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +49 -143
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +95 -189
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +4 -4
- package/agents/review.agent.md +10 -13
- package/extension/README.md +5 -4
- package/package.json +7 -5
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +109 -42
- package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
- package/scripts/github/verify-briefing-prefixes.mjs +382 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +220 -10
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +47 -16
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +3 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +65 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +42 -28
- package/skills/dev-loop/SKILL.md +3 -3
- package/skills/docs/acceptance-criteria-verification.md +13 -4
- package/skills/docs/anti-patterns.md +6 -5
- package/skills/docs/artifact-authority-contract.md +17 -14
- package/skills/docs/confirmation-rules.md +2 -1
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +21 -15
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +37 -22
- package/skills/docs/merge-preconditions.md +17 -13
- package/skills/docs/pr-lifecycle-contract.md +45 -36
- package/skills/docs/public-dev-loop-contract.md +61 -44
- package/skills/docs/required-rules.json +165 -0
- package/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +49 -143
- package/skills/loop-grill/SKILL.md +38 -17
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
|
@@ -1,7 +1,7 @@
|
|
|
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 { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
5
5
|
import { loadDevLoopConfig, resolveGateConfig } from "@dev-loops/core/config";
|
|
6
6
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
7
7
|
import { detectCheckpointEvidence } from "./detect-checkpoint-evidence.mjs";
|
|
@@ -105,7 +105,7 @@ const PR_ID_QUERY = [
|
|
|
105
105
|
" }",
|
|
106
106
|
"}",
|
|
107
107
|
].join("\n");
|
|
108
|
-
async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
|
|
108
|
+
async function resolvePrNodeId({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
109
109
|
const [owner, name] = repo.split("/");
|
|
110
110
|
const result = await runChild(ghCommand, [
|
|
111
111
|
"api", "graphql",
|
|
@@ -128,8 +128,8 @@ async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
|
|
|
128
128
|
}
|
|
129
129
|
return { id: prData.id, isDraft: prData.isDraft };
|
|
130
130
|
}
|
|
131
|
-
export async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
|
|
132
|
-
const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand });
|
|
131
|
+
export async function convertPrToDraft({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
132
|
+
const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand, runChild });
|
|
133
133
|
if (resolvedPr.isDraft === true) {
|
|
134
134
|
return {
|
|
135
135
|
...resolvedPr,
|
|
@@ -158,7 +158,7 @@ export async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
|
|
|
158
158
|
alreadyDraft: false,
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
|
-
export async function markPrReady({ repo, pr }, { env, ghCommand }) {
|
|
161
|
+
export async function markPrReady({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
162
162
|
const result = await runChild(ghCommand, [
|
|
163
163
|
"pr", "ready", String(pr),
|
|
164
164
|
"--repo", repo,
|
|
@@ -201,7 +201,7 @@ function summarizeBlockingChecks(blockingChecks) {
|
|
|
201
201
|
.map((check) => `${check.name || "unnamed-check"}=${check.bucket}`)
|
|
202
202
|
.join(", ");
|
|
203
203
|
}
|
|
204
|
-
async function checkCiStatus({ repo, pr, headSha }, { env, ghCommand }) {
|
|
204
|
+
async function checkCiStatus({ repo, pr, headSha }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
205
205
|
const result = await runChild(ghCommand, [
|
|
206
206
|
"pr", "checks", String(pr),
|
|
207
207
|
"--repo", repo,
|
|
@@ -244,12 +244,12 @@ async function checkCiStatus({ repo, pr, headSha }, { env, ghCommand }) {
|
|
|
244
244
|
: `Blocking CI/check state on head ${headSha.slice(0, 7)}: ${summarizeBlockingChecks(blockingChecks)}.`,
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
|
-
export async function reconcileDraftGate(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
|
|
247
|
+
export async function reconcileDraftGate(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd(), runChild = defaultRunChild } = {}) {
|
|
248
248
|
const { config } = await loadDevLoopConfig({ repoRoot });
|
|
249
249
|
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
250
250
|
const initialEvidence = await detectCheckpointEvidence(
|
|
251
251
|
{ repo: options.repo, pr: options.pr },
|
|
252
|
-
{ env, ghCommand }
|
|
252
|
+
{ env, ghCommand, runChild }
|
|
253
253
|
);
|
|
254
254
|
const headSha = initialEvidence.currentHeadSha;
|
|
255
255
|
if (!headSha) {
|
|
@@ -271,7 +271,7 @@ export async function reconcileDraftGate(options, { env = process.env, ghCommand
|
|
|
271
271
|
if (draftGateConfig.requireCi) {
|
|
272
272
|
const ciStatus = await checkCiStatus(
|
|
273
273
|
{ repo: options.repo, pr: options.pr, headSha },
|
|
274
|
-
{ env, ghCommand }
|
|
274
|
+
{ env, ghCommand, runChild }
|
|
275
275
|
);
|
|
276
276
|
if (ciStatus.status !== "success") {
|
|
277
277
|
throw new Error(
|
|
@@ -280,7 +280,7 @@ export async function reconcileDraftGate(options, { env = process.env, ghCommand
|
|
|
280
280
|
);
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
|
-
const draftConversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
283
|
+
const draftConversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
|
|
284
284
|
let gateResult;
|
|
285
285
|
try {
|
|
286
286
|
gateResult = await upsertCheckpointVerdict({
|
|
@@ -294,17 +294,17 @@ export async function reconcileDraftGate(options, { env = process.env, ghCommand
|
|
|
294
294
|
? "Reconciled non-draft PR — draft gate auto-reconciled (CI green)."
|
|
295
295
|
: "Reconciled non-draft PR — draft gate auto-reconciled (CI optional by config).",
|
|
296
296
|
nextAction: "Mark ready for review (auto-reconciled).",
|
|
297
|
-
}, { env, ghCommand, repoRoot });
|
|
297
|
+
}, { env, ghCommand, repoRoot, runChild });
|
|
298
298
|
} catch (error) {
|
|
299
299
|
if (draftConversion.alreadyDraft !== true) {
|
|
300
300
|
try {
|
|
301
|
-
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
301
|
+
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
|
|
302
302
|
} catch {
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
305
|
throw error;
|
|
306
306
|
}
|
|
307
|
-
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
307
|
+
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
|
|
308
308
|
return {
|
|
309
309
|
ok: true,
|
|
310
310
|
action: "reconciled",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import {
|
|
4
4
|
buildParseError,
|
|
5
|
+
containsBareCopilotSummon,
|
|
5
6
|
formatCliError,
|
|
6
7
|
isCopilotLogin,
|
|
7
8
|
isDirectCliRun,
|
|
@@ -10,11 +11,11 @@ import {
|
|
|
10
11
|
summarizeCopilotReviews,
|
|
11
12
|
summarizeGateReviewComments,
|
|
12
13
|
} from "../_core-helpers.mjs";
|
|
13
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
14
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
14
15
|
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
15
16
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
16
17
|
import { buildSnapshotFromPrFacts, interpretLoopState } from "@dev-loops/core/loop/copilot-loop-state";
|
|
17
|
-
import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
|
|
18
|
+
import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
|
|
18
19
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
19
20
|
const BLOCKED_BY_COPILOT_COMMENT_STATUS = "blocked_by_copilot_comment";
|
|
20
21
|
const SUPPRESSED_SAME_HEAD_CLEAN_STATUS = "suppressed_same_head_clean";
|
|
@@ -30,13 +31,19 @@ Optional:
|
|
|
30
31
|
--force-rerequest-review Bypass the round cap when new commits exist since
|
|
31
32
|
the last Copilot review. Refused when the PR head
|
|
32
33
|
has not changed since the last review.
|
|
34
|
+
--lightweight This PR is light-dispatched (#1210): enforce the
|
|
35
|
+
composed round cap min(localImplementation.lightMode.
|
|
36
|
+
maxCopilotRounds ?? 1, refinement.maxCopilotRounds)
|
|
37
|
+
instead of refinement.maxCopilotRounds alone.
|
|
33
38
|
Debug:
|
|
34
39
|
DEVLOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
|
|
35
40
|
convergence detection falls back to unsuppressed behavior
|
|
36
41
|
Output (stdout, JSON):
|
|
37
42
|
{ "ok": true, "status": "requested"|"already-requested"|"unavailable"|"suppressed_same_head_clean"|"blocked_by_copilot_comment"|"round_cap_reached"|"no_changes_since_last_review"|"suppressed_draft",
|
|
38
43
|
"repo": "...", "pr": N, "reviewer": "Copilot", "detail"?: "...",
|
|
39
|
-
"sameHeadCleanConverged"?: true, "violationCommentIds"?: [N], "completedRounds"?: N, "maxRounds"?: N
|
|
44
|
+
"sameHeadCleanConverged"?: true, "violationCommentIds"?: [N], "completedRounds"?: N, "maxRounds"?: N,
|
|
45
|
+
"configWarning"?: "..." (present only when --lightweight and dev-loop config failed to load/validate;
|
|
46
|
+
the lightweight default cap of 1 was applied instead of the full-PR default) }
|
|
40
47
|
Request statuses:
|
|
41
48
|
requested Copilot review was successfully requested
|
|
42
49
|
already-requested Copilot review was already observably in progress; no new request needed
|
|
@@ -52,9 +59,20 @@ Error output (stderr, JSON):
|
|
|
52
59
|
gh/runtime failures:
|
|
53
60
|
{ "ok": false, "error": "..." }
|
|
54
61
|
${JQ_OUTPUT_USAGE}
|
|
62
|
+
Status contract: "ok": true means the helper ran without error, NOT that a
|
|
63
|
+
review was placed. Callers MUST branch on "status", never on "ok" truthiness
|
|
64
|
+
alone — every non-"requested" status (including blocked_by_copilot_comment)
|
|
65
|
+
is a caller-must-branch outcome, not a silent success.
|
|
66
|
+
--silent exit code: 0 only when status is "requested" (a new request was just
|
|
67
|
+
placed this run); non-zero for every other status, including
|
|
68
|
+
already-requested/suppressed_same_head_clean/unavailable/blocked_by_copilot_comment/
|
|
69
|
+
round_cap_reached/no_changes_since_last_review/suppressed_draft. Without
|
|
70
|
+
--silent the JSON body always prints regardless of status. --jq combined with
|
|
71
|
+
--silent keeps the shared jq-stream truthiness semantics (exit reflects the
|
|
72
|
+
filtered value) and is exempt from the status-based rule above.
|
|
55
73
|
Exit codes:
|
|
56
|
-
0 Success (including unavailable)
|
|
57
|
-
1 Argument error or
|
|
74
|
+
0 Success (including unavailable); with --silent, only when status is "requested"
|
|
75
|
+
1 Argument error, gh failure, or (--silent) any non-"requested" status
|
|
58
76
|
2 Invalid --jq filter`.trim();
|
|
59
77
|
const parseError = buildParseError(USAGE);
|
|
60
78
|
export function parseRequestCliArgs(argv) {
|
|
@@ -63,6 +81,7 @@ export function parseRequestCliArgs(argv) {
|
|
|
63
81
|
options: {
|
|
64
82
|
help: { type: "boolean", short: "h" },
|
|
65
83
|
"force-rerequest-review": { type: "boolean" },
|
|
84
|
+
lightweight: { type: "boolean" },
|
|
66
85
|
repo: { type: "string" },
|
|
67
86
|
pr: { type: "string" },
|
|
68
87
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
@@ -76,6 +95,7 @@ export function parseRequestCliArgs(argv) {
|
|
|
76
95
|
repo: undefined,
|
|
77
96
|
pr: undefined,
|
|
78
97
|
forceRerequestReview: false,
|
|
98
|
+
lightweight: false,
|
|
79
99
|
};
|
|
80
100
|
for (const token of tokens) {
|
|
81
101
|
if (token.kind === "positional") {
|
|
@@ -92,6 +112,10 @@ export function parseRequestCliArgs(argv) {
|
|
|
92
112
|
options.forceRerequestReview = true;
|
|
93
113
|
continue;
|
|
94
114
|
}
|
|
115
|
+
if (token.name === "lightweight") {
|
|
116
|
+
options.lightweight = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
95
119
|
if (token.name === "repo") {
|
|
96
120
|
options.repo = requireTokenValue(token, parseError).trim();
|
|
97
121
|
continue;
|
|
@@ -152,7 +176,7 @@ function parseReviewsPayload(text, { draftGateResetAtMs = null } = {}) {
|
|
|
152
176
|
completedCopilotReviewRounds: reviewSummary.completedCopilotReviewRounds,
|
|
153
177
|
};
|
|
154
178
|
}
|
|
155
|
-
async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
179
|
+
async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
156
180
|
const result = await runChild(
|
|
157
181
|
ghCommand,
|
|
158
182
|
["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
|
|
@@ -164,7 +188,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
|
|
|
164
188
|
}
|
|
165
189
|
return parseRequestedReviewersPayload(result.stdout);
|
|
166
190
|
}
|
|
167
|
-
async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
191
|
+
async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
168
192
|
const result = await runChild(
|
|
169
193
|
ghCommand,
|
|
170
194
|
["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,isDraft,state,number,reviews,statusCheckRollup"],
|
|
@@ -189,7 +213,7 @@ async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghComman
|
|
|
189
213
|
// detector uses for the latest clean draft_gate marker), not the full checkpoint-
|
|
190
214
|
// evidence pipeline, to keep the added surface minimal. Best-effort: a fetch failure
|
|
191
215
|
// falls back to the raw count, so the cap is never silently disabled.
|
|
192
|
-
async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh" } = {}, before) {
|
|
216
|
+
async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}, before) {
|
|
193
217
|
try {
|
|
194
218
|
const currentHeadSha = typeof before?.prData?.headRefOid === "string" && before.prData.headRefOid.trim().length > 0
|
|
195
219
|
? before.prData.headRefOid.trim()
|
|
@@ -362,7 +386,7 @@ function classifyRequestFailure(detail) {
|
|
|
362
386
|
}
|
|
363
387
|
return undefined;
|
|
364
388
|
}
|
|
365
|
-
async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
389
|
+
async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
366
390
|
const result = await runChild(
|
|
367
391
|
ghCommand,
|
|
368
392
|
["pr", "edit", String(pr), "--repo", repo, "--add-reviewer", "@copilot"],
|
|
@@ -374,7 +398,7 @@ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand
|
|
|
374
398
|
if (classified === "unavailable") {
|
|
375
399
|
let existing;
|
|
376
400
|
try {
|
|
377
|
-
existing = await fetchCopilotReviewIds({ repo, pr }, { env, ghCommand });
|
|
401
|
+
existing = await fetchCopilotReviewIds({ repo, pr }, { env, ghCommand, runChild });
|
|
378
402
|
} catch {
|
|
379
403
|
// Best-effort: if gh pr view fails transiently (rate limit, network, auth),
|
|
380
404
|
// return unavailable rather than throwing — the 422 failure is already stable.
|
|
@@ -415,7 +439,7 @@ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand
|
|
|
415
439
|
reviewer: "Copilot",
|
|
416
440
|
};
|
|
417
441
|
}
|
|
418
|
-
export async function checkForCopilotComments({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
442
|
+
export async function checkForCopilotComments({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
419
443
|
const result = await runChild(
|
|
420
444
|
ghCommand,
|
|
421
445
|
["api", `repos/${repo}/issues/${pr}/comments`, "--paginate", "--jq", ".[]"],
|
|
@@ -442,7 +466,9 @@ export async function checkForCopilotComments({ repo, pr }, { env = process.env,
|
|
|
442
466
|
if (isCopilotLogin(author)) {
|
|
443
467
|
continue;
|
|
444
468
|
}
|
|
445
|
-
|
|
469
|
+
// Exempt bare-text occurrences inside inline code spans/fenced blocks: a
|
|
470
|
+
// gate-evidence comment legitimately quotes the anti-summon rule itself.
|
|
471
|
+
if (containsBareCopilotSummon(body)) {
|
|
446
472
|
violationCommentIds.push(comment.id);
|
|
447
473
|
}
|
|
448
474
|
}
|
|
@@ -451,8 +477,9 @@ export async function checkForCopilotComments({ repo, pr }, { env = process.env,
|
|
|
451
477
|
violationCommentIds,
|
|
452
478
|
};
|
|
453
479
|
}
|
|
454
|
-
export async function performCopilotReviewRequest(options, { env = process.env, ghCommand = "gh" } = {}) {
|
|
455
|
-
const
|
|
480
|
+
export async function performCopilotReviewRequest(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
481
|
+
const runtime = { env, ghCommand, runChild };
|
|
482
|
+
const before = await fetchCopilotReviewState(options, runtime);
|
|
456
483
|
if (before.prData?.isDraft) {
|
|
457
484
|
return {
|
|
458
485
|
ok: true,
|
|
@@ -464,7 +491,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
464
491
|
};
|
|
465
492
|
}
|
|
466
493
|
if (!env.GH_SEQUENCE_PATH) {
|
|
467
|
-
const copilotCommentCheck = await checkForCopilotComments(options,
|
|
494
|
+
const copilotCommentCheck = await checkForCopilotComments(options, runtime);
|
|
468
495
|
if (copilotCommentCheck.blocked) {
|
|
469
496
|
return {
|
|
470
497
|
ok: true,
|
|
@@ -479,16 +506,47 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
479
506
|
}
|
|
480
507
|
let refinementConfig = { maxCopilotRounds: 5 };
|
|
481
508
|
let maxRounds = 5; // Built-in default; overridden by config when loadable
|
|
509
|
+
// Lightweight fallback when config is unreadable/invalid: fail toward the
|
|
510
|
+
// SAFE (smaller) lightweight cap instead of silently inheriting the
|
|
511
|
+
// full-PR default of 5 above, which would let a light-dispatched PR run
|
|
512
|
+
// far more review rounds than intended whenever the config can't be read.
|
|
513
|
+
const LIGHTWEIGHT_DEFAULT_CAP = 1;
|
|
514
|
+
let configWarning = null;
|
|
482
515
|
try {
|
|
483
516
|
const { config, errors } = await loadDevLoopConfig();
|
|
484
517
|
if (!errors || errors.length === 0) {
|
|
485
518
|
refinementConfig = resolveRefinement(config);
|
|
486
|
-
|
|
487
|
-
|
|
519
|
+
// Light-dispatched PRs (#1210) enforce the COMPOSED cap —
|
|
520
|
+
// min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds) — so
|
|
521
|
+
// this enforcement backstop cannot permit rounds beyond the lightweight cap.
|
|
522
|
+
const effectiveCap = options.lightweight
|
|
523
|
+
? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
|
|
524
|
+
: refinementConfig.maxCopilotRounds;
|
|
525
|
+
// >= 0 (not > 0): maxCopilotRounds: 0 is documented as "disable Copilot
|
|
526
|
+
// rounds"; it must be honored as an immediate refusal, not silently
|
|
527
|
+
// ignored in favor of the built-in default of 5.
|
|
528
|
+
if (Number.isFinite(effectiveCap) && effectiveCap >= 0) {
|
|
529
|
+
maxRounds = effectiveCap;
|
|
488
530
|
}
|
|
531
|
+
if (options.lightweight) {
|
|
532
|
+
refinementConfig = { ...refinementConfig, maxCopilotRounds: effectiveCap };
|
|
533
|
+
}
|
|
534
|
+
} else if (options.lightweight) {
|
|
535
|
+
maxRounds = LIGHTWEIGHT_DEFAULT_CAP;
|
|
536
|
+
refinementConfig = { ...refinementConfig, maxCopilotRounds: LIGHTWEIGHT_DEFAULT_CAP };
|
|
537
|
+
configWarning = `dev-loop config could not be validated; using the lightweight default cap of ${LIGHTWEIGHT_DEFAULT_CAP} instead of the full-PR default. errors=${JSON.stringify(errors)}`;
|
|
538
|
+
}
|
|
539
|
+
} catch (err) {
|
|
540
|
+
if (options.lightweight) {
|
|
541
|
+
maxRounds = LIGHTWEIGHT_DEFAULT_CAP;
|
|
542
|
+
refinementConfig = { ...refinementConfig, maxCopilotRounds: LIGHTWEIGHT_DEFAULT_CAP };
|
|
543
|
+
configWarning = `dev-loop config could not be loaded; using the lightweight default cap of ${LIGHTWEIGHT_DEFAULT_CAP} instead of the full-PR default. error=${err instanceof Error ? err.message : String(err)}`;
|
|
489
544
|
}
|
|
490
|
-
} catch {
|
|
491
545
|
}
|
|
546
|
+
// Every remaining return in this function is config-dependent (round-cap
|
|
547
|
+
// decisions, the request itself); surface a config-load fallback on all of
|
|
548
|
+
// them rather than just the path a given test happens to exercise.
|
|
549
|
+
const withConfigWarning = (result) => (configWarning ? { ...result, configWarning } : result);
|
|
492
550
|
// Reconcile the completed-round count with detect-pr-gate-coordination-state (#896):
|
|
493
551
|
// when the raw count has reached the cap, re-derive it with the draft-gate round
|
|
494
552
|
// reset applied. A clean draft_gate re-pass on an earlier head resets the count, so
|
|
@@ -498,7 +556,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
498
556
|
if (completedRounds >= maxRounds
|
|
499
557
|
&& !before.requested
|
|
500
558
|
&& !before.hasPendingReviewOnCurrentHead) {
|
|
501
|
-
completedRounds = await resolveDraftGateAdjustedRounds(options,
|
|
559
|
+
completedRounds = await resolveDraftGateAdjustedRounds(options, runtime, before);
|
|
502
560
|
}
|
|
503
561
|
if (completedRounds >= maxRounds
|
|
504
562
|
&& !before.requested
|
|
@@ -506,12 +564,12 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
506
564
|
if (!options.forceRerequestReview) {
|
|
507
565
|
const roundCapAutoRerequest = await detectRoundCapAutoRerequestEligibility(
|
|
508
566
|
options,
|
|
509
|
-
|
|
567
|
+
runtime,
|
|
510
568
|
before,
|
|
511
569
|
refinementConfig,
|
|
512
570
|
);
|
|
513
571
|
if (!roundCapAutoRerequest.eligible) {
|
|
514
|
-
return {
|
|
572
|
+
return withConfigWarning({
|
|
515
573
|
ok: true,
|
|
516
574
|
status: ROUND_CAP_REACHED_STATUS,
|
|
517
575
|
repo: options.repo,
|
|
@@ -520,7 +578,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
520
578
|
completedRounds,
|
|
521
579
|
maxRounds,
|
|
522
580
|
detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. No further re-requests will be made.`,
|
|
523
|
-
};
|
|
581
|
+
});
|
|
524
582
|
}
|
|
525
583
|
}
|
|
526
584
|
// --force-rerequest-review: only bypass when there are new commits since the last review
|
|
@@ -531,7 +589,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
531
589
|
const canCompare = currentHeadSha !== null && lastReviewSha !== null;
|
|
532
590
|
const hasNewCommits = canCompare && currentHeadSha !== lastReviewSha;
|
|
533
591
|
if (!canCompare) {
|
|
534
|
-
return {
|
|
592
|
+
return withConfigWarning({
|
|
535
593
|
ok: true,
|
|
536
594
|
status: ROUND_CAP_REACHED_STATUS,
|
|
537
595
|
repo: options.repo,
|
|
@@ -540,10 +598,10 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
540
598
|
detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. --force-rerequest-review was supplied but commit SHA data is unavailable, so change-since-last-review could not be evaluated.`,
|
|
541
599
|
completedRounds,
|
|
542
600
|
maxRounds,
|
|
543
|
-
};
|
|
601
|
+
});
|
|
544
602
|
}
|
|
545
603
|
if (!hasNewCommits) {
|
|
546
|
-
return {
|
|
604
|
+
return withConfigWarning({
|
|
547
605
|
ok: true,
|
|
548
606
|
status: NO_CHANGES_SINCE_LAST_REVIEW_STATUS,
|
|
549
607
|
repo: options.repo,
|
|
@@ -552,17 +610,17 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
552
610
|
detail: "No changes since last Copilot review. --force-rerequest-review requires new commits on the PR head.",
|
|
553
611
|
completedRounds,
|
|
554
612
|
maxRounds,
|
|
555
|
-
};
|
|
613
|
+
});
|
|
556
614
|
}
|
|
557
615
|
// Has new commits — bypass the round cap and proceed with the request
|
|
558
616
|
}
|
|
559
617
|
const sameHeadCleanConverged = await detectSameHeadCleanConvergence(
|
|
560
618
|
options,
|
|
561
|
-
|
|
619
|
+
runtime,
|
|
562
620
|
before,
|
|
563
621
|
);
|
|
564
622
|
if (sameHeadCleanConverged) {
|
|
565
|
-
return {
|
|
623
|
+
return withConfigWarning({
|
|
566
624
|
ok: true,
|
|
567
625
|
status: SUPPRESSED_SAME_HEAD_CLEAN_STATUS,
|
|
568
626
|
repo: options.repo,
|
|
@@ -570,45 +628,45 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
570
628
|
reviewer: "Copilot",
|
|
571
629
|
sameHeadCleanConverged: true,
|
|
572
630
|
detail: "Current head already has a clean submitted Copilot review; same-head clean-convergence suppression is always enforced.",
|
|
573
|
-
};
|
|
631
|
+
});
|
|
574
632
|
}
|
|
575
633
|
if (before.requested || before.hasPendingReviewOnCurrentHead) {
|
|
576
|
-
return {
|
|
634
|
+
return withConfigWarning({
|
|
577
635
|
ok: true,
|
|
578
636
|
status: "already-requested",
|
|
579
637
|
repo: options.repo,
|
|
580
638
|
pr: options.pr,
|
|
581
639
|
reviewer: "Copilot",
|
|
582
|
-
};
|
|
640
|
+
});
|
|
583
641
|
}
|
|
584
|
-
const requestResult = await requestCopilotReview(options,
|
|
642
|
+
const requestResult = await requestCopilotReview(options, runtime);
|
|
585
643
|
if (requestResult.status === "unavailable") {
|
|
586
|
-
const after = await fetchCopilotReviewState(options,
|
|
644
|
+
const after = await fetchCopilotReviewState(options, runtime);
|
|
587
645
|
if (after.requested || after.hasPendingReviewOnCurrentHead || after.hasSubmittedReviewOnCurrentHead) {
|
|
588
|
-
return {
|
|
646
|
+
return withConfigWarning({
|
|
589
647
|
ok: true,
|
|
590
648
|
status: "already-requested",
|
|
591
649
|
repo: options.repo,
|
|
592
650
|
pr: options.pr,
|
|
593
651
|
reviewer: "Copilot",
|
|
594
|
-
};
|
|
652
|
+
});
|
|
595
653
|
}
|
|
596
|
-
return {
|
|
654
|
+
return withConfigWarning({
|
|
597
655
|
...requestResult,
|
|
598
|
-
};
|
|
656
|
+
});
|
|
599
657
|
}
|
|
600
658
|
if (requestResult.status === "already-requested") {
|
|
601
|
-
return requestResult;
|
|
659
|
+
return withConfigWarning(requestResult);
|
|
602
660
|
}
|
|
603
|
-
const after = await fetchCopilotReviewState(options,
|
|
661
|
+
const after = await fetchCopilotReviewState(options, runtime);
|
|
604
662
|
const reviewCountIncreased = after.copilotReviewIds.length > before.copilotReviewIds.length;
|
|
605
663
|
const reviewNowObservablyInProgress = after.requested || after.hasPendingReviewOnCurrentHead || reviewCountIncreased;
|
|
606
664
|
if (!reviewNowObservablyInProgress) {
|
|
607
665
|
throw new Error("Copilot review request did not appear in requested reviewers or fresh/in-progress Copilot reviews after gh pr edit");
|
|
608
666
|
}
|
|
609
|
-
return {
|
|
667
|
+
return withConfigWarning({
|
|
610
668
|
...requestResult,
|
|
611
|
-
};
|
|
669
|
+
});
|
|
612
670
|
}
|
|
613
671
|
export async function runCli(
|
|
614
672
|
argv = process.argv.slice(2),
|
|
@@ -625,7 +683,16 @@ export async function runCli(
|
|
|
625
683
|
return;
|
|
626
684
|
}
|
|
627
685
|
const result = await performCopilotReviewRequest(options, { env, ghCommand });
|
|
628
|
-
|
|
686
|
+
// Honest status under --silent: `ok: true` reports "the helper ran without
|
|
687
|
+
// error", not "a review was placed" — a caller checking only exit-code
|
|
688
|
+
// truthiness must NOT read a non-`requested` status (blocked_by_copilot_comment,
|
|
689
|
+
// round_cap_reached, etc.) as a placed request. --silent therefore answers
|
|
690
|
+
// "was a request just placed" specifically: exit 0 only for `requested`,
|
|
691
|
+
// non-zero for every other status. Non-silent output is unaffected — the full
|
|
692
|
+
// JSON body (with `ok: true`) still prints for every documented status; the
|
|
693
|
+
// caller MUST branch on `.status`, not `.ok`.
|
|
694
|
+
const silentOk = options.silent ? result.status === "requested" : undefined;
|
|
695
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: silentOk });
|
|
629
696
|
}
|
|
630
697
|
if (isDirectCliRun(import.meta.url)) {
|
|
631
698
|
runCli().catch((error) => {
|