@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,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
|
+
}
|
package/src/api/auth.mjs
ADDED
|
@@ -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
|
+
}
|