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.
Files changed (156) hide show
  1. package/.pi/dev-loop/defaults.yaml +477 -0
  2. package/AGENTS.md +25 -0
  3. package/CHANGELOG.md +18 -0
  4. package/LICENSE +21 -0
  5. package/README.md +178 -0
  6. package/agents/dev-loop.agent.md +82 -0
  7. package/agents/developer.agent.md +37 -0
  8. package/agents/docs.agent.md +33 -0
  9. package/agents/fixer.agent.md +53 -0
  10. package/agents/quality.agent.md +28 -0
  11. package/agents/refiner.agent.md +87 -0
  12. package/agents/review.agent.md +64 -0
  13. package/cli/index.mjs +424 -0
  14. package/extension/README.md +233 -0
  15. package/extension/checks.ts +94 -0
  16. package/extension/index.ts +131 -0
  17. package/extension/post-merge-update.ts +512 -0
  18. package/extension/presentation.ts +107 -0
  19. package/lib/dev-loops-core.mjs +284 -0
  20. package/package.json +103 -0
  21. package/scripts/README.md +1007 -0
  22. package/scripts/_cli-primitives.mjs +10 -0
  23. package/scripts/_core-helpers.mjs +30 -0
  24. package/scripts/docs/validate-links.mjs +567 -0
  25. package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
  26. package/scripts/github/_review-thread-mutations.mjs +214 -0
  27. package/scripts/github/capture-review-threads.mjs +180 -0
  28. package/scripts/github/create-draft-pr.mjs +108 -0
  29. package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
  30. package/scripts/github/detect-linked-issue-pr.mjs +331 -0
  31. package/scripts/github/manage-sub-issues.mjs +394 -0
  32. package/scripts/github/probe-copilot-review.mjs +323 -0
  33. package/scripts/github/ready-for-review.mjs +93 -0
  34. package/scripts/github/reconcile-draft-gate.mjs +328 -0
  35. package/scripts/github/reply-resolve-review-thread.mjs +42 -0
  36. package/scripts/github/reply-resolve-review-threads.mjs +329 -0
  37. package/scripts/github/request-copilot-review.mjs +551 -0
  38. package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
  39. package/scripts/github/stage-reviewer-draft.mjs +191 -0
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
  41. package/scripts/github/verify-fresh-review-context.mjs +125 -0
  42. package/scripts/github/write-gate-findings-log.mjs +212 -0
  43. package/scripts/loop/_checkpoint-io.mjs +55 -0
  44. package/scripts/loop/_checkpoint-paths.mjs +28 -0
  45. package/scripts/loop/_handoff-contract.mjs +230 -0
  46. package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
  47. package/scripts/loop/_loop-evidence.mjs +32 -0
  48. package/scripts/loop/_pr-runner-coordination.mjs +611 -0
  49. package/scripts/loop/_stale-runner-detection.mjs +145 -0
  50. package/scripts/loop/_steering-state-file.mjs +134 -0
  51. package/scripts/loop/build-handoff-envelope.mjs +181 -0
  52. package/scripts/loop/checkpoint-contract.mjs +49 -0
  53. package/scripts/loop/conductor-monitor.mjs +1850 -0
  54. package/scripts/loop/conductor.mjs +214 -0
  55. package/scripts/loop/copilot-pr-handoff.mjs +493 -0
  56. package/scripts/loop/debt-remediate.mjs +304 -0
  57. package/scripts/loop/detect-change-scope.mjs +102 -0
  58. package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
  59. package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
  60. package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
  61. package/scripts/loop/detect-internal-only-pr.mjs +270 -0
  62. package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
  63. package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
  64. package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
  65. package/scripts/loop/detect-stale-runner.mjs +250 -0
  66. package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
  67. package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
  68. package/scripts/loop/info.mjs +267 -0
  69. package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
  70. package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
  71. package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
  72. package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
  73. package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
  74. package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
  75. package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
  76. package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
  77. package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
  78. package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
  79. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
  80. package/scripts/loop/inspect-run-viewer.mjs +82 -0
  81. package/scripts/loop/inspect-run.mjs +382 -0
  82. package/scripts/loop/outer-loop.mjs +419 -0
  83. package/scripts/loop/pr-runner-coordination.mjs +143 -0
  84. package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
  85. package/scripts/loop/pre-flight-gate.mjs +236 -0
  86. package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
  87. package/scripts/loop/pre-push-main-guard.mjs +103 -0
  88. package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
  89. package/scripts/loop/print-gates.mjs +42 -0
  90. package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
  91. package/scripts/loop/run-conductor-cycle.mjs +322 -0
  92. package/scripts/loop/run-queue.mjs +124 -0
  93. package/scripts/loop/run-refinement-audit.mjs +513 -0
  94. package/scripts/loop/run-watch-cycle.mjs +358 -0
  95. package/scripts/loop/steer-loop.mjs +841 -0
  96. package/scripts/loop/ui-designer-review-contract.mjs +76 -0
  97. package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
  98. package/scripts/projects/add-queue-item.mjs +528 -0
  99. package/scripts/projects/ensure-queue-board.mjs +837 -0
  100. package/scripts/projects/list-queue-items.mjs +489 -0
  101. package/scripts/projects/move-queue-item.mjs +549 -0
  102. package/scripts/projects/reorder-queue-item.mjs +518 -0
  103. package/scripts/refine/_refine-helpers.mjs +258 -0
  104. package/scripts/refine/prose-linkage-detector.mjs +92 -0
  105. package/scripts/refine/refinement-completeness-checker.mjs +88 -0
  106. package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
  107. package/scripts/refine/tree-integrity-validator.mjs +211 -0
  108. package/scripts/refine/verify.mjs +178 -0
  109. package/scripts/repo-wiki-local.mjs +156 -0
  110. package/scripts/repo-wiki.mjs +119 -0
  111. package/skills/copilot-pr-followup/SKILL.md +380 -0
  112. package/skills/dev-loop/SKILL.md +141 -0
  113. package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
  114. package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
  115. package/skills/dev-loop/scripts/init-phase.mjs +71 -0
  116. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
  117. package/skills/dev-loop/scripts/phase-files.mjs +29 -0
  118. package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
  119. package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
  120. package/skills/dev-loop/scripts/render-template.mjs +82 -0
  121. package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
  122. package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  123. package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  124. package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  125. package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  126. package/skills/dev-loop/templates/dev-mode-review.md +17 -0
  127. package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  128. package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  129. package/skills/dev-loop/templates/phase-doc.md +27 -0
  130. package/skills/dev-loop/templates/phase-summary.md +13 -0
  131. package/skills/dev-loop/templates/phase-variant.md +15 -0
  132. package/skills/dev-loop/templates/retrospective.md +11 -0
  133. package/skills/dev-loop/templates/review.md +32 -0
  134. package/skills/dev-loop/templates/ui-vision-review.md +55 -0
  135. package/skills/docs/acceptance-criteria-verification.md +21 -0
  136. package/skills/docs/anti-patterns.md +21 -0
  137. package/skills/docs/artifact-authority-contract.md +119 -0
  138. package/skills/docs/confirmation-rules.md +28 -0
  139. package/skills/docs/copilot-ci-status-contract.md +52 -0
  140. package/skills/docs/copilot-loop-operations.md +233 -0
  141. package/skills/docs/debt-remediation-contract.md +107 -0
  142. package/skills/docs/entrypoint-strategies.md +115 -0
  143. package/skills/docs/epic-tree-refinement-procedure.md +234 -0
  144. package/skills/docs/issue-intake-procedure.md +235 -0
  145. package/skills/docs/main-agent-contract.md +72 -0
  146. package/skills/docs/merge-preconditions.md +29 -0
  147. package/skills/docs/pr-lifecycle-contract.md +209 -0
  148. package/skills/docs/public-dev-loop-contract.md +497 -0
  149. package/skills/docs/retrospective-checkpoint-contract.md +159 -0
  150. package/skills/docs/stop-conditions.md +29 -0
  151. package/skills/docs/structural-quality.md +42 -0
  152. package/skills/docs/tracker-first-loop-state.md +281 -0
  153. package/skills/docs/validation-policy.md +27 -0
  154. package/skills/docs/workflow-handoff-contract.md +135 -0
  155. package/skills/final-approval/SKILL.md +19 -0
  156. 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
+ }