@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.
- package/package.json +5 -3
- package/protocol/qa-result.v1.json +62 -0
- package/protocol/qa-trail-report.v1.json +113 -0
- package/src/api/github-graphql.mjs +6 -1
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +114 -9
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +183 -3
- package/src/commands/generate-qa-plan.mjs +421 -0
- package/src/commands/implement.mjs +56 -44
- package/src/commands/merge.mjs +43 -14
- package/src/commands/order.mjs +350 -96
- package/src/commands/qa-lead.mjs +748 -0
- package/src/commands/qa-run.mjs +892 -0
- package/src/commands/run.mjs +5 -1
- package/src/config.mjs +32 -1
- package/src/lib/artifact-pr.mjs +2 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/critique.mjs +38 -9
- package/src/lib/decomposition-doc.mjs +5 -1
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +9 -2
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/next-step.mjs +15 -3
- package/src/lib/qa-exec.mjs +335 -0
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-plan-doc.mjs +340 -0
- package/src/lib/qa-report.mjs +396 -0
- package/src/lib/skill-compose.mjs +234 -0
- package/src/lib/story-graph.mjs +256 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/order/SKILL.md +21 -5
- package/src/plugin/skills/qa/SKILL.md +107 -0
- package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
- package/src/plugin/skills/qa/model-prompt.md +68 -0
- package/src/plugin/skills/qa-executor/SKILL.md +76 -0
- package/src/plugin/skills/qa-lead/SKILL.md +89 -0
- package/src/templates/skill/SKILL.md +981 -279
- package/src/templates/skill/core.md +584 -0
- package/src/templates/workflows/generate-qa-plan.yml +64 -0
package/src/commands/doctor.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { execSync } from 'node:child_process';
|
|
6
6
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
8
9
|
import * as p from '@clack/prompts';
|
|
9
10
|
import chalk from 'chalk';
|
|
10
11
|
import { Octokit } from '@octokit/rest';
|
|
@@ -13,15 +14,17 @@ import {
|
|
|
13
14
|
resolveToken, verifyTokenScopes, describeTokenSource, activeGhAccount,
|
|
14
15
|
tokenMismatchWarning, parseActiveAccount,
|
|
15
16
|
} from '../api/auth.mjs';
|
|
16
|
-
import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
|
|
17
|
+
import { getProjectSnapshot, listSubIssues, listProjectItems } from '../api/github-graphql.mjs';
|
|
17
18
|
import { getRepoVariable } from '../api/github-rest.mjs';
|
|
18
19
|
import {
|
|
19
20
|
CONFIG_FILE, WORKFLOW_FILES, ARTIFACT_WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER,
|
|
20
21
|
AI_PROVIDERS, STATUS_OPTIONS,
|
|
21
|
-
RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
22
|
+
RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, LABEL_QA_READY, MODEL_LABEL_PREFIX,
|
|
22
23
|
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
|
|
23
|
-
modelLabels,
|
|
24
|
+
modelLabels, STAGE_QA,
|
|
24
25
|
} from '../config.mjs';
|
|
26
|
+
import { slugify } from '../lib/slugify.mjs';
|
|
27
|
+
import { QA_PLAN_FILE } from '../lib/qa-plan-doc.mjs';
|
|
25
28
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
26
29
|
import { configuredMode, describeModeState, EXECUTION_VARIABLE } from '../lib/execution-mode.mjs';
|
|
27
30
|
import { unguardedWorkflows } from './mode.mjs';
|
|
@@ -952,6 +955,182 @@ export function checkSpecKit(ctx) {
|
|
|
952
955
|
};
|
|
953
956
|
}
|
|
954
957
|
|
|
958
|
+
/**
|
|
959
|
+
* Features em 🧪 QA sem qa-plan.md no disco (função PURA — testável sem rede).
|
|
960
|
+
*
|
|
961
|
+
* Uma Feature parada em QA sem plano é validação manual sem rastro — o buraco
|
|
962
|
+
* que o fluxo de QA existe para fechar. Só aviso: gerar o plano é um passo, não
|
|
963
|
+
* um defeito de configuração.
|
|
964
|
+
*
|
|
965
|
+
* @param {object} params
|
|
966
|
+
* @param {Array<{number, title, state, fields}>} [params.items] itens do board
|
|
967
|
+
* @param {(rel: string) => boolean} params.exists sonda de existência no disco
|
|
968
|
+
* @returns {Array<{number: number, rel: string}>}
|
|
969
|
+
*/
|
|
970
|
+
export function featuresInQaWithoutPlan({ items = [], exists = () => false } = {}) {
|
|
971
|
+
return items
|
|
972
|
+
.filter(i => i?.number
|
|
973
|
+
&& String(i.state || '').toUpperCase() !== 'CLOSED'
|
|
974
|
+
&& (i.fields?.['Work Item Type'] === 'Feature' || /^\s*\[FEATURE\]/i.test(i.title || ''))
|
|
975
|
+
&& i.fields?.Etapa === STAGE_QA)
|
|
976
|
+
.map(i => ({ number: i.number, rel: `docs/features/${slugify(i.title || '')}/${QA_PLAN_FILE}` }))
|
|
977
|
+
.filter(f => !exists(f.rel));
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Checks do bloco `qa.lead` (função PURA — rfc/spec-qa-lead.md §6.6).
|
|
982
|
+
*
|
|
983
|
+
* `container.image` ausente é "!" e não "✗" de propósito: sem ela a fase A
|
|
984
|
+
* (`qa-lead plan`) funciona inteira — só a fase B recusa, orientando.
|
|
985
|
+
*
|
|
986
|
+
* @param {object} params
|
|
987
|
+
* @param {object|null} [params.lead] bloco `qa.lead` do .spec-wave.json
|
|
988
|
+
* @param {boolean|null} [params.dockerOk] docker acessível (null = não sondado)
|
|
989
|
+
* @param {boolean} [params.schemasOk] protocol/*.json presentes e legíveis
|
|
990
|
+
* @returns {{ warn: boolean, notes: string[] }}
|
|
991
|
+
*/
|
|
992
|
+
export function inspectQaLead({ lead = null, dockerOk = null, schemasOk = true } = {}) {
|
|
993
|
+
const notes = [];
|
|
994
|
+
let warn = false;
|
|
995
|
+
const backend = lead?.backend === 'sandbox' ? 'sandbox' : 'docker';
|
|
996
|
+
const image = lead?.container?.image;
|
|
997
|
+
|
|
998
|
+
if (!image) {
|
|
999
|
+
warn = true;
|
|
1000
|
+
notes.push(
|
|
1001
|
+
'qa-lead: `qa.lead.container.image` ausente — a fase B (`qa-lead run`) vai recusar. ' +
|
|
1002
|
+
'A fase A (`qa-lead plan`) funciona sem ela.'
|
|
1003
|
+
);
|
|
1004
|
+
} else {
|
|
1005
|
+
notes.push(`qa-lead: backend ${backend} · imagem ${image}.`);
|
|
1006
|
+
}
|
|
1007
|
+
if (backend === 'sandbox') {
|
|
1008
|
+
warn = true;
|
|
1009
|
+
notes.push(
|
|
1010
|
+
'qa-lead: backend `sandbox` ainda não tem implementação (a API de sessão do ' +
|
|
1011
|
+
'spec-wave-sandbox não existe) — use `docker`.'
|
|
1012
|
+
);
|
|
1013
|
+
} else if (dockerOk === false) {
|
|
1014
|
+
warn = true;
|
|
1015
|
+
notes.push('qa-lead: docker não acessível nesta máquina — a fase B não vai conseguir despachar containers.');
|
|
1016
|
+
}
|
|
1017
|
+
if (!schemasOk) {
|
|
1018
|
+
warn = true;
|
|
1019
|
+
notes.push(
|
|
1020
|
+
'qa-lead: schemas do protocol/ (qa-result.v1.json / qa-trail-report.v1.json) ausentes ou ' +
|
|
1021
|
+
'ilegíveis na instalação da CLI — reinstale o pacote.'
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
return { warn, notes };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// QA (rfc/spec-qa-skill.md + rfc/spec-qa-lead.md): executor configurado,
|
|
1028
|
+
// Features em 🧪 QA com plano, e a configuração do orquestrador de trilha.
|
|
1029
|
+
// As labels novas e o workflow generate-qa-plan.yml são cobertos pelos checks
|
|
1030
|
+
// existentes (higiene de labels e workflows), que leem as listas do config.mjs.
|
|
1031
|
+
async function checkQa(ctx) {
|
|
1032
|
+
const name = 'QA (qa.command, planos das Features em 🧪 QA e qa-lead)';
|
|
1033
|
+
const notes = [];
|
|
1034
|
+
let status = 'ok';
|
|
1035
|
+
|
|
1036
|
+
const fromEnv = process.env.SPEC_WAVE_QA_CMD;
|
|
1037
|
+
const fromConfig = ctx.cfg?.qa?.command;
|
|
1038
|
+
if (fromEnv) {
|
|
1039
|
+
notes.push(`Executor definido via env SPEC_WAVE_QA_CMD${fromConfig ? ' (sobrepõe o qa.command do config)' : ''}: ${fromEnv}`);
|
|
1040
|
+
} else if (fromConfig) {
|
|
1041
|
+
notes.push(`Executor definido no ${CONFIG_FILE}: ${fromConfig}`);
|
|
1042
|
+
} else {
|
|
1043
|
+
// "!" e não "✗" de propósito: sem executor o `qa` ainda monta o contexto e
|
|
1044
|
+
// orienta — é configuração pendente, não ambiente quebrado.
|
|
1045
|
+
status = 'warn';
|
|
1046
|
+
notes.push(
|
|
1047
|
+
'Nenhum executor configurado — `qa <issue>` só monta o contexto, sem executar os cenários.\n' +
|
|
1048
|
+
`Defina "qa": { "command": "..." } no ${CONFIG_FILE} (ou a env SPEC_WAVE_QA_CMD).\n` +
|
|
1049
|
+
'Placeholders: {contextFile} {qaPlanFile} {specFile} {issue} {type} {title}. Exemplos por agente:\n' +
|
|
1050
|
+
' Claude Code: claude -p "Execute o QA descrito em {contextFile}"\n' +
|
|
1051
|
+
' opencode: opencode run "Execute o QA descrito em {contextFile}"\n' +
|
|
1052
|
+
' Codex: codex exec "Execute o QA descrito em {contextFile}"\n' +
|
|
1053
|
+
' Copilot CLI: copilot -p "Execute o QA descrito em {contextFile}" --allow-all-tools'
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (ctx.token && ctx.cfg?.project?.id && ctx.root) {
|
|
1058
|
+
try {
|
|
1059
|
+
const items = await listProjectItems(ctx.token, ctx.cfg.project.id);
|
|
1060
|
+
const semPlano = featuresInQaWithoutPlan({
|
|
1061
|
+
items,
|
|
1062
|
+
exists: rel => existsSync(path.join(ctx.root, rel)),
|
|
1063
|
+
});
|
|
1064
|
+
if (semPlano.length > 0) {
|
|
1065
|
+
status = 'warn';
|
|
1066
|
+
notes.push(
|
|
1067
|
+
`Feature(s) em ${STAGE_QA} sem qa-plan.md no clone: ` +
|
|
1068
|
+
semPlano.map(f => `#${f.number} (${f.rel})`).join(', ') +
|
|
1069
|
+
' — gere com a label `spec-wave:qa` (ou rode `git pull` se o plano já foi mergeado).'
|
|
1070
|
+
);
|
|
1071
|
+
} else {
|
|
1072
|
+
notes.push(`Nenhuma Feature em ${STAGE_QA} sem plano de QA.`);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Trilha travada no portão da fase B (spec-qa-lead §6.6): Feature em
|
|
1076
|
+
// 🧪 QA sem `qa-ready` faz o `qa-lead run` do milestone dela recusar.
|
|
1077
|
+
const emQa = items.filter(i => i?.number
|
|
1078
|
+
&& String(i.state || '').toUpperCase() !== 'CLOSED'
|
|
1079
|
+
&& (i.fields?.['Work Item Type'] === 'Feature' || /^\s*\[FEATURE\]/i.test(i.title || ''))
|
|
1080
|
+
&& i.fields?.Etapa === STAGE_QA);
|
|
1081
|
+
const semReady = [];
|
|
1082
|
+
for (const item of emQa) {
|
|
1083
|
+
const issue = await makeOctokit(ctx.token).rest.issues
|
|
1084
|
+
.get({ owner: ctx.cfg.owner, repo: ctx.cfg.repo, issue_number: item.number })
|
|
1085
|
+
.catch(() => null);
|
|
1086
|
+
if (!issue) continue;
|
|
1087
|
+
const labels = (issue.data.labels || []).map(l => (typeof l === 'string' ? l : l.name));
|
|
1088
|
+
if (!labels.includes(LABEL_QA_READY)) {
|
|
1089
|
+
semReady.push({ number: item.number, milestone: issue.data.milestone?.title || '(sem milestone)' });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (semReady.length > 0) {
|
|
1093
|
+
status = 'warn';
|
|
1094
|
+
notes.push(
|
|
1095
|
+
`Feature(s) em ${STAGE_QA} sem \`${LABEL_QA_READY}\`: ` +
|
|
1096
|
+
semReady.map(f => `#${f.number} [${f.milestone}]`).join(', ') +
|
|
1097
|
+
' — o `qa-lead run` desses milestones vai recusar; rode `qa-lead plan` e revise os planos.'
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
notes.push(`Features em ${STAGE_QA} não verificáveis agora: ${err.message}`);
|
|
1102
|
+
}
|
|
1103
|
+
} else {
|
|
1104
|
+
notes.push('Features em 🧪 QA não verificadas (sem token, project ou raiz do repo).');
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Orquestrador de trilha (spec-qa-lead §6.6).
|
|
1108
|
+
const lead = ctx.cfg?.qa?.lead || null;
|
|
1109
|
+
let dockerOk = null;
|
|
1110
|
+
if (lead?.container?.image && lead?.backend !== 'sandbox') {
|
|
1111
|
+
try {
|
|
1112
|
+
execSync('docker info', { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1113
|
+
dockerOk = true;
|
|
1114
|
+
} catch {
|
|
1115
|
+
dockerOk = false;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
const protocolDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'protocol');
|
|
1119
|
+
const schemasOk = ['qa-result.v1.json', 'qa-trail-report.v1.json'].every((file) => {
|
|
1120
|
+
try {
|
|
1121
|
+
JSON.parse(readFileSync(path.join(protocolDir, file), 'utf-8'));
|
|
1122
|
+
return true;
|
|
1123
|
+
} catch {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
const leadCheck = inspectQaLead({ lead, dockerOk, schemasOk });
|
|
1128
|
+
if (leadCheck.warn) status = status === 'fail' ? 'fail' : 'warn';
|
|
1129
|
+
notes.push(...leadCheck.notes);
|
|
1130
|
+
|
|
1131
|
+
return { name, status, detail: notes.join('\n') };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
955
1134
|
// Modo de execução: o config e a variável do repositório precisam concordar.
|
|
956
1135
|
// Discordar não é detalhe — é o usuário achando que desligou os workflows e
|
|
957
1136
|
// continuando a pagar minutos (ou o contrário: tudo pulado e nada rodando).
|
|
@@ -1277,6 +1456,7 @@ export async function doctor() {
|
|
|
1277
1456
|
checkAi,
|
|
1278
1457
|
checkDecompositions,
|
|
1279
1458
|
checkSpecKit,
|
|
1459
|
+
checkQa,
|
|
1280
1460
|
checkWorkflows,
|
|
1281
1461
|
checkPrPublishing,
|
|
1282
1462
|
checkExecutionMode,
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Gera (ou re-critica) o plano de QA — docs/features/<slug>/qa-plan.md.
|
|
2
|
+
//
|
|
3
|
+
// Espelho do rascunho do decompose, com a mesma semântica de preservação:
|
|
4
|
+
// arquivo AUSENTE → gera via IA, publica em PR e critica; arquivo PRESENTE →
|
|
5
|
+
// valida + critica COMO ESTÁ, sem regerar (edições manuais são o esperado, não
|
|
6
|
+
// a exceção). Para gerar outro do zero: apagar o arquivo e reaplicar
|
|
7
|
+
// `spec-wave:qa`.
|
|
8
|
+
//
|
|
9
|
+
// A ordem interna importa (spec §6.1): a validação DETERMINÍSTICA roda ANTES da
|
|
10
|
+
// crítica — é barata e pega o que não precisa de julgamento (Story descoberta,
|
|
11
|
+
// referência a issue que não é sub-issue, campo obrigatório vazio, truncamento).
|
|
12
|
+
// Só um plano estruturalmente válido gasta uma chamada de crítica.
|
|
13
|
+
|
|
14
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
15
|
+
import {
|
|
16
|
+
getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments, getRepoDefaultBranch,
|
|
17
|
+
} from '../api/github-rest.mjs';
|
|
18
|
+
import { getIssueParent, listSubIssues } from '../api/github-graphql.mjs';
|
|
19
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
20
|
+
import { generateDocument } from '../lib/claude.mjs';
|
|
21
|
+
import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
|
|
22
|
+
import {
|
|
23
|
+
runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
|
|
24
|
+
parseCritiqueDecisions, applyCritiqueDecisions, renderRiskAcceptedComment,
|
|
25
|
+
} from '../lib/critique.mjs';
|
|
26
|
+
import { recordUsage } from '../lib/usage-report.mjs';
|
|
27
|
+
import { resolveDocDir } from '../lib/doc-paths.mjs';
|
|
28
|
+
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
29
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
30
|
+
import { resolveFlowContext } from '../lib/flow-run.mjs';
|
|
31
|
+
import { publishArtifact } from '../lib/artifact-publish.mjs';
|
|
32
|
+
import { loadArtifact, isAwaitingMerge } from '../lib/doc-source.mjs';
|
|
33
|
+
import { awaitingMergeBlock, renderPrLine } from '../lib/artifact-pr.mjs';
|
|
34
|
+
import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
|
|
35
|
+
import { buildTechContext } from '../lib/tech-context.mjs';
|
|
36
|
+
import {
|
|
37
|
+
QA_PLAN_FILE, parseQaPlanDoc, renderQaPlanDoc, validateQaPlan,
|
|
38
|
+
} from '../lib/qa-plan-doc.mjs';
|
|
39
|
+
import {
|
|
40
|
+
LABEL_QA, LABEL_QA_READY, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN,
|
|
41
|
+
LABEL_RISK_ACCEPTED, DEFAULT_MAX_CRITIQUE_ATTEMPTS, TARGET_LANGUAGE, labelNames,
|
|
42
|
+
} from '../config.mjs';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parada por decisão do fluxo: o comentário já foi postado; o erro precisa
|
|
46
|
+
* propagar para o Action ficar vermelho — mesmo contrato do decompose.
|
|
47
|
+
*/
|
|
48
|
+
class QaPlanBlockedError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'QaPlanBlockedError';
|
|
52
|
+
this.blocked = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Sobe a cadeia de pais até achar a Feature (mesma mecânica do implement).
|
|
57
|
+
async function resolveParentFeature(token, startNodeId) {
|
|
58
|
+
let current = startNodeId;
|
|
59
|
+
for (let depth = 0; depth < 5 && current; depth++) {
|
|
60
|
+
const parent = await getIssueParent(token, current);
|
|
61
|
+
if (!parent) return null;
|
|
62
|
+
if (detectIssueType({ title: parent.title }) === 'Feature') return parent;
|
|
63
|
+
current = parent.nodeId;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function generateQaPlan({ issueNumber }) {
|
|
69
|
+
const token = await resolveToken();
|
|
70
|
+
const { owner, repo, mode: runMode } = resolveFlowContext({ command: 'generate-qa-plan' });
|
|
71
|
+
console.log(`Modo de execução: ${runMode}`);
|
|
72
|
+
|
|
73
|
+
const number = parseInt(issueNumber, 10);
|
|
74
|
+
const issue = await getIssue(token, owner, repo, number);
|
|
75
|
+
const type = detectIssueType(issue);
|
|
76
|
+
const labels = labelNames(issue);
|
|
77
|
+
|
|
78
|
+
// ── Escopo por tipo (spec §3): o plano é POR FEATURE, arquivo único ────────
|
|
79
|
+
if (type !== 'Feature') {
|
|
80
|
+
console.log(`Issue #${number} é ${type || 'desconhecido'} — qa-plan é gerado só para Feature.`);
|
|
81
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
82
|
+
if (type === 'Story') {
|
|
83
|
+
const feature = await resolveParentFeature(token, issue.node_id).catch(() => null);
|
|
84
|
+
await commentOnIssue(token, owner, repo, number,
|
|
85
|
+
`ℹ️ **O plano de QA é por Feature, não por Story** (D-QA1). ` +
|
|
86
|
+
(feature
|
|
87
|
+
? `Aplique \`${LABEL_QA}\` na Feature-pai **#${feature.number}** — o plano dela ` +
|
|
88
|
+
`cobre esta Story com seções \`## Cenário N — Story #${number}\`.`
|
|
89
|
+
: 'Não encontrei a Feature-pai desta Story — vincule-a como sub-issue de uma Feature e ' +
|
|
90
|
+
`aplique \`${LABEL_QA}\` lá.`) +
|
|
91
|
+
`\n\nPara **executar** os cenários desta Story: \`npx @spec-wave/cli@latest qa ${number}\`.`
|
|
92
|
+
).catch(() => {});
|
|
93
|
+
} else if (type === 'Bug') {
|
|
94
|
+
await commentOnIssue(token, owner, repo, number,
|
|
95
|
+
`ℹ️ **Bug não gera plano de QA:** o QA de um Bug usa a seção ` +
|
|
96
|
+
'`Teste de Regressão` do próprio `bug.md`. ' +
|
|
97
|
+
`Execute direto: \`npx @spec-wave/cli@latest qa ${number}\`.`
|
|
98
|
+
).catch(() => {});
|
|
99
|
+
} else {
|
|
100
|
+
await commentOnIssue(token, owner, repo, number,
|
|
101
|
+
`ℹ️ **qa-plan não se aplica a ${type || 'este tipo'}.** ` +
|
|
102
|
+
'O plano de QA é gerado para **Features** (um arquivo, com cenários por Story).'
|
|
103
|
+
).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Portões humanos (spec §6.1 passo 2): falha imediata, com remediação ────
|
|
109
|
+
for (const [label, remedio] of [
|
|
110
|
+
[LABEL_NEEDS_HUMAN, 'revise os documentos e remova a label (e a `spec-wave:critique-failed`, se houver)'],
|
|
111
|
+
[LABEL_CRITIQUE_FAILED, 'corrija o documento apontado no comentário 🔎 e remova a label'],
|
|
112
|
+
]) {
|
|
113
|
+
if (labels.includes(label)) {
|
|
114
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
115
|
+
await commentOnIssue(token, owner, repo, number,
|
|
116
|
+
`⏸️ **generate-qa-plan parado:** a issue tem a label \`${label}\` — ${remedio}, ` +
|
|
117
|
+
`e reaplique \`${LABEL_QA}\`.`
|
|
118
|
+
).catch(() => {});
|
|
119
|
+
throw new QaPlanBlockedError(`portão humano pendente: ${label}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { config, root } = loadConfig();
|
|
124
|
+
const { rel: dirRel } = resolveDocDir(root, issue, type);
|
|
125
|
+
const docRel = `${dirRel}/${QA_PLAN_FILE}`;
|
|
126
|
+
const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
|
|
127
|
+
|
|
128
|
+
const ler = (doc, pathRel) => loadArtifact({
|
|
129
|
+
token, owner, repo, root, pathRel, doc, issueNumber: number, base,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── spec.md e plan.md são a matéria-prima (spec §6.1 passo 3) ──────────────
|
|
133
|
+
const spec = await ler('spec', `${dirRel}/spec.md`);
|
|
134
|
+
const plan = await ler('plan', `${dirRel}/plan.md`);
|
|
135
|
+
for (const [nome, doc] of [['spec.md', spec], ['plan.md', plan]]) {
|
|
136
|
+
if (isAwaitingMerge(doc.state)) {
|
|
137
|
+
const bloqueio = awaitingMergeBlock({
|
|
138
|
+
pathRel: `${dirRel}/${nome}`, state: doc.state, pr: doc.pr, branch: doc.ref,
|
|
139
|
+
});
|
|
140
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
141
|
+
await commentOnIssue(token, owner, repo, number,
|
|
142
|
+
`⏸️ **generate-qa-plan parado:** ${bloqueio.message}\n\n${bloqueio.unblock}\n\n` +
|
|
143
|
+
`Reaplique \`${LABEL_QA}\` depois do merge.`
|
|
144
|
+
).catch(() => {});
|
|
145
|
+
throw new QaPlanBlockedError(bloqueio.message);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const ausentes = [['spec.md', spec], ['plan.md', plan]]
|
|
149
|
+
.filter(([, doc]) => doc.content == null).map(([nome]) => nome);
|
|
150
|
+
if (ausentes.length > 0) {
|
|
151
|
+
console.log(`Documentos ausentes: ${ausentes.join(', ')} — nada gerado.`);
|
|
152
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
153
|
+
await commentOnIssue(token, owner, repo, number,
|
|
154
|
+
`ℹ️ **Plano de QA não gerado:** falta ${ausentes.map(a => `\`${a}\``).join(' e ')} em \`${dirRel}/\`. ` +
|
|
155
|
+
'O plano deriva dos critérios de aceite da spec — gere e valide spec/plan primeiro, ' +
|
|
156
|
+
`depois reaplique \`${LABEL_QA}\`.`
|
|
157
|
+
).catch(() => {});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Stories da Feature: o plano mapeia cenário → Story real ────────────────
|
|
162
|
+
let subIssues = [];
|
|
163
|
+
try {
|
|
164
|
+
subIssues = await listSubIssues(token, issue.node_id);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Sem a árvore não há como validar as referências `Story #X` — e um plano
|
|
167
|
+
// publicado sem essa checagem é o que a validação determinística existe
|
|
168
|
+
// para impedir.
|
|
169
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
170
|
+
await commentOnIssue(token, owner, repo, number,
|
|
171
|
+
`❌ **generate-qa-plan falhou:** não consegui listar as sub-issues da Feature ` +
|
|
172
|
+
`(${err.message}). Reaplique \`${LABEL_QA}\` para tentar de novo.`
|
|
173
|
+
).catch(() => {});
|
|
174
|
+
throw new QaPlanBlockedError(`sub-issues não legíveis: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
const stories = subIssues.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
|
|
177
|
+
if (stories.length === 0) {
|
|
178
|
+
console.log('Feature sem Stories — nada a planejar.');
|
|
179
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
180
|
+
await commentOnIssue(token, owner, repo, number,
|
|
181
|
+
'ℹ️ **Plano de QA não gerado:** a Feature não tem Stories (sub-issues). ' +
|
|
182
|
+
`Decomponha primeiro (\`spec-wave:decompose\`) e reaplique \`${LABEL_QA}\`.`
|
|
183
|
+
).catch(() => {});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const usageEntries = [];
|
|
188
|
+
try {
|
|
189
|
+
await runDraftOrCritique({
|
|
190
|
+
token, owner, repo, issue, number, labels, config, root, runMode,
|
|
191
|
+
dirRel, docRel, base, spec, plan, stories, usage: usageEntries, ler,
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (!err.blocked) {
|
|
195
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
196
|
+
await commentOnIssue(token, owner, repo, number,
|
|
197
|
+
'❌ **Falha ao gerar o plano de QA**\n\n' +
|
|
198
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
199
|
+
`A label \`${LABEL_QA}\` foi removida para destravar o gatilho — ` +
|
|
200
|
+
'adicione-a de novo para tentar outra vez.'
|
|
201
|
+
).catch(() => {});
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
} finally {
|
|
205
|
+
await recordUsage({ token, owner, repo, issueNumber: number, entries: usageEntries });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function runDraftOrCritique(ctx) {
|
|
210
|
+
const {
|
|
211
|
+
token, owner, repo, issue, number, labels, config, root, runMode,
|
|
212
|
+
dirRel, docRel, base, spec, plan, stories, usage, ler,
|
|
213
|
+
} = ctx;
|
|
214
|
+
|
|
215
|
+
// Plano existente é preservado COMO ESTÁ — inclusive o que vive num PR ainda
|
|
216
|
+
// não mergeado (é lá que o revisor edita). Mesma invariante do decompose.
|
|
217
|
+
const existente = await ler('qa-plan', docRel);
|
|
218
|
+
|
|
219
|
+
let markdown;
|
|
220
|
+
let publicado = null;
|
|
221
|
+
if (existente.content != null) {
|
|
222
|
+
const onde = existente.state === 'pending-pr'
|
|
223
|
+
? `no PR #${existente.pr?.number} (ainda não mergeado)`
|
|
224
|
+
: existente.state === 'branch-only'
|
|
225
|
+
? `na branch ${existente.ref} (sem PR aberto)`
|
|
226
|
+
: docRel;
|
|
227
|
+
console.log(`Plano encontrado ${onde} — validando e criticando o arquivo como está (sem regerar).`);
|
|
228
|
+
markdown = existente.content;
|
|
229
|
+
if (isAwaitingMerge(existente.state)) {
|
|
230
|
+
publicado = { pr: existente.pr, branch: existente.ref, unchanged: true };
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
console.log(`Gerando plano de QA para: ${issue.title}`);
|
|
234
|
+
const payload = {
|
|
235
|
+
feature: { number, title: issue.title },
|
|
236
|
+
stories: stories.map(s => ({ number: s.number, title: s.title, body: s.body || '' })),
|
|
237
|
+
spec: spec.content,
|
|
238
|
+
plan: plan.content,
|
|
239
|
+
};
|
|
240
|
+
const userContent =
|
|
241
|
+
'Gere o qa-plan.md a partir deste payload JSON. Os números de Story dos títulos ' +
|
|
242
|
+
'"## Cenário N — Story #X" DEVEM sair da lista `stories` abaixo — nunca invente números.\n\n' +
|
|
243
|
+
JSON.stringify(payload, null, 2);
|
|
244
|
+
|
|
245
|
+
const qaPrompt = loadPrompt('qa', { cwd: root });
|
|
246
|
+
const { content: bruto } = await generateDocument(
|
|
247
|
+
systemPromptWithTools(qaPrompt),
|
|
248
|
+
userContent,
|
|
249
|
+
{
|
|
250
|
+
action: 'qa', maxTurns: qaPrompt.maxTurns, labels, usage,
|
|
251
|
+
lint: { lang: TARGET_LANGUAGE }, withReport: true,
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// parse + render canônico: o arquivo nasce com marcador, numeração por
|
|
256
|
+
// posição e H1 — mesmo que o modelo tenha errado qualquer um dos três.
|
|
257
|
+
let gerado;
|
|
258
|
+
try {
|
|
259
|
+
gerado = parseQaPlanDoc(unwrapGeneratedDoc(bruto), { requireMarker: false });
|
|
260
|
+
} catch (err) {
|
|
261
|
+
throw new Error(`o modelo devolveu um plano fora do formato: ${err.message}`);
|
|
262
|
+
}
|
|
263
|
+
markdown = renderQaPlanDoc({
|
|
264
|
+
title: issue.title,
|
|
265
|
+
issueNumber: number,
|
|
266
|
+
scenarios: gerado.scenarios.map(s => ({ story: s.story, body: s.body })),
|
|
267
|
+
});
|
|
268
|
+
publicado = await publishArtifact({
|
|
269
|
+
token, owner, repo, doc: 'qa-plan',
|
|
270
|
+
issueNumber: number, issueTitle: issue.title, issueUrl: issue.html_url,
|
|
271
|
+
pathRel: docRel, content: markdown, base,
|
|
272
|
+
});
|
|
273
|
+
if (publicado.warning) console.warn(`⚠️ ${publicado.warning}`);
|
|
274
|
+
console.log(`Plano publicado em ${publicado.branch} (${docRel}).`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const refDoDocumento = publicado?.branch || existente.ref || undefined;
|
|
278
|
+
const blobUrl = docBlobUrl({
|
|
279
|
+
owner, repo, pathRel: docRel, mode: runMode, root, ref: refDoDocumento,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── Estrutura antes de qualquer gasto: parse do arquivo como está ──────────
|
|
283
|
+
let doc;
|
|
284
|
+
try {
|
|
285
|
+
doc = parseQaPlanDoc(markdown);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
await commentOnIssue(token, owner, repo, number,
|
|
288
|
+
`❌ **Não consegui ler o plano de QA.**\n\n` +
|
|
289
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
290
|
+
`Corrija [\`${docRel}\`](${blobUrl}) e reaplique \`${LABEL_QA}\`. ` +
|
|
291
|
+
'Para começar de novo do zero, apague o arquivo.'
|
|
292
|
+
).catch(() => {});
|
|
293
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
294
|
+
throw new QaPlanBlockedError(err.message);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Validação determinística (spec §6.1 passo 5) — antes da crítica ────────
|
|
298
|
+
const errosDeterministicos = validateQaPlan({ doc, stories, content: markdown });
|
|
299
|
+
if (errosDeterministicos.length > 0) {
|
|
300
|
+
console.log(`Validação determinística reprovou: ${errosDeterministicos.length} problema(s) — crítica não executada.`);
|
|
301
|
+
await commentOnIssue(token, owner, repo, number,
|
|
302
|
+
'⚠️ **Plano de QA reprovado na validação determinística** (a crítica adversarial ' +
|
|
303
|
+
'nem chegou a rodar — nada de IA foi gasto):\n\n' +
|
|
304
|
+
errosDeterministicos.map(e => `- ${e}`).join('\n') +
|
|
305
|
+
`\n\nCorrija [\`${docRel}\`](${blobUrl}) e reaplique \`${LABEL_QA}\`.`
|
|
306
|
+
).catch(() => {});
|
|
307
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
308
|
+
throw new QaPlanBlockedError(
|
|
309
|
+
`validação determinística reprovou o plano: ${errosDeterministicos[0]}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
console.log(`Plano válido: ${doc.scenarios.length} cenário(s) cobrindo ${stories.length} Story(ies).`);
|
|
313
|
+
|
|
314
|
+
// ── Crítica adversarial (kind 'qa') — mesma máquina de tentativas ──────────
|
|
315
|
+
const comments = await listIssueComments(token, owner, repo, number).catch(err => {
|
|
316
|
+
console.warn(`Não foi possível listar comentários: ${err.message} — contador de tentativas em 1.`);
|
|
317
|
+
return [];
|
|
318
|
+
});
|
|
319
|
+
const maxAttempts = Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
|
|
320
|
+
? config.ai.maxCritiqueAttempts
|
|
321
|
+
: DEFAULT_MAX_CRITIQUE_ATTEMPTS;
|
|
322
|
+
const escalationModel = config?.ai?.escalationModel || null;
|
|
323
|
+
const { attempt, blocked } = resolveCritiqueAttempt({ comments, labels, kind: 'qa', maxAttempts });
|
|
324
|
+
const decisions = parseCritiqueDecisions(comments, 'qa');
|
|
325
|
+
|
|
326
|
+
if (blocked) {
|
|
327
|
+
console.log(`Teto de ${maxAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
|
|
328
|
+
await commentOnIssue(token, owner, repo, number,
|
|
329
|
+
renderNeedsHumanComment({ kind: 'qa', attempt, maxAttempts, escalationModel })).catch(() => {});
|
|
330
|
+
await addLabel(token, owner, repo, number, LABEL_NEEDS_HUMAN);
|
|
331
|
+
await removeLabel(token, owner, repo, number, LABEL_QA);
|
|
332
|
+
throw new QaPlanBlockedError(
|
|
333
|
+
`A crítica reprovou ${maxAttempts - 1}x seguidas. Label ${LABEL_NEEDS_HUMAN} aplicada.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const model = attempt > 1 ? (escalationModel || undefined) : undefined;
|
|
338
|
+
if (model) console.log(`Tentativa ${attempt}: escalando a crítica para ${model}.`);
|
|
339
|
+
|
|
340
|
+
// Contexto da crítica (spec §6.1 passo 6): spec, plan, decomposition e
|
|
341
|
+
// tech_context — os documentos contra os quais o plano precisa se sustentar.
|
|
342
|
+
const decomposition = await ler('decomposition', `${dirRel}/decomposition.md`).catch(() => null);
|
|
343
|
+
const techContextYaml = buildTechContext({ issueBody: issue.body || '', cwd: root || process.cwd() }).yaml;
|
|
344
|
+
|
|
345
|
+
let critique;
|
|
346
|
+
try {
|
|
347
|
+
critique = await runCritique({
|
|
348
|
+
kind: 'qa',
|
|
349
|
+
spec: spec.content,
|
|
350
|
+
plan: plan.content,
|
|
351
|
+
decomposition: decomposition?.content || undefined,
|
|
352
|
+
techContextYaml,
|
|
353
|
+
qaPlan: markdown,
|
|
354
|
+
decisions,
|
|
355
|
+
attempt, maxAttempts, model, labels, usage, cwd: root || undefined,
|
|
356
|
+
});
|
|
357
|
+
} catch (err) {
|
|
358
|
+
// O verde do QA é automático (D-QA3), então o portão do plano não pode ser
|
|
359
|
+
// pulado: sem crítica não há `qa-ready`, e o run fica vermelho.
|
|
360
|
+
await commentOnIssue(token, owner, repo, number,
|
|
361
|
+
`❌ **A crítica adversarial do plano de QA não concluiu.**\n\n` +
|
|
362
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
363
|
+
`O plano está em [\`${docRel}\`](${blobUrl}), mas **sem crítica não há liberação** — ` +
|
|
364
|
+
`o verde do QA avança o board sozinho, e este é o portão. Reaplique \`${LABEL_QA}\`.`
|
|
365
|
+
).catch(() => {});
|
|
366
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
367
|
+
throw new QaPlanBlockedError(`Crítica adversarial não concluiu: ${err.message}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
await commentOnIssue(token, owner, repo, number, critique.markdown)
|
|
371
|
+
.catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
|
|
372
|
+
|
|
373
|
+
const { blocking, accepted } = applyCritiqueDecisions(critique.findings, decisions, 'qa');
|
|
374
|
+
if (accepted.length > 0) {
|
|
375
|
+
await addLabel(token, owner, repo, number, LABEL_RISK_ACCEPTED).catch(() => {});
|
|
376
|
+
await commentOnIssue(token, owner, repo, number,
|
|
377
|
+
renderRiskAcceptedComment({ kind: 'qa', accepted })).catch(() => {});
|
|
378
|
+
}
|
|
379
|
+
if (blocking.length > 0) {
|
|
380
|
+
console.log(`Crítica apontou ${blocking.length} finding(s) GRAVE(s) sem decisão.`);
|
|
381
|
+
await addLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED);
|
|
382
|
+
await removeLabel(token, owner, repo, number, LABEL_QA_READY).catch(() => {});
|
|
383
|
+
await removeLabel(token, owner, repo, number, LABEL_QA);
|
|
384
|
+
throw new QaPlanBlockedError(
|
|
385
|
+
`A crítica adversarial apontou findings graves no plano (tentativa ${attempt}). ` +
|
|
386
|
+
`Corrija ${docRel} e reaplique ${LABEL_QA}.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
await removeLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED).catch(() => {});
|
|
390
|
+
|
|
391
|
+
// ── Liberação: qa-ready = passou na validação + crítica, espera humano ─────
|
|
392
|
+
await addLabel(token, owner, repo, number, LABEL_QA_READY);
|
|
393
|
+
await removeLabel(token, owner, repo, number, LABEL_QA);
|
|
394
|
+
|
|
395
|
+
const porStory = new Map();
|
|
396
|
+
for (const s of doc.scenarios) porStory.set(s.story, (porStory.get(s.story) || 0) + 1);
|
|
397
|
+
const resumo = [...porStory.entries()]
|
|
398
|
+
.map(([story, n]) => `- Story #${story}: ${n} cenário(s)`)
|
|
399
|
+
.join('\n');
|
|
400
|
+
|
|
401
|
+
const pr = publicado?.pr;
|
|
402
|
+
const revisao = pr?.number
|
|
403
|
+
? `${renderPrLine(publicado)}\n\n**Revise o plano no PR** (edite o que precisar — as edições ` +
|
|
404
|
+
'são preservadas) e faça o merge. '
|
|
405
|
+
: '**Revise o plano** (edite o que precisar — as edições são preservadas). ';
|
|
406
|
+
|
|
407
|
+
await commentOnIssue(token, owner, repo, number,
|
|
408
|
+
`🧪 **Plano de QA pronto para revisão humana** (${doc.scenarios.length} cenário(s)).\n\n` +
|
|
409
|
+
`📄 Arquivo: [\`${docRel}\`](${blobUrl})\n\n${resumo}\n\n` +
|
|
410
|
+
'A crítica adversarial não encontrou problemas graves e a label ' +
|
|
411
|
+
`\`${LABEL_QA_READY}\` foi aplicada — **ela é o portão humano**: o veredito verde da ` +
|
|
412
|
+
'execução avança a Etapa sozinho, então revise os cenários ANTES de rodar.\n\n' +
|
|
413
|
+
revisao +
|
|
414
|
+
'Depois execute localmente:\n' +
|
|
415
|
+
`\`\`\`\nnpx @spec-wave/cli@latest qa ${number} --dry-run\nnpx @spec-wave/cli@latest qa ${number}\n\`\`\`\n` +
|
|
416
|
+
`Para uma nova crítica depois de editar, reaplique \`${LABEL_QA}\` — o arquivo é ` +
|
|
417
|
+
'criticado como está, sem ser regerado. Para gerar outro do zero, apague o arquivo.'
|
|
418
|
+
).catch(err => console.warn(`Falha ao comentar a liberação: ${err.message}`));
|
|
419
|
+
console.log(`Plano de QA liberado para revisão (${doc.scenarios.length} cenário(s)).`);
|
|
420
|
+
return { pr: pr || null };
|
|
421
|
+
}
|