@spec-wave/cli 0.6.0 → 0.7.1
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/bin/spec-wave.mjs +37 -0
- package/package.json +4 -1
- package/src/api/github-rest.mjs +60 -1
- package/src/commands/code-review.mjs +13 -52
- package/src/commands/decompose.mjs +288 -61
- package/src/commands/doctor.mjs +415 -0
- package/src/commands/generate-plan.mjs +95 -29
- package/src/commands/generate-spec.mjs +71 -28
- package/src/commands/implement.mjs +118 -3
- package/src/commands/init.mjs +2 -2
- package/src/commands/order.mjs +172 -0
- package/src/commands/qa.mjs +8 -46
- package/src/commands/story.mjs +128 -0
- package/src/commands/task.mjs +183 -0
- package/src/commands/validate.mjs +20 -3
- package/src/config.mjs +37 -0
- package/src/lib/board.mjs +106 -0
- package/src/lib/claude.mjs +138 -13
- package/src/lib/code-digest.mjs +183 -0
- package/src/lib/critique.mjs +160 -0
- package/src/lib/dependencies.mjs +92 -0
- package/src/lib/output-lint.mjs +92 -0
- package/src/lib/usage-report.mjs +167 -0
- package/src/setup/files.mjs +8 -20
- package/src/templates/skill/SKILL.md +104 -12
- package/src/templates/workflows/code-review.yml +4 -0
- package/src/templates/workflows/decompose.yml +7 -0
- package/src/templates/workflows/generate-plan.yml +4 -0
- package/src/templates/workflows/generate-spec.yml +4 -0
- package/src/templates/workflows/qa.yml +4 -0
- package/src/templates/workflows/validate.yml +4 -0
- package/src/ui/wizard.mjs +2 -2
package/bin/spec-wave.mjs
CHANGED
|
@@ -193,4 +193,41 @@ program
|
|
|
193
193
|
await implement({ issue, ...options }).catch(err => { console.error(err.message); process.exit(1); });
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
+
program
|
|
197
|
+
.command('order')
|
|
198
|
+
.description('Ordena as Stories de uma Feature pelas dependências (topológica)')
|
|
199
|
+
.argument('<feature>', 'Número da issue da Feature, ex.: 12 ou #12')
|
|
200
|
+
.action(async (feature) => {
|
|
201
|
+
const { order } = await import('../src/commands/order.mjs');
|
|
202
|
+
await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
program
|
|
206
|
+
.command('task')
|
|
207
|
+
.description('Gerencia uma Task no board: start (Status "In Progress") ou done (Done)')
|
|
208
|
+
.argument('<action>', 'Ação: start ou done')
|
|
209
|
+
.argument('<n>', 'Número da issue da Task, ex.: 12 ou #12')
|
|
210
|
+
.action(async (action, n) => {
|
|
211
|
+
const { task } = await import('../src/commands/task.mjs');
|
|
212
|
+
await task({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
program
|
|
216
|
+
.command('story')
|
|
217
|
+
.description('Gerencia uma Story no board: review (move para Code Review)')
|
|
218
|
+
.argument('<action>', 'Ação: review')
|
|
219
|
+
.argument('<n>', 'Número da issue da Story, ex.: 12 ou #12')
|
|
220
|
+
.action(async (action, n) => {
|
|
221
|
+
const { story } = await import('../src/commands/story.mjs');
|
|
222
|
+
await story({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
program
|
|
226
|
+
.command('doctor')
|
|
227
|
+
.description('Diagnostica a configuração do spec-wave no repositório atual')
|
|
228
|
+
.action(async () => {
|
|
229
|
+
const { doctor } = await import('../src/commands/doctor.mjs');
|
|
230
|
+
await doctor().catch(err => { console.error(err.message); process.exit(1); });
|
|
231
|
+
});
|
|
232
|
+
|
|
196
233
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spec-wave/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"bin",
|
|
14
14
|
"src"
|
|
15
15
|
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test test/*.test.mjs"
|
|
18
|
+
},
|
|
16
19
|
"engines": {
|
|
17
20
|
"node": ">=20"
|
|
18
21
|
},
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -87,10 +87,12 @@ export async function upsertFile(token, owner, repo, path, content, message) {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// `id` é o database id da issue — exigido pela API de dependências
|
|
91
|
+
// (blocked_by), que não aceita number nem node id.
|
|
90
92
|
export async function createIssue(token, owner, repo, title, body, labels) {
|
|
91
93
|
const octokit = makeOctokit(token);
|
|
92
94
|
const res = await octokit.rest.issues.create({ owner, repo, title, body, labels });
|
|
93
|
-
return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url };
|
|
95
|
+
return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url, id: res.data.id };
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
export async function getIssue(token, owner, repo, issueNumber) {
|
|
@@ -148,6 +150,63 @@ export async function removeLabel(token, owner, repo, issueNumber, labelName) {
|
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
// Lista todos os comentários de uma issue/PR: [{ id, author, body, createdAt }].
|
|
154
|
+
// Paginado — traz o histórico completo (usado pela crítica adversarial e pelo
|
|
155
|
+
// comentário acumulado de uso de IA). `id` é o database id do comentário,
|
|
156
|
+
// exigido pelo updateComment.
|
|
157
|
+
export async function listIssueComments(token, owner, repo, issueNumber) {
|
|
158
|
+
const octokit = makeOctokit(token);
|
|
159
|
+
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
|
|
160
|
+
owner,
|
|
161
|
+
repo,
|
|
162
|
+
issue_number: issueNumber,
|
|
163
|
+
per_page: 100,
|
|
164
|
+
});
|
|
165
|
+
return comments.map(c => ({
|
|
166
|
+
id: c.id,
|
|
167
|
+
author: c.user?.login || '',
|
|
168
|
+
body: c.body || '',
|
|
169
|
+
createdAt: c.created_at,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Atualiza o corpo de um comentário existente. `commentId` é o database id
|
|
174
|
+
// retornado por listIssueComments.
|
|
175
|
+
export async function updateComment(token, owner, repo, commentId, body) {
|
|
176
|
+
const octokit = makeOctokit(token);
|
|
177
|
+
await octokit.rest.issues.updateComment({
|
|
178
|
+
owner,
|
|
179
|
+
repo,
|
|
180
|
+
comment_id: commentId,
|
|
181
|
+
body,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Marca uma issue como bloqueada por outra (relação nativa do GitHub).
|
|
186
|
+
// `blockingIssueId` é o DATABASE id da issue bloqueadora (não o number nem o
|
|
187
|
+
// node id — ver createIssue). Erros propagam: o chamador usa como fallback.
|
|
188
|
+
export async function addBlockedBy(token, owner, repo, issueNumber, blockingIssueId) {
|
|
189
|
+
const octokit = makeOctokit(token);
|
|
190
|
+
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by', {
|
|
191
|
+
owner,
|
|
192
|
+
repo,
|
|
193
|
+
issue_number: issueNumber,
|
|
194
|
+
issue_id: blockingIssueId,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Lista as issues que bloqueiam `issueNumber`: [{ number, title, state, id }].
|
|
199
|
+
export async function listBlockedBy(token, owner, repo, issueNumber) {
|
|
200
|
+
const octokit = makeOctokit(token);
|
|
201
|
+
const issues = await octokit.paginate('GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by', {
|
|
202
|
+
owner,
|
|
203
|
+
repo,
|
|
204
|
+
issue_number: issueNumber,
|
|
205
|
+
per_page: 100,
|
|
206
|
+
});
|
|
207
|
+
return issues.map(i => ({ number: i.number, title: i.title, state: i.state, id: i.id }));
|
|
208
|
+
}
|
|
209
|
+
|
|
151
210
|
export async function commentOnIssue(token, owner, repo, issueNumber, body) {
|
|
152
211
|
const octokit = makeOctokit(token);
|
|
153
212
|
await octokit.rest.issues.createComment({
|
|
@@ -2,9 +2,10 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
4
|
import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
-
import { addProjectItem,
|
|
5
|
+
import { addProjectItem, getIssueParent, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
6
6
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
8
|
+
import { CONFIG_FILE, STATUS_OPTIONS, STAGE_ORDER, STAGE_DONE, PROGRESS_TODO, PROGRESS_DONE, isManualStageType } from '../config.mjs';
|
|
8
9
|
|
|
9
10
|
// Ao abrir o PR: Stories/Feature → Etapa "👀 Code Review" (Status "Todo");
|
|
10
11
|
// Tasks → Etapa "🎉 Done" (Status "Done"), pois a implementação da task terminou.
|
|
@@ -25,15 +26,6 @@ function extractIssueNumbers(body) {
|
|
|
25
26
|
return [...nums];
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
// Resolve o campo SINGLE_SELECT pelo nome: usa .spec-wave.json, legado ou API.
|
|
29
|
-
async function resolveField(token, project, name) {
|
|
30
|
-
if (project.fields?.[name]) return project.fields[name];
|
|
31
|
-
if (name === 'Etapa' && project.etapaFieldId) {
|
|
32
|
-
return { id: project.etapaFieldId, options: project.stageOptions || {} };
|
|
33
|
-
}
|
|
34
|
-
return await getSingleSelectField(token, project.id, name);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
29
|
// A partir de qualquer issue (Feature/Story/Task), sobe a hierarquia e retorna a Feature.
|
|
38
30
|
async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
39
31
|
let issue;
|
|
@@ -77,25 +69,29 @@ async function collectReviewUnit(token, owner, repo, issueNumber) {
|
|
|
77
69
|
? { number: featureIssue.number, nodeId: featureIssue.node_id, title: featureIssue.title }
|
|
78
70
|
: null;
|
|
79
71
|
|
|
72
|
+
// Spikes (tipos manuais) nunca são movidos automaticamente — pulados aqui.
|
|
73
|
+
const isManual = (sub) => isManualStageType(detectIssueType({ title: sub.title, labels: sub.labels }));
|
|
74
|
+
|
|
80
75
|
if (type === 'Feature') {
|
|
81
76
|
// Referência direta à Feature: toda a subárvore (Stories + Tasks).
|
|
82
77
|
const subs = await listSubIssues(token, issue.node_id).catch(() => []);
|
|
83
78
|
for (const st of subs) {
|
|
79
|
+
if (isManual(st)) continue;
|
|
84
80
|
addStory(st.number, st.nodeId, st.title);
|
|
85
81
|
const tks = await listSubIssues(token, st.nodeId).catch(() => []);
|
|
86
|
-
for (const t of tks) addTask(t.number, t.nodeId, t.title);
|
|
82
|
+
for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
87
83
|
}
|
|
88
84
|
} else if (type === 'Story') {
|
|
89
85
|
addStory(issue.number, issue.node_id, issue.title);
|
|
90
86
|
const tks = await listSubIssues(token, issue.node_id).catch(() => []);
|
|
91
|
-
for (const t of tks) addTask(t.number, t.nodeId, t.title);
|
|
87
|
+
for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
92
88
|
} else { // Task → inclui a Story pai e as Tasks irmãs.
|
|
93
89
|
addTask(issue.number, issue.node_id, issue.title);
|
|
94
90
|
const parent = await getIssueParent(token, issue.node_id).catch(() => null);
|
|
95
91
|
if (parent && detectIssueType({ title: parent.title }) === 'Story') {
|
|
96
92
|
addStory(parent.number, parent.nodeId, parent.title);
|
|
97
93
|
const tks = await listSubIssues(token, parent.nodeId).catch(() => []);
|
|
98
|
-
for (const t of tks) addTask(t.number, t.nodeId, t.title);
|
|
94
|
+
for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
99
95
|
}
|
|
100
96
|
}
|
|
101
97
|
return { feature, stories, tasks };
|
|
@@ -118,30 +114,6 @@ async function allStoriesReadyForReview(readToken, projToken, project, etapaFiel
|
|
|
118
114
|
return true;
|
|
119
115
|
}
|
|
120
116
|
|
|
121
|
-
// Avança um item do board para `targetStage` (Etapa) e define o Status para
|
|
122
|
-
// `targetStatus`. Uma issue só AVANÇA: se já estiver em `targetStage` ou em uma
|
|
123
|
-
// etapa posterior, não é tocada (retorna false). Retorna true se avançou.
|
|
124
|
-
async function advanceToStage(token, project, etapaField, statusField, nodeId, targetStage, targetStatus) {
|
|
125
|
-
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
126
|
-
|
|
127
|
-
if (etapaField?.id && targetStage) {
|
|
128
|
-
// Nunca retroceder: compara a etapa atual com a de destino na ordem canônica.
|
|
129
|
-
const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
130
|
-
const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
|
|
131
|
-
const tgtIdx = STAGE_ORDER.indexOf(targetStage);
|
|
132
|
-
if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
|
|
133
|
-
return false; // já está nessa etapa ou adiante — não retrocede
|
|
134
|
-
}
|
|
135
|
-
const optionId = etapaField.options?.[targetStage];
|
|
136
|
-
if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
137
|
-
}
|
|
138
|
-
if (statusField?.id && targetStatus) {
|
|
139
|
-
const optionId = statusField.options?.[targetStatus];
|
|
140
|
-
if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
141
|
-
}
|
|
142
|
-
return true;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
117
|
export async function codeReview({ prNumber }) {
|
|
146
118
|
const token = await resolveToken();
|
|
147
119
|
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
@@ -168,20 +140,9 @@ export async function codeReview({ prNumber }) {
|
|
|
168
140
|
}
|
|
169
141
|
|
|
170
142
|
// Carrega projeto do .spec-wave.json
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
console.warn(`${
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
let project;
|
|
177
|
-
try {
|
|
178
|
-
project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
|
|
179
|
-
} catch (err) {
|
|
180
|
-
console.warn(`${CONFIG_FILE} corrompido (${err.message}) — board não atualizado.`);
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
if (!project.id) {
|
|
184
|
-
console.warn(`Project não configurado em ${CONFIG_FILE} — board não atualizado.`);
|
|
143
|
+
const { project, error: projectError } = loadProjectConfig();
|
|
144
|
+
if (projectError) {
|
|
145
|
+
console.warn(`${projectError} — board não atualizado.`);
|
|
185
146
|
return;
|
|
186
147
|
}
|
|
187
148
|
|