@spec-wave/cli 0.27.0 → 0.28.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-rest.mjs +27 -0
- package/src/cli.mjs +12 -0
- package/src/commands/doctor.mjs +40 -16
- package/src/commands/implement.mjs +7 -2
- package/src/commands/install-skill.mjs +18 -8
- package/src/commands/preflight.mjs +322 -0
- package/src/commands/update.mjs +143 -12
- package/src/lib/pr-branch.mjs +96 -7
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/README.md +5 -0
- package/src/plugin/skills/preparar-feature/SKILL.md +245 -0
- package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
- package/src/plugin/skills/preparar-specs/SKILL.md +171 -0
- package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
- package/src/plugin/skills/preparar-specs/reference/revisao.md +107 -0
- package/src/plugin/skills/update/SKILL.md +10 -4
- package/src/plugin/skills/workflow/SKILL.md +6 -1
- package/src/templates/skill/SKILL.md +6 -3
package/package.json
CHANGED
package/src/api/github-rest.mjs
CHANGED
|
@@ -294,6 +294,33 @@ export async function createIssue(token, owner, repo, title, body, labels, { mil
|
|
|
294
294
|
return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url, id: res.data.id };
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
// Milestones do repositório (abertas e fechadas).
|
|
298
|
+
//
|
|
299
|
+
// O usuário fala o TÍTULO da milestone ("v06"), a API de issues filtra pelo
|
|
300
|
+
// NÚMERO. A tradução mora aqui — e a lista completa também serve para a
|
|
301
|
+
// mensagem de erro: "milestone não encontrada" sem dizer quais existem manda o
|
|
302
|
+
// usuário adivinhar entre nome errado e API fora do ar.
|
|
303
|
+
export async function listMilestones(token, owner, repo) {
|
|
304
|
+
const octokit = makeOctokit(token);
|
|
305
|
+
return await octokit.paginate(octokit.rest.issues.listMilestones, {
|
|
306
|
+
owner, repo, state: 'all', per_page: 100,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Issues de uma milestone (pelo NÚMERO dela), abertas e fechadas.
|
|
311
|
+
//
|
|
312
|
+
// `state: 'all'` de propósito: uma Feature fechada continua contando para o
|
|
313
|
+
// inventário — o que muda é que ela não entra na lista do que gerar.
|
|
314
|
+
export async function listIssuesByMilestone(token, owner, repo, milestoneNumber) {
|
|
315
|
+
const octokit = makeOctokit(token);
|
|
316
|
+
const issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
|
|
317
|
+
owner, repo, milestone: String(milestoneNumber), state: 'all', per_page: 100,
|
|
318
|
+
});
|
|
319
|
+
// `listForRepo` devolve Pull Requests junto — eles são issues para a API, e
|
|
320
|
+
// não para o fluxo.
|
|
321
|
+
return issues.filter(i => !i.pull_request);
|
|
322
|
+
}
|
|
323
|
+
|
|
297
324
|
export async function getIssue(token, owner, repo, issueNumber) {
|
|
298
325
|
const octokit = makeOctokit(token);
|
|
299
326
|
const res = await octokit.rest.issues.get({ owner, repo, issue_number: issueNumber });
|
package/src/cli.mjs
CHANGED
|
@@ -162,6 +162,16 @@ export function buildProgram() {
|
|
|
162
162
|
await run(issue, options).catch(err => { console.error(err.message); process.exit(1); });
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
program
|
|
166
|
+
.command('preflight')
|
|
167
|
+
.description('Confere, antes de gerar, tudo que decide uma rodada de specs de uma milestone')
|
|
168
|
+
.requiredOption('--milestone <nome>', 'Título da milestone a inventariar')
|
|
169
|
+
.option('--json', 'Imprime o relatório em JSON')
|
|
170
|
+
.action(async (options) => {
|
|
171
|
+
const { preflight } = await import('./commands/preflight.mjs');
|
|
172
|
+
await preflight(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
173
|
+
});
|
|
174
|
+
|
|
165
175
|
program
|
|
166
176
|
.command('mode')
|
|
167
177
|
.description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
|
|
@@ -188,6 +198,8 @@ export function buildProgram() {
|
|
|
188
198
|
.option('--branch [nome]', 'Envia os arquivos do repo como Pull Request numa branch, em um único commit (sem valor: spec-wave/update-v<versão>)')
|
|
189
199
|
.option('--config-in-pr', 'Força incluir o .spec-wave.json no Pull Request')
|
|
190
200
|
.option('--no-config-in-pr', 'Força manter o .spec-wave.json fora do Pull Request')
|
|
201
|
+
.option('--skill-in-pr', 'Força incluir a skill dos agentes no Pull Request')
|
|
202
|
+
.option('--no-skill-in-pr', 'Força manter a skill dos agentes fora do Pull Request')
|
|
191
203
|
.option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
|
|
192
204
|
.option('--yes', 'Aplica sem pedir confirmação')
|
|
193
205
|
.action(async (options) => {
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -1083,10 +1083,15 @@ export function inspectPrPublishing({
|
|
|
1083
1083
|
return { status, notes };
|
|
1084
1084
|
}
|
|
1085
1085
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1086
|
+
/**
|
|
1087
|
+
* Permissões de PR declaradas nos workflows instalados (I/O — nunca lança).
|
|
1088
|
+
*
|
|
1089
|
+
* O job MAIS RESTRITIVO manda: basta um sem a permissão para o fluxo quebrar.
|
|
1090
|
+
*
|
|
1091
|
+
* @param {string} dir diretório `.github/workflows`
|
|
1092
|
+
* @returns {Record<string, {contents?: string, pullRequests?: string}>}
|
|
1093
|
+
*/
|
|
1094
|
+
export function readWorkflowPrPermissions(dir) {
|
|
1090
1095
|
const workflowPerms = {};
|
|
1091
1096
|
for (const file of ARTIFACT_WORKFLOW_FILES) {
|
|
1092
1097
|
const caminho = path.join(dir, file);
|
|
@@ -1097,7 +1102,6 @@ async function checkPrPublishing(ctx) {
|
|
|
1097
1102
|
} catch {
|
|
1098
1103
|
continue; // YAML ilegível é problema de outro check
|
|
1099
1104
|
}
|
|
1100
|
-
// O job mais restritivo manda: basta um sem a permissão para o fluxo quebrar.
|
|
1101
1105
|
for (const job of Object.values(wf?.jobs || {})) {
|
|
1102
1106
|
const atual = workflowPerms[file];
|
|
1103
1107
|
const perm = {
|
|
@@ -1109,20 +1113,29 @@ async function checkPrPublishing(ctx) {
|
|
|
1109
1113
|
}
|
|
1110
1114
|
}
|
|
1111
1115
|
}
|
|
1116
|
+
return workflowPerms;
|
|
1117
|
+
}
|
|
1112
1118
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1119
|
+
/**
|
|
1120
|
+
* Coleta o contexto que `inspectPrPublishing` julga (I/O — nunca lança).
|
|
1121
|
+
*
|
|
1122
|
+
* Separado do check para o `preflight` poder fazer a MESMA pergunta sem
|
|
1123
|
+
* reimplementá-la: as três consultas têm cada uma o seu motivo para falhar sem
|
|
1124
|
+
* que isso signifique "está errado", e duplicar esse tratamento em dois comandos
|
|
1125
|
+
* é como as duas respostas passam a divergir.
|
|
1126
|
+
*
|
|
1127
|
+
* @param {{token?: string, owner?: string, repo?: string, root?: string}} params
|
|
1128
|
+
* @returns {Promise<{canCreatePr: boolean|null, workflowPerms: object,
|
|
1129
|
+
* requiredChecks: string[]|null, prTokenPresent: boolean|null}>}
|
|
1130
|
+
*/
|
|
1131
|
+
export async function readPrPublishingContext({ token, owner, repo, root }) {
|
|
1132
|
+
const workflowPerms = readWorkflowPrPermissions(path.join(root || process.cwd(), '.github', 'workflows'));
|
|
1120
1133
|
|
|
1121
1134
|
let canCreatePr = null;
|
|
1122
1135
|
let requiredChecks = null;
|
|
1123
1136
|
let prTokenPresent = null;
|
|
1124
|
-
if (
|
|
1125
|
-
const octokit = makeOctokit(
|
|
1137
|
+
if (token && owner && repo) {
|
|
1138
|
+
const octokit = makeOctokit(token);
|
|
1126
1139
|
try {
|
|
1127
1140
|
const res = await octokit.request('GET /repos/{owner}/{repo}/actions/secrets',
|
|
1128
1141
|
{ owner, repo });
|
|
@@ -1146,10 +1159,21 @@ async function checkPrPublishing(ctx) {
|
|
|
1146
1159
|
requiredChecks = null; // sem proteção (404) ou sem permissão — nos dois casos, não afirmar
|
|
1147
1160
|
}
|
|
1148
1161
|
}
|
|
1162
|
+
return { canCreatePr, workflowPerms, requiredChecks, prTokenPresent };
|
|
1163
|
+
}
|
|
1149
1164
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1165
|
+
async function checkPrPublishing(ctx) {
|
|
1166
|
+
const name = 'Publicação por Pull Request';
|
|
1167
|
+
const cfg = ctx.cfg || {};
|
|
1168
|
+
const contexto = await readPrPublishingContext({
|
|
1169
|
+
token: ctx.token, owner: cfg.owner, repo: cfg.repo, root: ctx.root || ctx.cwd,
|
|
1152
1170
|
});
|
|
1171
|
+
|
|
1172
|
+
if (Object.keys(contexto.workflowPerms).length === 0) {
|
|
1173
|
+
return { name, status: 'warn', detail: 'Workflows não encontrados — rode `init` ou `update`.' };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const { status, notes } = inspectPrPublishing(contexto);
|
|
1153
1177
|
return { name, status, detail: notes.join('\n ') };
|
|
1154
1178
|
}
|
|
1155
1179
|
|
|
@@ -215,7 +215,12 @@ function buildContext({
|
|
|
215
215
|
lines.push('');
|
|
216
216
|
lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks na Etapa ${STAGE_DONE}):`);
|
|
217
217
|
lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
|
|
218
|
-
|
|
218
|
+
// Draft de propósito: numa pilha de Stories (um PR baseado no anterior),
|
|
219
|
+
// cada rebase em cascata dispararia o CI inteiro em PRs que ninguém vai
|
|
220
|
+
// mergear naquele estado. Rascunho não mergeia (o GitHub bloqueia) e o
|
|
221
|
+
// required check continua valendo: quem revisa marca o PR como pronto, o
|
|
222
|
+
// `ready_for_review` dispara o CI, e só então o merge destrava.
|
|
223
|
+
lines.push(` 2. Abra o **Pull Request** da Story #${issue.number} **como rascunho** (\`gh pr create --draft\`) — o CI não roda em rascunho; quem revisa marca o PR como pronto e é aí que os checks disparam.`);
|
|
219
224
|
lines.push(
|
|
220
225
|
` 3. **Avance a Etapa da Story #${issue.number} para ${STAGE_CODE_REVIEW}** ` +
|
|
221
226
|
`(reinicie o Status para ${PROGRESS_TODO}). As Tasks já estão em ${STAGE_DONE}.`
|
|
@@ -398,7 +403,7 @@ export function buildFeatureContext({
|
|
|
398
403
|
lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
|
|
399
404
|
lines.push(' 2. **Implemente** a Task por completo.');
|
|
400
405
|
lines.push(` 3. **Ao concluir:** **avance a Task para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
|
|
401
|
-
lines.push(`3. **Ao concluir TODAS as Tasks da Story:** faça o **commit**, abra o **Pull Request** da Story e **avance a Etapa da Story para ${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}).`);
|
|
406
|
+
lines.push(`3. **Ao concluir TODAS as Tasks da Story:** faça o **commit**, abra o **Pull Request** da Story **como rascunho** (\`gh pr create --draft\` — o CI não roda em rascunho; quem revisa marca o PR como pronto e os checks disparam) e **avance a Etapa da Story para ${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}).`);
|
|
402
407
|
lines.push('4. Só então inicie a próxima Story.');
|
|
403
408
|
lines.push('');
|
|
404
409
|
lines.push(
|
|
@@ -141,18 +141,28 @@ export function renderContent(format, parsed, version) {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// Insere/atualiza o bloco spec-wave num arquivo compartilhado
|
|
145
|
-
// preservando o restante do conteúdo. Idempotente via marcadores.
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
// Insere/atualiza o bloco spec-wave num texto de arquivo compartilhado
|
|
145
|
+
// (AGENTS.md), preservando o restante do conteúdo. Idempotente via marcadores.
|
|
146
|
+
//
|
|
147
|
+
// Versão PURA, separada da que lê o disco porque o modo `--branch` do update
|
|
148
|
+
// precisa mesclar o bloco no conteúdo da BASE, não no do arquivo local: o
|
|
149
|
+
// AGENTS.md do desenvolvedor pode ter edições ainda não commitadas, e arrastá-las
|
|
150
|
+
// para dentro do Pull Request seria enviar o que ninguém pediu para revisar.
|
|
151
|
+
export function mergeAgentsContent(existing, block) {
|
|
152
|
+
const atual = existing || '';
|
|
148
153
|
const blockRe = new RegExp(
|
|
149
154
|
`${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`,
|
|
150
155
|
);
|
|
151
|
-
if (blockRe.test(
|
|
152
|
-
return
|
|
156
|
+
if (blockRe.test(atual)) {
|
|
157
|
+
return atual.replace(blockRe, block);
|
|
153
158
|
}
|
|
154
|
-
if (
|
|
155
|
-
return `${
|
|
159
|
+
if (atual.trim() === '') return block;
|
|
160
|
+
return `${atual.trimEnd()}\n\n${block}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Mesmo merge, lendo o arquivo de destino do disco.
|
|
164
|
+
export function mergeAgentsFile(destPath, block) {
|
|
165
|
+
return mergeAgentsContent(existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '', block);
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
function escapeRe(s) {
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Preflight de uma rodada de geração de specs por milestone.
|
|
2
|
+
//
|
|
3
|
+
// Existe porque **cada geração paga um modelo**: descobrir na sétima Feature que
|
|
4
|
+
// a credencial estava errada custa sete gerações. Este comando levanta de uma
|
|
5
|
+
// vez tudo que decide a estratégia da rodada e tudo que costuma fazê-la falhar
|
|
6
|
+
// no fim — antes de gerar qualquer coisa.
|
|
7
|
+
//
|
|
8
|
+
// Ele NÃO é um segundo `doctor`. As verificações de ambiente reaproveitam os
|
|
9
|
+
// mesmos inspetores puros que o doctor usa (`describeModeState`,
|
|
10
|
+
// `inspectPrPublishing`); o que é novo aqui é o **inventário da milestone**:
|
|
11
|
+
// quais Features existem, e onde está o `spec.md` de cada uma.
|
|
12
|
+
//
|
|
13
|
+
// E "onde está" é a parte que não dá para improvisar com `existsSync`. Um
|
|
14
|
+
// documento recém-gerado vive numa branch `spec-wave/<n>-spec` que ninguém
|
|
15
|
+
// mergeou: quem procura só no disco o declara ausente e manda gerar de novo —
|
|
16
|
+
// pagando o modelo pela segunda vez pelo mesmo documento. Por isso o estado sai
|
|
17
|
+
// de `artifactStates`, que enxerga as quatro camadas.
|
|
18
|
+
|
|
19
|
+
import * as p from '@clack/prompts';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
|
|
22
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
23
|
+
import {
|
|
24
|
+
getRepoDefaultBranch, getRepoVariable, listMilestones, listIssuesByMilestone,
|
|
25
|
+
} from '../api/github-rest.mjs';
|
|
26
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
27
|
+
import { inspectPrPublishing, readPrPublishingContext } from './doctor.mjs';
|
|
28
|
+
import { configuredMode, describeModeState, EXECUTION_VARIABLE } from '../lib/execution-mode.mjs';
|
|
29
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
30
|
+
import { isAwaitingMerge, artifactStates } from '../lib/doc-source.mjs';
|
|
31
|
+
import { featureDocPaths } from '../lib/doc-paths.mjs';
|
|
32
|
+
import { STEPS } from '../lib/next-step.mjs';
|
|
33
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
34
|
+
import { unguardedWorkflows } from './mode.mjs';
|
|
35
|
+
|
|
36
|
+
const TRIGGER_LABELS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* O inventário da milestone (função PURA — é onde mora a decisão).
|
|
40
|
+
*
|
|
41
|
+
* Separa as Features em quatro destinos porque cada um pede uma ação diferente,
|
|
42
|
+
* e confundi-los é caro nos dois sentidos: tratar `pending-pr` como ausente
|
|
43
|
+
* regera um documento já pago e ainda descarta a revisão em curso; tratar
|
|
44
|
+
* `unknown` (falha de rede) como pronto pula uma Feature que ninguém escreveu.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} [a]
|
|
47
|
+
* @param {Array<{number:number,title:string,state?:string,labels?:Array}>} [a.issues]
|
|
48
|
+
* issues da milestone, como a API devolve (de qualquer tipo)
|
|
49
|
+
* @param {Record<number, {state: string, pr: object|null}>} [a.specStates]
|
|
50
|
+
* estado do `spec.md` por número de issue
|
|
51
|
+
* @returns {{ features: object[], gerar: object[], prontas: object[],
|
|
52
|
+
* aguardandoMerge: object[], indefinidas: object[], fechadas: object[],
|
|
53
|
+
* gatilhosPendentes: Array<{number:number,labels:string[]}> }}
|
|
54
|
+
*/
|
|
55
|
+
export function inspectMilestone({ issues = [], specStates = {} } = {}) {
|
|
56
|
+
const features = (issues || [])
|
|
57
|
+
.filter(i => detectIssueType(i) === 'Feature')
|
|
58
|
+
.map(i => {
|
|
59
|
+
const { state = 'unknown', pr = null } = specStates[i.number] || {};
|
|
60
|
+
return {
|
|
61
|
+
number: i.number,
|
|
62
|
+
title: i.title,
|
|
63
|
+
closed: i.state === 'closed',
|
|
64
|
+
spec: state,
|
|
65
|
+
pr,
|
|
66
|
+
labels: (i.labels || []).map(l => (typeof l === 'string' ? l : l?.name)).filter(Boolean),
|
|
67
|
+
};
|
|
68
|
+
})
|
|
69
|
+
.sort((a, b) => a.number - b.number);
|
|
70
|
+
|
|
71
|
+
const abertas = features.filter(f => !f.closed);
|
|
72
|
+
return {
|
|
73
|
+
features,
|
|
74
|
+
fechadas: features.filter(f => f.closed),
|
|
75
|
+
gerar: abertas.filter(f => f.spec === 'missing'),
|
|
76
|
+
prontas: abertas.filter(f => f.spec === 'local' || f.spec === 'remote'),
|
|
77
|
+
aguardandoMerge: abertas.filter(f => isAwaitingMerge(f.spec)),
|
|
78
|
+
indefinidas: abertas.filter(f => f.spec === 'unknown'),
|
|
79
|
+
// Label de gatilho grudada significa Action em execução — ou uma que falhou e
|
|
80
|
+
// a deixou para trás. Nos dois casos o `run` se recusa a executar (portão
|
|
81
|
+
// `trigger-pending`), então a rodada travaria Feature a Feature.
|
|
82
|
+
gatilhosPendentes: features
|
|
83
|
+
.map(f => ({ number: f.number, labels: f.labels.filter(l => TRIGGER_LABELS.includes(l)) }))
|
|
84
|
+
.filter(f => f.labels.length > 0),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve o título da milestone para o número que a API de issues aceita (PURA).
|
|
90
|
+
*
|
|
91
|
+
* Casamento exato primeiro; sem ele, um único casamento sem diferenciar
|
|
92
|
+
* maiúsculas. Ambíguo é erro, não escolha silenciosa: gerar as specs da
|
|
93
|
+
* milestone errada custa uma geração por Feature.
|
|
94
|
+
*
|
|
95
|
+
* @param {Array<{number:number,title:string,state?:string}>} milestones
|
|
96
|
+
* @param {string} titulo
|
|
97
|
+
* @returns {{ milestone: object|null, error: string|null }}
|
|
98
|
+
*/
|
|
99
|
+
export function resolveMilestone(milestones = [], titulo = '') {
|
|
100
|
+
const alvo = String(titulo || '').trim();
|
|
101
|
+
const lista = () => (milestones.length
|
|
102
|
+
? milestones.map(m => `"${m.title}"`).join(', ')
|
|
103
|
+
: '(o repositório não tem milestone nenhuma)');
|
|
104
|
+
|
|
105
|
+
if (!alvo) return { milestone: null, error: `Milestone não informada. Existem: ${lista()}.` };
|
|
106
|
+
|
|
107
|
+
const exata = milestones.find(m => m.title === alvo);
|
|
108
|
+
if (exata) return { milestone: exata, error: null };
|
|
109
|
+
|
|
110
|
+
const caseInsensitive = milestones.filter(m => m.title.toLowerCase() === alvo.toLowerCase());
|
|
111
|
+
if (caseInsensitive.length === 1) return { milestone: caseInsensitive[0], error: null };
|
|
112
|
+
if (caseInsensitive.length > 1) {
|
|
113
|
+
return { milestone: null, error: `Milestone "${alvo}" é ambígua entre ${lista()}.` };
|
|
114
|
+
}
|
|
115
|
+
return { milestone: null, error: `Milestone "${alvo}" não existe. Existem: ${lista()}.` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Veredito final da rodada (função PURA).
|
|
120
|
+
*
|
|
121
|
+
* `bloqueios` impedem gerar; `avisos` não. A distinção é a razão de o comando
|
|
122
|
+
* existir: sair 1 por algo que não impede a geração faria o usuário aprender a
|
|
123
|
+
* ignorar o preflight, que é o mesmo que não o ter.
|
|
124
|
+
*
|
|
125
|
+
* @returns {{ status: 'ok'|'aviso'|'bloqueio', bloqueios: string[], avisos: string[] }}
|
|
126
|
+
*/
|
|
127
|
+
export function inspectPreflight({
|
|
128
|
+
tokenOk = true, modo = null, publicacao = null, inventario = null,
|
|
129
|
+
} = {}) {
|
|
130
|
+
const bloqueios = [];
|
|
131
|
+
const avisos = [];
|
|
132
|
+
|
|
133
|
+
if (!tokenOk) bloqueios.push('Sem token utilizável para este repositório.');
|
|
134
|
+
|
|
135
|
+
if (modo?.status === 'problem') bloqueios.push(modo.summary);
|
|
136
|
+
else if (modo?.status === 'warn') avisos.push(modo.summary);
|
|
137
|
+
|
|
138
|
+
// A publicação por PR é o desfecho de TODA geração: sem ela o documento é
|
|
139
|
+
// gerado, o commit criado e nenhum PR aparece — a falha mais cara do fluxo,
|
|
140
|
+
// porque o modelo já foi pago quando ela acontece.
|
|
141
|
+
if (publicacao?.status === 'fail') bloqueios.push(...publicacao.notes);
|
|
142
|
+
else if (publicacao?.status === 'warn') avisos.push(...publicacao.notes);
|
|
143
|
+
|
|
144
|
+
if (inventario) {
|
|
145
|
+
if (inventario.features.length === 0) {
|
|
146
|
+
bloqueios.push('Nenhuma Feature [FEATURE] nesta milestone.');
|
|
147
|
+
} else if (inventario.gerar.length === 0) {
|
|
148
|
+
avisos.push('Nenhuma Feature pendente: todas já têm spec.md.');
|
|
149
|
+
}
|
|
150
|
+
if (inventario.gatilhosPendentes.length > 0) {
|
|
151
|
+
avisos.push(
|
|
152
|
+
`${inventario.gatilhosPendentes.length} issue(s) com label de gatilho pendente — ` +
|
|
153
|
+
'o `run` se recusa a executar nelas até a label sair.'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (inventario.aguardandoMerge.length > 0) {
|
|
157
|
+
avisos.push(
|
|
158
|
+
`${inventario.aguardandoMerge.length} spec(s) já geradas aguardando merge — ` +
|
|
159
|
+
'NÃO as gere de novo: o documento existe e regerá-lo descarta a revisão.'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (inventario.indefinidas.length > 0) {
|
|
163
|
+
avisos.push(
|
|
164
|
+
`${inventario.indefinidas.length} Feature(s) com estado indeterminado (falha de rede) — ` +
|
|
165
|
+
'confirme antes de gerar, para não pagar duas vezes.'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (bloqueios.length) return { status: 'bloqueio', bloqueios, avisos };
|
|
171
|
+
if (avisos.length) return { status: 'aviso', bloqueios, avisos };
|
|
172
|
+
return { status: 'ok', bloqueios, avisos };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const MARCA = { local: '✓ no disco', remote: '✓ na base', 'pending-pr': '⏳ em PR aberto', 'branch-only': '⚠ em branch sem PR', missing: '· gerar', unknown: '? indeterminado' };
|
|
176
|
+
|
|
177
|
+
export async function preflight({ milestone: milestoneArg, json = false } = {}) {
|
|
178
|
+
const saida = { milestone: null, modo: null, publicacao: null, inventario: null, veredito: null };
|
|
179
|
+
const falhar = (msg) => {
|
|
180
|
+
if (json) console.log(JSON.stringify({ ...saida, erro: msg }, null, 2));
|
|
181
|
+
else p.log.error(msg);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (!json) p.intro(chalk.bold('spec-wave preflight'));
|
|
186
|
+
|
|
187
|
+
const { config, root, error: configError } = loadConfig();
|
|
188
|
+
if (configError || !config?.owner || !config?.repo) {
|
|
189
|
+
return falhar(
|
|
190
|
+
`${configError || `${CONFIG_FILE} sem owner/repo`} — rode \`spec-wave init\` antes.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const { owner, repo } = config;
|
|
194
|
+
|
|
195
|
+
let token;
|
|
196
|
+
try {
|
|
197
|
+
token = await resolveToken();
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return falhar(`Sem token utilizável para ${owner}/${repo}: ${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const s = json ? null : p.spinner();
|
|
203
|
+
s?.start('Consultando o repositório...');
|
|
204
|
+
|
|
205
|
+
let base;
|
|
206
|
+
try {
|
|
207
|
+
base = await getRepoDefaultBranch(token, owner, repo);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
s?.stop('');
|
|
210
|
+
return falhar(`Não foi possível ler o repositório ${owner}/${repo}: ${err.message}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Modo de execução: config × variável (o mesmo par que o doctor confere).
|
|
214
|
+
let variable;
|
|
215
|
+
try {
|
|
216
|
+
variable = await getRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
|
|
217
|
+
} catch {
|
|
218
|
+
variable = undefined; // exige admin — "não deu para ler" não é "ausente"
|
|
219
|
+
}
|
|
220
|
+
const modo = describeModeState({
|
|
221
|
+
configured: configuredMode(config),
|
|
222
|
+
variable,
|
|
223
|
+
unguardedWorkflows: unguardedWorkflows(root),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// --- Publicação por Pull Request: é o desfecho de toda geração.
|
|
227
|
+
const publicacao = inspectPrPublishing(
|
|
228
|
+
await readPrPublishingContext({ token, owner, repo, root })
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// --- Inventário da milestone.
|
|
232
|
+
let milestones;
|
|
233
|
+
try {
|
|
234
|
+
milestones = await listMilestones(token, owner, repo);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
s?.stop('');
|
|
237
|
+
return falhar(`Não foi possível listar as milestones: ${err.message}`);
|
|
238
|
+
}
|
|
239
|
+
const { milestone, error: msError } = resolveMilestone(milestones, milestoneArg);
|
|
240
|
+
if (msError) {
|
|
241
|
+
s?.stop('');
|
|
242
|
+
return falhar(msError);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let issues;
|
|
246
|
+
try {
|
|
247
|
+
issues = await listIssuesByMilestone(token, owner, repo, milestone.number);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
s?.stop('');
|
|
250
|
+
return falhar(`Não foi possível listar as issues da milestone "${milestone.title}": ${err.message}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// O estado do spec.md de cada Feature, nas quatro camadas. Uma issue por vez:
|
|
254
|
+
// são até 3 requisições cada, e disparar tudo de uma vez numa milestone de 20
|
|
255
|
+
// Features é o caminho mais curto para o rate limit secundário.
|
|
256
|
+
const featureIssues = issues.filter(i => detectIssueType(i) === 'Feature');
|
|
257
|
+
const specStates = {};
|
|
258
|
+
for (const issue of featureIssues) {
|
|
259
|
+
const docs = featureDocPaths(root, issue, 'Feature');
|
|
260
|
+
const estados = await artifactStates({
|
|
261
|
+
token, owner, repo, root, base,
|
|
262
|
+
issueNumber: issue.number,
|
|
263
|
+
docs: [{ doc: 'spec', pathRel: docs.spec.rel }],
|
|
264
|
+
});
|
|
265
|
+
specStates[issue.number] = estados.spec;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const inventario = inspectMilestone({ issues, specStates });
|
|
269
|
+
const veredito = inspectPreflight({ tokenOk: true, modo, publicacao, inventario });
|
|
270
|
+
s?.stop(`Milestone "${milestone.title}": ${inventario.features.length} Feature(s).`);
|
|
271
|
+
|
|
272
|
+
Object.assign(saida, {
|
|
273
|
+
milestone: { title: milestone.title, number: milestone.number, base },
|
|
274
|
+
modo: { configured: configuredMode(config), variable: variable ?? null, status: modo.status, summary: modo.summary },
|
|
275
|
+
publicacao: { status: publicacao.status, notes: publicacao.notes },
|
|
276
|
+
inventario,
|
|
277
|
+
veredito,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (json) {
|
|
281
|
+
console.log(JSON.stringify(saida, null, 2));
|
|
282
|
+
if (veredito.status === 'bloqueio') process.exitCode = 1;
|
|
283
|
+
return saida;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------- Relatório ----------
|
|
287
|
+
const linhas = [];
|
|
288
|
+
linhas.push(`${chalk.bold('Repositório:')} ${owner}/${repo} (base: ${base})`);
|
|
289
|
+
linhas.push(`${chalk.bold('Modo:')} config=${configuredMode(config)} · variável=${variable ?? '<ausente>'}`);
|
|
290
|
+
linhas.push(` ${modo.summary}`);
|
|
291
|
+
linhas.push(`${chalk.bold('Publicação por PR:')} ${publicacao.status}`);
|
|
292
|
+
for (const nota of publicacao.notes) linhas.push(` ${nota}`);
|
|
293
|
+
p.note(linhas.join('\n'), 'Ambiente');
|
|
294
|
+
|
|
295
|
+
const inv = [];
|
|
296
|
+
for (const f of inventario.features) {
|
|
297
|
+
const marca = f.closed ? '× fechada' : (MARCA[f.spec] || f.spec);
|
|
298
|
+
const pr = f.pr ? chalk.dim(` PR #${f.pr.number}`) : '';
|
|
299
|
+
inv.push(` ${marca.padEnd(20)} #${f.number} ${f.title.slice(0, 60)}${pr}`);
|
|
300
|
+
}
|
|
301
|
+
if (inventario.gatilhosPendentes.length) {
|
|
302
|
+
inv.push('');
|
|
303
|
+
inv.push(chalk.yellow(' Labels de gatilho pendentes (remova antes de gerar):'));
|
|
304
|
+
for (const g of inventario.gatilhosPendentes) inv.push(` #${g.number}: ${g.labels.join(', ')}`);
|
|
305
|
+
}
|
|
306
|
+
p.note(inv.join('\n'), `Milestone "${milestone.title}" — ${inventario.gerar.length} a gerar`);
|
|
307
|
+
|
|
308
|
+
for (const a of veredito.avisos) p.log.warn(a);
|
|
309
|
+
for (const b of veredito.bloqueios) p.log.error(b);
|
|
310
|
+
|
|
311
|
+
if (veredito.status === 'bloqueio') {
|
|
312
|
+
p.outro(`${veredito.bloqueios.length} bloqueio(s). Resolva antes de gerar.`);
|
|
313
|
+
process.exitCode = 1;
|
|
314
|
+
return saida;
|
|
315
|
+
}
|
|
316
|
+
p.outro(
|
|
317
|
+
inventario.gerar.length
|
|
318
|
+
? `Preflight ok. Confirme a lista com o usuário antes de gerar ${inventario.gerar.length} spec(s).`
|
|
319
|
+
: 'Preflight ok. Nada a gerar.'
|
|
320
|
+
);
|
|
321
|
+
return saida;
|
|
322
|
+
}
|