@spec-wave/cli 0.26.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 +52 -0
- package/src/cli.mjs +12 -0
- package/src/commands/decompose.mjs +166 -39
- package/src/commands/doctor.mjs +214 -3
- package/src/commands/generate-bug.mjs +22 -16
- package/src/commands/generate-plan.mjs +72 -23
- package/src/commands/generate-spec.mjs +19 -15
- package/src/commands/implement.mjs +47 -22
- package/src/commands/install-skill.mjs +18 -8
- package/src/commands/preflight.mjs +322 -0
- package/src/commands/run.mjs +51 -30
- package/src/commands/update.mjs +143 -12
- package/src/commands/validate.mjs +84 -17
- package/src/config.mjs +18 -0
- package/src/lib/artifact-pr.mjs +272 -0
- package/src/lib/artifact-publish.mjs +169 -0
- package/src/lib/doc-availability.mjs +23 -1
- package/src/lib/doc-source.mjs +162 -0
- package/src/lib/flow-run.mjs +9 -218
- package/src/lib/next-step.mjs +27 -4
- package/src/lib/pr-branch.mjs +106 -7
- package/src/lib/repo-links.mjs +8 -2
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/README.md +5 -0
- package/src/plugin/skills/bug/SKILL.md +2 -2
- package/src/plugin/skills/decompose/SKILL.md +4 -4
- package/src/plugin/skills/plan/SKILL.md +1 -1
- 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/run/SKILL.md +3 -1
- package/src/plugin/skills/spec/SKILL.md +4 -4
- package/src/plugin/skills/update/SKILL.md +10 -4
- package/src/plugin/skills/workflow/SKILL.md +8 -3
- package/src/templates/skill/SKILL.md +13 -10
- package/src/templates/workflows/code-review.yml +13 -2
- package/src/templates/workflows/critique.yml +1 -1
- package/src/templates/workflows/decompose.yml +13 -2
- package/src/templates/workflows/generate-bug.yml +17 -6
- package/src/templates/workflows/generate-plan.yml +20 -7
- package/src/templates/workflows/generate-spec.yml +20 -7
- package/src/templates/workflows/qa.yml +13 -0
|
@@ -13,6 +13,7 @@ import { listSubIssues, getIssueParent, addProjectItem, getItemSingleSelectValue
|
|
|
13
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
14
14
|
import { bugDocPaths } from '../lib/bug-doc.mjs';
|
|
15
15
|
import { missingDocMessage, existsOnRemote } from '../lib/doc-availability.mjs';
|
|
16
|
+
import { loadArtifact } from '../lib/doc-source.mjs';
|
|
16
17
|
import { buildBugContext } from '../lib/bug-context.mjs';
|
|
17
18
|
import { slugify } from '../lib/slugify.mjs';
|
|
18
19
|
import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
|
|
@@ -45,6 +46,32 @@ async function resolveFeature(token, startNodeId) {
|
|
|
45
46
|
return null;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Onde está o documento que não veio no clone — incluindo Pull Request aberto.
|
|
51
|
+
*
|
|
52
|
+
* `existsOnRemote` sozinho consulta só a branch base, então um documento
|
|
53
|
+
* recém-gerado (que vive na branch do PR até alguém mergear) era reportado como
|
|
54
|
+
* "não existe no repositório". Mentira cara: manda o executor refazer do zero um
|
|
55
|
+
* trabalho que está pronto, esperando revisão.
|
|
56
|
+
*
|
|
57
|
+
* Best-effort como a sonda que substitui: nunca lança, nunca bloqueia.
|
|
58
|
+
*/
|
|
59
|
+
async function probeMissingDoc({ token, owner, repo, root, pathRel, doc, issueNumber, fallback }) {
|
|
60
|
+
if (issueNumber) {
|
|
61
|
+
const achado = await loadArtifact({ token, owner, repo, root, pathRel, doc, issueNumber })
|
|
62
|
+
.catch(() => null);
|
|
63
|
+
if (achado?.state === 'pending-pr' || achado?.state === 'branch-only') {
|
|
64
|
+
return missingDocMessage({
|
|
65
|
+
pathRel, onRemote: false, fallback, pr: achado.pr, branch: achado.ref,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (achado?.state === 'remote') return missingDocMessage({ pathRel, onRemote: true, fallback });
|
|
69
|
+
if (achado?.state === 'missing') return missingDocMessage({ pathRel, onRemote: false, fallback });
|
|
70
|
+
}
|
|
71
|
+
const onRemote = await existsOnRemote({ getFileContent, token, owner, repo, pathRel });
|
|
72
|
+
return missingDocMessage({ pathRel, onRemote, fallback });
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
// Lê spec.md/plan.md de um docs/features/<slug> se existirem.
|
|
49
76
|
function readSpecPlan(featureDir) {
|
|
50
77
|
const specPath = path.join(featureDir, 'spec.md');
|
|
@@ -188,7 +215,12 @@ function buildContext({
|
|
|
188
215
|
lines.push('');
|
|
189
216
|
lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks na Etapa ${STAGE_DONE}):`);
|
|
190
217
|
lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
|
|
191
|
-
|
|
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.`);
|
|
192
224
|
lines.push(
|
|
193
225
|
` 3. **Avance a Etapa da Story #${issue.number} para ${STAGE_CODE_REVIEW}** ` +
|
|
194
226
|
`(reinicie o Status para ${PROGRESS_TODO}). As Tasks já estão em ${STAGE_DONE}.`
|
|
@@ -371,7 +403,7 @@ export function buildFeatureContext({
|
|
|
371
403
|
lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
|
|
372
404
|
lines.push(' 2. **Implemente** a Task por completo.');
|
|
373
405
|
lines.push(` 3. **Ao concluir:** **avance a Task para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
|
|
374
|
-
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}).`);
|
|
375
407
|
lines.push('4. Só então inicie a próxima Story.');
|
|
376
408
|
lines.push('');
|
|
377
409
|
lines.push(
|
|
@@ -463,12 +495,9 @@ async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot
|
|
|
463
495
|
bugDoc = readFileSync(fileAbs, 'utf-8');
|
|
464
496
|
p.log.info(`bug.md encontrado em ${chalk.cyan(fileRel)}.`);
|
|
465
497
|
} else {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
p.log.warn(missingDocMessage({
|
|
470
|
-
pathRel: fileRel,
|
|
471
|
-
onRemote,
|
|
498
|
+
p.log.warn(await probeMissingDoc({
|
|
499
|
+
token, owner, repo, root: repoRoot,
|
|
500
|
+
pathRel: fileRel, doc: 'bug', issueNumber: bug.number,
|
|
472
501
|
fallback: 'o contexto assume a investigação inteira.',
|
|
473
502
|
}));
|
|
474
503
|
}
|
|
@@ -626,12 +655,9 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
626
655
|
specPlan = readSpecPlan(featureDir);
|
|
627
656
|
} else {
|
|
628
657
|
const specRel = `docs/features/${slugify(feature.title)}/spec.md`;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
p.log.warn(missingDocMessage({
|
|
633
|
-
pathRel: specRel,
|
|
634
|
-
onRemote,
|
|
658
|
+
p.log.warn(await probeMissingDoc({
|
|
659
|
+
token, owner, repo, root: repoRoot,
|
|
660
|
+
pathRel: specRel, doc: 'spec', issueNumber: feature.number,
|
|
635
661
|
fallback: 'seguindo só com as Stories.',
|
|
636
662
|
}));
|
|
637
663
|
}
|
|
@@ -839,14 +865,13 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
839
865
|
const specRel = feature?.title
|
|
840
866
|
? `docs/features/${slugify(feature.title)}/spec.md`
|
|
841
867
|
: featureDir;
|
|
842
|
-
|
|
843
|
-
? await
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
fallback: 'seguindo só com as tasks.'
|
|
849
|
-
}));
|
|
868
|
+
p.log.warn(feature?.title
|
|
869
|
+
? await probeMissingDoc({
|
|
870
|
+
token, owner, repo, root: repoRoot,
|
|
871
|
+
pathRel: specRel, doc: 'spec', issueNumber: feature?.number,
|
|
872
|
+
fallback: 'seguindo só com as tasks.',
|
|
873
|
+
})
|
|
874
|
+
: missingDocMessage({ pathRel: specRel, onRemote: null, fallback: 'seguindo só com as tasks.' }));
|
|
850
875
|
} else {
|
|
851
876
|
p.log.warn('Não foi possível resolver a Feature; seguindo só com as tasks (use --feature-dir).');
|
|
852
877
|
}
|
|
@@ -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
|
+
}
|
package/src/commands/run.mjs
CHANGED
|
@@ -20,9 +20,9 @@ import path from 'node:path';
|
|
|
20
20
|
import chalk from 'chalk';
|
|
21
21
|
|
|
22
22
|
import { resolveToken } from '../api/auth.mjs';
|
|
23
|
-
import { getIssue, getPR,
|
|
23
|
+
import { getIssue, getPR, listPullRequestReviews } from '../api/github-rest.mjs';
|
|
24
24
|
import { loadProjectConfig } from '../lib/board.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { loadArtifact } from '../lib/doc-source.mjs';
|
|
26
26
|
import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
|
|
27
27
|
import { isActionsRun, resolveFlowContext } from '../lib/flow-run.mjs';
|
|
28
28
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
@@ -114,19 +114,6 @@ function releaseLock(file) {
|
|
|
114
114
|
} catch { /* já removido */ }
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
// Commits locais ainda não publicados: gerar o próximo documento por cima de um
|
|
118
|
-
// anterior que ficou só no clone é execução parcial disfarçada de sucesso.
|
|
119
|
-
function unpushedCommits(root) {
|
|
120
|
-
try {
|
|
121
|
-
const out = execSync('git rev-list --count @{u}..HEAD', {
|
|
122
|
-
cwd: root || process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
123
|
-
}).trim();
|
|
124
|
-
return Number.parseInt(out, 10) || 0;
|
|
125
|
-
} catch {
|
|
126
|
-
return 0; // sem upstream configurado: não dá para afirmar nada
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
117
|
function assertNotInActions() {
|
|
131
118
|
if (!isActionsRun()) return;
|
|
132
119
|
throw new Error(
|
|
@@ -189,18 +176,42 @@ export function docsToProbe(decision, type) {
|
|
|
189
176
|
return stepDocs(decision.action, type);
|
|
190
177
|
}
|
|
191
178
|
|
|
192
|
-
|
|
193
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Resolve o estado remoto dos documentos que faltam no clone.
|
|
181
|
+
*
|
|
182
|
+
* Duas camadas, não uma: além da branch base, o documento pode estar num Pull
|
|
183
|
+
* Request ainda não mergeado — é onde ele NASCE agora. Sem essa segunda camada,
|
|
184
|
+
* o `run` concluiria "não existe" e regeraria a spec a cada execução até alguém
|
|
185
|
+
* mergear, pagando a IA de novo toda vez e descartando as edições do revisor.
|
|
186
|
+
*
|
|
187
|
+
* `doc` do resolvedor usa 'decomposition' com o mesmo nome do fluxo, então o
|
|
188
|
+
* mapeamento é direto.
|
|
189
|
+
*/
|
|
190
|
+
async function probeRemote({ alvos, docs, docPaths, token, owner, repo, root, issueNumber }) {
|
|
191
|
+
if (alvos.length === 0) return { docs, docPrs: {} };
|
|
194
192
|
|
|
195
193
|
const atualizado = { ...docs };
|
|
194
|
+
const docPrs = {};
|
|
196
195
|
for (const doc of alvos) {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
const achado = await loadArtifact({
|
|
197
|
+
token, owner, repo, root, pathRel: docPaths[doc], doc, issueNumber,
|
|
198
|
+
}).catch(() => null);
|
|
199
|
+
|
|
200
|
+
if (!achado) {
|
|
201
|
+
atualizado[doc] = 'unknown';
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// 'local' aqui só aconteceria se o arquivo tivesse aparecido no meio da
|
|
205
|
+
// execução; manter o estado que a sonda local já apurou é mais previsível.
|
|
206
|
+
if (achado.state === 'local') continue;
|
|
207
|
+
atualizado[doc] = achado.state;
|
|
208
|
+
// Guarda PR e branch: o bloqueio de `branch-only` cita a branch, porque não
|
|
209
|
+
// há PR nenhum a citar.
|
|
210
|
+
if (achado.state === 'pending-pr' || achado.state === 'branch-only') {
|
|
211
|
+
docPrs[doc] = { pr: achado.pr || null, branch: achado.ref || null };
|
|
212
|
+
}
|
|
202
213
|
}
|
|
203
|
-
return atualizado;
|
|
214
|
+
return { docs: atualizado, docPrs };
|
|
204
215
|
}
|
|
205
216
|
|
|
206
217
|
// ---------------------------------------------------------------------------
|
|
@@ -432,8 +443,10 @@ export async function run(issueArg, options = {}) {
|
|
|
432
443
|
if (remoteCheck) {
|
|
433
444
|
const alvos = docsToProbe(decision, type).filter(doc => docs[doc] === 'missing');
|
|
434
445
|
if (alvos.length > 0) {
|
|
435
|
-
const
|
|
436
|
-
|
|
446
|
+
const sondado = await probeRemote({
|
|
447
|
+
alvos, docs, docPaths, token, owner, repo, root, issueNumber,
|
|
448
|
+
});
|
|
449
|
+
decision = nextStep({ ...entrada, docs: sondado.docs, docPrs: sondado.docPrs });
|
|
437
450
|
}
|
|
438
451
|
}
|
|
439
452
|
ultimaDecisao = decision;
|
|
@@ -470,11 +483,19 @@ export async function run(issueArg, options = {}) {
|
|
|
470
483
|
break;
|
|
471
484
|
}
|
|
472
485
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
486
|
+
// O passo publicou um documento em Pull Request? Então o próximo depende do
|
|
487
|
+
// merge, e encadear agora só produziria um bloqueio `pr-pending` uma volta
|
|
488
|
+
// depois — com uma mensagem menos direta e uma consulta à API a mais.
|
|
489
|
+
//
|
|
490
|
+
// Substitui o antigo guard de commits não publicados, que ficou
|
|
491
|
+
// permanentemente em zero: a publicação deixou de passar por git local.
|
|
492
|
+
const publicado = resultado?.pr || resultado?.published?.pr;
|
|
493
|
+
if (publicado?.number) {
|
|
494
|
+
console.log(chalk.yellow(
|
|
495
|
+
`\n⏸️ O documento foi publicado no PR #${publicado.number}` +
|
|
496
|
+
`${publicado.url ? ` (${publicado.url})` : ''}.\n` +
|
|
497
|
+
' O próximo passo do fluxo lê esse documento da branch base — ' +
|
|
498
|
+
'revise e faça o merge para continuar.'
|
|
478
499
|
));
|
|
479
500
|
break;
|
|
480
501
|
}
|