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