dev-loops 0.4.0 → 0.6.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/commands/auto.md +7 -0
- package/.claude/commands/continue.md +15 -0
- package/.claude/commands/info.md +7 -0
- package/.claude/commands/start-spike.md +16 -0
- package/.claude/commands/start.md +7 -0
- package/.claude/commands/status.md +6 -0
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/skills/dev-loop/SKILL.md +21 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/.claude/skills/local-implementation/SKILL.md +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +21 -1
- package/agents/dev-loop.agent.md +8 -1
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +2 -0
- package/extension/index.ts +10 -1
- package/extension/presentation.ts +15 -0
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +8 -3
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/comment-issue.mjs +181 -0
- package/scripts/github/fetch-ci-logs.mjs +215 -0
- package/scripts/github/list-issues.mjs +191 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-conductor-cycle.mjs +5 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/projects/resolve-active-board-item.mjs +193 -0
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/scaffold-spike-file.mjs +183 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/scripts/release/extract-changelog-section.mjs +111 -0
- package/skills/dev-loop/SKILL.md +24 -2
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/skills/local-implementation/SKILL.md +1 -1
package/lib/dev-loops-core.mjs
CHANGED
|
@@ -9,6 +9,19 @@ const LOCAL_READINESS_IDS = ['subagent-command', 'git-repo'];
|
|
|
9
9
|
const REMOTE_READINESS_IDS = ['gh-installed', 'gh-auth', 'subagent-command', 'git-repo'];
|
|
10
10
|
const INSPECT_ACTIONS = new Set(['open', 'resume', 'status', 'stop', 'restart']);
|
|
11
11
|
|
|
12
|
+
// Direct dev-loop entrypoints (#972): thin named wrappers over the public dev-loop contract.
|
|
13
|
+
// Each maps `<verb> <issue|pr>` to the canonical public-intent shorthand the `dev-loop` skill
|
|
14
|
+
// already accepts — no new routing/strategy logic lives here. `start`/`auto` target an issue,
|
|
15
|
+
// `continue` (#988) targets an issue OR a PR (the resolver picks the canonical artifact) and
|
|
16
|
+
// also accepts a bare form (no number) that continues the current in-progress board item;
|
|
17
|
+
// `info` is the read-only state shortcut for an issue or PR.
|
|
18
|
+
const ENTRYPOINT_VERBS = {
|
|
19
|
+
start: { target: 'issue', phrase: (n) => `start dev loop on issue ${n}` },
|
|
20
|
+
auto: { target: 'issue', phrase: (n) => `auto dev loop on issue ${n}` },
|
|
21
|
+
continue: { target: 'either', allowBare: true, phrase: (n) => (n ? `continue dev loop on ${n}` : 'continue the current dev loop') },
|
|
22
|
+
info: { target: 'either', phrase: (n) => `inspect dev loop state on ${n}` },
|
|
23
|
+
};
|
|
24
|
+
|
|
12
25
|
const UNICODE_SPACE_RE = /[\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/g;
|
|
13
26
|
|
|
14
27
|
function normalizeInput(input) {
|
|
@@ -88,6 +101,100 @@ function parseInspectCommand(tokens, { surface }) {
|
|
|
88
101
|
};
|
|
89
102
|
}
|
|
90
103
|
|
|
104
|
+
// Normalize a single entrypoint target token to a bare issue/PR number.
|
|
105
|
+
// Accepts `123`, `#123`, or a GitHub issue/PR URL (.../issues/123 or .../pull/123).
|
|
106
|
+
// Returns the numeric string, or null when it is not a recognized target.
|
|
107
|
+
function normalizeTargetNumber(raw) {
|
|
108
|
+
const token = String(raw).trim();
|
|
109
|
+
if (/^#?\d+$/.test(token)) {
|
|
110
|
+
return token.replace(/^#/, '');
|
|
111
|
+
}
|
|
112
|
+
const urlMatch = token.match(/^https?:\/\/[^\s]*\/(?:issues|pull)\/(\d+)(?:[/?#].*)?$/);
|
|
113
|
+
if (urlMatch) {
|
|
114
|
+
return urlMatch[1];
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// `start-spike` is a SIBLING of the numeric verbs (start/auto/continue/info): it
|
|
120
|
+
// takes FREE TEXT (a question) or `--file <path>`, NOT a numeric target, so it is
|
|
121
|
+
// parsed on its own path to keep the numeric-validation invariant of the other
|
|
122
|
+
// verbs intact (#988 P2). It is a thin wrapper over the shipped `--spike` intake:
|
|
123
|
+
// the inline-question form scaffolds a startable findings artifact, then both
|
|
124
|
+
// forms hand the resolved spike path to `loop startup --spike <path>`. No new
|
|
125
|
+
// spike behavior lives here — see skills/docs/spike-mode-contract.md.
|
|
126
|
+
function parseStartSpikeCommand(rest, tokens) {
|
|
127
|
+
const positional = rest.filter((t) => t !== undefined);
|
|
128
|
+
// `--file <path>`: start from a pre-authored spike artifact (no scaffolding).
|
|
129
|
+
if (positional[0] === '--file') {
|
|
130
|
+
const file = positional[1];
|
|
131
|
+
// Reject any leading `-`: the path is forwarded to `resolve-dev-loop-startup
|
|
132
|
+
// --spike <path>`, so a value like `-x` could be read as an option (option
|
|
133
|
+
// injection). Fail closed. The free-text question path below is unaffected.
|
|
134
|
+
if (typeof file !== 'string' || file.length === 0 || file.startsWith('-') || positional.length !== 2) {
|
|
135
|
+
return invalidCommand('`start-spike --file` requires exactly one `<path>`.', 'start-spike', tokens);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
kind: 'start_spike',
|
|
139
|
+
mode: 'file',
|
|
140
|
+
file,
|
|
141
|
+
question: null,
|
|
142
|
+
intent: `start a dev-loop spike from ${file}`,
|
|
143
|
+
tokens,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Inline free-text question. Joined verbatim so multi-word questions survive.
|
|
147
|
+
const question = positional.join(' ').trim();
|
|
148
|
+
if (question.length === 0) {
|
|
149
|
+
return invalidCommand('`start-spike` requires a question (or `--file <path>`).', 'start-spike', tokens);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
kind: 'start_spike',
|
|
153
|
+
mode: 'question',
|
|
154
|
+
file: null,
|
|
155
|
+
question,
|
|
156
|
+
intent: `start a dev-loop spike on the question: ${question}`,
|
|
157
|
+
tokens,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseEntrypointCommand(action, args, tokens) {
|
|
162
|
+
const spec = ENTRYPOINT_VERBS[action];
|
|
163
|
+
const positional = args.filter((a) => a !== undefined);
|
|
164
|
+
const targetHint = spec.target === 'pr' ? '<pr>' : spec.target === 'either' ? '<issue|pr>' : '<issue>';
|
|
165
|
+
const targetNoun = spec.target === 'pr' ? 'PR' : spec.target === 'either' ? 'issue/PR' : 'issue';
|
|
166
|
+
|
|
167
|
+
// Bare form (no target): only verbs that opt in (e.g. `continue` resumes the
|
|
168
|
+
// current in-progress board item). The command/skill does the board resolve.
|
|
169
|
+
if (positional.length === 0 && spec.allowBare) {
|
|
170
|
+
return {
|
|
171
|
+
kind: 'entrypoint',
|
|
172
|
+
action,
|
|
173
|
+
target: spec.target,
|
|
174
|
+
number: null,
|
|
175
|
+
intent: spec.phrase(null),
|
|
176
|
+
tokens,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (positional.length !== 1) {
|
|
181
|
+
const arity = spec.allowBare ? `at most one ${targetHint}` : `exactly one ${targetHint}`;
|
|
182
|
+
return invalidCommand(`\`${action}\` requires ${arity} argument.`, action, tokens);
|
|
183
|
+
}
|
|
184
|
+
const number = normalizeTargetNumber(positional[0]);
|
|
185
|
+
if (number === null) {
|
|
186
|
+
return invalidCommand(`\`${action}\` expects a numeric ${targetNoun}, got: ${positional[0]}.`, action, tokens);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
kind: 'entrypoint',
|
|
190
|
+
action,
|
|
191
|
+
target: spec.target,
|
|
192
|
+
number,
|
|
193
|
+
intent: spec.phrase(number),
|
|
194
|
+
tokens,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
91
198
|
export function parseDevLoopsCommand(input, { surface = 'extension' } = {}) {
|
|
92
199
|
const tokens = normalizeInput(input);
|
|
93
200
|
const [rawAction, rawScope, ...rest] = tokens;
|
|
@@ -98,6 +205,15 @@ export function parseDevLoopsCommand(input, { surface = 'extension' } = {}) {
|
|
|
98
205
|
return parseInspectCommand(tokens, { surface });
|
|
99
206
|
}
|
|
100
207
|
|
|
208
|
+
// `start-spike` is free-text/path, not a numeric verb — parsed on its own path.
|
|
209
|
+
if (action === 'start-spike') {
|
|
210
|
+
return parseStartSpikeCommand([rawScope, ...rest], tokens);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (action && Object.prototype.hasOwnProperty.call(ENTRYPOINT_VERBS, action)) {
|
|
214
|
+
return parseEntrypointCommand(action, [rawScope, ...rest], tokens);
|
|
215
|
+
}
|
|
216
|
+
|
|
101
217
|
switch (action) {
|
|
102
218
|
case undefined:
|
|
103
219
|
case '':
|
|
@@ -255,6 +371,31 @@ export async function executeDevLoopsCommand({ input, surface = 'extension', run
|
|
|
255
371
|
}
|
|
256
372
|
}
|
|
257
373
|
|
|
374
|
+
if (parsed.kind === 'start_spike') {
|
|
375
|
+
// Thin wrapper: surface the spike intent so the operator dispatches it through
|
|
376
|
+
// the dev-loop skill, which scaffolds (inline question) or uses the given file,
|
|
377
|
+
// then runs `loop startup --spike <path>`. No spike behavior is decided here.
|
|
378
|
+
return {
|
|
379
|
+
kind: 'start_spike',
|
|
380
|
+
mode: parsed.mode,
|
|
381
|
+
file: parsed.file,
|
|
382
|
+
question: parsed.question,
|
|
383
|
+
intent: parsed.intent,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (parsed.kind === 'entrypoint') {
|
|
388
|
+
// Thin wrapper: surface the canonical public intent so the user dispatches it through the
|
|
389
|
+
// `dev-loop` skill (the single public router). No routing/strategy decision is made here.
|
|
390
|
+
return {
|
|
391
|
+
kind: 'entrypoint',
|
|
392
|
+
action: parsed.action,
|
|
393
|
+
target: parsed.target,
|
|
394
|
+
number: parsed.number,
|
|
395
|
+
intent: parsed.intent,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
258
399
|
if (parsed.kind !== 'action') {
|
|
259
400
|
return parsed;
|
|
260
401
|
}
|
package/package.json
CHANGED
|
@@ -25,10 +25,14 @@
|
|
|
25
25
|
"test": "npm run test:assets && npm run test:extension && npm run test:scripts && npm run test:core && npm run test:docs",
|
|
26
26
|
"test:assets": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/dev-loop-init-phase-smoke.test.mjs test/contracts/*.test.mjs test/workflow-handoff-contract.test.mjs",
|
|
27
27
|
"test:extension": "node --import tsx --test --test-reporter ./test/failure-summary-reporter.mjs test/extension-checks.test.mjs test/extension-post-merge-update.test.mjs test/extension-command-contract.test.mjs test/extension-package-contract.test.mjs test/extension-pi-adapter.test.mjs test/extension-claude-adapter.test.mjs test/dev-loops-core.test.mjs test/dev-loops-cli.test.mjs",
|
|
28
|
-
"test:scripts": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/github/*.test.mjs test/loop/*.test.mjs test/docs/*.test.mjs test/projects/*.test.mjs",
|
|
28
|
+
"test:scripts": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/github/*.test.mjs test/loop/*.test.mjs test/docs/*.test.mjs test/projects/*.test.mjs test/pages/*.test.mjs",
|
|
29
29
|
"test:core": "node --test --test-reporter ./test/failure-summary-reporter.mjs packages/core/test/*.test.mjs",
|
|
30
30
|
"test:dev-loop": "node --test --test-reporter ./test/failure-summary-reporter.mjs skills/dev-loop/scripts/dev-mode-context.test.mjs skills/dev-loop/scripts/render-template.test.mjs skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs",
|
|
31
31
|
"test:playwright:viewer": "playwright test -c playwright.inspect-run-viewer.config.mjs",
|
|
32
|
+
"test:playwright:deep-dive": "playwright test -c playwright.dev-loops-deep-dive.config.mjs",
|
|
33
|
+
"test:playwright:intro-deck": "playwright test -c playwright.intro-deck.config.mjs",
|
|
34
|
+
"test:playwright:intro-article": "playwright test -c playwright.intro-article.config.mjs",
|
|
35
|
+
"test:playwright:deep-dive-article": "playwright test -c playwright.deep-dive-article.config.mjs",
|
|
32
36
|
"smoke:headless": "node scripts/claude/headless-info-smoke.mjs",
|
|
33
37
|
"test:docs": "node scripts/docs/validate-links.mjs && node scripts/docs/validate-no-duplicate-rules.mjs",
|
|
34
38
|
"repo-wiki": "node scripts/repo-wiki.mjs",
|
|
@@ -68,7 +72,7 @@
|
|
|
68
72
|
},
|
|
69
73
|
"dependencies": {
|
|
70
74
|
"mermaid": "11.15.0",
|
|
71
|
-
"@dev-loops/core": "^0.
|
|
75
|
+
"@dev-loops/core": "^0.6.0"
|
|
72
76
|
},
|
|
73
77
|
"repository": {
|
|
74
78
|
"type": "git",
|
|
@@ -78,7 +82,7 @@
|
|
|
78
82
|
"url": "https://github.com/mfittko/dev-loops/issues"
|
|
79
83
|
},
|
|
80
84
|
"homepage": "https://github.com/mfittko/dev-loops#readme",
|
|
81
|
-
"version": "0.
|
|
85
|
+
"version": "0.6.0",
|
|
82
86
|
"files": [
|
|
83
87
|
"cli/",
|
|
84
88
|
"lib/",
|
|
@@ -89,6 +93,7 @@
|
|
|
89
93
|
".claude-plugin/",
|
|
90
94
|
".claude/.claude-plugin/",
|
|
91
95
|
".claude/agents/",
|
|
96
|
+
".claude/commands/",
|
|
92
97
|
".claude/skills/",
|
|
93
98
|
".claude/hooks/",
|
|
94
99
|
"README.md",
|
|
@@ -14,7 +14,7 @@ import fs from "node:fs";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
|
|
17
|
-
import { transformAgent, transformSkill, stripPiOnlyBlocks } from "@dev-loops/core/claude/asset-generation";
|
|
17
|
+
import { transformAgent, transformSkill, transformCommand, stripPiOnlyBlocks } from "@dev-loops/core/claude/asset-generation";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Collect the generated assets as { target, content } pairs (target is repo-relative).
|
|
@@ -45,6 +45,19 @@ export function collectGeneratedAssets({ repoRoot = process.cwd() } = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Direct slash commands (#972): thin generated wrappers over the public dev-loop contract,
|
|
49
|
+
// one `.claude/commands/<name>.md` per `commands/<name>.command.md` source. No routing logic.
|
|
50
|
+
const commandsDir = path.join(repoRoot, "commands");
|
|
51
|
+
if (fs.existsSync(commandsDir)) {
|
|
52
|
+
for (const entry of fs.readdirSync(commandsDir).sort()) {
|
|
53
|
+
if (!entry.endsWith(".command.md")) continue;
|
|
54
|
+
const source = `commands/${entry}`;
|
|
55
|
+
const raw = fs.readFileSync(path.join(repoRoot, source), "utf8");
|
|
56
|
+
const base = entry.slice(0, -".command.md".length);
|
|
57
|
+
assets.push({ target: `.claude/commands/${base}.md`, content: transformCommand({ source, raw, version }) });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
const skillsDir = path.join(repoRoot, "skills");
|
|
49
62
|
if (fs.existsSync(skillsDir)) {
|
|
50
63
|
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -171,6 +184,7 @@ function listFilesRecursive(repoRoot, rel) {
|
|
|
171
184
|
function listExistingAssetFiles(repoRoot) {
|
|
172
185
|
const files = [
|
|
173
186
|
...listFilesRecursive(repoRoot, ".claude/agents"),
|
|
187
|
+
...listFilesRecursive(repoRoot, ".claude/commands"),
|
|
174
188
|
...listFilesRecursive(repoRoot, ".claude/skills"),
|
|
175
189
|
];
|
|
176
190
|
// `.claude/hooks/` mixes hand-authored scripts (hooks.json, _hook-io.mjs, the three hook
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
12
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
13
13
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
14
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
14
15
|
export const REVIEW_THREADS_QUERY = [
|
|
15
16
|
"query($owner: String!, $name: String!, $pr: Int!) {",
|
|
16
17
|
" repository(owner: $owner, name: $name) {",
|
|
@@ -45,9 +46,11 @@ Modes:
|
|
|
45
46
|
Options:
|
|
46
47
|
--output <path> Write JSON output to file in addition to stdout
|
|
47
48
|
--help, -h Show this help
|
|
49
|
+
${JQ_OUTPUT_USAGE}
|
|
48
50
|
Exit codes:
|
|
49
51
|
0 Success
|
|
50
52
|
1 Error
|
|
53
|
+
2 Invalid --jq filter
|
|
51
54
|
`;
|
|
52
55
|
export function parseCaptureCliArgs(argv) {
|
|
53
56
|
const { tokens } = parseArgs({
|
|
@@ -58,6 +61,7 @@ export function parseCaptureCliArgs(argv) {
|
|
|
58
61
|
output: { type: "string" },
|
|
59
62
|
repo: { type: "string" },
|
|
60
63
|
pr: { type: "string" },
|
|
64
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
61
65
|
},
|
|
62
66
|
allowPositionals: true,
|
|
63
67
|
strict: false,
|
|
@@ -69,6 +73,8 @@ export function parseCaptureCliArgs(argv) {
|
|
|
69
73
|
repo: undefined,
|
|
70
74
|
pr: undefined,
|
|
71
75
|
help: false,
|
|
76
|
+
jq: undefined,
|
|
77
|
+
silent: false,
|
|
72
78
|
};
|
|
73
79
|
for (const token of tokens) {
|
|
74
80
|
if (token.kind === "positional") {
|
|
@@ -97,6 +103,14 @@ export function parseCaptureCliArgs(argv) {
|
|
|
97
103
|
options.pr = parsePrNumber(requireTokenValue(token));
|
|
98
104
|
continue;
|
|
99
105
|
}
|
|
106
|
+
if (token.name === "jq") {
|
|
107
|
+
options.jq = requireTokenValue(token);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (token.name === "silent") {
|
|
111
|
+
options.silent = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
100
114
|
throw new Error(`Unknown argument: ${token.rawName}`);
|
|
101
115
|
}
|
|
102
116
|
const hasLiveArgs = options.repo !== undefined || options.pr !== undefined;
|
|
@@ -188,10 +202,14 @@ export async function runCli(
|
|
|
188
202
|
if (options.outputPath) {
|
|
189
203
|
await writeOutputFile(options.outputPath, payload);
|
|
190
204
|
}
|
|
191
|
-
|
|
205
|
+
return emitResult(payload, { jq: options.jq, silent: options.silent, stdout });
|
|
192
206
|
}
|
|
193
207
|
if (isDirectCliRun(import.meta.url)) {
|
|
194
|
-
runCli().
|
|
208
|
+
runCli().then((code) => {
|
|
209
|
+
if (typeof code === "number") {
|
|
210
|
+
process.exitCode = code;
|
|
211
|
+
}
|
|
212
|
+
}).catch((error) => {
|
|
195
213
|
process.stderr.write(`${formatCliError(error)}\n`);
|
|
196
214
|
process.exitCode = 1;
|
|
197
215
|
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
4
|
+
import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: comment-issue.mjs --repo <owner/name> --issue <number> (--body <text> | --body-file <path>)
|
|
10
|
+
Post a comment on a GitHub issue. Thin wrapper over \`gh issue comment\` — use this
|
|
11
|
+
instead of an agent-level raw \`gh issue comment\` so the loop's internal-tooling
|
|
12
|
+
record stays clean (#993; the read-side siblings are list-issues.mjs / fetch-ci-logs.mjs).
|
|
13
|
+
Required:
|
|
14
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
15
|
+
--issue <number> Issue number to comment on
|
|
16
|
+
--body <text> Comment body as a single argument
|
|
17
|
+
--body-file <path> Read the comment body from a file (preserves
|
|
18
|
+
newlines; alternative to --body; - reads stdin)
|
|
19
|
+
Output (stdout, JSON):
|
|
20
|
+
{ "ok": true, "repo": "owner/repo", "issue": 17, "commentUrl": "https://github.com/owner/repo/issues/17#issuecomment-123" }
|
|
21
|
+
Error output (stderr, JSON):
|
|
22
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
23
|
+
${JQ_OUTPUT_USAGE}
|
|
24
|
+
Exit codes:
|
|
25
|
+
0 Success
|
|
26
|
+
1 Argument error or gh failure
|
|
27
|
+
2 Invalid --jq filter`.trim();
|
|
28
|
+
const parseError = buildParseError(USAGE);
|
|
29
|
+
|
|
30
|
+
export function parseCommentIssueCliArgs(argv) {
|
|
31
|
+
const { tokens } = parseArgs({
|
|
32
|
+
args: [...argv],
|
|
33
|
+
options: {
|
|
34
|
+
help: { type: "boolean", short: "h" },
|
|
35
|
+
repo: { type: "string" },
|
|
36
|
+
issue: { type: "string" },
|
|
37
|
+
body: { type: "string" },
|
|
38
|
+
"body-file": { type: "string" },
|
|
39
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
40
|
+
},
|
|
41
|
+
allowPositionals: true,
|
|
42
|
+
strict: false,
|
|
43
|
+
tokens: true,
|
|
44
|
+
});
|
|
45
|
+
const options = {
|
|
46
|
+
help: false,
|
|
47
|
+
repo: undefined,
|
|
48
|
+
issue: undefined,
|
|
49
|
+
body: undefined,
|
|
50
|
+
bodyFile: undefined,
|
|
51
|
+
jq: undefined,
|
|
52
|
+
silent: false,
|
|
53
|
+
};
|
|
54
|
+
for (const token of tokens) {
|
|
55
|
+
if (token.kind === "positional") {
|
|
56
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
57
|
+
}
|
|
58
|
+
if (token.kind !== "option") {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (token.name === "help") {
|
|
62
|
+
options.help = true;
|
|
63
|
+
return options;
|
|
64
|
+
}
|
|
65
|
+
if (token.name === "repo") {
|
|
66
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (token.name === "issue") {
|
|
70
|
+
options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (token.name === "body") {
|
|
74
|
+
options.body = requireTokenValue(token, parseError);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (token.name === "body-file") {
|
|
78
|
+
const rawPath = requireTokenValue(token, parseError).trim();
|
|
79
|
+
if (rawPath.length === 0) {
|
|
80
|
+
throw parseError("--body-file must be a non-empty path");
|
|
81
|
+
}
|
|
82
|
+
options.bodyFile = rawPath;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.name === "jq") {
|
|
86
|
+
options.jq = requireTokenValue(token, parseError);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (token.name === "silent") {
|
|
90
|
+
options.silent = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
94
|
+
}
|
|
95
|
+
if (options.repo === undefined || options.issue === undefined) {
|
|
96
|
+
throw parseError("Commenting on an issue requires both --repo <owner/name> and --issue <number>");
|
|
97
|
+
}
|
|
98
|
+
if (options.body === undefined && options.bodyFile === undefined) {
|
|
99
|
+
throw parseError("Commenting on an issue requires --body <text> or --body-file <path>");
|
|
100
|
+
}
|
|
101
|
+
if (options.body !== undefined && options.bodyFile !== undefined) {
|
|
102
|
+
throw parseError("--body and --body-file are mutually exclusive; pass only one");
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
parseRepoSlug(options.repo);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
108
|
+
}
|
|
109
|
+
return options;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function resolveBody(options) {
|
|
113
|
+
if (options.bodyFile === undefined) {
|
|
114
|
+
if (options.body.trim().length === 0) {
|
|
115
|
+
throw new Error("--body must not be empty");
|
|
116
|
+
}
|
|
117
|
+
return options.body;
|
|
118
|
+
}
|
|
119
|
+
const source = options.bodyFile === "-" ? 0 : options.bodyFile;
|
|
120
|
+
const body = await readFile(source, "utf8");
|
|
121
|
+
if (body.trim().length === 0) {
|
|
122
|
+
throw new Error(`--body-file ${options.bodyFile} is empty`);
|
|
123
|
+
}
|
|
124
|
+
return body;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Post the comment via `gh issue comment`, then read its URL back from
|
|
128
|
+
// `gh issue comment` output. `gh issue comment` prints the new comment URL on
|
|
129
|
+
// success — capture it so callers don't need a follow-up read.
|
|
130
|
+
export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
|
|
131
|
+
const body = await resolveBody(options);
|
|
132
|
+
const result = await run(
|
|
133
|
+
ghCommand,
|
|
134
|
+
["issue", "comment", String(options.issue), "--repo", options.repo, "--body", body],
|
|
135
|
+
env,
|
|
136
|
+
);
|
|
137
|
+
if (result.code !== 0) {
|
|
138
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
139
|
+
throw new Error(`gh issue comment failed: ${detail}`);
|
|
140
|
+
}
|
|
141
|
+
// `gh issue comment` prints the created comment's URL (the last non-empty line
|
|
142
|
+
// of stdout). Surface it so the caller has the comment URL without a re-read.
|
|
143
|
+
const commentUrl = result.stdout
|
|
144
|
+
.split(/\r?\n/u)
|
|
145
|
+
.map((line) => line.trim())
|
|
146
|
+
.filter((line) => line.length > 0)
|
|
147
|
+
.pop() ?? null;
|
|
148
|
+
if (commentUrl === null || !/^https?:\/\//u.test(commentUrl)) {
|
|
149
|
+
throw new Error(`gh issue comment did not return a comment URL (got: ${result.stdout.trim() || "<empty>"})`);
|
|
150
|
+
}
|
|
151
|
+
return { ok: true, repo: options.repo, issue: options.issue, commentUrl };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function runCli(
|
|
155
|
+
argv = process.argv.slice(2),
|
|
156
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
|
|
157
|
+
) {
|
|
158
|
+
let options;
|
|
159
|
+
try {
|
|
160
|
+
options = parseCommentIssueCliArgs(argv);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
if (options.help) {
|
|
166
|
+
stdout.write(`${USAGE}\n`);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
let result;
|
|
170
|
+
try {
|
|
171
|
+
result = await commentIssue(options, { env, ghCommand, run });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
180
|
+
runCli().then((code) => { process.exitCode = code; });
|
|
181
|
+
}
|