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,551 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
buildParseError,
|
|
4
|
+
formatCliError,
|
|
5
|
+
isCopilotLogin,
|
|
6
|
+
isDirectCliRun,
|
|
7
|
+
parseReviewThreads,
|
|
8
|
+
summarizeCopilotReviews,
|
|
9
|
+
} from "../_core-helpers.mjs";
|
|
10
|
+
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
11
|
+
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
12
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
13
|
+
import { buildSnapshotFromPrFacts, interpretLoopState } from "@dev-loops/core/loop/copilot-loop-state";
|
|
14
|
+
import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
|
|
15
|
+
const BLOCKED_BY_COPILOT_COMMENT_STATUS = "blocked_by_copilot_comment";
|
|
16
|
+
const SUPPRESSED_SAME_HEAD_CLEAN_STATUS = "suppressed_same_head_clean";
|
|
17
|
+
const ROUND_CAP_REACHED_STATUS = "round_cap_reached";
|
|
18
|
+
const NO_CHANGES_SINCE_LAST_REVIEW_STATUS = "no_changes_since_last_review";
|
|
19
|
+
const SUPPRESSED_DRAFT_STATUS = "suppressed_draft";
|
|
20
|
+
const USAGE = `Usage: request-copilot-review.mjs --repo <owner/name> --pr <number>
|
|
21
|
+
Request Copilot as a reviewer on a GitHub pull request.
|
|
22
|
+
Required:
|
|
23
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
24
|
+
--pr <number> Pull request number
|
|
25
|
+
Optional:
|
|
26
|
+
--force-rerequest-review Bypass the round cap when new commits exist since
|
|
27
|
+
the last Copilot review. Refused when the PR head
|
|
28
|
+
has not changed since the last review.
|
|
29
|
+
Debug:
|
|
30
|
+
PI_DEV_LOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
|
|
31
|
+
convergence detection falls back to unsuppressed behavior
|
|
32
|
+
Output (stdout, JSON):
|
|
33
|
+
{ "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",
|
|
34
|
+
"repo": "...", "pr": N, "reviewer": "Copilot", "detail"?: "...",
|
|
35
|
+
"sameHeadCleanConverged"?: true, "violationCommentIds"?: [N], "completedRounds"?: N, "maxRounds"?: N }
|
|
36
|
+
Request statuses:
|
|
37
|
+
requested Copilot review was successfully requested
|
|
38
|
+
already-requested Copilot review was already observably in progress; no new request needed
|
|
39
|
+
unavailable Copilot review is not enabled/requestable and no in-progress evidence was found
|
|
40
|
+
suppressed_same_head_clean Current head is already clean-converged; no new request is made
|
|
41
|
+
blocked_by_copilot_comment A non-Copilot PR comment contains @copilot or /copilot; delete the comment(s) first
|
|
42
|
+
round_cap_reached Maximum Copilot review rounds reached; no further re-requests will be made
|
|
43
|
+
no_changes_since_last_review --force-rerequest-review used but PR head has not changed since the last review
|
|
44
|
+
suppressed_draft PR is in draft state; review requests are blocked until the PR is marked ready for review
|
|
45
|
+
Error output (stderr, JSON):
|
|
46
|
+
Argument/usage errors:
|
|
47
|
+
{ "ok": false, "error": "...", "usage": "..." }
|
|
48
|
+
gh/runtime failures:
|
|
49
|
+
{ "ok": false, "error": "..." }
|
|
50
|
+
Exit codes:
|
|
51
|
+
0 Success (including unavailable)
|
|
52
|
+
1 Argument error or gh failure`.trim();
|
|
53
|
+
const parseError = buildParseError(USAGE);
|
|
54
|
+
export function parseRequestCliArgs(argv) {
|
|
55
|
+
const args = [...argv];
|
|
56
|
+
const options = {
|
|
57
|
+
help: false,
|
|
58
|
+
repo: undefined,
|
|
59
|
+
pr: undefined,
|
|
60
|
+
forceRerequestReview: false,
|
|
61
|
+
};
|
|
62
|
+
while (args.length > 0) {
|
|
63
|
+
const token = args.shift();
|
|
64
|
+
if (token === "--help" || token === "-h") {
|
|
65
|
+
options.help = true;
|
|
66
|
+
return options;
|
|
67
|
+
}
|
|
68
|
+
if (token === "--force-rerequest-review") {
|
|
69
|
+
options.forceRerequestReview = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (token === "--repo") {
|
|
73
|
+
options.repo = requireOptionValue(args, "--repo", parseError).trim();
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (token === "--pr") {
|
|
77
|
+
options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
81
|
+
}
|
|
82
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
83
|
+
throw parseError("Requesting Copilot review requires both --repo <owner/name> and --pr <number>");
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
parseRepoSlug(options.repo);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
89
|
+
}
|
|
90
|
+
return options;
|
|
91
|
+
}
|
|
92
|
+
function parseRequestedReviewersPayload(text) {
|
|
93
|
+
let payload;
|
|
94
|
+
try {
|
|
95
|
+
payload = JSON.parse(text);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new Error(`Invalid JSON from gh: ${text.trim() || "<empty>"}`);
|
|
98
|
+
}
|
|
99
|
+
const users = Array.isArray(payload?.users) ? payload.users : [];
|
|
100
|
+
const teams = Array.isArray(payload?.teams) ? payload.teams : [];
|
|
101
|
+
return {
|
|
102
|
+
users,
|
|
103
|
+
teams,
|
|
104
|
+
requested: users.some((user) => isCopilotLogin(user?.login)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function parseReviewsPayload(text) {
|
|
108
|
+
let payload;
|
|
109
|
+
try {
|
|
110
|
+
payload = JSON.parse(text);
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error(`Invalid JSON from gh: ${text.trim() || "<empty>"}`);
|
|
113
|
+
}
|
|
114
|
+
const headSha = typeof payload?.headRefOid === "string" && payload.headRefOid.trim().length > 0
|
|
115
|
+
? payload.headRefOid.trim()
|
|
116
|
+
: null;
|
|
117
|
+
const reviewSummary = summarizeCopilotReviews(payload?.reviews, { headSha });
|
|
118
|
+
return {
|
|
119
|
+
prData: payload,
|
|
120
|
+
headSha,
|
|
121
|
+
copilotReviewIds: reviewSummary.copilotReviewIds,
|
|
122
|
+
copilotReviewPresent: reviewSummary.copilotReviewPresent,
|
|
123
|
+
hasCopilotPendingReviewOnCurrentHead: reviewSummary.hasPendingReviewOnCurrentHead,
|
|
124
|
+
hasCopilotSubmittedReviewOnCurrentHead: reviewSummary.hasSubmittedReviewOnCurrentHead,
|
|
125
|
+
completedCopilotReviewRounds: reviewSummary.completedCopilotReviewRounds,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
129
|
+
const result = await runChild(
|
|
130
|
+
ghCommand,
|
|
131
|
+
["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
|
|
132
|
+
env,
|
|
133
|
+
);
|
|
134
|
+
if (result.code !== 0) {
|
|
135
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
136
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
137
|
+
}
|
|
138
|
+
return parseRequestedReviewersPayload(result.stdout);
|
|
139
|
+
}
|
|
140
|
+
async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
141
|
+
const result = await runChild(
|
|
142
|
+
ghCommand,
|
|
143
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,isDraft,state,number,reviews,statusCheckRollup"],
|
|
144
|
+
env,
|
|
145
|
+
);
|
|
146
|
+
if (result.code !== 0) {
|
|
147
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
148
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
149
|
+
}
|
|
150
|
+
return parseReviewsPayload(result.stdout);
|
|
151
|
+
}
|
|
152
|
+
async function fetchCopilotReviewState(options, runtime) {
|
|
153
|
+
const requestedReviewers = await fetchRequestedReviewers(options, runtime);
|
|
154
|
+
const reviews = await fetchCopilotReviewIds(options, runtime);
|
|
155
|
+
return {
|
|
156
|
+
requested: requestedReviewers.requested,
|
|
157
|
+
prData: reviews.prData,
|
|
158
|
+
copilotReviewIds: reviews.copilotReviewIds,
|
|
159
|
+
copilotReviewPresent: reviews.copilotReviewPresent,
|
|
160
|
+
hasPendingReviewOnCurrentHead: reviews.hasCopilotPendingReviewOnCurrentHead,
|
|
161
|
+
hasSubmittedReviewOnCurrentHead: reviews.hasCopilotSubmittedReviewOnCurrentHead,
|
|
162
|
+
completedCopilotReviewRounds: reviews.completedCopilotReviewRounds,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async function detectSameHeadCleanConvergence(options, runtime, priorReviewState = {}) {
|
|
166
|
+
const {
|
|
167
|
+
requested = false,
|
|
168
|
+
prData = null,
|
|
169
|
+
copilotReviewPresent = false,
|
|
170
|
+
hasPendingReviewOnCurrentHead = false,
|
|
171
|
+
hasSubmittedReviewOnCurrentHead = false,
|
|
172
|
+
} = priorReviewState;
|
|
173
|
+
if (typeof options.sameHeadCleanConverged === "boolean") {
|
|
174
|
+
return options.sameHeadCleanConverged;
|
|
175
|
+
}
|
|
176
|
+
if (hasPendingReviewOnCurrentHead || !hasSubmittedReviewOnCurrentHead || prData === null) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const threadsPayload = await fetchGithubReviewThreadsPayload(
|
|
181
|
+
{ repo: options.repo, pr: options.pr },
|
|
182
|
+
runtime,
|
|
183
|
+
);
|
|
184
|
+
const parsedThreads = parseReviewThreads(threadsPayload);
|
|
185
|
+
const snapshot = buildSnapshotFromPrFacts({
|
|
186
|
+
prData,
|
|
187
|
+
prNumber: options.pr,
|
|
188
|
+
copilotReviewRequestStatus: hasPendingReviewOnCurrentHead || requested ? "requested" : "none",
|
|
189
|
+
copilotReviewPresent,
|
|
190
|
+
copilotReviewOnCurrentHead: hasSubmittedReviewOnCurrentHead,
|
|
191
|
+
unresolvedThreadCount: parsedThreads.summary.unresolvedThreads,
|
|
192
|
+
actionableThreadCount: parsedThreads.summary.actionableThreads,
|
|
193
|
+
copilotReviewRoundCount: priorReviewState.completedCopilotReviewRounds ?? 0,
|
|
194
|
+
});
|
|
195
|
+
const interpretation = interpretLoopState(snapshot);
|
|
196
|
+
return interpretation.sameHeadCleanConverged;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (runtime?.env?.PI_DEV_LOOPS_DEBUG === "1") {
|
|
199
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
200
|
+
process.stderr.write(`[request-copilot-review] same-head clean-convergence detection unavailable: ${detail}\n`);
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function detectRoundCapAutoRerequestEligibility(options, runtime, priorReviewState = {}, refinementConfig = {}) {
|
|
206
|
+
const {
|
|
207
|
+
requested = false,
|
|
208
|
+
prData = null,
|
|
209
|
+
copilotReviewPresent = false,
|
|
210
|
+
hasPendingReviewOnCurrentHead = false,
|
|
211
|
+
hasSubmittedReviewOnCurrentHead = false,
|
|
212
|
+
} = priorReviewState;
|
|
213
|
+
if (prData === null) {
|
|
214
|
+
return { eligible: false, interpretation: null };
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const threadsPayload = await fetchGithubReviewThreadsPayload(
|
|
218
|
+
{ repo: options.repo, pr: options.pr },
|
|
219
|
+
runtime,
|
|
220
|
+
);
|
|
221
|
+
const parsedThreads = parseReviewThreads(threadsPayload);
|
|
222
|
+
const snapshot = buildSnapshotFromPrFacts({
|
|
223
|
+
prData,
|
|
224
|
+
prNumber: options.pr,
|
|
225
|
+
copilotReviewRequestStatus: hasPendingReviewOnCurrentHead || requested ? "requested" : "none",
|
|
226
|
+
copilotReviewPresent,
|
|
227
|
+
copilotReviewOnCurrentHead: hasSubmittedReviewOnCurrentHead,
|
|
228
|
+
unresolvedThreadCount: parsedThreads.summary.unresolvedThreads,
|
|
229
|
+
actionableThreadCount: parsedThreads.summary.actionableThreads,
|
|
230
|
+
copilotReviewRoundCount: priorReviewState.completedCopilotReviewRounds ?? 0,
|
|
231
|
+
});
|
|
232
|
+
const interpretation = interpretLoopState(snapshot, refinementConfig);
|
|
233
|
+
return {
|
|
234
|
+
eligible: interpretation.state === "ready_to_rerequest_review" && interpretation.autoRerequestEligible === true,
|
|
235
|
+
interpretation,
|
|
236
|
+
};
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (runtime?.env?.PI_DEV_LOOPS_DEBUG === "1") {
|
|
239
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
240
|
+
process.stderr.write(`[request-copilot-review] round-cap auto-rerequest detection unavailable: ${detail}\n`);
|
|
241
|
+
}
|
|
242
|
+
return { eligible: false, interpretation: null };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function getLastCopilotReviewHeadSha(prData) {
|
|
246
|
+
const reviews = Array.isArray(prData?.reviews) ? prData.reviews : [];
|
|
247
|
+
// Only consider submitted (non-PENDING) Copilot reviews.
|
|
248
|
+
// A PENDING review on a stale head could be selected as "most recent"
|
|
249
|
+
// and cause incorrect round-cap bypass decisions.
|
|
250
|
+
const copilotReviews = reviews.filter(
|
|
251
|
+
(r) => r?.state !== "PENDING" && isCopilotLogin(r?.author?.login),
|
|
252
|
+
);
|
|
253
|
+
if (copilotReviews.length === 0) return null;
|
|
254
|
+
// Select the most recent Copilot review: sort by submittedAt descending,
|
|
255
|
+
// falling back to original array position when timestamps are missing
|
|
256
|
+
// (later index = more recent).
|
|
257
|
+
const indexed = copilotReviews.map((r, i) => ({ review: r, index: i }));
|
|
258
|
+
indexed.sort((a, b) => {
|
|
259
|
+
const parseTs = (r) => {
|
|
260
|
+
if (typeof r?.submittedAt === "string") {
|
|
261
|
+
const v = Date.parse(r.submittedAt);
|
|
262
|
+
if (!Number.isNaN(v)) return v;
|
|
263
|
+
}
|
|
264
|
+
if (typeof r?.submitted_at === "string") {
|
|
265
|
+
const v = Date.parse(r.submitted_at);
|
|
266
|
+
if (!Number.isNaN(v)) return v;
|
|
267
|
+
}
|
|
268
|
+
return NaN;
|
|
269
|
+
};
|
|
270
|
+
const aTs = parseTs(a.review);
|
|
271
|
+
const bTs = parseTs(b.review);
|
|
272
|
+
if (!Number.isNaN(aTs) && !Number.isNaN(bTs)) return bTs - aTs;
|
|
273
|
+
if (Number.isNaN(aTs) && Number.isNaN(bTs)) return b.index - a.index;
|
|
274
|
+
return Number.isNaN(aTs) ? 1 : -1;
|
|
275
|
+
});
|
|
276
|
+
const lastReview = indexed[0].review;
|
|
277
|
+
// Tolerate both GraphQL commit.oid and REST commit_id shapes
|
|
278
|
+
const sha = lastReview?.commit?.oid ?? lastReview?.commit_id;
|
|
279
|
+
return typeof sha === "string" && sha.trim().length > 0 ? sha.trim() : null;
|
|
280
|
+
}
|
|
281
|
+
function classifyRequestFailure(detail) {
|
|
282
|
+
const normalized = detail.toLowerCase();
|
|
283
|
+
if (
|
|
284
|
+
normalized.includes("not a collaborator") ||
|
|
285
|
+
normalized.includes("not requestable") ||
|
|
286
|
+
normalized.includes("copilot review") ||
|
|
287
|
+
normalized.includes("reviews may only be requested")
|
|
288
|
+
) {
|
|
289
|
+
return "unavailable";
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
294
|
+
const result = await runChild(
|
|
295
|
+
ghCommand,
|
|
296
|
+
["pr", "edit", String(pr), "--repo", repo, "--add-reviewer", "@copilot"],
|
|
297
|
+
env,
|
|
298
|
+
);
|
|
299
|
+
if (result.code !== 0) {
|
|
300
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
301
|
+
const classified = classifyRequestFailure(detail);
|
|
302
|
+
if (classified === "unavailable") {
|
|
303
|
+
let existing;
|
|
304
|
+
try {
|
|
305
|
+
existing = await fetchCopilotReviewIds({ repo, pr }, { env, ghCommand });
|
|
306
|
+
} catch {
|
|
307
|
+
// Best-effort: if gh pr view fails transiently (rate limit, network, auth),
|
|
308
|
+
// return unavailable rather than throwing — the 422 failure is already stable.
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
status: "unavailable",
|
|
312
|
+
repo,
|
|
313
|
+
pr,
|
|
314
|
+
reviewer: "Copilot",
|
|
315
|
+
detail,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (existing.hasCopilotPendingReviewOnCurrentHead || existing.hasCopilotSubmittedReviewOnCurrentHead) {
|
|
319
|
+
return {
|
|
320
|
+
ok: true,
|
|
321
|
+
status: "already-requested",
|
|
322
|
+
repo,
|
|
323
|
+
pr,
|
|
324
|
+
reviewer: "Copilot",
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
status: "unavailable",
|
|
330
|
+
repo,
|
|
331
|
+
pr,
|
|
332
|
+
reviewer: "Copilot",
|
|
333
|
+
detail,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
status: "requested",
|
|
341
|
+
repo,
|
|
342
|
+
pr,
|
|
343
|
+
reviewer: "Copilot",
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
export async function checkForCopilotComments({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
347
|
+
const result = await runChild(
|
|
348
|
+
ghCommand,
|
|
349
|
+
["api", `repos/${repo}/issues/${pr}/comments`, "--paginate", "--jq", ".[]"],
|
|
350
|
+
env,
|
|
351
|
+
);
|
|
352
|
+
if (result.code !== 0) {
|
|
353
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
354
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
355
|
+
}
|
|
356
|
+
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
|
357
|
+
let comments;
|
|
358
|
+
try {
|
|
359
|
+
comments = lines.map((line) => JSON.parse(line));
|
|
360
|
+
} catch (e) {
|
|
361
|
+
throw new Error(`Invalid JSON from gh: ${e.message} (${result.stdout.trim().slice(0, 200) || "<empty>"})`);
|
|
362
|
+
}
|
|
363
|
+
if (!Array.isArray(comments)) {
|
|
364
|
+
return { blocked: false, violationCommentIds: [] };
|
|
365
|
+
}
|
|
366
|
+
const violationCommentIds = [];
|
|
367
|
+
for (const comment of comments) {
|
|
368
|
+
const author = comment?.user?.login ?? "";
|
|
369
|
+
const body = comment?.body ?? "";
|
|
370
|
+
if (isCopilotLogin(author)) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (/(?:^|\W)(@copilot|\/copilot)(?:$|\W)/i.test(body)) {
|
|
374
|
+
violationCommentIds.push(comment.id);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
blocked: violationCommentIds.length > 0,
|
|
379
|
+
violationCommentIds,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
export async function performCopilotReviewRequest(options, { env = process.env, ghCommand = "gh" } = {}) {
|
|
383
|
+
const before = await fetchCopilotReviewState(options, { env, ghCommand });
|
|
384
|
+
if (before.prData?.isDraft) {
|
|
385
|
+
return {
|
|
386
|
+
ok: true,
|
|
387
|
+
status: SUPPRESSED_DRAFT_STATUS,
|
|
388
|
+
repo: options.repo,
|
|
389
|
+
pr: options.pr,
|
|
390
|
+
reviewer: "Copilot",
|
|
391
|
+
detail: "PR is in draft state; review requests are blocked until the PR is marked ready for review.",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (!env.GH_SEQUENCE_PATH) {
|
|
395
|
+
const copilotCommentCheck = await checkForCopilotComments(options, { env, ghCommand });
|
|
396
|
+
if (copilotCommentCheck.blocked) {
|
|
397
|
+
return {
|
|
398
|
+
ok: true,
|
|
399
|
+
status: BLOCKED_BY_COPILOT_COMMENT_STATUS,
|
|
400
|
+
repo: options.repo,
|
|
401
|
+
pr: options.pr,
|
|
402
|
+
reviewer: "Copilot",
|
|
403
|
+
detail: "Non-Copilot PR comment(s) detected containing @copilot or /copilot. Delete the violating comment(s) and re-run this helper instead.",
|
|
404
|
+
violationCommentIds: copilotCommentCheck.violationCommentIds,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
let refinementConfig = { maxCopilotRounds: 5 };
|
|
409
|
+
let maxRounds = 5; // Built-in default; overridden by config when loadable
|
|
410
|
+
try {
|
|
411
|
+
const { config, errors } = await loadDevLoopConfig();
|
|
412
|
+
if (!errors || errors.length === 0) {
|
|
413
|
+
refinementConfig = resolveRefinement(config);
|
|
414
|
+
if (Number.isFinite(refinementConfig.maxCopilotRounds) && refinementConfig.maxCopilotRounds > 0) {
|
|
415
|
+
maxRounds = refinementConfig.maxCopilotRounds;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} catch {
|
|
419
|
+
}
|
|
420
|
+
if ((before.completedCopilotReviewRounds ?? 0) >= maxRounds
|
|
421
|
+
&& !before.requested
|
|
422
|
+
&& !before.hasPendingReviewOnCurrentHead) {
|
|
423
|
+
if (!options.forceRerequestReview) {
|
|
424
|
+
const roundCapAutoRerequest = await detectRoundCapAutoRerequestEligibility(
|
|
425
|
+
options,
|
|
426
|
+
{ env, ghCommand },
|
|
427
|
+
before,
|
|
428
|
+
refinementConfig,
|
|
429
|
+
);
|
|
430
|
+
if (!roundCapAutoRerequest.eligible) {
|
|
431
|
+
return {
|
|
432
|
+
ok: true,
|
|
433
|
+
status: ROUND_CAP_REACHED_STATUS,
|
|
434
|
+
repo: options.repo,
|
|
435
|
+
pr: options.pr,
|
|
436
|
+
reviewer: "Copilot",
|
|
437
|
+
completedRounds: before.completedCopilotReviewRounds,
|
|
438
|
+
maxRounds,
|
|
439
|
+
detail: `Round cap of ${maxRounds} reached with ${before.completedCopilotReviewRounds} completed rounds. No further re-requests will be made.`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// --force-rerequest-review: only bypass when there are new commits since the last review
|
|
444
|
+
const currentHeadSha = typeof before.prData?.headRefOid === "string" && before.prData.headRefOid.trim().length > 0
|
|
445
|
+
? before.prData.headRefOid.trim()
|
|
446
|
+
: null;
|
|
447
|
+
const lastReviewSha = getLastCopilotReviewHeadSha(before.prData);
|
|
448
|
+
const canCompare = currentHeadSha !== null && lastReviewSha !== null;
|
|
449
|
+
const hasNewCommits = canCompare && currentHeadSha !== lastReviewSha;
|
|
450
|
+
if (!canCompare) {
|
|
451
|
+
return {
|
|
452
|
+
ok: true,
|
|
453
|
+
status: ROUND_CAP_REACHED_STATUS,
|
|
454
|
+
repo: options.repo,
|
|
455
|
+
pr: options.pr,
|
|
456
|
+
reviewer: "Copilot",
|
|
457
|
+
detail: `Round cap of ${maxRounds} reached with ${before.completedCopilotReviewRounds} completed rounds. --force-rerequest-review was supplied but commit SHA data is unavailable, so change-since-last-review could not be evaluated.`,
|
|
458
|
+
completedRounds: before.completedCopilotReviewRounds,
|
|
459
|
+
maxRounds,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (!hasNewCommits) {
|
|
463
|
+
return {
|
|
464
|
+
ok: true,
|
|
465
|
+
status: NO_CHANGES_SINCE_LAST_REVIEW_STATUS,
|
|
466
|
+
repo: options.repo,
|
|
467
|
+
pr: options.pr,
|
|
468
|
+
reviewer: "Copilot",
|
|
469
|
+
detail: "No changes since last Copilot review. --force-rerequest-review requires new commits on the PR head.",
|
|
470
|
+
completedRounds: before.completedCopilotReviewRounds,
|
|
471
|
+
maxRounds,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
// Has new commits — bypass the round cap and proceed with the request
|
|
475
|
+
}
|
|
476
|
+
const sameHeadCleanConverged = await detectSameHeadCleanConvergence(
|
|
477
|
+
options,
|
|
478
|
+
{ env, ghCommand },
|
|
479
|
+
before,
|
|
480
|
+
);
|
|
481
|
+
if (sameHeadCleanConverged) {
|
|
482
|
+
return {
|
|
483
|
+
ok: true,
|
|
484
|
+
status: SUPPRESSED_SAME_HEAD_CLEAN_STATUS,
|
|
485
|
+
repo: options.repo,
|
|
486
|
+
pr: options.pr,
|
|
487
|
+
reviewer: "Copilot",
|
|
488
|
+
sameHeadCleanConverged: true,
|
|
489
|
+
detail: "Current head already has a clean submitted Copilot review; same-head clean-convergence suppression is always enforced.",
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (before.requested || before.hasPendingReviewOnCurrentHead) {
|
|
493
|
+
return {
|
|
494
|
+
ok: true,
|
|
495
|
+
status: "already-requested",
|
|
496
|
+
repo: options.repo,
|
|
497
|
+
pr: options.pr,
|
|
498
|
+
reviewer: "Copilot",
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const requestResult = await requestCopilotReview(options, { env, ghCommand });
|
|
502
|
+
if (requestResult.status === "unavailable") {
|
|
503
|
+
const after = await fetchCopilotReviewState(options, { env, ghCommand });
|
|
504
|
+
if (after.requested || after.hasPendingReviewOnCurrentHead || after.hasSubmittedReviewOnCurrentHead) {
|
|
505
|
+
return {
|
|
506
|
+
ok: true,
|
|
507
|
+
status: "already-requested",
|
|
508
|
+
repo: options.repo,
|
|
509
|
+
pr: options.pr,
|
|
510
|
+
reviewer: "Copilot",
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
...requestResult,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
if (requestResult.status === "already-requested") {
|
|
518
|
+
return requestResult;
|
|
519
|
+
}
|
|
520
|
+
const after = await fetchCopilotReviewState(options, { env, ghCommand });
|
|
521
|
+
const reviewCountIncreased = after.copilotReviewIds.length > before.copilotReviewIds.length;
|
|
522
|
+
const reviewNowObservablyInProgress = after.requested || after.hasPendingReviewOnCurrentHead || reviewCountIncreased;
|
|
523
|
+
if (!reviewNowObservablyInProgress) {
|
|
524
|
+
throw new Error("Copilot review request did not appear in requested reviewers or fresh/in-progress Copilot reviews after gh pr edit");
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
...requestResult,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
export async function runCli(
|
|
531
|
+
argv = process.argv.slice(2),
|
|
532
|
+
{
|
|
533
|
+
stdout = process.stdout,
|
|
534
|
+
env = process.env,
|
|
535
|
+
ghCommand = "gh",
|
|
536
|
+
} = {},
|
|
537
|
+
) {
|
|
538
|
+
const options = parseRequestCliArgs(argv);
|
|
539
|
+
if (options.help) {
|
|
540
|
+
stdout.write(`${USAGE}\n`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const result = await performCopilotReviewRequest(options, { env, ghCommand });
|
|
544
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
545
|
+
}
|
|
546
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
547
|
+
runCli().catch((error) => {
|
|
548
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
549
|
+
process.exitCode = 1;
|
|
550
|
+
});
|
|
551
|
+
}
|