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,328 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
+ import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
4
+ import { loadDevLoopConfig, resolveGateConfig } from "@dev-loops/core/config";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { detectCheckpointEvidence } from "./detect-checkpoint-evidence.mjs";
7
+ import { upsertCheckpointVerdict } from "./upsert-checkpoint-verdict.mjs";
8
+ const USAGE = `Usage: reconcile-draft-gate.mjs --repo <owner/name> --pr <number>
9
+ Optional/manual recovery tool for an already non-draft PR when you want to
10
+ retroactively record clean \`draft_gate\` evidence.
11
+ Converts the PR to draft, validates the head, posts a reconciling clean
12
+ draft_gate comment, then marks the PR ready for review again.
13
+ Fail-closed guards:
14
+ - Refuses to reconcile if any draft_gate evidence already exists on the PR.
15
+ - Requires CI to be green on the current head SHA before posting the
16
+ reconciling gate comment unless config disables \`gates.draft.requireCi\`.
17
+ Required:
18
+ --repo <owner/name> Repository slug (e.g. owner/repo)
19
+ --pr <number> Pull request number
20
+ Output (stdout, JSON):
21
+ {
22
+ "ok": true,
23
+ "action": "reconciled",
24
+ "repo": "owner/repo",
25
+ "pr": 17,
26
+ "headSha": "abc1234",
27
+ "currentHeadSha": "abc1234",
28
+ "commentId": 101,
29
+ "commentUrl": "https://github.com/owner/repo/pull/17#issuecomment-101"
30
+ }
31
+ Error output (stderr, JSON):
32
+ { "ok": false, "error": "...", "usage": "..." }
33
+ { "ok": false, "error": "..." }
34
+ Exit codes:
35
+ 0 Success — PR was reconciled and gate evidence posted
36
+ 1 Argument error, gh failure, or unrecoverable state`.trim();
37
+ const parseError = buildParseError(USAGE);
38
+ export function parseReconcileDraftGateCliArgs(argv) {
39
+ const args = [...argv];
40
+ const options = {
41
+ help: false,
42
+ repo: undefined,
43
+ pr: undefined,
44
+ };
45
+ while (args.length > 0) {
46
+ const token = args.shift();
47
+ if (token === "--help" || token === "-h") {
48
+ options.help = true;
49
+ return options;
50
+ }
51
+ if (token === "--repo") {
52
+ options.repo = requireOptionValue(args, "--repo", parseError).trim();
53
+ continue;
54
+ }
55
+ if (token === "--pr") {
56
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
57
+ continue;
58
+ }
59
+ throw parseError(`Unknown argument: ${token}`);
60
+ }
61
+ const missing = ["repo", "pr"].filter((key) => options[key] === undefined);
62
+ if (missing.length > 0) {
63
+ throw parseError("reconcile-draft-gate requires --repo and --pr");
64
+ }
65
+ try {
66
+ parseRepoSlug(options.repo);
67
+ } catch (error) {
68
+ throw parseError(error instanceof Error ? error.message : String(error));
69
+ }
70
+ return options;
71
+ }
72
+ const CONVERT_TO_DRAFT_MUTATION = [
73
+ "mutation($pullRequestId:ID!) {",
74
+ " convertPullRequestToDraft(input: {pullRequestId: $pullRequestId}) {",
75
+ " pullRequest {",
76
+ " id",
77
+ " isDraft",
78
+ " }",
79
+ " }",
80
+ "}",
81
+ ].join("\n");
82
+ const PR_ID_QUERY = [
83
+ "query($owner:String!, $name:String!, $number:Int!) {",
84
+ " repository(owner: $owner, name: $name) {",
85
+ " pullRequest(number: $number) {",
86
+ " id",
87
+ " isDraft",
88
+ " }",
89
+ " }",
90
+ "}",
91
+ ].join("\n");
92
+ async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
93
+ const [owner, name] = repo.split("/");
94
+ const result = await runChild(ghCommand, [
95
+ "api", "graphql",
96
+ "-f", "query=" + PR_ID_QUERY,
97
+ "-f", `owner=${owner}`,
98
+ "-f", `name=${name}`,
99
+ "-F", `number=${pr}`,
100
+ ], env);
101
+ if (result.code !== 0) {
102
+ throw new Error(
103
+ `Failed to resolve PR node ID for #${pr}: ${result.stderr.trim() || `exit code ${result.code}`}`
104
+ );
105
+ }
106
+ const payload = parseJsonText(result.stdout, {
107
+ label: `gh api graphql (resolvePrNodeId for #${pr})`,
108
+ });
109
+ const prData = payload?.data?.repository?.pullRequest;
110
+ if (!prData?.id) {
111
+ throw new Error(`Could not resolve PR node ID for #${pr}`);
112
+ }
113
+ return { id: prData.id, isDraft: prData.isDraft };
114
+ }
115
+ async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
116
+ const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand });
117
+ if (resolvedPr.isDraft === true) {
118
+ return {
119
+ ...resolvedPr,
120
+ alreadyDraft: true,
121
+ };
122
+ }
123
+ const result = await runChild(ghCommand, [
124
+ "api", "graphql",
125
+ "-f", "query=" + CONVERT_TO_DRAFT_MUTATION,
126
+ "-F", `pullRequestId=${resolvedPr.id}`,
127
+ ], env);
128
+ if (result.code !== 0) {
129
+ throw new Error(
130
+ `Failed to convert PR #${pr} to draft: ${result.stderr.trim() || `exit code ${result.code}`}`
131
+ );
132
+ }
133
+ const payload = parseJsonText(result.stdout, {
134
+ label: `gh api graphql (convertPullRequestToDraft #${pr})`,
135
+ });
136
+ const converted = payload?.data?.convertPullRequestToDraft?.pullRequest;
137
+ if (converted?.isDraft !== true) {
138
+ throw new Error(`PR #${pr} was not set to draft state after mutation`);
139
+ }
140
+ return {
141
+ ...converted,
142
+ alreadyDraft: false,
143
+ };
144
+ }
145
+ async function markPrReady({ repo, pr }, { env, ghCommand }) {
146
+ const result = await runChild(ghCommand, [
147
+ "pr", "ready", String(pr),
148
+ "--repo", repo,
149
+ ], env);
150
+ if (result.code !== 0) {
151
+ throw new Error(
152
+ `Failed to mark PR #${pr} ready: ${result.stderr.trim() || `exit code ${result.code}`}`
153
+ );
154
+ }
155
+ return true;
156
+ }
157
+ function normalizeCheckBucket(check = {}) {
158
+ const bucket = typeof check.bucket === "string" ? check.bucket.trim().toLowerCase() : "";
159
+ if (bucket) {
160
+ return bucket;
161
+ }
162
+ const state = typeof check.state === "string" ? check.state.trim().toLowerCase() : "";
163
+ if (["success", "passed", "pass"].includes(state)) {
164
+ return "pass";
165
+ }
166
+ if (["skipped", "skipping"].includes(state)) {
167
+ return "skipping";
168
+ }
169
+ if (["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(state)) {
170
+ return "pending";
171
+ }
172
+ if (["failure", "failed", "fail", "error", "timed_out", "startup_failure"].includes(state)) {
173
+ return "fail";
174
+ }
175
+ if (["cancel", "cancelled", "canceled"].includes(state)) {
176
+ return "cancel";
177
+ }
178
+ return state || "unknown";
179
+ }
180
+ function summarizeBlockingChecks(blockingChecks) {
181
+ if (!Array.isArray(blockingChecks) || blockingChecks.length === 0) {
182
+ return "unknown blocking CI state";
183
+ }
184
+ return blockingChecks
185
+ .map((check) => `${check.name || "unnamed-check"}=${check.bucket}`)
186
+ .join(", ");
187
+ }
188
+ async function checkCiStatus({ repo, pr, headSha }, { env, ghCommand }) {
189
+ const result = await runChild(ghCommand, [
190
+ "pr", "checks", String(pr),
191
+ "--repo", repo,
192
+ "--json", "bucket,state,name,workflow",
193
+ ], env);
194
+ const stdout = result.stdout.trim();
195
+ if (result.code !== 0) {
196
+ if ((result.code !== 1 && result.code !== 8) || stdout.length === 0) {
197
+ throw new Error(
198
+ `Failed to check PR #${pr} CI status: ${result.stderr.trim() || `exit code ${result.code}`}`
199
+ );
200
+ }
201
+ }
202
+ const payload = parseJsonText(stdout || "[]", {
203
+ label: `gh pr checks #${pr}`,
204
+ });
205
+ if (!Array.isArray(payload)) {
206
+ throw new Error(`Invalid gh pr checks payload for PR #${pr}: expected an array`);
207
+ }
208
+ if (payload.length === 0) {
209
+ return {
210
+ status: "none",
211
+ checks: [],
212
+ blockingSummary: `No CI/check runs were reported for PR #${pr} head ${headSha.slice(0, 7)}.`,
213
+ };
214
+ }
215
+ const checks = payload.map((check) => ({
216
+ name: typeof check?.name === "string" && check.name.trim().length > 0 ? check.name.trim() : null,
217
+ workflow: typeof check?.workflow === "string" && check.workflow.trim().length > 0 ? check.workflow.trim() : null,
218
+ state: typeof check?.state === "string" && check.state.trim().length > 0 ? check.state.trim() : null,
219
+ bucket: normalizeCheckBucket(check),
220
+ }));
221
+ const blockingChecks = checks.filter((check) => !["pass", "skipping"].includes(check.bucket));
222
+ return {
223
+ status: blockingChecks.length === 0 ? "success" : "blocked",
224
+ checks,
225
+ blockingChecks,
226
+ blockingSummary: blockingChecks.length === 0
227
+ ? null
228
+ : `Blocking CI/check state on head ${headSha.slice(0, 7)}: ${summarizeBlockingChecks(blockingChecks)}.`,
229
+ };
230
+ }
231
+ export async function reconcileDraftGate(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
232
+ const { config } = await loadDevLoopConfig({ repoRoot });
233
+ const draftGateConfig = resolveGateConfig(config, "draft");
234
+ const initialEvidence = await detectCheckpointEvidence(
235
+ { repo: options.repo, pr: options.pr },
236
+ { env, ghCommand }
237
+ );
238
+ const headSha = initialEvidence.currentHeadSha;
239
+ if (!headSha) {
240
+ throw new Error(`Could not resolve current head SHA for PR #${options.pr}`);
241
+ }
242
+ if (initialEvidence.draftGate?.visible) {
243
+ throw new Error(
244
+ `PR #${options.pr} already has a visible draft_gate comment (verdict: ` +
245
+ `${initialEvidence.draftGate.verdict || "unknown"}). Refusing to overwrite existing ` +
246
+ `evidence. Reconcile manually or clear the existing comment first.`
247
+ );
248
+ }
249
+ if (initialEvidence.draftGateMarker?.visible) {
250
+ throw new Error(
251
+ `PR #${options.pr} already has a visible draft_gate marker. Refusing to overwrite ` +
252
+ `existing evidence. Reconcile manually or clear the existing marker first.`
253
+ );
254
+ }
255
+ if (draftGateConfig.requireCi) {
256
+ const ciStatus = await checkCiStatus(
257
+ { repo: options.repo, pr: options.pr, headSha },
258
+ { env, ghCommand }
259
+ );
260
+ if (ciStatus.status !== "success") {
261
+ throw new Error(
262
+ `PR #${options.pr} CI is not green. ${ciStatus.blockingSummary || "No successful check state was confirmed."} ` +
263
+ `Refusing to post a clean draft_gate comment. Fix CI first.`
264
+ );
265
+ }
266
+ }
267
+ const draftConversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand });
268
+ let gateResult;
269
+ try {
270
+ gateResult = await upsertCheckpointVerdict({
271
+ repo: options.repo,
272
+ pr: options.pr,
273
+ gate: "draft_gate",
274
+ headSha,
275
+ verdict: "clean",
276
+ findingsSeverityCounts: { "must-fix": 0, "worth-fixing-now": 0, "defer": 0 },
277
+ findingsSummary: draftGateConfig.requireCi
278
+ ? "Reconciled non-draft PR — draft gate auto-reconciled (CI green)."
279
+ : "Reconciled non-draft PR — draft gate auto-reconciled (CI optional by config).",
280
+ nextAction: "Mark ready for review (auto-reconciled).",
281
+ }, { env, ghCommand, repoRoot });
282
+ } catch (error) {
283
+ if (draftConversion.alreadyDraft !== true) {
284
+ try {
285
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
286
+ } catch {
287
+ }
288
+ }
289
+ throw error;
290
+ }
291
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
292
+ return {
293
+ ok: true,
294
+ action: "reconciled",
295
+ repo: options.repo,
296
+ pr: options.pr,
297
+ headSha: gateResult.headSha || headSha,
298
+ currentHeadSha: gateResult.currentHeadSha || headSha,
299
+ commentId: gateResult.commentId,
300
+ commentUrl: gateResult.commentUrl,
301
+ };
302
+ }
303
+ async function main() {
304
+ let options;
305
+ try {
306
+ options = parseReconcileDraftGateCliArgs(process.argv.slice(2));
307
+ } catch (error) {
308
+ process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
309
+ process.exitCode = 1;
310
+ return;
311
+ }
312
+ if (options.help) {
313
+ process.stdout.write(`${USAGE}\n`);
314
+ return;
315
+ }
316
+ try {
317
+ const result = await reconcileDraftGate(options);
318
+ process.stdout.write(`${JSON.stringify(result)}\n`);
319
+ } catch (error) {
320
+ process.stderr.write(
321
+ `${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`
322
+ );
323
+ process.exitCode = 1;
324
+ }
325
+ }
326
+ if (isDirectCliRun(import.meta.url)) {
327
+ await main();
328
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { defineSubcommand, isDirectCliRun } from "@dev-loops/core/cli/subcommand-runner";
4
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import {
6
+ replyAndMaybeResolve,
7
+ validateResolutionMessage,
8
+ } from "./_review-thread-mutations.mjs";
9
+
10
+ export { hasCommitShaReference } from "./_review-thread-mutations.mjs";
11
+
12
+ const { runAsScript } = defineSubcommand({
13
+ name: "reply-resolve-review-thread --repo <owner/name> --pr <n> --comment-id <n> --thread-id <id> --body-file <path>",
14
+ description: "Reply to a review thread comment and resolve the thread.",
15
+ options: [
16
+ { flag: "--repo", type: "string", required: true, description: "GitHub repository slug" },
17
+ { flag: "--pr", type: "pr", required: true, description: "Pull request number" },
18
+ { flag: "--comment-id", type: "positiveInt", required: true, description: "GraphQL databaseId of the comment to reply to" },
19
+ { flag: "--thread-id", type: "string", required: true, description: "GraphQL node ID of the review thread" },
20
+ { flag: "--body-file", type: "string", required: true, description: "Path to file containing the reply body text" },
21
+ ],
22
+ async run({ repo, pr, commentId, threadId, bodyFile }) {
23
+ parseRepoSlug(repo);
24
+
25
+ const rawBody = await readFile(bodyFile, "utf8");
26
+ if (rawBody.trim().length === 0) throw new Error("--body-file must contain non-empty text");
27
+ validateResolutionMessage(rawBody);
28
+
29
+ const result = await replyAndMaybeResolve(
30
+ { repo, pr, commentId, threadId, body: rawBody, resolve: true },
31
+ { env: process.env, ghCommand: "gh" },
32
+ );
33
+
34
+ process.stdout.write(JSON.stringify({
35
+ ok: true, repo, pr, commentId, threadId,
36
+ replyId: result.replyId, replyUrl: result.replyUrl, resolved: true,
37
+ }) + "\n");
38
+ return 0;
39
+ },
40
+ });
41
+
42
+ if (isDirectCliRun(import.meta.url)) { runAsScript(); }
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
3
+ import {
4
+ parsePrNumber,
5
+ requireOptionValue,
6
+ } from "../_cli-primitives.mjs";
7
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+ import {
9
+ authorMatchesFilter,
10
+ captureParsedReviewThreads,
11
+ replyAndMaybeResolve,
12
+ validateResolutionMessage,
13
+ } from "./_review-thread-mutations.mjs";
14
+ const USAGE = `Usage: reply-resolve-review-threads.mjs --repo <owner/name> --pr <number> [--author <login>] [--message <text>] [--resolve]
15
+ Reply to all matching unresolved review threads on one PR and optionally resolve them.
16
+ Required:
17
+ --repo <owner/name> Repository slug (e.g. owner/repo)
18
+ --pr <number> Pull request number
19
+ Optional:
20
+ --author <login> Match threads containing a comment from this author (default: Copilot)
21
+ --message <text> Reply body text; provide exactly one message source via --message or stdin
22
+ --resolve Resolve each matched thread after the reply succeeds
23
+ Output (stdout, JSON):
24
+ { "ok": true, "repo": "owner/name", "pr": 17, "author": "Copilot", "resolve": true,
25
+ "matchedThreadCount": 2, "repliedThreadCount": 2, "resolvedThreadCount": 2,
26
+ "skippedThreadCount": 1, "results": [{ ... }] }
27
+ Error output (stderr, JSON):
28
+ Argument/usage errors:
29
+ { "ok": false, "error": "...", "usage": "..." }
30
+ Runtime/gh failures:
31
+ { "ok": false, "error": "...", "partialProgress"?: { ... } }
32
+ Exit codes:
33
+ 0 Success
34
+ 1 Argument error or gh/runtime failure`.trim();
35
+ const parseError = buildParseError(USAGE);
36
+ export function parseReplyResolveThreadsCliArgs(argv) {
37
+ const args = [...argv];
38
+ const options = {
39
+ help: false,
40
+ repo: undefined,
41
+ pr: undefined,
42
+ author: "Copilot",
43
+ message: undefined,
44
+ resolve: false,
45
+ };
46
+ while (args.length > 0) {
47
+ const token = args.shift();
48
+ if (token === "--help" || token === "-h") {
49
+ options.help = true;
50
+ return options;
51
+ }
52
+ if (token === "--repo") {
53
+ options.repo = requireOptionValue(args, "--repo", parseError).trim();
54
+ continue;
55
+ }
56
+ if (token === "--pr") {
57
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
58
+ continue;
59
+ }
60
+ if (token === "--author") {
61
+ options.author = requireOptionValue(args, "--author", parseError).trim();
62
+ continue;
63
+ }
64
+ if (token === "--message") {
65
+ options.message = requireOptionValue(args, "--message", parseError);
66
+ continue;
67
+ }
68
+ if (token === "--resolve") {
69
+ options.resolve = true;
70
+ continue;
71
+ }
72
+ throw parseError(`Unknown argument: ${token}`);
73
+ }
74
+ if (options.repo === undefined || options.pr === undefined) {
75
+ throw parseError("Replying and resolving review threads requires both --repo <owner/name> and --pr <number>");
76
+ }
77
+ if (options.author.length === 0) {
78
+ throw parseError("--author must contain non-empty text");
79
+ }
80
+ try {
81
+ parseRepoSlug(options.repo);
82
+ } catch (error) {
83
+ throw parseError(error instanceof Error ? error.message : String(error));
84
+ }
85
+ return options;
86
+ }
87
+ async function readStdinText(stdin) {
88
+ let text = "";
89
+ stdin.setEncoding?.("utf8");
90
+ for await (const chunk of stdin) {
91
+ text += chunk;
92
+ }
93
+ return text;
94
+ }
95
+ async function resolveMessageInput(options, { stdin = process.stdin } = {}) {
96
+ if (typeof options.message === "string") {
97
+ if (stdin.isTTY) {
98
+ if (options.message.trim().length === 0) {
99
+ throw parseError("Reply message must contain non-empty text");
100
+ }
101
+ return options.message;
102
+ }
103
+ const stdinText = await readStdinText(stdin);
104
+ if (stdinText.trim().length > 0) {
105
+ throw parseError("Choose exactly one message source: --message <text> or stdin");
106
+ }
107
+ if (options.message.trim().length === 0) {
108
+ throw parseError("Reply message must contain non-empty text");
109
+ }
110
+ return options.message;
111
+ }
112
+ if (stdin.isTTY) {
113
+ throw parseError("Choose exactly one message source: --message <text> or stdin");
114
+ }
115
+ const stdinText = await readStdinText(stdin);
116
+ if (stdinText.trim().length === 0) {
117
+ throw parseError("Reply message must contain non-empty text");
118
+ }
119
+ return stdinText;
120
+ }
121
+ function commentRecencyValue(comment) {
122
+ if (typeof comment?.databaseId === "string" && /^\d+$/.test(comment.databaseId)) {
123
+ return Number(comment.databaseId);
124
+ }
125
+ return Number.NaN;
126
+ }
127
+ function selectNewestMatchingComment(parsed, threadId, author) {
128
+ const candidates = parsed.comments.filter((comment) => (
129
+ comment.threadId === threadId
130
+ && authorMatchesFilter(comment.author?.login, author)
131
+ ));
132
+ if (candidates.length === 0) {
133
+ return null;
134
+ }
135
+ return candidates.reduce((latest, comment) => {
136
+ if (latest === null) {
137
+ return comment;
138
+ }
139
+ const latestRecency = commentRecencyValue(latest);
140
+ const commentRecency = commentRecencyValue(comment);
141
+ if (Number.isFinite(latestRecency) && Number.isFinite(commentRecency) && commentRecency !== latestRecency) {
142
+ return commentRecency > latestRecency ? comment : latest;
143
+ }
144
+ if (!Number.isFinite(latestRecency) && Number.isFinite(commentRecency)) {
145
+ return comment;
146
+ }
147
+ if (comment.id.localeCompare(latest.id, undefined, { numeric: true }) > 0) {
148
+ return comment;
149
+ }
150
+ return latest;
151
+ }, null);
152
+ }
153
+ export function planBatchReplyTargets(parsed, author) {
154
+ const unresolvedThreads = parsed.threads.filter((thread) => !thread.isResolved);
155
+ const matchedTargets = [];
156
+ let skippedThreadCount = 0;
157
+ for (const thread of unresolvedThreads) {
158
+ const comment = selectNewestMatchingComment(parsed, thread.id, author);
159
+ if (comment === null) {
160
+ skippedThreadCount += 1;
161
+ continue;
162
+ }
163
+ if (typeof comment.databaseId !== "string" || !/^\d+$/.test(comment.databaseId)) {
164
+ throw new Error(`Matched review thread ${thread.id} did not include a REST-safe numeric comment id for the newest ${author} comment`);
165
+ }
166
+ matchedTargets.push({
167
+ threadId: thread.id,
168
+ commentId: Number(comment.databaseId),
169
+ });
170
+ }
171
+ return {
172
+ matchedTargets,
173
+ skippedThreadCount,
174
+ };
175
+ }
176
+ function createSuccessPayload({ repo, pr, author, resolve, matchedThreadCount, repliedThreadCount, resolvedThreadCount, skippedThreadCount, results }) {
177
+ return {
178
+ ok: true,
179
+ repo,
180
+ pr,
181
+ author,
182
+ resolve,
183
+ matchedThreadCount,
184
+ repliedThreadCount,
185
+ resolvedThreadCount,
186
+ skippedThreadCount,
187
+ results,
188
+ };
189
+ }
190
+ function buildPartialProgress({ repo, pr, author, resolve, matchedThreadCount, skippedThreadCount, results }) {
191
+ const resolvedThreadCount = results.filter((entry) => entry.resolved).length;
192
+ return {
193
+ repo,
194
+ pr,
195
+ author,
196
+ resolve,
197
+ matchedThreadCount,
198
+ repliedThreadCount: results.length,
199
+ resolvedThreadCount,
200
+ skippedThreadCount,
201
+ results,
202
+ };
203
+ }
204
+ function toCliFailurePayload(error) {
205
+ const payload = JSON.parse(formatCliError(error));
206
+ if (error instanceof Error && error.partialProgress) {
207
+ payload.partialProgress = error.partialProgress;
208
+ }
209
+ return payload;
210
+ }
211
+ function attachPartialProgress(error, partialProgress) {
212
+ if (error instanceof Error) {
213
+ error.partialProgress = partialProgress;
214
+ return error;
215
+ }
216
+ const wrapped = new Error(String(error));
217
+ wrapped.partialProgress = partialProgress;
218
+ return wrapped;
219
+ }
220
+ export async function runCli(
221
+ argv = process.argv.slice(2),
222
+ {
223
+ stdin = process.stdin,
224
+ stdout = process.stdout,
225
+ env = process.env,
226
+ ghCommand = "gh",
227
+ } = {},
228
+ ) {
229
+ const options = parseReplyResolveThreadsCliArgs(argv);
230
+ if (options.help) {
231
+ stdout.write(`${USAGE}\n`);
232
+ return;
233
+ }
234
+ const message = await resolveMessageInput(options, { stdin });
235
+ validateResolutionMessage(message);
236
+ const parsed = await captureParsedReviewThreads(
237
+ { repo: options.repo, pr: options.pr },
238
+ { env, ghCommand },
239
+ );
240
+ const { matchedTargets, skippedThreadCount } = planBatchReplyTargets(parsed, options.author);
241
+ if (matchedTargets.length === 0) {
242
+ stdout.write(`${JSON.stringify(createSuccessPayload({
243
+ repo: options.repo,
244
+ pr: options.pr,
245
+ author: options.author,
246
+ resolve: options.resolve,
247
+ matchedThreadCount: 0,
248
+ repliedThreadCount: 0,
249
+ resolvedThreadCount: 0,
250
+ skippedThreadCount,
251
+ results: [],
252
+ }))}\n`);
253
+ return;
254
+ }
255
+ const results = [];
256
+ const partialBase = {
257
+ repo: options.repo,
258
+ pr: options.pr,
259
+ author: options.author,
260
+ resolve: options.resolve,
261
+ matchedThreadCount: matchedTargets.length,
262
+ skippedThreadCount,
263
+ };
264
+ try {
265
+ for (const target of matchedTargets) {
266
+ const result = await replyAndMaybeResolve(
267
+ {
268
+ repo: options.repo,
269
+ pr: options.pr,
270
+ commentId: target.commentId,
271
+ threadId: target.threadId,
272
+ body: message,
273
+ resolve: options.resolve,
274
+ validatedSnapshot: parsed,
275
+ },
276
+ { env, ghCommand },
277
+ );
278
+ results.push({
279
+ threadId: target.threadId,
280
+ commentId: target.commentId,
281
+ replyId: result.replyId,
282
+ replyUrl: result.replyUrl,
283
+ resolved: result.resolved,
284
+ });
285
+ }
286
+ if (options.resolve) {
287
+ const refreshed = await captureParsedReviewThreads(
288
+ { repo: options.repo, pr: options.pr },
289
+ { env, ghCommand },
290
+ );
291
+ const stillUnresolvedThreadIds = matchedTargets
292
+ .map((target) => target.threadId)
293
+ .filter((threadId) => refreshed.threads.some((thread) => thread.id === threadId && !thread.isResolved));
294
+ if (stillUnresolvedThreadIds.length > 0) {
295
+ throw attachPartialProgress(
296
+ new Error(`Post-resolve verification failed; targeted thread(s) remain unresolved: ${stillUnresolvedThreadIds.join(", ")}`),
297
+ {
298
+ ...buildPartialProgress({ ...partialBase, results }),
299
+ stillUnresolvedThreadIds,
300
+ },
301
+ );
302
+ }
303
+ }
304
+ } catch (error) {
305
+ if (error instanceof Error && error.partialProgress) {
306
+ throw error;
307
+ }
308
+ throw attachPartialProgress(error, buildPartialProgress({ ...partialBase, results }));
309
+ }
310
+ const repliedThreadCount = results.length;
311
+ const resolvedThreadCount = results.filter((entry) => entry.resolved).length;
312
+ stdout.write(`${JSON.stringify(createSuccessPayload({
313
+ repo: options.repo,
314
+ pr: options.pr,
315
+ author: options.author,
316
+ resolve: options.resolve,
317
+ matchedThreadCount: matchedTargets.length,
318
+ repliedThreadCount,
319
+ resolvedThreadCount,
320
+ skippedThreadCount,
321
+ results,
322
+ }))}\n`);
323
+ }
324
+ if (isDirectCliRun(import.meta.url)) {
325
+ runCli().catch((error) => {
326
+ process.stderr.write(`${JSON.stringify(toCliFailurePayload(error))}\n`);
327
+ process.exitCode = 1;
328
+ });
329
+ }