@spec-wave/cli 0.12.0 → 0.14.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 +39 -27
- package/bin/spec-wave.mjs +14 -4
- package/package.json +1 -1
- package/src/api/github-graphql.mjs +0 -4
- package/src/api/github-rest.mjs +0 -13
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +410 -251
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +111 -51
- package/src/commands/generate-spec.mjs +20 -22
- package/src/commands/implement.mjs +46 -24
- package/src/commands/info.mjs +4 -3
- package/src/commands/issue.mjs +4 -4
- package/src/commands/move.mjs +162 -0
- package/src/commands/order.mjs +1 -12
- package/src/commands/qa.mjs +5 -8
- package/src/commands/refresh.mjs +4 -3
- package/src/commands/story.mjs +1 -12
- package/src/commands/task.mjs +1 -11
- package/src/commands/update.mjs +43 -19
- package/src/commands/validate.mjs +47 -35
- package/src/config.mjs +40 -6
- package/src/lib/board.mjs +88 -26
- package/src/lib/claude.mjs +315 -70
- package/src/lib/critique.mjs +391 -91
- package/src/lib/decomposition-doc.mjs +451 -0
- package/src/lib/implement-board.mjs +14 -1
- package/src/lib/project-root.mjs +93 -0
- package/src/lib/templates.mjs +53 -0
- package/src/setup/files.mjs +3 -10
- package/src/templates/skill/SKILL.md +137 -61
- package/src/templates/workflows/code-review.yml +1 -1
- package/src/templates/workflows/decompose.yml +20 -6
- package/src/templates/workflows/generate-plan.yml +1 -1
- package/src/templates/workflows/generate-spec.yml +1 -1
- package/src/templates/workflows/qa.yml +1 -1
- package/src/templates/workflows/validate.yml +1 -1
- package/src/lib/feature-docs.mjs +0 -89
- package/src/lib/force.mjs +0 -34
|
@@ -14,9 +14,9 @@ import { detectIssueType } from '../lib/issue-type.mjs';
|
|
|
14
14
|
import { slugify } from '../lib/slugify.mjs';
|
|
15
15
|
import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
|
|
16
16
|
import { loadProjectConfig, resolveField } from '../lib/board.mjs';
|
|
17
|
-
import { resolveDoc } from '../lib/feature-docs.mjs';
|
|
18
17
|
import { planBoardMoves, applyBoardMoves } from '../lib/implement-board.mjs';
|
|
19
18
|
import { extractPathsFromPlan, buildCodeDigest } from '../lib/code-digest.mjs';
|
|
19
|
+
import { findConfigPath, resolveFromRoot } from '../lib/project-root.mjs';
|
|
20
20
|
|
|
21
21
|
// Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
|
|
22
22
|
const WORK_DIR = '.spec-wave';
|
|
@@ -42,16 +42,15 @@ async function resolveFeature(token, startNodeId) {
|
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// Lê
|
|
46
|
-
// regerações forçadas o vigente é spec-v2.md/plan-v3.md…, não o original.
|
|
45
|
+
// Lê spec.md/plan.md de um docs/features/<slug> se existirem.
|
|
47
46
|
function readSpecPlan(featureDir) {
|
|
48
|
-
const
|
|
49
|
-
const
|
|
47
|
+
const specPath = path.join(featureDir, 'spec.md');
|
|
48
|
+
const planPath = path.join(featureDir, 'plan.md');
|
|
50
49
|
return {
|
|
51
|
-
specPath
|
|
52
|
-
planPath
|
|
53
|
-
spec:
|
|
54
|
-
plan:
|
|
50
|
+
specPath,
|
|
51
|
+
planPath,
|
|
52
|
+
spec: existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null,
|
|
53
|
+
plan: existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null,
|
|
55
54
|
};
|
|
56
55
|
}
|
|
57
56
|
|
|
@@ -361,7 +360,7 @@ function renderCommand(template, vars) {
|
|
|
361
360
|
// Modo Feature: avalia as Stories da Feature (dependências + Etapa no board),
|
|
362
361
|
// pula as já implementadas (Code Review+) e monta UM contexto único com todas
|
|
363
362
|
// as pendentes em ordem topológica — spec-kit acionado uma vez.
|
|
364
|
-
async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun }) {
|
|
363
|
+
async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun, repoRoot }) {
|
|
365
364
|
// F1. Stories (sub-issues) da Feature.
|
|
366
365
|
const subs = await listSubIssues(token, feature.node_id).catch(() => []);
|
|
367
366
|
const stories = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
|
|
@@ -376,7 +375,7 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
376
375
|
p.log.info(`Feature com ${stories.length} story(ies): ${stories.map(s => `#${s.number}`).join(', ')}`);
|
|
377
376
|
|
|
378
377
|
// F1-board. Feature → Desenvolvimento (In Progress) já no início.
|
|
379
|
-
await applyBoardMoves({ token, moves: planBoardMoves('start', {
|
|
378
|
+
await applyBoardMoves({ token, dryRun, moves: planBoardMoves('start', {
|
|
380
379
|
feature: { nodeId: feature.node_id, number: feature.number },
|
|
381
380
|
}) });
|
|
382
381
|
|
|
@@ -393,7 +392,15 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
393
392
|
// F3. Etapa de cada Story no board (best-effort — sem board, nada é pulado).
|
|
394
393
|
const { project, error: projectError } = loadProjectConfig();
|
|
395
394
|
const stageOf = new Map();
|
|
396
|
-
if (
|
|
395
|
+
if (dryRun) {
|
|
396
|
+
// A leitura da Etapa passa por addProjectItem, que é MUTAÇÃO (adiciona a
|
|
397
|
+
// Story ao Project se ainda não estiver lá). Num dry-run isso é proibido, então
|
|
398
|
+
// a consulta é pulada inteira — o planejamento segue com stage null.
|
|
399
|
+
p.log.warn(
|
|
400
|
+
'Dry-run: Etapas do board não consultadas (a consulta adicionaria itens ao Project); ' +
|
|
401
|
+
'nenhuma Story será considerada implementada.'
|
|
402
|
+
);
|
|
403
|
+
} else if (projectError) {
|
|
397
404
|
p.log.warn(`${projectError} — Etapas do board não consultadas; nenhuma Story será considerada implementada.`);
|
|
398
405
|
} else {
|
|
399
406
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
@@ -453,7 +460,8 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
453
460
|
}
|
|
454
461
|
|
|
455
462
|
// F6. spec.md/plan.md — a issue-alvo JÁ é a Feature (sem resolveFeature).
|
|
456
|
-
const featureDir = featureDirOpt
|
|
463
|
+
const featureDir = featureDirOpt
|
|
464
|
+
|| resolveFromRoot(repoRoot, 'docs', 'features', slugify(feature.title));
|
|
457
465
|
let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
|
|
458
466
|
if (existsSync(featureDir)) {
|
|
459
467
|
specPlan = readSpecPlan(featureDir);
|
|
@@ -558,12 +566,15 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
558
566
|
}
|
|
559
567
|
|
|
560
568
|
// 1. Config local (.spec-wave.json) — owner/repo e bloco opcional specKit.
|
|
561
|
-
|
|
562
|
-
|
|
569
|
+
// Procurado subindo na árvore, como o git faz com o .git: rodar de dentro de
|
|
570
|
+
// apps/web precisa achar o config da raiz. `repoRoot` ancora docs/features.
|
|
571
|
+
const configPath = findConfigPath();
|
|
572
|
+
if (!configPath) {
|
|
563
573
|
p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
|
|
564
574
|
process.exitCode = 1;
|
|
565
575
|
return;
|
|
566
576
|
}
|
|
577
|
+
const repoRoot = path.dirname(configPath);
|
|
567
578
|
let config;
|
|
568
579
|
try {
|
|
569
580
|
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
@@ -617,7 +628,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
617
628
|
p.log.info(`Task única #${issueNumber}.`);
|
|
618
629
|
} else if (type === 'Feature') {
|
|
619
630
|
// Modo Feature: Stories pendentes em ordem de dependência, contexto único.
|
|
620
|
-
await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun });
|
|
631
|
+
await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun, repoRoot });
|
|
621
632
|
return;
|
|
622
633
|
} else {
|
|
623
634
|
p.log.error(
|
|
@@ -632,7 +643,9 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
632
643
|
const feature = await resolveFeature(token, issue.node_id);
|
|
633
644
|
let featureDir = featureDirOpt;
|
|
634
645
|
if (!featureDir && feature?.title) {
|
|
635
|
-
|
|
646
|
+
// Ancorado na RAIZ do repo: o config é encontrado subindo na árvore, e os
|
|
647
|
+
// documentos precisam ser procurados no mesmo lugar.
|
|
648
|
+
featureDir = resolveFromRoot(repoRoot, 'docs', 'features', slugify(feature.title));
|
|
636
649
|
}
|
|
637
650
|
let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
|
|
638
651
|
if (featureDir && existsSync(featureDir)) {
|
|
@@ -646,7 +659,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
646
659
|
// 4a-board. Board determinístico: Feature/Story → Desenvolvimento (In
|
|
647
660
|
// Progress) já no início — a UI mostra o desenvolvimento em andamento sem
|
|
648
661
|
// depender de o LLM mover cards. Best-effort: falha vira warn.
|
|
649
|
-
await applyBoardMoves({ token, moves: planBoardMoves('start', {
|
|
662
|
+
await applyBoardMoves({ token, dryRun, moves: planBoardMoves('start', {
|
|
650
663
|
feature: feature ? { nodeId: feature.nodeId, number: feature.number } : null,
|
|
651
664
|
story: type === 'Story' ? { nodeId: issue.node_id, number: issue.number } : null,
|
|
652
665
|
tasks: tasks.map(t => ({ nodeId: t.nodeId, number: t.number })),
|
|
@@ -755,6 +768,21 @@ async function writeContextAndRunSpecKit({ config, issueNumber, type, title, spe
|
|
|
755
768
|
title,
|
|
756
769
|
};
|
|
757
770
|
|
|
771
|
+
// O dry-run é decidido ANTES do early return de "spec-kit não configurado":
|
|
772
|
+
// sem isso, com o comando ausente os dois caminhos ficavam indistinguíveis e a
|
|
773
|
+
// saída não dizia que estava em dry-run.
|
|
774
|
+
if (dryRun) {
|
|
775
|
+
if (template) {
|
|
776
|
+
p.note(renderCommand(template, vars), 'Comando que seria executado (--dry-run)');
|
|
777
|
+
} else {
|
|
778
|
+
p.log.warn(`Comando do spec-kit não configurado em ${CONFIG_FILE} — nada a imprimir.`);
|
|
779
|
+
}
|
|
780
|
+
p.outro(
|
|
781
|
+
`Dry-run: nada executado e nada escrito no GitHub. Contexto montado em ${tasksFile}.`
|
|
782
|
+
);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
758
786
|
if (!template) {
|
|
759
787
|
p.log.warn('Comando do spec-kit não configurado.');
|
|
760
788
|
p.note(
|
|
@@ -770,12 +798,6 @@ async function writeContextAndRunSpecKit({ config, issueNumber, type, title, spe
|
|
|
770
798
|
|
|
771
799
|
const command = renderCommand(template, vars);
|
|
772
800
|
|
|
773
|
-
if (dryRun) {
|
|
774
|
-
p.note(command, 'Comando que seria executado (--dry-run)');
|
|
775
|
-
p.outro(`Dry-run: nada executado. Contexto em ${tasksFile}.`);
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
801
|
p.log.step(`Executando: ${chalk.dim(command)}`);
|
|
780
802
|
try {
|
|
781
803
|
execSync(command, { stdio: 'inherit' });
|
package/src/commands/info.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { readFileSync
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { CONFIG_FILE, PORTAL_URL } from '../config.mjs';
|
|
6
|
+
import { findConfigPath } from '../lib/project-root.mjs';
|
|
6
7
|
import { skillStatus } from './install-skill.mjs';
|
|
7
8
|
|
|
8
9
|
// Resume o estado da skill para a saída JSON. `null` = não foi possível checar.
|
|
@@ -43,10 +44,10 @@ function reportSkill(status) {
|
|
|
43
44
|
// as informações ou oferecer rodar o `init`. Também valida se a skill instalada
|
|
44
45
|
// nos agentes detectados está em dia com a versão empacotada na CLI.
|
|
45
46
|
export async function info(options = {}) {
|
|
46
|
-
const configPath =
|
|
47
|
+
const configPath = findConfigPath();
|
|
47
48
|
const skill = skillStatus();
|
|
48
49
|
|
|
49
|
-
if (!
|
|
50
|
+
if (!configPath) {
|
|
50
51
|
if (options.json) {
|
|
51
52
|
console.log(JSON.stringify({ initialized: false, skill: skillJson(skill) }));
|
|
52
53
|
return;
|
package/src/commands/issue.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { readFileSync
|
|
4
|
-
import path from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
5
4
|
import { resolveToken } from '../api/auth.mjs';
|
|
6
5
|
import { CONFIG_FILE, STATUS_OPTIONS, PRIORITY_LABELS, WORK_ITEM_TYPES } from '../config.mjs';
|
|
6
|
+
import { findConfigPath } from '../lib/project-root.mjs';
|
|
7
7
|
import { createIssue, getIssue } from '../api/github-rest.mjs';
|
|
8
8
|
import { addProjectItem, setItemSingleSelect, getSingleSelectField, addSubIssue } from '../api/github-graphql.mjs';
|
|
9
9
|
|
|
@@ -70,8 +70,8 @@ export async function issue(options) {
|
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const configPath =
|
|
74
|
-
if (!
|
|
73
|
+
const configPath = findConfigPath();
|
|
74
|
+
if (!configPath) {
|
|
75
75
|
p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
|
|
76
76
|
process.exitCode = 1;
|
|
77
77
|
return;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Move QUALQUER item do board para uma Etapa (Feature, Story, Task, Bug, RFC…).
|
|
2
|
+
//
|
|
3
|
+
// Existia um buraco no fluxo: `story` só aceita `review` e `task` só
|
|
4
|
+
// `start`/`done`, então mover uma Feature pela CLI era impossível — o único
|
|
5
|
+
// caminho era mutação GraphQL manual, justamente o que a skill instrui a evitar
|
|
6
|
+
// ("prefira sempre os comandos da CLI a mutações manuais no board").
|
|
7
|
+
//
|
|
8
|
+
// O comando se chama `move` e não `feature <ação>` porque `feature` já está
|
|
9
|
+
// registrado no bin CRIANDO issues; reaproveitar o nome colidiria.
|
|
10
|
+
//
|
|
11
|
+
// As guardas do board são as mesmas dos outros comandos — reusa advanceToStage,
|
|
12
|
+
// logo a Etapa NUNCA retrocede e não há escape hatch.
|
|
13
|
+
import * as p from '@clack/prompts';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
16
|
+
import { getIssue } from '../api/github-rest.mjs';
|
|
17
|
+
import { addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
18
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
19
|
+
import { loadProjectConfig, resolveField, advanceToStage, resolveStageName } from '../lib/board.mjs';
|
|
20
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
21
|
+
import {
|
|
22
|
+
CONFIG_FILE, PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
23
|
+
isManualStageType, MANUAL_STAGE_TYPES,
|
|
24
|
+
} from '../config.mjs';
|
|
25
|
+
|
|
26
|
+
const PROGRESS_VALUES = [PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve o valor do campo Status a partir da entrada do usuário (função PURA).
|
|
30
|
+
* Sem `--status`, o destino é "Todo": ao avançar de Etapa o progresso reinicia
|
|
31
|
+
* (ver a distinção Etapa × Status em config.mjs).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} [input]
|
|
34
|
+
* @returns {{ status: string|null, error: string|null }}
|
|
35
|
+
*/
|
|
36
|
+
export function resolveProgressName(input) {
|
|
37
|
+
if (input === undefined || input === null || String(input).trim() === '') {
|
|
38
|
+
return { status: PROGRESS_TODO, error: null };
|
|
39
|
+
}
|
|
40
|
+
const key = String(input).trim().toLowerCase();
|
|
41
|
+
const match = PROGRESS_VALUES.find(v => v.toLowerCase() === key)
|
|
42
|
+
|| PROGRESS_VALUES.find(v => v.toLowerCase().replace(/\s+/g, '') === key.replace(/[\s_-]+/g, ''));
|
|
43
|
+
return match
|
|
44
|
+
? { status: match, error: null }
|
|
45
|
+
: {
|
|
46
|
+
status: null,
|
|
47
|
+
error:
|
|
48
|
+
`Status "${input}" não existe. Use um destes: ${PROGRESS_VALUES.map(v => `"${v}"`).join(', ')}.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function move({ issue: issueArg, stage: stageArg, status: statusArg }) {
|
|
53
|
+
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
54
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
55
|
+
p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { stage, error: stageError } = resolveStageName(stageArg);
|
|
61
|
+
if (stageError) {
|
|
62
|
+
p.log.error(stageError);
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const { status, error: statusError } = resolveProgressName(statusArg);
|
|
67
|
+
if (statusError) {
|
|
68
|
+
p.log.error(statusError);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
|
|
74
|
+
// fallback no .spec-wave.json, procurado subindo na árvore.
|
|
75
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
76
|
+
const { config, root } = loadConfig();
|
|
77
|
+
const owner = envOwner || config?.owner;
|
|
78
|
+
const repo = envRepo || config?.repo;
|
|
79
|
+
if (!owner || !repo) {
|
|
80
|
+
p.log.error(
|
|
81
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
82
|
+
`Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
|
|
83
|
+
);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let token;
|
|
89
|
+
try {
|
|
90
|
+
token = await resolveToken();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
p.log.error(err.message);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
p.intro(chalk.bold(`spec-wave move #${issueNumber} → ${stage}`));
|
|
98
|
+
|
|
99
|
+
let issue;
|
|
100
|
+
try {
|
|
101
|
+
issue = await getIssue(token, owner, repo, issueNumber);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Spike tem avanço decidido pelo usuário no board, não por comando.
|
|
109
|
+
const type = detectIssueType(issue);
|
|
110
|
+
if (isManualStageType(type)) {
|
|
111
|
+
p.log.error(
|
|
112
|
+
`Issue #${issueNumber} é do tipo ${type}, cuja Etapa é movida à mão no board ` +
|
|
113
|
+
`(tipos manuais: ${MANUAL_STAGE_TYPES.join(', ')}).`
|
|
114
|
+
);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
120
|
+
if (projectError) {
|
|
121
|
+
p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
126
|
+
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
127
|
+
|
|
128
|
+
let moved;
|
|
129
|
+
try {
|
|
130
|
+
moved = await advanceToStage(
|
|
131
|
+
token, project, etapaField, statusField, issue.node_id, stage, status);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
p.log.error(`Falha ao atualizar o board: ${err.message}`);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (moved) {
|
|
139
|
+
p.log.success(
|
|
140
|
+
`${type || 'Issue'} #${issueNumber} → Etapa ${chalk.bold(stage)} / Status ${chalk.bold(status)}.`
|
|
141
|
+
);
|
|
142
|
+
p.outro(`${chalk.green('✓')} ${issue.title}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// false = já está nessa Etapa ou adiante (ou numa coluna fora da ordem
|
|
147
|
+
// canônica). A Etapa NUNCA retrocede — lê a atual só para informar.
|
|
148
|
+
let current = null;
|
|
149
|
+
if (etapaField?.id) {
|
|
150
|
+
try {
|
|
151
|
+
const itemId = await addProjectItem(token, project.id, issue.node_id);
|
|
152
|
+
current = await getItemSingleSelectValue(token, itemId, etapaField.id);
|
|
153
|
+
} catch {
|
|
154
|
+
// sem leitura da Etapa — segue com o aviso genérico
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
p.log.info(
|
|
158
|
+
`#${issueNumber} não foi movida: a Etapa nunca retrocede, e ela já está em ` +
|
|
159
|
+
`${chalk.bold(current || `"${stage}" ou etapa posterior`)}.`
|
|
160
|
+
);
|
|
161
|
+
p.outro('Nada a fazer.');
|
|
162
|
+
}
|
package/src/commands/order.mjs
CHANGED
|
@@ -7,26 +7,15 @@
|
|
|
7
7
|
// GITHUB_REPOSITORY quando existir, senão do .spec-wave.json (gravado pelo init).
|
|
8
8
|
import * as p from '@clack/prompts';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
-
import path from 'node:path';
|
|
12
10
|
import { resolveToken } from '../api/auth.mjs';
|
|
13
11
|
import { getIssue, listBlockedBy } from '../api/github-rest.mjs';
|
|
14
12
|
import { addProjectItem, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
15
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
16
14
|
import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
|
|
17
15
|
import { loadProjectConfig, resolveField } from '../lib/board.mjs';
|
|
16
|
+
import { resolveRepoContext } from '../lib/project-root.mjs';
|
|
18
17
|
import { CONFIG_FILE, STAGE_ORDER, STAGE_DEVELOPMENT, STAGE_DONE } from '../config.mjs';
|
|
19
18
|
|
|
20
|
-
// Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
|
|
21
|
-
// fallback no .spec-wave.json — comandos locais rodam sem essa env.
|
|
22
|
-
function resolveRepoContext() {
|
|
23
|
-
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
24
|
-
let cfg = {};
|
|
25
|
-
const cfgPath = path.join(process.cwd(), CONFIG_FILE);
|
|
26
|
-
try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
|
|
27
|
-
return { owner: envOwner || cfg.owner, repo: envRepo || cfg.repo };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
19
|
export async function order({ feature: featureArg }) {
|
|
31
20
|
const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
|
|
32
21
|
if (!Number.isInteger(featureNumber) || featureNumber <= 0) {
|
package/src/commands/qa.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
2
|
import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
3
|
import { getIssueParent } from '../api/github-graphql.mjs';
|
|
6
4
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
7
5
|
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
8
|
-
import {
|
|
6
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
7
|
+
import { STATUS_OPTIONS, PROGRESS_TODO } from '../config.mjs';
|
|
9
8
|
|
|
10
9
|
const QA_STAGE = STATUS_OPTIONS.find(s => s.name.includes('QA'))?.name;
|
|
11
10
|
const TODO_STATUS = PROGRESS_TODO;
|
|
@@ -47,11 +46,9 @@ export async function qa({ prNumber }) {
|
|
|
47
46
|
const token = await resolveToken();
|
|
48
47
|
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
49
48
|
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const owner = envOwner || cfg.owner;
|
|
54
|
-
const repo = envRepo || cfg.repo;
|
|
49
|
+
const { config: cfg } = loadConfig();
|
|
50
|
+
const owner = envOwner || cfg?.owner;
|
|
51
|
+
const repo = envRepo || cfg?.repo;
|
|
55
52
|
|
|
56
53
|
if (!owner || !repo) {
|
|
57
54
|
throw new Error(
|
package/src/commands/refresh.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { readFileSync, writeFileSync
|
|
3
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
7
7
|
import { CONFIG_FILE } from '../config.mjs';
|
|
8
|
+
import { findConfigPath } from '../lib/project-root.mjs';
|
|
8
9
|
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
9
10
|
|
|
10
11
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -21,8 +22,8 @@ export async function refresh(options = {}) {
|
|
|
21
22
|
return;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
const configPath =
|
|
25
|
-
if (!
|
|
25
|
+
const configPath = findConfigPath();
|
|
26
|
+
if (!configPath) {
|
|
26
27
|
p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
|
|
27
28
|
process.exitCode = 1;
|
|
28
29
|
return;
|
package/src/commands/story.mjs
CHANGED
|
@@ -7,25 +7,14 @@
|
|
|
7
7
|
// estiver em Code Review ou adiante, o comando apenas informa a Etapa atual.
|
|
8
8
|
import * as p from '@clack/prompts';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
-
import path from 'node:path';
|
|
12
10
|
import { resolveToken } from '../api/auth.mjs';
|
|
13
11
|
import { getIssue } from '../api/github-rest.mjs';
|
|
14
12
|
import { addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
15
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
16
14
|
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
15
|
+
import { resolveRepoContext } from '../lib/project-root.mjs';
|
|
17
16
|
import { CONFIG_FILE, STAGE_CODE_REVIEW, PROGRESS_TODO } from '../config.mjs';
|
|
18
17
|
|
|
19
|
-
// Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
|
|
20
|
-
// fallback no .spec-wave.json — comandos locais rodam sem essa env.
|
|
21
|
-
function resolveRepoContext() {
|
|
22
|
-
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
23
|
-
let cfg = {};
|
|
24
|
-
const cfgPath = path.join(process.cwd(), CONFIG_FILE);
|
|
25
|
-
try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
|
|
26
|
-
return { owner: envOwner || cfg.owner, repo: envRepo || cfg.repo };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
18
|
export async function story({ action, issue: issueArg }) {
|
|
30
19
|
if (action !== 'review') {
|
|
31
20
|
p.log.error(
|
package/src/commands/task.mjs
CHANGED
|
@@ -10,13 +10,12 @@
|
|
|
10
10
|
// (regra pura canStartTask, testada em test/board-rules.test.mjs).
|
|
11
11
|
import * as p from '@clack/prompts';
|
|
12
12
|
import chalk from 'chalk';
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
-
import path from 'node:path';
|
|
15
13
|
import { resolveToken } from '../api/auth.mjs';
|
|
16
14
|
import { getIssue } from '../api/github-rest.mjs';
|
|
17
15
|
import { addProjectItem, getIssueParent, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
18
16
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
19
17
|
import { loadProjectConfig, resolveField, advanceToStage, setItemStatus } from '../lib/board.mjs';
|
|
18
|
+
import { resolveRepoContext } from '../lib/project-root.mjs';
|
|
20
19
|
import {
|
|
21
20
|
CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_DONE,
|
|
22
21
|
PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
@@ -36,15 +35,6 @@ export function canStartTask({ siblings } = {}) {
|
|
|
36
35
|
return busy ? { ok: false, blocker: busy.number } : { ok: true, blocker: null };
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
// Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos de Action) com
|
|
40
|
-
// fallback no .spec-wave.json — comandos locais rodam sem essa env.
|
|
41
|
-
function resolveRepoContext() {
|
|
42
|
-
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
43
|
-
let cfg = {};
|
|
44
|
-
const cfgPath = path.join(process.cwd(), CONFIG_FILE);
|
|
45
|
-
try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
|
|
46
|
-
return { owner: envOwner || cfg.owner, repo: envRepo || cfg.repo };
|
|
47
|
-
}
|
|
48
38
|
|
|
49
39
|
export async function task({ action, issue: issueArg }) {
|
|
50
40
|
if (action !== 'start' && action !== 'done') {
|
package/src/commands/update.mjs
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { readFileSync, writeFileSync,
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
5
4
|
import { homedir } from 'node:os';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
8
7
|
import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '../config.mjs';
|
|
9
8
|
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
10
9
|
import {
|
|
11
|
-
getFileContent, upsertFile, listLabels, createLabel, updateLabel,
|
|
10
|
+
getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
|
|
12
11
|
} from '../api/github-rest.mjs';
|
|
12
|
+
// MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
|
|
13
|
+
// byte com o remoto. Se só o init resolvesse, todo update veria os workflows
|
|
14
|
+
// como desatualizados para sempre.
|
|
15
|
+
import { readTemplate } from '../lib/templates.mjs';
|
|
13
16
|
import {
|
|
14
17
|
TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
|
|
15
18
|
mergeAgentsFile, resolveDest, isDetected, skillCopyReason,
|
|
16
19
|
} from './install-skill.mjs';
|
|
17
|
-
|
|
18
|
-
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
-
const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
|
|
20
|
-
function readTemplate(...parts) {
|
|
21
|
-
return readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8');
|
|
22
|
-
}
|
|
20
|
+
import { findConfigPath } from '../lib/project-root.mjs';
|
|
23
21
|
|
|
24
22
|
// Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
|
|
25
23
|
const REPO_FILES = [
|
|
@@ -52,10 +50,23 @@ function applySkill(job) {
|
|
|
52
50
|
writeFileSync(job.dest.path, content, 'utf-8');
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Compara ALL_LABELS com as labels do repo (função PURA — testável).
|
|
55
|
+
*
|
|
56
|
+
* `color` no config é hex maiúsculo e a API retorna minúsculo — daí o
|
|
57
|
+
* toLowerCase() na comparação.
|
|
58
|
+
*
|
|
59
|
+
* `orphan` são labels `spec-wave:*` que existem no repo mas saíram do config: o
|
|
60
|
+
* update só sabia criar e atualizar, nunca remover, então uma label descontinuada
|
|
61
|
+
* ficava para sempre — foi o caso da `spec-wave:force`, removida do código na
|
|
62
|
+
* 0.13.0 mas ainda presente em todo repo inicializado com a 0.12.0. Só o
|
|
63
|
+
* namespace `spec-wave:` é considerado: labels do time não são da nossa conta.
|
|
64
|
+
*
|
|
65
|
+
* @param {Array<{name,color,description}>} existing labels do repo
|
|
66
|
+
* @returns {{ missing: object[], changed: object[], orphan: object[] }}
|
|
67
|
+
*/
|
|
68
|
+
export function diffLabels(existing) {
|
|
69
|
+
const byName = new Map((existing || []).map(l => [l.name, l]));
|
|
59
70
|
const missing = [];
|
|
60
71
|
const changed = [];
|
|
61
72
|
for (const label of ALL_LABELS) {
|
|
@@ -63,13 +74,15 @@ function diffLabels(existing) {
|
|
|
63
74
|
if (!cur) {
|
|
64
75
|
missing.push(label);
|
|
65
76
|
} else if (
|
|
66
|
-
cur.color.toLowerCase() !== label.color.toLowerCase() ||
|
|
77
|
+
(cur.color || '').toLowerCase() !== label.color.toLowerCase() ||
|
|
67
78
|
(cur.description || '') !== (label.description || '')
|
|
68
79
|
) {
|
|
69
80
|
changed.push(label);
|
|
70
81
|
}
|
|
71
82
|
}
|
|
72
|
-
|
|
83
|
+
const known = new Set(ALL_LABELS.map(l => l.name));
|
|
84
|
+
const orphan = (existing || []).filter(l => l.name?.startsWith('spec-wave:') && !known.has(l.name));
|
|
85
|
+
return { missing, changed, orphan };
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
export async function update(options = {}) {
|
|
@@ -84,9 +97,9 @@ export async function update(options = {}) {
|
|
|
84
97
|
const skillJobs = options.skipSkill ? [] : detectSkill(parsed, baseDir, isGlobal);
|
|
85
98
|
|
|
86
99
|
// 2) Config + repo dependem do .spec-wave.json local do repo atual.
|
|
87
|
-
const configPath =
|
|
100
|
+
const configPath = findConfigPath();
|
|
88
101
|
let config = null;
|
|
89
|
-
if (
|
|
102
|
+
if (configPath) {
|
|
90
103
|
try {
|
|
91
104
|
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
92
105
|
} catch (err) {
|
|
@@ -124,7 +137,7 @@ export async function update(options = {}) {
|
|
|
124
137
|
|
|
125
138
|
// 2b) Arquivos do repo e labels divergentes (exige token + rede).
|
|
126
139
|
let repoFiles = [];
|
|
127
|
-
let labelDiff = { missing: [], changed: [] };
|
|
140
|
+
let labelDiff = { missing: [], changed: [], orphan: [] };
|
|
128
141
|
let repoChecked = false;
|
|
129
142
|
if (doRepo) {
|
|
130
143
|
const s = p.spinner();
|
|
@@ -153,7 +166,7 @@ export async function update(options = {}) {
|
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
// ---------- Resumo ----------
|
|
156
|
-
const labelTotal = labelDiff.missing.length + labelDiff.changed.length;
|
|
169
|
+
const labelTotal = labelDiff.missing.length + labelDiff.changed.length + labelDiff.orphan.length;
|
|
157
170
|
const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal;
|
|
158
171
|
|
|
159
172
|
if (total === 0) {
|
|
@@ -180,6 +193,9 @@ export async function update(options = {}) {
|
|
|
180
193
|
lines.push(chalk.bold('Labels:'));
|
|
181
194
|
if (labelDiff.missing.length) lines.push(` ${chalk.yellow('+')} criar: ${labelDiff.missing.map(l => l.name).join(', ')}`);
|
|
182
195
|
if (labelDiff.changed.length) lines.push(` ${chalk.yellow('↻')} atualizar: ${labelDiff.changed.map(l => l.name).join(', ')}`);
|
|
196
|
+
if (labelDiff.orphan.length) {
|
|
197
|
+
lines.push(` ${chalk.red('−')} remover (descontinuadas): ${labelDiff.orphan.map(l => l.name).join(', ')}`);
|
|
198
|
+
}
|
|
183
199
|
}
|
|
184
200
|
p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
|
|
185
201
|
|
|
@@ -279,6 +295,14 @@ export async function update(options = {}) {
|
|
|
279
295
|
p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
|
|
280
296
|
}
|
|
281
297
|
}
|
|
298
|
+
for (const label of labelDiff.orphan) {
|
|
299
|
+
try {
|
|
300
|
+
await deleteLabel(tk, owner, repo, label.name);
|
|
301
|
+
p.log.success(`Label descontinuada removida: ${label.name}`);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
p.log.error(`Falha ao remover label ${label.name}: ${err.message}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
282
306
|
}
|
|
283
307
|
|
|
284
308
|
const committedRepo = repoFiles.length > 0;
|