@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.
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+ import { readFileSync } from 'node:fs';
7
+
8
+ const __dir = path.dirname(fileURLToPath(import.meta.url));
9
+ const pkg = JSON.parse(readFileSync(path.join(__dir, '..', 'package.json'), 'utf-8'));
10
+
11
+ program
12
+ .name('spec-wave')
13
+ .description('Setup spec-driven GitHub workflow with Projects v2')
14
+ .version(pkg.version);
15
+
16
+ program
17
+ .command('init')
18
+ .description('Configura spec-wave em um repositório GitHub')
19
+ .option('--dry-run', 'Simula a configuração sem fazer alterações')
20
+ .option('--repo <owner/repo>', 'Repositório GitHub (ignora o wizard interativo)')
21
+ .option('--project-title <title>', 'Nome do GitHub Project (padrão: "<repo> — Spec Wave")')
22
+ .option('--skip-project', 'Pula a criação do GitHub Project (use se já foi criado)')
23
+ .option('--skip-labels', 'Pula a criação das labels')
24
+ .option('--skip-files', 'Pula a criação dos arquivos de workflow')
25
+ .action(async (options) => {
26
+ const { init } = await import('../src/commands/init.mjs');
27
+ await init(options);
28
+ });
29
+
30
+ program
31
+ .command('info')
32
+ .description('Mostra se o repositório atual foi inicializado e os dados do .spec-wave.json')
33
+ .option('--json', 'Saída em JSON (para uso programático)')
34
+ .action(async (options) => {
35
+ const { info } = await import('../src/commands/info.mjs');
36
+ await info(options);
37
+ });
38
+
39
+ program
40
+ .command('refresh')
41
+ .description('Atualiza o .spec-wave.json local com os dados atuais do GitHub Project')
42
+ .option('--config', 'Re-consulta o Project e reescreve o .spec-wave.json')
43
+ .action(async (options) => {
44
+ const { refresh } = await import('../src/commands/refresh.mjs');
45
+ await refresh(options).catch(err => { console.error(err.message); process.exit(1); });
46
+ });
47
+
48
+ program
49
+ .command('issue')
50
+ .description('Cria um work item (epic/feature/story/task...), opcionalmente como sub-issue, e adiciona ao board')
51
+ .requiredOption('--title <title>', 'Título (sem o prefixo de tipo, ex.: [FEATURE])')
52
+ .option('--type <type>', 'Tipo: epic, feature, story, task, bug, spike ou rfc', 'feature')
53
+ .option('--parent <n>', 'Número da issue pai (cria como sub-issue dela)')
54
+ .option('--body <text>', 'Descrição')
55
+ .option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3')
56
+ .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
57
+ .action(async (options) => {
58
+ const { issue } = await import('../src/commands/issue.mjs');
59
+ await issue(options).catch(err => { console.error(err.message); process.exit(1); });
60
+ });
61
+
62
+ program
63
+ .command('feature')
64
+ .description('Atalho de `issue --type feature`')
65
+ .requiredOption('--title <title>', 'Título da feature (sem o prefixo [FEATURE])')
66
+ .option('--parent <n>', 'Número da Epic pai (cria como sub-issue dela)')
67
+ .option('--body <text>', 'Descrição da feature')
68
+ .option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3 (adiciona label)')
69
+ .option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
70
+ .action(async (options) => {
71
+ const { feature } = await import('../src/commands/feature.mjs');
72
+ await feature(options).catch(err => { console.error(err.message); process.exit(1); });
73
+ });
74
+
75
+ program
76
+ .command('uninstall')
77
+ .description('Remove labels, arquivos .github e o .spec-wave.json (mantém o GitHub Project)')
78
+ .option('--repo <owner/repo>', 'Repositório (padrão: lê do .spec-wave.json)')
79
+ .option('--skip-labels', 'Não remove as labels')
80
+ .option('--skip-files', 'Não remove os arquivos .github')
81
+ .option('--keep-config', 'Mantém o .spec-wave.json local')
82
+ .option('--dry-run', 'Mostra o que seria removido sem alterar nada')
83
+ .option('--yes', 'Não pede confirmação')
84
+ .action(async (options) => {
85
+ const { uninstall } = await import('../src/commands/uninstall.mjs');
86
+ await uninstall(options).catch(err => { console.error(err.message); process.exit(1); });
87
+ });
88
+
89
+ program
90
+ .command('generate-plan')
91
+ .description('Gera plan.md para uma Feature (usado pelo GitHub Action)')
92
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
93
+ .action(async (options) => {
94
+ const { generatePlan } = await import('../src/commands/generate-plan.mjs');
95
+ await generatePlan(options).catch(err => { console.error(err.message); process.exit(1); });
96
+ });
97
+
98
+ program
99
+ .command('generate-spec')
100
+ .description('Gera spec.md para uma Feature (usado pelo GitHub Action)')
101
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
102
+ .action(async (options) => {
103
+ const { generateSpec } = await import('../src/commands/generate-spec.mjs');
104
+ await generateSpec(options).catch(err => { console.error(err.message); process.exit(1); });
105
+ });
106
+
107
+ program
108
+ .command('validate')
109
+ .description('Valida spec.md e plan.md de uma Feature (usado pelo GitHub Action)')
110
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
111
+ .action(async (options) => {
112
+ const { validate } = await import('../src/commands/validate.mjs');
113
+ await validate(options).catch(err => { console.error(err.message); process.exit(1); });
114
+ });
115
+
116
+ program
117
+ .command('decompose')
118
+ .description('Decompõe uma Feature em Stories e Tasks (usado pelo GitHub Action)')
119
+ .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
120
+ .action(async (options) => {
121
+ const { decompose } = await import('../src/commands/decompose.mjs');
122
+ await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
123
+ });
124
+
125
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@spec-wave/cli",
3
+ "version": "0.1.0",
4
+ "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
+ "type": "module",
6
+ "bin": {
7
+ "spec-wave": "./bin/spec-wave.mjs"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "dependencies": {
20
+ "@anthropic-ai/sdk": "^0.36.3",
21
+ "@clack/prompts": "^0.9.1",
22
+ "@octokit/graphql": "^9.0.1",
23
+ "@octokit/rest": "^22.0.0",
24
+ "chalk": "^5.4.1",
25
+ "commander": "^13.1.0"
26
+ }
27
+ }
@@ -0,0 +1,36 @@
1
+ import { execSync } from 'node:child_process';
2
+
3
+ export async function resolveToken() {
4
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
5
+ if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
6
+ try {
7
+ const token = execSync('gh auth token', { stdio: ['pipe', 'pipe', 'pipe'] })
8
+ .toString()
9
+ .trim();
10
+ if (token) return token;
11
+ } catch {
12
+ // gh not installed or not authenticated
13
+ }
14
+ throw new Error(
15
+ 'GitHub token not found.\n' +
16
+ 'Set GITHUB_TOKEN or GH_TOKEN environment variable, or run: gh auth login'
17
+ );
18
+ }
19
+
20
+ export async function verifyTokenScopes(token) {
21
+ const { Octokit } = await import('@octokit/rest');
22
+ const octokit = new Octokit({ auth: token });
23
+ try {
24
+ const response = await octokit.request('GET /user');
25
+ const scopes = response.headers['x-oauth-scopes'] || '';
26
+ const scopeList = scopes.split(',').map(s => s.trim());
27
+ const hasProject = scopeList.includes('project') || scopeList.includes('read:project');
28
+ // `repo` covers private repos; `public_repo` covers public-only repos
29
+ const hasRepo = scopeList.includes('repo') || scopeList.includes('public_repo');
30
+ // `workflow` is required to create/update files under .github/workflows/
31
+ const hasWorkflow = scopeList.includes('workflow');
32
+ return { login: response.data.login, hasProject, hasRepo, hasWorkflow, scopes: scopeList };
33
+ } catch (err) {
34
+ throw new Error(`Token verification failed: ${err.message}`);
35
+ }
36
+ }
@@ -0,0 +1,238 @@
1
+ import { graphql } from '@octokit/graphql';
2
+
3
+ function makeClient(token) {
4
+ return graphql.defaults({
5
+ headers: { authorization: `token ${token}` },
6
+ });
7
+ }
8
+
9
+ export async function createProject(token, ownerId, title) {
10
+ const client = makeClient(token);
11
+ const result = await client(`
12
+ mutation CreateProject($ownerId: ID!, $title: String!) {
13
+ createProjectV2(input: { ownerId: $ownerId, title: $title }) {
14
+ projectV2 {
15
+ id
16
+ number
17
+ url
18
+ fields(first: 20) {
19
+ nodes {
20
+ ... on ProjectV2SingleSelectField {
21
+ id
22
+ name
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ `, { ownerId, title });
30
+
31
+ const project = result.createProjectV2.projectV2;
32
+ const statusField = project.fields.nodes.find(f => f.name === 'Status');
33
+ return { projectId: project.id, projectNumber: project.number, projectUrl: project.url, statusFieldId: statusField?.id };
34
+ }
35
+
36
+ export async function updateStatusField(token, fieldId, options) {
37
+ const client = makeClient(token);
38
+ await client(`
39
+ mutation UpdateStatusField($fieldId: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
40
+ updateProjectV2Field(input: {
41
+ fieldId: $fieldId
42
+ singleSelectOptions: $options
43
+ }) {
44
+ projectV2Field {
45
+ ... on ProjectV2SingleSelectField {
46
+ id
47
+ name
48
+ }
49
+ }
50
+ }
51
+ }
52
+ `, {
53
+ fieldId,
54
+ options: options.map(o => ({ name: o.name, color: o.color, description: '' })),
55
+ });
56
+ }
57
+
58
+ export async function createSingleSelectField(token, projectId, name, options) {
59
+ const client = makeClient(token);
60
+ const result = await client(`
61
+ mutation CreateField($projectId: ID!, $name: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
62
+ createProjectV2Field(input: {
63
+ projectId: $projectId
64
+ dataType: SINGLE_SELECT
65
+ name: $name
66
+ singleSelectOptions: $options
67
+ }) {
68
+ projectV2Field {
69
+ ... on ProjectV2SingleSelectField {
70
+ id
71
+ name
72
+ options { id name }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ `, {
78
+ projectId,
79
+ name,
80
+ options: options.map(o => ({ name: o.name, color: o.color, description: o.description || '' })),
81
+ });
82
+ const field = result.createProjectV2Field.projectV2Field;
83
+ const optionIds = {};
84
+ for (const o of field.options) optionIds[o.name] = o.id;
85
+ return { id: field.id, options: optionIds };
86
+ }
87
+
88
+ // Lê um campo SINGLE_SELECT existente (id + mapa nome→id das opções). Usado pelo
89
+ // comando `feature` como fallback quando o .spec-wave.json não traz os IDs.
90
+ export async function getSingleSelectField(token, projectId, fieldName) {
91
+ const client = makeClient(token);
92
+ const result = await client(`
93
+ query GetField($projectId: ID!) {
94
+ node(id: $projectId) {
95
+ ... on ProjectV2 {
96
+ fields(first: 50) {
97
+ nodes {
98
+ ... on ProjectV2SingleSelectField {
99
+ id
100
+ name
101
+ options { id name }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ `, { projectId });
109
+ const field = result.node.fields.nodes.find(f => f && f.name === fieldName);
110
+ if (!field) return null;
111
+ const optionIds = {};
112
+ for (const o of field.options) optionIds[o.name] = o.id;
113
+ return { id: field.id, options: optionIds };
114
+ }
115
+
116
+ // Lê os metadados atuais de um Project (id, number, url, title) + o campo "Etapa"
117
+ // (id e opções), em uma única query. Usado pelo comando `refresh`.
118
+ export async function getProjectSnapshot(token, projectId) {
119
+ const client = makeClient(token);
120
+ const result = await client(`
121
+ query Snapshot($projectId: ID!) {
122
+ node(id: $projectId) {
123
+ ... on ProjectV2 {
124
+ id
125
+ number
126
+ url
127
+ title
128
+ fields(first: 50) {
129
+ nodes {
130
+ ... on ProjectV2SingleSelectField {
131
+ id
132
+ name
133
+ options { id name }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ `, { projectId });
141
+ const project = result.node;
142
+ if (!project) return null;
143
+ // Mapa nome → {id, options{nome→id}} de todos os campos single-select do Project.
144
+ const fields = {};
145
+ for (const f of project.fields.nodes) {
146
+ if (!f || !f.name || !f.options) continue;
147
+ const options = {};
148
+ for (const o of f.options) options[o.name] = o.id;
149
+ fields[f.name] = { id: f.id, options };
150
+ }
151
+ return {
152
+ id: project.id,
153
+ number: project.number,
154
+ url: project.url,
155
+ title: project.title,
156
+ fields,
157
+ };
158
+ }
159
+
160
+ // Adiciona uma issue/PR (pelo node id do conteúdo) ao Project. Retorna o id do item criado.
161
+ export async function addProjectItem(token, projectId, contentId) {
162
+ const client = makeClient(token);
163
+ const result = await client(`
164
+ mutation AddItem($projectId: ID!, $contentId: ID!) {
165
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
166
+ item { id }
167
+ }
168
+ }
169
+ `, { projectId, contentId });
170
+ return result.addProjectV2ItemById.item.id;
171
+ }
172
+
173
+ // Cria a relação de sub-issue nativa do GitHub (parent → child). Ambos os IDs
174
+ // são node ids de Issue. Faz com que o filho exiba o pai e vice-versa na UI.
175
+ export async function addSubIssue(token, parentIssueId, childIssueId) {
176
+ const client = makeClient(token);
177
+ await client(`
178
+ mutation AddSubIssue($issueId: ID!, $subIssueId: ID!) {
179
+ addSubIssue(input: { issueId: $issueId, subIssueId: $subIssueId }) {
180
+ subIssue { id number }
181
+ }
182
+ }
183
+ `, { issueId: parentIssueId, subIssueId: childIssueId });
184
+ }
185
+
186
+ // Define o valor de um campo SINGLE_SELECT para um item do Project.
187
+ export async function setItemSingleSelect(token, projectId, itemId, fieldId, optionId) {
188
+ const client = makeClient(token);
189
+ await client(`
190
+ mutation SetField($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
191
+ updateProjectV2ItemFieldValue(input: {
192
+ projectId: $projectId
193
+ itemId: $itemId
194
+ fieldId: $fieldId
195
+ value: { singleSelectOptionId: $optionId }
196
+ }) {
197
+ projectV2Item { id }
198
+ }
199
+ }
200
+ `, { projectId, itemId, fieldId, optionId });
201
+ }
202
+
203
+ export async function createTextField(token, projectId, name) {
204
+ const client = makeClient(token);
205
+ const result = await client(`
206
+ mutation CreateTextField($projectId: ID!, $name: String!) {
207
+ createProjectV2Field(input: {
208
+ projectId: $projectId
209
+ dataType: TEXT
210
+ name: $name
211
+ }) {
212
+ projectV2Field {
213
+ ... on ProjectV2Field {
214
+ id
215
+ name
216
+ }
217
+ }
218
+ }
219
+ }
220
+ `, { projectId, name });
221
+ return result.createProjectV2Field.projectV2Field.id;
222
+ }
223
+
224
+ export async function linkProjectToRepo(token, projectId, repositoryId) {
225
+ const client = makeClient(token);
226
+ await client(`
227
+ mutation LinkProject($projectId: ID!, $repositoryId: ID!) {
228
+ linkProjectV2ToRepository(input: {
229
+ projectId: $projectId
230
+ repositoryId: $repositoryId
231
+ }) {
232
+ repository {
233
+ id
234
+ }
235
+ }
236
+ }
237
+ `, { projectId, repositoryId });
238
+ }
@@ -0,0 +1,157 @@
1
+ import { Octokit } from '@octokit/rest';
2
+
3
+ function makeOctokit(token) {
4
+ return new Octokit({ auth: token });
5
+ }
6
+
7
+ export async function getOwnerNodeId(token, owner) {
8
+ const octokit = makeOctokit(token);
9
+ try {
10
+ const res = await octokit.rest.orgs.get({ org: owner });
11
+ return res.data.node_id;
12
+ } catch {
13
+ const res = await octokit.rest.users.getByUsername({ username: owner });
14
+ return res.data.node_id;
15
+ }
16
+ }
17
+
18
+ export async function getRepoNodeId(token, owner, repo) {
19
+ const octokit = makeOctokit(token);
20
+ const res = await octokit.rest.repos.get({ owner, repo });
21
+ return res.data.node_id;
22
+ }
23
+
24
+ export async function getRepoDefaultBranch(token, owner, repo) {
25
+ const octokit = makeOctokit(token);
26
+ const res = await octokit.rest.repos.get({ owner, repo });
27
+ return res.data.default_branch;
28
+ }
29
+
30
+ export async function createLabel(token, owner, repo, label) {
31
+ const octokit = makeOctokit(token);
32
+ try {
33
+ await octokit.rest.issues.createLabel({
34
+ owner,
35
+ repo,
36
+ name: label.name,
37
+ color: label.color,
38
+ description: label.description,
39
+ });
40
+ } catch (err) {
41
+ // 422 = label already exists, skip
42
+ if (err.status !== 422) throw err;
43
+ }
44
+ }
45
+
46
+ export async function upsertFile(token, owner, repo, path, content, message) {
47
+ const octokit = makeOctokit(token);
48
+ let sha;
49
+ try {
50
+ const existing = await octokit.rest.repos.getContent({ owner, repo, path });
51
+ sha = existing.data.sha;
52
+ } catch {
53
+ // file doesn't exist yet
54
+ }
55
+ await octokit.rest.repos.createOrUpdateFileContents({
56
+ owner,
57
+ repo,
58
+ path,
59
+ message,
60
+ content: Buffer.from(content).toString('base64'),
61
+ ...(sha ? { sha } : {}),
62
+ });
63
+ }
64
+
65
+ export async function createIssue(token, owner, repo, title, body, labels) {
66
+ const octokit = makeOctokit(token);
67
+ const res = await octokit.rest.issues.create({ owner, repo, title, body, labels });
68
+ return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url };
69
+ }
70
+
71
+ export async function getIssue(token, owner, repo, issueNumber) {
72
+ const octokit = makeOctokit(token);
73
+ const res = await octokit.rest.issues.get({ owner, repo, issue_number: issueNumber });
74
+ return res.data;
75
+ }
76
+
77
+ export async function deleteLabel(token, owner, repo, name) {
78
+ const octokit = makeOctokit(token);
79
+ try {
80
+ await octokit.rest.issues.deleteLabel({ owner, repo, name });
81
+ return true;
82
+ } catch (err) {
83
+ if (err.status === 404) return false; // label já não existe
84
+ throw err;
85
+ }
86
+ }
87
+
88
+ export async function deleteFile(token, owner, repo, filePath, message) {
89
+ const octokit = makeOctokit(token);
90
+ let sha;
91
+ try {
92
+ const existing = await octokit.rest.repos.getContent({ owner, repo, path: filePath });
93
+ sha = existing.data.sha;
94
+ } catch (err) {
95
+ if (err.status === 404) return false; // arquivo já não existe
96
+ throw err;
97
+ }
98
+ await octokit.rest.repos.deleteFile({ owner, repo, path: filePath, message, sha });
99
+ return true;
100
+ }
101
+
102
+ export async function addLabel(token, owner, repo, issueNumber, labelName) {
103
+ const octokit = makeOctokit(token);
104
+ await octokit.rest.issues.addLabels({
105
+ owner,
106
+ repo,
107
+ issue_number: issueNumber,
108
+ labels: [labelName],
109
+ });
110
+ }
111
+
112
+ export async function removeLabel(token, owner, repo, issueNumber, labelName) {
113
+ const octokit = makeOctokit(token);
114
+ try {
115
+ await octokit.rest.issues.removeLabel({
116
+ owner,
117
+ repo,
118
+ issue_number: issueNumber,
119
+ name: labelName,
120
+ });
121
+ } catch (err) {
122
+ if (err.status !== 404) throw err;
123
+ }
124
+ }
125
+
126
+ export async function commentOnIssue(token, owner, repo, issueNumber, body) {
127
+ const octokit = makeOctokit(token);
128
+ await octokit.rest.issues.createComment({
129
+ owner,
130
+ repo,
131
+ issue_number: issueNumber,
132
+ body,
133
+ });
134
+ }
135
+
136
+ export async function isRepoInitialized(token, owner, repo) {
137
+ const octokit = makeOctokit(token);
138
+ try {
139
+ await octokit.rest.repos.listCommits({ owner, repo, per_page: 1 });
140
+ return true;
141
+ } catch (err) {
142
+ // GitHub returns 409 "Git Repository is empty" for repos with no commits
143
+ if (err.status === 409 || err.status === 404) return false;
144
+ throw err;
145
+ }
146
+ }
147
+
148
+ export async function getFileContent(token, owner, repo, path) {
149
+ const octokit = makeOctokit(token);
150
+ try {
151
+ const res = await octokit.rest.repos.getContent({ owner, repo, path });
152
+ return Buffer.from(res.data.content, 'base64').toString('utf-8');
153
+ } catch (err) {
154
+ if (err.status === 404) return null;
155
+ throw err;
156
+ }
157
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
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 em decomposição de trabalho ágil.
9
+ A partir da Feature fornecida (com spec.md e plan.md), gere uma lista de Stories e Tasks.
10
+
11
+ Responda APENAS com JSON válido neste formato:
12
+ {
13
+ "stories": [
14
+ {
15
+ "title": "[STORY] Título da story no formato 'Como <perfil>, quero <objetivo>, para <benefício>'",
16
+ "body": "Descrição da story com contexto e critérios de aceite relevantes",
17
+ "tasks": [
18
+ {
19
+ "title": "[TASK] Título técnico da task",
20
+ "body": "Descrição técnica detalhada"
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+
27
+ Regras:
28
+ - Cada Story deve ter 2–5 Tasks associadas
29
+ - Stories devem seguir o formato de User Story
30
+ - Tasks devem ser atividades técnicas concretas
31
+ - Gere entre 3 e 7 Stories por Feature`;
32
+
33
+ export async function decompose({ issueNumber }) {
34
+ const token = await resolveToken();
35
+ const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
36
+
37
+ if (!owner || !repo) {
38
+ throw new Error(
39
+ 'GITHUB_REPOSITORY env var não definida.\n' +
40
+ 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
41
+ ' GITHUB_REPOSITORY=owner/repo spec-wave decompose --issue-number 1'
42
+ );
43
+ }
44
+
45
+ const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
46
+ const slug = slugify(issue.title);
47
+ const featureDir = `docs/features/${slug}`;
48
+
49
+ const planContent = existsSync(`${featureDir}/plan.md`)
50
+ ? readFileSync(`${featureDir}/plan.md`, 'utf-8')
51
+ : '(plan.md não encontrado)';
52
+
53
+ const specContent = existsSync(`${featureDir}/spec.md`)
54
+ ? readFileSync(`${featureDir}/spec.md`, 'utf-8')
55
+ : '(spec.md não encontrado)';
56
+
57
+ console.log(`Decompondo feature: ${issue.title}`);
58
+
59
+ const userContent = [
60
+ `Feature: ${issue.title}`,
61
+ `Issue #${issueNumber}`,
62
+ `\n## spec.md\n${specContent}`,
63
+ `\n## plan.md\n${planContent}`,
64
+ ].join('\n');
65
+
66
+ const raw = await generateDocument(SYSTEM_PROMPT, userContent);
67
+
68
+ let decomposition;
69
+ try {
70
+ decomposition = JSON.parse(raw);
71
+ } catch {
72
+ // Try to extract JSON from the response
73
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
74
+ if (!jsonMatch) throw new Error('Claude did not return valid JSON');
75
+ decomposition = JSON.parse(jsonMatch[0]);
76
+ }
77
+
78
+ const created = [];
79
+
80
+ for (const story of decomposition.stories) {
81
+ console.log(`Criando story: ${story.title}`);
82
+ const storyOutput = execFileSync(
83
+ 'gh',
84
+ ['issue', 'create', '--title', story.title, '--body', story.body, '--label', '[STORY]'],
85
+ { encoding: 'utf-8' }
86
+ ).trim();
87
+ const storyUrl = storyOutput.trim();
88
+ created.push({ type: 'story', title: story.title, url: storyUrl });
89
+
90
+ for (const task of story.tasks || []) {
91
+ console.log(` Criando task: ${task.title}`);
92
+ const taskBody = `${task.body}\n\n_Story pai: ${storyUrl}_`;
93
+ execFileSync(
94
+ 'gh',
95
+ ['issue', 'create', '--title', task.title, '--body', taskBody, '--label', '[TASK]'],
96
+ { encoding: 'utf-8' }
97
+ );
98
+ }
99
+ }
100
+
101
+ // Remove trigger label
102
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
103
+
104
+ const storyList = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
105
+ await commentOnIssue(
106
+ token, owner, repo, parseInt(issueNumber, 10),
107
+ `🔀 **Decomposição concluída!**\n\n` +
108
+ `Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${storyList}\n\n` +
109
+ `Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.`
110
+ );
111
+
112
+ console.log(`Decomposição concluída: ${decomposition.stories.length} stories criadas.`);
113
+ }