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,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
|
+
import { requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import {
|
|
7
|
+
detectIssueRefinementArtifact,
|
|
8
|
+
REFINEMENT_SOURCE,
|
|
9
|
+
} from "@dev-loops/core/loop/issue-refinement-artifact";
|
|
10
|
+
const USAGE = `Usage:
|
|
11
|
+
detect-issue-refinement-artifact.mjs --repo <owner/name> --issue <number>
|
|
12
|
+
detect-issue-refinement-artifact.mjs --input <path>
|
|
13
|
+
Detect whether a GitHub issue carries an explicit refinement artifact
|
|
14
|
+
(Acceptance criteria section, DoD section, or linked refinement doc).
|
|
15
|
+
Required (exactly one):
|
|
16
|
+
--repo <owner/name> Repository slug (e.g. owner/name)
|
|
17
|
+
--issue <number> Issue number
|
|
18
|
+
--input <path> Path to a JSON file with { "repo", "issue", "body" }
|
|
19
|
+
Success output (stdout, JSON):
|
|
20
|
+
{
|
|
21
|
+
"ok": true,
|
|
22
|
+
"repo": "owner/name",
|
|
23
|
+
"issue": 532,
|
|
24
|
+
"source": "issue-body-ac" | "issue-body-dod" | "linked-doc" | "missing",
|
|
25
|
+
"hasACs": true | false,
|
|
26
|
+
"acItems": [...],
|
|
27
|
+
"dodItems": [...],
|
|
28
|
+
"linkedDoc": { "found": true, "path": "...", "reason": "..." },
|
|
29
|
+
"finding": "missing_refinement_artifact" | null,
|
|
30
|
+
"reason": "..."
|
|
31
|
+
}
|
|
32
|
+
Error output (stderr, JSON):
|
|
33
|
+
{ "ok": false, "error": "...", "usage": "..." }`.trim();
|
|
34
|
+
const parseError = buildParseError(USAGE);
|
|
35
|
+
export function parseDetectIssueRefinementArtifactCliArgs(argv) {
|
|
36
|
+
const args = [...argv];
|
|
37
|
+
const options = {
|
|
38
|
+
help: false,
|
|
39
|
+
repo: undefined,
|
|
40
|
+
issue: undefined,
|
|
41
|
+
input: undefined,
|
|
42
|
+
};
|
|
43
|
+
while (args.length > 0) {
|
|
44
|
+
const token = args.shift();
|
|
45
|
+
if (token === "--help" || token === "-h") {
|
|
46
|
+
options.help = true;
|
|
47
|
+
return options;
|
|
48
|
+
}
|
|
49
|
+
if (token === "--repo") {
|
|
50
|
+
options.repo = requireOptionValue(args, "--repo", parseError).trim();
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (token === "--issue") {
|
|
54
|
+
const value = requireOptionValue(args, "--issue", parseError);
|
|
55
|
+
if (!/^\d+$/.test(value) || Number(value) === 0) {
|
|
56
|
+
throw parseError("--issue must be a positive integer");
|
|
57
|
+
}
|
|
58
|
+
options.issue = Number(value);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (token === "--input") {
|
|
62
|
+
options.input = requireOptionValue(args, "--input", parseError).trim();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
66
|
+
}
|
|
67
|
+
const hasInput = typeof options.input === "string" && options.input.length > 0;
|
|
68
|
+
const hasRemote = typeof options.repo === "string" && options.repo.length > 0 && Number.isInteger(options.issue);
|
|
69
|
+
if (options.help) {
|
|
70
|
+
return options;
|
|
71
|
+
}
|
|
72
|
+
if (hasInput === hasRemote) {
|
|
73
|
+
throw parseError("Provide exactly one of --input <path> or --repo <owner/name> --issue <number>");
|
|
74
|
+
}
|
|
75
|
+
return options;
|
|
76
|
+
}
|
|
77
|
+
async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
78
|
+
const result = await runChild(
|
|
79
|
+
ghCommand,
|
|
80
|
+
["issue", "view", String(issue), "--repo", repo, "--json", "body"],
|
|
81
|
+
env,
|
|
82
|
+
);
|
|
83
|
+
if (result.code !== 0) {
|
|
84
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
85
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
86
|
+
}
|
|
87
|
+
const payload = parseJsonText(result.stdout, { label: "gh issue view" });
|
|
88
|
+
if (!payload || typeof payload !== "object") {
|
|
89
|
+
throw new Error("Invalid gh issue view payload: missing body");
|
|
90
|
+
}
|
|
91
|
+
return typeof payload.body === "string" ? payload.body : "";
|
|
92
|
+
}
|
|
93
|
+
async function loadInputPayload(inputPath) {
|
|
94
|
+
const text = await readFile(inputPath, "utf8");
|
|
95
|
+
const payload = parseJsonText(text, { label: `input file ${inputPath}` });
|
|
96
|
+
if (!payload || typeof payload !== "object") {
|
|
97
|
+
throw new Error(`Input file ${inputPath} must be a JSON object`);
|
|
98
|
+
}
|
|
99
|
+
return payload;
|
|
100
|
+
}
|
|
101
|
+
function toOutput(repo, issue, artifact) {
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
repo: repo ?? null,
|
|
105
|
+
issue: issue ?? null,
|
|
106
|
+
source: artifact.source,
|
|
107
|
+
hasACs: artifact.hasACs,
|
|
108
|
+
acItems: artifact.acItems,
|
|
109
|
+
dodItems: artifact.dodItems,
|
|
110
|
+
linkedDoc: artifact.linkedDoc,
|
|
111
|
+
sections: artifact.sections,
|
|
112
|
+
finding: artifact.finding,
|
|
113
|
+
reason: artifact.reason,
|
|
114
|
+
sources: REFINEMENT_SOURCE,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export async function detectIssueRefinementArtifactFromOptions(options, { env = process.env, ghCommand = "gh" } = {}) {
|
|
118
|
+
if (typeof options.input === "string" && options.input.length > 0) {
|
|
119
|
+
const payload = await loadInputPayload(options.input);
|
|
120
|
+
const body = typeof payload.body === "string" ? payload.body : "";
|
|
121
|
+
const issue = Number.isInteger(payload.issue) ? payload.issue : options.issue ?? null;
|
|
122
|
+
const repo = typeof payload.repo === "string" ? payload.repo : options.repo ?? null;
|
|
123
|
+
const artifact = detectIssueRefinementArtifact({ body, issueNumber: issue });
|
|
124
|
+
return toOutput(repo, issue, artifact);
|
|
125
|
+
}
|
|
126
|
+
if (typeof options.repo === "string" && options.repo.length > 0 && Number.isInteger(options.issue)) {
|
|
127
|
+
parseRepoSlug(options.repo);
|
|
128
|
+
const body = await fetchIssueBody({ repo: options.repo, issue: options.issue }, { env, ghCommand });
|
|
129
|
+
const artifact = detectIssueRefinementArtifact({ body, issueNumber: options.issue });
|
|
130
|
+
return toOutput(options.repo, options.issue, artifact);
|
|
131
|
+
}
|
|
132
|
+
throw new Error("detect-issue-refinement-artifact requires either --input <path> or --repo/--issue");
|
|
133
|
+
}
|
|
134
|
+
export async function runCli(
|
|
135
|
+
argv = process.argv.slice(2),
|
|
136
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
|
|
137
|
+
) {
|
|
138
|
+
let options;
|
|
139
|
+
try {
|
|
140
|
+
options = parseDetectIssueRefinementArtifactCliArgs(argv);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
if (options.help) {
|
|
146
|
+
stdout.write(`${USAGE}\n`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const result = await detectIssueRefinementArtifactFromOptions(options, { env, ghCommand });
|
|
151
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
152
|
+
return 0;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
159
|
+
const code = await runCli();
|
|
160
|
+
if (code !== 0) {
|
|
161
|
+
process.exitCode = code;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
buildParseError,
|
|
6
|
+
formatCliError,
|
|
7
|
+
isCopilotLogin,
|
|
8
|
+
isDirectCliRun,
|
|
9
|
+
normalizeTimestamp,
|
|
10
|
+
parseJsonText,
|
|
11
|
+
parseReviewThreads,
|
|
12
|
+
summarizeCopilotReviews,
|
|
13
|
+
} from "../_core-helpers.mjs";
|
|
14
|
+
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
15
|
+
import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
16
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
17
|
+
import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
|
|
18
|
+
import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
19
|
+
import { shouldGuardCopilotReviewRequest } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
20
|
+
import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
|
|
21
|
+
import { detectCheckpointEvidence } from "../github/detect-checkpoint-evidence.mjs";
|
|
22
|
+
const UNMERGED_GIT_STATUS_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
|
|
23
|
+
const USAGE = `Usage: detect-pr-gate-coordination-state.mjs --repo <owner/name> --pr <number>
|
|
24
|
+
Determine which PR gate/transition is legal next for a pull request.
|
|
25
|
+
Required:
|
|
26
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
27
|
+
--pr <number> Pull request number
|
|
28
|
+
Optional:
|
|
29
|
+
Output (stdout, JSON):
|
|
30
|
+
{
|
|
31
|
+
"ok": true,
|
|
32
|
+
"repo": "owner/repo",
|
|
33
|
+
"pr": 266,
|
|
34
|
+
"currentHeadSha": "...",
|
|
35
|
+
"mergeStateStatus": "DIRTY",
|
|
36
|
+
"conflictFiles": ["config.test.mjs", "extension/README.md"],
|
|
37
|
+
"lifecycleState": "pr_ready_no_feedback",
|
|
38
|
+
"loopDisposition": "action_required",
|
|
39
|
+
"gateBoundary": "conflict_resolution",
|
|
40
|
+
"draftGate": {
|
|
41
|
+
"visible": true,
|
|
42
|
+
"markerVisible": false,
|
|
43
|
+
"anyVisible": true,
|
|
44
|
+
"currentHead": false,
|
|
45
|
+
"contractComplete": false,
|
|
46
|
+
"currentHeadClean": false,
|
|
47
|
+
"headSha": "c94679e",
|
|
48
|
+
"verdict": "clean"
|
|
49
|
+
},
|
|
50
|
+
"preApprovalGate": {
|
|
51
|
+
"visible": false,
|
|
52
|
+
"markerVisible": false,
|
|
53
|
+
"anyVisible": false,
|
|
54
|
+
"currentHead": false,
|
|
55
|
+
"contractComplete": false,
|
|
56
|
+
"currentHeadClean": false,
|
|
57
|
+
"headSha": null,
|
|
58
|
+
"verdict": null
|
|
59
|
+
},
|
|
60
|
+
"allowedNextActions": ["resolve_merge_conflicts"],
|
|
61
|
+
"forbiddenActions": ["run_pre_approval_gate", "declare_merge_ready"],
|
|
62
|
+
"nextAction": "resolve_merge_conflicts",
|
|
63
|
+
"reason": "..."
|
|
64
|
+
}
|
|
65
|
+
Error output (stderr, JSON):
|
|
66
|
+
{ "ok": false, "error": "...", "usage": "..." }
|
|
67
|
+
{ "ok": false, "error": "..." }
|
|
68
|
+
Exit codes:
|
|
69
|
+
0 Success
|
|
70
|
+
1 Argument error or gh/runtime failure`.trim();
|
|
71
|
+
const parseError = buildParseError(USAGE);
|
|
72
|
+
export function parseDetectPrGateCoordinationCliArgs(argv) {
|
|
73
|
+
const args = [...argv];
|
|
74
|
+
const options = {
|
|
75
|
+
help: false,
|
|
76
|
+
repo: undefined,
|
|
77
|
+
pr: undefined,
|
|
78
|
+
};
|
|
79
|
+
while (args.length > 0) {
|
|
80
|
+
const token = args.shift();
|
|
81
|
+
if (token === "--help" || token === "-h") {
|
|
82
|
+
options.help = true;
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
if (token === "--repo") {
|
|
86
|
+
options.repo = requireOptionValue(args, "--repo", parseError).trim();
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (token === "--pr") {
|
|
90
|
+
options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
94
|
+
}
|
|
95
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
96
|
+
throw parseError("detect-pr-gate-coordination-state requires both --repo <owner/name> and --pr <number>");
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
parseRepoSlug(options.repo);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
102
|
+
}
|
|
103
|
+
return options;
|
|
104
|
+
}
|
|
105
|
+
function parseRequestedReviewersPayload(text) {
|
|
106
|
+
const payload = parseJsonText(text, { label: "gh requested reviewers" });
|
|
107
|
+
const users = Array.isArray(payload?.users) ? payload.users : [];
|
|
108
|
+
return {
|
|
109
|
+
requested: users.some((user) => isCopilotLogin(user?.login)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function parseGitStatusConflictFiles(text) {
|
|
113
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const records = text.includes("\0")
|
|
117
|
+
? text.split("\0")
|
|
118
|
+
: text.split(/\r?\n/);
|
|
119
|
+
const conflictFiles = [];
|
|
120
|
+
for (const rawRecord of records) {
|
|
121
|
+
if (rawRecord.length < 4) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const status = rawRecord.slice(0, 2);
|
|
125
|
+
if (!UNMERGED_GIT_STATUS_CODES.has(status)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const rawPath = rawRecord.slice(3);
|
|
129
|
+
if (rawPath.trim().length > 0 && !conflictFiles.includes(rawPath)) {
|
|
130
|
+
conflictFiles.push(rawPath);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return conflictFiles;
|
|
134
|
+
}
|
|
135
|
+
async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
136
|
+
const result = await runChild(
|
|
137
|
+
ghCommand,
|
|
138
|
+
["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
|
|
139
|
+
env,
|
|
140
|
+
);
|
|
141
|
+
if (result.code !== 0) {
|
|
142
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
143
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
144
|
+
}
|
|
145
|
+
return parseRequestedReviewersPayload(result.stdout);
|
|
146
|
+
}
|
|
147
|
+
async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
148
|
+
const result = await runChild(
|
|
149
|
+
ghCommand,
|
|
150
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeStateStatus,body,closingIssuesReferences,reviews,statusCheckRollup"],
|
|
151
|
+
env,
|
|
152
|
+
);
|
|
153
|
+
if (result.code !== 0) {
|
|
154
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
155
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
156
|
+
}
|
|
157
|
+
return parseJsonText(result.stdout, { label: "gh pr view" });
|
|
158
|
+
}
|
|
159
|
+
export function resolveLinkedIssueFromPr(prData) {
|
|
160
|
+
if (!prData || typeof prData !== "object") return null;
|
|
161
|
+
const closing = Array.isArray(prData.closingIssuesReferences) ? prData.closingIssuesReferences : [];
|
|
162
|
+
const closingNumbers = closing
|
|
163
|
+
.map((entry) => Number(entry?.number))
|
|
164
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
165
|
+
if (closingNumbers.length === 1) {
|
|
166
|
+
return closingNumbers[0];
|
|
167
|
+
}
|
|
168
|
+
const body = typeof prData.body === "string" ? prData.body : "";
|
|
169
|
+
if (body.length === 0) return null;
|
|
170
|
+
const matches = body.match(/(?:closes|fixes|resolves)\s+#(\d+)/gi) || [];
|
|
171
|
+
const bodyNumbers = matches
|
|
172
|
+
.map((m) => Number((/(\d+)/.exec(m) || [])[1]))
|
|
173
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
174
|
+
if (bodyNumbers.length === 1) {
|
|
175
|
+
return bodyNumbers[0];
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
async function fetchIssueBody({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
180
|
+
const result = await runChild(
|
|
181
|
+
ghCommand,
|
|
182
|
+
["issue", "view", String(issue), "--repo", repo, "--json", "body"],
|
|
183
|
+
env,
|
|
184
|
+
);
|
|
185
|
+
if (result.code !== 0) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const payload = parseJsonText(result.stdout, { label: "gh issue view" });
|
|
190
|
+
return typeof payload?.body === "string" ? payload.body : "";
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function loadRefinementArtifact({ repo, prData, prDraft, prClosed, prMerged }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
196
|
+
const linkedIssue = resolveLinkedIssueFromPr(prData);
|
|
197
|
+
if (linkedIssue === null) {
|
|
198
|
+
if (prDraft) {
|
|
199
|
+
return {
|
|
200
|
+
status: "missing",
|
|
201
|
+
linkedIssue: null,
|
|
202
|
+
reason: "Draft PR has no deterministically resolvable linked issue (no closingIssuesReferences, no unique Closes/Fixes/Resolves pattern in body); draft gate cannot verify a refinement artifact.",
|
|
203
|
+
finding: "missing_refinement_artifact",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
status: "unknown",
|
|
208
|
+
linkedIssue: null,
|
|
209
|
+
reason: "No deterministically resolvable linked issue (no closingIssuesReferences, no unique Closes/Fixes/Resolves pattern in body).",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (!prDraft && !prClosed && !prMerged) {
|
|
213
|
+
return {
|
|
214
|
+
status: "unknown",
|
|
215
|
+
linkedIssue,
|
|
216
|
+
reason: `Linked issue #${linkedIssue} detected; refinement check is a draft-gate boundary and the PR is not draft, so the check is informational only and does not fetch the issue body.`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const body = await fetchIssueBody({ repo, issue: linkedIssue }, { env, ghCommand });
|
|
220
|
+
if (body === null) {
|
|
221
|
+
if (prDraft) {
|
|
222
|
+
return {
|
|
223
|
+
status: "missing",
|
|
224
|
+
linkedIssue,
|
|
225
|
+
reason: `Failed to fetch body for linked issue #${linkedIssue}; draft gate cannot verify a refinement artifact, treating as missing.`,
|
|
226
|
+
finding: "missing_refinement_artifact",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
status: "unknown",
|
|
231
|
+
linkedIssue,
|
|
232
|
+
reason: `Failed to fetch body for linked issue #${linkedIssue}; refinement status is unknown.`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const { detectIssueRefinementArtifact } = await import("@dev-loops/core/loop/issue-refinement-artifact");
|
|
236
|
+
const artifact = detectIssueRefinementArtifact({ body, issueNumber: linkedIssue });
|
|
237
|
+
return {
|
|
238
|
+
status: artifact.hasACs ? "present" : "missing",
|
|
239
|
+
linkedIssue,
|
|
240
|
+
source: artifact.source,
|
|
241
|
+
acItems: artifact.acItems,
|
|
242
|
+
dodItems: artifact.dodItems,
|
|
243
|
+
sections: artifact.sections,
|
|
244
|
+
linkedDoc: artifact.linkedDoc,
|
|
245
|
+
reason: artifact.reason,
|
|
246
|
+
finding: artifact.finding,
|
|
247
|
+
_onlyEnforcedWhenDraft: prDraft === true,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
async function fetchLocalConflictFiles({ env = process.env, gitCommand = "git" } = {}) {
|
|
251
|
+
let result;
|
|
252
|
+
try {
|
|
253
|
+
result = await runChild(
|
|
254
|
+
gitCommand,
|
|
255
|
+
["-c", "core.quotepath=false", "status", "--porcelain=v1", "-z", "--untracked-files=no"],
|
|
256
|
+
env,
|
|
257
|
+
);
|
|
258
|
+
} catch {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
if (result.code !== 0) {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
return parseGitStatusConflictFiles(result.stdout);
|
|
265
|
+
}
|
|
266
|
+
async function loadRetrospectiveCheckpoint(repoRoot) {
|
|
267
|
+
const checkpointPath = path.join(repoRoot, ".pi", "dev-loop-retrospective-checkpoint.json");
|
|
268
|
+
try {
|
|
269
|
+
const checkpointText = await readFile(checkpointPath, "utf8");
|
|
270
|
+
const checkpoint = parseJsonText(checkpointText, { label: "retrospective checkpoint" });
|
|
271
|
+
return checkpoint && typeof checkpoint === "object" ? checkpoint : null;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
277
|
+
const prData = await fetchPrFacts(options, runtime);
|
|
278
|
+
const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
|
|
279
|
+
? prData.headRefOid.trim()
|
|
280
|
+
: null;
|
|
281
|
+
if (!currentHeadSha) {
|
|
282
|
+
throw new Error("Invalid gh pr view payload: missing headRefOid");
|
|
283
|
+
}
|
|
284
|
+
const requestedReviewers = await fetchRequestedReviewers(options, runtime);
|
|
285
|
+
const threadsPayload = await fetchGithubReviewThreadsPayload(options, runtime);
|
|
286
|
+
const parsedThreads = parseReviewThreads(threadsPayload);
|
|
287
|
+
const gateEvidence = await detectCheckpointEvidence(options, runtime);
|
|
288
|
+
// When draft gate was re-passed on a different head, use its timestamp
|
|
289
|
+
// to reset the Copilot round count — only reviews after the re-pass count.
|
|
290
|
+
// Use prefix matching for the head SHA comparison so shortened SHAs (7+)
|
|
291
|
+
// from gate comments match the full headRefOid.
|
|
292
|
+
const draftGateHeadSha = gateEvidence.draftGate?.headSha;
|
|
293
|
+
const draftGateOnCurrentHead = typeof draftGateHeadSha === "string"
|
|
294
|
+
&& typeof currentHeadSha === "string"
|
|
295
|
+
&& currentHeadSha.startsWith(draftGateHeadSha);
|
|
296
|
+
const draftGateResetAtMs = gateEvidence.draftGate?.verdict === "clean"
|
|
297
|
+
&& typeof draftGateHeadSha === "string"
|
|
298
|
+
&& !draftGateOnCurrentHead
|
|
299
|
+
&& typeof gateEvidence.draftGate?.updatedAt === "string"
|
|
300
|
+
? normalizeTimestamp(gateEvidence.draftGate.updatedAt)
|
|
301
|
+
: null;
|
|
302
|
+
const reviewSummary = summarizeCopilotReviews(prData?.reviews, { headSha: currentHeadSha, draftGateResetAtMs });
|
|
303
|
+
const reviewRequestStatus = requestedReviewers.requested
|
|
304
|
+
? "requested"
|
|
305
|
+
: (reviewSummary.hasPendingReviewOnCurrentHead ? "already-requested" : "none");
|
|
306
|
+
const snapshot = buildSnapshotFromPrFacts({
|
|
307
|
+
prData,
|
|
308
|
+
prNumber: options.pr,
|
|
309
|
+
copilotReviewRequestStatus: reviewRequestStatus,
|
|
310
|
+
copilotReviewPresent: reviewSummary.copilotReviewPresent,
|
|
311
|
+
copilotReviewOnCurrentHead: reviewSummary.hasSubmittedReviewOnCurrentHead,
|
|
312
|
+
unresolvedThreadCount: parsedThreads.summary.unresolvedThreads,
|
|
313
|
+
actionableThreadCount: parsedThreads.summary.actionableThreads,
|
|
314
|
+
copilotReviewRoundCount: reviewSummary.completedCopilotReviewRounds,
|
|
315
|
+
});
|
|
316
|
+
if (snapshot.unresolvedThreadCount > 0
|
|
317
|
+
&& !snapshot.copilotReviewOnCurrentHead
|
|
318
|
+
&& snapshot.copilotReviewPresent) {
|
|
319
|
+
snapshot.agentFixStatus = "applied";
|
|
320
|
+
}
|
|
321
|
+
const conflictFiles = await fetchLocalConflictFiles(runtime);
|
|
322
|
+
if (gateEvidence.currentHeadSha !== currentHeadSha) {
|
|
323
|
+
throw new Error(`PR head changed while loading gate coordination facts for ${options.repo}#${options.pr}; refuse to evaluate mixed-head gate state.`);
|
|
324
|
+
}
|
|
325
|
+
const interpretation = interpretLoopState(snapshot);
|
|
326
|
+
const disposition = summarizeLoopInterpretation(interpretation);
|
|
327
|
+
const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
|
|
328
|
+
? prData.mergeStateStatus.trim().toUpperCase()
|
|
329
|
+
: null;
|
|
330
|
+
const isDraft = Boolean(prData?.isDraft);
|
|
331
|
+
const isClosed = String(prData?.state || "").toUpperCase() === "CLOSED";
|
|
332
|
+
const isMerged = String(prData?.state || "").toUpperCase() === "MERGED";
|
|
333
|
+
const refinementArtifact = await loadRefinementArtifact(
|
|
334
|
+
{ repo: options.repo, prData, prDraft: isDraft, prClosed: isClosed, prMerged: isMerged },
|
|
335
|
+
runtime,
|
|
336
|
+
);
|
|
337
|
+
return {
|
|
338
|
+
repo: options.repo,
|
|
339
|
+
pr: options.pr,
|
|
340
|
+
currentHeadSha,
|
|
341
|
+
mergeStateStatus,
|
|
342
|
+
conflictFiles,
|
|
343
|
+
prData,
|
|
344
|
+
snapshot,
|
|
345
|
+
gateEvidence,
|
|
346
|
+
interpretation,
|
|
347
|
+
disposition,
|
|
348
|
+
refinementArtifact,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function fetchCopilotEverFormallyRequested({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
353
|
+
const result = await runChild(
|
|
354
|
+
ghCommand,
|
|
355
|
+
["api", `repos/${repo}/issues/${pr}/timeline`, "--paginate", "--jq",
|
|
356
|
+
'.[] | select(.event == "review_requested") | select(.requested_reviewer.login != null) | .requested_reviewer.login'],
|
|
357
|
+
env,
|
|
358
|
+
);
|
|
359
|
+
if (result.code !== 0) return false;
|
|
360
|
+
for (const line of result.stdout.trim().split("\n")) {
|
|
361
|
+
const login = line.trim();
|
|
362
|
+
if (login && isCopilotLogin(login)) return true;
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
368
|
+
const context = await loadPrGateCoordinationContext(options, runtime);
|
|
369
|
+
const repoRoot = runtime.repoRoot ?? process.cwd();
|
|
370
|
+
const configLoadResult = await loadDevLoopConfig({ repoRoot });
|
|
371
|
+
const hasConfigErrors = Array.isArray(configLoadResult.errors) && configLoadResult.errors.length > 0;
|
|
372
|
+
const config = hasConfigErrors ? {} : (configLoadResult.config ?? {});
|
|
373
|
+
const draftGateConfig = resolveGateConfig(config, "draft");
|
|
374
|
+
const maxCopilotRounds = resolveRefinementConfig(config, "maxCopilotRounds");
|
|
375
|
+
const requireRetrospectiveGate = resolveWorkflowConfig(config, "requireRetrospectiveGate");
|
|
376
|
+
const retrospectiveCheckpoint = await loadRetrospectiveCheckpoint(repoRoot);
|
|
377
|
+
const result = evaluatePrGateCoordination({
|
|
378
|
+
repo: context.repo,
|
|
379
|
+
pr: context.pr,
|
|
380
|
+
currentHeadSha: context.currentHeadSha,
|
|
381
|
+
prDraft: Boolean(context.prData?.isDraft),
|
|
382
|
+
prClosed: String(context.prData?.state || "").toUpperCase() === "CLOSED",
|
|
383
|
+
prMerged: String(context.prData?.state || "").toUpperCase() === "MERGED",
|
|
384
|
+
mergeStateStatus: context.mergeStateStatus,
|
|
385
|
+
conflictFiles: context.conflictFiles,
|
|
386
|
+
lifecycleState: context.interpretation.state,
|
|
387
|
+
loopDisposition: context.disposition.loopDisposition,
|
|
388
|
+
ciStatus: context.snapshot?.ciStatus ?? null,
|
|
389
|
+
copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
|
|
390
|
+
maxCopilotRounds,
|
|
391
|
+
sameHeadCleanConverged: context.interpretation.sameHeadCleanConverged,
|
|
392
|
+
draftGateRequireCi: draftGateConfig.requireCi,
|
|
393
|
+
requireRetrospectiveGate,
|
|
394
|
+
retrospectiveCheckpoint,
|
|
395
|
+
draftGate: context.gateEvidence.draftGate,
|
|
396
|
+
draftGateMarker: context.gateEvidence.draftGateMarker,
|
|
397
|
+
preApprovalGate: context.gateEvidence.preApprovalGate,
|
|
398
|
+
preApprovalGateMarker: context.gateEvidence.preApprovalGateMarker,
|
|
399
|
+
refinementArtifact: context.refinementArtifact,
|
|
400
|
+
});
|
|
401
|
+
// Copilot review request guard (#613): When Copilot has reviewed the PR
|
|
402
|
+
// but no formal review request was made, block pre-approval gate entry.
|
|
403
|
+
// Only query timeline when cheap preconditions pass — avoids unnecessary
|
|
404
|
+
// API call when guard cannot possibly trigger.
|
|
405
|
+
const copilotReviewRequestStatus = context.snapshot?.copilotReviewRequestStatus ?? "none";
|
|
406
|
+
const guardBoundaries = new Set([
|
|
407
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
|
|
408
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
|
|
409
|
+
PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
410
|
+
]);
|
|
411
|
+
const roundCapReached = maxCopilotRounds !== null
|
|
412
|
+
&& typeof (context.snapshot?.copilotReviewRoundCount) === "number"
|
|
413
|
+
&& context.snapshot?.copilotReviewRoundCount >= maxCopilotRounds;
|
|
414
|
+
const sameHeadCleanConverged = context.interpretation?.sameHeadCleanConverged ?? false;
|
|
415
|
+
const copilotReviewEverFormallyRequested = copilotReviewRequestStatus === "none"
|
|
416
|
+
&& guardBoundaries.has(result.gateBoundary)
|
|
417
|
+
&& !(roundCapReached && sameHeadCleanConverged)
|
|
418
|
+
? await fetchCopilotEverFormallyRequested(
|
|
419
|
+
{ repo: context.repo, pr: context.pr },
|
|
420
|
+
runtime,
|
|
421
|
+
)
|
|
422
|
+
: false;
|
|
423
|
+
if (shouldGuardCopilotReviewRequest({
|
|
424
|
+
copilotReviewRequestStatus,
|
|
425
|
+
copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
|
|
426
|
+
copilotReviewEverFormallyRequested,
|
|
427
|
+
maxCopilotRounds,
|
|
428
|
+
sameHeadCleanConverged: context.interpretation?.sameHeadCleanConverged ?? false,
|
|
429
|
+
gateBoundary: result.gateBoundary,
|
|
430
|
+
})) {
|
|
431
|
+
result.gateBoundary = PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW;
|
|
432
|
+
result.nextAction = PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW;
|
|
433
|
+
result.reason = "No formal Copilot review request found — run request-copilot-review.mjs first.";
|
|
434
|
+
result.allowedNextActions = [PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW];
|
|
435
|
+
result.forbiddenActions = [
|
|
436
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
437
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
438
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
439
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
440
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
441
|
+
];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const preApprovalNeverEntered = !(result.preApprovalGate?.contractComplete === true);
|
|
445
|
+
const gateBoundariesExpectingPreApproval = new Set([
|
|
446
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
|
|
447
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
|
|
448
|
+
PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
449
|
+
]);
|
|
450
|
+
if (preApprovalNeverEntered && gateBoundariesExpectingPreApproval.has(result.gateBoundary)) {
|
|
451
|
+
result.gateBoundary = PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED;
|
|
452
|
+
result.nextAction = PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE;
|
|
453
|
+
result.reason = "No contract-complete pre_approval_gate marker exists for the current head SHA; run pre_approval_gate before proceeding.";
|
|
454
|
+
result.allowedNextActions = [PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE];
|
|
455
|
+
}
|
|
456
|
+
const draftGateEvidenceMissing = !(result.draftGate?.cleanEvidenceExists);
|
|
457
|
+
const gateBoundariesExpectingDraftGate = new Set([
|
|
458
|
+
PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
|
|
459
|
+
PR_CHECKPOINT.FEEDBACK_RESOLUTION,
|
|
460
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
|
|
461
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
|
|
462
|
+
PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
463
|
+
]);
|
|
464
|
+
if (draftGateEvidenceMissing && gateBoundariesExpectingDraftGate.has(result.gateBoundary)) {
|
|
465
|
+
result.gateBoundary = PR_CHECKPOINT.DRAFT_GATE_NEEDED;
|
|
466
|
+
result.nextAction = PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE;
|
|
467
|
+
result.reason = result.draftGate?.anyVisible
|
|
468
|
+
? "Clean draft_gate evidence is required before merge (no gate exemptions, #579). A draft_gate comment exists but is not clean; convert the PR back to draft before re-running draft_gate, or clear the existing evidence before running reconcile_draft_gate."
|
|
469
|
+
: "Clean draft_gate evidence is required before merge (no gate exemptions, #579). No visible clean draft_gate comment exists for this PR; run reconcile_draft_gate before proceeding.";
|
|
470
|
+
result.allowedNextActions = [PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE];
|
|
471
|
+
result.forbiddenActions = [
|
|
472
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
473
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
474
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
475
|
+
PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW,
|
|
476
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
477
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
478
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
479
|
+
];
|
|
480
|
+
result.gateEvidenceNote = null;
|
|
481
|
+
}
|
|
482
|
+
// Expose effective round count in output for testability (#560)
|
|
483
|
+
result.copilotReviewRoundCount = context.snapshot?.copilotReviewRoundCount ?? 0;
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
async function main() {
|
|
487
|
+
let options;
|
|
488
|
+
try {
|
|
489
|
+
options = parseDetectPrGateCoordinationCliArgs(process.argv.slice(2));
|
|
490
|
+
} catch (error) {
|
|
491
|
+
process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
|
|
492
|
+
process.exitCode = 1;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (options.help) {
|
|
496
|
+
process.stdout.write(`${USAGE}\n`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const result = await detectPrGateCoordinationState(options);
|
|
501
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
504
|
+
process.exitCode = 1;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
508
|
+
await main();
|
|
509
|
+
}
|