dev-loops 0.2.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +3 -3
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +25 -5
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +8 -2
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +60 -8
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +8 -7
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +72 -11
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
- package/scripts/loop/run-queue.mjs +112 -16
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +80 -48
- package/scripts/projects/archive-done-items.mjs +136 -39
- package/scripts/projects/ensure-queue-board.mjs +67 -65
- package/scripts/projects/list-queue-items.mjs +59 -57
- package/scripts/projects/move-queue-item.mjs +125 -125
- package/scripts/projects/reorder-queue-item.mjs +67 -48
- package/scripts/projects/sync-item-status.mjs +199 -0
- package/skills/copilot-pr-followup/SKILL.md +62 -6
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +3 -3
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +25 -5
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
6
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
7
|
+
import {
|
|
8
|
+
summarizeHeadScopedCheckRunsSignal,
|
|
9
|
+
normalizeHeadScopedCommitStatus,
|
|
10
|
+
normalizeHeadScopedCiContract,
|
|
11
|
+
} from "@dev-loops/core/loop/copilot-ci-status";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
14
|
+
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
15
|
+
} from "@dev-loops/core/loop/policy-constants";
|
|
16
|
+
|
|
17
|
+
/** Maximum interval between heartbeat outputs during watch delays.
|
|
18
|
+
* Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
|
|
19
|
+
const WATCH_HEARTBEAT_MS = 45_000; // 45 seconds
|
|
20
|
+
const USAGE = `Usage: probe-ci-status.mjs --repo <owner/name> --pr <number> [--timeout-ms <n>] [--poll-interval-ms <n>]
|
|
21
|
+
Block-wait on a PR's combined CI check/status state (GitHub Actions + CircleCI +
|
|
22
|
+
any external commit-status / check-run) for the current head SHA, until terminal
|
|
23
|
+
or timeout. Provider-agnostic — unlike \`gh run watch\`, which is Actions-only.
|
|
24
|
+
Required:
|
|
25
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
26
|
+
--pr <number> Pull request number
|
|
27
|
+
Optional:
|
|
28
|
+
--timeout-ms <n> Total watch budget (default 1800000; 0 = single check, no wait)
|
|
29
|
+
--poll-interval-ms <n> Delay between polls (default 60000)
|
|
30
|
+
Output (stdout, JSON):
|
|
31
|
+
{ "ok": true, "status": "success"|"failure"|"pending"|"timeout"|"changed",
|
|
32
|
+
"settled": bool, "ciStatus": "success"|"failure"|"pending"|"none",
|
|
33
|
+
"failedChecks": [{ "name": "...", "conclusion"?: "..." }], "headSha": "...", "attempts": N }
|
|
34
|
+
Statuses:
|
|
35
|
+
success Combined CI is green (or no checks present — see no-checks rule)
|
|
36
|
+
failure At least one check/status failed (failedChecks populated)
|
|
37
|
+
pending Timed-out single check (timeout-ms 0) found CI still in flight
|
|
38
|
+
timeout Watch budget elapsed while CI was still pending
|
|
39
|
+
changed Head SHA advanced during the wait; caller must re-baseline
|
|
40
|
+
No-checks rule (grace, race-safe):
|
|
41
|
+
Zero check-runs AND zero commit-statuses is NOT settled green on the first
|
|
42
|
+
poll — a provider (CircleCI/Actions) may post its first check a beat after a
|
|
43
|
+
fresh push, and settling early would report green before any CI ran. Instead
|
|
44
|
+
the watcher awaits 2 consecutive zero-check polls (a ~2-poll-interval grace)
|
|
45
|
+
before settling success (ciStatus "none"): a genuinely check-less repo still
|
|
46
|
+
settles instead of hanging, while a late first check is awaited. If the PR's
|
|
47
|
+
statusCheckRollup lists EXPECTED checks while the APIs still report zero, that
|
|
48
|
+
is pending (checks expected, not yet reported), never none. A gh-api / parse
|
|
49
|
+
failure is never treated as empty — it forces pending so the watch keeps
|
|
50
|
+
polling, and a persistent error settles as "timeout", never fabricated green.
|
|
51
|
+
(timeout-ms 0 single check has no waiting budget, so a clean no-checks head
|
|
52
|
+
settles immediately.)
|
|
53
|
+
Diagnostic output (stderr):
|
|
54
|
+
{ "ok": true, "type": "watch_heartbeat", "elapsedMs": N, "totalBudgetMs": N, "poll": N, "maxPolls": N }
|
|
55
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
56
|
+
Exit codes:
|
|
57
|
+
0 Success
|
|
58
|
+
1 Argument error or gh failure`.trim();
|
|
59
|
+
const parseError = buildParseError(USAGE);
|
|
60
|
+
|
|
61
|
+
export function parseCiWatchCliArgs(argv) {
|
|
62
|
+
const { tokens } = parseArgs({
|
|
63
|
+
args: [...argv],
|
|
64
|
+
options: {
|
|
65
|
+
help: { type: "boolean", short: "h" },
|
|
66
|
+
repo: { type: "string" },
|
|
67
|
+
pr: { type: "string" },
|
|
68
|
+
"timeout-ms": { type: "string" },
|
|
69
|
+
"poll-interval-ms": { type: "string" },
|
|
70
|
+
},
|
|
71
|
+
allowPositionals: true,
|
|
72
|
+
strict: false,
|
|
73
|
+
tokens: true,
|
|
74
|
+
});
|
|
75
|
+
const options = {
|
|
76
|
+
help: false,
|
|
77
|
+
repo: undefined,
|
|
78
|
+
pr: undefined,
|
|
79
|
+
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
80
|
+
timeoutMs: COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
81
|
+
};
|
|
82
|
+
for (const token of tokens) {
|
|
83
|
+
if (token.kind === "positional") {
|
|
84
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
85
|
+
}
|
|
86
|
+
if (token.kind !== "option") {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (token.name === "help") {
|
|
90
|
+
options.help = true;
|
|
91
|
+
return options;
|
|
92
|
+
}
|
|
93
|
+
if (token.name === "repo") {
|
|
94
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (token.name === "pr") {
|
|
98
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (token.name === "timeout-ms") {
|
|
102
|
+
options.timeoutMs = parseNonNegativeMs(requireTokenValue(token, parseError), "--timeout-ms");
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (token.name === "poll-interval-ms") {
|
|
106
|
+
options.pollIntervalMs = parsePositiveMs(requireTokenValue(token, parseError), "--poll-interval-ms");
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
110
|
+
}
|
|
111
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
112
|
+
throw parseError("Watching CI requires both --repo <owner/name> and --pr <number>");
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
parseRepoSlug(options.repo);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
118
|
+
}
|
|
119
|
+
return options;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseNonNegativeMs(raw, flag) {
|
|
123
|
+
const value = Number(raw);
|
|
124
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
125
|
+
throw parseError(`${flag} must be a non-negative integer`);
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parsePositiveMs(raw, flag) {
|
|
131
|
+
const value = Number(raw);
|
|
132
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
133
|
+
throw parseError(`${flag} must be a positive integer`);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function ghJson(ghCommand, args, env) {
|
|
139
|
+
const result = await runChild(ghCommand, args, env);
|
|
140
|
+
if (result.code !== 0) {
|
|
141
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
142
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(result.stdout);
|
|
146
|
+
} catch {
|
|
147
|
+
throw new Error(`Invalid JSON from gh: ${result.stdout.trim() || "<empty>"}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function extractPrVisibleCheckNames(statusCheckRollup) {
|
|
152
|
+
if (!Array.isArray(statusCheckRollup)) return [];
|
|
153
|
+
return statusCheckRollup
|
|
154
|
+
.map((entry) => entry?.name || entry?.context)
|
|
155
|
+
.filter((name) => typeof name === "string" && name.length > 0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Failing commit-status contexts (state "failure"/"error"), e.g. CircleCI which
|
|
159
|
+
// reports through the status API rather than as check-runs.
|
|
160
|
+
function extractFailedStatusContexts(statuses) {
|
|
161
|
+
if (!Array.isArray(statuses)) return [];
|
|
162
|
+
return statuses
|
|
163
|
+
.filter((s) => {
|
|
164
|
+
const state = typeof s?.state === "string" ? s.state.toLowerCase() : "";
|
|
165
|
+
return state === "failure" || state === "error";
|
|
166
|
+
})
|
|
167
|
+
.map((s) => ({
|
|
168
|
+
name: typeof s?.context === "string" && s.context.length > 0 ? s.context : "unknown",
|
|
169
|
+
conclusion: typeof s?.state === "string" ? s.state.toLowerCase() : "",
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function fetchPrHeadSha({ repo, pr }, { env, ghCommand }) {
|
|
174
|
+
const payload = await ghJson(
|
|
175
|
+
ghCommand,
|
|
176
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,statusCheckRollup"],
|
|
177
|
+
env,
|
|
178
|
+
);
|
|
179
|
+
const headSha = typeof payload.headRefOid === "string" ? payload.headRefOid.trim() : "";
|
|
180
|
+
if (headSha.length === 0) {
|
|
181
|
+
throw new Error("Missing required PR facts: headRefOid");
|
|
182
|
+
}
|
|
183
|
+
return { headSha, prVisibleCheckNames: extractPrVisibleCheckNames(payload.statusCheckRollup) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Read the combined check-runs + commit-status state for one head SHA.
|
|
188
|
+
* Provider-agnostic: covers GitHub Actions, CircleCI, and any external
|
|
189
|
+
* commit-status / check-run reported against the commit.
|
|
190
|
+
*
|
|
191
|
+
* `fetchError` is true when a `gh api` call failed (non-zero exit) or returned
|
|
192
|
+
* an unparseable / malformed payload. The caller MUST NOT treat a fetchError as
|
|
193
|
+
* a genuine empty (no-checks) state — a transient API error would otherwise
|
|
194
|
+
* fabricate green. On fetchError, ciStatus is forced to "pending" so the watch
|
|
195
|
+
* keeps polling (and a persistent error settles as "timeout", never success).
|
|
196
|
+
*
|
|
197
|
+
* @returns {{ ciStatus: "success"|"failure"|"pending"|"none", noChecks: boolean, fetchError: boolean, failedChecks: Array<{ name: string, conclusion?: string }> }}
|
|
198
|
+
*/
|
|
199
|
+
async function fetchHeadCiState({ repo, headSha, prVisibleCheckNames }, { env, ghCommand }) {
|
|
200
|
+
const [checkRunsResult, statusesResult] = await Promise.all([
|
|
201
|
+
runChild(ghCommand, ["api", `repos/${repo}/commits/${headSha}/check-runs?per_page=100`], env),
|
|
202
|
+
runChild(ghCommand, ["api", `repos/${repo}/commits/${headSha}/status?per_page=100`], env),
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
let checkRunsSignal = null;
|
|
206
|
+
let checkRunsCount = 0;
|
|
207
|
+
let checkRunsError = checkRunsResult.code !== 0;
|
|
208
|
+
if (checkRunsResult.code === 0) {
|
|
209
|
+
try {
|
|
210
|
+
const payload = JSON.parse(checkRunsResult.stdout);
|
|
211
|
+
if (Array.isArray(payload?.check_runs)) {
|
|
212
|
+
const visibleSet = prVisibleCheckNames?.length > 0 ? new Set(prVisibleCheckNames) : null;
|
|
213
|
+
const visibleRuns = visibleSet
|
|
214
|
+
? payload.check_runs.filter((run) => !run.name || visibleSet.has(run.name))
|
|
215
|
+
: payload.check_runs;
|
|
216
|
+
const visibleSignal = summarizeHeadScopedCheckRunsSignal({ check_runs: visibleRuns });
|
|
217
|
+
const fullSignal = summarizeHeadScopedCheckRunsSignal(payload);
|
|
218
|
+
checkRunsSignal = { ...visibleSignal, unsupportedCompleted: fullSignal.unsupportedCompleted };
|
|
219
|
+
checkRunsCount = payload.check_runs.length;
|
|
220
|
+
} else {
|
|
221
|
+
checkRunsError = true; // exit 0 but no check_runs array → malformed payload, not empty
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
checkRunsSignal = null;
|
|
225
|
+
checkRunsError = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let commitStatus = null;
|
|
230
|
+
let statusesCount = 0;
|
|
231
|
+
let statusFailures = [];
|
|
232
|
+
let statusesError = statusesResult.code !== 0;
|
|
233
|
+
if (statusesResult.code === 0) {
|
|
234
|
+
try {
|
|
235
|
+
const payload = JSON.parse(statusesResult.stdout);
|
|
236
|
+
if (Array.isArray(payload?.statuses)) {
|
|
237
|
+
commitStatus = normalizeHeadScopedCommitStatus(payload);
|
|
238
|
+
statusesCount = payload.statuses.length;
|
|
239
|
+
statusFailures = extractFailedStatusContexts(payload.statuses);
|
|
240
|
+
} else {
|
|
241
|
+
statusesError = true; // exit 0 but no statuses array → malformed payload, not empty
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
commitStatus = null;
|
|
245
|
+
statusesError = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const fetchError = checkRunsError || statusesError;
|
|
250
|
+
const ciStatus = fetchError
|
|
251
|
+
? "pending"
|
|
252
|
+
: normalizeHeadScopedCiContract({
|
|
253
|
+
checkRunsStatus: checkRunsSignal?.status ?? "none",
|
|
254
|
+
commitStatus: commitStatus ?? "none",
|
|
255
|
+
checkRunsUnsupportedCompleted: checkRunsSignal?.unsupportedCompleted ?? false,
|
|
256
|
+
}).overallStatus;
|
|
257
|
+
// Provider-agnostic failure reporting: check-runs failures AND failing
|
|
258
|
+
// commit-status contexts (e.g. CircleCI reports via the status API with NO
|
|
259
|
+
// check-runs). Without the status side, a CircleCI failure would surface
|
|
260
|
+
// ciStatus "failure" with an empty failedChecks.
|
|
261
|
+
const failedChecks = fetchError
|
|
262
|
+
? []
|
|
263
|
+
: [
|
|
264
|
+
...(checkRunsSignal?.failureDetails ?? []).map((name) => ({ name })),
|
|
265
|
+
...statusFailures,
|
|
266
|
+
];
|
|
267
|
+
// No-checks: zero check-runs AND zero commit-statuses, observed cleanly (no
|
|
268
|
+
// fetchError) AND with no PR-visible expected checks. If statusCheckRollup
|
|
269
|
+
// lists expected checks the providers haven't reported yet, that is pending
|
|
270
|
+
// (checks expected but not yet posted), not a genuinely check-less head.
|
|
271
|
+
const noChecks =
|
|
272
|
+
!fetchError &&
|
|
273
|
+
checkRunsCount === 0 &&
|
|
274
|
+
statusesCount === 0 &&
|
|
275
|
+
!(prVisibleCheckNames?.length > 0);
|
|
276
|
+
return { ciStatus, noChecks, fetchError, failedChecks };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function buildAttemptBudget(timeoutMs, pollIntervalMs) {
|
|
280
|
+
if (timeoutMs === 0) {
|
|
281
|
+
return 1;
|
|
282
|
+
}
|
|
283
|
+
// Polls land at t=0, interval, 2*interval, ... so floor(timeout/interval)+1
|
|
284
|
+
// polls fit inside the budget (the first poll costs no delay).
|
|
285
|
+
return Math.max(1, Math.floor(timeoutMs / pollIntervalMs) + 1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Attempt 1 polls immediately (t=0); attempt N waits (N-1)*pollIntervalMs from
|
|
289
|
+
// the watch start, capped by the total timeout budget.
|
|
290
|
+
function buildPollDelayMs(watchStartedAtMs, timeoutMs, pollIntervalMs, attempt, nowMs) {
|
|
291
|
+
if (timeoutMs === 0 || attempt <= 1) {
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
const scheduledAtMs = watchStartedAtMs + Math.min(timeoutMs, (attempt - 1) * pollIntervalMs);
|
|
295
|
+
return Math.max(0, scheduledAtMs - nowMs);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function settledResult(state, { settled, status }) {
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
status,
|
|
302
|
+
settled,
|
|
303
|
+
ciStatus: state.ciStatus,
|
|
304
|
+
failedChecks: state.failedChecks,
|
|
305
|
+
headSha: state.headSha,
|
|
306
|
+
attempts: state.attempts,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Consecutive clean zero-check polls required before settling none->success.
|
|
311
|
+
* A provider (CircleCI/Actions) may post its first check a beat after the push;
|
|
312
|
+
* settling on the FIRST zero-check poll would fabricate green before any CI ran.
|
|
313
|
+
* So we await this many consecutive zero-check observations (a grace of ~2 poll
|
|
314
|
+
* intervals) before treating a head as genuinely check-less. A repo that truly
|
|
315
|
+
* has no CI still settles after the grace instead of hanging to timeout. */
|
|
316
|
+
export const NO_CHECKS_GRACE_POLLS = 2;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Map a head CI state to a terminal watcher status, or null when still in flight.
|
|
320
|
+
* - failure / success classify immediately (a check is terminal).
|
|
321
|
+
* - none (zero checks, clean fetch, no expected checks) settles success only
|
|
322
|
+
* after NO_CHECKS_GRACE_POLLS consecutive observations (see constant).
|
|
323
|
+
* - fetchError / expected-but-unreported checks → pending (keep polling).
|
|
324
|
+
*/
|
|
325
|
+
function terminalStatusFor({ ciStatus, noChecks }, consecutiveNoChecks, graceFloor) {
|
|
326
|
+
if (ciStatus === "failure") return "failure";
|
|
327
|
+
if (ciStatus === "success") return "success";
|
|
328
|
+
if (noChecks && consecutiveNoChecks >= graceFloor) return "success";
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function watchCiStatus(
|
|
333
|
+
options,
|
|
334
|
+
{
|
|
335
|
+
env = process.env,
|
|
336
|
+
ghCommand = "gh",
|
|
337
|
+
delayImpl = delay,
|
|
338
|
+
now = Date.now,
|
|
339
|
+
} = {},
|
|
340
|
+
) {
|
|
341
|
+
const { headSha: baselineSha, prVisibleCheckNames } = await fetchPrHeadSha(
|
|
342
|
+
{ repo: options.repo, pr: options.pr },
|
|
343
|
+
{ env, ghCommand },
|
|
344
|
+
);
|
|
345
|
+
const attemptBudget = buildAttemptBudget(options.timeoutMs, options.pollIntervalMs);
|
|
346
|
+
const watchStartedAtMs = now();
|
|
347
|
+
// timeout-ms 0 is a single live check with no waiting budget: there is no
|
|
348
|
+
// grace window to await a late first check, so a clean no-checks head settles
|
|
349
|
+
// immediately (preserves single-check semantics). A real watch awaits the grace.
|
|
350
|
+
const graceFloor = options.timeoutMs === 0 ? 1 : NO_CHECKS_GRACE_POLLS;
|
|
351
|
+
let consecutiveNoChecks = 0;
|
|
352
|
+
for (let attempt = 1; attempt <= attemptBudget; attempt += 1) {
|
|
353
|
+
// Attempt 1 polls at t=0 (CI may already be terminal); sleep only between
|
|
354
|
+
// subsequent polls so the watcher never burns a full interval before its
|
|
355
|
+
// first observation.
|
|
356
|
+
if (attempt > 1) {
|
|
357
|
+
const pollDelayMs = buildPollDelayMs(
|
|
358
|
+
watchStartedAtMs,
|
|
359
|
+
options.timeoutMs,
|
|
360
|
+
options.pollIntervalMs,
|
|
361
|
+
attempt,
|
|
362
|
+
now(),
|
|
363
|
+
);
|
|
364
|
+
let remainingMs = pollDelayMs;
|
|
365
|
+
while (remainingMs > 0) {
|
|
366
|
+
const chunkMs = Math.min(WATCH_HEARTBEAT_MS, remainingMs);
|
|
367
|
+
await delayImpl(chunkMs);
|
|
368
|
+
remainingMs -= chunkMs;
|
|
369
|
+
if (remainingMs > 0) {
|
|
370
|
+
process.stderr.write(
|
|
371
|
+
JSON.stringify({
|
|
372
|
+
ok: true,
|
|
373
|
+
type: "watch_heartbeat",
|
|
374
|
+
elapsedMs: now() - watchStartedAtMs,
|
|
375
|
+
totalBudgetMs: options.timeoutMs,
|
|
376
|
+
poll: attempt,
|
|
377
|
+
maxPolls: attemptBudget,
|
|
378
|
+
}) + "\n",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Re-resolve the head SHA every poll: a new push must short-circuit to
|
|
384
|
+
// "changed" so the caller re-baselines instead of waiting on a stale head.
|
|
385
|
+
const { headSha: currentSha, prVisibleCheckNames: currentNames } = await fetchPrHeadSha(
|
|
386
|
+
{ repo: options.repo, pr: options.pr },
|
|
387
|
+
{ env, ghCommand },
|
|
388
|
+
);
|
|
389
|
+
if (currentSha !== baselineSha) {
|
|
390
|
+
const changedState = await fetchHeadCiState(
|
|
391
|
+
{ repo: options.repo, headSha: currentSha, prVisibleCheckNames: currentNames },
|
|
392
|
+
{ env, ghCommand },
|
|
393
|
+
);
|
|
394
|
+
return settledResult({ ...changedState, headSha: currentSha, attempts: attempt }, {
|
|
395
|
+
settled: false,
|
|
396
|
+
status: "changed",
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// Use the per-poll currentNames (not the baseline): statusCheckRollup may
|
|
400
|
+
// start empty and later populate expected checks for the SAME head SHA. With
|
|
401
|
+
// the stale baseline names this head would look check-less and wrongly settle
|
|
402
|
+
// none->success; currentNames keeps it pending until the checks report.
|
|
403
|
+
const state = await fetchHeadCiState(
|
|
404
|
+
{ repo: options.repo, headSha: currentSha, prVisibleCheckNames: currentNames },
|
|
405
|
+
{ env, ghCommand },
|
|
406
|
+
);
|
|
407
|
+
consecutiveNoChecks = state.noChecks ? consecutiveNoChecks + 1 : 0;
|
|
408
|
+
const terminal = terminalStatusFor(state, consecutiveNoChecks, graceFloor);
|
|
409
|
+
if (terminal !== null) {
|
|
410
|
+
return settledResult({ ...state, headSha: currentSha, attempts: attempt }, {
|
|
411
|
+
settled: true,
|
|
412
|
+
status: terminal,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Budget exhausted while still pending. Re-resolve the head before the final
|
|
417
|
+
// fetch: the head may have advanced during the last delay (short-circuit to
|
|
418
|
+
// "changed" so the caller re-baselines), and the rollup may have populated
|
|
419
|
+
// expected checks for the same SHA (use the current names, not baseline).
|
|
420
|
+
// A zero-timeout single check reports the live "pending" state; a real watch
|
|
421
|
+
// budget reports "timeout".
|
|
422
|
+
const { headSha: finalSha, prVisibleCheckNames: finalNames } = await fetchPrHeadSha(
|
|
423
|
+
{ repo: options.repo, pr: options.pr },
|
|
424
|
+
{ env, ghCommand },
|
|
425
|
+
);
|
|
426
|
+
if (finalSha !== baselineSha) {
|
|
427
|
+
const changedState = await fetchHeadCiState(
|
|
428
|
+
{ repo: options.repo, headSha: finalSha, prVisibleCheckNames: finalNames },
|
|
429
|
+
{ env, ghCommand },
|
|
430
|
+
);
|
|
431
|
+
return settledResult({ ...changedState, headSha: finalSha, attempts: attemptBudget }, {
|
|
432
|
+
settled: false,
|
|
433
|
+
status: "changed",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const finalState = await fetchHeadCiState(
|
|
437
|
+
{ repo: options.repo, headSha: finalSha, prVisibleCheckNames: finalNames },
|
|
438
|
+
{ env, ghCommand },
|
|
439
|
+
);
|
|
440
|
+
return settledResult({ ...finalState, headSha: finalSha, attempts: attemptBudget }, {
|
|
441
|
+
settled: false,
|
|
442
|
+
status: options.timeoutMs === 0 ? "pending" : "timeout",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function runCli(
|
|
447
|
+
argv = process.argv.slice(2),
|
|
448
|
+
{
|
|
449
|
+
stdout = process.stdout,
|
|
450
|
+
env = process.env,
|
|
451
|
+
ghCommand = "gh",
|
|
452
|
+
} = {},
|
|
453
|
+
) {
|
|
454
|
+
const options = parseCiWatchCliArgs(argv);
|
|
455
|
+
if (options.help) {
|
|
456
|
+
stdout.write(`${USAGE}\n`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const result = await watchCiStatus(options, { env, ghCommand });
|
|
460
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
464
|
+
runCli().catch((error) => {
|
|
465
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
466
|
+
process.exitCode = 1;
|
|
467
|
+
});
|
|
468
|
+
}
|
|
@@ -124,7 +124,7 @@ async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
|
|
|
124
124
|
}
|
|
125
125
|
return { id: prData.id, isDraft: prData.isDraft };
|
|
126
126
|
}
|
|
127
|
-
async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
|
|
127
|
+
export async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
|
|
128
128
|
const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand });
|
|
129
129
|
if (resolvedPr.isDraft === true) {
|
|
130
130
|
return {
|
|
@@ -154,7 +154,7 @@ async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
|
|
|
154
154
|
alreadyDraft: false,
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
|
-
async function markPrReady({ repo, pr }, { env, ghCommand }) {
|
|
157
|
+
export async function markPrReady({ repo, pr }, { env, ghCommand }) {
|
|
158
158
|
const result = await runChild(ghCommand, [
|
|
159
159
|
"pr", "ready", String(pr),
|
|
160
160
|
"--repo", repo,
|
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
isCopilotLogin,
|
|
7
7
|
isDirectCliRun,
|
|
8
8
|
parseReviewThreads,
|
|
9
|
+
resolveDraftGateRoundResetMs,
|
|
9
10
|
summarizeCopilotReviews,
|
|
11
|
+
summarizeGateReviewComments,
|
|
10
12
|
} from "../_core-helpers.mjs";
|
|
11
13
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
12
14
|
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
@@ -28,7 +30,7 @@ Optional:
|
|
|
28
30
|
the last Copilot review. Refused when the PR head
|
|
29
31
|
has not changed since the last review.
|
|
30
32
|
Debug:
|
|
31
|
-
|
|
33
|
+
DEVLOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
|
|
32
34
|
convergence detection falls back to unsuppressed behavior
|
|
33
35
|
Output (stdout, JSON):
|
|
34
36
|
{ "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",
|
|
@@ -121,7 +123,7 @@ function parseRequestedReviewersPayload(text) {
|
|
|
121
123
|
requested: users.some((user) => isCopilotLogin(user?.login)),
|
|
122
124
|
};
|
|
123
125
|
}
|
|
124
|
-
function parseReviewsPayload(text) {
|
|
126
|
+
function parseReviewsPayload(text, { draftGateResetAtMs = null } = {}) {
|
|
125
127
|
let payload;
|
|
126
128
|
try {
|
|
127
129
|
payload = JSON.parse(text);
|
|
@@ -131,7 +133,10 @@ function parseReviewsPayload(text) {
|
|
|
131
133
|
const headSha = typeof payload?.headRefOid === "string" && payload.headRefOid.trim().length > 0
|
|
132
134
|
? payload.headRefOid.trim()
|
|
133
135
|
: null;
|
|
134
|
-
|
|
136
|
+
// Apply the draft-gate round reset so the completed round count matches what
|
|
137
|
+
// detect-pr-gate-coordination-state computes (#896): when the draft gate has
|
|
138
|
+
// re-passed clean on an earlier head, only reviews after that re-pass count.
|
|
139
|
+
const reviewSummary = summarizeCopilotReviews(payload?.reviews, { headSha, draftGateResetAtMs });
|
|
135
140
|
return {
|
|
136
141
|
prData: payload,
|
|
137
142
|
headSha,
|
|
@@ -166,6 +171,51 @@ async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghComman
|
|
|
166
171
|
}
|
|
167
172
|
return parseReviewsPayload(result.stdout);
|
|
168
173
|
}
|
|
174
|
+
|
|
175
|
+
// Re-derive the completed Copilot round count with the draft-gate round reset
|
|
176
|
+
// applied, mirroring detect-pr-gate-coordination-state so both scripts agree on the
|
|
177
|
+
// completed count and therefore on round-cap-reached (#896). A clean draft_gate
|
|
178
|
+
// re-pass on an earlier head resets the count, so post-reset reviews must not be
|
|
179
|
+
// counted toward the cap.
|
|
180
|
+
//
|
|
181
|
+
// Queried lazily — only when the raw (un-reset) count has already hit the cap — so
|
|
182
|
+
// the common (under-cap) request path keeps its existing gh-call contract and adds
|
|
183
|
+
// no API round-trip. Uses a single issue-comments fetch (the same source the gate
|
|
184
|
+
// detector uses for the latest clean draft_gate marker), not the full checkpoint-
|
|
185
|
+
// evidence pipeline, to keep the added surface minimal. Best-effort: a fetch failure
|
|
186
|
+
// falls back to the raw count, so the cap is never silently disabled.
|
|
187
|
+
async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh" } = {}, before) {
|
|
188
|
+
try {
|
|
189
|
+
const currentHeadSha = typeof before?.prData?.headRefOid === "string" && before.prData.headRefOid.trim().length > 0
|
|
190
|
+
? before.prData.headRefOid.trim()
|
|
191
|
+
: null;
|
|
192
|
+
const result = await runChild(
|
|
193
|
+
ghCommand,
|
|
194
|
+
["api", "--paginate", "--slurp", `repos/${options.repo}/issues/${options.pr}/comments?per_page=100`],
|
|
195
|
+
env,
|
|
196
|
+
);
|
|
197
|
+
if (result.code !== 0) {
|
|
198
|
+
return before.completedCopilotReviewRounds ?? 0;
|
|
199
|
+
}
|
|
200
|
+
let comments;
|
|
201
|
+
try {
|
|
202
|
+
const payload = JSON.parse(result.stdout);
|
|
203
|
+
comments = Array.isArray(payload) ? payload.flat() : [];
|
|
204
|
+
} catch {
|
|
205
|
+
return before.completedCopilotReviewRounds ?? 0;
|
|
206
|
+
}
|
|
207
|
+
const gateSummary = summarizeGateReviewComments(comments);
|
|
208
|
+
const draftGateResetAtMs = resolveDraftGateRoundResetMs({ draftGate: gateSummary?.draft_gate, currentHeadSha });
|
|
209
|
+
if (draftGateResetAtMs == null) {
|
|
210
|
+
return before.completedCopilotReviewRounds ?? 0;
|
|
211
|
+
}
|
|
212
|
+
const adjusted = parseReviewsPayload(JSON.stringify(before.prData ?? {}), { draftGateResetAtMs });
|
|
213
|
+
return adjusted.completedCopilotReviewRounds ?? 0;
|
|
214
|
+
} catch {
|
|
215
|
+
return before.completedCopilotReviewRounds ?? 0;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
169
219
|
async function fetchCopilotReviewState(options, runtime) {
|
|
170
220
|
const requestedReviewers = await fetchRequestedReviewers(options, runtime);
|
|
171
221
|
const reviews = await fetchCopilotReviewIds(options, runtime);
|
|
@@ -212,7 +262,7 @@ async function detectSameHeadCleanConvergence(options, runtime, priorReviewState
|
|
|
212
262
|
const interpretation = interpretLoopState(snapshot);
|
|
213
263
|
return interpretation.sameHeadCleanConverged;
|
|
214
264
|
} catch (error) {
|
|
215
|
-
if (runtime?.env?.
|
|
265
|
+
if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
|
|
216
266
|
const detail = error instanceof Error ? error.message : String(error);
|
|
217
267
|
process.stderr.write(`[request-copilot-review] same-head clean-convergence detection unavailable: ${detail}\n`);
|
|
218
268
|
}
|
|
@@ -252,7 +302,7 @@ async function detectRoundCapAutoRerequestEligibility(options, runtime, priorRev
|
|
|
252
302
|
interpretation,
|
|
253
303
|
};
|
|
254
304
|
} catch (error) {
|
|
255
|
-
if (runtime?.env?.
|
|
305
|
+
if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
|
|
256
306
|
const detail = error instanceof Error ? error.message : String(error);
|
|
257
307
|
process.stderr.write(`[request-copilot-review] round-cap auto-rerequest detection unavailable: ${detail}\n`);
|
|
258
308
|
}
|
|
@@ -434,7 +484,18 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
434
484
|
}
|
|
435
485
|
} catch {
|
|
436
486
|
}
|
|
437
|
-
|
|
487
|
+
// Reconcile the completed-round count with detect-pr-gate-coordination-state (#896):
|
|
488
|
+
// when the raw count has reached the cap, re-derive it with the draft-gate round
|
|
489
|
+
// reset applied. A clean draft_gate re-pass on an earlier head resets the count, so
|
|
490
|
+
// a post-reset PR that detect reports as under-cap must NOT be refused here as
|
|
491
|
+
// cap-reached. Only query checkpoint evidence on this (at/over-cap) path.
|
|
492
|
+
let completedRounds = before.completedCopilotReviewRounds ?? 0;
|
|
493
|
+
if (completedRounds >= maxRounds
|
|
494
|
+
&& !before.requested
|
|
495
|
+
&& !before.hasPendingReviewOnCurrentHead) {
|
|
496
|
+
completedRounds = await resolveDraftGateAdjustedRounds(options, { env, ghCommand }, before);
|
|
497
|
+
}
|
|
498
|
+
if (completedRounds >= maxRounds
|
|
438
499
|
&& !before.requested
|
|
439
500
|
&& !before.hasPendingReviewOnCurrentHead) {
|
|
440
501
|
if (!options.forceRerequestReview) {
|
|
@@ -451,9 +512,9 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
451
512
|
repo: options.repo,
|
|
452
513
|
pr: options.pr,
|
|
453
514
|
reviewer: "Copilot",
|
|
454
|
-
completedRounds
|
|
515
|
+
completedRounds,
|
|
455
516
|
maxRounds,
|
|
456
|
-
detail: `Round cap of ${maxRounds} reached with ${
|
|
517
|
+
detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. No further re-requests will be made.`,
|
|
457
518
|
};
|
|
458
519
|
}
|
|
459
520
|
}
|
|
@@ -471,8 +532,8 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
471
532
|
repo: options.repo,
|
|
472
533
|
pr: options.pr,
|
|
473
534
|
reviewer: "Copilot",
|
|
474
|
-
detail: `Round cap of ${maxRounds} reached with ${
|
|
475
|
-
completedRounds
|
|
535
|
+
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.`,
|
|
536
|
+
completedRounds,
|
|
476
537
|
maxRounds,
|
|
477
538
|
};
|
|
478
539
|
}
|
|
@@ -484,7 +545,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
484
545
|
pr: options.pr,
|
|
485
546
|
reviewer: "Copilot",
|
|
486
547
|
detail: "No changes since last Copilot review. --force-rerequest-review requires new commits on the PR head.",
|
|
487
|
-
completedRounds
|
|
548
|
+
completedRounds,
|
|
488
549
|
maxRounds,
|
|
489
550
|
};
|
|
490
551
|
}
|