@spec-wave/cli 0.19.0 → 0.21.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-graphql.mjs +64 -7
- package/src/commands/code-review.mjs +15 -4
- package/src/commands/decompose.mjs +138 -19
- package/src/commands/doctor.mjs +88 -8
- package/src/commands/move.mjs +6 -1
- package/src/commands/qa.mjs +8 -2
- package/src/commands/repair-stage.mjs +6 -1
- package/src/commands/story.mjs +6 -1
- package/src/commands/task.mjs +6 -1
- package/src/commands/triage.mjs +4 -1
- package/src/config.mjs +38 -0
- package/src/lib/board.mjs +54 -3
- package/src/lib/dependencies.mjs +49 -0
- package/src/lib/implement-board.mjs +15 -11
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/templates/workflows/code-review.yml +9 -2
- package/src/templates/workflows/critique.yml +19 -2
- package/src/templates/workflows/decompose.yml +19 -2
- package/src/templates/workflows/generate-bug.yml +19 -2
- package/src/templates/workflows/generate-plan.yml +19 -2
- package/src/templates/workflows/generate-spec.yml +19 -2
- package/src/templates/workflows/qa.yml +5 -1
- package/src/templates/workflows/validate.yml +19 -2
package/package.json
CHANGED
|
@@ -179,17 +179,74 @@ export async function getProjectSnapshot(token, projectId) {
|
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
/**
|
|
183
|
+
* O item deste conteúdo que JÁ está no project (função de rede, sem mutação).
|
|
184
|
+
*
|
|
185
|
+
* Vai pelo conteúdo (`node(contentId).projectItems`), não pelos itens do
|
|
186
|
+
* project: um board com milhares de itens paginaria por todos eles para achar
|
|
187
|
+
* um; uma issue está em poucos projects.
|
|
188
|
+
*
|
|
189
|
+
* @returns {Promise<string|null>} id do item, ou null se não estiver no project
|
|
190
|
+
*/
|
|
191
|
+
export async function findProjectItem(token, projectId, contentId) {
|
|
184
192
|
const client = makeClient(token);
|
|
185
193
|
const result = await client(`
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
194
|
+
query FindItem($contentId: ID!) {
|
|
195
|
+
node(id: $contentId) {
|
|
196
|
+
... on Issue { projectItems(first: 50) { nodes { id project { id } } } }
|
|
197
|
+
... on PullRequest { projectItems(first: 50) { nodes { id project { id } } } }
|
|
189
198
|
}
|
|
190
199
|
}
|
|
191
|
-
`, {
|
|
192
|
-
|
|
200
|
+
`, { contentId });
|
|
201
|
+
const nodes = result?.node?.projectItems?.nodes || [];
|
|
202
|
+
return nodes.find(n => n?.project?.id === projectId)?.id ?? null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Adiciona uma issue/PR (pelo node id do conteúdo) ao Project e devolve o id do
|
|
207
|
+
* item. IDEMPOTENTE: item já presente devolve o id existente.
|
|
208
|
+
*
|
|
209
|
+
* `addProjectV2ItemById` é documentado como idempotente, mas na prática responde
|
|
210
|
+
* `Content already exists in this project` quando o item entrou por outro
|
|
211
|
+
* caminho entre a leitura e a escrita — a automação nativa "auto-add" do
|
|
212
|
+
* project, ou um run concorrente do próprio spec-wave. Como esta é a PRIMEIRA
|
|
213
|
+
* chamada de advanceToStage(), o erro abortava tudo o que vinha depois: o item
|
|
214
|
+
* ficava no board com o Status que a automação do project escreve e SEM Etapa —
|
|
215
|
+
* exatamente o estado que some de todas as telas (caso da #281). Traduzir o erro
|
|
216
|
+
* numa busca pelo item existente é o que torna a escrita seguinte possível.
|
|
217
|
+
*/
|
|
218
|
+
export async function addProjectItem(token, projectId, contentId) {
|
|
219
|
+
const client = makeClient(token);
|
|
220
|
+
try {
|
|
221
|
+
const result = await client(`
|
|
222
|
+
mutation AddItem($projectId: ID!, $contentId: ID!) {
|
|
223
|
+
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
|
|
224
|
+
item { id }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
`, { projectId, contentId });
|
|
228
|
+
return result.addProjectV2ItemById.item.id;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (!isAlreadyInProjectError(err)) throw err;
|
|
231
|
+
const existing = await findProjectItem(token, projectId, contentId);
|
|
232
|
+
// Sem item correspondente, o erro não era duplicidade — devolvê-lo é mais
|
|
233
|
+
// honesto que um null que estouraria adiante como "field id inválido".
|
|
234
|
+
if (!existing) throw err;
|
|
235
|
+
return existing;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* O erro é "esse conteúdo já está no project"? (função PURA)
|
|
241
|
+
*
|
|
242
|
+
* A mensagem vem de fora e pode mudar; o teste é por substring e não por
|
|
243
|
+
* igualdade justamente por isso. Errar para o lado de NÃO reconhecer só
|
|
244
|
+
* restaura o comportamento antigo (o erro sobe).
|
|
245
|
+
*/
|
|
246
|
+
export function isAlreadyInProjectError(err) {
|
|
247
|
+
const msg = String(err?.message || '');
|
|
248
|
+
return /already exists in this project/i.test(msg)
|
|
249
|
+
|| /content already exists/i.test(msg);
|
|
193
250
|
}
|
|
194
251
|
|
|
195
252
|
// Cria a relação de sub-issue nativa do GitHub (parent → child). Ambos os IDs
|
|
@@ -282,6 +282,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
|
|
|
282
282
|
// Resolve campos uma vez, reutiliza em todas as Features.
|
|
283
283
|
const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
|
|
284
284
|
const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
|
|
285
|
+
// Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
|
|
286
|
+
// Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
|
|
287
|
+
const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
|
|
285
288
|
|
|
286
289
|
const seen = new Set();
|
|
287
290
|
const updated = [];
|
|
@@ -312,7 +315,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
|
|
|
312
315
|
if (seen.has(n)) continue;
|
|
313
316
|
seen.add(n);
|
|
314
317
|
try {
|
|
315
|
-
const moved = await advanceToStage(
|
|
318
|
+
const moved = await advanceToStage(
|
|
319
|
+
projectToken, project, etapaField, statusField, info.nodeId, DONE_STAGE, DONE_STATUS,
|
|
320
|
+
{ typeField, itemType: 'Task' });
|
|
316
321
|
if (moved) {
|
|
317
322
|
updated.push(`#${n} ${info.title} → ${DONE_STAGE}`);
|
|
318
323
|
console.log(`#${n} → "${DONE_STAGE}" / Status "${DONE_STATUS}".`);
|
|
@@ -329,7 +334,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
|
|
|
329
334
|
if (seen.has(n)) continue;
|
|
330
335
|
seen.add(n);
|
|
331
336
|
try {
|
|
332
|
-
const moved = await advanceToStage(
|
|
337
|
+
const moved = await advanceToStage(
|
|
338
|
+
projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS,
|
|
339
|
+
{ typeField, itemType: 'Story' });
|
|
333
340
|
if (moved) {
|
|
334
341
|
updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
|
|
335
342
|
console.log(`#${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
|
|
@@ -346,7 +353,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
|
|
|
346
353
|
if (seen.has(n)) continue;
|
|
347
354
|
seen.add(n);
|
|
348
355
|
try {
|
|
349
|
-
const moved = await advanceToStage(
|
|
356
|
+
const moved = await advanceToStage(
|
|
357
|
+
projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS,
|
|
358
|
+
{ typeField, itemType: 'Bug' });
|
|
350
359
|
if (moved) {
|
|
351
360
|
updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
|
|
352
361
|
console.log(`Bug #${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
|
|
@@ -367,7 +376,9 @@ export async function codeReview({ prNumber, resolveOnly = false }) {
|
|
|
367
376
|
console.log(`Feature #${feature.number} mantida em desenvolvimento — ainda há Stories pendentes (fora de "${CODE_REVIEW_STAGE}").`);
|
|
368
377
|
} else if (!seen.has(feature.number)) {
|
|
369
378
|
seen.add(feature.number);
|
|
370
|
-
const moved = await advanceToStage(
|
|
379
|
+
const moved = await advanceToStage(
|
|
380
|
+
projectToken, project, etapaField, statusField, feature.nodeId, CODE_REVIEW_STAGE, TODO_STATUS,
|
|
381
|
+
{ typeField, itemType: 'Feature' });
|
|
371
382
|
if (moved) {
|
|
372
383
|
updated.push(`#${feature.number} ${feature.title} (Feature) → ${CODE_REVIEW_STAGE}`);
|
|
373
384
|
console.log(`Feature #${feature.number} → "${CODE_REVIEW_STAGE}" (todas as Stories concluídas).`);
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
parseCritiqueDecisions, applyCritiqueDecisions, renderRiskAcceptedComment,
|
|
32
32
|
} from '../lib/critique.mjs';
|
|
33
33
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
34
|
-
import { formatDependencyLine } from '../lib/dependencies.mjs';
|
|
34
|
+
import { formatDependencyLine, orderStories, renderOrderComment } from '../lib/dependencies.mjs';
|
|
35
35
|
import { lintLanguage } from '../lib/output-lint.mjs';
|
|
36
36
|
import { slugify } from '../lib/slugify.mjs';
|
|
37
37
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
@@ -49,9 +49,16 @@ import {
|
|
|
49
49
|
|
|
50
50
|
// Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
|
|
51
51
|
// Etapa nunca retrocede (advanceToStage não toca itens já adiante).
|
|
52
|
-
|
|
52
|
+
//
|
|
53
|
+
// `itemType` é o tipo do que ACABOU de ser criado ('Story'/'Task'/'Feature') —
|
|
54
|
+
// aqui não há dúvida sobre ele, e sem essa passagem o Work Item Type das issues
|
|
55
|
+
// nascidas do apply ficava vazio para sempre (ver ensureWorkItemType).
|
|
56
|
+
async function moveToReady(token, project, fields, nodeId, itemType) {
|
|
53
57
|
if (!project?.id) return;
|
|
54
|
-
await advanceToStage(
|
|
58
|
+
await advanceToStage(
|
|
59
|
+
token, project, fields.etapaField, fields.statusField, nodeId, STAGE_READY, PROGRESS_TODO,
|
|
60
|
+
{ typeField: fields.typeField, itemType },
|
|
61
|
+
);
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
/**
|
|
@@ -457,8 +464,8 @@ async function applyDecomposition(ctx) {
|
|
|
457
464
|
|
|
458
465
|
// Campos do board só são resolvidos aqui: a etapa de rascunho não toca o board.
|
|
459
466
|
// `strict`: no apply, board inalcançável ABORTA antes da primeira criação.
|
|
460
|
-
const { project, etapaField, statusField } = await resolveBoard(ctx, { strict: true });
|
|
461
|
-
const applyCtx = { ...ctx, project, etapaField, statusField };
|
|
467
|
+
const { project, etapaField, statusField, typeField } = await resolveBoard(ctx, { strict: true });
|
|
468
|
+
const applyCtx = { ...ctx, project, etapaField, statusField, typeField };
|
|
462
469
|
|
|
463
470
|
// As falhas de board voltam em vez de subir na hora: a exceção precisa esperar
|
|
464
471
|
// a contabilidade de labels abaixo. Lançar aqui deixaria o gatilho
|
|
@@ -495,7 +502,16 @@ async function applyDecomposition(ctx) {
|
|
|
495
502
|
|
|
496
503
|
await addLabel(token, owner, repo, number, LABEL_DECOMPOSED)
|
|
497
504
|
.catch(err => console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`));
|
|
498
|
-
|
|
505
|
+
// Best-effort como as duas vizinhas: as issues já existem, e derrubar o run
|
|
506
|
+
// porque a REMOÇÃO de uma label falhou (404 de label já removida por um run
|
|
507
|
+
// concorrente, 5xx da API) transformava um apply concluído em vermelho — e o
|
|
508
|
+
// vermelho manda o humano reaplicar o gatilho, que é o caminho para duplicar
|
|
509
|
+
// dezenas de issues. A label que sobra é visível na issue e removível à mão.
|
|
510
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY)
|
|
511
|
+
.catch(err => console.warn(
|
|
512
|
+
`Decomposição aplicada, mas a label ${LABEL_DECOMPOSE_APPLY} não pôde ser removida ` +
|
|
513
|
+
`(${err.message}). Remova-a à mão: gh issue edit ${number} --remove-label "${LABEL_DECOMPOSE_APPLY}".`
|
|
514
|
+
));
|
|
499
515
|
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
|
|
500
516
|
|
|
501
517
|
// Por último, com as labels já consistentes: escrita de board incompleta
|
|
@@ -519,7 +535,10 @@ export function renderBoardFailures(failures) {
|
|
|
519
535
|
`${linhas}\n\n` +
|
|
520
536
|
`Motivo: \`${motivo}\`\n\n` +
|
|
521
537
|
'Verifique o `GH_PROJECT_TOKEN` (scope `project` e acesso ao Project da organização) e ' +
|
|
522
|
-
'reposicione os itens com `npx @spec-wave/cli@latest repair-stage <issue
|
|
538
|
+
'reposicione os itens com `npx @spec-wave/cli@latest repair-stage <issue>`.\n\n' +
|
|
539
|
+
'O run termina em VERMELHO de propósito — a decomposição em si CONCLUIU. ' +
|
|
540
|
+
`Não reaplique \`${LABEL_DECOMPOSE_APPLY}\`: as issues acima já existem e o apply ` +
|
|
541
|
+
'recomeça do zero; o que falta é só a Etapa, e quem repara isso é o `repair-stage`.'
|
|
523
542
|
);
|
|
524
543
|
}
|
|
525
544
|
|
|
@@ -542,7 +561,8 @@ function failIfBoardIncomplete(failures) {
|
|
|
542
561
|
}
|
|
543
562
|
|
|
544
563
|
async function createStoriesFromDoc(ctx, doc) {
|
|
545
|
-
const { token, projectToken, owner, repo, issue, issueNumber, project,
|
|
564
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
|
|
565
|
+
const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
|
|
546
566
|
const featureNodeId = issue.node_id;
|
|
547
567
|
const created = [];
|
|
548
568
|
const createdStories = []; // issues criadas, na ordem dos índices das stories
|
|
@@ -566,6 +586,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
566
586
|
if (depLine) storyBody += `\n\n${depLine}`;
|
|
567
587
|
|
|
568
588
|
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
|
|
589
|
+
ctx.createdItems.push(createdStory.number);
|
|
569
590
|
// Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
|
|
570
591
|
// arquivo no fim do apply.
|
|
571
592
|
story.issue = createdStory.number;
|
|
@@ -588,7 +609,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
588
609
|
console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
|
|
589
610
|
}
|
|
590
611
|
try {
|
|
591
|
-
await moveToReady(projectToken, project,
|
|
612
|
+
await moveToReady(projectToken, project, fields, createdStory.nodeId, 'Story');
|
|
592
613
|
} catch (err) {
|
|
593
614
|
console.warn(` Falha ao mover story #${createdStory.number} para "${STAGE_READY}": ${err.message}`);
|
|
594
615
|
boardFailures.push({ number: createdStory.number, kind: 'Story', reason: err.message });
|
|
@@ -599,6 +620,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
599
620
|
const taskTitle = `[TASK] ${task.title}`;
|
|
600
621
|
const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
|
|
601
622
|
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
623
|
+
ctx.createdItems.push(createdTask.number);
|
|
602
624
|
task.issue = createdTask.number;
|
|
603
625
|
generatedTexts.push(taskTitle, taskBody);
|
|
604
626
|
try {
|
|
@@ -607,7 +629,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
607
629
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
|
|
608
630
|
}
|
|
609
631
|
try {
|
|
610
|
-
await moveToReady(projectToken, project,
|
|
632
|
+
await moveToReady(projectToken, project, fields, createdTask.nodeId, 'Task');
|
|
611
633
|
} catch (err) {
|
|
612
634
|
console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
|
|
613
635
|
boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
|
|
@@ -616,8 +638,8 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
616
638
|
}
|
|
617
639
|
|
|
618
640
|
try {
|
|
619
|
-
await moveToReady(projectToken, project,
|
|
620
|
-
if (project?.id && etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
|
|
641
|
+
await moveToReady(projectToken, project, fields, featureNodeId, ctx.type);
|
|
642
|
+
if (project?.id && fields.etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
|
|
621
643
|
} catch (err) {
|
|
622
644
|
console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
|
|
623
645
|
boardFailures.push({ number: parseInt(issueNumber, 10), kind: 'Feature', reason: err.message });
|
|
@@ -633,12 +655,60 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
633
655
|
posicionamento +
|
|
634
656
|
formatItemsLintWarning(generatedTexts)
|
|
635
657
|
).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
|
|
658
|
+
|
|
659
|
+
await commentStoryOrder({ token, owner, repo, issueNumber, doc, createdStories });
|
|
660
|
+
|
|
636
661
|
console.log(`Decomposição aplicada: ${created.length} stories criadas.`);
|
|
637
662
|
return boardFailures;
|
|
638
663
|
}
|
|
639
664
|
|
|
665
|
+
/**
|
|
666
|
+
* Publica a ordem de execução das Stories como um segundo comentário.
|
|
667
|
+
*
|
|
668
|
+
* Os dados saem do que ACABOU de ser criado — `createdStories` (índice → issue)
|
|
669
|
+
* e `doc.stories[i].dependsOn` (0-based, já validado pelo parser). Não consulta
|
|
670
|
+
* o board: reconstruir isso pela API custaria ~4 chamadas por Story para chegar
|
|
671
|
+
* ao que já está em memória, e a Etapa é ✅ Ready por construção — se não fosse,
|
|
672
|
+
* `failIfBoardIncomplete` já teria derrubado o run.
|
|
673
|
+
*
|
|
674
|
+
* BEST-EFFORT, como o comentário que o antecede: a decomposição está feita e
|
|
675
|
+
* paga, e falhar ao comentar não pode desfazê-la nem mascarar o que deu certo.
|
|
676
|
+
*/
|
|
677
|
+
async function commentStoryOrder({ token, owner, repo, issueNumber, doc, createdStories }) {
|
|
678
|
+
try {
|
|
679
|
+
const stories = doc.stories
|
|
680
|
+
.map((story, i) => {
|
|
681
|
+
const criada = createdStories[i];
|
|
682
|
+
if (!criada) return null; // criação falhou no meio: não inventa a Story
|
|
683
|
+
return {
|
|
684
|
+
number: criada.number,
|
|
685
|
+
title: story.title,
|
|
686
|
+
// dependsOn vem 0-based (índice no doc) e vira número de issue.
|
|
687
|
+
dependsOn: (story.dependsOn || [])
|
|
688
|
+
.map(idx => createdStories[idx]?.number)
|
|
689
|
+
.filter(Boolean),
|
|
690
|
+
};
|
|
691
|
+
})
|
|
692
|
+
.filter(Boolean);
|
|
693
|
+
|
|
694
|
+
if (stories.length === 0) return;
|
|
695
|
+
|
|
696
|
+
const byNumber = new Map(stories.map(s => [s.number, s]));
|
|
697
|
+
const { order: sorted, cycle } = orderStories(
|
|
698
|
+
stories.map(({ number, dependsOn }) => ({ number, dependsOn }))
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
702
|
+
renderOrderComment({ sorted, cycle, byNumber }));
|
|
703
|
+
console.log(`Ordem de execução comentada: ${sorted.length} story(ies).`);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
console.warn(`Falha ao comentar a ordem de execução: ${err.message}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
640
709
|
async function createTasksFromDoc(ctx, doc) {
|
|
641
|
-
const { token, projectToken, owner, repo, issue, issueNumber, project,
|
|
710
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
|
|
711
|
+
const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
|
|
642
712
|
const parentNodeId = issue.node_id;
|
|
643
713
|
const created = [];
|
|
644
714
|
const generatedTexts = [];
|
|
@@ -649,6 +719,7 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
649
719
|
const taskTitle = `[TASK] ${task.title}`;
|
|
650
720
|
const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
|
|
651
721
|
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
722
|
+
ctx.createdItems.push(createdTask.number);
|
|
652
723
|
task.issue = createdTask.number;
|
|
653
724
|
created.push({ title: taskTitle, url: createdTask.url });
|
|
654
725
|
generatedTexts.push(taskTitle, taskBody);
|
|
@@ -659,7 +730,7 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
659
730
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
|
|
660
731
|
}
|
|
661
732
|
try {
|
|
662
|
-
await moveToReady(projectToken, project,
|
|
733
|
+
await moveToReady(projectToken, project, fields, createdTask.nodeId, 'Task');
|
|
663
734
|
} catch (err) {
|
|
664
735
|
console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
|
|
665
736
|
boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
|
|
@@ -710,6 +781,7 @@ async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
|
|
|
710
781
|
|
|
711
782
|
let etapaField = null;
|
|
712
783
|
let statusField = null;
|
|
784
|
+
let typeField = null;
|
|
713
785
|
if (project?.id) {
|
|
714
786
|
try {
|
|
715
787
|
etapaField = await resolveField(projectToken, project, 'Etapa');
|
|
@@ -723,14 +795,58 @@ async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
|
|
|
723
795
|
if (strict) throw new BoardUnreachableError(`campo Status não resolvido (${err.message})`);
|
|
724
796
|
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
725
797
|
}
|
|
798
|
+
// NUNCA strict: o tipo é organização, não visibilidade. Um board sem o campo
|
|
799
|
+
// "Work Item Type" continua sendo um board válido — sem Etapa, não.
|
|
800
|
+
try {
|
|
801
|
+
typeField = await resolveField(projectToken, project, 'Work Item Type');
|
|
802
|
+
} catch (err) {
|
|
803
|
+
console.warn(`Não foi possível resolver campo Work Item Type do board: ${err.message}`);
|
|
804
|
+
}
|
|
726
805
|
}
|
|
727
|
-
return { project, etapaField, statusField };
|
|
806
|
+
return { project, etapaField, statusField, typeField };
|
|
728
807
|
}
|
|
729
808
|
|
|
730
809
|
// ---------------------------------------------------------------------------
|
|
731
810
|
// Entrada
|
|
732
811
|
// ---------------------------------------------------------------------------
|
|
733
812
|
|
|
813
|
+
/**
|
|
814
|
+
* Comentário de falha do apply (função PURA).
|
|
815
|
+
*
|
|
816
|
+
* O conselho depende de UMA coisa: já existe issue criada por este run?
|
|
817
|
+
*
|
|
818
|
+
* • Não → o retry é seguro e é o que se quer: reaplique o gatilho.
|
|
819
|
+
* • Sim → o apply recomeça do zero, e o único freio é o guard de idempotência
|
|
820
|
+
* (label `spec-wave:decomposed` ou sub-issues do tipo-alvo). Se a falha foi
|
|
821
|
+
* ANTES de a label ser aplicada e o vínculo de sub-issue também não pegou,
|
|
822
|
+
* reaplicar duplica tudo o que a lista abaixo mostra. Mandar "adicione a
|
|
823
|
+
* label de novo" nesse estado — que é o que este comentário fazia sempre —
|
|
824
|
+
* é o conselho errado exatamente quando ele custa mais caro.
|
|
825
|
+
*
|
|
826
|
+
* @param {object} params
|
|
827
|
+
* @param {string} params.trigger label que disparou o run
|
|
828
|
+
* @param {string} params.message mensagem do erro
|
|
829
|
+
* @param {number[]} [params.createdItems] issues já criadas por este run
|
|
830
|
+
* @param {'apply'|'draft'} [params.mode]
|
|
831
|
+
*/
|
|
832
|
+
export function renderApplyFailureComment({ trigger, message, createdItems = [], mode = 'apply' }) {
|
|
833
|
+
const cabecalho =
|
|
834
|
+
`❌ **Falha no decompose (${mode === 'apply' ? 'aplicação' : 'rascunho'}).**\n\n` +
|
|
835
|
+
`\`\`\`\n${message}\n\`\`\`\n\n`;
|
|
836
|
+
if (createdItems.length === 0) {
|
|
837
|
+
return cabecalho +
|
|
838
|
+
`A label \`${trigger}\` foi removida para destravar o gatilho — ` +
|
|
839
|
+
'adicione-a de novo para tentar outra vez.';
|
|
840
|
+
}
|
|
841
|
+
const lista = createdItems.map(n => `#${n}`).join(', ');
|
|
842
|
+
return cabecalho +
|
|
843
|
+
`⚠️ **${createdItems.length} issue(s) JÁ foram criadas por este run:** ${lista}.\n\n` +
|
|
844
|
+
`A label \`${trigger}\` foi removida para destravar o gatilho, mas **não a reaplique sem ` +
|
|
845
|
+
'conferir**: o apply recomeça do zero e, se o guard de idempotência não tiver pegado ' +
|
|
846
|
+
`(label \`${LABEL_DECOMPOSED}\` ou sub-issues do tipo-alvo), essas issues são recriadas. ` +
|
|
847
|
+
'Complete o que falta à mão, ou apague as issues acima antes de tentar de novo.';
|
|
848
|
+
}
|
|
849
|
+
|
|
734
850
|
export async function decompose({ issueNumber, apply = false }) {
|
|
735
851
|
const token = await resolveToken();
|
|
736
852
|
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
@@ -811,6 +927,10 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
811
927
|
const ctx = {
|
|
812
928
|
token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
|
|
813
929
|
root, runMode, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
|
|
930
|
+
// Números das issues já criadas por este run. Compartilhado por referência
|
|
931
|
+
// com o applyCtx: o catch externo precisa saber se houve criação para não
|
|
932
|
+
// aconselhar um retry que duplicaria itens.
|
|
933
|
+
createdItems: [],
|
|
814
934
|
escalationModel: config?.ai?.escalationModel || null,
|
|
815
935
|
maxCritiqueAttempts:
|
|
816
936
|
Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
|
|
@@ -827,10 +947,9 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
827
947
|
if (!err.blocked) {
|
|
828
948
|
await removeLabel(token, owner, repo, number, trigger).catch(() => {});
|
|
829
949
|
await commentOnIssue(token, owner, repo, number,
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
'adicione-a de novo para tentar outra vez.'
|
|
950
|
+
renderApplyFailureComment({
|
|
951
|
+
trigger, message: err.message, createdItems: ctx.createdItems, mode,
|
|
952
|
+
})
|
|
834
953
|
).catch(() => {});
|
|
835
954
|
}
|
|
836
955
|
throw err;
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -16,7 +16,8 @@ import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
|
|
|
16
16
|
import {
|
|
17
17
|
CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
|
|
18
18
|
RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
19
|
-
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS,
|
|
19
|
+
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
|
|
20
|
+
modelLabels,
|
|
20
21
|
} from '../config.mjs';
|
|
21
22
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
22
23
|
import {
|
|
@@ -284,7 +285,10 @@ async function checkConfig(ctx) {
|
|
|
284
285
|
* @param {object} params
|
|
285
286
|
* @param {string[]} [params.boardStages] opções reais de Etapa no project
|
|
286
287
|
* @param {string[]} [params.repoLabels] nomes das labels do repo
|
|
287
|
-
* @returns {{ unknownStages: string[], missingStages: string[], orphanLabels: string[],
|
|
288
|
+
* @returns {{ unknownStages: string[], missingStages: string[], orphanLabels: string[],
|
|
289
|
+
* missingLabels: string[], missingModelLabels: string[] }}
|
|
290
|
+
* `missingLabels` traz só as do fluxo; as de modelo saem separadas
|
|
291
|
+
* porque a orientação é outra (ver o uso em checkBoardHygiene).
|
|
288
292
|
*/
|
|
289
293
|
/**
|
|
290
294
|
* Os ids de opção do .spec-wave.json batem com os do Project? (função PURA)
|
|
@@ -314,6 +318,35 @@ export function inspectStageIds({ configOptions = null, boardOptions = null } =
|
|
|
314
318
|
return { staleIds, checked: true };
|
|
315
319
|
}
|
|
316
320
|
|
|
321
|
+
/**
|
|
322
|
+
* O que falta em `ai.modelAliases` para chegar ao conjunto recomendado (função PURA).
|
|
323
|
+
*
|
|
324
|
+
* Sem apelido nenhum, a label `spec-wave:model:<apelido>` não resolve nada: o
|
|
325
|
+
* override por issue — o jeito de reprocessar UMA Feature difícil num modelo
|
|
326
|
+
* mais forte sem mexer na config do repo inteiro — fica inerte, e o doctor só
|
|
327
|
+
* dizia que estava inerte, sem dizer o que colar para destravá-lo.
|
|
328
|
+
*
|
|
329
|
+
* Sugere o MERGE (configurado + faltante), nunca a substituição: apelido já
|
|
330
|
+
* configurado pode estar em uso numa issue aberta, e mandar sobrescrever o
|
|
331
|
+
* bloco quebraria essa issue em silêncio.
|
|
332
|
+
*
|
|
333
|
+
* @param {object} params
|
|
334
|
+
* @param {string} [params.provider] valor de `ai.provider`
|
|
335
|
+
* @param {object|null} [params.aliases] bloco `ai.modelAliases` atual
|
|
336
|
+
* @returns {{ missing: Array<{alias: string, model: string}>, snippet: string }}
|
|
337
|
+
*/
|
|
338
|
+
export function suggestModelAliases({ provider = undefined, aliases = null } = {}) {
|
|
339
|
+
const recommended = recommendedModelAliases(provider);
|
|
340
|
+
const have = new Set(Object.keys(aliases || {}));
|
|
341
|
+
const missing = Object.entries(recommended)
|
|
342
|
+
.filter(([alias]) => !have.has(alias))
|
|
343
|
+
.map(([alias, model]) => ({ alias, model }));
|
|
344
|
+
if (missing.length === 0) return { missing, snippet: '' };
|
|
345
|
+
const merged = { ...(aliases || {}) };
|
|
346
|
+
for (const { alias, model } of missing) merged[alias] = model;
|
|
347
|
+
return { missing, snippet: `"modelAliases": ${JSON.stringify(merged, null, 2)}` };
|
|
348
|
+
}
|
|
349
|
+
|
|
317
350
|
export function inspectBoardHygiene({ boardStages = null, repoLabels = null, modelAliases = null } = {}) {
|
|
318
351
|
const canonical = STATUS_OPTIONS.map(s => s.name);
|
|
319
352
|
const known = new Set(canonical);
|
|
@@ -342,10 +375,18 @@ export function inspectBoardHygiene({ boardStages = null, repoLabels = null, mod
|
|
|
342
375
|
? repoLabels.filter(n => n.startsWith('spec-wave:') && !knownLabels.has(n)
|
|
343
376
|
&& (modelAliases !== null || !n.startsWith(MODEL_LABEL_PREFIX)))
|
|
344
377
|
: [];
|
|
345
|
-
|
|
346
|
-
|
|
378
|
+
// Ausentes saem em duas listas: uma label do fluxo que falta é um repo
|
|
379
|
+
// desatualizado; uma `spec-wave:model:<apelido>` que falta é um apelido
|
|
380
|
+
// configurado que NINGUÉM consegue usar — a label não existe para ser aplicada
|
|
381
|
+
// na issue, então o override de modelo por execução está morto na origem.
|
|
382
|
+
const allMissing = repoLabels
|
|
383
|
+
? wantedLabels.filter(l => !repoLabels.includes(l.name)).map(l => l.name)
|
|
347
384
|
: [];
|
|
348
|
-
|
|
385
|
+
const missingModelLabels = allMissing.filter(n => n.startsWith(MODEL_LABEL_PREFIX));
|
|
386
|
+
const missingLabels = allMissing.filter(n => !n.startsWith(MODEL_LABEL_PREFIX));
|
|
387
|
+
return {
|
|
388
|
+
unknownStages, retiredStages, missingStages, orphanLabels, missingLabels, missingModelLabels,
|
|
389
|
+
};
|
|
349
390
|
}
|
|
350
391
|
|
|
351
392
|
/**
|
|
@@ -407,7 +448,7 @@ async function checkBoardHygiene(ctx) {
|
|
|
407
448
|
}
|
|
408
449
|
}
|
|
409
450
|
|
|
410
|
-
const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels } =
|
|
451
|
+
const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels, missingModelLabels } =
|
|
411
452
|
inspectBoardHygiene({ boardStages, repoLabels, modelAliases: cfg?.ai?.modelAliases ?? null });
|
|
412
453
|
|
|
413
454
|
const notes = [];
|
|
@@ -481,8 +522,32 @@ async function checkBoardHygiene(ctx) {
|
|
|
481
522
|
status = 'warn';
|
|
482
523
|
notes.push(`Labels do fluxo ausentes no repo: ${missingLabels.join(', ')} — rode \`update\`.`);
|
|
483
524
|
}
|
|
484
|
-
|
|
485
|
-
|
|
525
|
+
// Apelido configurado sem label é uma promessa que o repo não cumpre: a skill
|
|
526
|
+
// manda aplicar `spec-wave:model:<apelido>` na issue e o `gh issue edit` falha
|
|
527
|
+
// com "label not found" — ou, pior, cria a label sem cor nem descrição.
|
|
528
|
+
if (missingModelLabels.length > 0) {
|
|
529
|
+
status = 'warn';
|
|
530
|
+
const specs = modelLabels(cfg?.ai?.modelAliases);
|
|
531
|
+
const exemplo = specs.find(l => l.name === missingModelLabels[0]);
|
|
532
|
+
notes.push(
|
|
533
|
+
`Labels de modelo ausentes no repo: ${missingModelLabels.join(', ')} — o apelido está em ` +
|
|
534
|
+
'`ai.modelAliases`, mas a label não existe, então não há como aplicá-la numa issue e o ' +
|
|
535
|
+
'override de modelo por execução fica indisponível. Rode ' +
|
|
536
|
+
'`npx @spec-wave/cli@latest update` para criar todas' +
|
|
537
|
+
(exemplo
|
|
538
|
+
? `, ou uma a uma: \`gh label create "${exemplo.name}" --color ${exemplo.color} ` +
|
|
539
|
+
`--description "${exemplo.description}"\`.`
|
|
540
|
+
: '.')
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (orphanLabels.length === 0 && missingLabels.length === 0 && missingModelLabels.length === 0) {
|
|
544
|
+
// Conta as de modelo à parte — dizer só "N do fluxo" omitia que as labels
|
|
545
|
+
// de apelido também foram conferidas.
|
|
546
|
+
const modelCount = modelLabels(cfg?.ai?.modelAliases).length;
|
|
547
|
+
notes.push(
|
|
548
|
+
`Labels: ${ALL_LABELS.length} do fluxo` +
|
|
549
|
+
`${modelCount > 0 ? ` + ${modelCount} de modelo` : ''} presentes, nenhuma descontinuada.`
|
|
550
|
+
);
|
|
486
551
|
}
|
|
487
552
|
} else {
|
|
488
553
|
notes.push('Labels do repo não verificáveis agora (sem token ou sem acesso).');
|
|
@@ -595,11 +660,19 @@ async function checkAi(ctx) {
|
|
|
595
660
|
const aliases = fileAi.modelAliases && Object.keys(fileAi.modelAliases).length > 0
|
|
596
661
|
? fileAi.modelAliases
|
|
597
662
|
: null;
|
|
663
|
+
const suggestion = suggestModelAliases({ provider: provider.value, aliases });
|
|
664
|
+
// Onde colar e o que rodar depois: o bloco sozinho não cria as labels, e sem
|
|
665
|
+
// a label criada no repo o override continua sem existir para quem abre a issue.
|
|
666
|
+
const howTo = (
|
|
667
|
+
`Cole no bloco \`ai\` do ${CONFIG_FILE}, commite e rode ` +
|
|
668
|
+
'`npx @spec-wave/cli@latest update` para criar as labels correspondentes:'
|
|
669
|
+
);
|
|
598
670
|
if (!aliases) {
|
|
599
671
|
notes.push(
|
|
600
672
|
`Apelidos de modelo: nenhum (\`ai.modelAliases\` ausente) — labels ` +
|
|
601
673
|
`\`${MODEL_LABEL_PREFIX}<apelido>\` serão ignoradas.`
|
|
602
674
|
);
|
|
675
|
+
notes.push(`Sugestão de apelidos para o provider ${provider.value}. ${howTo}\n${suggestion.snippet}`);
|
|
603
676
|
} else {
|
|
604
677
|
notes.push(
|
|
605
678
|
`Apelidos de modelo (\`${MODEL_LABEL_PREFIX}<apelido>\`): ` +
|
|
@@ -616,6 +689,13 @@ async function checkAi(ctx) {
|
|
|
616
689
|
`${wrongShape.map(([a, m]) => `${a}=${m}`).join(', ')}.`
|
|
617
690
|
);
|
|
618
691
|
}
|
|
692
|
+
if (suggestion.missing.length > 0) {
|
|
693
|
+
notes.push(
|
|
694
|
+
`Apelidos recomendados ainda não configurados: ` +
|
|
695
|
+
`${suggestion.missing.map(({ alias, model }) => `${alias}=${model}`).join(', ')}. ` +
|
|
696
|
+
`${howTo}\n${suggestion.snippet}`
|
|
697
|
+
);
|
|
698
|
+
}
|
|
619
699
|
}
|
|
620
700
|
|
|
621
701
|
// Saída estruturada da crítica: sem structured output confiável, a validação
|
package/src/commands/move.mjs
CHANGED
|
@@ -134,11 +134,16 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
|
|
|
134
134
|
}
|
|
135
135
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
136
136
|
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
137
|
+
// O `move` também REPARA o Work Item Type quando ele está vazio (o `apply` não
|
|
138
|
+
// o escrevia, então há board com dezenas de itens sem tipo). Só no vazio, e
|
|
139
|
+
// mesmo quando a Etapa não avança — ver ensureWorkItemType.
|
|
140
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
137
141
|
|
|
138
142
|
let moved;
|
|
139
143
|
try {
|
|
140
144
|
moved = await advanceToStage(
|
|
141
|
-
token, project, etapaField, statusField, issue.node_id, stage, status
|
|
145
|
+
token, project, etapaField, statusField, issue.node_id, stage, status,
|
|
146
|
+
{ typeField, itemType: type });
|
|
142
147
|
} catch (err) {
|
|
143
148
|
p.log.error(`Falha ao atualizar o board: ${err.message}`);
|
|
144
149
|
process.exitCode = 1;
|
package/src/commands/qa.mjs
CHANGED
|
@@ -73,6 +73,9 @@ export async function qa({ prNumber }) {
|
|
|
73
73
|
|
|
74
74
|
const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
|
|
75
75
|
const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
|
|
76
|
+
// Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
|
|
77
|
+
// Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
|
|
78
|
+
const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
|
|
76
79
|
|
|
77
80
|
const seen = new Set();
|
|
78
81
|
const updated = [];
|
|
@@ -86,7 +89,8 @@ export async function qa({ prNumber }) {
|
|
|
86
89
|
seen.add(issue.number);
|
|
87
90
|
try {
|
|
88
91
|
const moved = await advanceToStage(
|
|
89
|
-
projectToken, project, etapaField, statusField, issue.node_id, QA_STAGE, TODO_STATUS
|
|
92
|
+
projectToken, project, etapaField, statusField, issue.node_id, QA_STAGE, TODO_STATUS,
|
|
93
|
+
{ typeField, itemType: 'Bug' });
|
|
90
94
|
if (moved) {
|
|
91
95
|
updated.push(`#${issue.number} ${issue.title} (bug)`);
|
|
92
96
|
console.log(`Bug #${issue.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
@@ -104,7 +108,9 @@ export async function qa({ prNumber }) {
|
|
|
104
108
|
seen.add(feature.number);
|
|
105
109
|
try {
|
|
106
110
|
// Avança para "🧪 QA" e reinicia o Status em "Todo" (nunca retrocede).
|
|
107
|
-
const moved = await advanceToStage(
|
|
111
|
+
const moved = await advanceToStage(
|
|
112
|
+
projectToken, project, etapaField, statusField, feature.node_id, QA_STAGE, TODO_STATUS,
|
|
113
|
+
{ typeField, itemType: 'Feature' });
|
|
108
114
|
if (moved) {
|
|
109
115
|
updated.push(`#${feature.number} ${feature.title}`);
|
|
110
116
|
console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
@@ -164,6 +164,10 @@ export async function repairStage(issuesArg, stageArg, options = {}) {
|
|
|
164
164
|
}
|
|
165
165
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
166
166
|
const statusField = status ? await resolveField(token, project, 'Status').catch(() => null) : null;
|
|
167
|
+
// Reparar a Etapa e deixar o item sem tipo seria consertar metade: os mesmos
|
|
168
|
+
// itens que perderam a Etapa nasceram do apply, que também não escrevia o
|
|
169
|
+
// Work Item Type. Só preenche o vazio (ver ensureWorkItemType).
|
|
170
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
167
171
|
|
|
168
172
|
// Lê tudo ANTES de escrever qualquer coisa: reparo parcial por issue
|
|
169
173
|
// inexistente no meio da lista é o tipo de surpresa que ninguém quer aqui.
|
|
@@ -216,7 +220,8 @@ export async function repairStage(issuesArg, stageArg, options = {}) {
|
|
|
216
220
|
for (const alvo of alvos) {
|
|
217
221
|
try {
|
|
218
222
|
const { from } = await setItemStage(
|
|
219
|
-
token, project, etapaField, statusField, alvo.issue.node_id, stage, status
|
|
223
|
+
token, project, etapaField, statusField, alvo.issue.node_id, stage, status,
|
|
224
|
+
{ typeField, itemType: alvo.type });
|
|
220
225
|
reparados += 1;
|
|
221
226
|
p.log.success(`#${alvo.number}: ${from || '(sem etapa)'} → ${chalk.bold(stage)}${status ? ` / ${status}` : ''}`);
|
|
222
227
|
await commentOnIssue(token, owner, repo, alvo.number, renderRepairComment({
|
package/src/commands/story.mjs
CHANGED
|
@@ -81,11 +81,16 @@ export async function story({ action, issue: issueArg }) {
|
|
|
81
81
|
}
|
|
82
82
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
83
83
|
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
84
|
+
// Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
|
|
85
|
+
// Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
|
|
86
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
84
87
|
|
|
85
88
|
// 3. Avança para Code Review (Status reinicia em Todo ao trocar de etapa).
|
|
86
89
|
let moved;
|
|
87
90
|
try {
|
|
88
|
-
moved = await advanceToStage(
|
|
91
|
+
moved = await advanceToStage(
|
|
92
|
+
token, project, etapaField, statusField, issue.node_id, STAGE_CODE_REVIEW, PROGRESS_TODO,
|
|
93
|
+
{ typeField, itemType: type });
|
|
89
94
|
} catch (err) {
|
|
90
95
|
p.log.error(`Falha ao atualizar o board: ${err.message}`);
|
|
91
96
|
process.exitCode = 1;
|
package/src/commands/task.mjs
CHANGED
|
@@ -102,6 +102,9 @@ export async function task({ action, issue: issueArg }) {
|
|
|
102
102
|
}
|
|
103
103
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
104
104
|
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
105
|
+
// Repara o Work Item Type vazio de passagem (o apply nunca o escreveu).
|
|
106
|
+
// Só preenche o vazio e nunca derruba o movimento — ver ensureWorkItemType.
|
|
107
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
105
108
|
|
|
106
109
|
// 3. start: garante que nenhuma task irmã (mesma Story) está In Progress.
|
|
107
110
|
if (action === 'start') {
|
|
@@ -146,7 +149,9 @@ export async function task({ action, issue: issueArg }) {
|
|
|
146
149
|
|
|
147
150
|
let moved;
|
|
148
151
|
try {
|
|
149
|
-
moved = await advanceToStage(
|
|
152
|
+
moved = await advanceToStage(
|
|
153
|
+
token, project, etapaField, statusField, issue.node_id, target.stage, target.status,
|
|
154
|
+
{ typeField, itemType: type });
|
|
150
155
|
} catch (err) {
|
|
151
156
|
p.log.error(`Falha ao atualizar o board: ${err.message}`);
|
|
152
157
|
process.exitCode = 1;
|
package/src/commands/triage.mjs
CHANGED
|
@@ -163,8 +163,11 @@ async function moveToReady({ token, issueNumber, issue, root }) {
|
|
|
163
163
|
try {
|
|
164
164
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
165
165
|
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
166
|
+
// Triagem só trata Bug — o tipo aqui é certeza, não inferência de título.
|
|
167
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
166
168
|
const moved = await advanceToStage(
|
|
167
|
-
token, project, etapaField, statusField, issue.node_id, STAGE_READY, PROGRESS_TODO
|
|
169
|
+
token, project, etapaField, statusField, issue.node_id, STAGE_READY, PROGRESS_TODO,
|
|
170
|
+
{ typeField, itemType: 'Bug' });
|
|
168
171
|
if (!moved) {
|
|
169
172
|
p.log.info(`#${issueNumber} já está em ${STAGE_READY} ou etapa posterior — mantido.`);
|
|
170
173
|
}
|
package/src/config.mjs
CHANGED
|
@@ -43,6 +43,44 @@ export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug'];
|
|
|
43
43
|
// repositório inteiro. Resolvido por resolveModelLabel() em src/lib/claude.mjs.
|
|
44
44
|
export const MODEL_LABEL_PREFIX = 'spec-wave:model:';
|
|
45
45
|
|
|
46
|
+
// Apelidos de modelo SUGERIDOS pelo `doctor` quando o repo não tem
|
|
47
|
+
// `ai.modelAliases` (ou tem só parte deles). São os mesmos que o repo do
|
|
48
|
+
// spec-wave usa: um por família de modelo, para que a label
|
|
49
|
+
// `spec-wave:model:<apelido>` cubra "roda de novo num modelo mais forte" sem
|
|
50
|
+
// obrigar cada projeto a inventar a própria nomenclatura.
|
|
51
|
+
//
|
|
52
|
+
// O mapa é POR PROVIDER porque o formato do id não é o mesmo: a OpenRouter usa
|
|
53
|
+
// slug com "/" (anthropic/claude-opus-5) e a API da Anthropic usa o id nu
|
|
54
|
+
// (claude-opus-5). Sugerir o formato errado geraria exatamente o aviso de
|
|
55
|
+
// "apelido com formato incompatível" que o doctor emite na linha seguinte.
|
|
56
|
+
export const RECOMMENDED_MODEL_ALIASES = {
|
|
57
|
+
openrouter: {
|
|
58
|
+
opus: 'anthropic/claude-opus-5',
|
|
59
|
+
sonnet: 'anthropic/claude-sonnet-5',
|
|
60
|
+
haiku: 'anthropic/claude-haiku-4.5',
|
|
61
|
+
glm: 'z-ai/glm-5.2',
|
|
62
|
+
'kimi-k3': 'moonshotai/kimi-k3',
|
|
63
|
+
'gpt-sol': 'openai/gpt-5.6-sol',
|
|
64
|
+
'gpt-luna': 'openai/gpt-5.6-luna',
|
|
65
|
+
},
|
|
66
|
+
// Só os modelos da própria Anthropic — os demais não existem nessa API.
|
|
67
|
+
anthropic: {
|
|
68
|
+
opus: 'claude-opus-5',
|
|
69
|
+
sonnet: 'claude-sonnet-5',
|
|
70
|
+
haiku: 'claude-haiku-4-5',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Apelidos recomendados para um provider (função PURA).
|
|
76
|
+
*
|
|
77
|
+
* @param {string} [provider] valor de `ai.provider`
|
|
78
|
+
* @returns {Record<string,string>}
|
|
79
|
+
*/
|
|
80
|
+
export function recommendedModelAliases(provider) {
|
|
81
|
+
return RECOMMENDED_MODEL_ALIASES[provider] || RECOMMENDED_MODEL_ALIASES[DEFAULT_PROVIDER];
|
|
82
|
+
}
|
|
83
|
+
|
|
46
84
|
// Quantas críticas seguidas podem reprovar a mesma issue antes de exigir revisão
|
|
47
85
|
// humana. Na última tentativa a IA NÃO é chamada: o fluxo aplica
|
|
48
86
|
// `spec-wave:needs-human` e para. Ajustável por `ai.maxCritiqueAttempts`.
|
package/src/lib/board.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Extraídos de code-review.mjs/qa.mjs para uso também pelos comandos de CLI
|
|
3
3
|
// (task/story/order). Ver a distinção Etapa × Status em config.mjs.
|
|
4
4
|
import { addProjectItem, setItemSingleSelect, getSingleSelectField, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
5
|
-
import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS } from '../config.mjs';
|
|
5
|
+
import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS, WORK_ITEM_TYPES } from '../config.mjs';
|
|
6
6
|
import { loadConfig } from './project-root.mjs';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -116,6 +116,45 @@ export function resolveStageName(input) {
|
|
|
116
116
|
return { stage: null, error: `Etapa "${input}" não existe. Use uma destas: ${list()}.` };
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Preenche o "Work Item Type" do item quando ele está VAZIO (best-effort).
|
|
121
|
+
*
|
|
122
|
+
* O campo existe no board desde o `init`, mas só o comando `issue` o escrevia:
|
|
123
|
+
* toda issue nascida do `decompose --apply` (dezenas por Feature) e todo item
|
|
124
|
+
* que entrou no board por outro caminho ficavam com o tipo em branco, e as
|
|
125
|
+
* telas que agrupam por Work Item Type os perdiam. Não é configuração faltando:
|
|
126
|
+
* os mesmos ids, project e token escrevem o campo sem falha quando alguém o faz
|
|
127
|
+
* à mão. O `move` também não o reparava, então nem passar pelo fluxo corrigia.
|
|
128
|
+
*
|
|
129
|
+
* Escreve só no vazio, de propósito: o tipo do título ("[STORY] …") é uma
|
|
130
|
+
* inferência, e sobrescrever um valor que um humano ajustou no board seria
|
|
131
|
+
* trocar um dado bom por um palpite. Por isso também roda ANTES do guard de
|
|
132
|
+
* avanço de Etapa — item já adiante não avança, mas continua merecendo o reparo.
|
|
133
|
+
*
|
|
134
|
+
* Nunca lança: campo ausente, opção inexistente ou falha de rede viram `false`.
|
|
135
|
+
* O tipo é informação de organização; derrubar por causa dele um comando que
|
|
136
|
+
* moveu a Etapa seria trocar o essencial pelo acessório.
|
|
137
|
+
*
|
|
138
|
+
* @returns {Promise<boolean>} true se escreveu o campo agora
|
|
139
|
+
*/
|
|
140
|
+
export async function ensureWorkItemType(token, project, typeField, itemId, itemType) {
|
|
141
|
+
if (!typeField?.id || !itemType) return false;
|
|
142
|
+
if (!WORK_ITEM_TYPES.includes(itemType)) return false;
|
|
143
|
+
const optionId = typeField.options?.[itemType];
|
|
144
|
+
if (!optionId) return false;
|
|
145
|
+
try {
|
|
146
|
+
const current = await getItemSingleSelectValue(token, itemId, typeField.id);
|
|
147
|
+
if (current) return false; // já tem tipo — nunca sobrescreve
|
|
148
|
+
await setItemSingleSelect(token, project.id, itemId, typeField.id, optionId);
|
|
149
|
+
return true;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
// Avisa em vez de calar: foi o silêncio que deixou 120 itens sem tipo por
|
|
152
|
+
// seis features seguidas sem ninguém perceber.
|
|
153
|
+
console.warn(`Work Item Type "${itemType}" não pôde ser escrito: ${err.message}`);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
119
158
|
/**
|
|
120
159
|
* Avança um item do board para `targetStage` (Etapa) e define o Status para
|
|
121
160
|
* `targetStatus`. Uma issue só AVANÇA: se já estiver em `targetStage` ou em uma
|
|
@@ -128,10 +167,18 @@ export function resolveStageName(input) {
|
|
|
128
167
|
* @param {string} nodeId node id da issue
|
|
129
168
|
* @param {string} targetStage nome da etapa de destino
|
|
130
169
|
* @param {string} targetStatus valor do Status (Todo/In Progress/Done)
|
|
170
|
+
* @param {object} [opts]
|
|
171
|
+
* @param {{id,options}|null} [opts.typeField] campo "Work Item Type" (ver resolveField)
|
|
172
|
+
* @param {string} [opts.itemType] tipo do item ('Story', 'Task', …) — escrito
|
|
173
|
+
* apenas se o campo estiver vazio (ver ensureWorkItemType)
|
|
131
174
|
* @returns {Promise<boolean>} true se avançou; false se já estava adiante
|
|
132
175
|
*/
|
|
133
|
-
export async function advanceToStage(
|
|
176
|
+
export async function advanceToStage(
|
|
177
|
+
token, project, etapaField, statusField, nodeId, targetStage, targetStatus, opts = {}
|
|
178
|
+
) {
|
|
134
179
|
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
180
|
+
// Antes do guard de avanço: item que não avança também precisa do reparo.
|
|
181
|
+
await ensureWorkItemType(token, project, opts.typeField, itemId, opts.itemType);
|
|
135
182
|
|
|
136
183
|
if (etapaField?.id && targetStage) {
|
|
137
184
|
// Nunca retroceder — a decisão vive em shouldAdvanceStage (pura, testada).
|
|
@@ -167,9 +214,12 @@ export async function advanceToStage(token, project, etapaField, statusField, no
|
|
|
167
214
|
* @param {string} nodeId node id da issue
|
|
168
215
|
* @param {string} targetStage etapa de destino (precisa existir em STAGE_ORDER)
|
|
169
216
|
* @param {string} [targetStatus] valor do Status; omitido = não mexe no Status
|
|
217
|
+
* @param {object} [opts] mesmo `{ typeField, itemType }` de advanceToStage
|
|
170
218
|
* @returns {Promise<{ from: string|null }>} etapa em que o item estava
|
|
171
219
|
*/
|
|
172
|
-
export async function setItemStage(
|
|
220
|
+
export async function setItemStage(
|
|
221
|
+
token, project, etapaField, statusField, nodeId, targetStage, targetStatus, opts = {}
|
|
222
|
+
) {
|
|
173
223
|
if (!etapaField?.id) throw new Error('Campo "Etapa" não encontrado no Project — nada a reparar.');
|
|
174
224
|
if (STAGE_ORDER.indexOf(targetStage) === -1) {
|
|
175
225
|
throw new Error(`Etapa "${targetStage}" não faz parte do fluxo (${STAGE_ORDER.join(' → ')}).`);
|
|
@@ -182,6 +232,7 @@ export async function setItemStage(token, project, etapaField, statusField, node
|
|
|
182
232
|
);
|
|
183
233
|
}
|
|
184
234
|
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
235
|
+
await ensureWorkItemType(token, project, opts.typeField, itemId, opts.itemType);
|
|
185
236
|
const from = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
186
237
|
await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
187
238
|
if (statusField?.id && targetStatus) {
|
package/src/lib/dependencies.mjs
CHANGED
|
@@ -90,3 +90,52 @@ export function orderStories(stories) {
|
|
|
90
90
|
.sort((a, b) => a - b);
|
|
91
91
|
return { order, cycle };
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Comentário com a ordem de execução das Stories (função PURA).
|
|
96
|
+
*
|
|
97
|
+
* A decomposição já comentava a lista das Stories criadas, mas na ordem de
|
|
98
|
+
* criação e sem dizer o que depende de quê — para saber por onde começar, o dev
|
|
99
|
+
* tinha de rodar `spec-wave order` no terminal. A informação existia fora da
|
|
100
|
+
* issue, que é onde o time olha.
|
|
101
|
+
*
|
|
102
|
+
* Sobre a expectativa: como `normalizeDependsOn` só aceita dependências para
|
|
103
|
+
* trás, a ordem de criação já é topologicamente válida e `orderStories` devolve
|
|
104
|
+
* a mesma sequência. O ganho aqui não é reordenar — é tornar a cadeia visível,
|
|
105
|
+
* com os números clicáveis. Passar por `orderStories` mesmo assim mantém uma só
|
|
106
|
+
* definição de "ordem de execução" (a que o comando `order` usa) e dá a
|
|
107
|
+
* detecção de ciclo de graça.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} params
|
|
110
|
+
* @param {number[]} params.sorted números das Stories em ordem topológica
|
|
111
|
+
* @param {number[]} [params.cycle] Stories que ficaram fora da ordem (ciclo)
|
|
112
|
+
* @param {Map<number,{title: string, dependsOn: number[]}>} params.byNumber
|
|
113
|
+
* @returns {string} markdown
|
|
114
|
+
*/
|
|
115
|
+
export function renderOrderComment({ sorted, cycle = [], byNumber }) {
|
|
116
|
+
const linha = (n, i) => {
|
|
117
|
+
const s = byNumber.get(n);
|
|
118
|
+
const deps = s?.dependsOn?.length > 0
|
|
119
|
+
? ` — depende de ${s.dependsOn.map(d => `#${d}`).join(', ')}`
|
|
120
|
+
: '';
|
|
121
|
+
return `${i + 1}. #${n} ${s?.title ?? ''}${deps}`.trimEnd();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const partes = [
|
|
125
|
+
'📋 **Ordem de execução das Stories**',
|
|
126
|
+
sorted.map(linha).join('\n'),
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
// Espelha o aviso que o comando `order` dá no terminal. Estruturalmente o
|
|
130
|
+
// parser impede ciclo (dependência só para trás), mas se a validação relaxar,
|
|
131
|
+
// silenciar aqui esconderia Stories do comentário — e ninguém perceberia.
|
|
132
|
+
if (cycle.length > 0) {
|
|
133
|
+
partes.push(
|
|
134
|
+
`⚠️ **Ciclo de dependências:** ${cycle.map(n => `#${n}`).join(', ')} ` +
|
|
135
|
+
'ficaram FORA da ordem acima. Corrija as linhas "Depende de" (ou as relações ' +
|
|
136
|
+
'blocked_by) dessas issues.'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return partes.join('\n\n');
|
|
141
|
+
}
|
|
@@ -22,8 +22,8 @@ import {
|
|
|
22
22
|
* story?: {nodeId:string,number:number}|null,
|
|
23
23
|
* bug?: {nodeId:string,number:number}|null,
|
|
24
24
|
* tasks?: Array<{nodeId?:string,number:number}> }} refs
|
|
25
|
-
* @returns {Array<{nodeId:string, label:string,
|
|
26
|
-
* statusFallback:boolean}>}
|
|
25
|
+
* @returns {Array<{nodeId:string, label:string, type:string, stage:string,
|
|
26
|
+
* status:string, statusFallback:boolean}>}
|
|
27
27
|
* statusFallback: se a Etapa já estiver adiante (advanceToStage devolve
|
|
28
28
|
* false), ainda assim alinhar o Status — usado nos movimentos de início
|
|
29
29
|
* (ex.: Feature já em Desenvolvimento volta a mostrar In Progress).
|
|
@@ -37,37 +37,37 @@ export function planBoardMoves(phase, {
|
|
|
37
37
|
const moves = [];
|
|
38
38
|
if (phase === 'start') {
|
|
39
39
|
if (feature?.nodeId) {
|
|
40
|
-
moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`,
|
|
40
|
+
moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`, type: 'Feature',
|
|
41
41
|
stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS, statusFallback: true });
|
|
42
42
|
}
|
|
43
43
|
if (story?.nodeId) {
|
|
44
|
-
moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`,
|
|
44
|
+
moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`, type: 'Story',
|
|
45
45
|
stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS, statusFallback: true });
|
|
46
46
|
}
|
|
47
47
|
// Bug é folha e não tem Feature-pai a arrastar: um defeito em correção não
|
|
48
48
|
// deve puxar a Feature inteira de volta para Desenvolvimento.
|
|
49
49
|
if (bug?.nodeId) {
|
|
50
|
-
moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`,
|
|
50
|
+
moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`, type: 'Bug',
|
|
51
51
|
stage: STAGE_DEVELOPMENT, status: PROGRESS_IN_PROGRESS, statusFallback: true });
|
|
52
52
|
}
|
|
53
53
|
for (const t of tasks) {
|
|
54
54
|
if (!t?.nodeId) continue;
|
|
55
|
-
moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`,
|
|
55
|
+
moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`, type: 'Task',
|
|
56
56
|
stage: STAGE_DEVELOPMENT, status: tasksStartStatus,
|
|
57
57
|
statusFallback: tasksStartStatus === PROGRESS_IN_PROGRESS });
|
|
58
58
|
}
|
|
59
59
|
} else if (phase === 'success') {
|
|
60
60
|
for (const t of tasks) {
|
|
61
61
|
if (!t?.nodeId) continue;
|
|
62
|
-
moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`,
|
|
62
|
+
moves.push({ nodeId: t.nodeId, label: `Task #${t.number}`, type: 'Task',
|
|
63
63
|
stage: STAGE_DONE, status: PROGRESS_DONE, statusFallback: false });
|
|
64
64
|
}
|
|
65
65
|
if (story?.nodeId) {
|
|
66
|
-
moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`,
|
|
66
|
+
moves.push({ nodeId: story.nodeId, label: `Story #${story.number}`, type: 'Story',
|
|
67
67
|
stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
|
|
68
68
|
}
|
|
69
69
|
if (bug?.nodeId) {
|
|
70
|
-
moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`,
|
|
70
|
+
moves.push({ nodeId: bug.nodeId, label: `Bug #${bug.number}`, type: 'Bug',
|
|
71
71
|
stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
|
|
72
72
|
}
|
|
73
73
|
// Feature: só o modo Feature passa `feature` aqui, e só depois de TODAS as
|
|
@@ -77,7 +77,7 @@ export function planBoardMoves(phase, {
|
|
|
77
77
|
// num branch único e não abre PR), a Feature ficava presa em
|
|
78
78
|
// Desenvolvimento com todas as Stories já em Code Review.
|
|
79
79
|
if (feature?.nodeId) {
|
|
80
|
-
moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`,
|
|
80
|
+
moves.push({ nodeId: feature.nodeId, label: `Feature #${feature.number}`, type: 'Feature',
|
|
81
81
|
stage: STAGE_CODE_REVIEW, status: PROGRESS_TODO, statusFallback: false });
|
|
82
82
|
}
|
|
83
83
|
}
|
|
@@ -113,6 +113,9 @@ export async function applyBoardMoves({
|
|
|
113
113
|
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
114
114
|
const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
|
|
115
115
|
const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
|
|
116
|
+
// Repara o Work Item Type vazio de passagem — o tipo de cada movimento é
|
|
117
|
+
// conhecido por construção (planBoardMoves). Ver ensureWorkItemType.
|
|
118
|
+
const typeField = await resolveField(projectToken, project, 'Work Item Type').catch(() => null);
|
|
116
119
|
if (!etapaField?.id) {
|
|
117
120
|
log.warn('board: campo Etapa não resolvido — Etapas não atualizadas.');
|
|
118
121
|
return;
|
|
@@ -120,7 +123,8 @@ export async function applyBoardMoves({
|
|
|
120
123
|
for (const m of moves) {
|
|
121
124
|
try {
|
|
122
125
|
const advanced = await advanceToStage(
|
|
123
|
-
projectToken, project, etapaField, statusField, m.nodeId, m.stage, m.status
|
|
126
|
+
projectToken, project, etapaField, statusField, m.nodeId, m.stage, m.status,
|
|
127
|
+
{ typeField, itemType: m.type });
|
|
124
128
|
if (advanced) {
|
|
125
129
|
log.info(`board: ${m.label} → ${m.stage} (${m.status})`);
|
|
126
130
|
} else if (m.statusFallback && statusField?.id) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spec-wave",
|
|
3
3
|
"displayName": "Spec Wave",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.21.0",
|
|
5
5
|
"description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Astratech",
|
|
@@ -27,8 +27,12 @@ jobs:
|
|
|
27
27
|
- uses: actions/setup-node@v4
|
|
28
28
|
with:
|
|
29
29
|
node-version: '24'
|
|
30
|
+
|
|
31
|
+
- name: Instala a CLI
|
|
32
|
+
run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
|
|
33
|
+
|
|
30
34
|
- id: resolve
|
|
31
|
-
run:
|
|
35
|
+
run: spec-wave code-review --pr-number ${{ github.event.pull_request.number }} --resolve-only
|
|
32
36
|
env:
|
|
33
37
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
34
38
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
@@ -56,8 +60,11 @@ jobs:
|
|
|
56
60
|
with:
|
|
57
61
|
node-version: '24'
|
|
58
62
|
|
|
63
|
+
- name: Instala a CLI
|
|
64
|
+
run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
|
|
65
|
+
|
|
59
66
|
- name: Move Feature to Code Review
|
|
60
|
-
run:
|
|
67
|
+
run: spec-wave code-review --pr-number ${{ github.event.pull_request.number }}
|
|
61
68
|
env:
|
|
62
69
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
63
70
|
PROJECT_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
|
|
@@ -5,7 +5,21 @@ on:
|
|
|
5
5
|
types: [labeled]
|
|
6
6
|
|
|
7
7
|
concurrency:
|
|
8
|
-
|
|
8
|
+
# Um run que NÃO vai fazer nada não pode disputar este grupo.
|
|
9
|
+
#
|
|
10
|
+
# `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
|
|
11
|
+
# `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
|
|
12
|
+
# entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
|
|
13
|
+
# o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
|
|
14
|
+
# assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
|
|
15
|
+
# em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
|
|
16
|
+
# fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
|
|
17
|
+
# nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
|
|
18
|
+
# da fila sem afrouxar a serialização de quem realmente vai rodar.
|
|
19
|
+
group: >-
|
|
20
|
+
${{ github.event.label.name == 'spec-wave:critique'
|
|
21
|
+
&& format('spec-wave-critique-{0}', github.event.issue.number)
|
|
22
|
+
|| format('spec-wave-noop-{0}', github.run_id) }}
|
|
9
23
|
cancel-in-progress: false
|
|
10
24
|
|
|
11
25
|
jobs:
|
|
@@ -29,8 +43,11 @@ jobs:
|
|
|
29
43
|
with:
|
|
30
44
|
node-version: '24'
|
|
31
45
|
|
|
46
|
+
- name: Instala a CLI
|
|
47
|
+
run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
|
|
48
|
+
|
|
32
49
|
- name: Critique plan.md as-is
|
|
33
|
-
run:
|
|
50
|
+
run: spec-wave critique --issue-number ${{ github.event.issue.number }}
|
|
34
51
|
env:
|
|
35
52
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
36
53
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
@@ -20,7 +20,21 @@ on:
|
|
|
20
20
|
# o spec.md/plan.md do checkout — que uma geração concorrente estaria trocando
|
|
21
21
|
# debaixo dele.
|
|
22
22
|
concurrency:
|
|
23
|
-
|
|
23
|
+
# Um run que NÃO vai fazer nada não pode disputar este grupo.
|
|
24
|
+
#
|
|
25
|
+
# `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
|
|
26
|
+
# `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
|
|
27
|
+
# entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
|
|
28
|
+
# o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
|
|
29
|
+
# assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
|
|
30
|
+
# em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
|
|
31
|
+
# fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
|
|
32
|
+
# nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
|
|
33
|
+
# da fila sem afrouxar a serialização de quem realmente vai rodar.
|
|
34
|
+
group: >-
|
|
35
|
+
${{ (github.event.label.name == 'spec-wave:decompose' || github.event.label.name == 'spec-wave:decompose-apply')
|
|
36
|
+
&& format('spec-wave-item-{0}', github.event.issue.number)
|
|
37
|
+
|| format('spec-wave-noop-{0}', github.run_id) }}
|
|
24
38
|
cancel-in-progress: false
|
|
25
39
|
|
|
26
40
|
jobs:
|
|
@@ -50,9 +64,12 @@ jobs:
|
|
|
50
64
|
with:
|
|
51
65
|
node-version: '24'
|
|
52
66
|
|
|
67
|
+
- name: Instala a CLI
|
|
68
|
+
run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
|
|
69
|
+
|
|
53
70
|
- name: Decompose into Stories and Tasks
|
|
54
71
|
run: >
|
|
55
|
-
|
|
72
|
+
spec-wave decompose
|
|
56
73
|
--issue-number ${{ github.event.issue.number }}
|
|
57
74
|
${{ github.event.label.name == 'spec-wave:decompose-apply' && '--apply' || '' }}
|
|
58
75
|
env:
|
|
@@ -12,7 +12,21 @@ on:
|
|
|
12
12
|
# diferentes a disputa é resolvida no laço de repetição de push do
|
|
13
13
|
# lib/flow-run.mjs — serializar o repositório inteiro mataria a vazão.
|
|
14
14
|
concurrency:
|
|
15
|
-
|
|
15
|
+
# Um run que NÃO vai fazer nada não pode disputar este grupo.
|
|
16
|
+
#
|
|
17
|
+
# `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
|
|
18
|
+
# `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
|
|
19
|
+
# entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
|
|
20
|
+
# o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
|
|
21
|
+
# assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
|
|
22
|
+
# em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
|
|
23
|
+
# fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
|
|
24
|
+
# nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
|
|
25
|
+
# da fila sem afrouxar a serialização de quem realmente vai rodar.
|
|
26
|
+
group: >-
|
|
27
|
+
${{ github.event.label.name == 'spec-wave:bug'
|
|
28
|
+
&& format('spec-wave-item-{0}', github.event.issue.number)
|
|
29
|
+
|| format('spec-wave-noop-{0}', github.run_id) }}
|
|
16
30
|
cancel-in-progress: false
|
|
17
31
|
|
|
18
32
|
jobs:
|
|
@@ -34,8 +48,11 @@ jobs:
|
|
|
34
48
|
with:
|
|
35
49
|
node-version: '24'
|
|
36
50
|
|
|
51
|
+
- name: Instala a CLI
|
|
52
|
+
run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
|
|
53
|
+
|
|
37
54
|
- name: Generate bug.md
|
|
38
|
-
run:
|
|
55
|
+
run: spec-wave generate-bug --issue-number ${{ github.event.issue.number }}
|
|
39
56
|
env:
|
|
40
57
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
41
58
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
@@ -20,7 +20,21 @@ on:
|
|
|
20
20
|
# mataria a vazão, e a disputa entre issues diferentes é resolvida onde ela
|
|
21
21
|
# acontece — no laço de repetição de push do lib/flow-run.mjs.
|
|
22
22
|
concurrency:
|
|
23
|
-
|
|
23
|
+
# Um run que NÃO vai fazer nada não pode disputar este grupo.
|
|
24
|
+
#
|
|
25
|
+
# `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
|
|
26
|
+
# `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
|
|
27
|
+
# entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
|
|
28
|
+
# o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
|
|
29
|
+
# assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
|
|
30
|
+
# em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
|
|
31
|
+
# fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
|
|
32
|
+
# nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
|
|
33
|
+
# da fila sem afrouxar a serialização de quem realmente vai rodar.
|
|
34
|
+
group: >-
|
|
35
|
+
${{ github.event.label.name == 'spec-wave:plan'
|
|
36
|
+
&& format('spec-wave-item-{0}', github.event.issue.number)
|
|
37
|
+
|| format('spec-wave-noop-{0}', github.run_id) }}
|
|
24
38
|
cancel-in-progress: false
|
|
25
39
|
|
|
26
40
|
jobs:
|
|
@@ -42,8 +56,11 @@ jobs:
|
|
|
42
56
|
with:
|
|
43
57
|
node-version: '24'
|
|
44
58
|
|
|
59
|
+
- name: Instala a CLI
|
|
60
|
+
run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
|
|
61
|
+
|
|
45
62
|
- name: Generate plan.md
|
|
46
|
-
run:
|
|
63
|
+
run: spec-wave generate-plan --issue-number ${{ github.event.issue.number }}
|
|
47
64
|
env:
|
|
48
65
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
49
66
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
@@ -20,7 +20,21 @@ on:
|
|
|
20
20
|
# mataria a vazão, e a disputa entre issues diferentes é resolvida onde ela
|
|
21
21
|
# acontece — no laço de repetição de push do lib/flow-run.mjs.
|
|
22
22
|
concurrency:
|
|
23
|
-
|
|
23
|
+
# Um run que NÃO vai fazer nada não pode disputar este grupo.
|
|
24
|
+
#
|
|
25
|
+
# `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
|
|
26
|
+
# `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
|
|
27
|
+
# entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
|
|
28
|
+
# o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
|
|
29
|
+
# assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
|
|
30
|
+
# em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
|
|
31
|
+
# fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
|
|
32
|
+
# nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
|
|
33
|
+
# da fila sem afrouxar a serialização de quem realmente vai rodar.
|
|
34
|
+
group: >-
|
|
35
|
+
${{ github.event.label.name == 'spec-wave:spec'
|
|
36
|
+
&& format('spec-wave-item-{0}', github.event.issue.number)
|
|
37
|
+
|| format('spec-wave-noop-{0}', github.run_id) }}
|
|
24
38
|
cancel-in-progress: false
|
|
25
39
|
|
|
26
40
|
jobs:
|
|
@@ -42,8 +56,11 @@ jobs:
|
|
|
42
56
|
with:
|
|
43
57
|
node-version: '24'
|
|
44
58
|
|
|
59
|
+
- name: Instala a CLI
|
|
60
|
+
run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
|
|
61
|
+
|
|
45
62
|
- name: Generate spec.md
|
|
46
|
-
run:
|
|
63
|
+
run: spec-wave generate-spec --issue-number ${{ github.event.issue.number }}
|
|
47
64
|
env:
|
|
48
65
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
49
66
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
@@ -24,8 +24,12 @@ jobs:
|
|
|
24
24
|
with:
|
|
25
25
|
node-version: '24'
|
|
26
26
|
|
|
27
|
+
- name: Instala a CLI
|
|
28
|
+
run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
|
|
29
|
+
|
|
30
|
+
|
|
27
31
|
- name: Move Feature to QA
|
|
28
|
-
run:
|
|
32
|
+
run: spec-wave qa --pr-number ${{ github.event.pull_request.number }}
|
|
29
33
|
env:
|
|
30
34
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
31
35
|
PROJECT_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
|
|
@@ -5,7 +5,21 @@ on:
|
|
|
5
5
|
types: [labeled]
|
|
6
6
|
|
|
7
7
|
concurrency:
|
|
8
|
-
|
|
8
|
+
# Um run que NÃO vai fazer nada não pode disputar este grupo.
|
|
9
|
+
#
|
|
10
|
+
# `issues: [labeled]` dispara para QUALQUER label — prioridade, tipo,
|
|
11
|
+
# `spec-wave:model:*`. O job é filtrado pelo `if:` e termina skipped, mas o RUN
|
|
12
|
+
# entra na fila de concurrency do mesmo jeito, e com `cancel-in-progress: false`
|
|
13
|
+
# o GitHub cancela o run que já estava PENDENTE para pôr o novo no lugar. Foi
|
|
14
|
+
# assim que um decompose enfileirado atrás de um generate-plan morreu cancelado
|
|
15
|
+
# em 1 segundo por causa de uma label sem relação nenhuma — a label de gatilho
|
|
16
|
+
# fica na issue, `labeled` não redispara com ela aplicada, e o fluxo trava sem
|
|
17
|
+
# nenhum sinal. Rotular o run inútil com um grupo único (`github.run_id`) o tira
|
|
18
|
+
# da fila sem afrouxar a serialização de quem realmente vai rodar.
|
|
19
|
+
group: >-
|
|
20
|
+
${{ github.event.label.name == 'spec-wave:ready'
|
|
21
|
+
&& format('spec-wave-validate-{0}', github.event.issue.number)
|
|
22
|
+
|| format('spec-wave-noop-{0}', github.run_id) }}
|
|
9
23
|
cancel-in-progress: false
|
|
10
24
|
|
|
11
25
|
jobs:
|
|
@@ -26,8 +40,11 @@ jobs:
|
|
|
26
40
|
with:
|
|
27
41
|
node-version: '24'
|
|
28
42
|
|
|
43
|
+
- name: Instala a CLI
|
|
44
|
+
run: npm install -g @spec-wave/cli@{{CLI_VERSION}}
|
|
45
|
+
|
|
29
46
|
- name: Validate spec and plan
|
|
30
|
-
run:
|
|
47
|
+
run: spec-wave validate --issue-number ${{ github.event.issue.number }}
|
|
31
48
|
env:
|
|
32
49
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
33
50
|
GITHUB_REPOSITORY: ${{ github.repository }}
|