@spec-wave/cli 0.29.0 → 0.32.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.
Files changed (44) hide show
  1. package/package.json +5 -3
  2. package/protocol/qa-result.v1.json +62 -0
  3. package/protocol/qa-trail-report.v1.json +113 -0
  4. package/src/api/github-graphql.mjs +6 -1
  5. package/src/api/github-rest.mjs +21 -0
  6. package/src/cli.mjs +114 -9
  7. package/src/commands/decompose.mjs +29 -3
  8. package/src/commands/doctor.mjs +183 -3
  9. package/src/commands/generate-qa-plan.mjs +421 -0
  10. package/src/commands/implement.mjs +56 -44
  11. package/src/commands/merge.mjs +43 -14
  12. package/src/commands/order.mjs +350 -96
  13. package/src/commands/qa-lead.mjs +748 -0
  14. package/src/commands/qa-run.mjs +892 -0
  15. package/src/commands/run.mjs +5 -1
  16. package/src/config.mjs +32 -1
  17. package/src/lib/artifact-pr.mjs +2 -0
  18. package/src/lib/artifact-publish.mjs +5 -2
  19. package/src/lib/board.mjs +14 -0
  20. package/src/lib/critique.mjs +38 -9
  21. package/src/lib/decomposition-doc.mjs +5 -1
  22. package/src/lib/dependency-map.mjs +300 -0
  23. package/src/lib/doc-paths.mjs +9 -2
  24. package/src/lib/git-retry.mjs +82 -0
  25. package/src/lib/net-cache.mjs +142 -0
  26. package/src/lib/next-step.mjs +15 -3
  27. package/src/lib/qa-exec.mjs +335 -0
  28. package/src/lib/qa-lead-backend.mjs +213 -0
  29. package/src/lib/qa-lead.mjs +627 -0
  30. package/src/lib/qa-plan-doc.mjs +340 -0
  31. package/src/lib/qa-report.mjs +396 -0
  32. package/src/lib/skill-compose.mjs +234 -0
  33. package/src/lib/story-graph.mjs +256 -0
  34. package/src/plugin/.claude-plugin/plugin.json +1 -1
  35. package/src/plugin/skills/merge/SKILL.md +1 -0
  36. package/src/plugin/skills/order/SKILL.md +21 -5
  37. package/src/plugin/skills/qa/SKILL.md +107 -0
  38. package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
  39. package/src/plugin/skills/qa/model-prompt.md +68 -0
  40. package/src/plugin/skills/qa-executor/SKILL.md +76 -0
  41. package/src/plugin/skills/qa-lead/SKILL.md +89 -0
  42. package/src/templates/skill/SKILL.md +981 -279
  43. package/src/templates/skill/core.md +584 -0
  44. package/src/templates/workflows/generate-qa-plan.yml +64 -0
@@ -0,0 +1,892 @@
1
+ // Execução LOCAL do plano de QA — `spec-wave qa <issue>` (spec rfc/spec-qa-skill.md).
2
+ //
3
+ // D-QA2: a geração do plano é label + Action (generate-qa-plan); a EXECUÇÃO é
4
+ // sempre local — QA de verdade roda contra um checkout. O comando monta o
5
+ // contexto em `.spec-wave/qa-<n>.md`, aciona o executor configurado em
6
+ // `qa.command` (mesmo padrão do `implement`/specKit) e lê o veredito do arquivo
7
+ // de resultados que o executor grava.
8
+ //
9
+ // D-QA3/D-QA4: o veredito VERDE avança a Etapa sozinho — por isso o portão
10
+ // humano fica antes, na revisão do plano (`spec-wave:qa-ready`), e os portões
11
+ // de execução (lib/qa-exec.mjs) recusam tudo que tornaria o verde automático
12
+ // perigoso.
13
+
14
+ import { execSync } from 'node:child_process';
15
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import chalk from 'chalk';
18
+
19
+ import { resolveToken } from '../api/auth.mjs';
20
+ import {
21
+ getIssue, createIssue, addLabel, commentOnIssue, listIssueComments,
22
+ } from '../api/github-rest.mjs';
23
+ import {
24
+ getIssueParent, listSubIssues, listIssuePullRequests, addSubIssue,
25
+ addProjectItem, getItemSingleSelectValue, setItemSingleSelect,
26
+ } from '../api/github-graphql.mjs';
27
+ import { detectIssueType } from '../lib/issue-type.mjs';
28
+ import { loadConfig, findConfigPath } from '../lib/project-root.mjs';
29
+ import { loadProjectConfig, resolveField, advanceToStage, setItemStatus } from '../lib/board.mjs';
30
+ import { loadArtifact, isAwaitingMerge } from '../lib/doc-source.mjs';
31
+ import { awaitingMergeBlock } from '../lib/artifact-pr.mjs';
32
+ import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
33
+ import { parseQaPlanDoc, resolveTargetScenarios } from '../lib/qa-plan-doc.mjs';
34
+ import {
35
+ qaExecutionGate, greenTargetStage, storyCanAdvance, featureCanAdvanceQa,
36
+ extractRegressionSection, renderQaCommand, buildQaContext, qaProcessEnv,
37
+ } from '../lib/qa-exec.mjs';
38
+ import { pushWithRebase } from '../lib/git-retry.mjs';
39
+ import {
40
+ aggregateVerdict, validateQaResults, combineWithPrevious, parseLastQaReport,
41
+ renderQaReport, renderQaBugDoc, qaOriginMarker, matchesQaOrigin, shortSha,
42
+ } from '../lib/qa-report.mjs';
43
+ import {
44
+ CONFIG_FILE, STAGE_QA, STAGE_UAT, STAGE_READY, PROGRESS_TODO, PROGRESS_IN_PROGRESS,
45
+ LABEL_QA_APPROVED, LABEL_BUG_APPROVED, PRIORITY_LABELS, bugOriginLabel, labelNames,
46
+ } from '../config.mjs';
47
+
48
+ const WORK_DIR = '.spec-wave';
49
+ const MAX_COMMENTS = 15;
50
+ const MAX_COMMENT_CHARS = 2000;
51
+ const VALID_SEVERITIES = PRIORITY_LABELS.map(l => l.name);
52
+
53
+ // Recusa "esperada": mensagem para o usuário, exit 1, sem stack trace.
54
+ class QaRefusal extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = 'QaRefusal';
58
+ }
59
+ }
60
+
61
+ async function resolveParentFeature(token, startNodeId) {
62
+ let current = startNodeId;
63
+ for (let depth = 0; depth < 5 && current; depth++) {
64
+ const parent = await getIssueParent(token, current);
65
+ if (!parent) return null;
66
+ if (detectIssueType({ title: parent.title }) === 'Feature') return parent;
67
+ current = parent.nodeId;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // Campos do board, resolvidos uma vez. Best-effort: sem board, os movimentos
73
+ // viram avisos (o veredito e o relatório não dependem dele).
74
+ async function resolveBoard(root, projectToken) {
75
+ const { project, error } = loadProjectConfig({ cwd: root || process.cwd() });
76
+ if (error || !project?.id) {
77
+ return { project: null, error: error || 'Project não configurado', etapaField: null, statusField: null, typeField: null };
78
+ }
79
+ const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
80
+ const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
81
+ const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
82
+ const priorityField = await resolveField(projectToken, project, 'Priority').catch(() => null);
83
+ return { project, error: null, etapaField, statusField, typeField, priorityField };
84
+ }
85
+
86
+ async function readStage(projectToken, board, nodeId) {
87
+ if (!board.project || !board.etapaField?.id) return null;
88
+ try {
89
+ const itemId = await addProjectItem(projectToken, board.project.id, nodeId);
90
+ return await getItemSingleSelectValue(projectToken, itemId, board.etapaField.id);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function headShaOf(root) {
97
+ try {
98
+ return execSync('git rev-parse --short HEAD', {
99
+ cwd: root || process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
100
+ }).trim() || null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // Commit LOCAL escopado ao arquivo — o `qa` roda no checkout do usuário, e o
107
+ // bug.md de reprovação é conteúdo determinístico produzido aqui mesmo. Nunca
108
+ // varre o index: `git commit -- <path>` só leva o que este comando escreveu.
109
+ function commitLocalFile(root, fileRel, message) {
110
+ const cwd = root || process.cwd();
111
+ try {
112
+ execSync(`git add -- ${JSON.stringify(fileRel)}`, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
113
+ execSync(`git commit -m ${JSON.stringify(message)} -- ${JSON.stringify(fileRel)}`, {
114
+ cwd, stdio: ['ignore', 'pipe', 'pipe'],
115
+ });
116
+ return true;
117
+ } catch (err) {
118
+ console.warn(chalk.yellow(
119
+ `⚠️ ${fileRel} escrito, mas o commit local falhou (${String(err.message).split('\n')[0]}) — ` +
120
+ 'commite-o você mesmo.'
121
+ ));
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function parseOnly(only) {
127
+ if (!only) return null;
128
+ const nums = String(only).split(',').map(s => parseInt(s.trim(), 10));
129
+ if (nums.some(n => !Number.isInteger(n) || n <= 0)) {
130
+ throw new QaRefusal(`--only inválido: "${only}". Use números de cenário, ex.: --only 2 ou --only 2,3.`);
131
+ }
132
+ return [...new Set(nums)];
133
+ }
134
+
135
+ async function trimmedComments(token, owner, repo, issueNumber, kind) {
136
+ const all = await listIssueComments(token, owner, repo, issueNumber).catch(() => []);
137
+ if (all.length === 0) return { all, groups: [] };
138
+ const items = all.slice(-MAX_COMMENTS).map(c => ({
139
+ author: c.user?.login,
140
+ createdAt: c.created_at,
141
+ body: c.body.length > MAX_COMMENT_CHARS ? `${c.body.slice(0, MAX_COMMENT_CHARS)}…[truncado]` : c.body,
142
+ }));
143
+ return { all, groups: [{ issueNumber, kind, total: all.length, items }] };
144
+ }
145
+
146
+ export async function qaRun({ issue: issueArg, only: onlyArg, severity: severityArg, dryRun = false }) {
147
+ const number = parseInt(String(issueArg).replace('#', ''), 10);
148
+ if (!Number.isInteger(number) || number <= 0) {
149
+ throw new QaRefusal(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
150
+ }
151
+ const only = parseOnly(onlyArg);
152
+
153
+ // Guarda 1: sem .spec-wave.json não há repo configurado.
154
+ const configPath = findConfigPath();
155
+ if (!configPath) {
156
+ throw new QaRefusal(
157
+ `Repositório não inicializado (sem ${CONFIG_FILE}). ` +
158
+ 'Rode `npx @spec-wave/cli@latest init` (ou a skill setup) primeiro.'
159
+ );
160
+ }
161
+ const { config, root } = loadConfig();
162
+ const { owner, repo } = config || {};
163
+ if (!owner || !repo) {
164
+ throw new QaRefusal(`${CONFIG_FILE} não contém owner/repo. Rode \`spec-wave init\` novamente.`);
165
+ }
166
+
167
+ const severity = severityArg || config?.qa?.defaultBugPriority || 'P2';
168
+ if (!VALID_SEVERITIES.includes(severity)) {
169
+ throw new QaRefusal(`Severidade inválida: "${severity}". Use uma de: ${VALID_SEVERITIES.join(', ')}.`);
170
+ }
171
+
172
+ const token = await resolveToken();
173
+ const projectToken = process.env.PROJECT_TOKEN || token;
174
+
175
+ const issue = await getIssue(token, owner, repo, number);
176
+ const type = detectIssueType(issue);
177
+
178
+ // ── Feature dona do plano (Feature/Story) ──────────────────────────────────
179
+ let feature = null; // { number, title, nodeId, labels, milestone }
180
+ if (type === 'Feature') {
181
+ feature = {
182
+ number, title: issue.title, nodeId: issue.node_id,
183
+ labels: labelNames(issue), milestone: issue.milestone || null,
184
+ };
185
+ } else if (type === 'Story') {
186
+ const parent = await resolveParentFeature(token, issue.node_id).catch(() => null);
187
+ if (!parent) {
188
+ throw new QaRefusal(
189
+ `A Story #${number} não tem Feature-pai — o plano de QA é por Feature (D-QA1), ` +
190
+ 'e sem ela não há plano a executar. Vincule a Story como sub-issue de uma Feature.'
191
+ );
192
+ }
193
+ const parentIssue = await getIssue(token, owner, repo, parent.number);
194
+ feature = {
195
+ number: parent.number, title: parentIssue.title, nodeId: parentIssue.node_id,
196
+ labels: labelNames(parentIssue), milestone: parentIssue.milestone || null,
197
+ };
198
+ }
199
+
200
+ // ── Etapa no board. Pulada no dry-run: a leitura passa por addProjectItem,
201
+ // que é mutação, e o dry-run promete ZERO escrita no GitHub. ────────────────
202
+ const board = dryRun
203
+ ? { project: null, error: 'dry-run', etapaField: null, statusField: null, typeField: null }
204
+ : await resolveBoard(root, projectToken);
205
+ let stage = null;
206
+ if (!dryRun) {
207
+ if (board.error) console.warn(chalk.yellow(`⚠️ ${board.error} — Etapa não verificada e board não será atualizado.`));
208
+ else stage = await readStage(projectToken, board, issue.node_id);
209
+ } else {
210
+ console.log(chalk.dim('Dry-run: Etapa do board não consultada (a leitura adicionaria o item ao Project).'));
211
+ }
212
+
213
+ // ── Portões (lib/qa-exec.mjs — tabela do spec §6.2) ────────────────────────
214
+ const gate = qaExecutionGate({
215
+ type,
216
+ labels: labelNames(issue),
217
+ featureLabels: type === 'Bug' ? null : feature.labels,
218
+ stage,
219
+ });
220
+ if (!gate.ok) throw new QaRefusal(gate.message);
221
+ if (gate.exitZero) {
222
+ console.log(gate.message);
223
+ return { verdict: null, skipped: true };
224
+ }
225
+
226
+ // ── Cenários-alvo ──────────────────────────────────────────────────────────
227
+ let scenarios; // todos os cenários do ESCOPO (antes do --only)
228
+ let targets; // os que serão executados nesta corrida
229
+ let planContent; // conteúdo cujo sha vai no marcador
230
+ let qaPlanRel = null;
231
+ let specRel = null;
232
+ let featureStories = []; // Stories da Feature (modo Feature — o verde as aprova uma a uma)
233
+
234
+ if (type === 'Bug') {
235
+ const { fileRel, fileAbs } = bugDocPaths(issue.title, root);
236
+ qaPlanRel = fileRel;
237
+ if (!existsSync(fileAbs)) {
238
+ const achado = await loadArtifact({
239
+ token, owner, repo, root, pathRel: fileRel, doc: 'bug', issueNumber: number,
240
+ }).catch(() => null);
241
+ if (achado && isAwaitingMerge(achado.state)) {
242
+ const b = awaitingMergeBlock({ pathRel: fileRel, state: achado.state, pr: achado.pr, branch: achado.ref });
243
+ throw new QaRefusal(`${b.message}\n${b.unblock}`);
244
+ }
245
+ if (achado?.state === 'remote') {
246
+ throw new QaRefusal(`\`${fileRel}\` existe no repositório mas não no seu clone — rode \`git pull\` e repita.`);
247
+ }
248
+ throw new QaRefusal(
249
+ `\`${fileRel}\` não encontrado. O slug vem do TÍTULO da issue: se o Bug foi renomeado ` +
250
+ `depois de gerar o bug.md, o diretório antigo ficou órfão — procure em docs/bugs/ e ` +
251
+ 'renomeie o diretório para o slug atual (ou regenere com `spec-wave:bug`).'
252
+ );
253
+ }
254
+ planContent = readFileSync(fileAbs, 'utf-8');
255
+ const regressao = extractRegressionSection(planContent);
256
+ if (!regressao) {
257
+ throw new QaRefusal(
258
+ `O \`${fileRel}\` não tem a seção **Teste de Regressão** (ou ela está vazia) — ` +
259
+ 'é ela que o `qa <bug>` executa. Complete a seção ou regenere o bug.md.'
260
+ );
261
+ }
262
+ scenarios = [{
263
+ anchor: 'Cenário 1', numero: 1, story: number,
264
+ criterio: `Teste de regressão do Bug #${number}`,
265
+ precondicoes: '', passos: '', esperado: 'O teste de regressão passa',
266
+ body: regressao,
267
+ }];
268
+ targets = only ? scenarios.filter(s => only.includes(s.numero)) : scenarios;
269
+ if (targets.length === 0) {
270
+ throw new QaRefusal(`--only ${onlyArg}: o Teste de Regressão de um Bug é o cenário 1.`);
271
+ }
272
+ } else {
273
+ const paths = featureDocPaths(root, { title: feature.title }, 'Feature');
274
+ qaPlanRel = paths['qa-plan'].rel;
275
+ specRel = paths.spec.rel;
276
+ const plano = await loadArtifact({
277
+ token, owner, repo, root, pathRel: qaPlanRel, doc: 'qa-plan', issueNumber: feature.number,
278
+ }).catch(() => null);
279
+ if (!plano || plano.content == null || plano.state === 'unknown') {
280
+ // `qa-ready` está na Feature (o portão passou), então o plano EXISTE em
281
+ // algum lugar — arquivo ausente aqui é quase sempre slug órfão.
282
+ throw new QaRefusal(
283
+ `\`${qaPlanRel}\` não encontrado, mas a Feature #${feature.number} tem \`spec-wave:qa-ready\` — ` +
284
+ 'o plano foi gerado. O slug vem do TÍTULO: se a Feature foi renomeada depois da geração, ' +
285
+ 'o diretório antigo ficou órfão. Procure o qa-plan.md em docs/features/ e renomeie o ' +
286
+ 'diretório para o slug atual (ou apague-o e reaplique `spec-wave:qa`).'
287
+ );
288
+ }
289
+ if (isAwaitingMerge(plano.state)) {
290
+ const b = awaitingMergeBlock({ pathRel: qaPlanRel, state: plano.state, pr: plano.pr, branch: plano.ref });
291
+ throw new QaRefusal(
292
+ `${b.message}\n${b.unblock}\n` +
293
+ 'O merge do PR é a revisão humana do plano — o `qa` só executa o que está na base.'
294
+ );
295
+ }
296
+ if (plano.state === 'remote') {
297
+ throw new QaRefusal(`\`${qaPlanRel}\` existe no repositório mas não no seu clone — rode \`git pull\` e repita.`);
298
+ }
299
+ planContent = plano.content;
300
+
301
+ let doc;
302
+ try {
303
+ doc = parseQaPlanDoc(planContent);
304
+ } catch (err) {
305
+ throw new QaRefusal(`${err.message}\nCorrija \`${qaPlanRel}\` (ou reaplique \`spec-wave:qa\` para re-criticar).`);
306
+ }
307
+ if (doc.issueNumber && doc.issueNumber !== feature.number) {
308
+ console.warn(chalk.yellow(
309
+ `⚠️ ${qaPlanRel} foi gerado para a issue #${doc.issueNumber}, não a #${feature.number} ` +
310
+ '(a Feature foi retitulada?). Seguindo com o arquivo encontrado.'
311
+ ));
312
+ }
313
+
314
+ // Stories já aprovadas saem do alvo no modo Feature.
315
+ let subStories = [];
316
+ try {
317
+ subStories = (await listSubIssues(token, feature.nodeId))
318
+ .filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
319
+ } catch (err) {
320
+ console.warn(chalk.yellow(`⚠️ Não foi possível listar as Stories da Feature: ${err.message}.`));
321
+ }
322
+ const approvedStories = subStories
323
+ .filter(s => (s.labels || []).includes(LABEL_QA_APPROVED))
324
+ .map(s => s.number);
325
+
326
+ const resolved = resolveTargetScenarios({
327
+ doc,
328
+ story: type === 'Story' ? number : null,
329
+ approvedStories,
330
+ only,
331
+ });
332
+ if (resolved.unknownOnly.length > 0) {
333
+ throw new QaRefusal(
334
+ `--only ${onlyArg}: cenário(s) ${resolved.unknownOnly.join(', ')} não existe(m) no escopo. ` +
335
+ `O plano tem ${doc.scenarios.length} cenário(s) — a numeração é POSICIONAL (a ordem do arquivo).`
336
+ );
337
+ }
338
+ // Escopo = cenários da corrida ANTES do --only; é sobre ele que a aprovação
339
+ // é decidida (cenário fora do --only herda o veredito do último relatório).
340
+ scenarios = resolveTargetScenarios({
341
+ doc, story: type === 'Story' ? number : null, approvedStories, only: null,
342
+ }).targets;
343
+ targets = resolved.targets;
344
+
345
+ if (scenarios.length === 0) {
346
+ if (type === 'Story') {
347
+ throw new QaRefusal(
348
+ `Nenhum cenário do plano casa com a Story #${number}. Ou o plano está DESATUALIZADO ` +
349
+ '(um re-decompose criou Stories novas — apague o qa-plan.md e reaplique `spec-wave:qa`), ' +
350
+ 'ou a Feature foi renomeada e você está lendo um plano órfão de outro slug.'
351
+ );
352
+ }
353
+ console.log('Todas as Stories desta Feature já têm `spec-wave:qa-approved` — nada a executar.');
354
+ return { verdict: 'pass', skipped: true };
355
+ }
356
+ featureStories = subStories;
357
+ }
358
+
359
+ // ── Estado anterior (relatórios) e contexto ────────────────────────────────
360
+ const { all: allComments, groups: commentGroups } =
361
+ await trimmedComments(token, owner, repo, number, type);
362
+ const previous = parseLastQaReport(allComments);
363
+ const run = previous.run + 1;
364
+
365
+ const pullRequests = await listIssuePullRequests(token, issue.node_id).catch(() => []);
366
+
367
+ mkdirSync(path.join(root || process.cwd(), WORK_DIR), { recursive: true });
368
+ const contextFile = path.join(WORK_DIR, `qa-${number}.md`);
369
+ const resultFile = path.join(WORK_DIR, `qa-result-${number}.json`);
370
+ const contextAbs = path.join(root || process.cwd(), contextFile);
371
+ const resultAbs = path.join(root || process.cwd(), resultFile);
372
+
373
+ const context = buildQaContext({
374
+ type, issue: { number, title: issue.title }, stage,
375
+ scenarios: targets, specRel, qaPlanRel,
376
+ comments: commentGroups, pullRequests,
377
+ setup: config?.qa?.setup || null,
378
+ resultFile,
379
+ });
380
+ writeFileSync(contextAbs, context);
381
+ if (existsSync(resultAbs)) unlinkSync(resultAbs); // resultado velho não pode virar veredito novo
382
+
383
+ const template = process.env.SPEC_WAVE_QA_CMD || config?.qa?.command;
384
+ const vars = {
385
+ contextFile,
386
+ qaPlanFile: qaPlanRel || '',
387
+ specFile: specRel || '',
388
+ issue: String(number),
389
+ type,
390
+ title: issue.title,
391
+ };
392
+
393
+ console.log(`Contexto montado em ${chalk.cyan(contextFile)} (${targets.length} cenário(s) alvo).`);
394
+ for (const s of targets) {
395
+ console.log(` - ${s.anchor} — Story #${s.story}${s.criterio ? `: ${s.criterio}` : ''}`);
396
+ }
397
+
398
+ if (dryRun) {
399
+ if (template) {
400
+ console.log(`\nComando que seria executado (--dry-run):\n ${chalk.dim(renderQaCommand(template, vars))}`);
401
+ } else {
402
+ console.log(chalk.yellow('\nComando do QA não configurado — nada a executar (veja `spec-wave doctor`).'));
403
+ }
404
+ console.log(chalk.dim('\nDry-run: nada executado e ZERO escrita no GitHub.'));
405
+ return { verdict: null, dryRun: true };
406
+ }
407
+
408
+ if (!template) {
409
+ console.log(chalk.yellow('\nComando do QA não configurado.'));
410
+ console.log(
411
+ `Configure em ${CONFIG_FILE}:\n` +
412
+ ' "qa": { "command": "<comando do executor com placeholders>" }\n' +
413
+ 'ou defina a env SPEC_WAVE_QA_CMD (ela tem precedência).\n\n' +
414
+ 'Placeholders: {contextFile} {qaPlanFile} {specFile} {issue} {type} {title}. Exemplos:\n' +
415
+ ' Claude Code: claude -p "Execute o QA descrito em {contextFile}"\n' +
416
+ ' opencode: opencode run "Execute o QA descrito em {contextFile}"\n' +
417
+ ' Codex: codex exec "Execute o QA descrito em {contextFile}"\n\n' +
418
+ `Contexto pronto em ${contextFile} — acione o executor manualmente com esse arquivo.`
419
+ );
420
+ return { verdict: null, notConfigured: true };
421
+ }
422
+
423
+ const command = renderQaCommand(template, vars);
424
+ console.log(`\nExecutando: ${chalk.dim(command)}\n`);
425
+ let executorFailed = null;
426
+ try {
427
+ execSync(command, {
428
+ stdio: 'inherit',
429
+ cwd: root || process.cwd(),
430
+ // §3.1 da spec-qa-lead: a env do PROCESSO vence a `qa.env`, chave a chave
431
+ // — é assim que o `qa-lead` injeta o endereço real de cada container sem
432
+ // mexer no arquivo versionado.
433
+ env: qaProcessEnv(config?.qa?.env, process.env),
434
+ });
435
+ } catch (err) {
436
+ executorFailed = err;
437
+ }
438
+
439
+ // O veredito sai do ARQUIVO, não do exit code: um executor que achou `fail`
440
+ // pode sair não-zero e mesmo assim ter registrado tudo.
441
+ if (!existsSync(resultAbs)) {
442
+ process.exitCode = 1;
443
+ console.error(chalk.red(
444
+ `\nO executor terminou${executorFailed ? ' com erro' : ''} sem gravar ${resultFile} — ` +
445
+ 'sem resultados não há veredito: nada foi movido, nenhum Bug criado, nenhum comentário postado. ' +
446
+ 'Verifique a saída acima e rode de novo.'
447
+ ));
448
+ return { verdict: null, error: 'sem-resultados' };
449
+ }
450
+ let results;
451
+ try {
452
+ results = validateQaResults(
453
+ JSON.parse(readFileSync(resultAbs, 'utf-8')),
454
+ targets.map(t => t.numero),
455
+ );
456
+ } catch (err) {
457
+ process.exitCode = 1;
458
+ console.error(chalk.red(`\nResultados inválidos em ${resultFile}: ${err.message}`));
459
+ return { verdict: null, error: 'resultados-invalidos' };
460
+ }
461
+
462
+ // D-QAL5: plano que MUDOU durante a execução invalida a corrida — o veredito
463
+ // seria de cenários que já não são os do arquivo. A checagem é da CLI (re-lê
464
+ // o plano do disco), não do executor: não depende de cooperação de agente.
465
+ {
466
+ const planAbs = path.join(root || process.cwd(), qaPlanRel);
467
+ let planNow = null;
468
+ try {
469
+ planNow = readFileSync(planAbs, 'utf-8');
470
+ } catch { /* ausente agora — cai no erro abaixo */ }
471
+ if (planNow === null || shortSha(planNow) !== shortSha(planContent)) {
472
+ process.exitCode = 1;
473
+ console.error(chalk.red(
474
+ `\n\`${qaPlanRel}\` mudou durante a execução (planSha ${shortSha(planContent)} → ` +
475
+ `${planNow === null ? 'arquivo ausente' : shortSha(planNow)}) — o veredito seria de um plano ` +
476
+ 'que não existe mais. Nada foi movido, nenhum Bug criado. Rode de novo com o plano estável.'
477
+ ));
478
+ return { verdict: null, error: 'plano-mudou' };
479
+ }
480
+ }
481
+
482
+ // ── Estado acumulado e veredito ────────────────────────────────────────────
483
+ const { combined, pendingNumbers } = combineWithPrevious({
484
+ executed: results,
485
+ previous: previous.results,
486
+ allNumbers: scenarios.map(s => s.numero),
487
+ });
488
+ let verdict = aggregateVerdict(combined);
489
+ const planSha = shortSha(planContent);
490
+ const headSha = headShaOf(root);
491
+ const byNumero = new Map(scenarios.map(s => [s.numero, s]));
492
+
493
+ console.log(`\nVeredito agregado: ${chalk.bold(verdict)} (${combined.length} cenário(s) no escopo, ${results.length} executado(s) agora).`);
494
+
495
+ const outcome = {
496
+ token, projectToken, owner, repo, root, config, board, issue, number, type, feature,
497
+ featureStories,
498
+ };
499
+
500
+ let bugs = [];
501
+ if (verdict === 'fail') {
502
+ // Reprovar o re-teste de um BUG não abre outro Bug: o vermelho significa
503
+ // que o fix não segurou — o defeito é o mesmo, e o registro é o relatório.
504
+ const failed = type === 'Bug' ? [] : results.filter(r => r.verdict === 'fail');
505
+ const opened = await openBugsForFailures({ ...outcome, failed, byNumero, severity, headSha, qaPlanRel });
506
+ bugs = opened.bugs;
507
+ // §3.2 da spec-qa-lead: NUNCA reporte `fail` sem o Bug correspondente. O
508
+ // push do bug.md rejeitado após o teto de retries degrada o cenário para
509
+ // `blocked`/`outro` — o defeito pode existir, mas sem o registro publicado
510
+ // ele viraria um vermelho sem Bug, que a próxima execução recriaria.
511
+ if (opened.pushFailures.length > 0) {
512
+ for (const pf of opened.pushFailures) {
513
+ const idx = combined.findIndex(r => r.numero === pf.numero && !r.carried);
514
+ if (idx !== -1) {
515
+ combined[idx] = {
516
+ ...combined[idx],
517
+ verdict: 'blocked',
518
+ blockedReason: 'outro',
519
+ evidencia: `commit do bug.md rejeitado: ${pf.error}`.slice(0, 400),
520
+ };
521
+ }
522
+ }
523
+ verdict = aggregateVerdict(combined);
524
+ console.log(chalk.yellow(
525
+ `Cenário(s) ${opened.pushFailures.map(f => f.numero).join(', ')} degradado(s) para ` +
526
+ `blocked/outro (push do bug.md rejeitado) — veredito recalculado: ${chalk.bold(verdict)}.`
527
+ ));
528
+ }
529
+ }
530
+
531
+ if (verdict === 'fail') {
532
+ await stayInQa(outcome);
533
+ await postReport({
534
+ ...outcome, run, verdict, planSha, headSha, combined, pendingNumbers, bugs,
535
+ trailer: type === 'Bug'
536
+ ? `❌ **O teste de regressão ainda reprova** — o fix não cobriu o defeito. O Bug permanece ` +
537
+ `em **${STAGE_QA}** (Status ${PROGRESS_IN_PROGRESS}); nenhum Bug novo foi aberto (é o mesmo defeito).`
538
+ : `O item permanece em **${STAGE_QA}** (Status ${PROGRESS_IN_PROGRESS}). Corrija o(s) Bug(s) e ` +
539
+ `re-teste com \`npx @spec-wave/cli@latest qa <story> --only <cenário>\`.`,
540
+ });
541
+ process.exitCode = 1;
542
+ return { verdict, bugs };
543
+ }
544
+
545
+ if (verdict === 'blocked') {
546
+ const blockedList = combined.filter(r => r.verdict === 'blocked').map(r => r.numero);
547
+ await postReport({
548
+ ...outcome, run, verdict, planSha, headSha, combined, pendingNumbers, bugs: [],
549
+ trailer:
550
+ `⚪ **Inconclusivo:** cenário(s) ${blockedList.join(', ')} bloqueado(s) e nenhum \`fail\`. ` +
551
+ 'Ambiente quebrado não é defeito de produto: **nada foi movido e nenhum Bug foi criado**. ' +
552
+ 'Destrave o ambiente e rode de novo.',
553
+ });
554
+ process.exitCode = 1;
555
+ return { verdict };
556
+ }
557
+
558
+ // verdict === 'pass'
559
+ if (pendingNumbers.length > 0) {
560
+ await postReport({
561
+ ...outcome, run, verdict, planSha, headSha, combined, pendingNumbers, bugs: [],
562
+ trailer:
563
+ 'Todos os cenários executados passaram, mas ainda há cenário(s) **sem veredito em nenhuma ' +
564
+ 'corrida** — a aprovação só sai quando todos tiverem passado. Rode os que faltam.',
565
+ });
566
+ process.exitCode = 1;
567
+ return { verdict, pendingNumbers };
568
+ }
569
+
570
+ const moved = await applyGreen({ ...outcome, combined, byNumero });
571
+ await postReport({
572
+ ...outcome, run, verdict, planSha, headSha, combined, pendingNumbers: [], bugs: [],
573
+ trailer: moved.trailer,
574
+ });
575
+ if (moved.blockedByBugs) process.exitCode = 1;
576
+ return { verdict, moved };
577
+ }
578
+
579
+ // ── Desfechos ────────────────────────────────────────────────────────────────
580
+
581
+ async function postReport({
582
+ token, owner, repo, number, type, run, verdict, planSha, headSha,
583
+ combined, pendingNumbers, bugs, trailer,
584
+ }) {
585
+ const markdown = renderQaReport({
586
+ issue: number,
587
+ scope: `${type} #${number}`,
588
+ run, verdict, planSha, headSha,
589
+ results: combined,
590
+ bugs,
591
+ pendingNumbers,
592
+ trailer,
593
+ });
594
+ await commentOnIssue(token, owner, repo, number, markdown)
595
+ .catch(err => console.warn(chalk.yellow(`⚠️ Falha ao comentar o relatório: ${err.message}`)));
596
+ }
597
+
598
+ async function stayInQa({ projectToken, board, issue }) {
599
+ if (!board.project || !board.statusField) return;
600
+ await setItemStatus(projectToken, board.project, board.statusField, issue.node_id, PROGRESS_IN_PROGRESS)
601
+ .catch(() => {});
602
+ }
603
+
604
+ // Vermelho: um Bug por cenário reprovado — filho da STORY dona do cenário
605
+ // (nunca da Feature), com bug.md determinístico commitado E PUBLICADO (push com
606
+ // retry — §3.2 da spec-qa-lead) e `bug-approved` aplicada (exceção documentada
607
+ // — spec §2.1). O bug.md vai para o remoto ANTES de a issue nascer: rodando num
608
+ // container do `qa-lead`, um commit que fica local morre com o container, e um
609
+ // Bug sem bug.md quebra o guard de idempotência da próxima corrida.
610
+ //
611
+ // Devolve também `pushFailures`: cenários cujo bug.md não pôde ser publicado —
612
+ // o chamador os degrada para `blocked`/`outro`, porque `fail` sem Bug é o
613
+ // estado que a spec proíbe.
614
+ async function openBugsForFailures({
615
+ token, projectToken, owner, repo, root, board,
616
+ failed, byNumero, severity, headSha, qaPlanRel, feature, type, number,
617
+ }) {
618
+ const bugs = [];
619
+ const pushFailures = [];
620
+ for (const r of failed) {
621
+ const scenario = byNumero.get(r.numero);
622
+ if (!scenario) continue;
623
+ const storyNumber = scenario.story;
624
+
625
+ let story = null;
626
+ try {
627
+ story = await getIssue(token, owner, repo, storyNumber);
628
+ } catch (err) {
629
+ console.warn(chalk.yellow(`⚠️ Não consegui ler a Story #${storyNumber} (${err.message}) — Bug do cenário ${r.numero} NÃO criado.`));
630
+ pushFailures.push({ numero: r.numero, error: `Story #${storyNumber} ilegível: ${err.message}` });
631
+ continue;
632
+ }
633
+
634
+ // Idempotência: Bug filho ABERTO com o mesmo marcador de origem → comenta
635
+ // nele em vez de duplicar.
636
+ const children = await listSubIssues(token, story.node_id).catch(() => []);
637
+ const existing = children.find(c =>
638
+ detectIssueType({ title: c.title, labels: c.labels }) === 'Bug' &&
639
+ c.state !== 'closed' &&
640
+ matchesQaOrigin(c.body, { issue: storyNumber, cenario: scenario.numero }));
641
+ if (existing) {
642
+ console.log(`Cenário ${scenario.numero} já tem Bug aberto (#${existing.number}) — comentando nele.`);
643
+ await commentOnIssue(token, owner, repo, existing.number,
644
+ `🧪 **O cenário ${scenario.numero} da Story #${storyNumber} reprovou de novo** ` +
645
+ `(${headSha ? `commit \`${headSha}\`` : 'nova execução'}).\n\n` +
646
+ `**Evidência:** ${r.evidencia || '(sem evidência registrada)'}`
647
+ ).catch(() => {});
648
+ bugs.push({ number: existing.number, cenario: scenario.numero, existing: true });
649
+ continue;
650
+ }
651
+
652
+ const resumo = (scenario.criterio || `cenário ${scenario.numero} de QA reprovado`).slice(0, 120);
653
+ const title = `[BUG] ${resumo}`;
654
+ const bodyLines = [
655
+ qaOriginMarker({ issue: storyNumber, cenario: scenario.numero }),
656
+ '',
657
+ `**Parent:** #${storyNumber} — ${story.title}`,
658
+ '',
659
+ `Aberto automaticamente pela reprovação do **${scenario.anchor}** do plano de QA` +
660
+ (feature ? ` da Feature #${feature.number}` : '') +
661
+ (qaPlanRel ? ` (\`${qaPlanRel}\`)` : '') + '.',
662
+ '',
663
+ `**Critério:** ${scenario.criterio || '—'}`,
664
+ `**Esperado:** ${scenario.esperado || '—'}`,
665
+ `**Obtido:** ${r.evidencia || '(sem evidência registrada)'}`,
666
+ ];
667
+ const milestone = story.milestone?.number ?? undefined; // herda do pai (D5)
668
+ const labels = ['[BUG]', severity, bugOriginLabel('qa')].filter(Boolean);
669
+
670
+ // bug.md determinístico, escrito, commitado e PUBLICADO antes de a issue
671
+ // nascer (§3.2): se o push não segura nem com retry, o Bug NÃO é criado e o
672
+ // cenário será degradado para blocked/outro pelo chamador.
673
+ const { fileRel, fileAbs, dirAbs } = bugDocPaths(title, root);
674
+ try {
675
+ mkdirSync(dirAbs, { recursive: true });
676
+ writeFileSync(fileAbs, renderQaBugDoc({
677
+ title: resumo, scenario, evidence: r.evidencia, severity, headSha,
678
+ featureNumber: feature?.number ?? null,
679
+ }));
680
+ } catch (err) {
681
+ console.warn(chalk.yellow(`⚠️ Não consegui escrever ${fileRel}: ${err.message} — Bug do cenário ${scenario.numero} NÃO criado.`));
682
+ pushFailures.push({ numero: r.numero, error: `escrita do ${fileRel} falhou: ${err.message}` });
683
+ continue;
684
+ }
685
+ const committed = commitLocalFile(root, fileRel,
686
+ `docs: gera ${fileRel} (reprovação de QA, cenário ${scenario.numero} da Story #${storyNumber}) [spec-wave]`);
687
+ if (!committed) {
688
+ pushFailures.push({ numero: r.numero, error: `commit do ${fileRel} falhou` });
689
+ continue;
690
+ }
691
+ const push = await pushWithRebase({ cwd: root || process.cwd() });
692
+ if (!push.ok) {
693
+ console.warn(chalk.yellow(
694
+ `⚠️ Push do ${fileRel} rejeitado após ${push.attempts} tentativa(s): ` +
695
+ `${String(push.error).split('\n')[0]} — Bug do cenário ${scenario.numero} NÃO criado.`
696
+ ));
697
+ pushFailures.push({ numero: r.numero, error: String(push.error).split('\n')[0] });
698
+ continue;
699
+ }
700
+
701
+ let created;
702
+ try {
703
+ created = await createIssue(token, owner, repo, title, bodyLines.join('\n'), labels, { milestone });
704
+ } catch (err) {
705
+ console.warn(chalk.yellow(`⚠️ Falha ao criar o Bug do cenário ${scenario.numero}: ${err.message}`));
706
+ pushFailures.push({ numero: r.numero, error: `criação da issue falhou: ${err.message}` });
707
+ continue;
708
+ }
709
+ console.log(`Bug #${created.number} criado para o cenário ${scenario.numero} (filho da Story #${storyNumber}).`);
710
+
711
+ await addSubIssue(token, story.node_id, created.nodeId)
712
+ .catch(err => console.warn(chalk.yellow(`⚠️ Bug #${created.number} criado, mas não vinculado à Story: ${err.message}`)));
713
+
714
+ // Board: 🧪 QA achou → o Bug nasce em ✅ Ready (triagem já feita pela reprova).
715
+ if (board.project && board.etapaField) {
716
+ try {
717
+ await advanceToStage(
718
+ projectToken, board.project, board.etapaField, board.statusField,
719
+ created.nodeId, STAGE_READY, PROGRESS_TODO,
720
+ { typeField: board.typeField, itemType: 'Bug' });
721
+ } catch (err) {
722
+ console.warn(chalk.yellow(`⚠️ Bug #${created.number} sem Etapa no board: ${err.message} — repare com \`spec-wave repair-stage\`.`));
723
+ }
724
+ if (board.priorityField?.options?.[severity]) {
725
+ try {
726
+ const itemId = await addProjectItem(projectToken, board.project.id, created.nodeId);
727
+ await setItemSingleSelect(projectToken, board.project.id, itemId,
728
+ board.priorityField.id, board.priorityField.options[severity]);
729
+ } catch { /* prioridade no board é acessório */ }
730
+ }
731
+ }
732
+
733
+ // `bug-approved` direto: reprodução, esperado/obtido e regressão são a
734
+ // execução observada — regerar por IA só introduziria alucinação.
735
+ await addLabel(token, owner, repo, created.number, LABEL_BUG_APPROVED).catch(() => {});
736
+
737
+ await commentOnIssue(token, owner, repo, created.number,
738
+ `🧪 **Bug aberto pela reprovação de QA** — ${scenario.anchor} da Story #${storyNumber}.\n\n` +
739
+ `📄 \`${fileRel}\` foi escrito, commitado e publicado com as seis seções preenchidas a partir ` +
740
+ 'do cenário e da saída real da execução (exceção documentada à regra do `spec-wave:bug` — ' +
741
+ 'conteúdo determinístico de uma execução observada, sem IA).\n\n' +
742
+ `Após o fix, re-teste: \`npx @spec-wave/cli@latest qa ${storyNumber} --only ${scenario.numero}\``
743
+ ).catch(() => {});
744
+
745
+ bugs.push({ number: created.number, cenario: scenario.numero, existing: false });
746
+ }
747
+ return { bugs, pushFailures };
748
+ }
749
+
750
+ // Verde: aprova e move — Story → 📋 Homologação, Bug → 🚀 Deploy, Feature
751
+ // quando todas as Stories liberarem. Guarda dura: Bug filho aberto segura tudo.
752
+ async function applyGreen({
753
+ token, projectToken, owner, repo, board, issue, number, type, feature, combined, byNumero,
754
+ featureStories = [],
755
+ }) {
756
+ const move = async (nodeId, targetStage, itemType) => {
757
+ if (!board.project || !board.etapaField) return false;
758
+ return await advanceToStage(
759
+ projectToken, board.project, board.etapaField, board.statusField,
760
+ nodeId, targetStage, PROGRESS_TODO,
761
+ { typeField: board.typeField, itemType });
762
+ };
763
+
764
+ if (type === 'Bug') {
765
+ await addLabel(token, owner, repo, number, LABEL_QA_APPROVED).catch(() => {});
766
+ const target = greenTargetStage('Bug');
767
+ try {
768
+ await move(issue.node_id, target, 'Bug');
769
+ console.log(`Bug #${number} → "${target}" (D-QA6: Bug não passa por Homologação).`);
770
+ } catch (err) {
771
+ console.warn(chalk.yellow(`⚠️ Falha ao mover o Bug no board: ${err.message}`));
772
+ }
773
+ return { trailer: `✅ Todos os cenários passaram. Bug segue para **${target}** (D-QA6 — sem Homologação).` };
774
+ }
775
+
776
+ if (type === 'Story') {
777
+ const children = (await listSubIssues(token, issue.node_id).catch(() => []))
778
+ .map(c => ({ ...c, type: detectIssueType({ title: c.title, labels: c.labels }) }));
779
+ const guard = storyCanAdvance({ children });
780
+ if (!guard.ok) {
781
+ console.log(chalk.yellow(`Story verde, mas com Bug(s) filho(s) aberto(s): ${guard.openBugs.map(n => `#${n}`).join(', ')} — não avança.`));
782
+ return {
783
+ blockedByBugs: true,
784
+ trailer:
785
+ `⛔ **Todos os cenários passaram, mas a Story NÃO avança:** há Bug(s) filho(s) ` +
786
+ `aberto(s) — ${guard.openBugs.map(n => `#${n}`).join(', ')}. Feche-os (fix + re-teste) ` +
787
+ 'e rode o `qa` de novo.',
788
+ };
789
+ }
790
+ await addLabel(token, owner, repo, number, LABEL_QA_APPROVED).catch(() => {});
791
+ try {
792
+ await move(issue.node_id, STAGE_UAT, 'Story');
793
+ console.log(`Story #${number} → "${STAGE_UAT}" / Status "${PROGRESS_TODO}".`);
794
+ } catch (err) {
795
+ console.warn(chalk.yellow(`⚠️ Falha ao mover a Story no board: ${err.message}`));
796
+ }
797
+
798
+ // Última Story da Feature? Então a Feature avança na MESMA execução.
799
+ let featureAdvanced = false;
800
+ if (feature?.nodeId) {
801
+ featureAdvanced = await maybeAdvanceFeature({
802
+ token, projectToken, owner, repo, board, feature, justApproved: number, move,
803
+ });
804
+ }
805
+ return {
806
+ trailer:
807
+ `✅ Todos os cenários passaram — \`${LABEL_QA_APPROVED}\` aplicada e Story movida para ` +
808
+ `**${STAGE_UAT}** (aprovação humana de negócio).` +
809
+ (featureAdvanced ? `\n\nEsta era a última Story pendente: a **Feature #${feature.number} também avançou** para ${STAGE_UAT}.` : ''),
810
+ };
811
+ }
812
+
813
+ // Feature: aprova por Story (as que passaram todos os SEUS cenários) e então
814
+ // avalia a própria Feature. A Story de cada resultado vem do cenário do
815
+ // escopo (byNumero) — o resultado em si só carrega o número posicional.
816
+ const approvedNow = [];
817
+ for (const story of featureStories) {
818
+ if ((story.labels || []).includes(LABEL_QA_APPROVED)) continue;
819
+ const daStory = combined.filter(r => byNumero.get(r.numero)?.story === story.number);
820
+ if (daStory.length === 0) continue;
821
+ if (!daStory.every(r => r.verdict === 'pass')) continue;
822
+ const children = (await listSubIssues(token, story.nodeId).catch(() => []))
823
+ .map(c => ({ ...c, type: detectIssueType({ title: c.title, labels: c.labels }) }));
824
+ const guard = storyCanAdvance({ children });
825
+ if (!guard.ok) {
826
+ console.log(chalk.yellow(`Story #${story.number} verde, mas com Bug aberto (${guard.openBugs.map(n => `#${n}`).join(', ')}) — não avança.`));
827
+ continue;
828
+ }
829
+ await addLabel(token, owner, repo, story.number, LABEL_QA_APPROVED).catch(() => {});
830
+ try {
831
+ await move(story.nodeId, STAGE_UAT, 'Story');
832
+ console.log(`Story #${story.number} → "${STAGE_UAT}".`);
833
+ } catch (err) {
834
+ console.warn(chalk.yellow(`⚠️ Falha ao mover a Story #${story.number}: ${err.message}`));
835
+ }
836
+ approvedNow.push(story.number);
837
+ }
838
+
839
+ const featureAdvanced = await maybeAdvanceFeature({
840
+ token, projectToken, owner, repo, board, feature,
841
+ justApproved: approvedNow, move,
842
+ });
843
+ return {
844
+ trailer:
845
+ `✅ Todos os cenários do escopo passaram.` +
846
+ (approvedNow.length > 0 ? ` Stories aprovadas agora: ${approvedNow.map(n => `#${n}`).join(', ')}.` : '') +
847
+ (featureAdvanced
848
+ ? `\n\nTodas as Stories liberaram: a **Feature #${feature.number} avançou** para ${STAGE_UAT}.`
849
+ : ''),
850
+ };
851
+ }
852
+
853
+ // A Feature avança quando TODAS as Stories têm qa-approved ou já estão em
854
+ // Homologação+ — mesma regra do Code Review (spec §6.3).
855
+ async function maybeAdvanceFeature({ token, projectToken, owner, repo, board, feature, justApproved, move }) {
856
+ const aprovadas = new Set(Array.isArray(justApproved) ? justApproved : [justApproved]);
857
+ let stories = [];
858
+ try {
859
+ stories = (await listSubIssues(token, feature.nodeId))
860
+ .filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
861
+ } catch {
862
+ return false; // sem a lista, na dúvida a Feature não avança
863
+ }
864
+ const enriched = [];
865
+ for (const s of stories) {
866
+ const labels = aprovadas.has(s.number) ? [...(s.labels || []), LABEL_QA_APPROVED] : (s.labels || []);
867
+ let stage = null;
868
+ if (!labelNames(labels).includes(LABEL_QA_APPROVED) && board.project && board.etapaField) {
869
+ try {
870
+ const itemId = await addProjectItem(projectToken, board.project.id, s.nodeId);
871
+ stage = await getItemSingleSelectValue(projectToken, itemId, board.etapaField.id);
872
+ } catch { stage = null; }
873
+ }
874
+ enriched.push({ number: s.number, labels, stage });
875
+ }
876
+ const check = featureCanAdvanceQa(enriched);
877
+ if (!check.ok) {
878
+ if (check.pending.length > 0) {
879
+ console.log(`Feature #${feature.number} ainda não avança — Stories pendentes: ${check.pending.map(n => `#${n}`).join(', ')}.`);
880
+ }
881
+ return false;
882
+ }
883
+ await addLabel(token, owner, repo, feature.number, LABEL_QA_APPROVED).catch(() => {});
884
+ try {
885
+ await move(feature.nodeId, STAGE_UAT, 'Feature');
886
+ console.log(`Feature #${feature.number} → "${STAGE_UAT}".`);
887
+ return true;
888
+ } catch (err) {
889
+ console.warn(chalk.yellow(`⚠️ Falha ao mover a Feature #${feature.number}: ${err.message}`));
890
+ return false;
891
+ }
892
+ }