@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
|
@@ -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,
|
|
@@ -157,16 +160,27 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
|
|
|
157
160
|
/**
|
|
158
161
|
* Modo do run (função PURA — testável).
|
|
159
162
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
163
|
+
* TRI-ESTADO, e a distinção é o que impede um estrago: `apply` pode ser `true`
|
|
164
|
+
* (aplique), `false` (NÃO aplique) ou `undefined` (ninguém decidiu — use a
|
|
165
|
+
* label). Só o `undefined` cai no fallback por label, que é o caminho do
|
|
166
|
+
* workflow (onde a label que disparou é a fonte de verdade) e do retry de
|
|
167
|
+
* runner.
|
|
168
|
+
*
|
|
169
|
+
* Antes, o default `apply = false` colapsava "não aplique" em "ninguém
|
|
170
|
+
* decidiu": o `run` despachava o rascunho com `apply: false` e a label
|
|
171
|
+
* `spec-wave:decompose-apply` esquecida na issue por uma Action que falhou
|
|
172
|
+
* promovia a execução a aplicação. O comando anunciava "gera/re-critica o
|
|
173
|
+
* rascunho" e CRIAVA Stories e Tasks — sem passar pelo portão de confirmação
|
|
174
|
+
* do `run`, que existe exatamente para isso.
|
|
162
175
|
*
|
|
163
176
|
* @param {object} params
|
|
164
177
|
* @param {Array<string|{name:string}>} [params.labels] labels da issue
|
|
165
|
-
* @param {boolean} [params.apply]
|
|
178
|
+
* @param {boolean} [params.apply] `true`/`false` decidem; `undefined` consulta a label
|
|
166
179
|
* @returns {'draft'|'apply'}
|
|
167
180
|
*/
|
|
168
|
-
export function resolveDecomposeMode({ labels = [], apply
|
|
169
|
-
if (apply) return 'apply';
|
|
181
|
+
export function resolveDecomposeMode({ labels = [], apply } = {}) {
|
|
182
|
+
if (apply === true) return 'apply';
|
|
183
|
+
if (apply === false) return 'draft';
|
|
170
184
|
return labelNames(labels).includes(LABEL_DECOMPOSE_APPLY) ? 'apply' : 'draft';
|
|
171
185
|
}
|
|
172
186
|
|
|
@@ -235,11 +249,26 @@ function formatItemsLintWarning(texts) {
|
|
|
235
249
|
return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
|
|
236
250
|
}
|
|
237
251
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
});
|
|
242
270
|
if (published.warning) console.warn(`⚠️ ${published.warning}`);
|
|
271
|
+
return published;
|
|
243
272
|
}
|
|
244
273
|
|
|
245
274
|
// Os prompts vivem em `src/plugin/skills/decompose/model-prompt.{feature,rfc}.md`
|
|
@@ -252,23 +281,67 @@ function commitFile(filePath, content, message, mode) {
|
|
|
252
281
|
// ---------------------------------------------------------------------------
|
|
253
282
|
|
|
254
283
|
async function draftDecomposition(ctx) {
|
|
255
|
-
const {
|
|
284
|
+
const {
|
|
285
|
+
token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode,
|
|
286
|
+
docRel, dirRel, base,
|
|
287
|
+
} = ctx;
|
|
256
288
|
const number = parseInt(issueNumber, 10);
|
|
257
289
|
const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
|
|
258
|
-
const blobUrl = docBlobUrl({ owner, repo, pathRel: docRel, mode: runMode, root });
|
|
259
290
|
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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;
|
|
264
315
|
|
|
265
316
|
// Rascunho existente é preservado COMO ESTÁ: o humano corrige o arquivo e
|
|
266
317
|
// re-aplica a label para uma nova crítica. Regenerar aqui apagaria a correção
|
|
267
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
|
+
|
|
268
326
|
let markdown;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|
|
272
345
|
} else {
|
|
273
346
|
console.log(`Gerando rascunho de decomposição para ${type}: ${issue.title}`);
|
|
274
347
|
const userContent = type === 'RFC'
|
|
@@ -301,10 +374,22 @@ async function draftDecomposition(ctx) {
|
|
|
301
374
|
stories: generated.stories || [],
|
|
302
375
|
tasks: generated.tasks || [],
|
|
303
376
|
});
|
|
304
|
-
|
|
305
|
-
|
|
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}).`);
|
|
306
382
|
}
|
|
307
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
|
+
|
|
308
393
|
// Valida a estrutura ANTES de gastar tokens com a crítica: um arquivo quebrado
|
|
309
394
|
// por edição humana precisa de mensagem clara, não de uma crítica sobre nada.
|
|
310
395
|
let doc;
|
|
@@ -327,7 +412,7 @@ async function draftDecomposition(ctx) {
|
|
|
327
412
|
|
|
328
413
|
// O RFC não tem spec/plan para auditar contra — a crítica não teria referência.
|
|
329
414
|
if (kind !== 'stories') {
|
|
330
|
-
await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false });
|
|
415
|
+
return await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false, publicado });
|
|
331
416
|
return;
|
|
332
417
|
}
|
|
333
418
|
|
|
@@ -411,26 +496,41 @@ async function draftDecomposition(ctx) {
|
|
|
411
496
|
// Crítica limpa: remove o bloqueio anterior, que também zera o contador de
|
|
412
497
|
// tentativas na próxima rodada (ver resolveCritiqueAttempt).
|
|
413
498
|
await removeLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED).catch(() => {});
|
|
414
|
-
await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true });
|
|
499
|
+
return await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true, publicado });
|
|
415
500
|
}
|
|
416
501
|
|
|
417
502
|
// Fecho comum do rascunho aprovado: libera para revisão humana e o apply.
|
|
418
|
-
async function finishDraft(
|
|
503
|
+
async function finishDraft(
|
|
504
|
+
{ token, owner, repo, issueNumber, docRel }, { blobUrl, itemCount, critiqued, publicado }
|
|
505
|
+
) {
|
|
419
506
|
const number = parseInt(issueNumber, 10);
|
|
420
507
|
await addLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY);
|
|
421
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
|
+
|
|
422
520
|
await commentOnIssue(token, owner, repo, number,
|
|
423
521
|
`📝 **Rascunho de decomposição pronto para revisão** (${itemCount}).\n\n` +
|
|
424
522
|
`📄 Arquivo: [\`${docRel}\`](${blobUrl})\n\n` +
|
|
425
523
|
(critiqued
|
|
426
524
|
? 'A crítica adversarial não encontrou contradições graves. '
|
|
427
525
|
: '') +
|
|
428
|
-
|
|
526
|
+
revisao +
|
|
429
527
|
`\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE_APPLY}"\n\`\`\`\n` +
|
|
430
528
|
`Se preferir uma nova crítica depois de editar, reaplique \`${LABEL_DECOMPOSE}\` — ` +
|
|
431
|
-
'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.'
|
|
432
531
|
).catch(err => console.warn(`Falha ao comentar o rascunho: ${err.message}`));
|
|
433
532
|
console.log(`Rascunho liberado para revisão: ${itemCount}.`);
|
|
533
|
+
return { pr: pr || null };
|
|
434
534
|
}
|
|
435
535
|
|
|
436
536
|
// ---------------------------------------------------------------------------
|
|
@@ -438,14 +538,38 @@ async function finishDraft({ token, owner, repo, issueNumber, docRel }, { blobUr
|
|
|
438
538
|
// ---------------------------------------------------------------------------
|
|
439
539
|
|
|
440
540
|
async function applyDecomposition(ctx) {
|
|
441
|
-
const { token, owner, repo, issueNumber,
|
|
541
|
+
const { token, owner, repo, issueNumber, docRel, root, base } = ctx;
|
|
442
542
|
const number = parseInt(issueNumber, 10);
|
|
443
543
|
|
|
444
|
-
|
|
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) {
|
|
445
569
|
await commentOnIssue(token, owner, repo, number,
|
|
446
570
|
`❌ **Não há rascunho de decomposição para aplicar.**\n\n` +
|
|
447
571
|
`Esperava encontrar \`${docRel}\`. Gere o rascunho primeiro:\n` +
|
|
448
|
-
`\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n
|
|
572
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n\`\`\`\n`
|
|
449
573
|
).catch(() => {});
|
|
450
574
|
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
|
|
451
575
|
throw new DecomposeBlockedError(`${docRel} não encontrado — aplique ${LABEL_DECOMPOSE} primeiro.`);
|
|
@@ -453,7 +577,7 @@ async function applyDecomposition(ctx) {
|
|
|
453
577
|
|
|
454
578
|
let doc;
|
|
455
579
|
try {
|
|
456
|
-
doc = parseDecompositionDoc(
|
|
580
|
+
doc = parseDecompositionDoc(rascunho.content);
|
|
457
581
|
} catch (err) {
|
|
458
582
|
await commentOnIssue(token, owner, repo, number,
|
|
459
583
|
`❌ **Não consegui ler o rascunho da decomposição.**\n\n` +
|
|
@@ -497,14 +621,19 @@ async function applyDecomposition(ctx) {
|
|
|
497
621
|
// Depois da criação de propósito: se este commit falhar, as issues já existem
|
|
498
622
|
// e o pior caso é o comportamento anterior (arquivo sem anotação), avisado no
|
|
499
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.
|
|
500
628
|
try {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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}.`);
|
|
508
637
|
} catch (err) {
|
|
509
638
|
console.warn(
|
|
510
639
|
`⚠️ Issues criadas, mas ${docRel} não foi anotado (${err.message}). ` +
|
|
@@ -917,7 +1046,10 @@ export function renderApplyFailureComment({ trigger, message, createdItems = [],
|
|
|
917
1046
|
'Complete o que falta à mão, ou apague as issues acima antes de tentar de novo.';
|
|
918
1047
|
}
|
|
919
1048
|
|
|
920
|
-
|
|
1049
|
+
// `apply` SEM default: `undefined` significa "ninguém decidiu" e é o que faz o
|
|
1050
|
+
// fallback por label valer no workflow. Um default `false` aqui reintroduziria
|
|
1051
|
+
// o bug, porque a CLI passa `undefined` quando a flag não é usada.
|
|
1052
|
+
export async function decompose({ issueNumber, apply }) {
|
|
921
1053
|
const token = await resolveToken();
|
|
922
1054
|
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
923
1055
|
// Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
|
|
@@ -978,8 +1110,12 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
978
1110
|
|
|
979
1111
|
// Config do repo: raiz para os caminhos de documento e escalada da crítica.
|
|
980
1112
|
const { config, root } = loadConfig();
|
|
981
|
-
const { rel: docRel
|
|
982
|
-
|
|
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);
|
|
983
1119
|
|
|
984
1120
|
// Comentários só são necessários no rascunho (contador de tentativas).
|
|
985
1121
|
let comments = [];
|
|
@@ -996,7 +1132,9 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
996
1132
|
const usageEntries = [];
|
|
997
1133
|
const ctx = {
|
|
998
1134
|
token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
|
|
999
|
-
root, runMode,
|
|
1135
|
+
root, runMode, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
|
|
1136
|
+
dirRel: docRel,
|
|
1137
|
+
base,
|
|
1000
1138
|
// Números das issues já criadas por este run. Compartilhado por referência
|
|
1001
1139
|
// com o applyCtx: o catch externo precisa saber se houve criação para não
|
|
1002
1140
|
// aconselhar um retry que duplicaria itens.
|
|
@@ -1010,8 +1148,11 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
1010
1148
|
};
|
|
1011
1149
|
|
|
1012
1150
|
try {
|
|
1013
|
-
|
|
1014
|
-
|
|
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);
|
|
1015
1156
|
} catch (err) {
|
|
1016
1157
|
// Paradas por decisão do fluxo já comentaram na issue; erros inesperados não.
|
|
1017
1158
|
if (!err.blocked) {
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import * as p from '@clack/prompts';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import { Octokit } from '@octokit/rest';
|
|
11
|
+
import yaml from 'js-yaml';
|
|
11
12
|
import {
|
|
12
13
|
resolveToken, verifyTokenScopes, describeTokenSource, activeGhAccount,
|
|
13
14
|
tokenMismatchWarning, parseActiveAccount,
|
|
@@ -15,7 +16,8 @@ import {
|
|
|
15
16
|
import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
|
|
16
17
|
import { getRepoVariable } from '../api/github-rest.mjs';
|
|
17
18
|
import {
|
|
18
|
-
CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER,
|
|
19
|
+
CONFIG_FILE, WORKFLOW_FILES, ARTIFACT_WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER,
|
|
20
|
+
AI_PROVIDERS, STATUS_OPTIONS,
|
|
19
21
|
RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
20
22
|
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
|
|
21
23
|
modelLabels,
|
|
@@ -903,10 +905,21 @@ async function checkDecompositions(ctx) {
|
|
|
903
905
|
);
|
|
904
906
|
}
|
|
905
907
|
|
|
908
|
+
// Esta checagem lê o DISCO de propósito: ela audita registro consolidado, e um
|
|
909
|
+
// rascunho ainda em Pull Request não é registro. A consequência a declarar é
|
|
910
|
+
// que rascunhos não mergeados ficam FORA da conferência de deriva — não é
|
|
911
|
+
// omissão, é o recorte; mas quem lê o relatório precisa saber.
|
|
912
|
+
const nota = 'Rascunhos ainda em Pull Request não entram nesta conferência — ' +
|
|
913
|
+
'só o que está na branch base é registro.';
|
|
914
|
+
|
|
906
915
|
if (aplicados === 0) {
|
|
907
|
-
return {
|
|
916
|
+
return {
|
|
917
|
+
name,
|
|
918
|
+
status: 'ok',
|
|
919
|
+
detail: `${arquivos.length} rascunho(s) ainda não aplicado(s) — nada a conferir. ${nota}`,
|
|
920
|
+
};
|
|
908
921
|
}
|
|
909
|
-
return { name, status, detail: notes.join('\n') };
|
|
922
|
+
return { name, status, detail: [...notes, nota].join('\n') };
|
|
910
923
|
}
|
|
911
924
|
|
|
912
925
|
export function checkSpecKit(ctx) {
|
|
@@ -967,6 +980,179 @@ async function checkExecutionMode(ctx) {
|
|
|
967
980
|
return { name, status, detail: [estado.summary, ...estado.notes, ...estado.fixes].join('\n') };
|
|
968
981
|
}
|
|
969
982
|
|
|
983
|
+
/**
|
|
984
|
+
* Veredito sobre a capacidade de publicar documentos por Pull Request (função PURA).
|
|
985
|
+
*
|
|
986
|
+
* Três coisas precisam ser verdade, e as três falham de formas diferentes:
|
|
987
|
+
*
|
|
988
|
+
* • **Permissões no YAML.** Sem `pull-requests: write`, o commit é criado e o PR
|
|
989
|
+
* não — o documento fica numa branch que ninguém vê. Sem `contents: write`, a
|
|
990
|
+
* Git Data API responde 403 e nada é publicado.
|
|
991
|
+
* • **O Actions pode abrir PR.** "Allow GitHub Actions to create and approve
|
|
992
|
+
* pull requests" vem DESLIGADO em muitas organizações, e é a falha mais
|
|
993
|
+
* provável deste fluxo. Warn e não fail: a consulta exige `administration:
|
|
994
|
+
* read`, que o GITHUB_TOKEN não tem, então "não consegui ver" é comum e não
|
|
995
|
+
* pode virar vermelho.
|
|
996
|
+
* • **Status checks obrigatórios na branch default.** Este é FAIL, e o motivo é
|
|
997
|
+
* contraintuitivo: PR aberto pelo GITHUB_TOKEN não dispara workflow nenhum,
|
|
998
|
+
* logo o check obrigatório nunca sai de "expected" e NENHUM PR de documento
|
|
999
|
+
* fica mergeável. O fluxo trava no primeiro passo, sem erro visível em lugar
|
|
1000
|
+
* nenhum — a issue simplesmente para.
|
|
1001
|
+
*
|
|
1002
|
+
* @param {object} params
|
|
1003
|
+
* @param {boolean|null} [params.canCreatePr] null = não foi possível consultar
|
|
1004
|
+
* @param {Record<string, {contents?: string, pullRequests?: string}>} [params.workflowPerms]
|
|
1005
|
+
* @param {string[]|null} [params.requiredChecks] null = não foi possível consultar
|
|
1006
|
+
* @param {boolean|null} [params.prTokenPresent] o secret alternativo existe no repo
|
|
1007
|
+
* @param {string} [params.prTokenSecret] nome do secret alternativo
|
|
1008
|
+
* @returns {{status: 'ok'|'warn'|'fail', notes: string[]}}
|
|
1009
|
+
*/
|
|
1010
|
+
export function inspectPrPublishing({
|
|
1011
|
+
canCreatePr = null, workflowPerms = {}, requiredChecks = null,
|
|
1012
|
+
prTokenPresent = null, prTokenSecret = 'GH_PR_TOKEN',
|
|
1013
|
+
} = {}) {
|
|
1014
|
+
const notes = [];
|
|
1015
|
+
let status = 'ok';
|
|
1016
|
+
const piora = (novo) => {
|
|
1017
|
+
if (novo === 'fail' || status === 'fail') status = 'fail';
|
|
1018
|
+
else if (novo === 'warn') status = 'warn';
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
const semPr = Object.entries(workflowPerms)
|
|
1022
|
+
.filter(([, perm]) => perm?.pullRequests !== 'write').map(([f]) => f);
|
|
1023
|
+
const semContents = Object.entries(workflowPerms)
|
|
1024
|
+
.filter(([, perm]) => perm?.contents !== 'write').map(([f]) => f);
|
|
1025
|
+
|
|
1026
|
+
if (semPr.length > 0) {
|
|
1027
|
+
piora('fail');
|
|
1028
|
+
notes.push(
|
|
1029
|
+
`Sem \`pull-requests: write\`: ${semPr.join(', ')}. O commit é criado e o Pull ` +
|
|
1030
|
+
'Request não — o documento fica numa branch que ninguém vê. Rode `update`.'
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
if (semContents.length > 0) {
|
|
1034
|
+
piora('fail');
|
|
1035
|
+
notes.push(
|
|
1036
|
+
`Sem \`contents: write\`: ${semContents.join(', ')}. O commit vai por Git Data API, ` +
|
|
1037
|
+
'que é autorizada por essa permissão. Rode `update`.'
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (canCreatePr === false && !prTokenPresent) {
|
|
1042
|
+
piora('warn');
|
|
1043
|
+
notes.push(
|
|
1044
|
+
'O GitHub Actions está proibido de criar Pull Requests neste repositório. ' +
|
|
1045
|
+
'Ligue Settings → Actions → General → "Allow GitHub Actions to create and approve ' +
|
|
1046
|
+
`pull requests", ou defina o secret \`${prTokenSecret}\` com um PAT.`
|
|
1047
|
+
);
|
|
1048
|
+
} else if (canCreatePr === false) {
|
|
1049
|
+
// Mitigado: o PR é aberto com o PAT, não com o GITHUB_TOKEN. Continuar
|
|
1050
|
+
// pedindo a configuração que a pessoa ACABOU de fazer é o jeito mais rápido
|
|
1051
|
+
// de ensinar que o doctor pode ser ignorado.
|
|
1052
|
+
notes.push(
|
|
1053
|
+
`O Actions não pode abrir PR com o GITHUB_TOKEN, mas \`${prTokenSecret}\` está ` +
|
|
1054
|
+
'definido e é ele que será usado — só o NOME é verificável daqui, não o valor ' +
|
|
1055
|
+
'nem as permissões.'
|
|
1056
|
+
);
|
|
1057
|
+
} else if (canCreatePr === null) {
|
|
1058
|
+
notes.push(
|
|
1059
|
+
'Não foi possível verificar se o Actions pode criar Pull Requests (a consulta ' +
|
|
1060
|
+
'exige `administration: read`). Se a publicação falhar com 403, é aqui.'
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (requiredChecks?.length && !prTokenPresent) {
|
|
1065
|
+
piora('fail');
|
|
1066
|
+
notes.push(
|
|
1067
|
+
`A branch default exige status check(s) obrigatório(s): ${requiredChecks.join(', ')}. ` +
|
|
1068
|
+
'Pull Request aberto pelo GITHUB_TOKEN NÃO dispara workflow, então esses checks ' +
|
|
1069
|
+
'nunca ficam verdes e nenhum PR de documento se torna mergeável — o fluxo trava no ' +
|
|
1070
|
+
`primeiro passo. Use um PAT no secret \`${prTokenSecret}\` (PR aberto por PAT dispara ` +
|
|
1071
|
+
'os workflows), ou dispense os checks para as branches `spec-wave/*`.'
|
|
1072
|
+
);
|
|
1073
|
+
} else if (requiredChecks?.length) {
|
|
1074
|
+
notes.push(
|
|
1075
|
+
`A branch default exige ${requiredChecks.join(', ')}; como o PR é aberto por ` +
|
|
1076
|
+
`\`${prTokenSecret}\`, os workflows disparam e os checks podem ficar verdes.`
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (status === 'ok' && notes.length === 0) {
|
|
1081
|
+
notes.push('Os workflows podem publicar documentos por Pull Request.');
|
|
1082
|
+
}
|
|
1083
|
+
return { status, notes };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function checkPrPublishing(ctx) {
|
|
1087
|
+
const name = 'Publicação por Pull Request';
|
|
1088
|
+
const dir = path.join(ctx.root || ctx.cwd, '.github', 'workflows');
|
|
1089
|
+
|
|
1090
|
+
const workflowPerms = {};
|
|
1091
|
+
for (const file of ARTIFACT_WORKFLOW_FILES) {
|
|
1092
|
+
const caminho = path.join(dir, file);
|
|
1093
|
+
if (!existsSync(caminho)) continue; // ausência já é reportada por checkWorkflows
|
|
1094
|
+
let wf;
|
|
1095
|
+
try {
|
|
1096
|
+
wf = yaml.load(readFileSync(caminho, 'utf-8'));
|
|
1097
|
+
} catch {
|
|
1098
|
+
continue; // YAML ilegível é problema de outro check
|
|
1099
|
+
}
|
|
1100
|
+
// O job mais restritivo manda: basta um sem a permissão para o fluxo quebrar.
|
|
1101
|
+
for (const job of Object.values(wf?.jobs || {})) {
|
|
1102
|
+
const atual = workflowPerms[file];
|
|
1103
|
+
const perm = {
|
|
1104
|
+
contents: job?.permissions?.contents,
|
|
1105
|
+
pullRequests: job?.permissions?.['pull-requests'],
|
|
1106
|
+
};
|
|
1107
|
+
if (!atual || perm.pullRequests !== 'write' || perm.contents !== 'write') {
|
|
1108
|
+
workflowPerms[file] = perm;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (Object.keys(workflowPerms).length === 0) {
|
|
1114
|
+
return { name, status: 'warn', detail: 'Workflows não encontrados — rode `init` ou `update`.' };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const cfg = ctx.cfg || {};
|
|
1118
|
+
const owner = cfg.owner;
|
|
1119
|
+
const repo = cfg.repo;
|
|
1120
|
+
|
|
1121
|
+
let canCreatePr = null;
|
|
1122
|
+
let requiredChecks = null;
|
|
1123
|
+
let prTokenPresent = null;
|
|
1124
|
+
if (ctx.token && owner && repo) {
|
|
1125
|
+
const octokit = makeOctokit(ctx.token);
|
|
1126
|
+
try {
|
|
1127
|
+
const res = await octokit.request('GET /repos/{owner}/{repo}/actions/secrets',
|
|
1128
|
+
{ owner, repo });
|
|
1129
|
+
prTokenPresent = (res.data.secrets || []).some(sec => sec.name === 'GH_PR_TOKEN');
|
|
1130
|
+
} catch {
|
|
1131
|
+
prTokenPresent = null; // sem permissão de ler secrets — não afirmar ausência
|
|
1132
|
+
}
|
|
1133
|
+
try {
|
|
1134
|
+
const res = await octokit.request('GET /repos/{owner}/{repo}/actions/permissions/workflow',
|
|
1135
|
+
{ owner, repo });
|
|
1136
|
+
canCreatePr = res.data?.can_approve_pull_request_reviews ?? null;
|
|
1137
|
+
} catch {
|
|
1138
|
+
canCreatePr = null; // exige administration:read — ausência é comum, não é erro
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
const info = await octokit.rest.repos.get({ owner, repo });
|
|
1142
|
+
const branch = info.data.default_branch;
|
|
1143
|
+
const prot = await octokit.rest.repos.getBranchProtection({ owner, repo, branch });
|
|
1144
|
+
requiredChecks = prot.data?.required_status_checks?.contexts || [];
|
|
1145
|
+
} catch {
|
|
1146
|
+
requiredChecks = null; // sem proteção (404) ou sem permissão — nos dois casos, não afirmar
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const { status, notes } = inspectPrPublishing({
|
|
1151
|
+
canCreatePr, workflowPerms, requiredChecks, prTokenPresent,
|
|
1152
|
+
});
|
|
1153
|
+
return { name, status, detail: notes.join('\n ') };
|
|
1154
|
+
}
|
|
1155
|
+
|
|
970
1156
|
async function checkWorkflows(ctx) {
|
|
971
1157
|
const name = 'Workflows do Actions';
|
|
972
1158
|
// Ancorado na raiz do projeto, não no cwd: rodar o doctor de um subdiretório
|
|
@@ -1068,6 +1254,7 @@ export async function doctor() {
|
|
|
1068
1254
|
checkDecompositions,
|
|
1069
1255
|
checkSpecKit,
|
|
1070
1256
|
checkWorkflows,
|
|
1257
|
+
checkPrPublishing,
|
|
1071
1258
|
checkExecutionMode,
|
|
1072
1259
|
];
|
|
1073
1260
|
const results = [];
|