@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
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 });
|
|
@@ -311,6 +338,31 @@ export async function deleteLabel(token, owner, repo, name) {
|
|
|
311
338
|
}
|
|
312
339
|
}
|
|
313
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Apaga a ref de uma branch.
|
|
343
|
+
*
|
|
344
|
+
* Existe para UM caso, e só ele: a branch de artefato que sobrou de um PR
|
|
345
|
+
* mergeado com squash. Nesse merge a ponta da branch não é ancestral da base, e
|
|
346
|
+
* empilhar o próximo commit nela produziria um PR que reintroduz estado antigo.
|
|
347
|
+
* Sem PR aberto e sem commits à frente da base, a branch não guarda nada — pode
|
|
348
|
+
* ser recriada a partir da base.
|
|
349
|
+
*
|
|
350
|
+
* NUNCA use isto para resolver conflito: apagar uma branch com PR aberto
|
|
351
|
+
* descartaria revisão humana.
|
|
352
|
+
*
|
|
353
|
+
* @returns {Promise<boolean>} false quando a branch já não existia
|
|
354
|
+
*/
|
|
355
|
+
export async function deleteBranch(token, owner, repo, branch) {
|
|
356
|
+
const octokit = makeQuietOctokit(token); // 404 = já não existe, não é erro
|
|
357
|
+
try {
|
|
358
|
+
await octokit.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` });
|
|
359
|
+
return true;
|
|
360
|
+
} catch (err) {
|
|
361
|
+
if (err.status === 404 || err.status === 422) return false;
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
314
366
|
export async function deleteFile(token, owner, repo, filePath, message) {
|
|
315
367
|
const octokit = makeOctokit(token);
|
|
316
368
|
let sha;
|
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) => {
|
|
@@ -17,11 +17,10 @@
|
|
|
17
17
|
// edições humanas. Para regerar do zero, apague o arquivo.
|
|
18
18
|
|
|
19
19
|
import { execSync } from 'node:child_process';
|
|
20
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
21
|
-
import path from 'node:path';
|
|
22
20
|
import { resolveToken } from '../api/auth.mjs';
|
|
23
21
|
import {
|
|
24
22
|
getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, listIssueComments,
|
|
23
|
+
getRepoDefaultBranch,
|
|
25
24
|
} from '../api/github-rest.mjs';
|
|
26
25
|
import { addSubIssue, listSubIssues, getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
27
26
|
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
@@ -37,7 +36,11 @@ import { resolveDocDir } from '../lib/doc-paths.mjs';
|
|
|
37
36
|
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
38
37
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
39
38
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
40
|
-
import { resolveFlowContext
|
|
39
|
+
import { resolveFlowContext } from '../lib/flow-run.mjs';
|
|
40
|
+
import { publishArtifact } from '../lib/artifact-publish.mjs';
|
|
41
|
+
import { loadArtifact } from '../lib/doc-source.mjs';
|
|
42
|
+
import { awaitingMergeBlock } from '../lib/artifact-pr.mjs';
|
|
43
|
+
import { isAwaitingMerge } from '../lib/doc-source.mjs';
|
|
41
44
|
import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
|
|
42
45
|
import {
|
|
43
46
|
renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
|
|
@@ -246,11 +249,26 @@ function formatItemsLintWarning(texts) {
|
|
|
246
249
|
return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Publica o documento em branch própria + Pull Request — ver lib/artifact-publish.mjs.
|
|
254
|
+
*
|
|
255
|
+
* ASSÍNCRONA, e todo chamador precisa de `await`. O `apply` chama isto dentro de
|
|
256
|
+
* um `try/catch` cuja única função é degradar a falha em aviso (as issues já
|
|
257
|
+
* existem; derrubar o run aqui mandaria o humano reaplicar o gatilho e duplicar
|
|
258
|
+
* dezenas de itens). Sem o `await`, a promise rejeitada não é capturada por esse
|
|
259
|
+
* catch: o aviso some e vira unhandled rejection.
|
|
260
|
+
*/
|
|
261
|
+
async function publishFile(ctx, { doc, pathRel, content, nextLabel = null }) {
|
|
262
|
+
const { token, owner, repo, issue, issueNumber, base } = ctx;
|
|
263
|
+
const published = await publishArtifact({
|
|
264
|
+
token, owner, repo, doc,
|
|
265
|
+
issueNumber: parseInt(issueNumber, 10),
|
|
266
|
+
issueTitle: issue?.title || '',
|
|
267
|
+
issueUrl: issue?.html_url || '',
|
|
268
|
+
pathRel, content, base, nextLabel,
|
|
269
|
+
});
|
|
253
270
|
if (published.warning) console.warn(`⚠️ ${published.warning}`);
|
|
271
|
+
return published;
|
|
254
272
|
}
|
|
255
273
|
|
|
256
274
|
// Os prompts vivem em `src/plugin/skills/decompose/model-prompt.{feature,rfc}.md`
|
|
@@ -263,23 +281,67 @@ function commitFile(filePath, content, message, mode) {
|
|
|
263
281
|
// ---------------------------------------------------------------------------
|
|
264
282
|
|
|
265
283
|
async function draftDecomposition(ctx) {
|
|
266
|
-
const {
|
|
284
|
+
const {
|
|
285
|
+
token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode,
|
|
286
|
+
docRel, dirRel, base,
|
|
287
|
+
} = ctx;
|
|
267
288
|
const number = parseInt(issueNumber, 10);
|
|
268
289
|
const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
|
|
269
|
-
const blobUrl = docBlobUrl({ owner, repo, pathRel: docRel, mode: runMode, root });
|
|
270
290
|
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
291
|
+
const ler = (doc, pathRel) => loadArtifact({
|
|
292
|
+
token, owner, repo, root, pathRel, doc, issueNumber: number, base,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// spec e plan alimentam o rascunho de Feature. Um deles preso num PR não
|
|
296
|
+
// mergeado com leitura ingênua viraria "(spec.md não encontrado)" no payload:
|
|
297
|
+
// rascunho gerado sobre o vazio, sem erro nenhum. Recusar é mais barato.
|
|
298
|
+
const spec = type === 'RFC' ? null : await ler('spec', `${dirRel}/spec.md`);
|
|
299
|
+
const plan = type === 'RFC' ? null : await ler('plan', `${dirRel}/plan.md`);
|
|
300
|
+
for (const [nome, doc] of [['spec.md', spec], ['plan.md', plan]]) {
|
|
301
|
+
if (doc && isAwaitingMerge(doc.state)) {
|
|
302
|
+
const bloqueio = awaitingMergeBlock({
|
|
303
|
+
pathRel: `${dirRel}/${nome}`, state: doc.state, pr: doc.pr, branch: doc.ref,
|
|
304
|
+
});
|
|
305
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE).catch(() => {});
|
|
306
|
+
await commentOnIssue(token, owner, repo, number,
|
|
307
|
+
`⏸️ **decompose parado:** ${bloqueio.message}\n\n${bloqueio.unblock}\n\n` +
|
|
308
|
+
`Reaplique \`${LABEL_DECOMPOSE}\` depois do merge.`
|
|
309
|
+
).catch(() => {});
|
|
310
|
+
throw new DecomposeBlockedError(bloqueio.message);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const specContent = spec?.content ?? null;
|
|
314
|
+
const planContent = plan?.content ?? null;
|
|
275
315
|
|
|
276
316
|
// Rascunho existente é preservado COMO ESTÁ: o humano corrige o arquivo e
|
|
277
317
|
// re-aplica a label para uma nova crítica. Regenerar aqui apagaria a correção
|
|
278
318
|
// — é justamente o que fazia o ciclo não convergir.
|
|
319
|
+
//
|
|
320
|
+
// Com a publicação por Pull Request, "existente" deixou de ser `existsSync`: o
|
|
321
|
+
// rascunho que o revisor está editando mora na branch do PR. Ler só o disco
|
|
322
|
+
// aqui INVERTERIA a invariante — regeneraria por cima da revisão, e ainda
|
|
323
|
+
// pagaria a IA de novo.
|
|
324
|
+
const rascunho = await ler('decomposition', docRel);
|
|
325
|
+
|
|
279
326
|
let markdown;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
327
|
+
let publicado = null;
|
|
328
|
+
if (rascunho.content != null) {
|
|
329
|
+
const onde = rascunho.state === 'pending-pr'
|
|
330
|
+
? `no PR #${rascunho.pr?.number} (ainda não mergeado)`
|
|
331
|
+
: rascunho.state === 'branch-only'
|
|
332
|
+
? `na branch ${rascunho.ref} (sem PR aberto)`
|
|
333
|
+
: docRel;
|
|
334
|
+
console.log(`Rascunho encontrado ${onde} — criticando o arquivo como está (sem regerar).`);
|
|
335
|
+
markdown = rascunho.content;
|
|
336
|
+
// O PR que já existe é o mesmo lugar onde o revisor vai corrigir: o
|
|
337
|
+
// comentário de fecho precisa apontá-lo, senão a re-crítica sai sem link e a
|
|
338
|
+
// pessoa fica procurando onde editar.
|
|
339
|
+
if (isAwaitingMerge(rascunho.state)) {
|
|
340
|
+
// `branch-only` entra aqui de propósito: republicar reaproveita a branch e
|
|
341
|
+
// TENTA abrir o PR de novo — é o caminho de recuperação quando a abertura
|
|
342
|
+
// falhou antes. O conteúdo é o do revisor, não um regerado.
|
|
343
|
+
publicado = { pr: rascunho.pr, branch: rascunho.ref, unchanged: true };
|
|
344
|
+
}
|
|
283
345
|
} else {
|
|
284
346
|
console.log(`Gerando rascunho de decomposição para ${type}: ${issue.title}`);
|
|
285
347
|
const userContent = type === 'RFC'
|
|
@@ -312,10 +374,22 @@ async function draftDecomposition(ctx) {
|
|
|
312
374
|
stories: generated.stories || [],
|
|
313
375
|
tasks: generated.tasks || [],
|
|
314
376
|
});
|
|
315
|
-
|
|
316
|
-
|
|
377
|
+
publicado = await publishFile(ctx, {
|
|
378
|
+
doc: 'decomposition', pathRel: docRel, content: markdown,
|
|
379
|
+
nextLabel: LABEL_DECOMPOSE_APPLY,
|
|
380
|
+
});
|
|
381
|
+
console.log(`Rascunho publicado em ${publicado.branch} (${docRel}).`);
|
|
317
382
|
}
|
|
318
383
|
|
|
384
|
+
// O link só pode ser montado DEPOIS de saber onde o documento está: na branch
|
|
385
|
+
// do PR recém-aberto, na do PR que já existia, ou na base. Inferir a ref pelo
|
|
386
|
+
// ambiente (o que o docBlobUrl faz por padrão) daria a branch default num
|
|
387
|
+
// evento `issues: labeled` — e lá o arquivo ainda não está.
|
|
388
|
+
const refDoDocumento = publicado?.branch || rascunho.ref || undefined;
|
|
389
|
+
const blobUrl = docBlobUrl({
|
|
390
|
+
owner, repo, pathRel: docRel, mode: runMode, root, ref: refDoDocumento,
|
|
391
|
+
});
|
|
392
|
+
|
|
319
393
|
// Valida a estrutura ANTES de gastar tokens com a crítica: um arquivo quebrado
|
|
320
394
|
// por edição humana precisa de mensagem clara, não de uma crítica sobre nada.
|
|
321
395
|
let doc;
|
|
@@ -338,7 +412,7 @@ async function draftDecomposition(ctx) {
|
|
|
338
412
|
|
|
339
413
|
// O RFC não tem spec/plan para auditar contra — a crítica não teria referência.
|
|
340
414
|
if (kind !== 'stories') {
|
|
341
|
-
await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false });
|
|
415
|
+
return await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false, publicado });
|
|
342
416
|
return;
|
|
343
417
|
}
|
|
344
418
|
|
|
@@ -422,26 +496,41 @@ async function draftDecomposition(ctx) {
|
|
|
422
496
|
// Crítica limpa: remove o bloqueio anterior, que também zera o contador de
|
|
423
497
|
// tentativas na próxima rodada (ver resolveCritiqueAttempt).
|
|
424
498
|
await removeLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED).catch(() => {});
|
|
425
|
-
await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true });
|
|
499
|
+
return await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true, publicado });
|
|
426
500
|
}
|
|
427
501
|
|
|
428
502
|
// Fecho comum do rascunho aprovado: libera para revisão humana e o apply.
|
|
429
|
-
async function finishDraft(
|
|
503
|
+
async function finishDraft(
|
|
504
|
+
{ token, owner, repo, issueNumber, docRel }, { blobUrl, itemCount, critiqued, publicado }
|
|
505
|
+
) {
|
|
430
506
|
const number = parseInt(issueNumber, 10);
|
|
431
507
|
await addLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY);
|
|
432
508
|
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
|
|
509
|
+
|
|
510
|
+
// O apply LÊ o rascunho da branch base, então ele só roda depois do merge —
|
|
511
|
+
// dizer "crie as issues agora" sem essa ressalva manda o humano num passo que
|
|
512
|
+
// vai ser recusado.
|
|
513
|
+
const pr = publicado?.pr;
|
|
514
|
+
const revisao = pr?.number
|
|
515
|
+
? `🔀 Pull Request: #${pr.number} — ${pr.url}\n\n` +
|
|
516
|
+
'**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo **no PR**, ' +
|
|
517
|
+
'faça o merge e então crie as issues:\n'
|
|
518
|
+
: '**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo e então crie as issues:\n';
|
|
519
|
+
|
|
433
520
|
await commentOnIssue(token, owner, repo, number,
|
|
434
521
|
`📝 **Rascunho de decomposição pronto para revisão** (${itemCount}).\n\n` +
|
|
435
522
|
`📄 Arquivo: [\`${docRel}\`](${blobUrl})\n\n` +
|
|
436
523
|
(critiqued
|
|
437
524
|
? 'A crítica adversarial não encontrou contradições graves. '
|
|
438
525
|
: '') +
|
|
439
|
-
|
|
526
|
+
revisao +
|
|
440
527
|
`\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE_APPLY}"\n\`\`\`\n` +
|
|
441
528
|
`Se preferir uma nova crítica depois de editar, reaplique \`${LABEL_DECOMPOSE}\` — ` +
|
|
442
|
-
'o arquivo é criticado como está, sem ser regerado. Para gerar outro do zero,
|
|
529
|
+
'o arquivo é criticado como está, sem ser regerado. Para gerar outro do zero, ' +
|
|
530
|
+
'feche o PR e apague a branch.'
|
|
443
531
|
).catch(err => console.warn(`Falha ao comentar o rascunho: ${err.message}`));
|
|
444
532
|
console.log(`Rascunho liberado para revisão: ${itemCount}.`);
|
|
533
|
+
return { pr: pr || null };
|
|
445
534
|
}
|
|
446
535
|
|
|
447
536
|
// ---------------------------------------------------------------------------
|
|
@@ -449,14 +538,38 @@ async function finishDraft({ token, owner, repo, issueNumber, docRel }, { blobUr
|
|
|
449
538
|
// ---------------------------------------------------------------------------
|
|
450
539
|
|
|
451
540
|
async function applyDecomposition(ctx) {
|
|
452
|
-
const { token, owner, repo, issueNumber,
|
|
541
|
+
const { token, owner, repo, issueNumber, docRel, root, base } = ctx;
|
|
453
542
|
const number = parseInt(issueNumber, 10);
|
|
454
543
|
|
|
455
|
-
|
|
544
|
+
const rascunho = await loadArtifact({
|
|
545
|
+
token, owner, repo, root, pathRel: docRel,
|
|
546
|
+
doc: 'decomposition', issueNumber: number, base,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Rascunho ainda em Pull Request é recusa DURA, não aviso: aplicar criaria
|
|
550
|
+
// dezenas de issues a partir de um documento que ninguém aprovou, e o próprio
|
|
551
|
+
// arquivo passaria a afirmar `applied=<data>` enquanto o revisor ainda decide.
|
|
552
|
+
// O merge do PR É a aprovação humana.
|
|
553
|
+
// Vale para os DOIS estados fora da base. Cobrir só `pending-pr` deixaria o
|
|
554
|
+
// apply criar dezenas de issues a partir de um rascunho que está numa branch
|
|
555
|
+
// sem PR — ou seja, que ninguém teve como revisar.
|
|
556
|
+
if (isAwaitingMerge(rascunho.state)) {
|
|
557
|
+
const bloqueio = awaitingMergeBlock({
|
|
558
|
+
pathRel: docRel, state: rascunho.state, pr: rascunho.pr, branch: rascunho.ref,
|
|
559
|
+
});
|
|
560
|
+
await commentOnIssue(token, owner, repo, number,
|
|
561
|
+
`⏸️ **decompose-apply parado:** ${bloqueio.message}\n\n${bloqueio.unblock}\n\n` +
|
|
562
|
+
`Nenhuma issue foi criada. Reaplique \`${LABEL_DECOMPOSE_APPLY}\` depois do merge.`
|
|
563
|
+
).catch(() => {});
|
|
564
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
|
|
565
|
+
throw new DecomposeBlockedError(bloqueio.message);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (rascunho.content == null) {
|
|
456
569
|
await commentOnIssue(token, owner, repo, number,
|
|
457
570
|
`❌ **Não há rascunho de decomposição para aplicar.**\n\n` +
|
|
458
571
|
`Esperava encontrar \`${docRel}\`. Gere o rascunho primeiro:\n` +
|
|
459
|
-
`\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n
|
|
572
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n\`\`\`\n`
|
|
460
573
|
).catch(() => {});
|
|
461
574
|
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
|
|
462
575
|
throw new DecomposeBlockedError(`${docRel} não encontrado — aplique ${LABEL_DECOMPOSE} primeiro.`);
|
|
@@ -464,7 +577,7 @@ async function applyDecomposition(ctx) {
|
|
|
464
577
|
|
|
465
578
|
let doc;
|
|
466
579
|
try {
|
|
467
|
-
doc = parseDecompositionDoc(
|
|
580
|
+
doc = parseDecompositionDoc(rascunho.content);
|
|
468
581
|
} catch (err) {
|
|
469
582
|
await commentOnIssue(token, owner, repo, number,
|
|
470
583
|
`❌ **Não consegui ler o rascunho da decomposição.**\n\n` +
|
|
@@ -508,14 +621,19 @@ async function applyDecomposition(ctx) {
|
|
|
508
621
|
// Depois da criação de propósito: se este commit falhar, as issues já existem
|
|
509
622
|
// e o pior caso é o comportamento anterior (arquivo sem anotação), avisado no
|
|
510
623
|
// log. O contrário — anotar antes e falhar na criação — inventaria issues.
|
|
624
|
+
//
|
|
625
|
+
// Vai para uma branch PRÓPRIA (`spec-wave/<n>-decompose-apply`), nunca por cima
|
|
626
|
+
// da branch do rascunho: aquela já foi mergeada (é pré-condição do apply), e
|
|
627
|
+
// reabri-la produziria um PR reintroduzindo estado antigo.
|
|
511
628
|
try {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
629
|
+
const anotado = await publishFile(ctx, {
|
|
630
|
+
doc: 'decomposition-apply',
|
|
631
|
+
pathRel: docRel,
|
|
632
|
+
content: renderDecompositionDoc({ ...doc, appliedAt: new Date().toISOString() }),
|
|
633
|
+
});
|
|
634
|
+
console.log(anotado.pr?.number
|
|
635
|
+
? `${docRel} anotado com as issues criadas — PR #${anotado.pr.number}.`
|
|
636
|
+
: `${docRel} anotado com as issues criadas em ${anotado.branch}.`);
|
|
519
637
|
} catch (err) {
|
|
520
638
|
console.warn(
|
|
521
639
|
`⚠️ Issues criadas, mas ${docRel} não foi anotado (${err.message}). ` +
|
|
@@ -992,8 +1110,12 @@ export async function decompose({ issueNumber, apply }) {
|
|
|
992
1110
|
|
|
993
1111
|
// Config do repo: raiz para os caminhos de documento e escalada da crítica.
|
|
994
1112
|
const { config, root } = loadConfig();
|
|
995
|
-
const { rel: docRel
|
|
996
|
-
|
|
1113
|
+
const { rel: docRel } = resolveDocDir(root, issue, type);
|
|
1114
|
+
|
|
1115
|
+
// Branch base: é dela que os documentos são lidos quando não estão no clone, e
|
|
1116
|
+
// é contra ela que o PR do rascunho é aberto. Best-effort — sem ela a leitura
|
|
1117
|
+
// cai para o default da API, que é o mesmo lugar.
|
|
1118
|
+
const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
|
|
997
1119
|
|
|
998
1120
|
// Comentários só são necessários no rascunho (contador de tentativas).
|
|
999
1121
|
let comments = [];
|
|
@@ -1010,7 +1132,9 @@ export async function decompose({ issueNumber, apply }) {
|
|
|
1010
1132
|
const usageEntries = [];
|
|
1011
1133
|
const ctx = {
|
|
1012
1134
|
token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
|
|
1013
|
-
root, runMode,
|
|
1135
|
+
root, runMode, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
|
|
1136
|
+
dirRel: docRel,
|
|
1137
|
+
base,
|
|
1014
1138
|
// Números das issues já criadas por este run. Compartilhado por referência
|
|
1015
1139
|
// com o applyCtx: o catch externo precisa saber se houve criação para não
|
|
1016
1140
|
// aconselhar um retry que duplicaria itens.
|
|
@@ -1024,8 +1148,11 @@ export async function decompose({ issueNumber, apply }) {
|
|
|
1024
1148
|
};
|
|
1025
1149
|
|
|
1026
1150
|
try {
|
|
1027
|
-
|
|
1028
|
-
|
|
1151
|
+
// O desfecho volta ao chamador (o `run` usa o PR para parar a cadeia: o passo
|
|
1152
|
+
// seguinte lê o documento da base, então depende do merge).
|
|
1153
|
+
return mode === 'apply'
|
|
1154
|
+
? await applyDecomposition(ctx)
|
|
1155
|
+
: await draftDecomposition(ctx);
|
|
1029
1156
|
} catch (err) {
|
|
1030
1157
|
// Paradas por decisão do fluxo já comentaram na issue; erros inesperados não.
|
|
1031
1158
|
if (!err.blocked) {
|