@spec-wave/cli 0.26.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/package.json +1 -1
- package/src/api/github-rest.mjs +25 -0
- package/src/commands/decompose.mjs +166 -39
- 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/run.mjs +51 -30
- 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 +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 +3 -1
- 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/package.json
CHANGED
package/src/api/github-rest.mjs
CHANGED
|
@@ -311,6 +311,31 @@ export async function deleteLabel(token, owner, repo, name) {
|
|
|
311
311
|
}
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Apaga a ref de uma branch.
|
|
316
|
+
*
|
|
317
|
+
* Existe para UM caso, e só ele: a branch de artefato que sobrou de um PR
|
|
318
|
+
* mergeado com squash. Nesse merge a ponta da branch não é ancestral da base, e
|
|
319
|
+
* empilhar o próximo commit nela produziria um PR que reintroduz estado antigo.
|
|
320
|
+
* Sem PR aberto e sem commits à frente da base, a branch não guarda nada — pode
|
|
321
|
+
* ser recriada a partir da base.
|
|
322
|
+
*
|
|
323
|
+
* NUNCA use isto para resolver conflito: apagar uma branch com PR aberto
|
|
324
|
+
* descartaria revisão humana.
|
|
325
|
+
*
|
|
326
|
+
* @returns {Promise<boolean>} false quando a branch já não existia
|
|
327
|
+
*/
|
|
328
|
+
export async function deleteBranch(token, owner, repo, branch) {
|
|
329
|
+
const octokit = makeQuietOctokit(token); // 404 = já não existe, não é erro
|
|
330
|
+
try {
|
|
331
|
+
await octokit.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` });
|
|
332
|
+
return true;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (err.status === 404 || err.status === 422) return false;
|
|
335
|
+
throw err;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
314
339
|
export async function deleteFile(token, owner, repo, filePath, message) {
|
|
315
340
|
const octokit = makeOctokit(token);
|
|
316
341
|
let sha;
|
|
@@ -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) {
|
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 = [];
|