@spec-wave/cli 0.6.0 → 0.7.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/bin/spec-wave.mjs +37 -0
- package/package.json +4 -1
- package/src/api/github-rest.mjs +45 -1
- package/src/commands/code-review.mjs +13 -52
- package/src/commands/decompose.mjs +278 -61
- package/src/commands/doctor.mjs +415 -0
- package/src/commands/generate-plan.mjs +58 -3
- package/src/commands/generate-spec.mjs +35 -2
- 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 +71 -11
- package/src/lib/code-digest.mjs +183 -0
- package/src/lib/critique.mjs +158 -0
- package/src/lib/dependencies.mjs +92 -0
- package/src/lib/output-lint.mjs +92 -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.0",
|
|
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,48 @@ 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: [{ author, body, createdAt }].
|
|
154
|
+
// Paginado — traz o histórico completo (usado pela crítica adversarial).
|
|
155
|
+
export async function listIssueComments(token, owner, repo, issueNumber) {
|
|
156
|
+
const octokit = makeOctokit(token);
|
|
157
|
+
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
|
|
158
|
+
owner,
|
|
159
|
+
repo,
|
|
160
|
+
issue_number: issueNumber,
|
|
161
|
+
per_page: 100,
|
|
162
|
+
});
|
|
163
|
+
return comments.map(c => ({
|
|
164
|
+
author: c.user?.login || '',
|
|
165
|
+
body: c.body || '',
|
|
166
|
+
createdAt: c.created_at,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Marca uma issue como bloqueada por outra (relação nativa do GitHub).
|
|
171
|
+
// `blockingIssueId` é o DATABASE id da issue bloqueadora (não o number nem o
|
|
172
|
+
// node id — ver createIssue). Erros propagam: o chamador usa como fallback.
|
|
173
|
+
export async function addBlockedBy(token, owner, repo, issueNumber, blockingIssueId) {
|
|
174
|
+
const octokit = makeOctokit(token);
|
|
175
|
+
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by', {
|
|
176
|
+
owner,
|
|
177
|
+
repo,
|
|
178
|
+
issue_number: issueNumber,
|
|
179
|
+
issue_id: blockingIssueId,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Lista as issues que bloqueiam `issueNumber`: [{ number, title, state, id }].
|
|
184
|
+
export async function listBlockedBy(token, owner, repo, issueNumber) {
|
|
185
|
+
const octokit = makeOctokit(token);
|
|
186
|
+
const issues = await octokit.paginate('GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by', {
|
|
187
|
+
owner,
|
|
188
|
+
repo,
|
|
189
|
+
issue_number: issueNumber,
|
|
190
|
+
per_page: 100,
|
|
191
|
+
});
|
|
192
|
+
return issues.map(i => ({ number: i.number, title: i.title, state: i.state, id: i.id }));
|
|
193
|
+
}
|
|
194
|
+
|
|
151
195
|
export async function commentOnIssue(token, owner, repo, issueNumber, body) {
|
|
152
196
|
const octokit = makeOctokit(token);
|
|
153
197
|
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
|
|