@spec-wave/cli 0.5.11 → 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 +66 -83
- 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 +134 -24
- 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 +38 -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 +105 -13
- 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
|
@@ -5,13 +5,15 @@ import { execSync } from 'node:child_process';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
7
7
|
import {
|
|
8
|
-
CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW,
|
|
8
|
+
CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW, STAGE_DONE,
|
|
9
9
|
PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
10
10
|
} from '../config.mjs';
|
|
11
|
-
import { getIssue } from '../api/github-rest.mjs';
|
|
11
|
+
import { getIssue, listIssueComments, listBlockedBy } from '../api/github-rest.mjs';
|
|
12
12
|
import { listSubIssues, getIssueParent } from '../api/github-graphql.mjs';
|
|
13
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
14
14
|
import { slugify } from '../lib/slugify.mjs';
|
|
15
|
+
import { parseDependencies } from '../lib/dependencies.mjs';
|
|
16
|
+
import { extractPathsFromPlan, buildCodeDigest } from '../lib/code-digest.mjs';
|
|
15
17
|
|
|
16
18
|
// Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
|
|
17
19
|
const WORK_DIR = '.spec-wave';
|
|
@@ -46,7 +48,10 @@ function readSpecPlan(featureDir) {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
// Monta o markdown de contexto que será entregue ao spec-kit implement.
|
|
49
|
-
function buildContext({
|
|
51
|
+
function buildContext({
|
|
52
|
+
type, issue, tasks, feature, siblingStories = [], spec, plan, specPath, planPath,
|
|
53
|
+
comments = [], codeDigest = null, blockedByWarnings = [],
|
|
54
|
+
}) {
|
|
50
55
|
const lines = [];
|
|
51
56
|
lines.push(`# Contexto de implementação — ${type} #${issue.number}`);
|
|
52
57
|
lines.push('');
|
|
@@ -56,39 +61,52 @@ function buildContext({ type, issue, tasks, feature, siblingStories = [], spec,
|
|
|
56
61
|
lines.push(issue.body.trim());
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
// Dependências ainda abertas — logo após o cabeçalho, para máxima visibilidade.
|
|
65
|
+
if (blockedByWarnings.length > 0) {
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push('## ⚠️ Dependências pendentes');
|
|
68
|
+
lines.push('');
|
|
69
|
+
for (const w of blockedByWarnings) lines.push(`- ${w}`);
|
|
70
|
+
lines.push('');
|
|
71
|
+
lines.push(
|
|
72
|
+
'**Implemente somente se tiver certeza de que a dependência não é bloqueante; ' +
|
|
73
|
+
'caso contrário, pare e reporte.**'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
// Modelo do board: "Etapa" (coluna do kanban) = DIREÇÃO, só avança; "Status"
|
|
60
|
-
// (Todo/In Progress/Done) = PROGRESSO dentro da etapa
|
|
61
|
-
//
|
|
62
|
-
//
|
|
78
|
+
// (Todo/In Progress/Done) = PROGRESSO dentro da etapa. O desenvolvimento de
|
|
79
|
+
// cada Task acontece na Etapa Desenvolvimento (Status In Progress); ao concluir,
|
|
80
|
+
// a Task avança para a Etapa Done (Status Done). A Story avança para Code Review.
|
|
63
81
|
lines.push('');
|
|
64
82
|
lines.push('## Instruções de execução (uma task por vez, sequencial)');
|
|
65
83
|
lines.push('');
|
|
66
84
|
lines.push(
|
|
67
85
|
'Há **dois campos** no board com papéis diferentes — não os confunda:\n' +
|
|
68
|
-
`- **Etapa** (Backlog → … → ${STAGE_DEVELOPMENT} → ${STAGE_CODE_REVIEW} → …): a DIREÇÃO no kanban. Uma issue só **avança**, **nunca** volta para uma etapa anterior.\n` +
|
|
69
|
-
`- **Status** (${PROGRESS_TODO} → ${PROGRESS_IN_PROGRESS} → ${PROGRESS_DONE}): o **progresso dentro da etapa atual**. Ao avançar de etapa, o Status
|
|
86
|
+
`- **Etapa** (Backlog → … → ${STAGE_DEVELOPMENT} → ${STAGE_CODE_REVIEW} → … → ${STAGE_DONE}): a DIREÇÃO no kanban. Uma issue só **avança**, **nunca** volta para uma etapa anterior.\n` +
|
|
87
|
+
`- **Status** (${PROGRESS_TODO} → ${PROGRESS_IN_PROGRESS} → ${PROGRESS_DONE}): o **progresso dentro da etapa atual**. Ao avançar de etapa, o Status reinicia em ${PROGRESS_TODO} — exceto ao chegar na Etapa ${STAGE_DONE}, onde o Status fica **${PROGRESS_DONE}**.`
|
|
70
88
|
);
|
|
71
89
|
lines.push('');
|
|
72
90
|
if (type === 'Story') {
|
|
73
91
|
lines.push(
|
|
74
92
|
`Nesta fase, a Story #${issue.number} e suas Tasks estão na Etapa **${STAGE_DEVELOPMENT}**. ` +
|
|
75
|
-
|
|
93
|
+
`Durante o desenvolvimento de cada Task, mexa no **Status**; ao **concluir** a Task, ela ` +
|
|
94
|
+
`**avança para a Etapa ${STAGE_DONE}** (com Status ${PROGRESS_DONE}).`
|
|
76
95
|
);
|
|
77
96
|
lines.push('');
|
|
78
97
|
lines.push(`1. Garanta que a Story #${issue.number} está na Etapa **${STAGE_DEVELOPMENT}** com Status **${PROGRESS_IN_PROGRESS}**, e que as Tasks estão nessa Etapa com Status **${PROGRESS_TODO}**.`);
|
|
79
98
|
lines.push(`2. Implemente as ${tasks.length} task(s) **uma de cada vez, na ordem abaixo**. É PROIBIDO ter mais de uma Task com Status **${PROGRESS_IN_PROGRESS}** ao mesmo tempo. Para **cada Task**, na ordem:`);
|
|
80
99
|
lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
|
|
81
100
|
lines.push(' 2. **Implemente** a Task por completo.');
|
|
82
|
-
lines.push(` 3. **Ao concluir:**
|
|
101
|
+
lines.push(` 3. **Ao concluir:** **avance a Task para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
|
|
83
102
|
lines.push(' 4. Só então avance para a próxima Task.');
|
|
84
103
|
lines.push('');
|
|
85
|
-
lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks
|
|
104
|
+
lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks na Etapa ${STAGE_DONE}):`);
|
|
86
105
|
lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
|
|
87
106
|
lines.push(` 2. Abra o **Pull Request** da Story #${issue.number}.`);
|
|
88
107
|
lines.push(
|
|
89
|
-
` 3. **Avance a Etapa da Story #${issue.number}
|
|
90
|
-
`(
|
|
91
|
-
`de cada uma para ${PROGRESS_TODO}. (Story e Tasks andam juntas.)`
|
|
108
|
+
` 3. **Avance a Etapa da Story #${issue.number} para ${STAGE_CODE_REVIEW}** ` +
|
|
109
|
+
`(reinicie o Status para ${PROGRESS_TODO}). As Tasks já estão em ${STAGE_DONE}.`
|
|
92
110
|
);
|
|
93
111
|
// A Feature só avança quando TODAS as suas Stories estiverem implementadas.
|
|
94
112
|
lines.push(` 4. **A Feature${feature ? ` #${feature.number}` : ' (issue pai da Story)'} só avança para ${STAGE_CODE_REVIEW} quando TODAS as suas Stories estiverem implementadas:**`);
|
|
@@ -100,21 +118,16 @@ function buildContext({ type, issue, tasks, feature, siblingStories = [], spec,
|
|
|
100
118
|
lines.push(` - Se **qualquer** Story ainda estiver pendente (antes de ${STAGE_CODE_REVIEW}), **NÃO mova a Feature** — deixe-a em ${STAGE_DEVELOPMENT} até a última Story ser concluída.`);
|
|
101
119
|
}
|
|
102
120
|
} else {
|
|
103
|
-
lines.push(
|
|
104
|
-
`Esta Task #${issue.number} está na Etapa **${STAGE_DEVELOPMENT}**. Acompanhe o progresso pelo ` +
|
|
105
|
-
'**Status** — **não** mude a Etapa aqui:'
|
|
106
|
-
);
|
|
121
|
+
lines.push(`Esta Task #${issue.number} está na Etapa **${STAGE_DEVELOPMENT}**:`);
|
|
107
122
|
lines.push('');
|
|
108
|
-
lines.push(`1. **Ao começar:** Status da Task #${issue.number} → **${PROGRESS_IN_PROGRESS}
|
|
123
|
+
lines.push(`1. **Ao começar:** Status da Task #${issue.number} → **${PROGRESS_IN_PROGRESS}** (Etapa continua ${STAGE_DEVELOPMENT}).`);
|
|
109
124
|
lines.push('2. **Implemente** a Task por completo.');
|
|
110
|
-
lines.push(`3. **Ao concluir:**
|
|
111
|
-
lines.push('');
|
|
112
|
-
lines.push(`> A Etapa avança para **${STAGE_CODE_REVIEW}** (Status reinicia em ${PROGRESS_TODO}) junto com a Story/Feature quando o PR é aberto — nunca volta para uma etapa anterior.`);
|
|
125
|
+
lines.push(`3. **Ao concluir:** **avance a Task #${issue.number} para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
|
|
113
126
|
}
|
|
114
127
|
lines.push('');
|
|
115
128
|
lines.push(
|
|
116
129
|
`> **Regra do board:** a **Etapa** só avança (nunca retrocede); o **Status** (${PROGRESS_TODO}/${PROGRESS_IN_PROGRESS}/${PROGRESS_DONE}) ` +
|
|
117
|
-
|
|
130
|
+
`mede o progresso dentro da etapa atual e reinicia a cada avanço (na Etapa ${STAGE_DONE}, o Status fica ${PROGRESS_DONE}).`
|
|
118
131
|
);
|
|
119
132
|
|
|
120
133
|
lines.push('');
|
|
@@ -125,6 +138,42 @@ function buildContext({ type, issue, tasks, feature, siblingStories = [], spec,
|
|
|
125
138
|
if (t.body && t.body.trim()) lines.push(t.body.trim());
|
|
126
139
|
});
|
|
127
140
|
|
|
141
|
+
// Comentários das issues — é onde vivem as revisões/correções feitas depois
|
|
142
|
+
// que spec/plan/stories foram escritos; em conflito, o comentário vence.
|
|
143
|
+
if (comments.length > 0) {
|
|
144
|
+
lines.push('');
|
|
145
|
+
lines.push('## Comentários das issues (revisões e correções)');
|
|
146
|
+
lines.push('');
|
|
147
|
+
lines.push(
|
|
148
|
+
'> Comentários frequentemente **corrigem ou substituem** instruções dos documentos ' +
|
|
149
|
+
'acima — em caso de conflito, o comentário mais recente prevalece.'
|
|
150
|
+
);
|
|
151
|
+
for (const group of comments) {
|
|
152
|
+
lines.push('');
|
|
153
|
+
lines.push(`### Comentários da ${group.kind} #${group.issueNumber}`);
|
|
154
|
+
if (group.total > group.items.length) {
|
|
155
|
+
lines.push('');
|
|
156
|
+
lines.push(`_(mostrando os ${group.items.length} mais recentes de ${group.total})_`);
|
|
157
|
+
}
|
|
158
|
+
for (const c of group.items) {
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push(`**${c.author || 'desconhecido'}** (${c.createdAt}):`);
|
|
161
|
+
lines.push('');
|
|
162
|
+
lines.push(c.body.trim());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Estado atual do código — evita reimplementar módulos que já existem.
|
|
168
|
+
if (codeDigest) {
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push('## Estado atual do código');
|
|
171
|
+
lines.push('');
|
|
172
|
+
lines.push('> **NÃO reimplemente o que já existe; estenda os módulos listados abaixo.**');
|
|
173
|
+
lines.push('');
|
|
174
|
+
lines.push(codeDigest.trim());
|
|
175
|
+
}
|
|
176
|
+
|
|
128
177
|
if (spec) {
|
|
129
178
|
lines.push('');
|
|
130
179
|
lines.push(`## spec.md (${specPath})`);
|
|
@@ -246,8 +295,69 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
246
295
|
.map(s => ({ number: s.number, title: s.title }));
|
|
247
296
|
}
|
|
248
297
|
|
|
298
|
+
// 4c. Comentários das issues (best-effort) — revisões e correções vivem nos
|
|
299
|
+
// comentários, não no body; sem eles o agente implementa instruções já
|
|
300
|
+
// corrigidas. Feature primeiro (correções de escopo), depois a issue-alvo.
|
|
301
|
+
const MAX_COMMENTS_PER_ISSUE = 15;
|
|
302
|
+
const MAX_COMMENT_CHARS = 2000;
|
|
303
|
+
const comments = [];
|
|
304
|
+
const commentSources = [];
|
|
305
|
+
if (feature && feature.number !== issue.number) {
|
|
306
|
+
commentSources.push({ number: feature.number, kind: 'Feature' });
|
|
307
|
+
}
|
|
308
|
+
commentSources.push({ number: issue.number, kind: type });
|
|
309
|
+
for (const src of commentSources) {
|
|
310
|
+
const all = await listIssueComments(token, owner, repo, src.number).catch(() => []);
|
|
311
|
+
if (all.length === 0) continue;
|
|
312
|
+
const items = all.slice(-MAX_COMMENTS_PER_ISSUE).map(c => ({
|
|
313
|
+
...c,
|
|
314
|
+
body: c.body.length > MAX_COMMENT_CHARS
|
|
315
|
+
? `${c.body.slice(0, MAX_COMMENT_CHARS)}…[truncado]`
|
|
316
|
+
: c.body,
|
|
317
|
+
}));
|
|
318
|
+
comments.push({ issueNumber: src.number, kind: src.kind, total: all.length, items });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 4d. Digest do estado do código (best-effort) — commits desde a criação da
|
|
322
|
+
// Feature + árvore dos módulos citados no plan, para o agente estender o que
|
|
323
|
+
// já existe em vez de reimplementar.
|
|
324
|
+
let codeDigest = null;
|
|
325
|
+
try {
|
|
326
|
+
let sinceIso = issue.created_at || null;
|
|
327
|
+
if (feature?.number) {
|
|
328
|
+
const featureIssue = await getIssue(token, owner, repo, feature.number).catch(() => null);
|
|
329
|
+
if (featureIssue?.created_at) sinceIso = featureIssue.created_at;
|
|
330
|
+
}
|
|
331
|
+
const paths = specPlan.plan ? extractPathsFromPlan(specPlan.plan) : [];
|
|
332
|
+
codeDigest = await buildCodeDigest({ sinceIso, paths });
|
|
333
|
+
} catch {
|
|
334
|
+
codeDigest = null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 4e. Dependências pendentes (best-effort) — une a linha "Depende de:" do
|
|
338
|
+
// body com a relação nativa blocked_by do GitHub; considera pendente toda
|
|
339
|
+
// dependência ainda não fechada.
|
|
340
|
+
const blockedByWarnings = [];
|
|
341
|
+
try {
|
|
342
|
+
const depNumbers = new Set(parseDependencies(issue.body));
|
|
343
|
+
const blocked = await listBlockedBy(token, owner, repo, issue.number).catch(() => []);
|
|
344
|
+
for (const b of blocked) depNumbers.add(b.number);
|
|
345
|
+
for (const depNumber of [...depNumbers].sort((a, b) => a - b)) {
|
|
346
|
+
const dep = await getIssue(token, owner, repo, depNumber).catch(() => null);
|
|
347
|
+
if (!dep || dep.state === 'closed') continue;
|
|
348
|
+
const warning =
|
|
349
|
+
`${type} #${issue.number} depende de #${depNumber} («${dep.title}»), ` +
|
|
350
|
+
`que ainda não está concluída (state: ${dep.state}).`;
|
|
351
|
+
blockedByWarnings.push(warning);
|
|
352
|
+
p.log.warn(warning);
|
|
353
|
+
}
|
|
354
|
+
} catch { /* aviso é best-effort — segue sem ele */ }
|
|
355
|
+
|
|
249
356
|
// 5. Monta e grava o arquivo de contexto.
|
|
250
|
-
const context = buildContext({
|
|
357
|
+
const context = buildContext({
|
|
358
|
+
type, issue, tasks, feature, siblingStories, ...specPlan,
|
|
359
|
+
comments, codeDigest, blockedByWarnings,
|
|
360
|
+
});
|
|
251
361
|
mkdirSync(WORK_DIR, { recursive: true });
|
|
252
362
|
const tasksFile = path.join(WORK_DIR, `implement-${issueNumber}.md`);
|
|
253
363
|
writeFileSync(tasksFile, context);
|
package/src/commands/init.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { setupProject } from '../setup/project.mjs';
|
|
|
9
9
|
import { setupLabels } from '../setup/labels.mjs';
|
|
10
10
|
import { setupFiles } from '../setup/files.mjs';
|
|
11
11
|
import { getFileContent } from '../api/github-rest.mjs';
|
|
12
|
-
import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER, PORTAL_URL } from '../config.mjs';
|
|
12
|
+
import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER, PORTAL_URL, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES } from '../config.mjs';
|
|
13
13
|
|
|
14
14
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
|
|
@@ -160,7 +160,7 @@ export async function init(options) {
|
|
|
160
160
|
filesSpinner.start('Criando arquivos no repositório...');
|
|
161
161
|
try {
|
|
162
162
|
await setupFiles(token, owner, repo, filesSpinner);
|
|
163
|
-
filesSpinner.stop(
|
|
163
|
+
filesSpinner.stop(`Arquivos criados (${WORKFLOW_FILES.length} workflows + ${ISSUE_TEMPLATE_FILES.length} issue templates)`);
|
|
164
164
|
} catch (err) {
|
|
165
165
|
filesSpinner.stop('');
|
|
166
166
|
p.log.error(`Erro ao criar arquivos: ${err.message}`);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Ordena as Stories de uma Feature pelas dependências (topológica, Kahn —
|
|
2
|
+
// ver orderStories em src/lib/dependencies.mjs). As dependências vêm de duas
|
|
3
|
+
// fontes, mescladas: a linha "Depende de: #N" no corpo da Story e a relação
|
|
4
|
+
// nativa blocked_by do GitHub.
|
|
5
|
+
//
|
|
6
|
+
// Comando LOCAL — rodado pelo dev no terminal. owner/repo vêm da env
|
|
7
|
+
// GITHUB_REPOSITORY quando existir, senão do .spec-wave.json (gravado pelo init).
|
|
8
|
+
import * as p from '@clack/prompts';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
13
|
+
import { getIssue, listBlockedBy } from '../api/github-rest.mjs';
|
|
14
|
+
import { addProjectItem, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
15
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
16
|
+
import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
|
|
17
|
+
import { loadProjectConfig, resolveField } from '../lib/board.mjs';
|
|
18
|
+
import { CONFIG_FILE, STAGE_ORDER, STAGE_DEVELOPMENT, STAGE_DONE } from '../config.mjs';
|
|
19
|
+
|
|
20
|
+
// Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
|
|
21
|
+
// fallback no .spec-wave.json — comandos locais rodam sem essa env.
|
|
22
|
+
function resolveRepoContext() {
|
|
23
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
24
|
+
let cfg = {};
|
|
25
|
+
const cfgPath = path.join(process.cwd(), CONFIG_FILE);
|
|
26
|
+
try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
|
|
27
|
+
return { owner: envOwner || cfg.owner, repo: envRepo || cfg.repo };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function order({ feature: featureArg }) {
|
|
31
|
+
const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
|
|
32
|
+
if (!Number.isInteger(featureNumber) || featureNumber <= 0) {
|
|
33
|
+
p.log.error(`Feature inválida: "${featureArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { owner, repo } = resolveRepoContext();
|
|
39
|
+
if (!owner || !repo) {
|
|
40
|
+
p.log.error(
|
|
41
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
42
|
+
`Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
|
|
43
|
+
);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let token;
|
|
49
|
+
try {
|
|
50
|
+
token = await resolveToken();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
p.log.error(err.message);
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
p.intro(chalk.bold(`spec-wave order #${featureNumber}`));
|
|
58
|
+
|
|
59
|
+
// 1. Lê a Feature e valida o tipo.
|
|
60
|
+
let featureIssue;
|
|
61
|
+
try {
|
|
62
|
+
featureIssue = await getIssue(token, owner, repo, featureNumber);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
p.log.error(`Não foi possível ler a issue #${featureNumber}: ${err.message}`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const type = detectIssueType(featureIssue);
|
|
69
|
+
if (type !== 'Feature') {
|
|
70
|
+
p.log.error(
|
|
71
|
+
`\`spec-wave order\` só aceita issues do tipo Feature. ` +
|
|
72
|
+
`Issue #${featureNumber} é do tipo ${type || 'desconhecido'} (${featureIssue.title}).`
|
|
73
|
+
);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Sub-issues da Feature → só as Stories entram na ordenação.
|
|
79
|
+
let subs;
|
|
80
|
+
try {
|
|
81
|
+
subs = await listSubIssues(token, featureIssue.node_id);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
p.log.error(`Não foi possível listar as sub-issues da Feature #${featureNumber}: ${err.message}`);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const stories = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
|
|
88
|
+
if (stories.length === 0) {
|
|
89
|
+
p.log.info(`Feature #${featureNumber} não tem Stories (sub-issues) — nada a ordenar. Rode \`spec-wave decompose\` antes.`);
|
|
90
|
+
p.outro('Nada a fazer.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 3. Dependências de cada Story: linha "Depende de: #N" do corpo (buscando o
|
|
95
|
+
// corpo via getIssue quando listSubIssues não o trouxer) mesclada com a
|
|
96
|
+
// relação nativa blocked_by (falha na API → sem bloqueios, não interrompe).
|
|
97
|
+
const enriched = await Promise.all(stories.map(async (s) => {
|
|
98
|
+
let body = s.body;
|
|
99
|
+
if (!body) {
|
|
100
|
+
body = (await getIssue(token, owner, repo, s.number).catch(() => null))?.body || '';
|
|
101
|
+
}
|
|
102
|
+
const fromBody = parseDependencies(body);
|
|
103
|
+
const fromBlockedBy = (await listBlockedBy(token, owner, repo, s.number).catch(() => []))
|
|
104
|
+
.map(b => b.number);
|
|
105
|
+
const dependsOn = [...new Set([...fromBody, ...fromBlockedBy])];
|
|
106
|
+
return { number: s.number, title: s.title, nodeId: s.nodeId, dependsOn };
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
// 4. Etapa atual de cada Story no board (falha em qualquer leitura → '—').
|
|
110
|
+
const stageOf = new Map();
|
|
111
|
+
const { project, error: projectError } = loadProjectConfig();
|
|
112
|
+
if (projectError) {
|
|
113
|
+
p.log.warn(`${projectError} — Etapas do board não consultadas.`);
|
|
114
|
+
} else {
|
|
115
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
116
|
+
if (etapaField?.id) {
|
|
117
|
+
await Promise.all(enriched.map(async (s) => {
|
|
118
|
+
try {
|
|
119
|
+
const itemId = await addProjectItem(token, project.id, s.nodeId);
|
|
120
|
+
stageOf.set(s.number, await getItemSingleSelectValue(token, itemId, etapaField.id));
|
|
121
|
+
} catch {
|
|
122
|
+
stageOf.set(s.number, null);
|
|
123
|
+
}
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 5. Ordenação topológica (nunca lança; ciclo vem em `cycle`).
|
|
129
|
+
const byNumber = new Map(enriched.map(s => [s.number, s]));
|
|
130
|
+
const { order: sorted, cycle } = orderStories(enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
|
|
131
|
+
|
|
132
|
+
if (cycle.length > 0) {
|
|
133
|
+
p.log.warn(
|
|
134
|
+
chalk.yellow.bold('⚠ CICLO DE DEPENDÊNCIAS detectado!') + '\n' +
|
|
135
|
+
`Stories envolvidas (ou bloqueadas pelo ciclo): ${cycle.map(n => `#${n}`).join(', ')}.\n` +
|
|
136
|
+
'Elas ficaram fora da ordem abaixo — corrija as linhas "Depende de" (ou as relações blocked_by) dessas issues.'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const line = (n, i) => {
|
|
141
|
+
const s = byNumber.get(n);
|
|
142
|
+
const stage = stageOf.get(n) || '—';
|
|
143
|
+
const deps = s.dependsOn.length > 0
|
|
144
|
+
? ` ${chalk.dim(`← depende de ${s.dependsOn.map(d => `#${d}`).join(', ')}`)}`
|
|
145
|
+
: '';
|
|
146
|
+
return `${String(i + 1).padStart(2)}. #${n} ${s.title}\n Etapa: ${stage}${deps}`;
|
|
147
|
+
};
|
|
148
|
+
p.note(sorted.map(line).join('\n'), `Ordem de execução das Stories da Feature #${featureNumber}`);
|
|
149
|
+
|
|
150
|
+
// 6. Aviso final: dependente já em Desenvolvimento+ com dependência não-Done.
|
|
151
|
+
const devIdx = STAGE_ORDER.indexOf(STAGE_DEVELOPMENT);
|
|
152
|
+
const outOfOrder = [];
|
|
153
|
+
for (const s of enriched) {
|
|
154
|
+
const stage = stageOf.get(s.number);
|
|
155
|
+
const idx = stage ? STAGE_ORDER.indexOf(stage) : -1;
|
|
156
|
+
if (idx === -1 || idx < devIdx) continue; // ainda não chegou em Desenvolvimento
|
|
157
|
+
for (const d of s.dependsOn) {
|
|
158
|
+
if (!byNumber.has(d)) continue; // dependência externa ao conjunto — sem Etapa conhecida
|
|
159
|
+
const depStage = stageOf.get(d);
|
|
160
|
+
if (depStage !== STAGE_DONE) {
|
|
161
|
+
outOfOrder.push(
|
|
162
|
+
`#${s.number} já está em "${stage}", mas depende de #${d} (Etapa: ${depStage || '—'}), que ainda não chegou em "${STAGE_DONE}".`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (outOfOrder.length > 0) {
|
|
168
|
+
p.log.warn('Dependências fora de ordem:\n' + outOfOrder.map(w => ` • ${w}`).join('\n'));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
|
|
172
|
+
}
|
package/src/commands/qa.mjs
CHANGED
|
@@ -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 {
|
|
5
|
+
import { getIssueParent } 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, PROGRESS_TODO } from '../config.mjs';
|
|
8
9
|
|
|
9
10
|
const QA_STAGE = STATUS_OPTIONS.find(s => s.name.includes('QA'))?.name;
|
|
10
11
|
const TODO_STATUS = PROGRESS_TODO;
|
|
@@ -20,14 +21,6 @@ function extractIssueNumbers(body) {
|
|
|
20
21
|
return [...nums];
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
async function resolveField(token, project, name) {
|
|
24
|
-
if (project.fields?.[name]) return project.fields[name];
|
|
25
|
-
if (name === 'Etapa' && project.etapaFieldId) {
|
|
26
|
-
return { id: project.etapaFieldId, options: project.stageOptions || {} };
|
|
27
|
-
}
|
|
28
|
-
return await getSingleSelectField(token, project.id, name);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
24
|
async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
32
25
|
let issue;
|
|
33
26
|
try {
|
|
@@ -50,27 +43,6 @@ async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
|
50
43
|
return null;
|
|
51
44
|
}
|
|
52
45
|
|
|
53
|
-
// Avança para a Etapa "🧪 QA" e reinicia o Status para "Todo". Uma issue só
|
|
54
|
-
// AVANÇA: se já estiver em QA ou etapa posterior, não é tocada (retorna false).
|
|
55
|
-
async function setQA(token, project, etapaField, statusField, nodeId) {
|
|
56
|
-
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
57
|
-
if (etapaField?.id && QA_STAGE) {
|
|
58
|
-
const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
59
|
-
const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
|
|
60
|
-
const tgtIdx = STAGE_ORDER.indexOf(QA_STAGE);
|
|
61
|
-
if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
|
|
62
|
-
return false; // já está em QA ou adiante — não retrocede
|
|
63
|
-
}
|
|
64
|
-
const optionId = etapaField.options?.[QA_STAGE];
|
|
65
|
-
if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
66
|
-
}
|
|
67
|
-
if (statusField?.id) {
|
|
68
|
-
const optionId = statusField.options?.[TODO_STATUS];
|
|
69
|
-
if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
70
|
-
}
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
46
|
export async function qa({ prNumber }) {
|
|
75
47
|
const token = await resolveToken();
|
|
76
48
|
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
@@ -96,20 +68,9 @@ export async function qa({ prNumber }) {
|
|
|
96
68
|
return;
|
|
97
69
|
}
|
|
98
70
|
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
101
|
-
console.warn(`${
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
let project;
|
|
105
|
-
try {
|
|
106
|
-
project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
|
|
107
|
-
} catch (err) {
|
|
108
|
-
console.warn(`${CONFIG_FILE} corrompido (${err.message}) — board não atualizado.`);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (!project.id) {
|
|
112
|
-
console.warn(`Project não configurado em ${CONFIG_FILE} — board não atualizado.`);
|
|
71
|
+
const { project, error: projectError } = loadProjectConfig();
|
|
72
|
+
if (projectError) {
|
|
73
|
+
console.warn(`${projectError} — board não atualizado.`);
|
|
113
74
|
return;
|
|
114
75
|
}
|
|
115
76
|
|
|
@@ -124,7 +85,8 @@ export async function qa({ prNumber }) {
|
|
|
124
85
|
if (!feature || seen.has(feature.number)) continue;
|
|
125
86
|
seen.add(feature.number);
|
|
126
87
|
try {
|
|
127
|
-
|
|
88
|
+
// Avança para "🧪 QA" e reinicia o Status em "Todo" (nunca retrocede).
|
|
89
|
+
const moved = await advanceToStage(projectToken, project, etapaField, statusField, feature.node_id, QA_STAGE, TODO_STATUS);
|
|
128
90
|
if (moved) {
|
|
129
91
|
updated.push(`#${feature.number} ${feature.title}`);
|
|
130
92
|
console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Gerencia uma Story no board: review (Etapa "👀 Code Review" + Status "Todo").
|
|
2
|
+
//
|
|
3
|
+
// Comando LOCAL — rodado pelo dev no terminal. owner/repo vêm da env
|
|
4
|
+
// GITHUB_REPOSITORY quando existir, senão do .spec-wave.json (gravado pelo init).
|
|
5
|
+
//
|
|
6
|
+
// Regra do board (ver config.mjs): a Etapa nunca retrocede — se a Story já
|
|
7
|
+
// estiver em Code Review ou adiante, o comando apenas informa a Etapa atual.
|
|
8
|
+
import * as p from '@clack/prompts';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
13
|
+
import { getIssue } from '../api/github-rest.mjs';
|
|
14
|
+
import { addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
15
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
16
|
+
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
17
|
+
import { CONFIG_FILE, STAGE_CODE_REVIEW, PROGRESS_TODO } from '../config.mjs';
|
|
18
|
+
|
|
19
|
+
// Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
|
|
20
|
+
// fallback no .spec-wave.json — comandos locais rodam sem essa env.
|
|
21
|
+
function resolveRepoContext() {
|
|
22
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
23
|
+
let cfg = {};
|
|
24
|
+
const cfgPath = path.join(process.cwd(), CONFIG_FILE);
|
|
25
|
+
try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
|
|
26
|
+
return { owner: envOwner || cfg.owner, repo: envRepo || cfg.repo };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function story({ action, issue: issueArg }) {
|
|
30
|
+
if (action !== 'review') {
|
|
31
|
+
p.log.error(
|
|
32
|
+
`Ação desconhecida: "${action}".\n` +
|
|
33
|
+
'Uso: spec-wave story review <número> — ex.: spec-wave story review 12'
|
|
34
|
+
);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
40
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
41
|
+
p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { owner, repo } = resolveRepoContext();
|
|
47
|
+
if (!owner || !repo) {
|
|
48
|
+
p.log.error(
|
|
49
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
50
|
+
`Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
|
|
51
|
+
);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let token;
|
|
57
|
+
try {
|
|
58
|
+
token = await resolveToken();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
p.log.error(err.message);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
p.intro(chalk.bold(`spec-wave story review #${issueNumber}`));
|
|
66
|
+
|
|
67
|
+
// 1. Lê a issue e valida o tipo.
|
|
68
|
+
let issue;
|
|
69
|
+
try {
|
|
70
|
+
issue = await getIssue(token, owner, repo, issueNumber);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const type = detectIssueType(issue);
|
|
77
|
+
if (type !== 'Story') {
|
|
78
|
+
p.log.error(
|
|
79
|
+
`\`spec-wave story\` só aceita issues do tipo Story. ` +
|
|
80
|
+
`Issue #${issueNumber} é do tipo ${type || 'desconhecido'} (${issue.title}).`
|
|
81
|
+
);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. Project do .spec-wave.json — sem ele não há board para atualizar.
|
|
87
|
+
const { project, error: projectError } = loadProjectConfig();
|
|
88
|
+
if (projectError) {
|
|
89
|
+
p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
94
|
+
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
95
|
+
|
|
96
|
+
// 3. Avança para Code Review (Status reinicia em Todo ao trocar de etapa).
|
|
97
|
+
let moved;
|
|
98
|
+
try {
|
|
99
|
+
moved = await advanceToStage(token, project, etapaField, statusField, issue.node_id, STAGE_CODE_REVIEW, PROGRESS_TODO);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
p.log.error(`Falha ao atualizar o board: ${err.message}`);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (moved) {
|
|
107
|
+
p.log.success(`Story #${issueNumber} → Etapa ${chalk.bold(STAGE_CODE_REVIEW)} / Status ${chalk.bold(PROGRESS_TODO)}.`);
|
|
108
|
+
p.outro(`${chalk.green('✓')} Story #${issueNumber} pronta para revisão.`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// false = a Story já está em Code Review ou em etapa posterior — e a Etapa
|
|
113
|
+
// NUNCA retrocede. Lê a Etapa atual só para informar o usuário.
|
|
114
|
+
let current = null;
|
|
115
|
+
if (etapaField?.id) {
|
|
116
|
+
try {
|
|
117
|
+
const itemId = await addProjectItem(token, project.id, issue.node_id);
|
|
118
|
+
current = await getItemSingleSelectValue(token, itemId, etapaField.id);
|
|
119
|
+
} catch {
|
|
120
|
+
// sem leitura da Etapa — segue com o aviso genérico
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
p.log.info(
|
|
124
|
+
`Story #${issueNumber} não foi movida: a Etapa nunca retrocede, e ela já está em ` +
|
|
125
|
+
`${chalk.bold(current || `"${STAGE_CODE_REVIEW}" ou etapa posterior`)}.`
|
|
126
|
+
);
|
|
127
|
+
p.outro('Nada a fazer.');
|
|
128
|
+
}
|