@spec-wave/cli 0.3.1 → 0.5.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 +32 -2
- package/package.json +1 -1
- package/src/api/github-rest.mjs +6 -0
- package/src/commands/code-review.mjs +138 -0
- package/src/commands/decompose.mjs +64 -1
- package/src/commands/init.mjs +1 -1
- package/src/commands/initiative.mjs +8 -0
- package/src/commands/issue.mjs +6 -1
- package/src/commands/qa.mjs +131 -0
- package/src/commands/validate.mjs +58 -2
- package/src/config.mjs +4 -0
- package/src/templates/workflows/code-review.yml +26 -0
- package/src/templates/workflows/qa.yml +27 -0
package/bin/spec-wave.mjs
CHANGED
|
@@ -49,9 +49,9 @@ program
|
|
|
49
49
|
|
|
50
50
|
program
|
|
51
51
|
.command('issue')
|
|
52
|
-
.description('Cria um work item (epic/feature/story/task...), opcionalmente como sub-issue, e adiciona ao board')
|
|
52
|
+
.description('Cria um work item (initiative/epic/feature/story/task...), opcionalmente como sub-issue, e adiciona ao board')
|
|
53
53
|
.requiredOption('--title <title>', 'Título (sem o prefixo de tipo, ex.: [FEATURE])')
|
|
54
|
-
.option('--type <type>', 'Tipo: epic, feature, story, task, bug, spike ou rfc', 'feature')
|
|
54
|
+
.option('--type <type>', 'Tipo: initiative, epic, feature, story, task, bug, spike ou rfc', 'feature')
|
|
55
55
|
.option('--parent <n>', 'Número da issue pai (cria como sub-issue dela)')
|
|
56
56
|
.option('--body <text>', 'Descrição')
|
|
57
57
|
.option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3')
|
|
@@ -61,6 +61,18 @@ program
|
|
|
61
61
|
await issue(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
program
|
|
65
|
+
.command('initiative')
|
|
66
|
+
.description('Atalho de `issue --type initiative` (nó raiz que agrupa Epics)')
|
|
67
|
+
.requiredOption('--title <title>', 'Título da initiative (sem o prefixo [INITIATIVE])')
|
|
68
|
+
.option('--body <text>', 'Descrição da initiative')
|
|
69
|
+
.option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3 (adiciona label)')
|
|
70
|
+
.option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
|
|
71
|
+
.action(async (options) => {
|
|
72
|
+
const { initiative } = await import('../src/commands/initiative.mjs');
|
|
73
|
+
await initiative(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
74
|
+
});
|
|
75
|
+
|
|
64
76
|
program
|
|
65
77
|
.command('feature')
|
|
66
78
|
.description('Atalho de `issue --type feature`')
|
|
@@ -124,6 +136,24 @@ program
|
|
|
124
136
|
await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
125
137
|
});
|
|
126
138
|
|
|
139
|
+
program
|
|
140
|
+
.command('code-review')
|
|
141
|
+
.description('Move Feature para Code Review ao abrir um PR (usado pelo GitHub Action)')
|
|
142
|
+
.requiredOption('--pr-number <n>', 'Número do Pull Request')
|
|
143
|
+
.action(async (options) => {
|
|
144
|
+
const { codeReview } = await import('../src/commands/code-review.mjs');
|
|
145
|
+
await codeReview(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
program
|
|
149
|
+
.command('qa')
|
|
150
|
+
.description('Move Feature para QA ao aprovar um PR (usado pelo GitHub Action)')
|
|
151
|
+
.requiredOption('--pr-number <n>', 'Número do Pull Request')
|
|
152
|
+
.action(async (options) => {
|
|
153
|
+
const { qa } = await import('../src/commands/qa.mjs');
|
|
154
|
+
await qa(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
155
|
+
});
|
|
156
|
+
|
|
127
157
|
program
|
|
128
158
|
.command('implement')
|
|
129
159
|
.description('Aciona o spec-kit implement para uma Story (todas as tasks) ou uma Task')
|
package/package.json
CHANGED
package/src/api/github-rest.mjs
CHANGED
|
@@ -145,6 +145,12 @@ export async function isRepoInitialized(token, owner, repo) {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
export async function getPR(token, owner, repo, prNumber) {
|
|
149
|
+
const octokit = makeOctokit(token);
|
|
150
|
+
const res = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
|
151
|
+
return res.data;
|
|
152
|
+
}
|
|
153
|
+
|
|
148
154
|
export async function getFileContent(token, owner, repo, path) {
|
|
149
155
|
const octokit = makeOctokit(token);
|
|
150
156
|
try {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
4
|
+
import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
+
import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent } from '../api/github-graphql.mjs';
|
|
6
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
7
|
+
import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
|
|
8
|
+
|
|
9
|
+
// Campo "Etapa" (custom) → "👀 Code Review". Campo "Status" (nativo) → "Todo".
|
|
10
|
+
const CODE_REVIEW_STAGE = STATUS_OPTIONS.find(s => s.name.includes('Code Review'))?.name;
|
|
11
|
+
const TODO_STATUS = 'Todo';
|
|
12
|
+
|
|
13
|
+
// Extrai números de issues referenciadas no corpo do PR (Closes #N, Fixes #N, #N solto).
|
|
14
|
+
function extractIssueNumbers(body) {
|
|
15
|
+
if (!body) return [];
|
|
16
|
+
const nums = new Set();
|
|
17
|
+
const re = /(?:closes?|fixes?|resolves?)\s+#(\d+)|(?<![/\w#])#(\d+)/gi;
|
|
18
|
+
for (const m of body.matchAll(re)) {
|
|
19
|
+
const n = parseInt(m[1] || m[2], 10);
|
|
20
|
+
if (n) nums.add(n);
|
|
21
|
+
}
|
|
22
|
+
return [...nums];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolve o campo SINGLE_SELECT pelo nome: usa .spec-wave.json, legado ou API.
|
|
26
|
+
async function resolveField(token, project, name) {
|
|
27
|
+
if (project.fields?.[name]) return project.fields[name];
|
|
28
|
+
if (name === 'Etapa' && project.etapaFieldId) {
|
|
29
|
+
return { id: project.etapaFieldId, options: project.stageOptions || {} };
|
|
30
|
+
}
|
|
31
|
+
return await getSingleSelectField(token, project.id, name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A partir de qualquer issue (Feature/Story/Task), sobe a hierarquia e retorna a Feature.
|
|
35
|
+
async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
36
|
+
let issue;
|
|
37
|
+
try {
|
|
38
|
+
issue = await getIssue(token, owner, repo, issueNumber);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const type = detectIssueType(issue);
|
|
43
|
+
if (type === 'Feature') return issue;
|
|
44
|
+
if (type !== 'Story' && type !== 'Task') return null;
|
|
45
|
+
let currentNodeId = issue.node_id;
|
|
46
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
47
|
+
const parent = await getIssueParent(token, currentNodeId);
|
|
48
|
+
if (!parent) return null;
|
|
49
|
+
if (detectIssueType({ title: parent.title }) === 'Feature') {
|
|
50
|
+
return await getIssue(token, owner, repo, parent.number).catch(() => null);
|
|
51
|
+
}
|
|
52
|
+
currentNodeId = parent.nodeId;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Move um item do board para Etapa "👀 Code Review" e Status "Todo".
|
|
58
|
+
async function setCodeReview(token, project, etapaField, statusField, nodeId) {
|
|
59
|
+
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
60
|
+
if (etapaField?.id && CODE_REVIEW_STAGE) {
|
|
61
|
+
const optionId = etapaField.options?.[CODE_REVIEW_STAGE];
|
|
62
|
+
if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
63
|
+
}
|
|
64
|
+
if (statusField?.id) {
|
|
65
|
+
const optionId = statusField.options?.[TODO_STATUS];
|
|
66
|
+
if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function codeReview({ prNumber }) {
|
|
71
|
+
const token = await resolveToken();
|
|
72
|
+
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
73
|
+
|
|
74
|
+
if (!owner || !repo) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
77
|
+
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
78
|
+
` GITHUB_REPOSITORY=owner/repo spec-wave code-review --pr-number ${prNumber}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const pr = await getPR(token, owner, repo, parseInt(prNumber, 10));
|
|
83
|
+
const issueNums = extractIssueNumbers(pr.body || '');
|
|
84
|
+
|
|
85
|
+
if (issueNums.length === 0) {
|
|
86
|
+
console.log('PR sem referências a issues — nenhuma Feature atualizada.');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Carrega projeto do .spec-wave.json
|
|
91
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
92
|
+
if (!existsSync(configPath)) {
|
|
93
|
+
console.warn(`${CONFIG_FILE} não encontrado — board não atualizado.`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let project;
|
|
97
|
+
try {
|
|
98
|
+
project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn(`${CONFIG_FILE} corrompido (${err.message}) — board não atualizado.`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!project.id) {
|
|
104
|
+
console.warn(`Project não configurado em ${CONFIG_FILE} — board não atualizado.`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Resolve campos uma vez, reutiliza em todas as Features.
|
|
109
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
110
|
+
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
111
|
+
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
const updated = [];
|
|
114
|
+
|
|
115
|
+
for (const num of issueNums) {
|
|
116
|
+
const feature = await resolveFeatureIssue(token, owner, repo, num);
|
|
117
|
+
if (!feature || seen.has(feature.number)) continue;
|
|
118
|
+
seen.add(feature.number);
|
|
119
|
+
try {
|
|
120
|
+
await setCodeReview(token, project, etapaField, statusField, feature.node_id);
|
|
121
|
+
updated.push(`#${feature.number} ${feature.title}`);
|
|
122
|
+
console.log(`Feature #${feature.number} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.warn(`Falha ao atualizar Feature #${feature.number}: ${err.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (updated.length > 0) {
|
|
129
|
+
await commentOnIssue(
|
|
130
|
+
token, owner, repo, parseInt(prNumber, 10),
|
|
131
|
+
`🔍 **Code Review iniciado**\n\n` +
|
|
132
|
+
`Feature(s) movida(s) para **${CODE_REVIEW_STAGE}**:\n\n` +
|
|
133
|
+
updated.map(f => `- ${f}`).join('\n')
|
|
134
|
+
).catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`code-review: ${updated.length} feature(s) atualizada(s).`);
|
|
138
|
+
}
|
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
3
4
|
import { getIssue, createIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
4
|
-
import { addSubIssue } from '../api/github-graphql.mjs';
|
|
5
|
+
import { addSubIssue, addProjectItem, setItemSingleSelect, getSingleSelectField } from '../api/github-graphql.mjs';
|
|
5
6
|
import { generateDocument } from '../lib/claude.mjs';
|
|
6
7
|
import { slugify } from '../lib/slugify.mjs';
|
|
8
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
9
|
+
|
|
10
|
+
const READY_STAGE = 'Todo';
|
|
11
|
+
|
|
12
|
+
// Carrega o projeto do .spec-wave.json. Retorna null se ausente ou sem project.id.
|
|
13
|
+
function loadProject() {
|
|
14
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
15
|
+
if (!existsSync(configPath)) return null;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(configPath, 'utf-8')).project || null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Resolve o campo Status do Project: usa .spec-wave.json ou consulta API.
|
|
24
|
+
async function resolveStatusField(token, project) {
|
|
25
|
+
if (project.fields?.Status) return project.fields.Status;
|
|
26
|
+
return await getSingleSelectField(token, project.id, 'Status');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Adiciona issue ao board e move para a etapa informada. Best-effort.
|
|
30
|
+
async function moveToStage(token, project, statusField, nodeId, stageName) {
|
|
31
|
+
if (!project?.id || !statusField) return;
|
|
32
|
+
const optionId = statusField.options?.[stageName];
|
|
33
|
+
if (!statusField.id || !optionId) return;
|
|
34
|
+
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
35
|
+
await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
36
|
+
}
|
|
7
37
|
|
|
8
38
|
const SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
|
|
9
39
|
A partir da Feature fornecida (com spec.md e plan.md), gere uma lista de Stories e Tasks.
|
|
@@ -47,6 +77,17 @@ export async function decompose({ issueNumber }) {
|
|
|
47
77
|
|
|
48
78
|
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
49
79
|
const slug = slugify(issue.title);
|
|
80
|
+
|
|
81
|
+
// Carrega projeto e resolve campo Status uma vez (reutilizado em todos os itens).
|
|
82
|
+
const project = loadProject();
|
|
83
|
+
let statusField = null;
|
|
84
|
+
if (project?.id && READY_STAGE) {
|
|
85
|
+
try {
|
|
86
|
+
statusField = await resolveStatusField(token, project);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
50
91
|
const featureDir = `docs/features/${slug}`;
|
|
51
92
|
|
|
52
93
|
const planContent = existsSync(`${featureDir}/plan.md`)
|
|
@@ -101,6 +142,13 @@ export async function decompose({ issueNumber }) {
|
|
|
101
142
|
console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
|
|
102
143
|
}
|
|
103
144
|
|
|
145
|
+
// Move story para Ready no board.
|
|
146
|
+
try {
|
|
147
|
+
await moveToStage(token, project, statusField, createdStory.nodeId, READY_STAGE);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.warn(` Falha ao mover story #${createdStory.number} para "${READY_STAGE}": ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
104
152
|
for (const task of story.tasks || []) {
|
|
105
153
|
console.log(` Criando task: ${task.title}`);
|
|
106
154
|
const taskTitle = `[TASK] ${task.title}`;
|
|
@@ -113,9 +161,24 @@ export async function decompose({ issueNumber }) {
|
|
|
113
161
|
} catch (err) {
|
|
114
162
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
|
|
115
163
|
}
|
|
164
|
+
|
|
165
|
+
// Move task para Ready no board.
|
|
166
|
+
try {
|
|
167
|
+
await moveToStage(token, project, statusField, createdTask.nodeId, READY_STAGE);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.warn(` Falha ao mover task #${createdTask.number} para "${READY_STAGE}": ${err.message}`);
|
|
170
|
+
}
|
|
116
171
|
}
|
|
117
172
|
}
|
|
118
173
|
|
|
174
|
+
// Move a própria Feature para Ready no board.
|
|
175
|
+
try {
|
|
176
|
+
await moveToStage(token, project, statusField, featureNodeId, READY_STAGE);
|
|
177
|
+
if (project?.id && statusField) console.log(`Feature movida para "${READY_STAGE}" no board.`);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.warn(`Falha ao mover Feature para "${READY_STAGE}": ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
119
182
|
// Remove trigger label
|
|
120
183
|
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
121
184
|
|
package/src/commands/init.mjs
CHANGED
|
@@ -122,7 +122,7 @@ export async function init(options) {
|
|
|
122
122
|
labelSpinner.start('Criando labels...');
|
|
123
123
|
try {
|
|
124
124
|
await setupLabels(token, owner, repo, labelSpinner);
|
|
125
|
-
labelSpinner.stop('Labels criadas (
|
|
125
|
+
labelSpinner.stop('Labels criadas (16 labels)');
|
|
126
126
|
} catch (err) {
|
|
127
127
|
labelSpinner.stop('');
|
|
128
128
|
p.log.error(`Erro ao criar labels: ${err.message}`);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { issue } from './issue.mjs';
|
|
2
|
+
|
|
3
|
+
// `initiative` é um atalho de `issue --type initiative`. A Initiative é o nó raiz
|
|
4
|
+
// da hierarquia (Initiative → Epic → Feature → Story → Task) e agrupa Epics.
|
|
5
|
+
// Toda a lógica vive em issue.mjs.
|
|
6
|
+
export async function initiative(options) {
|
|
7
|
+
return issue({ ...options, type: 'initiative' });
|
|
8
|
+
}
|
package/src/commands/issue.mjs
CHANGED
|
@@ -179,8 +179,13 @@ export async function issue(options) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
const parentLine = parent ? ` (sub-issue de #${parent.number})` : '';
|
|
182
|
+
const hints = {
|
|
183
|
+
Feature: `Próximo: \`/spec-wave plan ${created.number}\` para o planejamento técnico.`,
|
|
184
|
+
Initiative: `Próximo: crie Epics sob esta Initiative com \`spec-wave issue --type epic --parent ${created.number} --title "..."\`.`,
|
|
185
|
+
Epic: `Próximo: crie Features sob este Epic com \`spec-wave feature --parent ${created.number} --title "..."\`.`,
|
|
186
|
+
};
|
|
182
187
|
p.outro(
|
|
183
188
|
`${chalk.green('✓')} ${type} #${created.number} criado em "${INITIAL_STAGE}"${parentLine}.\n` +
|
|
184
|
-
` ${type
|
|
189
|
+
` ${hints[type] || ''}`
|
|
185
190
|
);
|
|
186
191
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
4
|
+
import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
+
import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent } from '../api/github-graphql.mjs';
|
|
6
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
7
|
+
import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
|
|
8
|
+
|
|
9
|
+
const QA_STAGE = STATUS_OPTIONS.find(s => s.name.includes('QA'))?.name;
|
|
10
|
+
const TODO_STATUS = 'Todo';
|
|
11
|
+
|
|
12
|
+
function extractIssueNumbers(body) {
|
|
13
|
+
if (!body) return [];
|
|
14
|
+
const nums = new Set();
|
|
15
|
+
const re = /(?:closes?|fixes?|resolves?)\s+#(\d+)|(?<![/\w#])#(\d+)/gi;
|
|
16
|
+
for (const m of body.matchAll(re)) {
|
|
17
|
+
const n = parseInt(m[1] || m[2], 10);
|
|
18
|
+
if (n) nums.add(n);
|
|
19
|
+
}
|
|
20
|
+
return [...nums];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resolveField(token, project, name) {
|
|
24
|
+
if (project.fields?.[name]) return project.fields[name];
|
|
25
|
+
if (name === 'Etapa' && project.etapaFieldId) {
|
|
26
|
+
return { id: project.etapaFieldId, options: project.stageOptions || {} };
|
|
27
|
+
}
|
|
28
|
+
return await getSingleSelectField(token, project.id, name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
32
|
+
let issue;
|
|
33
|
+
try {
|
|
34
|
+
issue = await getIssue(token, owner, repo, issueNumber);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const type = detectIssueType(issue);
|
|
39
|
+
if (type === 'Feature') return issue;
|
|
40
|
+
if (type !== 'Story' && type !== 'Task') return null;
|
|
41
|
+
let currentNodeId = issue.node_id;
|
|
42
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
43
|
+
const parent = await getIssueParent(token, currentNodeId);
|
|
44
|
+
if (!parent) return null;
|
|
45
|
+
if (detectIssueType({ title: parent.title }) === 'Feature') {
|
|
46
|
+
return await getIssue(token, owner, repo, parent.number).catch(() => null);
|
|
47
|
+
}
|
|
48
|
+
currentNodeId = parent.nodeId;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function setQA(token, project, etapaField, statusField, nodeId) {
|
|
54
|
+
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
55
|
+
if (etapaField?.id && QA_STAGE) {
|
|
56
|
+
const optionId = etapaField.options?.[QA_STAGE];
|
|
57
|
+
if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
58
|
+
}
|
|
59
|
+
if (statusField?.id) {
|
|
60
|
+
const optionId = statusField.options?.[TODO_STATUS];
|
|
61
|
+
if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function qa({ prNumber }) {
|
|
66
|
+
const token = await resolveToken();
|
|
67
|
+
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
68
|
+
|
|
69
|
+
if (!owner || !repo) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
72
|
+
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
73
|
+
` GITHUB_REPOSITORY=owner/repo spec-wave qa --pr-number ${prNumber}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const pr = await getPR(token, owner, repo, parseInt(prNumber, 10));
|
|
78
|
+
const issueNums = extractIssueNumbers(pr.body || '');
|
|
79
|
+
|
|
80
|
+
if (issueNums.length === 0) {
|
|
81
|
+
console.log('PR sem referências a issues — nenhuma Feature atualizada.');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
86
|
+
if (!existsSync(configPath)) {
|
|
87
|
+
console.warn(`${CONFIG_FILE} não encontrado — board não atualizado.`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let project;
|
|
91
|
+
try {
|
|
92
|
+
project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.warn(`${CONFIG_FILE} corrompido (${err.message}) — board não atualizado.`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!project.id) {
|
|
98
|
+
console.warn(`Project não configurado em ${CONFIG_FILE} — board não atualizado.`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
103
|
+
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
104
|
+
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
const updated = [];
|
|
107
|
+
|
|
108
|
+
for (const num of issueNums) {
|
|
109
|
+
const feature = await resolveFeatureIssue(token, owner, repo, num);
|
|
110
|
+
if (!feature || seen.has(feature.number)) continue;
|
|
111
|
+
seen.add(feature.number);
|
|
112
|
+
try {
|
|
113
|
+
await setQA(token, project, etapaField, statusField, feature.node_id);
|
|
114
|
+
updated.push(`#${feature.number} ${feature.title}`);
|
|
115
|
+
console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.warn(`Falha ao atualizar Feature #${feature.number}: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (updated.length > 0) {
|
|
122
|
+
await commentOnIssue(
|
|
123
|
+
token, owner, repo, parseInt(prNumber, 10),
|
|
124
|
+
`🧪 **PR aprovado — QA iniciado**\n\n` +
|
|
125
|
+
`Feature(s) movida(s) para **${QA_STAGE}**:\n\n` +
|
|
126
|
+
updated.map(f => `- ${f}`).join('\n')
|
|
127
|
+
).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`qa: ${updated.length} feature(s) atualizada(s).`);
|
|
131
|
+
}
|
|
@@ -1,8 +1,54 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
3
4
|
import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
+
import { addProjectItem, setItemSingleSelect, getSingleSelectField } from '../api/github-graphql.mjs';
|
|
4
6
|
import { slugify } from '../lib/slugify.mjs';
|
|
5
|
-
import { REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS } from '../config.mjs';
|
|
7
|
+
import { CONFIG_FILE, REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS } from '../config.mjs';
|
|
8
|
+
|
|
9
|
+
// Opção nativa do campo Status do GitHub Projects (Todo / In Progress / Done).
|
|
10
|
+
const DONE_STAGE = 'Done';
|
|
11
|
+
|
|
12
|
+
// Resolve um campo SINGLE_SELECT pelo nome: usa o .spec-wave.json, cai para o
|
|
13
|
+
// formato legado (etapaFieldId/stageOptions) e, por fim, consulta o Project.
|
|
14
|
+
async function resolveField(token, project, name) {
|
|
15
|
+
if (project.fields && project.fields[name]) return project.fields[name];
|
|
16
|
+
if (name === 'Etapa' && project.etapaFieldId) {
|
|
17
|
+
return { id: project.etapaFieldId, options: project.stageOptions || {} };
|
|
18
|
+
}
|
|
19
|
+
return await getSingleSelectField(token, project.id, name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Move o item da issue para a Etapa "🎉 Done" no board. Best-effort: loga e
|
|
23
|
+
// segue se o .spec-wave.json não tiver o Project ou o campo não for encontrado.
|
|
24
|
+
async function moveToDone(token, issue) {
|
|
25
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
26
|
+
if (!existsSync(configPath)) {
|
|
27
|
+
console.warn(`${CONFIG_FILE} não encontrado — status do board não atualizado.`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
let project;
|
|
31
|
+
try {
|
|
32
|
+
project = (JSON.parse(readFileSync(configPath, 'utf-8')).project) || {};
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.warn(`${CONFIG_FILE} corrompido (${err.message}) — status do board não atualizado.`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!project.id) {
|
|
38
|
+
console.warn(`Project não configurado no ${CONFIG_FILE} — status do board não atualizado.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// addProjectItem é idempotente: retorna o item existente se a issue já está no board.
|
|
42
|
+
const itemId = await addProjectItem(token, project.id, issue.node_id);
|
|
43
|
+
const field = await resolveField(token, project, 'Status');
|
|
44
|
+
const optionId = field?.options?.[DONE_STAGE];
|
|
45
|
+
if (field?.id && optionId) {
|
|
46
|
+
await setItemSingleSelect(token, project.id, itemId, field.id, optionId);
|
|
47
|
+
console.log(`Status do board atualizado para "${DONE_STAGE}".`);
|
|
48
|
+
} else {
|
|
49
|
+
console.warn(`Etapa "${DONE_STAGE}" não encontrada no Project — status do board não atualizado.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
6
52
|
|
|
7
53
|
export async function validate({ issueNumber }) {
|
|
8
54
|
const token = await resolveToken();
|
|
@@ -64,12 +110,22 @@ export async function validate({ issueNumber }) {
|
|
|
64
110
|
process.exit(1);
|
|
65
111
|
}
|
|
66
112
|
|
|
113
|
+
// Validação passou: move a issue para "🎉 Done" no board (best-effort).
|
|
114
|
+
let doneOk = false;
|
|
115
|
+
try {
|
|
116
|
+
await moveToDone(token, issue);
|
|
117
|
+
doneOk = !!DONE_STAGE;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.warn(`Falha ao atualizar status do board: ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
67
122
|
await commentOnIssue(
|
|
68
123
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
69
124
|
`✅ **Validação concluída com sucesso!**\n\n` +
|
|
70
125
|
`- [\`${specPath}\`](${specPath}) ✓\n` +
|
|
71
126
|
`- [\`${planPath}\`](${planPath}) ✓\n\n` +
|
|
72
|
-
|
|
127
|
+
(doneOk ? `Status movido para **${DONE_STAGE}**. ` : '') +
|
|
128
|
+
`A Feature está pronta para decomposição. Use:\n` +
|
|
73
129
|
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:decompose"\n\`\`\``
|
|
74
130
|
);
|
|
75
131
|
|
package/src/config.mjs
CHANGED
|
@@ -53,6 +53,7 @@ export const CUSTOM_FIELDS = [
|
|
|
53
53
|
name: 'Work Item Type',
|
|
54
54
|
dataType: 'SINGLE_SELECT',
|
|
55
55
|
options: [
|
|
56
|
+
{ name: 'Initiative', color: 'PINK', description: 'Agrupamento estratégico de Epics' },
|
|
56
57
|
{ name: 'Epic', color: 'PURPLE', description: 'Objetivo estratégico' },
|
|
57
58
|
{ name: 'Feature', color: 'BLUE', description: 'Capacidade funcional' },
|
|
58
59
|
{ name: 'Story', color: 'GREEN', description: 'Necessidade do usuário' },
|
|
@@ -110,6 +111,7 @@ export const WORK_ITEM_TYPES = CUSTOM_FIELDS
|
|
|
110
111
|
.options.map(o => o.name);
|
|
111
112
|
|
|
112
113
|
export const TYPE_LABELS = [
|
|
114
|
+
{ name: '[INITIATIVE]', color: 'C5DEF5', description: 'Agrupamento estratégico de Epics' },
|
|
113
115
|
{ name: '[EPIC]', color: '7B61FF', description: 'Objetivo estratégico' },
|
|
114
116
|
{ name: '[FEATURE]', color: '0075CA', description: 'Capacidade funcional' },
|
|
115
117
|
{ name: '[STORY]', color: '0E8A16', description: 'Necessidade do usuário' },
|
|
@@ -140,6 +142,8 @@ export const WORKFLOW_FILES = [
|
|
|
140
142
|
'generate-spec.yml',
|
|
141
143
|
'validate.yml',
|
|
142
144
|
'decompose.yml',
|
|
145
|
+
'code-review.yml',
|
|
146
|
+
'qa.yml',
|
|
143
147
|
];
|
|
144
148
|
|
|
145
149
|
export const ISSUE_TEMPLATE_FILES = [
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Code Review
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened, reopened]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
code-review:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
issues: write
|
|
12
|
+
pull-requests: write
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: '20'
|
|
21
|
+
|
|
22
|
+
- name: Move Feature to Code Review
|
|
23
|
+
run: npx @spec-wave/cli code-review --pr-number ${{ github.event.pull_request.number }}
|
|
24
|
+
env:
|
|
25
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
26
|
+
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: QA
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request_review:
|
|
5
|
+
types: [submitted]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
qa:
|
|
9
|
+
if: github.event.review.state == 'approved'
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
issues: write
|
|
13
|
+
pull-requests: write
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-node@v4
|
|
20
|
+
with:
|
|
21
|
+
node-version: '20'
|
|
22
|
+
|
|
23
|
+
- name: Move Feature to QA
|
|
24
|
+
run: npx @spec-wave/cli qa --pr-number ${{ github.event.pull_request.number }}
|
|
25
|
+
env:
|
|
26
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
27
|
+
GITHUB_REPOSITORY: ${{ github.repository }}
|