@spec-wave/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/spec-wave.mjs +125 -0
- package/package.json +27 -0
- package/src/api/auth.mjs +36 -0
- package/src/api/github-graphql.mjs +238 -0
- package/src/api/github-rest.mjs +157 -0
- package/src/commands/decompose.mjs +113 -0
- package/src/commands/feature.mjs +7 -0
- package/src/commands/generate-plan.mjs +71 -0
- package/src/commands/generate-spec.mjs +77 -0
- package/src/commands/info.mjs +53 -0
- package/src/commands/init.mjs +191 -0
- package/src/commands/issue.mjs +186 -0
- package/src/commands/refresh.mjs +116 -0
- package/src/commands/uninstall.mjs +132 -0
- package/src/commands/validate.mjs +77 -0
- package/src/config.mjs +131 -0
- package/src/lib/claude.mjs +25 -0
- package/src/lib/slugify.mjs +13 -0
- package/src/setup/files.mjs +63 -0
- package/src/setup/labels.mjs +17 -0
- package/src/setup/project.mjs +62 -0
- package/src/templates/issue/plan-template.md +35 -0
- package/src/templates/issue/spec-template.md +32 -0
- package/src/templates/workflows/decompose.yml +29 -0
- package/src/templates/workflows/generate-plan.yml +31 -0
- package/src/templates/workflows/generate-spec.yml +31 -0
- package/src/templates/workflows/validate.yml +27 -0
- package/src/ui/wizard.mjs +83 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { issue } from './issue.mjs';
|
|
2
|
+
|
|
3
|
+
// `feature` é um atalho de `issue --type feature`, mantido para compatibilidade
|
|
4
|
+
// com a skill e o fluxo do RFC-001. Toda a lógica vive em issue.mjs.
|
|
5
|
+
export async function feature(options) {
|
|
6
|
+
return issue({ ...options, type: 'feature' });
|
|
7
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
4
|
+
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
+
import { generateDocument } from '../lib/claude.mjs';
|
|
6
|
+
import { slugify } from '../lib/slugify.mjs';
|
|
7
|
+
|
|
8
|
+
const SYSTEM_PROMPT = `Você é um Tech Lead experiente. Gere um plano técnico (plan.md) completo e detalhado para a Feature descrita pelo usuário.
|
|
9
|
+
|
|
10
|
+
O plano deve conter exatamente estas seções em português:
|
|
11
|
+
# Frontend
|
|
12
|
+
# Backend
|
|
13
|
+
# Banco de dados
|
|
14
|
+
# Infraestrutura
|
|
15
|
+
# Segurança
|
|
16
|
+
# Testes
|
|
17
|
+
# Estimativa (Story Points)
|
|
18
|
+
|
|
19
|
+
Para cada seção, forneça detalhes técnicos concretos e acionáveis baseados na descrição da Feature.
|
|
20
|
+
A estimativa de Story Points deve usar a sequência de Fibonacci: 1, 2, 3, 5, 8, 13, 21.
|
|
21
|
+
Responda APENAS com o conteúdo do plan.md, sem texto adicional.`;
|
|
22
|
+
|
|
23
|
+
export async function generatePlan({ issueNumber }) {
|
|
24
|
+
const token = await resolveToken();
|
|
25
|
+
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
26
|
+
|
|
27
|
+
if (!owner || !repo) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
30
|
+
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
31
|
+
' GITHUB_REPOSITORY=owner/repo spec-wave generate-plan --issue-number 1'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(`Buscando issue #${issueNumber}...`);
|
|
36
|
+
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
37
|
+
const slug = slugify(issue.title);
|
|
38
|
+
const featureDir = `docs/features/${slug}`;
|
|
39
|
+
const filePath = `${featureDir}/plan.md`;
|
|
40
|
+
|
|
41
|
+
console.log(`Gerando plan.md para: ${issue.title}`);
|
|
42
|
+
const content = await generateDocument(
|
|
43
|
+
SYSTEM_PROMPT,
|
|
44
|
+
`Feature: ${issue.title}\n\nDescrição:\n${issue.body || '(sem descrição)'}`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
mkdirSync(featureDir, { recursive: true });
|
|
48
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
49
|
+
|
|
50
|
+
// Commit and push
|
|
51
|
+
const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
|
|
52
|
+
git(`git config user.email "spec-wave[bot]@github.com"`);
|
|
53
|
+
git(`git config user.name "spec-wave[bot]"`);
|
|
54
|
+
git(`git add "${filePath}"`);
|
|
55
|
+
git(`git commit -m "docs: generate plan.md for ${slug} [spec-wave]"`);
|
|
56
|
+
git('git push');
|
|
57
|
+
|
|
58
|
+
// Remove trigger label
|
|
59
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
|
|
60
|
+
|
|
61
|
+
// Comment on issue
|
|
62
|
+
await commentOnIssue(
|
|
63
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
64
|
+
`📋 **plan.md gerado automaticamente!**\n\n` +
|
|
65
|
+
`📄 Arquivo: [\`${filePath}\`](${filePath})\n\n` +
|
|
66
|
+
`Revise o plano e, quando estiver pronto, mova o card para a coluna **📋 Spec** ou use:\n` +
|
|
67
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:spec"\n\`\`\``
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
console.log(`plan.md criado em: ${filePath}`);
|
|
71
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
4
|
+
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
+
import { generateDocument } from '../lib/claude.mjs';
|
|
6
|
+
import { slugify } from '../lib/slugify.mjs';
|
|
7
|
+
|
|
8
|
+
const SYSTEM_PROMPT = `Você é um Product Manager experiente. Gere uma especificação funcional (spec.md) completa para a Feature descrita pelo usuário.
|
|
9
|
+
|
|
10
|
+
O spec deve conter exatamente estas seções em português:
|
|
11
|
+
# Objetivo
|
|
12
|
+
# Regras de Negócio
|
|
13
|
+
# Fluxos
|
|
14
|
+
# Critérios de Aceite
|
|
15
|
+
# Casos de Erro
|
|
16
|
+
# Dependências
|
|
17
|
+
|
|
18
|
+
Para cada seção, seja específico e detalhado. Os Critérios de Aceite devem estar no formato de checklist markdown (- [ ] item).
|
|
19
|
+
Se um plano técnico (plan.md) for fornecido, use-o para enriquecer os detalhes técnicos relevantes.
|
|
20
|
+
Responda APENAS com o conteúdo do spec.md, sem texto adicional.`;
|
|
21
|
+
|
|
22
|
+
export async function generateSpec({ issueNumber }) {
|
|
23
|
+
const token = await resolveToken();
|
|
24
|
+
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
25
|
+
|
|
26
|
+
if (!owner || !repo) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
29
|
+
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
30
|
+
' GITHUB_REPOSITORY=owner/repo spec-wave generate-spec --issue-number 1'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(`Buscando issue #${issueNumber}...`);
|
|
35
|
+
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
36
|
+
const slug = slugify(issue.title);
|
|
37
|
+
const featureDir = `docs/features/${slug}`;
|
|
38
|
+
const filePath = `${featureDir}/spec.md`;
|
|
39
|
+
|
|
40
|
+
// Read existing plan.md if available
|
|
41
|
+
const planPath = `${featureDir}/plan.md`;
|
|
42
|
+
const planContent = existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null;
|
|
43
|
+
|
|
44
|
+
const userContent = [
|
|
45
|
+
`Feature: ${issue.title}`,
|
|
46
|
+
`\nDescrição:\n${issue.body || '(sem descrição)'}`,
|
|
47
|
+
planContent ? `\nPlano Técnico (plan.md):\n${planContent}` : '',
|
|
48
|
+
].join('');
|
|
49
|
+
|
|
50
|
+
console.log(`Gerando spec.md para: ${issue.title}`);
|
|
51
|
+
const content = await generateDocument(SYSTEM_PROMPT, userContent);
|
|
52
|
+
|
|
53
|
+
mkdirSync(featureDir, { recursive: true });
|
|
54
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
55
|
+
|
|
56
|
+
// Commit and push
|
|
57
|
+
const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
|
|
58
|
+
git(`git config user.email "spec-wave[bot]@github.com"`);
|
|
59
|
+
git(`git config user.name "spec-wave[bot]"`);
|
|
60
|
+
git(`git add "${filePath}"`);
|
|
61
|
+
git(`git commit -m "docs: generate spec.md for ${slug} [spec-wave]"`);
|
|
62
|
+
git('git push');
|
|
63
|
+
|
|
64
|
+
// Remove trigger label
|
|
65
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
|
|
66
|
+
|
|
67
|
+
// Comment on issue
|
|
68
|
+
await commentOnIssue(
|
|
69
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
70
|
+
`📋 **spec.md gerado automaticamente!**\n\n` +
|
|
71
|
+
`📄 Arquivo: [\`${filePath}\`](${filePath})\n\n` +
|
|
72
|
+
`Revise a especificação e, quando estiver pronto, mova o card para **✅ Ready** ou use:\n` +
|
|
73
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\``
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
console.log(`spec.md criado em: ${filePath}`);
|
|
77
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
6
|
+
|
|
7
|
+
// Lê o marcador .spec-wave.json do repositório atual (cwd) e reporta se o
|
|
8
|
+
// spec-wave já foi inicializado. Usado pela skill para decidir entre mostrar
|
|
9
|
+
// as informações ou oferecer rodar o `init`.
|
|
10
|
+
export async function info(options = {}) {
|
|
11
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
12
|
+
|
|
13
|
+
if (!existsSync(configPath)) {
|
|
14
|
+
if (options.json) {
|
|
15
|
+
console.log(JSON.stringify({ initialized: false }));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
p.intro(chalk.bold('spec-wave info'));
|
|
19
|
+
p.log.warn(`Este repositório ${chalk.bold('não foi inicializado')} (sem ${CONFIG_FILE}).`);
|
|
20
|
+
p.outro('Execute `npx @spec-wave/cli init` para configurar.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let config;
|
|
25
|
+
try {
|
|
26
|
+
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (options.json) {
|
|
29
|
+
console.log(JSON.stringify({ initialized: false, error: err.message }));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
p.log.error(`${CONFIG_FILE} existe mas está corrompido: ${err.message}`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (options.json) {
|
|
38
|
+
console.log(JSON.stringify({ initialized: true, ...config }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
p.intro(chalk.bold('spec-wave info'));
|
|
43
|
+
p.log.success(`Repositório ${chalk.bold('inicializado')}.`);
|
|
44
|
+
p.note(
|
|
45
|
+
`${chalk.dim('Repositório:')} ${config.owner ?? '?'}/${config.repo ?? '?'}\n` +
|
|
46
|
+
`${chalk.dim('Project:')} ${config.project?.title ?? '—'}\n` +
|
|
47
|
+
`${chalk.dim('URL:')} ${config.project?.url ? chalk.cyan(config.project.url) : '—'}\n` +
|
|
48
|
+
`${chalk.dim('Versão CLI:')} ${config.version ?? '?'}\n` +
|
|
49
|
+
`${chalk.dim('Criado em:')} ${config.initializedAt ?? '?'}`,
|
|
50
|
+
'Configuração'
|
|
51
|
+
);
|
|
52
|
+
p.outro('Use `/spec-wave feature <descrição>` para criar uma Feature.');
|
|
53
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
|
|
7
|
+
import { runWizard } from '../ui/wizard.mjs';
|
|
8
|
+
import { setupProject } from '../setup/project.mjs';
|
|
9
|
+
import { setupLabels } from '../setup/labels.mjs';
|
|
10
|
+
import { setupFiles } from '../setup/files.mjs';
|
|
11
|
+
import { upsertFile } from '../api/github-rest.mjs';
|
|
12
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
13
|
+
|
|
14
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
|
|
16
|
+
|
|
17
|
+
export async function init(options) {
|
|
18
|
+
// --- Token ---
|
|
19
|
+
let token;
|
|
20
|
+
try {
|
|
21
|
+
token = await resolveToken();
|
|
22
|
+
} catch (err) {
|
|
23
|
+
p.log.error(err.message);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tokenSpinner = p.spinner();
|
|
28
|
+
tokenSpinner.start('Verificando token GitHub...');
|
|
29
|
+
let tokenInfo;
|
|
30
|
+
try {
|
|
31
|
+
tokenInfo = await verifyTokenScopes(token);
|
|
32
|
+
tokenSpinner.stop(`Autenticado como ${chalk.bold(tokenInfo.login)}`);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
tokenSpinner.stop('');
|
|
35
|
+
p.log.error(`Falha ao verificar token: ${err.message}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const missingScopes = [];
|
|
40
|
+
if (!tokenInfo.hasProject) missingScopes.push('project');
|
|
41
|
+
if (!tokenInfo.hasRepo) missingScopes.push('repo');
|
|
42
|
+
if (!tokenInfo.hasWorkflow) missingScopes.push('workflow');
|
|
43
|
+
|
|
44
|
+
if (missingScopes.length > 0) {
|
|
45
|
+
p.log.error(
|
|
46
|
+
`Token sem os escopos necessários: ${missingScopes.join(', ')}\n` +
|
|
47
|
+
`Execute: gh auth refresh --scopes project,repo,workflow\n` +
|
|
48
|
+
`Ou crie um Personal Access Token com os escopos "project", "repo" e "workflow".`
|
|
49
|
+
);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Wizard ou flags ---
|
|
54
|
+
let owner, repo, projectTitle;
|
|
55
|
+
if (options.repo) {
|
|
56
|
+
if (!options.repo.includes('/')) {
|
|
57
|
+
p.log.error('Formato inválido para --repo. Use: owner/repo');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
[owner, repo] = options.repo.split('/');
|
|
61
|
+
projectTitle = options.projectTitle ?? `${repo} — Spec Wave`;
|
|
62
|
+
p.log.info(`Repositório: ${owner}/${repo}`);
|
|
63
|
+
p.log.info(`Projeto: ${projectTitle}`);
|
|
64
|
+
} else {
|
|
65
|
+
({ owner, repo, projectTitle } = await runWizard());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (options.dryRun) {
|
|
69
|
+
p.log.info(chalk.yellow('Modo dry-run: nenhuma alteração será feita.'));
|
|
70
|
+
p.log.info(` Repositório: ${owner}/${repo}`);
|
|
71
|
+
p.log.info(` Projeto: ${projectTitle}`);
|
|
72
|
+
p.log.info(' Fases: project board → labels → workflow files');
|
|
73
|
+
p.outro('Dry-run concluído.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Phase 1: Project Board ---
|
|
78
|
+
let projectUrl, projectId, projectNumber, projectFields;
|
|
79
|
+
if (options.skipProject) {
|
|
80
|
+
p.log.info('Pulando criação do GitHub Project (--skip-project).');
|
|
81
|
+
} else {
|
|
82
|
+
const projectSpinner = p.spinner();
|
|
83
|
+
projectSpinner.start('Criando GitHub Project...');
|
|
84
|
+
try {
|
|
85
|
+
const result = await setupProject(token, owner, repo, projectTitle, projectSpinner);
|
|
86
|
+
projectUrl = result.projectUrl;
|
|
87
|
+
projectId = result.projectId;
|
|
88
|
+
projectNumber = result.projectNumber;
|
|
89
|
+
projectFields = result.fields;
|
|
90
|
+
projectSpinner.stop(`Projeto criado: ${chalk.cyan(projectUrl)}`);
|
|
91
|
+
if (result.linkWarning) {
|
|
92
|
+
p.log.warn(
|
|
93
|
+
'Não foi possível vincular o projeto ao repositório (requer escopo "repo").\n' +
|
|
94
|
+
'Faça manualmente: abra o projeto → Settings → Linked repositories → Add repository.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
projectSpinner.stop('');
|
|
99
|
+
p.log.error(`Erro ao criar projeto: ${err.message}`);
|
|
100
|
+
p.log.info('Use --skip-project para pular esta fase e tentar de novo.');
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Phase 2: Labels ---
|
|
106
|
+
if (options.skipLabels) {
|
|
107
|
+
p.log.info('Pulando criação das labels (--skip-labels).');
|
|
108
|
+
} else {
|
|
109
|
+
const labelSpinner = p.spinner();
|
|
110
|
+
labelSpinner.start('Criando labels...');
|
|
111
|
+
try {
|
|
112
|
+
await setupLabels(token, owner, repo, labelSpinner);
|
|
113
|
+
labelSpinner.stop('Labels criadas (15 labels)');
|
|
114
|
+
} catch (err) {
|
|
115
|
+
labelSpinner.stop('');
|
|
116
|
+
p.log.error(`Erro ao criar labels: ${err.message}`);
|
|
117
|
+
p.log.info('Use --skip-labels para pular esta fase e tentar de novo.');
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Phase 3: Files ---
|
|
123
|
+
if (options.skipFiles) {
|
|
124
|
+
p.log.info('Pulando criação dos arquivos (--skip-files).');
|
|
125
|
+
} else {
|
|
126
|
+
const filesSpinner = p.spinner();
|
|
127
|
+
filesSpinner.start('Criando arquivos no repositório...');
|
|
128
|
+
try {
|
|
129
|
+
await setupFiles(token, owner, repo, filesSpinner);
|
|
130
|
+
filesSpinner.stop('Arquivos criados (4 workflows + 2 issue templates)');
|
|
131
|
+
} catch (err) {
|
|
132
|
+
filesSpinner.stop('');
|
|
133
|
+
p.log.error(`Erro ao criar arquivos: ${err.message}`);
|
|
134
|
+
p.log.info('Use --skip-files para pular esta fase e tentar de novo.');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Marcador de configuração (.spec-wave.json) ---
|
|
140
|
+
// Commitado no repo-alvo para que a skill detecte, em sessões futuras, que o
|
|
141
|
+
// init já rodou e qual project/versão foi usado. É a fonte de estado persistente.
|
|
142
|
+
const configSpinner = p.spinner();
|
|
143
|
+
configSpinner.start(`Gravando ${CONFIG_FILE}...`);
|
|
144
|
+
try {
|
|
145
|
+
const config = {
|
|
146
|
+
version: pkg.version,
|
|
147
|
+
owner,
|
|
148
|
+
repo,
|
|
149
|
+
project: {
|
|
150
|
+
title: projectTitle,
|
|
151
|
+
url: projectUrl ?? null,
|
|
152
|
+
id: projectId ?? null,
|
|
153
|
+
number: projectNumber ?? null,
|
|
154
|
+
fields: projectFields ?? null,
|
|
155
|
+
},
|
|
156
|
+
initializedAt: new Date().toISOString(),
|
|
157
|
+
};
|
|
158
|
+
await upsertFile(
|
|
159
|
+
token,
|
|
160
|
+
owner,
|
|
161
|
+
repo,
|
|
162
|
+
CONFIG_FILE,
|
|
163
|
+
JSON.stringify(config, null, 2) + '\n',
|
|
164
|
+
'chore: record spec-wave config [spec-wave]'
|
|
165
|
+
);
|
|
166
|
+
configSpinner.stop(`${CONFIG_FILE} gravado (spec-wave v${pkg.version})`);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
configSpinner.stop('');
|
|
169
|
+
p.log.warn(`Não foi possível gravar ${CONFIG_FILE}: ${err.message}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
p.note(
|
|
173
|
+
'As 12 colunas do RFC-001 foram criadas no campo "Etapa".\n' +
|
|
174
|
+
'Para usá-las como colunas do board:\n' +
|
|
175
|
+
' 1. Abra o projeto no GitHub\n' +
|
|
176
|
+
' 2. Clique em "..." → "Settings" da view de Board\n' +
|
|
177
|
+
' 3. Em "Group by", selecione o campo "Etapa"',
|
|
178
|
+
'Configurar Board View'
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
p.outro(
|
|
182
|
+
`\n${chalk.green('✓')} spec-wave configurado com sucesso!\n\n` +
|
|
183
|
+
(projectUrl ? ` Projeto: ${chalk.cyan(projectUrl)}\n\n` : '') +
|
|
184
|
+
` Próximos passos:\n` +
|
|
185
|
+
` 1. Adicione ANTHROPIC_API_KEY como secret no repositório\n` +
|
|
186
|
+
` 2. Configure o board view para agrupar por "Etapa"\n` +
|
|
187
|
+
` 3. Crie uma Feature com o prefixo [FEATURE] no título\n` +
|
|
188
|
+
` 4. Use a skill spec-wave para guiar o fluxo\n\n` +
|
|
189
|
+
` ${chalk.dim('Para usar a skill: adicione skill/SKILL.md ao seu projeto Claude Code')}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
6
|
+
import { CONFIG_FILE, STATUS_OPTIONS, PRIORITY_LABELS, WORK_ITEM_TYPES } from '../config.mjs';
|
|
7
|
+
import { createIssue, getIssue } from '../api/github-rest.mjs';
|
|
8
|
+
import { addProjectItem, setItemSingleSelect, getSingleSelectField, addSubIssue } from '../api/github-graphql.mjs';
|
|
9
|
+
|
|
10
|
+
// Etapa inicial de todo work item recém-criado (📥 Backlog).
|
|
11
|
+
const INITIAL_STAGE = STATUS_OPTIONS[0].name;
|
|
12
|
+
const VALID_PRIORITIES = PRIORITY_LABELS.map(l => l.name);
|
|
13
|
+
|
|
14
|
+
// Resolve o tipo informado (case-insensitive) para o nome canônico (ex.: "Feature").
|
|
15
|
+
function normalizeType(input) {
|
|
16
|
+
if (!input) return null;
|
|
17
|
+
return WORK_ITEM_TYPES.find(t => t.toLowerCase() === input.toLowerCase()) || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildBody(options, parent) {
|
|
21
|
+
const parts = [];
|
|
22
|
+
if (parent) parts.push(`**Parent:** #${parent.number} — ${parent.title}`);
|
|
23
|
+
const desc = (options.body || '').trim();
|
|
24
|
+
if (desc) parts.push(desc);
|
|
25
|
+
const meta = [];
|
|
26
|
+
if (options.area) meta.push(`- **Área:** ${options.area}`);
|
|
27
|
+
if (options.priority) meta.push(`- **Prioridade:** ${options.priority}`);
|
|
28
|
+
if (meta.length) parts.push(`## Metadados\n${meta.join('\n')}`);
|
|
29
|
+
return parts.join('\n\n') || '_(sem descrição)_';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Resolve um campo SINGLE_SELECT pelo nome: usa o .spec-wave.json, cai para o
|
|
33
|
+
// formato legado (etapaFieldId/stageOptions) e, por fim, consulta o Project.
|
|
34
|
+
async function resolveField(token, project, name) {
|
|
35
|
+
if (project.fields && project.fields[name]) return project.fields[name];
|
|
36
|
+
if (name === 'Etapa' && project.etapaFieldId) {
|
|
37
|
+
return { id: project.etapaFieldId, options: project.stageOptions || {} };
|
|
38
|
+
}
|
|
39
|
+
return await getSingleSelectField(token, project.id, name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Seta o valor de um campo do item no board. Retorna true se aplicou.
|
|
43
|
+
async function setField(token, project, itemId, fieldName, optionName) {
|
|
44
|
+
const field = await resolveField(token, project, fieldName);
|
|
45
|
+
const optionId = field?.options?.[optionName];
|
|
46
|
+
if (field?.id && optionId) {
|
|
47
|
+
await setItemSingleSelect(token, project.id, itemId, field.id, optionId);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cria um work item (issue tipada), opcionalmente como sub-issue de um parent,
|
|
54
|
+
// adiciona ao Project e define Etapa, Work Item Type, Prioridade e Área no board.
|
|
55
|
+
export async function issue(options) {
|
|
56
|
+
if (!options.title) {
|
|
57
|
+
p.log.error('Informe o título: --title "<título>"');
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const type = normalizeType(options.type || 'feature');
|
|
62
|
+
if (!type) {
|
|
63
|
+
p.log.error(`Tipo inválido: "${options.type}". Use um de: ${WORK_ITEM_TYPES.join(', ')}`);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (options.priority && !VALID_PRIORITIES.includes(options.priority)) {
|
|
68
|
+
p.log.error(`Prioridade inválida: ${options.priority}. Use uma de: ${VALID_PRIORITIES.join(', ')}`);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
74
|
+
if (!existsSync(configPath)) {
|
|
75
|
+
p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let config;
|
|
80
|
+
try {
|
|
81
|
+
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
p.log.error(`${CONFIG_FILE} corrompido: ${err.message}`);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const { owner, repo } = config;
|
|
88
|
+
const project = config.project || {};
|
|
89
|
+
if (!owner || !repo) {
|
|
90
|
+
p.log.error(`${CONFIG_FILE} não contém owner/repo. Rode \`spec-wave init\` novamente.`);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let token;
|
|
96
|
+
try {
|
|
97
|
+
token = await resolveToken();
|
|
98
|
+
} catch (err) {
|
|
99
|
+
p.log.error(err.message);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
p.intro(chalk.bold(`spec-wave issue (${type})`));
|
|
105
|
+
|
|
106
|
+
// Resolve o parent (se informado) — precisamos do node id para a sub-issue.
|
|
107
|
+
let parent = null;
|
|
108
|
+
if (options.parent) {
|
|
109
|
+
const parentNumber = parseInt(String(options.parent).replace('#', ''), 10);
|
|
110
|
+
if (!Number.isInteger(parentNumber)) {
|
|
111
|
+
p.log.error(`--parent inválido: ${options.parent}. Use o número da issue pai.`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const data = await getIssue(token, owner, repo, parentNumber);
|
|
117
|
+
parent = { number: data.number, nodeId: data.node_id, title: data.title };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
p.log.error(`Não foi possível ler a issue pai #${parentNumber}: ${err.message}`);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 1. Cria a issue (título e label prefixados pelo tipo, ex.: [FEATURE]).
|
|
126
|
+
const tag = `[${type.toUpperCase()}]`;
|
|
127
|
+
const labels = [tag];
|
|
128
|
+
if (options.priority) labels.push(options.priority);
|
|
129
|
+
|
|
130
|
+
const issueSpinner = p.spinner();
|
|
131
|
+
issueSpinner.start('Criando issue...');
|
|
132
|
+
let created;
|
|
133
|
+
try {
|
|
134
|
+
created = await createIssue(token, owner, repo, `${tag} ${options.title}`, buildBody(options, parent), labels);
|
|
135
|
+
issueSpinner.stop(`Issue #${created.number} criada: ${chalk.cyan(created.url)}`);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
issueSpinner.stop('');
|
|
138
|
+
p.log.error(`Erro ao criar issue: ${err.message}`);
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. Vincula como sub-issue do parent (relação nativa do GitHub).
|
|
144
|
+
if (parent) {
|
|
145
|
+
try {
|
|
146
|
+
await addSubIssue(token, parent.nodeId, created.nodeId);
|
|
147
|
+
p.log.success(`Vinculada como sub-issue de #${parent.number}.`);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
p.log.warn(`Issue criada, mas falhou ao vincular ao parent #${parent.number}: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 3. Adiciona ao Project e define os campos do board.
|
|
154
|
+
if (!project.id) {
|
|
155
|
+
p.log.warn(
|
|
156
|
+
`Project não configurado no ${CONFIG_FILE} — a issue não foi adicionada ao board.\n` +
|
|
157
|
+
'Re-rode `spec-wave init` (sem --skip-project) ou `spec-wave refresh --config`.'
|
|
158
|
+
);
|
|
159
|
+
p.outro(`${type} #${created.number} criado (fora do board).`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const boardSpinner = p.spinner();
|
|
164
|
+
boardSpinner.start('Adicionando ao Project...');
|
|
165
|
+
try {
|
|
166
|
+
const itemId = await addProjectItem(token, project.id, created.nodeId);
|
|
167
|
+
|
|
168
|
+
const stageOk = await setField(token, project, itemId, 'Etapa', INITIAL_STAGE);
|
|
169
|
+
const typeOk = await setField(token, project, itemId, 'Work Item Type', type);
|
|
170
|
+
if (options.priority) await setField(token, project, itemId, 'Priority', options.priority);
|
|
171
|
+
if (options.area) await setField(token, project, itemId, 'Area', options.area);
|
|
172
|
+
|
|
173
|
+
boardSpinner.stop(`Adicionada ao Project${stageOk ? ` em "${INITIAL_STAGE}"` : ''}.`);
|
|
174
|
+
if (!stageOk) p.log.warn(`Não foi possível definir a Etapa "${INITIAL_STAGE}" (campo não encontrado).`);
|
|
175
|
+
if (!typeOk) p.log.warn(`Não foi possível definir o Work Item Type "${type}" (campo não encontrado).`);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
boardSpinner.stop('');
|
|
178
|
+
p.log.warn(`Issue criada, mas falhou ao adicionar ao Project: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const parentLine = parent ? ` (sub-issue de #${parent.number})` : '';
|
|
182
|
+
p.outro(
|
|
183
|
+
`${chalk.green('✓')} ${type} #${created.number} criado em "${INITIAL_STAGE}"${parentLine}.\n` +
|
|
184
|
+
` ${type === 'Feature' ? `Próximo: \`/spec-wave plan ${created.number}\` para o planejamento técnico.` : ''}`
|
|
185
|
+
);
|
|
186
|
+
}
|