@spec-wave/cli 0.21.0 → 0.24.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/README.md +25 -5
- package/bin/spec-wave.mjs +2 -2
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +43 -20
- package/src/agent/errors.mjs +59 -13
- package/src/agent/index.mjs +104 -38
- package/src/agent/openrouter-agent.mjs +5 -1
- package/src/api/github-graphql.mjs +63 -0
- package/src/api/github-rest.mjs +9 -2
- package/src/commands/decompose.mjs +82 -5
- package/src/commands/doctor.mjs +38 -12
- package/src/commands/init.mjs +5 -0
- package/src/commands/move.mjs +52 -0
- package/src/commands/order.mjs +200 -7
- package/src/commands/validate.mjs +39 -18
- package/src/config.mjs +45 -8
- package/src/lib/board.mjs +34 -1
- package/src/lib/bug-doc.mjs +71 -0
- package/src/lib/claude.mjs +36 -9
- package/src/lib/decomposition-doc.mjs +66 -14
- package/src/lib/dependencies.mjs +14 -4
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/decompose/SKILL.md +3 -3
- package/src/plugin/skills/doctor/SKILL.md +1 -1
- package/src/plugin/skills/order/SKILL.md +8 -4
- package/src/plugin/skills/ready/SKILL.md +1 -1
- package/src/plugin/skills/setup/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +5 -5
- package/src/templates/workflows/critique.yml +1 -0
- package/src/templates/workflows/decompose.yml +1 -0
- package/src/templates/workflows/generate-bug.yml +1 -0
- package/src/templates/workflows/generate-plan.yml +1 -0
- package/src/templates/workflows/generate-spec.yml +1 -0
|
@@ -203,6 +203,25 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
|
|
|
203
203
|
return { skip: false, reason: '' };
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Milestone que os filhos herdam do pai (função PURA — testável).
|
|
208
|
+
*
|
|
209
|
+
* Uma Story/Task nasce para entregar a Feature que a gerou: separá-las da release
|
|
210
|
+
* do pai faria a mesma entrega aparecer em dois grupos — e o milestone é o que o
|
|
211
|
+
* fluxo usa como release (é o milestone fechado que sinaliza o deploy). Mesma
|
|
212
|
+
* regra que a reprovação de QA já aplica ao registrar um bug filho.
|
|
213
|
+
*
|
|
214
|
+
* Devolve o NÚMERO (o que `createIssue` aceita) ou `undefined` quando o pai não
|
|
215
|
+
* tem milestone — aí não há release a herdar, e o filho nasce sem, como antes.
|
|
216
|
+
*
|
|
217
|
+
* @param {{ milestone?: { number?: number, title?: string } | null }} [issue]
|
|
218
|
+
* @returns {number|undefined}
|
|
219
|
+
*/
|
|
220
|
+
export function resolveInheritedMilestone(issue) {
|
|
221
|
+
const number = issue?.milestone?.number;
|
|
222
|
+
return Number.isInteger(number) && number > 0 ? number : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
206
225
|
// Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
|
|
207
226
|
// spec/plan; RFC ganha o seu, já que não passa por spec/plan.
|
|
208
227
|
function resolveDocDir(root, issue, type) {
|
|
@@ -560,10 +579,59 @@ function failIfBoardIncomplete(failures) {
|
|
|
560
579
|
throw err;
|
|
561
580
|
}
|
|
562
581
|
|
|
582
|
+
// Linha do comentário registrando o milestone herdado ('' quando não há).
|
|
583
|
+
function milestoneLine(issue, milestone) {
|
|
584
|
+
if (!milestone) return '';
|
|
585
|
+
return `🏁 Milestone herdado do pai: **${issue.milestone?.title ?? `#${milestone}`}**.\n\n`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Resolve as dependências externas do rascunho ANTES de criar qualquer issue.
|
|
590
|
+
*
|
|
591
|
+
* `#N` só entra no documento se a issue JÁ existe (fase 1 da proposta), e é
|
|
592
|
+
* aqui que isso é cobrado. Falhar cedo é a única opção honesta: descobrir no
|
|
593
|
+
* meio do laço que a #412 não existe deixaria metade das Stories criadas e a
|
|
594
|
+
* outra metade não — o estado que o apply inteiro foi desenhado para evitar.
|
|
595
|
+
*
|
|
596
|
+
* `addBlockedBy` exige o DATABASE id da bloqueadora (não aceita number nem node
|
|
597
|
+
* id), e é por isso que resolver custa uma leitura por issue distinta.
|
|
598
|
+
*
|
|
599
|
+
* @returns {Promise<Map<number, {number:number, id:number, title:string}>>}
|
|
600
|
+
*/
|
|
601
|
+
async function resolveExternalDeps({ token, owner, repo }, doc) {
|
|
602
|
+
const numeros = [...new Set(
|
|
603
|
+
(doc.stories || []).flatMap(s => s.dependsOnIssues || [])
|
|
604
|
+
)].sort((a, b) => a - b);
|
|
605
|
+
const resolvidas = new Map();
|
|
606
|
+
const faltando = [];
|
|
607
|
+
for (const n of numeros) {
|
|
608
|
+
try {
|
|
609
|
+
const issue = await getIssue(token, owner, repo, n);
|
|
610
|
+
resolvidas.set(n, { number: issue.number, id: issue.id, title: issue.title });
|
|
611
|
+
} catch (err) {
|
|
612
|
+
faltando.push(`#${n} (${err.status === 404 ? 'não existe' : err.message})`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (faltando.length > 0) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`o rascunho depende de issue(s) que não consegui ler: ${faltando.join(', ')}. ` +
|
|
618
|
+
'Em "**Depende de:**", `#N` precisa ser uma issue que JÁ existe — corrija o ' +
|
|
619
|
+
'decomposition.md (ou aplique antes a Feature que cria essa Story).'
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
return resolvidas;
|
|
623
|
+
}
|
|
624
|
+
|
|
563
625
|
async function createStoriesFromDoc(ctx, doc) {
|
|
564
626
|
const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
|
|
565
627
|
const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
|
|
566
628
|
const featureNodeId = issue.node_id;
|
|
629
|
+
// Herdado uma vez e reusado por todas as Stories e Tasks deste apply.
|
|
630
|
+
const milestone = resolveInheritedMilestone(issue);
|
|
631
|
+
if (milestone) console.log(`Milestone herdado da ${ctx.type}: ${issue.milestone?.title ?? `#${milestone}`}.`);
|
|
632
|
+
// Antes da primeira criação: rascunho que aponta para issue inexistente é
|
|
633
|
+
// erro de escrita, e o lugar de reprovar é aqui.
|
|
634
|
+
const externas = await resolveExternalDeps(ctx, doc);
|
|
567
635
|
const created = [];
|
|
568
636
|
const createdStories = []; // issues criadas, na ordem dos índices das stories
|
|
569
637
|
const generatedTexts = []; // títulos+corpos para o lint de idioma final
|
|
@@ -580,12 +648,17 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
580
648
|
.filter(Boolean)
|
|
581
649
|
.join('\n\n') || '_(sem descrição)_';
|
|
582
650
|
|
|
583
|
-
//
|
|
584
|
-
|
|
651
|
+
// Irmãs (índices já validados pelo parser, sempre para trás) + externas (já
|
|
652
|
+
// resolvidas acima). As duas viram a MESMA linha no corpo e a mesma relação
|
|
653
|
+
// blocked_by: para quem lê a issue, a fronteira da Feature não existe.
|
|
654
|
+
const depIssues = [
|
|
655
|
+
...story.dependsOn.map(idx => createdStories[idx]),
|
|
656
|
+
...(story.dependsOnIssues || []).map(n => externas.get(n)),
|
|
657
|
+
].filter(Boolean);
|
|
585
658
|
const depLine = formatDependencyLine(depIssues.map(d => d.number));
|
|
586
659
|
if (depLine) storyBody += `\n\n${depLine}`;
|
|
587
660
|
|
|
588
|
-
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
|
|
661
|
+
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]'], { milestone });
|
|
589
662
|
ctx.createdItems.push(createdStory.number);
|
|
590
663
|
// Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
|
|
591
664
|
// arquivo no fim do apply.
|
|
@@ -619,7 +692,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
619
692
|
console.log(` Criando task: ${task.title}`);
|
|
620
693
|
const taskTitle = `[TASK] ${task.title}`;
|
|
621
694
|
const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
|
|
622
|
-
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
695
|
+
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
|
|
623
696
|
ctx.createdItems.push(createdTask.number);
|
|
624
697
|
task.issue = createdTask.number;
|
|
625
698
|
generatedTexts.push(taskTitle, taskBody);
|
|
@@ -652,6 +725,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
652
725
|
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
653
726
|
`🔀 **Decomposição aplicada!**\n\n` +
|
|
654
727
|
`A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
|
|
728
|
+
milestoneLine(issue, milestone) +
|
|
655
729
|
posicionamento +
|
|
656
730
|
formatItemsLintWarning(generatedTexts)
|
|
657
731
|
).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
|
|
@@ -710,6 +784,8 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
710
784
|
const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
|
|
711
785
|
const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
|
|
712
786
|
const parentNodeId = issue.node_id;
|
|
787
|
+
const milestone = resolveInheritedMilestone(issue);
|
|
788
|
+
if (milestone) console.log(`Milestone herdado do RFC: ${issue.milestone?.title ?? `#${milestone}`}.`);
|
|
713
789
|
const created = [];
|
|
714
790
|
const generatedTexts = [];
|
|
715
791
|
const boardFailures = [];
|
|
@@ -718,7 +794,7 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
718
794
|
console.log(`Criando task: ${task.title}`);
|
|
719
795
|
const taskTitle = `[TASK] ${task.title}`;
|
|
720
796
|
const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
|
|
721
|
-
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
797
|
+
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
|
|
722
798
|
ctx.createdItems.push(createdTask.number);
|
|
723
799
|
task.issue = createdTask.number;
|
|
724
800
|
created.push({ title: taskTitle, url: createdTask.url });
|
|
@@ -744,6 +820,7 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
744
820
|
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
745
821
|
`🔀 **Decomposição do RFC aplicada!**\n\n` +
|
|
746
822
|
`A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
|
|
823
|
+
milestoneLine(issue, milestone) +
|
|
747
824
|
posicionamento +
|
|
748
825
|
formatItemsLintWarning(generatedTexts)
|
|
749
826
|
).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from '../api/auth.mjs';
|
|
15
15
|
import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
|
|
16
16
|
import {
|
|
17
|
-
CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
|
|
17
|
+
CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, AI_PROVIDERS, STATUS_OPTIONS,
|
|
18
18
|
RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
19
19
|
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
|
|
20
20
|
modelLabels,
|
|
@@ -679,8 +679,9 @@ async function checkAi(ctx) {
|
|
|
679
679
|
`${Object.entries(aliases).map(([a, m]) => `${a}=${m}`).join(', ')}.`
|
|
680
680
|
);
|
|
681
681
|
// Slug da OpenRouter tem "/" (anthropic/claude-…); id da Anthropic, não.
|
|
682
|
+
// O critério é o BACKEND: `claude-oauth` fala com a mesma API do `anthropic`.
|
|
682
683
|
const wrongShape = Object.entries(aliases).filter(([, m]) => (
|
|
683
|
-
provider.
|
|
684
|
+
provider.backend === 'openrouter' ? !String(m).includes('/') : String(m).includes('/')
|
|
684
685
|
));
|
|
685
686
|
if (wrongShape.length > 0) {
|
|
686
687
|
status = 'warn';
|
|
@@ -700,7 +701,7 @@ async function checkAi(ctx) {
|
|
|
700
701
|
|
|
701
702
|
// Saída estruturada da crítica: sem structured output confiável, a validação
|
|
702
703
|
// do schema queima os retries antes de falhar.
|
|
703
|
-
if (provider.
|
|
704
|
+
if (provider.backend === 'openrouter' && !fileAi.models?.critique) {
|
|
704
705
|
status = 'warn';
|
|
705
706
|
notes.push(
|
|
706
707
|
`Saída estruturada da crítica: o provider é openrouter e \`ai.models.critique\` não está ` +
|
|
@@ -708,21 +709,27 @@ async function checkAi(ctx) {
|
|
|
708
709
|
'e a crítica falha na primeira execução. Aponte `ai.models.critique` para um modelo com ' +
|
|
709
710
|
'saída estruturada.'
|
|
710
711
|
);
|
|
712
|
+
} else if (provider.backend === 'anthropic') {
|
|
713
|
+
// No backend anthropic quem garante o schema é o próprio Claude Code
|
|
714
|
+
// (`outputFormat: json_schema`), repetindo o turno até casar — não há
|
|
715
|
+
// tool call forçado nem `strict` de endpoint para reportar.
|
|
716
|
+
notes.push(
|
|
717
|
+
`Saída estruturada da crítica: json_schema do Claude Code (${provider.value}) ` +
|
|
718
|
+
'— o CLI repete o turno até a resposta casar com o schema.'
|
|
719
|
+
);
|
|
711
720
|
} else {
|
|
712
721
|
notes.push(
|
|
713
722
|
`Saída estruturada da crítica: tool call forçado (${provider.value}) ` +
|
|
714
723
|
`· strict=${supportsStrictSchema(critiqueModel) ? 'sim' : 'não'} neste modelo.`
|
|
715
724
|
);
|
|
716
725
|
}
|
|
717
|
-
// O backend anthropic
|
|
718
|
-
//
|
|
719
|
-
if (provider.
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
'
|
|
723
|
-
'
|
|
724
|
-
'OPENROUTER_API_KEY), ou instale o Claude Code no workflow e defina ' +
|
|
725
|
-
'SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1. Localmente o anthropic funciona.'
|
|
726
|
+
// O backend anthropic sobe o Claude Code CLI como subprocesso — e o binário
|
|
727
|
+
// vem embarcado no SDK, então o runner não precisa de instalação extra.
|
|
728
|
+
if (provider.backend === 'anthropic') {
|
|
729
|
+
notes.push(
|
|
730
|
+
`Backend anthropic (${provider.value}): o Claude Code sobe como subprocesso a partir do ` +
|
|
731
|
+
'binário embarcado no @anthropic-ai/claude-agent-sdk — nada a instalar no runner além do ' +
|
|
732
|
+
'próprio CLI. Só falha se o pacote for instalado com `--omit=optional`.'
|
|
726
733
|
);
|
|
727
734
|
}
|
|
728
735
|
|
|
@@ -955,10 +962,19 @@ async function checkWorkflows(ctx) {
|
|
|
955
962
|
// comportamento de pipelines já em andamento.
|
|
956
963
|
const unpinned = [];
|
|
957
964
|
const otherVersion = [];
|
|
965
|
+
// O secret do provider escolhido chega ao runner? A YAML é que decide: um
|
|
966
|
+
// repo configurado com um provider cujo secret o workflow não encaminha morre
|
|
967
|
+
// com "Faltam credenciais" no meio do fluxo, e o único conserto é `update`.
|
|
968
|
+
const provider = getProvider(ctx.cfg?.ai?.provider) || getProvider(DEFAULT_PROVIDER);
|
|
969
|
+
const missingSecret = [];
|
|
958
970
|
for (const file of WORKFLOW_FILES) {
|
|
959
971
|
const content = readFileSync(path.join(dir, file), 'utf-8');
|
|
960
972
|
if (/@spec-wave\/cli@latest/.test(content)) unpinned.push(file);
|
|
961
973
|
else if (!content.includes(`@spec-wave/cli@${CLI_VERSION}`)) otherVersion.push(file);
|
|
974
|
+
// Identifica os workflows de IA pelo que eles próprios declaram, em vez de
|
|
975
|
+
// manter uma segunda lista para sair de sincronia com WORKFLOW_FILES.
|
|
976
|
+
const usesAi = AI_PROVIDERS.some(pr => content.includes(`secrets.${pr.secret}`));
|
|
977
|
+
if (usesAi && !content.includes(`secrets.${provider.secret}`)) missingSecret.push(file);
|
|
962
978
|
}
|
|
963
979
|
const notes = [`Os ${WORKFLOW_FILES.length} workflows do spec-wave estão presentes.`];
|
|
964
980
|
let status = 'ok';
|
|
@@ -976,6 +992,16 @@ async function checkWorkflows(ctx) {
|
|
|
976
992
|
'para fazer o bump explícito.'
|
|
977
993
|
);
|
|
978
994
|
}
|
|
995
|
+
if (missingSecret.length > 0) {
|
|
996
|
+
status = 'fail';
|
|
997
|
+
notes.push(
|
|
998
|
+
`Não encaminham \`${provider.secret}\` (secret do provider ${provider.value}): ` +
|
|
999
|
+
`${missingSecret.join(', ')}. Esses workflows vão falhar com "Faltam credenciais" — ` +
|
|
1000
|
+
'rode `npx @spec-wave/cli@latest update`.'
|
|
1001
|
+
);
|
|
1002
|
+
} else {
|
|
1003
|
+
notes.push(`Todos encaminham \`${provider.secret}\` (provider ${provider.value}).`);
|
|
1004
|
+
}
|
|
979
1005
|
return { name, status, detail: notes.join('\n') };
|
|
980
1006
|
}
|
|
981
1007
|
|
package/src/commands/init.mjs
CHANGED
|
@@ -220,6 +220,11 @@ export async function init(options) {
|
|
|
220
220
|
? ` 1. Commite o ${CONFIG_FILE} quando quiser (git add ${CONFIG_FILE} && git commit)\n`
|
|
221
221
|
: '') +
|
|
222
222
|
` ${configWritten ? '2' : '1'}. Adicione ${providerMeta.secret} como secret no repositório (provider: ${providerMeta.label})\n` +
|
|
223
|
+
// O valor do token da assinatura não é copiável de lugar nenhum: só existe
|
|
224
|
+
// depois de rodar o comando que o gera.
|
|
225
|
+
(providerMeta.secret === 'CLAUDE_CODE_OAUTH_TOKEN'
|
|
226
|
+
? ` ${chalk.dim('Gere o valor com: claude setup-token')}\n`
|
|
227
|
+
: '') +
|
|
223
228
|
` ${configWritten ? '3' : '2'}. Configure o board view para agrupar por "Etapa"\n` +
|
|
224
229
|
` ${configWritten ? '4' : '3'}. Crie uma Feature com o prefixo [FEATURE] no título\n` +
|
|
225
230
|
` ${configWritten ? '5' : '4'}. Use a skill spec-wave para guiar o fluxo\n\n` +
|
package/src/commands/move.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { loadConfig } from '../lib/project-root.mjs';
|
|
|
21
21
|
import {
|
|
22
22
|
CONFIG_FILE, PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
23
23
|
isManualStageType, MANUAL_STAGE_TYPES, isStageInTrack, STAGE_TRACKS,
|
|
24
|
+
STAGE_READY, LABEL_PLAN_APPROVED, LABEL_SPEC, LABEL_PLAN, LABEL_CRITIQUE_FAILED,
|
|
25
|
+
LABEL_NEEDS_HUMAN, allowsSpecPlan, labelNames,
|
|
24
26
|
} from '../config.mjs';
|
|
25
27
|
|
|
26
28
|
const PROGRESS_VALUES = [PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE];
|
|
@@ -49,6 +51,51 @@ export function resolveProgressName(input) {
|
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* O que o board sabe e quem move à mão pode não saber (função PURA).
|
|
56
|
+
*
|
|
57
|
+
* `advanceToStage` já protege a ORDEM das etapas ("a Etapa nunca retrocede"),
|
|
58
|
+
* mas nada olhava os PORTÕES: mover uma Feature para ✅ Ready sem
|
|
59
|
+
* `spec-wave:plan-approved` põe na fila do time um item cuja validação não
|
|
60
|
+
* passou — e com `spec-wave:spec` ainda na issue, que é o rastro de uma reprova
|
|
61
|
+
* recente. Foi o que aconteceu com a #43.
|
|
62
|
+
*
|
|
63
|
+
* AVISA, não bloqueia: a decisão pode ser deliberada (documentos corrigidos à
|
|
64
|
+
* mão, validação que não se quer rodar de novo), e o comando existe justamente
|
|
65
|
+
* para os casos que o fluxo não cobre. O que não pode é ser silencioso.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} params
|
|
68
|
+
* @param {string|null} params.type tipo do work item
|
|
69
|
+
* @param {string} params.stage etapa de destino
|
|
70
|
+
* @param {Array<string|{name:string}>} [params.labels] labels da issue
|
|
71
|
+
* @returns {string[]} avisos, na ordem de gravidade
|
|
72
|
+
*/
|
|
73
|
+
export function readinessWarnings({ type, stage, labels = [] } = {}) {
|
|
74
|
+
if (stage !== STAGE_READY) return [];
|
|
75
|
+
const nomes = labelNames(labels);
|
|
76
|
+
const avisos = [];
|
|
77
|
+
|
|
78
|
+
// Bug, RFC e Spike não passam por spec/plan — cobrar plan-approved deles
|
|
79
|
+
// seria inventar um portão que o fluxo não tem.
|
|
80
|
+
if (allowsSpecPlan(type) && !nomes.includes(LABEL_PLAN_APPROVED)) {
|
|
81
|
+
avisos.push(
|
|
82
|
+
`não tem \`${LABEL_PLAN_APPROVED}\`: spec+plan não passaram pelo \`validate\`. ` +
|
|
83
|
+
`Para validar, aplique \`spec-wave:ready\` na issue.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
for (const gatilho of [LABEL_SPEC, LABEL_PLAN]) {
|
|
87
|
+
if (nomes.includes(gatilho)) {
|
|
88
|
+
avisos.push(`ainda tem \`${gatilho}\` — há geração de documento pendente ou uma reprova recente.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const portao of [LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN]) {
|
|
92
|
+
if (nomes.includes(portao)) {
|
|
93
|
+
avisos.push(`tem \`${portao}\` — a crítica reprovou e ninguém liberou.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return avisos;
|
|
97
|
+
}
|
|
98
|
+
|
|
52
99
|
export async function move({ issue: issueArg, stage: stageArg, status: statusArg }) {
|
|
53
100
|
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
54
101
|
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
@@ -126,6 +173,11 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
|
|
|
126
173
|
);
|
|
127
174
|
}
|
|
128
175
|
|
|
176
|
+
// Portões do fluxo: aviso antes de escrever, com a issue já em mãos.
|
|
177
|
+
for (const aviso of readinessWarnings({ type, stage, labels: issue.labels })) {
|
|
178
|
+
p.log.warn(`#${issueNumber} ${aviso}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
129
181
|
const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
130
182
|
if (projectError) {
|
|
131
183
|
p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
|
package/src/commands/order.mjs
CHANGED
|
@@ -1,24 +1,176 @@
|
|
|
1
|
-
// Ordena as Stories
|
|
1
|
+
// Ordena as Stories pelas dependências (topológica, Kahn —
|
|
2
2
|
// ver orderStories em src/lib/dependencies.mjs). As dependências vêm de duas
|
|
3
3
|
// fontes, mescladas: a linha "Depende de: #N" no corpo da Story e a relação
|
|
4
4
|
// nativa blocked_by do GitHub.
|
|
5
5
|
//
|
|
6
|
+
// Dependência para FORA da Feature não entra na ordenação (não há como saber
|
|
7
|
+
// onde a Story de outra Feature entra nesta sequência), mas é EXIBIDA: era lida,
|
|
8
|
+
// descartada em silêncio por orderStories e ignorada de novo no aviso de
|
|
9
|
+
// fora-de-ordem. Quem montava o mapa de trabalho de dois devs tinha que
|
|
10
|
+
// reconstruir isso à mão, lendo os decomposition.md.
|
|
11
|
+
//
|
|
12
|
+
// Dois escopos: `order <feature>` (uma Feature) e `order` sem argumento — o
|
|
13
|
+
// mapa de TODAS as Features com trabalho no board, onde as dependências entre
|
|
14
|
+
// Features deixam de ser "externas" e entram no mesmo grafo.
|
|
15
|
+
//
|
|
6
16
|
// Comando LOCAL — rodado pelo dev no terminal. owner/repo vêm da env
|
|
7
17
|
// GITHUB_REPOSITORY quando existir, senão do .spec-wave.json (gravado pelo init).
|
|
8
18
|
import * as p from '@clack/prompts';
|
|
9
19
|
import chalk from 'chalk';
|
|
10
20
|
import { resolveToken } from '../api/auth.mjs';
|
|
11
21
|
import { getIssue, listBlockedBy } from '../api/github-rest.mjs';
|
|
12
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
addProjectItem, listSubIssues, getItemSingleSelectValue, listProjectItems,
|
|
24
|
+
} from '../api/github-graphql.mjs';
|
|
13
25
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
14
26
|
import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
|
|
15
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
loadProjectConfig, resolveField, indexBoardItems, selectOpenFeatures,
|
|
29
|
+
} from '../lib/board.mjs';
|
|
16
30
|
import { resolveRepoContext } from '../lib/project-root.mjs';
|
|
17
31
|
import { CONFIG_FILE, STAGE_ORDER, STAGE_DEVELOPMENT, STAGE_DONE } from '../config.mjs';
|
|
18
32
|
|
|
19
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Rótulo curto de uma Feature para a coluna do mapa (função PURA).
|
|
35
|
+
*/
|
|
36
|
+
export function featureLabel(feature) {
|
|
37
|
+
const titulo = String(feature?.title || '').replace(/^\s*\[FEATURE\]\s*/i, '').trim();
|
|
38
|
+
const curto = titulo.length > 28 ? `${titulo.slice(0, 27)}…` : titulo;
|
|
39
|
+
return `#${feature?.number} ${curto}`.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* O mapa de execução de várias Features (função PURA).
|
|
44
|
+
*
|
|
45
|
+
* Uma ordem SÓ, não uma lista por Feature: é isso que responde "o que os dois
|
|
46
|
+
* devs pegam agora" — a pergunta que obrigava a abrir nove decomposition.md e
|
|
47
|
+
* reconstruir o grafo à mão. A coluna da Feature vem junto porque, num grafo de
|
|
48
|
+
* várias, saber a que Feature cada Story pertence é metade da informação.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} params
|
|
51
|
+
* @param {number[]} params.sorted ordem topológica (números de Story)
|
|
52
|
+
* @param {Map<number, {title:string, dependsOn:number[]}>} params.byNumber
|
|
53
|
+
* @param {Map<number, object>} params.featureOf Story → Feature dona
|
|
54
|
+
* @param {Map<number, string|null>} params.stageOf Story → Etapa
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function renderBoardOrder({ sorted, byNumber, featureOf, stageOf }) {
|
|
58
|
+
return (sorted || []).map((n, i) => {
|
|
59
|
+
const s = byNumber.get(n);
|
|
60
|
+
const etapa = stageOf.get(n) || '—';
|
|
61
|
+
const deps = (s?.dependsOn || []).filter(d => byNumber.has(d));
|
|
62
|
+
const dep = deps.length > 0 ? ` ← depende de ${deps.map(d => `#${d}`).join(', ')}` : '';
|
|
63
|
+
return `${String(i + 1).padStart(2)}. #${n} ${s?.title || ''}\n` +
|
|
64
|
+
` ${featureLabel(featureOf.get(n))} · Etapa: ${etapa}${dep}`;
|
|
65
|
+
}).join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `spec-wave order` sem argumento: o grafo de TODAS as Features com trabalho.
|
|
70
|
+
*
|
|
71
|
+
* Fonte do conjunto é o BOARD, não os arquivos — `decomposition.md` registra o
|
|
72
|
+
* que foi proposto, a issue é o que existe. Uma query paginada de itens do
|
|
73
|
+
* project resolve Etapa e tipo de todo mundo de uma vez, no lugar do laço de
|
|
74
|
+
* duas chamadas por Story que o modo de uma Feature ainda usa.
|
|
75
|
+
*/
|
|
76
|
+
async function orderBoard({ token, owner, repo }) {
|
|
77
|
+
const { project, error: projectError } = loadProjectConfig();
|
|
78
|
+
if (projectError) {
|
|
79
|
+
p.log.error(
|
|
80
|
+
`${projectError} — sem board não há como saber quais Features têm trabalho. ` +
|
|
81
|
+
'Use `spec-wave order <feature>` para ordenar uma Feature específica.'
|
|
82
|
+
);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let itens;
|
|
88
|
+
try {
|
|
89
|
+
itens = await listProjectItems(token, project.id);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
p.log.error(`Não foi possível ler os itens do Project: ${err.message}`);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const indice = indexBoardItems(itens);
|
|
96
|
+
const features = selectOpenFeatures(itens);
|
|
97
|
+
if (features.length === 0) {
|
|
98
|
+
p.log.info(`Nenhuma Feature aberta fora de "${STAGE_DONE}" no board — nada a ordenar.`);
|
|
99
|
+
p.outro('Nada a fazer.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const enriched = [];
|
|
104
|
+
const featureOf = new Map();
|
|
105
|
+
const stageOf = new Map();
|
|
106
|
+
const semStories = [];
|
|
107
|
+
for (const feature of features) {
|
|
108
|
+
const subs = await listSubIssues(token, feature.nodeId).catch(() => []);
|
|
109
|
+
const stories = subs.filter(sub =>
|
|
110
|
+
detectIssueType({ title: sub.title, labels: sub.labels }) === 'Story');
|
|
111
|
+
if (stories.length === 0) { semStories.push(feature); continue; }
|
|
112
|
+
for (const story of stories) {
|
|
113
|
+
// Story concluída não entra: o mapa é do que falta fazer.
|
|
114
|
+
if (indice.get(story.number)?.fields?.Etapa === STAGE_DONE) continue;
|
|
115
|
+
const fromBody = parseDependencies(story.body);
|
|
116
|
+
const fromBlockedBy = (await listBlockedBy(token, owner, repo, story.number).catch(() => []))
|
|
117
|
+
.map(b => b.number);
|
|
118
|
+
enriched.push({
|
|
119
|
+
number: story.number,
|
|
120
|
+
title: story.title,
|
|
121
|
+
dependsOn: [...new Set([...fromBody, ...fromBlockedBy])],
|
|
122
|
+
});
|
|
123
|
+
featureOf.set(story.number, feature);
|
|
124
|
+
stageOf.set(story.number, indice.get(story.number)?.fields?.Etapa || null);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (enriched.length === 0) {
|
|
129
|
+
p.log.info(`${features.length} Feature(s) aberta(s), nenhuma com Story pendente.`);
|
|
130
|
+
p.outro('Nada a fazer.');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const byNumber = new Map(enriched.map(s => [s.number, s]));
|
|
135
|
+
const { order: sorted, cycle, external } = orderStories(
|
|
136
|
+
enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
|
|
137
|
+
|
|
138
|
+
if (cycle.length > 0) {
|
|
139
|
+
p.log.warn(
|
|
140
|
+
chalk.yellow.bold('⚠ CICLO DE DEPENDÊNCIAS detectado!') + '\n' +
|
|
141
|
+
`Stories envolvidas (ou bloqueadas pelo ciclo): ${cycle.map(n => `#${n}`).join(', ')}.\n` +
|
|
142
|
+
'Elas ficaram fora da ordem abaixo. Num grafo de várias Features o ciclo pode ' +
|
|
143
|
+
'ATRAVESSAR a fronteira delas — confira as linhas "Depende de" dos dois lados.'
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
p.note(
|
|
148
|
+
renderBoardOrder({ sorted, byNumber, featureOf, stageOf }),
|
|
149
|
+
`Ordem de execução — ${sorted.length} Story(ies) de ${features.length} Feature(s)`
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Aqui "fora do conjunto" não é mais "de outra Feature" (essas agora estão
|
|
153
|
+
// DENTRO): é Story já concluída, Feature em Done ou issue de outro board.
|
|
154
|
+
if (external.size > 0) {
|
|
155
|
+
const linhas = [...external.entries()].map(([n, deps]) =>
|
|
156
|
+
` #${n} ← ${deps.map(d => `#${d}`).join(', ')}`);
|
|
157
|
+
p.note(
|
|
158
|
+
linhas.join('\n'),
|
|
159
|
+
'Dependências fora do conjunto (concluídas, de Feature em Done ou de outro board)'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (semStories.length > 0) {
|
|
164
|
+
p.log.info(`Sem Stories (ainda não decompostas): ${semStories.map(f => `#${f.number}`).join(', ')}.`);
|
|
165
|
+
}
|
|
166
|
+
p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function order({ feature: featureArg } = {}) {
|
|
170
|
+
const semArgumento = featureArg === undefined || featureArg === null
|
|
171
|
+
|| String(featureArg).trim() === '';
|
|
20
172
|
const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
|
|
21
|
-
if (!Number.isInteger(featureNumber) || featureNumber <= 0) {
|
|
173
|
+
if (!semArgumento && (!Number.isInteger(featureNumber) || featureNumber <= 0)) {
|
|
22
174
|
p.log.error(`Feature inválida: "${featureArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
23
175
|
process.exitCode = 1;
|
|
24
176
|
return;
|
|
@@ -43,6 +195,11 @@ export async function order({ feature: featureArg }) {
|
|
|
43
195
|
return;
|
|
44
196
|
}
|
|
45
197
|
|
|
198
|
+
if (semArgumento) {
|
|
199
|
+
p.intro(chalk.bold('spec-wave order — todas as Features com trabalho'));
|
|
200
|
+
return await orderBoard({ token, owner, repo });
|
|
201
|
+
}
|
|
202
|
+
|
|
46
203
|
p.intro(chalk.bold(`spec-wave order #${featureNumber}`));
|
|
47
204
|
|
|
48
205
|
// 1. Lê a Feature e valida o tipo.
|
|
@@ -116,7 +273,19 @@ export async function order({ feature: featureArg }) {
|
|
|
116
273
|
|
|
117
274
|
// 5. Ordenação topológica (nunca lança; ciclo vem em `cycle`).
|
|
118
275
|
const byNumber = new Map(enriched.map(s => [s.number, s]));
|
|
119
|
-
const { order: sorted, cycle } = orderStories(
|
|
276
|
+
const { order: sorted, cycle, external } = orderStories(
|
|
277
|
+
enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
|
|
278
|
+
|
|
279
|
+
// Uma leitura por issue externa DISTINTA — o estado dela é o que diz se o
|
|
280
|
+
// bloqueio ainda vale.
|
|
281
|
+
const foraDoConjunto = [...new Set([...external.values()].flat())];
|
|
282
|
+
const externasInfo = new Map();
|
|
283
|
+
await Promise.all(foraDoConjunto.map(async (n) => {
|
|
284
|
+
const issue = await getIssue(token, owner, repo, n).catch(() => null);
|
|
285
|
+
externasInfo.set(n, issue
|
|
286
|
+
? { title: issue.title, state: issue.state, aberta: issue.state === 'open' }
|
|
287
|
+
: { title: '(não foi possível ler)', state: null, aberta: true });
|
|
288
|
+
}));
|
|
120
289
|
|
|
121
290
|
if (cycle.length > 0) {
|
|
122
291
|
p.log.warn(
|
|
@@ -136,6 +305,18 @@ export async function order({ feature: featureArg }) {
|
|
|
136
305
|
};
|
|
137
306
|
p.note(sorted.map(line).join('\n'), `Ordem de execução das Stories da Feature #${featureNumber}`);
|
|
138
307
|
|
|
308
|
+
if (external.size > 0) {
|
|
309
|
+
const linhas = [...external.entries()].map(([n, deps]) => {
|
|
310
|
+
const detalhe = deps.map(d => {
|
|
311
|
+
const info = externasInfo.get(d);
|
|
312
|
+
const marca = info?.aberta ? chalk.yellow('aberta') : chalk.green('fechada');
|
|
313
|
+
return `#${d} (${marca}) ${info?.title || ''}`.trim();
|
|
314
|
+
}).join('\n ');
|
|
315
|
+
return ` #${n} ${byNumber.get(n)?.title || ''}\n ← ${detalhe}`;
|
|
316
|
+
});
|
|
317
|
+
p.note(linhas.join('\n'), 'Bloqueadas por fora desta Feature');
|
|
318
|
+
}
|
|
319
|
+
|
|
139
320
|
// 6. Aviso final: dependente já em Desenvolvimento+ com dependência não-Done.
|
|
140
321
|
const devIdx = STAGE_ORDER.indexOf(STAGE_DEVELOPMENT);
|
|
141
322
|
const outOfOrder = [];
|
|
@@ -144,7 +325,19 @@ export async function order({ feature: featureArg }) {
|
|
|
144
325
|
const idx = stage ? STAGE_ORDER.indexOf(stage) : -1;
|
|
145
326
|
if (idx === -1 || idx < devIdx) continue; // ainda não chegou em Desenvolvimento
|
|
146
327
|
for (const d of s.dependsOn) {
|
|
147
|
-
if (!byNumber.has(d))
|
|
328
|
+
if (!byNumber.has(d)) {
|
|
329
|
+
// Externa: não tem Etapa neste conjunto, mas tem ESTADO — e uma issue
|
|
330
|
+
// aberta bloqueando quem já está em Desenvolvimento é a mesma falha que
|
|
331
|
+
// o aviso abaixo denuncia, só que atravessando a fronteira.
|
|
332
|
+
const info = externasInfo.get(d);
|
|
333
|
+
if (info?.aberta) {
|
|
334
|
+
outOfOrder.push(
|
|
335
|
+
`#${s.number} já está em "${stage}", mas depende de #${d}, de outra Feature, ` +
|
|
336
|
+
'que continua ABERTA.'
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
148
341
|
const depStage = stageOf.get(d);
|
|
149
342
|
if (depStage !== STAGE_DONE) {
|
|
150
343
|
outOfOrder.push(
|