@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.
@@ -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({ type, issue, tasks, feature, siblingStories = [], spec, plan, specPath, planPath }) {
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, reinicia a cada avanço.
61
- // O desenvolvimento acontece na Etapa 🚧 Desenvolvimento; o progresso por task
62
- // é registrado no STATUS. Ao final, a Etapa avança para Code Review.
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 **reinicia em ${PROGRESS_TODO}**.`
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
- 'Durante a implementação, mexa **apenas no Status** das Tasks — **não** mude a Etapa delas.'
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:** Status da Task **${PROGRESS_DONE}** (ainda na Etapa ${STAGE_DEVELOPMENT}).`);
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 com Status ${PROGRESS_DONE}):`);
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} e de todas as suas ${tasks.length} Task(s) ` +
90
- `(${tasks.map(t => `#${t.number}`).join(', ')}) para ${STAGE_CODE_REVIEW}**, reiniciando o Status ` +
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 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:** Status da Task #${issue.number} **${PROGRESS_DONE}**.`);
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
- 'só mede o progresso dentro da etapa atual e reinicia a cada avanço de etapa.'
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({ type, issue, tasks, feature, siblingStories, ...specPlan });
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);
@@ -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('Arquivos criados (4 workflows + 2 issue templates)');
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
+ }
@@ -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, setItemSingleSelect, getSingleSelectField, getIssueParent, getItemSingleSelectValue } from '../api/github-graphql.mjs';
5
+ import { getIssueParent } from '../api/github-graphql.mjs';
6
6
  import { detectIssueType } from '../lib/issue-type.mjs';
7
- import { CONFIG_FILE, STATUS_OPTIONS, STAGE_ORDER, PROGRESS_TODO } from '../config.mjs';
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 configPath = path.join(process.cwd(), CONFIG_FILE);
100
- if (!existsSync(configPath)) {
101
- console.warn(`${CONFIG_FILE} não encontrado — board não atualizado.`);
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
- const moved = await setQA(projectToken, project, etapaField, statusField, feature.node_id);
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
+ }