dev-loops 0.1.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/.pi/dev-loop/defaults.yaml +477 -0
- package/AGENTS.md +25 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/agents/dev-loop.agent.md +82 -0
- package/agents/developer.agent.md +37 -0
- package/agents/docs.agent.md +33 -0
- package/agents/fixer.agent.md +53 -0
- package/agents/quality.agent.md +28 -0
- package/agents/refiner.agent.md +87 -0
- package/agents/review.agent.md +64 -0
- package/cli/index.mjs +424 -0
- package/extension/README.md +233 -0
- package/extension/checks.ts +94 -0
- package/extension/index.ts +131 -0
- package/extension/post-merge-update.ts +512 -0
- package/extension/presentation.ts +107 -0
- package/lib/dev-loops-core.mjs +284 -0
- package/package.json +103 -0
- package/scripts/README.md +1007 -0
- package/scripts/_cli-primitives.mjs +10 -0
- package/scripts/_core-helpers.mjs +30 -0
- package/scripts/docs/validate-links.mjs +567 -0
- package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
- package/scripts/github/_review-thread-mutations.mjs +214 -0
- package/scripts/github/capture-review-threads.mjs +180 -0
- package/scripts/github/create-draft-pr.mjs +108 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
- package/scripts/github/detect-linked-issue-pr.mjs +331 -0
- package/scripts/github/manage-sub-issues.mjs +394 -0
- package/scripts/github/probe-copilot-review.mjs +323 -0
- package/scripts/github/ready-for-review.mjs +93 -0
- package/scripts/github/reconcile-draft-gate.mjs +328 -0
- package/scripts/github/reply-resolve-review-thread.mjs +42 -0
- package/scripts/github/reply-resolve-review-threads.mjs +329 -0
- package/scripts/github/request-copilot-review.mjs +551 -0
- package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
- package/scripts/github/stage-reviewer-draft.mjs +191 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
- package/scripts/github/verify-fresh-review-context.mjs +125 -0
- package/scripts/github/write-gate-findings-log.mjs +212 -0
- package/scripts/loop/_checkpoint-io.mjs +55 -0
- package/scripts/loop/_checkpoint-paths.mjs +28 -0
- package/scripts/loop/_handoff-contract.mjs +230 -0
- package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
- package/scripts/loop/_loop-evidence.mjs +32 -0
- package/scripts/loop/_pr-runner-coordination.mjs +611 -0
- package/scripts/loop/_stale-runner-detection.mjs +145 -0
- package/scripts/loop/_steering-state-file.mjs +134 -0
- package/scripts/loop/build-handoff-envelope.mjs +181 -0
- package/scripts/loop/checkpoint-contract.mjs +49 -0
- package/scripts/loop/conductor-monitor.mjs +1850 -0
- package/scripts/loop/conductor.mjs +214 -0
- package/scripts/loop/copilot-pr-handoff.mjs +493 -0
- package/scripts/loop/debt-remediate.mjs +304 -0
- package/scripts/loop/detect-change-scope.mjs +102 -0
- package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
- package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
- package/scripts/loop/detect-internal-only-pr.mjs +270 -0
- package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
- package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
- package/scripts/loop/detect-stale-runner.mjs +250 -0
- package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
- package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
- package/scripts/loop/info.mjs +267 -0
- package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
- package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
- package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
- package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
- package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
- package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
- package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
- package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
- package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
- package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
- package/scripts/loop/inspect-run-viewer.mjs +82 -0
- package/scripts/loop/inspect-run.mjs +382 -0
- package/scripts/loop/outer-loop.mjs +419 -0
- package/scripts/loop/pr-runner-coordination.mjs +143 -0
- package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
- package/scripts/loop/pre-flight-gate.mjs +236 -0
- package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
- package/scripts/loop/pre-push-main-guard.mjs +103 -0
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
- package/scripts/loop/print-gates.mjs +42 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
- package/scripts/loop/run-conductor-cycle.mjs +322 -0
- package/scripts/loop/run-queue.mjs +124 -0
- package/scripts/loop/run-refinement-audit.mjs +513 -0
- package/scripts/loop/run-watch-cycle.mjs +358 -0
- package/scripts/loop/steer-loop.mjs +841 -0
- package/scripts/loop/ui-designer-review-contract.mjs +76 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
- package/scripts/projects/add-queue-item.mjs +528 -0
- package/scripts/projects/ensure-queue-board.mjs +837 -0
- package/scripts/projects/list-queue-items.mjs +489 -0
- package/scripts/projects/move-queue-item.mjs +549 -0
- package/scripts/projects/reorder-queue-item.mjs +518 -0
- package/scripts/refine/_refine-helpers.mjs +258 -0
- package/scripts/refine/prose-linkage-detector.mjs +92 -0
- package/scripts/refine/refinement-completeness-checker.mjs +88 -0
- package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
- package/scripts/refine/tree-integrity-validator.mjs +211 -0
- package/scripts/refine/verify.mjs +178 -0
- package/scripts/repo-wiki-local.mjs +156 -0
- package/scripts/repo-wiki.mjs +119 -0
- package/skills/copilot-pr-followup/SKILL.md +380 -0
- package/skills/dev-loop/SKILL.md +141 -0
- package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
- package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
- package/skills/dev-loop/scripts/init-phase.mjs +71 -0
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
- package/skills/dev-loop/scripts/phase-files.mjs +29 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
- package/skills/dev-loop/scripts/render-template.mjs +82 -0
- package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
- package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
- package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
- package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
- package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
- package/skills/dev-loop/templates/dev-mode-review.md +17 -0
- package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
- package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
- package/skills/dev-loop/templates/phase-doc.md +27 -0
- package/skills/dev-loop/templates/phase-summary.md +13 -0
- package/skills/dev-loop/templates/phase-variant.md +15 -0
- package/skills/dev-loop/templates/retrospective.md +11 -0
- package/skills/dev-loop/templates/review.md +32 -0
- package/skills/dev-loop/templates/ui-vision-review.md +55 -0
- package/skills/docs/acceptance-criteria-verification.md +21 -0
- package/skills/docs/anti-patterns.md +21 -0
- package/skills/docs/artifact-authority-contract.md +119 -0
- package/skills/docs/confirmation-rules.md +28 -0
- package/skills/docs/copilot-ci-status-contract.md +52 -0
- package/skills/docs/copilot-loop-operations.md +233 -0
- package/skills/docs/debt-remediation-contract.md +107 -0
- package/skills/docs/entrypoint-strategies.md +115 -0
- package/skills/docs/epic-tree-refinement-procedure.md +234 -0
- package/skills/docs/issue-intake-procedure.md +235 -0
- package/skills/docs/main-agent-contract.md +72 -0
- package/skills/docs/merge-preconditions.md +29 -0
- package/skills/docs/pr-lifecycle-contract.md +209 -0
- package/skills/docs/public-dev-loop-contract.md +497 -0
- package/skills/docs/retrospective-checkpoint-contract.md +159 -0
- package/skills/docs/stop-conditions.md +29 -0
- package/skills/docs/structural-quality.md +42 -0
- package/skills/docs/tracker-first-loop-state.md +281 -0
- package/skills/docs/validation-policy.md +27 -0
- package/skills/docs/workflow-handoff-contract.md +135 -0
- package/skills/final-approval/SKILL.md +19 -0
- package/skills/local-implementation/SKILL.md +640 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, parseJsonText, parseReviewThreads } from "../_core-helpers.mjs";
|
|
4
|
+
import { parsePositiveInteger, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
8
|
+
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
9
|
+
} from "@dev-loops/core/loop/policy-constants";
|
|
10
|
+
|
|
11
|
+
/** Maximum interval between heartbeat outputs during watch delays.
|
|
12
|
+
* Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
|
|
13
|
+
const WATCH_HEARTBEAT_MS = 45_000; // 45 seconds
|
|
14
|
+
const REMOVED_FLAGS = new Set([
|
|
15
|
+
"--poll-interval-ms",
|
|
16
|
+
"--timeout-ms",
|
|
17
|
+
]);
|
|
18
|
+
const USAGE = `Usage: probe-copilot-review.mjs --repo <owner/name> --pr <number>
|
|
19
|
+
Poll for fresh Copilot review activity on a GitHub pull request.
|
|
20
|
+
Required:
|
|
21
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
22
|
+
--pr <number> Pull request number
|
|
23
|
+
Output (stdout, JSON):
|
|
24
|
+
{ "ok": true, "status": "changed"|"timeout"|"idle", "repo": "...", "pr": N, "attempts": N,
|
|
25
|
+
"newComments": [...], "newReviews": [...], "newIssueComments": [...] }
|
|
26
|
+
Activity statuses:
|
|
27
|
+
changed Fresh Copilot review activity found (check newComments/newReviews/newIssueComments)
|
|
28
|
+
timeout Watch period elapsed with no fresh Copilot activity
|
|
29
|
+
idle Zero-timeout single check found no change
|
|
30
|
+
Diagnostic output (stderr):
|
|
31
|
+
Progress/heartbeat (during watch):
|
|
32
|
+
{ "ok": true, "type": "watch_heartbeat", "elapsedMs": N, "totalBudgetMs": N, "poll": N, "maxPolls": N }
|
|
33
|
+
Argument/usage errors:
|
|
34
|
+
{ "ok": false, "error": "...", "usage": "..." }
|
|
35
|
+
gh/runtime failures:
|
|
36
|
+
{ "ok": false, "error": "..." }
|
|
37
|
+
Exit codes:
|
|
38
|
+
0 Success
|
|
39
|
+
1 Argument error or gh failure`.trim();
|
|
40
|
+
const COPILOT_ACTIVITY_QUERY = [
|
|
41
|
+
"query($owner: String!, $name: String!, $pr: Int!) {",
|
|
42
|
+
" repository(owner: $owner, name: $name) {",
|
|
43
|
+
" pullRequest(number: $pr) {",
|
|
44
|
+
" reviewThreads(first: 100) {",
|
|
45
|
+
" nodes {",
|
|
46
|
+
" id",
|
|
47
|
+
" isResolved",
|
|
48
|
+
" comments(first: 100) {",
|
|
49
|
+
" nodes {",
|
|
50
|
+
" id",
|
|
51
|
+
" body",
|
|
52
|
+
" author {",
|
|
53
|
+
" login",
|
|
54
|
+
" __typename",
|
|
55
|
+
" }",
|
|
56
|
+
" }",
|
|
57
|
+
" }",
|
|
58
|
+
" }",
|
|
59
|
+
" }",
|
|
60
|
+
" reviews(first: 100) {",
|
|
61
|
+
" nodes {",
|
|
62
|
+
" id",
|
|
63
|
+
" body",
|
|
64
|
+
" author {",
|
|
65
|
+
" login",
|
|
66
|
+
" __typename",
|
|
67
|
+
" }",
|
|
68
|
+
" }",
|
|
69
|
+
" }",
|
|
70
|
+
" comments(first: 100) {",
|
|
71
|
+
" nodes {",
|
|
72
|
+
" id",
|
|
73
|
+
" body",
|
|
74
|
+
" author {",
|
|
75
|
+
" login",
|
|
76
|
+
" __typename",
|
|
77
|
+
" }",
|
|
78
|
+
" }",
|
|
79
|
+
" }",
|
|
80
|
+
" }",
|
|
81
|
+
" }",
|
|
82
|
+
"}",
|
|
83
|
+
].join("\n");
|
|
84
|
+
const parseError = buildParseError(USAGE);
|
|
85
|
+
function rejectRemovedFlag(token) {
|
|
86
|
+
throw parseError(
|
|
87
|
+
`${token} has been removed. Poll interval and timeout are centralized policy constants. Omit the flag.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
export function parseWatchCliArgs(argv) {
|
|
91
|
+
const args = [...argv];
|
|
92
|
+
const options = {
|
|
93
|
+
help: false,
|
|
94
|
+
repo: undefined,
|
|
95
|
+
pr: undefined,
|
|
96
|
+
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
97
|
+
timeoutMs: COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
98
|
+
};
|
|
99
|
+
while (args.length > 0) {
|
|
100
|
+
const token = args.shift();
|
|
101
|
+
if (token === "--help" || token === "-h") {
|
|
102
|
+
options.help = true;
|
|
103
|
+
return options;
|
|
104
|
+
}
|
|
105
|
+
if (REMOVED_FLAGS.has(token)) {
|
|
106
|
+
rejectRemovedFlag(token);
|
|
107
|
+
}
|
|
108
|
+
if (token === "--repo") {
|
|
109
|
+
options.repo = requireOptionValue(args, "--repo", parseError).trim();
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (token === "--pr") {
|
|
113
|
+
options.pr = parsePositiveInteger(requireOptionValue(args, "--pr", parseError), "--pr", parseError);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
117
|
+
}
|
|
118
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
119
|
+
throw parseError("Watching Copilot review requires both --repo <owner/name> and --pr <number>");
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
parseRepoSlug(options.repo);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
125
|
+
}
|
|
126
|
+
return options;
|
|
127
|
+
}
|
|
128
|
+
async function fetchGithubCopilotActivityPayload(
|
|
129
|
+
{ repo, pr },
|
|
130
|
+
{ env = process.env, ghCommand = "gh" } = {},
|
|
131
|
+
) {
|
|
132
|
+
const { owner, name } = parseRepoSlug(repo);
|
|
133
|
+
const result = await runChild(
|
|
134
|
+
ghCommand,
|
|
135
|
+
[
|
|
136
|
+
"api",
|
|
137
|
+
"graphql",
|
|
138
|
+
"--field",
|
|
139
|
+
`owner=${owner}`,
|
|
140
|
+
"--field",
|
|
141
|
+
`name=${name}`,
|
|
142
|
+
"--field",
|
|
143
|
+
`pr=${pr}`,
|
|
144
|
+
"--field",
|
|
145
|
+
`query=${COPILOT_ACTIVITY_QUERY}`,
|
|
146
|
+
],
|
|
147
|
+
env,
|
|
148
|
+
);
|
|
149
|
+
if (result.code !== 0) {
|
|
150
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
151
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
152
|
+
}
|
|
153
|
+
return parseJsonText(result.stdout);
|
|
154
|
+
}
|
|
155
|
+
function normalizeAuthorLogin(author) {
|
|
156
|
+
return typeof author?.login === "string" ? author.login : "";
|
|
157
|
+
}
|
|
158
|
+
function normalizeBody(body) {
|
|
159
|
+
return typeof body === "string" ? body.trim() : "";
|
|
160
|
+
}
|
|
161
|
+
function extractCopilotReviews(payload) {
|
|
162
|
+
const reviews = payload?.data?.repository?.pullRequest?.reviews?.nodes;
|
|
163
|
+
if (!Array.isArray(reviews)) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
return reviews
|
|
167
|
+
.filter((review) => isCopilotLogin(normalizeAuthorLogin(review?.author)))
|
|
168
|
+
.map((review) => ({
|
|
169
|
+
id: String(review?.id ?? ""),
|
|
170
|
+
authorLogin: normalizeAuthorLogin(review?.author),
|
|
171
|
+
body: normalizeBody(review?.body),
|
|
172
|
+
}))
|
|
173
|
+
.filter((review) => review.id.length > 0);
|
|
174
|
+
}
|
|
175
|
+
function extractCopilotIssueComments(payload) {
|
|
176
|
+
const comments = payload?.data?.repository?.pullRequest?.comments?.nodes;
|
|
177
|
+
if (!Array.isArray(comments)) {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
return comments
|
|
181
|
+
.filter((comment) => isCopilotLogin(normalizeAuthorLogin(comment?.author)))
|
|
182
|
+
.map((comment) => ({
|
|
183
|
+
id: String(comment?.id ?? ""),
|
|
184
|
+
authorLogin: normalizeAuthorLogin(comment?.author),
|
|
185
|
+
body: normalizeBody(comment?.body),
|
|
186
|
+
}))
|
|
187
|
+
.filter((comment) => comment.id.length > 0);
|
|
188
|
+
}
|
|
189
|
+
function parseCopilotActivity(payload) {
|
|
190
|
+
const parsedThreads = parseReviewThreads(payload);
|
|
191
|
+
const newComments = (parsedThreads?.comments ?? [])
|
|
192
|
+
.filter((comment) => isCopilotLogin(comment.author?.login))
|
|
193
|
+
.map((comment) => ({
|
|
194
|
+
id: comment.id,
|
|
195
|
+
threadId: comment.threadId,
|
|
196
|
+
authorLogin: comment.author?.login ?? "",
|
|
197
|
+
body: comment.body,
|
|
198
|
+
}));
|
|
199
|
+
return {
|
|
200
|
+
reviewThreadComments: newComments,
|
|
201
|
+
reviews: extractCopilotReviews(payload),
|
|
202
|
+
issueComments: extractCopilotIssueComments(payload),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
export function findFreshCopilotActivity(baseline, current) {
|
|
206
|
+
const baselineCommentIds = new Set((baseline?.reviewThreadComments ?? []).map((comment) => comment.id));
|
|
207
|
+
const baselineReviewIds = new Set((baseline?.reviews ?? []).map((review) => review.id));
|
|
208
|
+
const baselineIssueCommentIds = new Set((baseline?.issueComments ?? []).map((comment) => comment.id));
|
|
209
|
+
return {
|
|
210
|
+
newComments: (current?.reviewThreadComments ?? []).filter((comment) => !baselineCommentIds.has(comment.id)),
|
|
211
|
+
newReviews: (current?.reviews ?? []).filter((review) => !baselineReviewIds.has(review.id)),
|
|
212
|
+
newIssueComments: (current?.issueComments ?? []).filter((comment) => !baselineIssueCommentIds.has(comment.id)),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function buildNoChangePayload(status, repo, pr, attempts) {
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
status,
|
|
219
|
+
repo,
|
|
220
|
+
pr,
|
|
221
|
+
attempts,
|
|
222
|
+
newComments: [],
|
|
223
|
+
newReviews: [],
|
|
224
|
+
newIssueComments: [],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export async function watchCopilotReview(
|
|
228
|
+
options,
|
|
229
|
+
{
|
|
230
|
+
env = process.env,
|
|
231
|
+
ghCommand = "gh",
|
|
232
|
+
} = {},
|
|
233
|
+
) {
|
|
234
|
+
const baseline = parseCopilotActivity(await fetchGithubCopilotActivityPayload(
|
|
235
|
+
{ repo: options.repo, pr: options.pr },
|
|
236
|
+
{ env, ghCommand },
|
|
237
|
+
));
|
|
238
|
+
const attemptBudget = buildAttemptBudget(options.timeoutMs, options.pollIntervalMs);
|
|
239
|
+
const watchStartedAtMs = Date.now();
|
|
240
|
+
for (let attempt = 1; attempt <= attemptBudget; attempt += 1) {
|
|
241
|
+
if (!(options.timeoutMs === 0 && attempt === 1)) {
|
|
242
|
+
const pollDelayMs = buildPollDelayMs(
|
|
243
|
+
watchStartedAtMs,
|
|
244
|
+
options.timeoutMs,
|
|
245
|
+
options.pollIntervalMs,
|
|
246
|
+
attempt,
|
|
247
|
+
);
|
|
248
|
+
if (pollDelayMs > 0) {
|
|
249
|
+
let remainingMs = pollDelayMs;
|
|
250
|
+
while (remainingMs > 0) {
|
|
251
|
+
const chunkMs = Math.min(WATCH_HEARTBEAT_MS, remainingMs);
|
|
252
|
+
await delay(chunkMs);
|
|
253
|
+
remainingMs -= chunkMs;
|
|
254
|
+
if (remainingMs > 0) {
|
|
255
|
+
const nowMs = Date.now();
|
|
256
|
+
process.stderr.write(
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
ok: true,
|
|
259
|
+
type: "watch_heartbeat",
|
|
260
|
+
elapsedMs: nowMs - watchStartedAtMs,
|
|
261
|
+
totalBudgetMs: options.timeoutMs,
|
|
262
|
+
poll: attempt,
|
|
263
|
+
maxPolls: attemptBudget,
|
|
264
|
+
}) + "\n",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const current = parseCopilotActivity(await fetchGithubCopilotActivityPayload(
|
|
271
|
+
{ repo: options.repo, pr: options.pr },
|
|
272
|
+
{ env, ghCommand },
|
|
273
|
+
));
|
|
274
|
+
const activity = findFreshCopilotActivity(baseline, current);
|
|
275
|
+
if (activity.newComments.length > 0 || activity.newReviews.length > 0 || activity.newIssueComments.length > 0) {
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
status: "changed",
|
|
279
|
+
repo: options.repo,
|
|
280
|
+
pr: options.pr,
|
|
281
|
+
attempts: attempt,
|
|
282
|
+
...activity,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const status = options.timeoutMs === 0 ? "idle" : "timeout";
|
|
287
|
+
return buildNoChangePayload(status, options.repo, options.pr, attemptBudget);
|
|
288
|
+
}
|
|
289
|
+
export function buildAttemptBudget(timeoutMs, pollIntervalMs) {
|
|
290
|
+
if (timeoutMs === 0) {
|
|
291
|
+
return 1;
|
|
292
|
+
}
|
|
293
|
+
return Math.max(1, Math.ceil(timeoutMs / pollIntervalMs));
|
|
294
|
+
}
|
|
295
|
+
export function buildPollDelayMs(watchStartedAtMs, timeoutMs, pollIntervalMs, attempt, nowMs = Date.now()) {
|
|
296
|
+
if (timeoutMs === 0) {
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
const scheduledAtMs = watchStartedAtMs + Math.min(timeoutMs, attempt * pollIntervalMs);
|
|
300
|
+
return Math.max(0, scheduledAtMs - nowMs);
|
|
301
|
+
}
|
|
302
|
+
export async function runCli(
|
|
303
|
+
argv = process.argv.slice(2),
|
|
304
|
+
{
|
|
305
|
+
stdout = process.stdout,
|
|
306
|
+
env = process.env,
|
|
307
|
+
ghCommand = "gh",
|
|
308
|
+
} = {},
|
|
309
|
+
) {
|
|
310
|
+
const options = parseWatchCliArgs(argv);
|
|
311
|
+
if (options.help) {
|
|
312
|
+
stdout.write(`${USAGE}\n`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const result = await watchCopilotReview(options, { env, ghCommand });
|
|
316
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
317
|
+
}
|
|
318
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
319
|
+
runCli().catch((error) => {
|
|
320
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
321
|
+
process.exitCode = 1;
|
|
322
|
+
});
|
|
323
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, summarizeGateReviewComments, summarizeGateReviewCommentMarkers } from "../_core-helpers.mjs";
|
|
3
|
+
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
|
+
import { loadDevLoopConfig, resolveGateConfig } from "@dev-loops/core/config";
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage: ready-for-review.mjs --repo <owner/name> --pr <number>\nWrapper around gh pr ready that enforces gate-evidence validation.`;
|
|
8
|
+
const parseError = buildParseError(USAGE);
|
|
9
|
+
const PR_VIEW_QUERY = `query($owner:String!, $name:String!, $number:Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$number) { id, isDraft, headRefOid, state, mergeStateStatus } } }`;
|
|
10
|
+
|
|
11
|
+
export function parseReadyForReviewCliArgs(argv) {
|
|
12
|
+
const args = [...argv], opts = { help: false, repo: undefined, pr: undefined };
|
|
13
|
+
while (args.length > 0) {
|
|
14
|
+
const token = args.shift();
|
|
15
|
+
if (token === "--help" || token === "-h") { opts.help = true; return opts; }
|
|
16
|
+
if (token === "--repo") { opts.repo = requireOptionValue(args, "--repo", parseError).trim(); continue; }
|
|
17
|
+
if (token === "--pr") { opts.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError); continue; }
|
|
18
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
19
|
+
}
|
|
20
|
+
if (!opts.repo || opts.pr === undefined) throw parseError("ready-for-review requires --repo and --pr");
|
|
21
|
+
parseRepoSlug(opts.repo);
|
|
22
|
+
return opts;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runGhJson(args, { env, ghCommand }) {
|
|
26
|
+
const result = await runChild(ghCommand, args, env);
|
|
27
|
+
if (result.code !== 0) throw new Error(`gh command failed: ${result.stderr.trim() || `exit code ${result.code}`}`);
|
|
28
|
+
return parseJsonText(result.stdout);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function fetchPrState({ repo, pr }, { env, ghCommand }) {
|
|
32
|
+
const [owner, name] = repo.split("/");
|
|
33
|
+
const r = await runGhJson(["api", "graphql", "-f", `query=${PR_VIEW_QUERY}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${pr}`], { env, ghCommand });
|
|
34
|
+
const d = r?.data?.repository?.pullRequest;
|
|
35
|
+
if (!d) throw new Error(`Could not fetch PR #${pr}`);
|
|
36
|
+
return { id: d.id, isDraft: d.isDraft === true, headRefOid: typeof d.headRefOid === "string" ? d.headRefOid.trim() : null, state: typeof d.state === "string" ? d.state.trim() : null, mergeStateStatus: typeof d.mergeStateStatus === "string" ? d.mergeStateStatus.trim() : null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function fetchCiStatus({ repo, pr }, { env, ghCommand }) {
|
|
40
|
+
const result = await runChild(ghCommand, ["pr", "checks", String(pr), "--repo", repo, "--json", "bucket,state,name,workflow"], env);
|
|
41
|
+
if (result.code !== 0 && result.code !== 1 && result.code !== 8) throw new Error(`gh pr checks failed`);
|
|
42
|
+
const stdout = result.stdout.trim();
|
|
43
|
+
if (!stdout) return { status: "none" };
|
|
44
|
+
const payload = parseJsonText(stdout);
|
|
45
|
+
if (!Array.isArray(payload)) return { status: "none" };
|
|
46
|
+
const buck = (c = {}) => { const b = typeof c?.bucket === "string" ? c.bucket.trim().toLowerCase() : ""; if (b) return b; const s = typeof c?.state === "string" ? c.state.trim().toLowerCase() : ""; if (["success","passed","pass"].includes(s)) return "pass"; if (["skipped","skipping"].includes(s)) return "skipping"; if (["pending","queued","in_progress","waiting"].includes(s)) return "pending"; if (["failure","failed","fail","error","timed_out","startup_failure"].includes(s)) return "fail"; if (["cancel","cancelled"].includes(s)) return "cancel"; return s||"unknown"; };
|
|
47
|
+
const checks = payload.map(c => ({ bucket: buck(c) }));
|
|
48
|
+
const blocking = checks.filter(c => !["pass","skipping"].includes(c.bucket));
|
|
49
|
+
return { status: blocking.length === 0 ? "success" : "blocked", blockingSummary: blocking.length > 0 ? `Blocking: ${blocking.map(c=>c.bucket).join(", ")}` : null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchGateEvidence({ repo, pr, headSha }, { env, ghCommand }) {
|
|
53
|
+
const r = await runChild(ghCommand, ["api", "--paginate", "--slurp", `repos/${repo}/issues/${pr}/comments?per_page=100`], env);
|
|
54
|
+
if (r.code !== 0) throw new Error(`Failed to fetch PR comments`);
|
|
55
|
+
const raw = parseJsonText(r.stdout), comments = Array.isArray(raw) ? (raw.every(e=>Array.isArray(e)) ? raw.flat() : raw) : [];
|
|
56
|
+
const cs = summarizeGateReviewComments(comments), ms = summarizeGateReviewCommentMarkers(comments, { headSha });
|
|
57
|
+
const dg = cs.draft_gate ? { ...cs.draft_gate, visible: true } : { visible: false };
|
|
58
|
+
const dm = ms.draft_gate ? { ...ms.draft_gate, visible: true, contractComplete: ms.draft_gate.contractComplete === true } : { visible: false, contractComplete: false };
|
|
59
|
+
const mh = dm.headSha && headSha && headSha.startsWith(dm.headSha);
|
|
60
|
+
const chc = dm.visible && mh && dm.verdict === "clean" && dm.contractComplete;
|
|
61
|
+
const cee = dg.visible && dg.verdict === "clean" && dg.headSha;
|
|
62
|
+
const cphm = !chc && dg.headSha && headSha && headSha.startsWith(dg.headSha) && dg.verdict === "clean";
|
|
63
|
+
return { draftGate: dg, draftGateMarker: dm, currentHeadClean: chc, cleanEvidenceExists: cee, effectiveHeadClean: chc || cphm };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readyForReview(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
|
|
67
|
+
const { config } = await loadDevLoopConfig({ repoRoot });
|
|
68
|
+
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
69
|
+
const requireCi = draftGateConfig?.requireCi !== false;
|
|
70
|
+
const prState = await fetchPrState({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
71
|
+
const headSha = prState.headRefOid;
|
|
72
|
+
if (!headSha) throw new Error(`Could not resolve head SHA`);
|
|
73
|
+
if (!prState.isDraft) throw new Error(`PR #${options.pr} is not in draft state`);
|
|
74
|
+
if (requireCi) { const ci = await fetchCiStatus({ repo: options.repo, pr: options.pr }, { env, ghCommand }); if (ci.status === "blocked") throw new Error(`PR #${options.pr} has blocking CI checks`); if (ci.status !== "success") throw new Error(`PR #${options.pr} CI is not green`); }
|
|
75
|
+
const gate = await fetchGateEvidence({ repo: options.repo, pr: options.pr, headSha }, { env, ghCommand });
|
|
76
|
+
if (!gate.cleanEvidenceExists && !gate.effectiveHeadClean) throw new Error(`No visible clean draft_gate evidence on ${headSha.slice(0,7)}`);
|
|
77
|
+
if (!gate.effectiveHeadClean) { const mv = gate.draftGateMarker?.visible; const mh = gate.draftGateMarker?.headSha; throw new Error(mv && mh ? `PR #${options.pr} draft_gate marker does not match current head ${headSha.slice(0,7)}. Re-run draft gate.` : `PR #${options.pr} draft_gate marker is missing or incomplete on current head ${headSha.slice(0,7)}. Re-run draft gate.`); }
|
|
78
|
+
const readyResult = await runChild(ghCommand, ["pr", "ready", String(options.pr), "--repo", options.repo], env);
|
|
79
|
+
if (readyResult.code !== 0) throw new Error(`gh pr ready failed`);
|
|
80
|
+
return { ok: true, action: "marked_ready", repo: options.repo, pr: options.pr, headSha, draftGateSatisfied: gate.effectiveHeadClean };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function main(argv = process.argv.slice(2), runtime = {}) {
|
|
84
|
+
const options = parseReadyForReviewCliArgs(argv);
|
|
85
|
+
if (options.help) { process.stdout.write(`${USAGE}\n`); return 0; }
|
|
86
|
+
const result = await readyForReview(options, runtime);
|
|
87
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
92
|
+
main().then(c => { process.exitCode = c; }).catch(e => { process.stderr.write(`${formatCliError(e, { usage: USAGE })}\n`); process.exitCode = 1; });
|
|
93
|
+
}
|