dev-loops 0.7.2 → 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 +1 -1
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +9 -12
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +8 -1
- package/.claude/commands/loop-info.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 +5 -5
- package/.claude/skills/dev-loop/SKILL.md +7 -7
- package/.claude/skills/docs/acceptance-criteria-verification.md +12 -4
- package/.claude/skills/docs/anti-patterns.md +3 -3
- package/.claude/skills/docs/artifact-authority-contract.md +6 -4
- package/.claude/skills/docs/confirmation-rules.md +1 -1
- package/.claude/skills/docs/copilot-loop-operations.md +4 -2
- package/.claude/skills/docs/issue-intake-procedure.md +12 -3
- package/.claude/skills/docs/merge-preconditions.md +5 -3
- package/.claude/skills/docs/pr-lifecycle-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +2 -1
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +9 -6
- package/.claude/skills/docs/validation-policy.md +1 -1
- package/.claude/skills/local-implementation/SKILL.md +3 -3
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +95 -189
- package/agents/refiner.agent.md +2 -2
- package/agents/review.agent.md +9 -12
- package/extension/README.md +5 -4
- package/package.json +5 -4
- package/scripts/docs/validate-state-machine-conformance.mjs +78 -1
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/detect-checkpoint-evidence.mjs +7 -7
- package/scripts/github/edit-issue.mjs +259 -0
- 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 +17 -16
- package/scripts/github/upsert-checkpoint-verdict.mjs +25 -23
- package/scripts/github/verify-briefing-prefixes.mjs +224 -33
- package/scripts/github/write-gate-context.mjs +8 -4
- 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 +18 -14
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -11
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +117 -15
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +1 -0
- package/scripts/pages/build-state-atlas.mjs +15 -0
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/skills/copilot-pr-followup/SKILL.md +5 -5
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/docs/acceptance-criteria-verification.md +12 -4
- package/skills/docs/anti-patterns.md +3 -3
- package/skills/docs/artifact-authority-contract.md +6 -4
- package/skills/docs/confirmation-rules.md +1 -1
- package/skills/docs/copilot-loop-operations.md +4 -2
- package/skills/docs/issue-intake-procedure.md +12 -3
- package/skills/docs/merge-preconditions.md +5 -3
- package/skills/docs/pr-lifecycle-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +2 -1
- package/skills/docs/required-rules.json +17 -1
- package/skills/docs/retrospective-checkpoint-contract.md +9 -6
- package/skills/docs/validation-policy.md +1 -1
- package/skills/local-implementation/SKILL.md +3 -3
- package/skills/loop-grill/SKILL.md +38 -17
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
DEFAULT_POLL_INTERVAL_MS,
|
|
10
10
|
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
11
11
|
} from "@dev-loops/core/loop/policy-constants";
|
|
12
|
+
import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
|
|
13
|
+
import { resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
|
|
12
14
|
|
|
13
15
|
/** Maximum interval between heartbeat outputs during watch delays.
|
|
14
16
|
* Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
|
|
@@ -275,14 +277,18 @@ export async function watchCopilotReview(
|
|
|
275
277
|
{
|
|
276
278
|
env = process.env,
|
|
277
279
|
ghCommand = "gh",
|
|
280
|
+
delayImpl = delay,
|
|
281
|
+
now = Date.now,
|
|
282
|
+
ensureOwnershipImpl = ensureAsyncRunnerOwnership,
|
|
278
283
|
} = {},
|
|
279
284
|
) {
|
|
285
|
+
const leaseCwd = resolveRepoRoot(process.cwd());
|
|
280
286
|
const baseline = parseCopilotActivity(await fetchGithubCopilotActivityPayload(
|
|
281
287
|
{ repo: options.repo, pr: options.pr },
|
|
282
288
|
{ env, ghCommand },
|
|
283
289
|
));
|
|
284
290
|
const attemptBudget = buildAttemptBudget(options.timeoutMs, options.pollIntervalMs);
|
|
285
|
-
const watchStartedAtMs =
|
|
291
|
+
const watchStartedAtMs = now();
|
|
286
292
|
for (let attempt = 1; attempt <= attemptBudget; attempt += 1) {
|
|
287
293
|
if (!(options.timeoutMs === 0 && attempt === 1)) {
|
|
288
294
|
const pollDelayMs = buildPollDelayMs(
|
|
@@ -290,15 +296,16 @@ export async function watchCopilotReview(
|
|
|
290
296
|
options.timeoutMs,
|
|
291
297
|
options.pollIntervalMs,
|
|
292
298
|
attempt,
|
|
299
|
+
now(),
|
|
293
300
|
);
|
|
294
301
|
if (pollDelayMs > 0) {
|
|
295
302
|
let remainingMs = pollDelayMs;
|
|
296
303
|
while (remainingMs > 0) {
|
|
297
304
|
const chunkMs = Math.min(WATCH_HEARTBEAT_MS, remainingMs);
|
|
298
|
-
await
|
|
305
|
+
await delayImpl(chunkMs);
|
|
299
306
|
remainingMs -= chunkMs;
|
|
300
307
|
if (remainingMs > 0) {
|
|
301
|
-
const nowMs =
|
|
308
|
+
const nowMs = now();
|
|
302
309
|
process.stderr.write(
|
|
303
310
|
JSON.stringify({
|
|
304
311
|
ok: true,
|
|
@@ -309,6 +316,20 @@ export async function watchCopilotReview(
|
|
|
309
316
|
maxPolls: attemptBudget,
|
|
310
317
|
}) + "\n",
|
|
311
318
|
);
|
|
319
|
+
// The blocking review wait can span the full watch budget, which
|
|
320
|
+
// equals the runner-coordination stale window; refresh the lease
|
|
321
|
+
// alongside each heartbeat so the claim stays fresh for every caller
|
|
322
|
+
// of this engine. No-ops without DEVLOOPS_RUN_ID; best-effort.
|
|
323
|
+
try {
|
|
324
|
+
await ensureOwnershipImpl({
|
|
325
|
+
repo: options.repo,
|
|
326
|
+
pr: options.pr,
|
|
327
|
+
env,
|
|
328
|
+
cwd: leaseCwd,
|
|
329
|
+
claimIfMissing: true,
|
|
330
|
+
requireExisting: false,
|
|
331
|
+
});
|
|
332
|
+
} catch { /* best-effort: never affect the watch */ }
|
|
312
333
|
}
|
|
313
334
|
}
|
|
314
335
|
}
|
|
@@ -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",
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
summarizeCopilotReviews,
|
|
12
12
|
summarizeGateReviewComments,
|
|
13
13
|
} from "../_core-helpers.mjs";
|
|
14
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
14
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } 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
17
|
import { buildSnapshotFromPrFacts, interpretLoopState } from "@dev-loops/core/loop/copilot-loop-state";
|
|
@@ -176,7 +176,7 @@ function parseReviewsPayload(text, { draftGateResetAtMs = null } = {}) {
|
|
|
176
176
|
completedCopilotReviewRounds: reviewSummary.completedCopilotReviewRounds,
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
|
-
async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
179
|
+
async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
180
180
|
const result = await runChild(
|
|
181
181
|
ghCommand,
|
|
182
182
|
["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
|
|
@@ -188,7 +188,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
|
|
|
188
188
|
}
|
|
189
189
|
return parseRequestedReviewersPayload(result.stdout);
|
|
190
190
|
}
|
|
191
|
-
async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
191
|
+
async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
192
192
|
const result = await runChild(
|
|
193
193
|
ghCommand,
|
|
194
194
|
["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,isDraft,state,number,reviews,statusCheckRollup"],
|
|
@@ -213,7 +213,7 @@ async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghComman
|
|
|
213
213
|
// detector uses for the latest clean draft_gate marker), not the full checkpoint-
|
|
214
214
|
// evidence pipeline, to keep the added surface minimal. Best-effort: a fetch failure
|
|
215
215
|
// falls back to the raw count, so the cap is never silently disabled.
|
|
216
|
-
async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh" } = {}, before) {
|
|
216
|
+
async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}, before) {
|
|
217
217
|
try {
|
|
218
218
|
const currentHeadSha = typeof before?.prData?.headRefOid === "string" && before.prData.headRefOid.trim().length > 0
|
|
219
219
|
? before.prData.headRefOid.trim()
|
|
@@ -386,7 +386,7 @@ function classifyRequestFailure(detail) {
|
|
|
386
386
|
}
|
|
387
387
|
return undefined;
|
|
388
388
|
}
|
|
389
|
-
async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
389
|
+
async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
390
390
|
const result = await runChild(
|
|
391
391
|
ghCommand,
|
|
392
392
|
["pr", "edit", String(pr), "--repo", repo, "--add-reviewer", "@copilot"],
|
|
@@ -398,7 +398,7 @@ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand
|
|
|
398
398
|
if (classified === "unavailable") {
|
|
399
399
|
let existing;
|
|
400
400
|
try {
|
|
401
|
-
existing = await fetchCopilotReviewIds({ repo, pr }, { env, ghCommand });
|
|
401
|
+
existing = await fetchCopilotReviewIds({ repo, pr }, { env, ghCommand, runChild });
|
|
402
402
|
} catch {
|
|
403
403
|
// Best-effort: if gh pr view fails transiently (rate limit, network, auth),
|
|
404
404
|
// return unavailable rather than throwing — the 422 failure is already stable.
|
|
@@ -439,7 +439,7 @@ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand
|
|
|
439
439
|
reviewer: "Copilot",
|
|
440
440
|
};
|
|
441
441
|
}
|
|
442
|
-
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 } = {}) {
|
|
443
443
|
const result = await runChild(
|
|
444
444
|
ghCommand,
|
|
445
445
|
["api", `repos/${repo}/issues/${pr}/comments`, "--paginate", "--jq", ".[]"],
|
|
@@ -477,8 +477,9 @@ export async function checkForCopilotComments({ repo, pr }, { env = process.env,
|
|
|
477
477
|
violationCommentIds,
|
|
478
478
|
};
|
|
479
479
|
}
|
|
480
|
-
export async function performCopilotReviewRequest(options, { env = process.env, ghCommand = "gh" } = {}) {
|
|
481
|
-
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);
|
|
482
483
|
if (before.prData?.isDraft) {
|
|
483
484
|
return {
|
|
484
485
|
ok: true,
|
|
@@ -490,7 +491,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
490
491
|
};
|
|
491
492
|
}
|
|
492
493
|
if (!env.GH_SEQUENCE_PATH) {
|
|
493
|
-
const copilotCommentCheck = await checkForCopilotComments(options,
|
|
494
|
+
const copilotCommentCheck = await checkForCopilotComments(options, runtime);
|
|
494
495
|
if (copilotCommentCheck.blocked) {
|
|
495
496
|
return {
|
|
496
497
|
ok: true,
|
|
@@ -555,7 +556,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
555
556
|
if (completedRounds >= maxRounds
|
|
556
557
|
&& !before.requested
|
|
557
558
|
&& !before.hasPendingReviewOnCurrentHead) {
|
|
558
|
-
completedRounds = await resolveDraftGateAdjustedRounds(options,
|
|
559
|
+
completedRounds = await resolveDraftGateAdjustedRounds(options, runtime, before);
|
|
559
560
|
}
|
|
560
561
|
if (completedRounds >= maxRounds
|
|
561
562
|
&& !before.requested
|
|
@@ -563,7 +564,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
563
564
|
if (!options.forceRerequestReview) {
|
|
564
565
|
const roundCapAutoRerequest = await detectRoundCapAutoRerequestEligibility(
|
|
565
566
|
options,
|
|
566
|
-
|
|
567
|
+
runtime,
|
|
567
568
|
before,
|
|
568
569
|
refinementConfig,
|
|
569
570
|
);
|
|
@@ -615,7 +616,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
615
616
|
}
|
|
616
617
|
const sameHeadCleanConverged = await detectSameHeadCleanConvergence(
|
|
617
618
|
options,
|
|
618
|
-
|
|
619
|
+
runtime,
|
|
619
620
|
before,
|
|
620
621
|
);
|
|
621
622
|
if (sameHeadCleanConverged) {
|
|
@@ -638,9 +639,9 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
638
639
|
reviewer: "Copilot",
|
|
639
640
|
});
|
|
640
641
|
}
|
|
641
|
-
const requestResult = await requestCopilotReview(options,
|
|
642
|
+
const requestResult = await requestCopilotReview(options, runtime);
|
|
642
643
|
if (requestResult.status === "unavailable") {
|
|
643
|
-
const after = await fetchCopilotReviewState(options,
|
|
644
|
+
const after = await fetchCopilotReviewState(options, runtime);
|
|
644
645
|
if (after.requested || after.hasPendingReviewOnCurrentHead || after.hasSubmittedReviewOnCurrentHead) {
|
|
645
646
|
return withConfigWarning({
|
|
646
647
|
ok: true,
|
|
@@ -657,7 +658,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
657
658
|
if (requestResult.status === "already-requested") {
|
|
658
659
|
return withConfigWarning(requestResult);
|
|
659
660
|
}
|
|
660
|
-
const after = await fetchCopilotReviewState(options,
|
|
661
|
+
const after = await fetchCopilotReviewState(options, runtime);
|
|
661
662
|
const reviewCountIncreased = after.copilotReviewIds.length > before.copilotReviewIds.length;
|
|
662
663
|
const reviewNowObservablyInProgress = after.requested || after.hasPendingReviewOnCurrentHead || reviewCountIncreased;
|
|
663
664
|
if (!reviewNowObservablyInProgress) {
|
|
@@ -5,7 +5,7 @@ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveGateAngleCon
|
|
|
5
5
|
import { checkFanoutAngleCoverage } from "@dev-loops/core/loop/gate-fanin";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
8
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
8
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
9
9
|
import { truncateText } from "@dev-loops/core/bash-exit-one";
|
|
10
10
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
11
11
|
import { loadPrGateCoordinationContext } from "../loop/detect-pr-gate-coordination-state.mjs";
|
|
@@ -839,7 +839,7 @@ function detectStaleGateCommentWarning({ strict, headSha, gate }) {
|
|
|
839
839
|
}
|
|
840
840
|
return `A gate comment for \`${gate}\` already exists on a different head SHA \`${strict.headSha}\` (comment ${strict.commentId}). The old comment is stale for the current head.`;
|
|
841
841
|
}
|
|
842
|
-
async function runGhJson(args, { env, ghCommand }) {
|
|
842
|
+
async function runGhJson(args, { env, ghCommand, runChild = defaultRunChild }) {
|
|
843
843
|
const result = await runChild(ghCommand, args, env);
|
|
844
844
|
if (result.code !== 0) {
|
|
845
845
|
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
@@ -857,18 +857,18 @@ function parseCommentMutationResponse(payload) {
|
|
|
857
857
|
}
|
|
858
858
|
return { commentId, commentUrl };
|
|
859
859
|
}
|
|
860
|
-
async function createComment({ repo, pr, body }, { env, ghCommand }) {
|
|
861
|
-
const payload = await runGhJson(["api", "repos/" + repo + "/issues/" + pr + "/comments", "-f", `body=${body}`], { env, ghCommand });
|
|
860
|
+
async function createComment({ repo, pr, body }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
861
|
+
const payload = await runGhJson(["api", "repos/" + repo + "/issues/" + pr + "/comments", "-f", `body=${body}`], { env, ghCommand, runChild });
|
|
862
862
|
return parseCommentMutationResponse(payload);
|
|
863
863
|
}
|
|
864
|
-
async function updateComment({ repo, commentId, body }, { env, ghCommand }) {
|
|
865
|
-
const payload = await runGhJson(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`], { env, ghCommand });
|
|
864
|
+
async function updateComment({ repo, commentId, body }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
865
|
+
const payload = await runGhJson(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`], { env, ghCommand, runChild });
|
|
866
866
|
return parseCommentMutationResponse(payload);
|
|
867
867
|
}
|
|
868
868
|
|
|
869
|
-
async function verifyComment({ repo, commentId }, { env, ghCommand }) {
|
|
869
|
+
async function verifyComment({ repo, commentId }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
870
870
|
try {
|
|
871
|
-
const payload = await runGhJson(["api", `repos/${repo}/issues/comments/${commentId}`], { env, ghCommand });
|
|
871
|
+
const payload = await runGhJson(["api", `repos/${repo}/issues/comments/${commentId}`], { env, ghCommand, runChild });
|
|
872
872
|
return payload?.id != null;
|
|
873
873
|
} catch {
|
|
874
874
|
return false;
|
|
@@ -895,13 +895,13 @@ async function verifyComment({ repo, commentId }, { env, ghCommand }) {
|
|
|
895
895
|
// markPrReady mutations are individually idempotent, so concurrent cooperating
|
|
896
896
|
// runners cause at most a transient draft flicker (not a stuck draft) — only a hard
|
|
897
897
|
// crash mid-transition can leave the PR drafted until a subsequent run.
|
|
898
|
-
async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot }) {
|
|
898
|
+
async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot, runChild = defaultRunChild }) {
|
|
899
899
|
const { convertPrToDraft, markPrReady } = await import("./reconcile-draft-gate.mjs");
|
|
900
900
|
process.stderr.write(
|
|
901
901
|
`[draft_gate] ${options.repo}#${options.pr} is ready but needs clean draft_gate evidence; ` +
|
|
902
902
|
`temporarily converting to draft to post the verdict, then restoring ready.\n`,
|
|
903
903
|
);
|
|
904
|
-
const conversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
904
|
+
const conversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
|
|
905
905
|
let result;
|
|
906
906
|
try {
|
|
907
907
|
// The PR is now a draft, so RUN_DRAFT_GATE is the legal action. Re-enter with
|
|
@@ -912,12 +912,12 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
|
|
|
912
912
|
// with a clear error instead of recursing indefinitely (exit 13). (#1020)
|
|
913
913
|
result = await upsertCheckpointVerdict(
|
|
914
914
|
{ ...options, _draftTransitionInProgress: true },
|
|
915
|
-
{ env, ghCommand, repoRoot },
|
|
915
|
+
{ env, ghCommand, repoRoot, runChild },
|
|
916
916
|
);
|
|
917
917
|
} catch (error) {
|
|
918
918
|
if (conversion.alreadyDraft !== true) {
|
|
919
919
|
try {
|
|
920
|
-
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
920
|
+
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
|
|
921
921
|
process.stderr.write(`[draft_gate] restored ${options.repo}#${options.pr} to ready after a failed verdict post.\n`);
|
|
922
922
|
} catch (restoreError) {
|
|
923
923
|
// Best-effort restore; surface the original error but log the restore failure
|
|
@@ -932,7 +932,7 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
|
|
|
932
932
|
}
|
|
933
933
|
if (conversion.alreadyDraft !== true) {
|
|
934
934
|
try {
|
|
935
|
-
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
935
|
+
await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
|
|
936
936
|
} catch (restoreError) {
|
|
937
937
|
// The verdict WAS posted successfully; only the ready-restore failed. Make that
|
|
938
938
|
// explicit so the caller does not re-post the gate (the comment already exists)
|
|
@@ -949,7 +949,8 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
|
|
|
949
949
|
return { ...result, draftTransition: true };
|
|
950
950
|
}
|
|
951
951
|
|
|
952
|
-
export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
|
|
952
|
+
export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd(), runChild = defaultRunChild } = {}) {
|
|
953
|
+
const gh = { env, ghCommand, repoRoot, runChild };
|
|
953
954
|
// Root cause 1: allow resurrected sessions to claim ownership when the previous
|
|
954
955
|
// run's coordination record is stale. Without this, a new run ID is rejected even
|
|
955
956
|
// though the old run is dead, forcing manual file deletion.
|
|
@@ -968,7 +969,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
968
969
|
// Thread the light-dispatch signal (#1210) so the context interpreter and the
|
|
969
970
|
// maxCopilotRounds resolution below both use the composed lightweight cap —
|
|
970
971
|
// the two must never disagree at the cap boundary (#1126).
|
|
971
|
-
const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr, lightweight: options.lightweight === true },
|
|
972
|
+
const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr, lightweight: options.lightweight === true }, gh);
|
|
972
973
|
const evidence = coordinationContext.gateEvidence;
|
|
973
974
|
const canonicalHeadSha = resolveRequestedHeadSha(options.headSha, evidence.currentHeadSha);
|
|
974
975
|
const { config } = await loadDevLoopConfig({ repoRoot });
|
|
@@ -982,7 +983,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
982
983
|
// without requiring an external Copilot review cycle.
|
|
983
984
|
let reviewMode = null;
|
|
984
985
|
try {
|
|
985
|
-
const internalResult = await detectInternalOnly({ repo: options.repo, pr: options.pr },
|
|
986
|
+
const internalResult = await detectInternalOnly({ repo: options.repo, pr: options.pr }, gh);
|
|
986
987
|
if (internalResult?.ok && internalResult.internalOnly) {
|
|
987
988
|
reviewMode = "internal_only";
|
|
988
989
|
}
|
|
@@ -1010,6 +1011,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1010
1011
|
draftGateRequireCi: draftGateConfig.requireCi,
|
|
1011
1012
|
draftGate: coordinationContext.gateEvidence.draftGate,
|
|
1012
1013
|
draftGateMarker: coordinationContext.gateEvidence.draftGateMarker,
|
|
1014
|
+
refinementArtifact: coordinationContext.refinementArtifact,
|
|
1013
1015
|
preApprovalGate: coordinationContext.gateEvidence.preApprovalGate,
|
|
1014
1016
|
preApprovalGateMarker: coordinationContext.gateEvidence.preApprovalGateMarker,
|
|
1015
1017
|
...(reviewMode ? { reviewMode } : {}),
|
|
@@ -1089,7 +1091,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1089
1091
|
&& !coordination.draftGateAlreadySatisfied
|
|
1090
1092
|
&& coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)
|
|
1091
1093
|
) {
|
|
1092
|
-
return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot });
|
|
1094
|
+
return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot, runChild });
|
|
1093
1095
|
}
|
|
1094
1096
|
// Fail closed on a lagged draft-state read: we are re-entering FROM
|
|
1095
1097
|
// postDraftGateViaDraftTransition (which just converted the PR to draft) yet the
|
|
@@ -1296,15 +1298,15 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1296
1298
|
};
|
|
1297
1299
|
}
|
|
1298
1300
|
if (existing) {
|
|
1299
|
-
const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody },
|
|
1301
|
+
const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, gh);
|
|
1300
1302
|
// Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
|
|
1301
1303
|
// A run id is set (production context) — DEVLOOPS_RUN_ID.
|
|
1302
1304
|
let updateVerificationWarning = null;
|
|
1303
1305
|
if (envRunId) {
|
|
1304
|
-
let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId },
|
|
1306
|
+
let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, gh);
|
|
1305
1307
|
if (!verified) {
|
|
1306
1308
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
1307
|
-
verified = await verifyComment({ repo: options.repo, commentId: updated.commentId },
|
|
1309
|
+
verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, gh);
|
|
1308
1310
|
}
|
|
1309
1311
|
updateVerificationWarning = !verified
|
|
1310
1312
|
? `Post-update verification failed: comment ${updated.commentId} not retrievable after retry.`
|
|
@@ -1327,7 +1329,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1327
1329
|
...(updateVerificationWarning ? { verificationWarning: updateVerificationWarning } : {}),
|
|
1328
1330
|
};
|
|
1329
1331
|
}
|
|
1330
|
-
const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody },
|
|
1332
|
+
const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, gh);
|
|
1331
1333
|
// Post-creation verification: verify the comment is retrievable before returning.
|
|
1332
1334
|
// GitHub API can have brief eventual-consistency windows where a just-posted
|
|
1333
1335
|
// comment is not yet returned by paginated list endpoints. A direct fetch
|
|
@@ -1337,11 +1339,11 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
|
|
|
1337
1339
|
let verified = true;
|
|
1338
1340
|
let verificationWarning = null;
|
|
1339
1341
|
if (envRunId) {
|
|
1340
|
-
verified = await verifyComment({ repo: options.repo, commentId: created.commentId },
|
|
1342
|
+
verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, gh);
|
|
1341
1343
|
if (!verified) {
|
|
1342
1344
|
// Brief wait then retry — eventual consistency should resolve within ~2s.
|
|
1343
1345
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
1344
|
-
verified = await verifyComment({ repo: options.repo, commentId: created.commentId },
|
|
1346
|
+
verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, gh);
|
|
1345
1347
|
}
|
|
1346
1348
|
verificationWarning = !verified
|
|
1347
1349
|
? `Post-creation verification failed: comment ${created.commentId} not retrievable after retry. The comment was created (API confirmed) but may not appear in list endpoints immediately.`
|