@spec-wave/cli 0.25.0 → 0.27.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/bin/spec-wave.mjs +4 -374
- package/package.json +1 -1
- package/src/api/github-rest.mjs +25 -0
- package/src/cli.mjs +400 -0
- package/src/commands/decompose.mjs +186 -45
- package/src/commands/doctor.mjs +190 -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 +40 -20
- package/src/commands/mode.mjs +16 -5
- package/src/commands/run.mjs +156 -40
- 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/execution-mode.mjs +22 -0
- package/src/lib/flow-run.mjs +9 -218
- package/src/lib/next-step.mjs +87 -8
- package/src/lib/pr-branch.mjs +10 -0
- package/src/lib/repo-links.mjs +8 -2
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- 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/run/SKILL.md +4 -2
- package/src/plugin/skills/spec/SKILL.md +4 -4
- package/src/plugin/skills/workflow/SKILL.md +2 -2
- package/src/templates/skill/SKILL.md +7 -7
- 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
package/src/commands/run.mjs
CHANGED
|
@@ -20,13 +20,13 @@ 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';
|
|
29
|
-
import { nextStep, resolveForcedStep, STEPS } from '../lib/next-step.mjs';
|
|
29
|
+
import { nextStep, resolveForcedStep, stepDocs, docsForType, STEPS } from '../lib/next-step.mjs';
|
|
30
30
|
import { nextPrStep, reviewVerdict } from '../lib/pr-step.mjs';
|
|
31
31
|
import { configuredMode } from '../lib/execution-mode.mjs';
|
|
32
32
|
import { labelNames } from '../config.mjs';
|
|
@@ -41,8 +41,44 @@ const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
|
41
41
|
// comentário de uso. Fica em .git/ porque já é ignorado e é por clone.
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Diretório .git COMPARTILHADO do clone (função com I/O, isolada para teste).
|
|
46
|
+
*
|
|
47
|
+
* Montar `<root>/.git` à mão assume que `.git` é um diretório — e num git
|
|
48
|
+
* worktree ele é um ARQUIVO com `gitdir: <caminho real>`. O `mkdirSync` do
|
|
49
|
+
* acquireLock estourava ENOTDIR ali, derrubando TODO `spec-wave run` de dentro
|
|
50
|
+
* de um worktree, inclusive `--dry-run`, antes de qualquer trabalho.
|
|
51
|
+
*
|
|
52
|
+
* `--git-common-dir` e não `--git-dir`: num worktree o `--git-dir` é
|
|
53
|
+
* `.git/worktrees/<nome>`, o que tornaria o lock por WORKTREE. Dois `run` na
|
|
54
|
+
* mesma issue em worktrees diferentes rodariam em paralelo commitando na mesma
|
|
55
|
+
* branch — exatamente o que este lock existe para impedir. O comum preserva o
|
|
56
|
+
* "é por clone" que o comentário acima declara.
|
|
57
|
+
*
|
|
58
|
+
* O caminho devolvido é RELATIVO ao cwd quando se está no clone principal
|
|
59
|
+
* (`.git` na raiz, `../../.git` num subdiretório) e absoluto de dentro de um
|
|
60
|
+
* worktree — daí o `path.resolve`. Sem ele, rodar de um subdiretório criaria um
|
|
61
|
+
* `.git/` novo ali dentro, que é um estrago pior e mais silencioso que o ENOTDIR.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} [root] raiz do projeto (a do .spec-wave.json)
|
|
64
|
+
* @returns {string} caminho absoluto do diretório .git compartilhado
|
|
65
|
+
*/
|
|
66
|
+
export function gitCommonDir(root) {
|
|
67
|
+
const cwd = root || process.cwd();
|
|
68
|
+
try {
|
|
69
|
+
const out = execSync('git rev-parse --git-common-dir', {
|
|
70
|
+
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
71
|
+
}).trim();
|
|
72
|
+
if (out) return path.resolve(cwd, out);
|
|
73
|
+
} catch {
|
|
74
|
+
// Fora de um repositório git: cai no palpite antigo. O `run` vai falhar
|
|
75
|
+
// adiante de qualquer forma (ele commita e faz push), e com mensagem melhor.
|
|
76
|
+
}
|
|
77
|
+
return path.join(cwd, '.git');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function lockPath(root, key) {
|
|
81
|
+
return path.join(gitCommonDir(root), 'spec-wave', `run-${key}.lock`);
|
|
46
82
|
}
|
|
47
83
|
|
|
48
84
|
function acquireLock(root, key) {
|
|
@@ -78,19 +114,6 @@ function releaseLock(file) {
|
|
|
78
114
|
} catch { /* já removido */ }
|
|
79
115
|
}
|
|
80
116
|
|
|
81
|
-
// Commits locais ainda não publicados: gerar o próximo documento por cima de um
|
|
82
|
-
// anterior que ficou só no clone é execução parcial disfarçada de sucesso.
|
|
83
|
-
function unpushedCommits(root) {
|
|
84
|
-
try {
|
|
85
|
-
const out = execSync('git rev-list --count @{u}..HEAD', {
|
|
86
|
-
cwd: root || process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
87
|
-
}).trim();
|
|
88
|
-
return Number.parseInt(out, 10) || 0;
|
|
89
|
-
} catch {
|
|
90
|
-
return 0; // sem upstream configurado: não dá para afirmar nada
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
117
|
function assertNotInActions() {
|
|
95
118
|
if (!isActionsRun()) return;
|
|
96
119
|
throw new Error(
|
|
@@ -126,23 +149,69 @@ function localDocStates(issue, type, root) {
|
|
|
126
149
|
* clone. É a diferença entre "ainda não foi gerado" e "foi gerado e você não
|
|
127
150
|
* puxou" — a segunda leva a sobrescrever trabalho publicado.
|
|
128
151
|
*/
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Documentos que vale sondar no remoto antes de acreditar na decisão (PURA).
|
|
154
|
+
*
|
|
155
|
+
* O G5 ("as labels afirmam um documento que não existe aqui") diz, com todas as
|
|
156
|
+
* letras, que o arquivo *não está no clone nem no remoto* — e manda renomear o
|
|
157
|
+
* diretório ou REGERAR. Enquanto a sonda só rodava para decisão não-bloqueada,
|
|
158
|
+
* essa frase era afirmada sem nunca ter perguntado ao remoto: num clone que
|
|
159
|
+
* está apenas atrás, o conselho levava a regerar por cima de um documento
|
|
160
|
+
* publicado, gastando uma chamada de IA para destruir o artefato bom. A
|
|
161
|
+
* correção certa era `git pull`.
|
|
162
|
+
*
|
|
163
|
+
* Nesse caso o passo é `none` e não há `writes`/`reads` de onde tirar a lista —
|
|
164
|
+
* sondamos os documentos do tipo.
|
|
165
|
+
*
|
|
166
|
+
* @param {{action: string, blocked: object|null}} decision
|
|
167
|
+
* @param {string|null} type
|
|
168
|
+
* @returns {string[]}
|
|
169
|
+
*/
|
|
170
|
+
export function docsToProbe(decision, type) {
|
|
171
|
+
if (decision.blocked?.code === 'inconsistent-state') return docsForType(type);
|
|
172
|
+
if (decision.blocked || !STEPS[decision.action]) return [];
|
|
173
|
+
// stepDocs e não `[writes, ...reads]`: `validate` e `decompose` leem
|
|
174
|
+
// documentos diferentes conforme o tipo, e sondar a lista errada devolve um
|
|
175
|
+
// `docs` que o G6 aprova por engano.
|
|
176
|
+
return stepDocs(decision.action, type);
|
|
177
|
+
}
|
|
178
|
+
|
|
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: {} };
|
|
136
192
|
|
|
137
193
|
const atualizado = { ...docs };
|
|
194
|
+
const docPrs = {};
|
|
138
195
|
for (const doc of alvos) {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|
|
144
213
|
}
|
|
145
|
-
return atualizado;
|
|
214
|
+
return { docs: atualizado, docPrs };
|
|
146
215
|
}
|
|
147
216
|
|
|
148
217
|
// ---------------------------------------------------------------------------
|
|
@@ -180,6 +249,32 @@ function parityWarnings(issue, type) {
|
|
|
180
249
|
// Despacho
|
|
181
250
|
// ---------------------------------------------------------------------------
|
|
182
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Saída de `--json` (função PURA — devolve o texto, não imprime).
|
|
254
|
+
*
|
|
255
|
+
* UM documento, sempre. A impressão morava dentro do laço de passos, então
|
|
256
|
+
* `--max-steps N` emitia N documentos JSON concatenados — saída que nenhum
|
|
257
|
+
* parser aceita, numa flag que a skill anuncia justamente para ramificar
|
|
258
|
+
* programaticamente.
|
|
259
|
+
*
|
|
260
|
+
* O desfecho fica no TOPO, e não só dentro de `steps`: é o que quase todo
|
|
261
|
+
* consumidor lê, e é exatamente o formato que a execução de um passo só já
|
|
262
|
+
* produzia. Assim a correção não quebra quem já lia `.action`.
|
|
263
|
+
*
|
|
264
|
+
* @param {object} params
|
|
265
|
+
* @param {string|number} params.issueNumber
|
|
266
|
+
* @param {string|null} params.type
|
|
267
|
+
* @param {object|null} params.ultimaDecisao
|
|
268
|
+
* @param {object[]} [params.passos]
|
|
269
|
+
* @returns {string} JSON indentado
|
|
270
|
+
*/
|
|
271
|
+
export function renderRunJson({ issueNumber, type, ultimaDecisao, passos = [] }) {
|
|
272
|
+
return JSON.stringify(
|
|
273
|
+
{ issue: Number(issueNumber), type: type ?? null, ...ultimaDecisao, steps: passos },
|
|
274
|
+
null, 2,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
183
278
|
async function dispatch(action, { issueNumber }) {
|
|
184
279
|
switch (action) {
|
|
185
280
|
case 'generate-spec': {
|
|
@@ -315,6 +410,9 @@ export async function run(issueArg, options = {}) {
|
|
|
315
410
|
const exitCodeAntes = process.exitCode;
|
|
316
411
|
try {
|
|
317
412
|
let ultimaDecisao = null;
|
|
413
|
+
// `--json` acumula e imprime UMA vez no fim (ver renderRunJson).
|
|
414
|
+
const passos = [];
|
|
415
|
+
let ultimoTipo = null;
|
|
318
416
|
|
|
319
417
|
for (let i = 0; i < teto; i++) {
|
|
320
418
|
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
@@ -342,9 +440,14 @@ export async function run(issueArg, options = {}) {
|
|
|
342
440
|
};
|
|
343
441
|
|
|
344
442
|
let decision = nextStep({ ...entrada, docs });
|
|
345
|
-
if (remoteCheck
|
|
346
|
-
const
|
|
347
|
-
|
|
443
|
+
if (remoteCheck) {
|
|
444
|
+
const alvos = docsToProbe(decision, type).filter(doc => docs[doc] === 'missing');
|
|
445
|
+
if (alvos.length > 0) {
|
|
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 });
|
|
450
|
+
}
|
|
348
451
|
}
|
|
349
452
|
ultimaDecisao = decision;
|
|
350
453
|
|
|
@@ -357,7 +460,8 @@ export async function run(issueArg, options = {}) {
|
|
|
357
460
|
);
|
|
358
461
|
}
|
|
359
462
|
|
|
360
|
-
|
|
463
|
+
ultimoTipo = type;
|
|
464
|
+
if (json) passos.push(decision);
|
|
361
465
|
else reportDecision(decision, { issueNumber, type, title: issue.title, warnings: parityWarnings(issue, type) });
|
|
362
466
|
|
|
363
467
|
if (decision.blocked) { process.exitCode = 2; break; }
|
|
@@ -379,16 +483,28 @@ export async function run(issueArg, options = {}) {
|
|
|
379
483
|
break;
|
|
380
484
|
}
|
|
381
485
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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.'
|
|
387
499
|
));
|
|
388
500
|
break;
|
|
389
501
|
}
|
|
390
502
|
}
|
|
391
503
|
|
|
504
|
+
if (json) {
|
|
505
|
+
console.log(renderRunJson({ issueNumber, type: ultimoTipo, ultimaDecisao, passos }));
|
|
506
|
+
}
|
|
507
|
+
|
|
392
508
|
return ultimaDecisao;
|
|
393
509
|
} finally {
|
|
394
510
|
releaseLock(lock);
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
2
|
import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
3
|
import { slugify } from '../lib/slugify.mjs';
|
|
@@ -14,6 +12,48 @@ import { detectIssueType } from '../lib/issue-type.mjs';
|
|
|
14
12
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
15
13
|
import { executionMode } from '../lib/flow-run.mjs';
|
|
16
14
|
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
15
|
+
import { loadArtifact } from '../lib/doc-source.mjs';
|
|
16
|
+
import { awaitingMergeBlock } from '../lib/artifact-pr.mjs';
|
|
17
|
+
import { isAwaitingMerge } from '../lib/doc-source.mjs';
|
|
18
|
+
import { getRepoDefaultBranch } from '../api/github-rest.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Aborto por documento ainda em Pull Request.
|
|
22
|
+
*
|
|
23
|
+
* Este é o portão mais importante do `validate`, e existe por um motivo
|
|
24
|
+
* assimétrico: a reprova NÃO é inócua. Ela remove `spec-wave:ready` (o gatilho,
|
|
25
|
+
* que `issues: [labeled]` não redispara sozinho) e comenta a falha na issue —
|
|
26
|
+
* ou seja, danifica estado COMPARTILHADO. Fazer isso porque o documento está num
|
|
27
|
+
* PR não mergeado seria reprovar por uma condição que não é do documento.
|
|
28
|
+
*
|
|
29
|
+
* É exatamente o incidente que o comentário do `readsByType` em
|
|
30
|
+
* lib/next-step.mjs já registra ter acontecido antes, com clone desatualizado.
|
|
31
|
+
*
|
|
32
|
+
* Aborta ANTES do removeLabel: a label fica, e reaplicá-la depois do merge
|
|
33
|
+
* simplesmente funciona.
|
|
34
|
+
*/
|
|
35
|
+
async function abortIfPendingPr({ token, owner, repo, n, docs }) {
|
|
36
|
+
const pendentes = docs.filter(d => isAwaitingMerge(d.state));
|
|
37
|
+
if (pendentes.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
// Cada documento carrega o SEU desbloqueio: um está esperando merge, outro
|
|
40
|
+
// esperando alguém abrir o PR. Um texto único ("faça o merge do PR") mandaria
|
|
41
|
+
// metade dos casos procurar uma tela que não existe.
|
|
42
|
+
const blocos = pendentes.map(d => awaitingMergeBlock({
|
|
43
|
+
pathRel: d.pathRel, state: d.state, pr: d.pr, branch: d.ref,
|
|
44
|
+
}));
|
|
45
|
+
const linhas = blocos.map(b => `- ${b.message}\n ${b.unblock}`);
|
|
46
|
+
|
|
47
|
+
await commentOnIssue(token, owner, repo, n,
|
|
48
|
+
'⏸️ **Validação adiada — documento ainda não está na branch base.**\n\n' +
|
|
49
|
+
`${linhas.join('\n')}\n\n` +
|
|
50
|
+
'Resolvido isso, reaplique `spec-wave:ready` — a label foi **mantida** na issue, ' +
|
|
51
|
+
'e nada foi reprovado.'
|
|
52
|
+
).catch(() => {});
|
|
53
|
+
|
|
54
|
+
console.error(`Validação adiada: ${pendentes.map(d => d.pathRel).join(', ')} fora da base.`);
|
|
55
|
+
return { ok: false, errors: linhas, awaitingMerge: true };
|
|
56
|
+
}
|
|
17
57
|
|
|
18
58
|
/**
|
|
19
59
|
* Validação do bug.md (RFC-004 §5).
|
|
@@ -23,11 +63,19 @@ import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
|
23
63
|
* um único arquivo, e a falha NÃO devolve o item para a etapa de spec (Bug não
|
|
24
64
|
* tem etapa de spec). Reaplicar `spec-wave:bug` é o caminho de retomada.
|
|
25
65
|
*/
|
|
26
|
-
async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
66
|
+
async function validateBug({ token, owner, repo, issue, issueNumber, root, base }) {
|
|
27
67
|
const n = parseInt(issueNumber, 10);
|
|
28
|
-
const { fileRel
|
|
68
|
+
const { fileRel } = bugDocPaths(issue.title, root);
|
|
29
69
|
const errors = [];
|
|
30
70
|
|
|
71
|
+
const bug = await loadArtifact({
|
|
72
|
+
token, owner, repo, root, pathRel: fileRel, doc: 'bug', issueNumber: n, base,
|
|
73
|
+
});
|
|
74
|
+
const adiado = await abortIfPendingPr({
|
|
75
|
+
token, owner, repo, n, docs: [{ ...bug, pathRel: fileRel }],
|
|
76
|
+
});
|
|
77
|
+
if (adiado) return adiado;
|
|
78
|
+
|
|
31
79
|
const names = labelNames(issue);
|
|
32
80
|
const critiqueFailed = names.includes(LABEL_CRITIQUE_FAILED);
|
|
33
81
|
const needsHuman = names.includes(LABEL_NEEDS_HUMAN);
|
|
@@ -44,12 +92,12 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
44
92
|
);
|
|
45
93
|
}
|
|
46
94
|
|
|
47
|
-
if (
|
|
95
|
+
if (bug.content == null) {
|
|
48
96
|
errors.push(
|
|
49
97
|
`❌ \`bug.md\` não encontrado em \`${fileRel}\` — aplique \`${LABEL_BUG}\` para gerá-lo.`
|
|
50
98
|
);
|
|
51
99
|
} else {
|
|
52
|
-
const content =
|
|
100
|
+
const content = bug.content;
|
|
53
101
|
for (const faltante of describeMissingSections(content, REQUIRED_BUG_SECTIONS)) {
|
|
54
102
|
errors.push(renderMissingSection('bug.md', faltante));
|
|
55
103
|
}
|
|
@@ -124,19 +172,23 @@ export async function validate({ issueNumber }) {
|
|
|
124
172
|
);
|
|
125
173
|
}
|
|
126
174
|
|
|
127
|
-
const
|
|
175
|
+
const n = parseInt(issueNumber, 10);
|
|
176
|
+
const issue = await getIssue(token, owner, repo, n);
|
|
177
|
+
|
|
178
|
+
// Branch base para a leitura em camadas. Best-effort: sem ela o resolvedor cai
|
|
179
|
+
// no default da API, que é a mesma branch.
|
|
180
|
+
const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
|
|
128
181
|
|
|
129
182
|
// Bug tem artefato próprio (bug.md) e caminho próprio de validação — não passa
|
|
130
183
|
// pelo par spec.md + plan.md, que é exclusivo de Feature.
|
|
131
184
|
if (detectIssueType(issue) === 'Bug') {
|
|
132
|
-
return await validateBug({ token, owner, repo, issue, issueNumber, root });
|
|
185
|
+
return await validateBug({ token, owner, repo, issue, issueNumber, root, base });
|
|
133
186
|
}
|
|
134
187
|
|
|
135
188
|
const slug = slugify(issue.title);
|
|
136
189
|
// Ancorado na RAIZ do repo, não no cwd: rodar de um subdiretório encontra o
|
|
137
190
|
// config subindo na árvore e precisa encontrar os documentos no mesmo lugar.
|
|
138
191
|
const featureRel = `docs/features/${slug}`;
|
|
139
|
-
const featureDir = path.resolve(root || process.cwd(), featureRel);
|
|
140
192
|
|
|
141
193
|
const errors = [];
|
|
142
194
|
|
|
@@ -158,12 +210,28 @@ export async function validate({ issueNumber }) {
|
|
|
158
210
|
);
|
|
159
211
|
}
|
|
160
212
|
|
|
213
|
+
// Os dois documentos vêm do resolvedor em camadas (disco → base → PR aberto):
|
|
214
|
+
// ler só o disco reprovaria uma Feature cujo plan.md está publicado, o que
|
|
215
|
+
// custa a label de gatilho e um comentário de falha na issue.
|
|
216
|
+
const planRel = `${featureRel}/plan.md`;
|
|
217
|
+
const specRel = `${featureRel}/spec.md`;
|
|
218
|
+
const ler = (doc, pathRel) => loadArtifact({
|
|
219
|
+
token, owner, repo, root, pathRel, doc, issueNumber: n, base,
|
|
220
|
+
});
|
|
221
|
+
const plan = await ler('plan', planRel);
|
|
222
|
+
const spec = await ler('spec', specRel);
|
|
223
|
+
|
|
224
|
+
const adiado = await abortIfPendingPr({
|
|
225
|
+
token, owner, repo, n,
|
|
226
|
+
docs: [{ ...plan, pathRel: planRel }, { ...spec, pathRel: specRel }],
|
|
227
|
+
});
|
|
228
|
+
if (adiado) return adiado;
|
|
229
|
+
|
|
161
230
|
// Check plan.md
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
|
|
231
|
+
if (plan.content == null) {
|
|
232
|
+
errors.push('❌ `plan.md` não encontrado em `' + planRel + '`');
|
|
165
233
|
} else {
|
|
166
|
-
const planContent =
|
|
234
|
+
const planContent = plan.content;
|
|
167
235
|
for (const faltante of describeMissingSections(planContent, REQUIRED_PLAN_SECTIONS)) {
|
|
168
236
|
errors.push(renderMissingSection('plan.md', faltante));
|
|
169
237
|
}
|
|
@@ -173,11 +241,10 @@ export async function validate({ issueNumber }) {
|
|
|
173
241
|
}
|
|
174
242
|
|
|
175
243
|
// Check spec.md
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
|
|
244
|
+
if (spec.content == null) {
|
|
245
|
+
errors.push('❌ `spec.md` não encontrado em `' + specRel + '`');
|
|
179
246
|
} else {
|
|
180
|
-
const specContent =
|
|
247
|
+
const specContent = spec.content;
|
|
181
248
|
for (const faltante of describeMissingSections(specContent, REQUIRED_SPEC_SECTIONS)) {
|
|
182
249
|
errors.push(renderMissingSection('spec.md', faltante));
|
|
183
250
|
}
|
package/src/config.mjs
CHANGED
|
@@ -519,6 +519,24 @@ export const WORKFLOW_FILES = [
|
|
|
519
519
|
'qa.yml',
|
|
520
520
|
];
|
|
521
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Workflows que PUBLICAM um documento gerado (branch própria + Pull Request).
|
|
524
|
+
*
|
|
525
|
+
* Lista explícita de propósito. O teste que verifica permissões selecionava os
|
|
526
|
+
* workflows por `content.includes('contents: write')` — um predicado que passa
|
|
527
|
+
* VACUAMENTE se o marcador sumir do YAML: nenhum arquivo é selecionado, o laço
|
|
528
|
+
* não roda, e o teste fica verde justamente no cenário que ele deveria pegar.
|
|
529
|
+
*
|
|
530
|
+
* Estes quatro precisam de `contents: write` (o commit vai por Git Data API) E
|
|
531
|
+
* de `pull-requests: write` (sem ela o commit existe e o PR não).
|
|
532
|
+
*/
|
|
533
|
+
export const ARTIFACT_WORKFLOW_FILES = [
|
|
534
|
+
'generate-spec.yml',
|
|
535
|
+
'generate-plan.yml',
|
|
536
|
+
'generate-bug.yml',
|
|
537
|
+
'decompose.yml',
|
|
538
|
+
];
|
|
539
|
+
|
|
522
540
|
export const ISSUE_TEMPLATE_FILES = [
|
|
523
541
|
'plan-template.md',
|
|
524
542
|
'spec-template.md',
|