@spec-wave/cli 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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({ 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,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({ type, issue, tasks, feature, siblingStories, ...specPlan });
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);
@@ -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
+ }