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,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* validate-no-duplicate-rules.mjs — CI guardrail: detect duplicated imperative
|
|
4
|
+
* rule text across skill doc files under skills/.
|
|
5
|
+
*
|
|
6
|
+
* Scans skills/ for Markdown files, extracts imperative sentences (those
|
|
7
|
+
* containing "must", "never", "do not", or "require"/"required"), and reports
|
|
8
|
+
* any sentence that appears in more than one file.
|
|
9
|
+
*
|
|
10
|
+
* Symlink note:
|
|
11
|
+
* .pi/skills/ -> ../skills on this repo, so scanning skills/ covers both.
|
|
12
|
+
*
|
|
13
|
+
* False-positive suppression:
|
|
14
|
+
* - Fenced code blocks (``` or ~~~)
|
|
15
|
+
* - Inline code spans (`)
|
|
16
|
+
* - Blockquotes (lines starting with ">")
|
|
17
|
+
* - Markdown link URLs (text inside (...))
|
|
18
|
+
* - Headings (lines starting with "#")
|
|
19
|
+
* - Only cross-file duplicates reported (same-file dupes are intentional)
|
|
20
|
+
*
|
|
21
|
+
* Exclusion list:
|
|
22
|
+
* Duplicates where all occurrences are within canonical contract docs
|
|
23
|
+
* (which own their content by design) are suppressed. Duplicates involving
|
|
24
|
+
* any non-canonical file are still reported.
|
|
25
|
+
* Known intentional duplicates (mirrored skill procedure text) are also
|
|
26
|
+
* excluded so that the guardrail enforces new duplication without failing
|
|
27
|
+
* on deliberate mirrors.
|
|
28
|
+
*
|
|
29
|
+
* Exit 0 when clean, exit 1 when duplicates found.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
|
|
36
|
+
import { isDirectCliRun } from "../_core-helpers.mjs";
|
|
37
|
+
|
|
38
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
40
|
+
const SKILLS_DIR = path.join(REPO_ROOT, "skills");
|
|
41
|
+
|
|
42
|
+
const CANONICAL_CONTRACT_DOCS = new Set([
|
|
43
|
+
"skills/docs/copilot-loop-operations.md",
|
|
44
|
+
"skills/docs/public-dev-loop-contract.md",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Known intentional duplicates: text that appears in multiple skill files
|
|
49
|
+
* because it is deliberately mirrored (shared procedure sections, template
|
|
50
|
+
* mirrors, etc.). These are excluded from cross-file duplicate reporting so
|
|
51
|
+
* the guardrail stays fatal for new duplicates without noise from mirrors.
|
|
52
|
+
*/
|
|
53
|
+
const KNOWN_INTENTIONAL_DUPLICATES = new Set([
|
|
54
|
+
"- **PERSISTENCE RULE: Do not exit your session until the PR is merged or you hit a hard stop that requires conductor authorization.**",
|
|
55
|
+
"If any required bundled contract doc is missing from the installed skill layout, treat that as a packaging/installer bug.",
|
|
56
|
+
"Each reviewer starts in fresh context with the briefing artifact, inspects the diff, returns findings via output artifacts only, and never edits files.",
|
|
57
|
+
"3. **Consolidation:** reconcile all review outputs into a consolidated fix plan with classified findings (must-fix, worth-fixing-now, defer).",
|
|
58
|
+
"5. **Fix cycle:** apply only accepted must-fix changes on the same branch.",
|
|
59
|
+
"- remains a stop/fix state, never a wait loop",
|
|
60
|
+
"Do not create a fresh PR directly in ready-for-review state unless the user explicitly overrides that policy for the current PR scope.",
|
|
61
|
+
"Each reviewer starts in fresh context (subagent({context:\"fresh\"}) mandatory), inspects the diff, returns findings via output artifacts only, and never edits files. **Before starting:** run to self-verify fresh context; refuse to proceed on contamination.",
|
|
62
|
+
"If includes , then worth-fixing-now findings must be fixed before the gate can reach .",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const IMPERATIVE_PATTERNS = [
|
|
66
|
+
/\bmust\b/i,
|
|
67
|
+
/\bnever\b/i,
|
|
68
|
+
/\bdo not\b/i,
|
|
69
|
+
/\brequire[sd]?\b/i,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const MIN_SENTENCE_LENGTH = 20;
|
|
73
|
+
|
|
74
|
+
const USAGE = `Usage: validate-no-duplicate-rules.mjs [--help]
|
|
75
|
+
|
|
76
|
+
Scan skills/ for duplicated imperative rule text across Markdown files.
|
|
77
|
+
Exit 0 when no duplicates found. Exit 1 when duplicates found.
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
--help, -h Show this help`.trim();
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
export async function* collectMarkdownFiles(dir, repoRoot = REPO_ROOT) {
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// Ignore ENOENT (missing directory) silently; other errors must fail loudly
|
|
89
|
+
// so the CI guardrail doesn't silently pass with 0 files scanned.
|
|
90
|
+
if (err?.code === "ENOENT") {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const fullPath = path.join(dir, entry.name);
|
|
98
|
+
|
|
99
|
+
if (entry.isSymbolicLink()) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
yield* collectMarkdownFiles(fullPath, repoRoot);
|
|
105
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
106
|
+
yield fullPath;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isImperativeSentence(sentence) {
|
|
112
|
+
return IMPERATIVE_PATTERNS.some((pattern) => pattern.test(sentence));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function normalizeSentence(text) {
|
|
116
|
+
return text.replace(/\s+/g, " ").trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize a repo-relative path to POSIX separators.
|
|
121
|
+
* On Windows, path.relative() produces backslashes, which would break
|
|
122
|
+
* comparisons against CANONICAL_CONTRACT_DOCS and report formatting.
|
|
123
|
+
*/
|
|
124
|
+
function toPosixPath(p) {
|
|
125
|
+
return p.replace(/\\/g, "/");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function extractSentences(content) {
|
|
129
|
+
const lines = content.split(/\r?\n/);
|
|
130
|
+
const sentences = [];
|
|
131
|
+
let inFencedBlock = false;
|
|
132
|
+
let fencedDelimiter = "";
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < lines.length; i++) {
|
|
135
|
+
let line = lines[i];
|
|
136
|
+
|
|
137
|
+
const rawTrimmed = line.trim();
|
|
138
|
+
|
|
139
|
+
if (/^\s*#/.test(line)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (/^\s*>/.test(line)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
|
|
148
|
+
if (fenceMatch) {
|
|
149
|
+
if (!inFencedBlock) {
|
|
150
|
+
inFencedBlock = true;
|
|
151
|
+
fencedDelimiter = fenceMatch[1];
|
|
152
|
+
continue;
|
|
153
|
+
} else if (rawTrimmed.startsWith(fencedDelimiter)) {
|
|
154
|
+
inFencedBlock = false;
|
|
155
|
+
fencedDelimiter = "";
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (inFencedBlock) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
line = line
|
|
165
|
+
.replace(/`[^`]*`/g, "")
|
|
166
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
167
|
+
|
|
168
|
+
const parts = line.split(/(?<=[.!?])\s+(?=[A-Z])/);
|
|
169
|
+
for (const part of parts) {
|
|
170
|
+
const normalized = normalizeSentence(part);
|
|
171
|
+
if (normalized.length >= MIN_SENTENCE_LENGTH && isImperativeSentence(normalized)) {
|
|
172
|
+
sentences.push({ text: normalized, line: i + 1 });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return sentences;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function scanSkills(skillsDir = SKILLS_DIR, repoRoot = REPO_ROOT) {
|
|
181
|
+
const fileMap = new Map();
|
|
182
|
+
let totalFilesScanned = 0;
|
|
183
|
+
|
|
184
|
+
for await (const filePath of collectMarkdownFiles(skillsDir, repoRoot)) {
|
|
185
|
+
const relativePath = toPosixPath(path.relative(repoRoot, filePath));
|
|
186
|
+
|
|
187
|
+
totalFilesScanned++;
|
|
188
|
+
const content = await readFile(filePath, "utf8");
|
|
189
|
+
const sentences = extractSentences(content);
|
|
190
|
+
|
|
191
|
+
for (const { text, line } of sentences) {
|
|
192
|
+
if (!fileMap.has(text)) {
|
|
193
|
+
fileMap.set(text, []);
|
|
194
|
+
}
|
|
195
|
+
fileMap.get(text).push({ file: relativePath, line });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const duplicates = new Map();
|
|
200
|
+
for (const [text, occurrences] of fileMap) {
|
|
201
|
+
if (KNOWN_INTENTIONAL_DUPLICATES.has(text)) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const uniqueFiles = new Set(occurrences.map((o) => o.file));
|
|
205
|
+
if (uniqueFiles.size > 1) {
|
|
206
|
+
// Suppress duplicates where all occurrences are within canonical
|
|
207
|
+
// contract docs (these own their content by design and may mirror
|
|
208
|
+
// each other). Duplicates that involve any non-canonical file
|
|
209
|
+
// are still reported.
|
|
210
|
+
const allCanonical = [...uniqueFiles].every((f) => CANONICAL_CONTRACT_DOCS.has(f));
|
|
211
|
+
if (!allCanonical) {
|
|
212
|
+
duplicates.set(text, occurrences);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { fileMap, duplicates, totalFilesScanned };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main(argv = process.argv.slice(2)) {
|
|
221
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
222
|
+
process.stdout.write(`${USAGE}\n`);
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { fileMap, duplicates, totalFilesScanned } = await scanSkills();
|
|
227
|
+
|
|
228
|
+
if (duplicates.size === 0) {
|
|
229
|
+
process.stdout.write(`No duplicate imperative rules found across skill docs.\n`);
|
|
230
|
+
process.stdout.write(`\n${totalFilesScanned} files scanned, ${fileMap.size} unique imperative sentences extracted, 0 duplicates found.\n`);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
process.stdout.write(`Duplicate imperative rules found across skill docs:\n\n`);
|
|
235
|
+
for (const [text, occurrences] of duplicates) {
|
|
236
|
+
for (const { file, line } of occurrences) {
|
|
237
|
+
process.stdout.write(` ${file}:${line}\n`);
|
|
238
|
+
}
|
|
239
|
+
process.stdout.write(` "${text}"\n\n`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
process.stdout.write(`${totalFilesScanned} files scanned, ${fileMap.size} unique imperative sentences extracted, ${duplicates.size} duplicates found.\n`);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Only run main() when executed directly, not when imported for testing.
|
|
247
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
248
|
+
const exitCode = await main();
|
|
249
|
+
process.exitCode = exitCode;
|
|
250
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { isCopilotLogin, parseReviewThreads } from "../_core-helpers.mjs";
|
|
3
|
+
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
4
|
+
export const MIN_DISMISSAL_REASON_LENGTH = 30;
|
|
5
|
+
export function hasCommitShaReference(text) {
|
|
6
|
+
const trimmed = text.trim();
|
|
7
|
+
const hexTokens = trimmed.match(/\b[0-9a-f]{7,40}\b/gi) ?? [];
|
|
8
|
+
const hasHexLetterToken = hexTokens.some((token) => /[a-f]/i.test(token));
|
|
9
|
+
const hasContextualNumericRef =
|
|
10
|
+
/\b(?:fixed\s+in|commit|sha|rev(?:ision)?)\s+[0-9a-f]{7,40}\b/i.test(trimmed)
|
|
11
|
+
|| /\/commit\/[0-9a-f]{7,40}\b/i.test(trimmed);
|
|
12
|
+
return hasHexLetterToken || hasContextualNumericRef;
|
|
13
|
+
}
|
|
14
|
+
export function validateResolutionMessage(body) {
|
|
15
|
+
const trimmedBody = body.trim();
|
|
16
|
+
const hasCommitSha = hasCommitShaReference(trimmedBody);
|
|
17
|
+
const hasDismissalReason = trimmedBody.length >= MIN_DISMISSAL_REASON_LENGTH;
|
|
18
|
+
if (!hasCommitSha && !hasDismissalReason) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Reply body (${trimmedBody.length} characters after trimming) must contain either a commit SHA reference or a dismissal reason (at least ${MIN_DISMISSAL_REASON_LENGTH} characters after trimming). `
|
|
21
|
+
+ 'Bare acknowledgments like "Acknowledged." are not valid resolutions.',
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
trimmedBody,
|
|
26
|
+
hasCommitSha,
|
|
27
|
+
hasDismissalReason,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function runChildWithInput(command, args, env, stdinText) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const child = spawn(command, args, {
|
|
33
|
+
env,
|
|
34
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
35
|
+
});
|
|
36
|
+
let stdout = "";
|
|
37
|
+
let stderr = "";
|
|
38
|
+
child.stdout.on("data", (chunk) => {
|
|
39
|
+
stdout += String(chunk);
|
|
40
|
+
});
|
|
41
|
+
child.stderr.on("data", (chunk) => {
|
|
42
|
+
stderr += String(chunk);
|
|
43
|
+
});
|
|
44
|
+
if (stdinText === undefined) {
|
|
45
|
+
child.stdin.end();
|
|
46
|
+
} else {
|
|
47
|
+
child.stdin.end(stdinText);
|
|
48
|
+
}
|
|
49
|
+
child.on("error", reject);
|
|
50
|
+
child.on("close", (code) => {
|
|
51
|
+
resolve({ code, stdout, stderr });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function parseJson(text) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(`Invalid JSON from gh: ${text.trim() || "<empty>"}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function parseReplyPayload(payload) {
|
|
63
|
+
const replyId = payload?.id;
|
|
64
|
+
const replyUrl = payload?.html_url;
|
|
65
|
+
if (!Number.isFinite(replyId) || typeof replyUrl !== "string" || replyUrl.trim().length === 0) {
|
|
66
|
+
throw new Error("Reply payload from gh did not include both id and html_url");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
replyId,
|
|
70
|
+
replyUrl,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const RESOLVE_REVIEW_THREAD_MUTATION = [
|
|
74
|
+
"mutation($threadId: ID!) {",
|
|
75
|
+
" resolveReviewThread(input: { threadId: $threadId }) {",
|
|
76
|
+
" thread {",
|
|
77
|
+
" id",
|
|
78
|
+
" isResolved",
|
|
79
|
+
" }",
|
|
80
|
+
" }",
|
|
81
|
+
"}",
|
|
82
|
+
].join("\n");
|
|
83
|
+
export async function captureParsedReviewThreads(
|
|
84
|
+
{ repo, pr },
|
|
85
|
+
{ env = process.env, ghCommand = "gh" } = {},
|
|
86
|
+
) {
|
|
87
|
+
const payload = await fetchGithubReviewThreadsPayload({ repo, pr }, { env, ghCommand });
|
|
88
|
+
return parseReviewThreads(payload);
|
|
89
|
+
}
|
|
90
|
+
export function assertReplyTargetFromSnapshot(parsed, { repo, pr, commentId, threadId }) {
|
|
91
|
+
const targetCommentId = String(commentId);
|
|
92
|
+
const thread = parsed.threads.find((entry) => entry.id === threadId) ?? null;
|
|
93
|
+
const comment = parsed.comments.find((entry) => entry.databaseId === targetCommentId) ?? null;
|
|
94
|
+
if (thread === null) {
|
|
95
|
+
throw new Error(`Review thread ${threadId} was not found on pull request ${repo}#${pr}`);
|
|
96
|
+
}
|
|
97
|
+
if (comment === null) {
|
|
98
|
+
throw new Error(`Review comment ${commentId} was not found on pull request ${repo}#${pr}`);
|
|
99
|
+
}
|
|
100
|
+
if (comment.threadId !== threadId) {
|
|
101
|
+
throw new Error(`Review comment ${commentId} does not belong to review thread ${threadId} on pull request ${repo}#${pr}`);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
thread,
|
|
105
|
+
comment,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export async function validateReplyTarget(
|
|
109
|
+
{ repo, pr, commentId, threadId },
|
|
110
|
+
{ env = process.env, ghCommand = "gh" } = {},
|
|
111
|
+
) {
|
|
112
|
+
const parsed = await captureParsedReviewThreads({ repo, pr }, { env, ghCommand });
|
|
113
|
+
return {
|
|
114
|
+
parsed,
|
|
115
|
+
...assertReplyTargetFromSnapshot(parsed, { repo, pr, commentId, threadId }),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export async function postReply(
|
|
119
|
+
{ repo, pr, commentId, body },
|
|
120
|
+
{ env = process.env, ghCommand = "gh" } = {},
|
|
121
|
+
) {
|
|
122
|
+
const result = await runChildWithInput(
|
|
123
|
+
ghCommand,
|
|
124
|
+
[
|
|
125
|
+
"api",
|
|
126
|
+
"-X",
|
|
127
|
+
"POST",
|
|
128
|
+
`repos/${repo}/pulls/${pr}/comments/${commentId}/replies`,
|
|
129
|
+
"--input",
|
|
130
|
+
"-",
|
|
131
|
+
],
|
|
132
|
+
env,
|
|
133
|
+
`${JSON.stringify({ body })}\n`,
|
|
134
|
+
);
|
|
135
|
+
if (result.code !== 0) {
|
|
136
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
137
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
138
|
+
}
|
|
139
|
+
return parseJson(result.stdout);
|
|
140
|
+
}
|
|
141
|
+
export async function resolveThread(threadId, { env = process.env, ghCommand = "gh" } = {}) {
|
|
142
|
+
const result = await runChildWithInput(
|
|
143
|
+
ghCommand,
|
|
144
|
+
[
|
|
145
|
+
"api",
|
|
146
|
+
"graphql",
|
|
147
|
+
"--field",
|
|
148
|
+
`threadId=${threadId}`,
|
|
149
|
+
"--field",
|
|
150
|
+
`query=${RESOLVE_REVIEW_THREAD_MUTATION}`,
|
|
151
|
+
],
|
|
152
|
+
env,
|
|
153
|
+
);
|
|
154
|
+
if (result.code !== 0) {
|
|
155
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
156
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
157
|
+
}
|
|
158
|
+
const payload = parseJson(result.stdout);
|
|
159
|
+
return payload?.data?.resolveReviewThread?.thread;
|
|
160
|
+
}
|
|
161
|
+
export async function replyAndMaybeResolve(
|
|
162
|
+
{
|
|
163
|
+
repo,
|
|
164
|
+
pr,
|
|
165
|
+
commentId,
|
|
166
|
+
threadId,
|
|
167
|
+
body,
|
|
168
|
+
resolve = true,
|
|
169
|
+
validatedSnapshot = null,
|
|
170
|
+
},
|
|
171
|
+
{ env = process.env, ghCommand = "gh" } = {},
|
|
172
|
+
) {
|
|
173
|
+
if (validatedSnapshot) {
|
|
174
|
+
assertReplyTargetFromSnapshot(validatedSnapshot, { repo, pr, commentId, threadId });
|
|
175
|
+
} else {
|
|
176
|
+
await validateReplyTarget({ repo, pr, commentId, threadId }, { env, ghCommand });
|
|
177
|
+
}
|
|
178
|
+
const reply = parseReplyPayload(await postReply(
|
|
179
|
+
{
|
|
180
|
+
repo,
|
|
181
|
+
pr,
|
|
182
|
+
commentId,
|
|
183
|
+
body,
|
|
184
|
+
},
|
|
185
|
+
{ env, ghCommand },
|
|
186
|
+
));
|
|
187
|
+
if (!resolve) {
|
|
188
|
+
return {
|
|
189
|
+
replyId: reply.replyId,
|
|
190
|
+
replyUrl: reply.replyUrl,
|
|
191
|
+
resolved: false,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const resolvedThread = await resolveThread(threadId, { env, ghCommand });
|
|
195
|
+
if (!resolvedThread?.isResolved) {
|
|
196
|
+
throw new Error(`Review thread did not resolve successfully: ${threadId}`);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
replyId: reply.replyId,
|
|
200
|
+
replyUrl: reply.replyUrl,
|
|
201
|
+
resolved: true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
export function authorMatchesFilter(commentAuthorLogin, authorFilter) {
|
|
205
|
+
const normalizedLogin = typeof commentAuthorLogin === "string" ? commentAuthorLogin.trim() : "";
|
|
206
|
+
const normalizedFilter = typeof authorFilter === "string" ? authorFilter.trim() : "";
|
|
207
|
+
if (normalizedLogin.length === 0 || normalizedFilter.length === 0) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
if (normalizedFilter.toLowerCase() === "copilot") {
|
|
211
|
+
return isCopilotLogin(normalizedLogin) || normalizedLogin.toLowerCase() === "copilot";
|
|
212
|
+
}
|
|
213
|
+
return normalizedLogin.toLowerCase() === normalizedFilter.toLowerCase();
|
|
214
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
formatCliError,
|
|
6
|
+
isDirectCliRun,
|
|
7
|
+
parseJsonText,
|
|
8
|
+
parseReviewThreads,
|
|
9
|
+
readInput,
|
|
10
|
+
} from "../_core-helpers.mjs";
|
|
11
|
+
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
12
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
13
|
+
export const REVIEW_THREADS_QUERY = [
|
|
14
|
+
"query($owner: String!, $name: String!, $pr: Int!) {",
|
|
15
|
+
" repository(owner: $owner, name: $name) {",
|
|
16
|
+
" pullRequest(number: $pr) {",
|
|
17
|
+
" reviewThreads(first: 100) {",
|
|
18
|
+
" nodes {",
|
|
19
|
+
" id",
|
|
20
|
+
" isResolved",
|
|
21
|
+
" comments(first: 100) {",
|
|
22
|
+
" nodes {",
|
|
23
|
+
" id",
|
|
24
|
+
" databaseId",
|
|
25
|
+
" body",
|
|
26
|
+
" author {",
|
|
27
|
+
" login",
|
|
28
|
+
" __typename",
|
|
29
|
+
" }",
|
|
30
|
+
" }",
|
|
31
|
+
" }",
|
|
32
|
+
" }",
|
|
33
|
+
" }",
|
|
34
|
+
" }",
|
|
35
|
+
" }",
|
|
36
|
+
"}",
|
|
37
|
+
].join("\n");
|
|
38
|
+
const HELP = `Usage: capture-review-threads.mjs [--input <path> | --repo <owner/name> --pr <number>] [--output <path>]
|
|
39
|
+
Capture review threads from a GitHub PR or from a local JSON snapshot.
|
|
40
|
+
Modes:
|
|
41
|
+
--input <path> Read JSON snapshot from file
|
|
42
|
+
(no mode flag) Read JSON snapshot from stdin
|
|
43
|
+
--repo <owner/name> --pr <n> Fetch live review threads from GitHub PR
|
|
44
|
+
Options:
|
|
45
|
+
--output <path> Write JSON output to file in addition to stdout
|
|
46
|
+
--help, -h Show this help
|
|
47
|
+
Exit codes:
|
|
48
|
+
0 Success
|
|
49
|
+
1 Error
|
|
50
|
+
`;
|
|
51
|
+
export function parseCaptureCliArgs(argv) {
|
|
52
|
+
const args = [...argv];
|
|
53
|
+
const options = {
|
|
54
|
+
inputPath: undefined,
|
|
55
|
+
outputPath: undefined,
|
|
56
|
+
repo: undefined,
|
|
57
|
+
pr: undefined,
|
|
58
|
+
help: false,
|
|
59
|
+
};
|
|
60
|
+
while (args.length > 0) {
|
|
61
|
+
const token = args.shift();
|
|
62
|
+
if (token === "--help" || token === "-h") {
|
|
63
|
+
options.help = true;
|
|
64
|
+
return options;
|
|
65
|
+
}
|
|
66
|
+
if (token === "--input") {
|
|
67
|
+
options.inputPath = requireOptionValue(args, "--input");
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (token === "--output") {
|
|
71
|
+
options.outputPath = requireOptionValue(args, "--output");
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (token === "--repo") {
|
|
75
|
+
options.repo = requireOptionValue(args, "--repo");
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (token === "--pr") {
|
|
79
|
+
options.pr = parsePrNumber(requireOptionValue(args, "--pr"));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`Unknown argument: ${token}`);
|
|
83
|
+
}
|
|
84
|
+
const hasLiveArgs = options.repo !== undefined || options.pr !== undefined;
|
|
85
|
+
const hasCompleteLiveArgs = options.repo !== undefined && options.pr !== undefined;
|
|
86
|
+
if (hasLiveArgs && !hasCompleteLiveArgs) {
|
|
87
|
+
throw new Error("Live GitHub capture requires both --repo <owner/name> and --pr <number>");
|
|
88
|
+
}
|
|
89
|
+
if (options.inputPath && hasCompleteLiveArgs) {
|
|
90
|
+
throw new Error("Choose exactly one input source: --input <path>, stdin, or live --repo/--pr");
|
|
91
|
+
}
|
|
92
|
+
return options;
|
|
93
|
+
}
|
|
94
|
+
export async function fetchGithubReviewThreadsPayload(
|
|
95
|
+
{ repo, pr },
|
|
96
|
+
{ env = process.env, ghCommand = "gh" } = {},
|
|
97
|
+
) {
|
|
98
|
+
const { owner, name } = parseRepoSlug(repo);
|
|
99
|
+
const result = await runChild(
|
|
100
|
+
ghCommand,
|
|
101
|
+
[
|
|
102
|
+
"api",
|
|
103
|
+
"graphql",
|
|
104
|
+
"--field",
|
|
105
|
+
`owner=${owner}`,
|
|
106
|
+
"--field",
|
|
107
|
+
`name=${name}`,
|
|
108
|
+
"--field",
|
|
109
|
+
`pr=${pr}`,
|
|
110
|
+
"--field",
|
|
111
|
+
`query=${REVIEW_THREADS_QUERY}`,
|
|
112
|
+
],
|
|
113
|
+
env,
|
|
114
|
+
);
|
|
115
|
+
if (result.code !== 0) {
|
|
116
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
117
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
118
|
+
}
|
|
119
|
+
return parseJsonText(result.stdout);
|
|
120
|
+
}
|
|
121
|
+
function createSuccessPayload(source, result, outputPath) {
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
source,
|
|
125
|
+
...(outputPath ? { outputPath } : {}),
|
|
126
|
+
...result,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async function writeOutputFile(outputPath, payload) {
|
|
130
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
131
|
+
await writeFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
132
|
+
}
|
|
133
|
+
export async function runCli(
|
|
134
|
+
argv = process.argv.slice(2),
|
|
135
|
+
{
|
|
136
|
+
stdin = process.stdin,
|
|
137
|
+
stdout = process.stdout,
|
|
138
|
+
env = process.env,
|
|
139
|
+
ghCommand = "gh",
|
|
140
|
+
} = {},
|
|
141
|
+
) {
|
|
142
|
+
const options = parseCaptureCliArgs(argv);
|
|
143
|
+
if (options.help) {
|
|
144
|
+
stdout.write(HELP);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
let source;
|
|
148
|
+
let parsed;
|
|
149
|
+
if (options.repo && options.pr !== undefined) {
|
|
150
|
+
source = {
|
|
151
|
+
type: "github",
|
|
152
|
+
repo: options.repo,
|
|
153
|
+
pr: options.pr,
|
|
154
|
+
};
|
|
155
|
+
parsed = parseReviewThreads(await fetchGithubReviewThreadsPayload(
|
|
156
|
+
{ repo: options.repo, pr: options.pr },
|
|
157
|
+
{ env, ghCommand },
|
|
158
|
+
));
|
|
159
|
+
} else if (options.inputPath) {
|
|
160
|
+
source = {
|
|
161
|
+
type: "input",
|
|
162
|
+
inputPath: options.inputPath,
|
|
163
|
+
};
|
|
164
|
+
parsed = parseReviewThreads(parseJsonText(await readInput({ inputPath: options.inputPath, stdin })));
|
|
165
|
+
} else {
|
|
166
|
+
source = { type: "stdin" };
|
|
167
|
+
parsed = parseReviewThreads(parseJsonText(await readInput({ stdin })));
|
|
168
|
+
}
|
|
169
|
+
const payload = createSuccessPayload(source, parsed, options.outputPath);
|
|
170
|
+
if (options.outputPath) {
|
|
171
|
+
await writeOutputFile(options.outputPath, payload);
|
|
172
|
+
}
|
|
173
|
+
stdout.write(`${JSON.stringify(payload)}\n`);
|
|
174
|
+
}
|
|
175
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
176
|
+
runCli().catch((error) => {
|
|
177
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
});
|
|
180
|
+
}
|