@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
|
@@ -8,10 +8,12 @@ import {
|
|
|
8
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,6 +61,19 @@ 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
78
|
// (Todo/In Progress/Done) = PROGRESSO dentro da etapa. O desenvolvimento de
|
|
61
79
|
// cada Task acontece na Etapa Desenvolvimento (Status In Progress); ao concluir,
|
|
@@ -120,6 +138,42 @@ function buildContext({ type, issue, tasks, feature, siblingStories = [], spec,
|
|
|
120
138
|
if (t.body && t.body.trim()) lines.push(t.body.trim());
|
|
121
139
|
});
|
|
122
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
|
+
|
|
123
177
|
if (spec) {
|
|
124
178
|
lines.push('');
|
|
125
179
|
lines.push(`## spec.md (${specPath})`);
|
|
@@ -241,8 +295,69 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
241
295
|
.map(s => ({ number: s.number, title: s.title }));
|
|
242
296
|
}
|
|
243
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
|
+
|
|
244
356
|
// 5. Monta e grava o arquivo de contexto.
|
|
245
|
-
const context = buildContext({
|
|
357
|
+
const context = buildContext({
|
|
358
|
+
type, issue, tasks, feature, siblingStories, ...specPlan,
|
|
359
|
+
comments, codeDigest, blockedByWarnings,
|
|
360
|
+
});
|
|
246
361
|
mkdirSync(WORK_DIR, { recursive: true });
|
|
247
362
|
const tasksFile = path.join(WORK_DIR, `implement-${issueNumber}.md`);
|
|
248
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
|
+
}
|