dev-loops 0.3.0 → 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/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +1 -1
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- 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 +8 -2
- package/CHANGELOG.md +26 -0
- package/README.md +8 -2
- package/cli/index.mjs +21 -2
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +2 -2
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/request-copilot-review.mjs +3 -3
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +2 -2
- 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-stale-runner.mjs +3 -4
- 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 +25 -1
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +20 -5
- package/scripts/projects/archive-done-items.mjs +2 -1
- package/scripts/projects/ensure-queue-board.mjs +2 -1
- package/scripts/projects/list-queue-items.mjs +2 -1
- package/scripts/projects/move-queue-item.mjs +2 -1
- package/scripts/projects/reorder-queue-item.mjs +5 -4
- package/scripts/projects/sync-item-status.mjs +2 -1
- package/skills/copilot-pr-followup/SKILL.md +9 -7
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/docs/anti-patterns.md +1 -1
- package/skills/docs/copilot-loop-operations.md +1 -1
- 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 +8 -2
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
3
|
+
import { parsePrNumber, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
|
+
import { loadDevLoopConfig } from "@dev-loops/core/config";
|
|
6
|
+
import { resolveHandoffCandidates } from "./resolve-handoff-candidates.mjs";
|
|
7
|
+
|
|
8
|
+
const USAGE = `Usage: offer-human-handoff.mjs --repo <owner/name> --pr <number> [--assign <login>...] [--request-review <login>...] [--changed-files <csv>] [--pr-author <login>]
|
|
9
|
+
Human-handoff offer at the pre-approval / merge-handoff boundary (#920, Request B
|
|
10
|
+
of #910). OFFER-only: without --assign/--request-review it resolves and prints
|
|
11
|
+
the candidate list (the "offer"); the operator confirms who takes it. With
|
|
12
|
+
--assign / --request-review it performs the confirmed action via \`gh pr edit\`.
|
|
13
|
+
|
|
14
|
+
This pairs with autonomy.humanMergeOnly: when human-merge is enforced, the offer
|
|
15
|
+
names who should take the merge. No-op when approval.humanHandoff is disabled
|
|
16
|
+
(default) — prints an empty offer.
|
|
17
|
+
|
|
18
|
+
Required:
|
|
19
|
+
--repo <owner/name>
|
|
20
|
+
--pr <number>
|
|
21
|
+
Optional:
|
|
22
|
+
--assign <login> Add a confirmed assignee (repeatable). Performs
|
|
23
|
+
\`gh pr edit --add-assignee\`.
|
|
24
|
+
--request-review <login> Request review from a confirmed reviewer (repeatable).
|
|
25
|
+
Performs \`gh pr edit --add-reviewer\`.
|
|
26
|
+
--changed-files <csv> Forwarded to the candidate resolver.
|
|
27
|
+
--pr-author <login> Forwarded to the candidate resolver.
|
|
28
|
+
Output (stdout, JSON):
|
|
29
|
+
Offer mode: { "ok": true, "mode": "offer", "enabled", "candidates": [...], ... }
|
|
30
|
+
Apply mode: { "ok": true, "mode": "apply", "assigned": [...], "requestedReview": [...] }
|
|
31
|
+
Exit codes:
|
|
32
|
+
0 Success (including disabled no-op offer)
|
|
33
|
+
1 Argument error or gh failure`.trim();
|
|
34
|
+
|
|
35
|
+
const parseError = buildParseError(USAGE);
|
|
36
|
+
|
|
37
|
+
function nextValue(args, i, flag) {
|
|
38
|
+
const value = args[i];
|
|
39
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
40
|
+
throw parseError(`Missing value for ${flag}`);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A login flag value with the leading `@` stripped and trimmed. Reject an empty
|
|
46
|
+
// result (e.g. `--assign @`) so a blank login can never reach `gh pr edit
|
|
47
|
+
// --add-assignee ""`.
|
|
48
|
+
function loginValue(args, i, flag) {
|
|
49
|
+
const login = nextValue(args, i, flag).trim().replace(/^@/, "").trim();
|
|
50
|
+
if (login === "") throw parseError(`${flag} requires a non-empty login`);
|
|
51
|
+
return login;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseOfferCliArgs(argv) {
|
|
55
|
+
const args = [...argv];
|
|
56
|
+
if (args.includes("--help") || args.includes("-h")) return { help: true };
|
|
57
|
+
const options = {
|
|
58
|
+
help: false, repo: undefined, pr: undefined,
|
|
59
|
+
assign: [], requestReview: [], changedFiles: undefined, prAuthor: undefined,
|
|
60
|
+
};
|
|
61
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
62
|
+
const token = args[i];
|
|
63
|
+
if (token === "--repo") { options.repo = nextValue(args, ++i, "--repo"); continue; }
|
|
64
|
+
if (token === "--pr") { options.pr = parsePrNumber(nextValue(args, ++i, "--pr"), parseError); continue; }
|
|
65
|
+
if (token === "--assign") { options.assign.push(loginValue(args, ++i, "--assign")); continue; }
|
|
66
|
+
if (token === "--request-review") { options.requestReview.push(loginValue(args, ++i, "--request-review")); continue; }
|
|
67
|
+
if (token === "--changed-files") {
|
|
68
|
+
options.changedFiles = nextValue(args, ++i, "--changed-files")
|
|
69
|
+
.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
|
|
73
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
74
|
+
}
|
|
75
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
76
|
+
throw parseError("Offering human handoff requires both --repo <owner/name> and --pr <number>");
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
parseRepoSlug(options.repo);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
82
|
+
}
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Perform the confirmed assign / request-review actions via `gh pr edit`.
|
|
88
|
+
* OFFER-only contract: this is reached only when the operator passed
|
|
89
|
+
* --assign / --request-review (the confirmation). It never auto-assigns.
|
|
90
|
+
* @returns {Promise<{ ok: boolean, mode: "apply", assigned: string[], requestedReview: string[] }>}
|
|
91
|
+
*/
|
|
92
|
+
export async function applyHandoff({ repo, pr, assign, requestReview }, { run = (cmd, args) => runChild(cmd, args), ghCommand = "gh" } = {}) {
|
|
93
|
+
const ghArgs = ["pr", "edit", String(pr), "--repo", repo];
|
|
94
|
+
for (const login of assign) ghArgs.push("--add-assignee", login);
|
|
95
|
+
for (const login of requestReview) ghArgs.push("--add-reviewer", login);
|
|
96
|
+
const result = await run(ghCommand, ghArgs);
|
|
97
|
+
if (result.code !== 0) {
|
|
98
|
+
throw new Error(result.stderr.trim() || `gh pr edit exited ${result.code}`);
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, mode: "apply", assigned: assign, requestedReview: requestReview };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
104
|
+
let options;
|
|
105
|
+
try {
|
|
106
|
+
options = parseOfferCliArgs(argv);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
if (options.help) {
|
|
112
|
+
process.stdout.write(`${USAGE}\n`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const run = deps.run ?? ((cmd, args) => runChild(cmd, args));
|
|
117
|
+
const ghCommand = deps.ghCommand ?? "gh";
|
|
118
|
+
|
|
119
|
+
// Apply mode: operator confirmed an assignee/reviewer.
|
|
120
|
+
if (options.assign.length > 0 || options.requestReview.length > 0) {
|
|
121
|
+
try {
|
|
122
|
+
const applied = await applyHandoff(
|
|
123
|
+
{ repo: options.repo, pr: options.pr, assign: options.assign, requestReview: options.requestReview },
|
|
124
|
+
{ run, ghCommand },
|
|
125
|
+
);
|
|
126
|
+
process.stdout.write(`${JSON.stringify(applied)}\n`);
|
|
127
|
+
return 0;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Offer mode: resolve + print candidates, assign nobody.
|
|
135
|
+
const repoRoot = deps.repoRoot ?? process.cwd();
|
|
136
|
+
const config = deps.config !== undefined ? deps.config : (await loadDevLoopConfig({ repoRoot })).config;
|
|
137
|
+
const offer = await resolveHandoffCandidates(
|
|
138
|
+
{ repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
|
|
139
|
+
{ ...deps, config, repoRoot, run, ghCommand },
|
|
140
|
+
);
|
|
141
|
+
process.stdout.write(`${JSON.stringify({ ...offer, mode: "offer" })}\n`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
146
|
+
main().then((code) => { process.exitCode = code; });
|
|
147
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -30,7 +30,7 @@ Optional:
|
|
|
30
30
|
the last Copilot review. Refused when the PR head
|
|
31
31
|
has not changed since the last review.
|
|
32
32
|
Debug:
|
|
33
|
-
|
|
33
|
+
DEVLOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
|
|
34
34
|
convergence detection falls back to unsuppressed behavior
|
|
35
35
|
Output (stdout, JSON):
|
|
36
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",
|
|
@@ -262,7 +262,7 @@ async function detectSameHeadCleanConvergence(options, runtime, priorReviewState
|
|
|
262
262
|
const interpretation = interpretLoopState(snapshot);
|
|
263
263
|
return interpretation.sameHeadCleanConverged;
|
|
264
264
|
} catch (error) {
|
|
265
|
-
if (runtime?.env?.
|
|
265
|
+
if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
|
|
266
266
|
const detail = error instanceof Error ? error.message : String(error);
|
|
267
267
|
process.stderr.write(`[request-copilot-review] same-head clean-convergence detection unavailable: ${detail}\n`);
|
|
268
268
|
}
|
|
@@ -302,7 +302,7 @@ async function detectRoundCapAutoRerequestEligibility(options, runtime, priorRev
|
|
|
302
302
|
interpretation,
|
|
303
303
|
};
|
|
304
304
|
} catch (error) {
|
|
305
|
-
if (runtime?.env?.
|
|
305
|
+
if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
|
|
306
306
|
const detail = error instanceof Error ? error.message : String(error);
|
|
307
307
|
process.stderr.write(`[request-copilot-review] round-cap auto-rerequest detection unavailable: ${detail}\n`);
|
|
308
308
|
}
|