@spec-wave/cli 0.4.0 → 0.5.1
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 +18 -0
- package/package.json +1 -1
- package/src/api/github-rest.mjs +6 -0
- package/src/commands/code-review.mjs +142 -0
- package/src/commands/decompose.mjs +64 -1
- package/src/commands/init.mjs +17 -1
- package/src/commands/qa.mjs +135 -0
- package/src/commands/validate.mjs +66 -6
- package/src/config.mjs +2 -0
- package/src/templates/workflows/code-review.yml +26 -0
- package/src/templates/workflows/qa.yml +27 -0
package/bin/spec-wave.mjs
CHANGED
|
@@ -136,6 +136,24 @@ program
|
|
|
136
136
|
await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
137
137
|
});
|
|
138
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
|
+
|
|
139
157
|
program
|
|
140
158
|
.command('implement')
|
|
141
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,142 @@
|
|
|
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 [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
73
|
+
const cfgPath = path.join(process.cwd(), CONFIG_FILE);
|
|
74
|
+
let cfg = {};
|
|
75
|
+
try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
|
|
76
|
+
const owner = envOwner || cfg.owner;
|
|
77
|
+
const repo = envRepo || cfg.repo;
|
|
78
|
+
|
|
79
|
+
if (!owner || !repo) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
82
|
+
'Defina GITHUB_REPOSITORY=owner/repo ou rode dentro de um repositório com .spec-wave.json.'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pr = await getPR(token, owner, repo, parseInt(prNumber, 10));
|
|
87
|
+
const issueNums = extractIssueNumbers(pr.body || '');
|
|
88
|
+
|
|
89
|
+
if (issueNums.length === 0) {
|
|
90
|
+
console.log('PR sem referências a issues — nenhuma Feature atualizada.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Carrega projeto do .spec-wave.json
|
|
95
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
96
|
+
if (!existsSync(configPath)) {
|
|
97
|
+
console.warn(`${CONFIG_FILE} não encontrado — board não atualizado.`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
let project;
|
|
101
|
+
try {
|
|
102
|
+
project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.warn(`${CONFIG_FILE} corrompido (${err.message}) — board não atualizado.`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!project.id) {
|
|
108
|
+
console.warn(`Project não configurado em ${CONFIG_FILE} — board não atualizado.`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Resolve campos uma vez, reutiliza em todas as Features.
|
|
113
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
114
|
+
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
115
|
+
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const updated = [];
|
|
118
|
+
|
|
119
|
+
for (const num of issueNums) {
|
|
120
|
+
const feature = await resolveFeatureIssue(token, owner, repo, num);
|
|
121
|
+
if (!feature || seen.has(feature.number)) continue;
|
|
122
|
+
seen.add(feature.number);
|
|
123
|
+
try {
|
|
124
|
+
await setCodeReview(token, project, etapaField, statusField, feature.node_id);
|
|
125
|
+
updated.push(`#${feature.number} ${feature.title}`);
|
|
126
|
+
console.log(`Feature #${feature.number} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.warn(`Falha ao atualizar Feature #${feature.number}: ${err.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (updated.length > 0) {
|
|
133
|
+
await commentOnIssue(
|
|
134
|
+
token, owner, repo, parseInt(prNumber, 10),
|
|
135
|
+
`🔍 **Code Review iniciado**\n\n` +
|
|
136
|
+
`Feature(s) movida(s) para **${CODE_REVIEW_STAGE}**:\n\n` +
|
|
137
|
+
updated.map(f => `- ${f}`).join('\n')
|
|
138
|
+
).catch(() => {});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log(`code-review: ${updated.length} feature(s) atualizada(s).`);
|
|
142
|
+
}
|
|
@@ -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
|
@@ -8,7 +8,7 @@ import { runWizard } from '../ui/wizard.mjs';
|
|
|
8
8
|
import { setupProject } from '../setup/project.mjs';
|
|
9
9
|
import { setupLabels } from '../setup/labels.mjs';
|
|
10
10
|
import { setupFiles } from '../setup/files.mjs';
|
|
11
|
-
import { upsertFile } from '../api/github-rest.mjs';
|
|
11
|
+
import { upsertFile, getFileContent } from '../api/github-rest.mjs';
|
|
12
12
|
import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
|
|
13
13
|
|
|
14
14
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -90,6 +90,22 @@ export async function init(options) {
|
|
|
90
90
|
let projectUrl, projectId, projectNumber, projectFields;
|
|
91
91
|
if (options.skipProject) {
|
|
92
92
|
p.log.info('Pulando criação do GitHub Project (--skip-project).');
|
|
93
|
+
// Preserva o bloco project do .spec-wave.json existente (se houver).
|
|
94
|
+
try {
|
|
95
|
+
const raw = await getFileContent(token, owner, repo, CONFIG_FILE);
|
|
96
|
+
if (raw) {
|
|
97
|
+
const existing = JSON.parse(raw);
|
|
98
|
+
if (existing.project) {
|
|
99
|
+
projectUrl = existing.project.url ?? undefined;
|
|
100
|
+
projectId = existing.project.id ?? undefined;
|
|
101
|
+
projectNumber = existing.project.number ?? undefined;
|
|
102
|
+
projectFields = existing.project.fields ?? undefined;
|
|
103
|
+
p.log.info('Dados do Project preservados do config existente.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Sem config existente — mantém undefined (será gravado como null).
|
|
108
|
+
}
|
|
93
109
|
} else {
|
|
94
110
|
const projectSpinner = p.spinner();
|
|
95
111
|
projectSpinner.start('Criando GitHub Project...');
|
|
@@ -0,0 +1,135 @@
|
|
|
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 [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
68
|
+
const cfgPath = path.join(process.cwd(), CONFIG_FILE);
|
|
69
|
+
let cfg = {};
|
|
70
|
+
try { if (existsSync(cfgPath)) cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch {}
|
|
71
|
+
const owner = envOwner || cfg.owner;
|
|
72
|
+
const repo = envRepo || cfg.repo;
|
|
73
|
+
|
|
74
|
+
if (!owner || !repo) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
77
|
+
'Defina GITHUB_REPOSITORY=owner/repo ou rode dentro de um repositório com .spec-wave.json.'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const pr = await getPR(token, owner, repo, parseInt(prNumber, 10));
|
|
82
|
+
const issueNums = extractIssueNumbers(pr.body || '');
|
|
83
|
+
|
|
84
|
+
if (issueNums.length === 0) {
|
|
85
|
+
console.log('PR sem referências a issues — nenhuma Feature atualizada.');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
90
|
+
if (!existsSync(configPath)) {
|
|
91
|
+
console.warn(`${CONFIG_FILE} não encontrado — board não atualizado.`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let project;
|
|
95
|
+
try {
|
|
96
|
+
project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.warn(`${CONFIG_FILE} corrompido (${err.message}) — board não atualizado.`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!project.id) {
|
|
102
|
+
console.warn(`Project não configurado em ${CONFIG_FILE} — board não atualizado.`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
107
|
+
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
108
|
+
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const updated = [];
|
|
111
|
+
|
|
112
|
+
for (const num of issueNums) {
|
|
113
|
+
const feature = await resolveFeatureIssue(token, owner, repo, num);
|
|
114
|
+
if (!feature || seen.has(feature.number)) continue;
|
|
115
|
+
seen.add(feature.number);
|
|
116
|
+
try {
|
|
117
|
+
await setQA(token, project, etapaField, statusField, feature.node_id);
|
|
118
|
+
updated.push(`#${feature.number} ${feature.title}`);
|
|
119
|
+
console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.warn(`Falha ao atualizar Feature #${feature.number}: ${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (updated.length > 0) {
|
|
126
|
+
await commentOnIssue(
|
|
127
|
+
token, owner, repo, parseInt(prNumber, 10),
|
|
128
|
+
`🧪 **PR aprovado — QA iniciado**\n\n` +
|
|
129
|
+
`Feature(s) movida(s) para **${QA_STAGE}**:\n\n` +
|
|
130
|
+
updated.map(f => `- ${f}`).join('\n')
|
|
131
|
+
).catch(() => {});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(`qa: ${updated.length} feature(s) atualizada(s).`);
|
|
135
|
+
}
|
|
@@ -1,18 +1,68 @@
|
|
|
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();
|
|
9
|
-
const [
|
|
55
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
56
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
57
|
+
let cfg = {};
|
|
58
|
+
try { if (existsSync(configPath)) cfg = JSON.parse(readFileSync(configPath, 'utf-8')); } catch {}
|
|
59
|
+
const owner = envOwner || cfg.owner;
|
|
60
|
+
const repo = envRepo || cfg.repo;
|
|
10
61
|
|
|
11
62
|
if (!owner || !repo) {
|
|
12
63
|
throw new Error(
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
' GITHUB_REPOSITORY=owner/repo spec-wave validate --issue-number 1'
|
|
64
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
65
|
+
'Defina GITHUB_REPOSITORY=owner/repo ou rode o comando dentro de um repositório com .spec-wave.json.'
|
|
16
66
|
);
|
|
17
67
|
}
|
|
18
68
|
|
|
@@ -64,12 +114,22 @@ export async function validate({ issueNumber }) {
|
|
|
64
114
|
process.exit(1);
|
|
65
115
|
}
|
|
66
116
|
|
|
117
|
+
// Validação passou: move a issue para "🎉 Done" no board (best-effort).
|
|
118
|
+
let doneOk = false;
|
|
119
|
+
try {
|
|
120
|
+
await moveToDone(token, issue);
|
|
121
|
+
doneOk = !!DONE_STAGE;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.warn(`Falha ao atualizar status do board: ${err.message}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
67
126
|
await commentOnIssue(
|
|
68
127
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
69
128
|
`✅ **Validação concluída com sucesso!**\n\n` +
|
|
70
129
|
`- [\`${specPath}\`](${specPath}) ✓\n` +
|
|
71
130
|
`- [\`${planPath}\`](${planPath}) ✓\n\n` +
|
|
72
|
-
|
|
131
|
+
(doneOk ? `Status movido para **${DONE_STAGE}**. ` : '') +
|
|
132
|
+
`A Feature está pronta para decomposição. Use:\n` +
|
|
73
133
|
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:decompose"\n\`\`\``
|
|
74
134
|
);
|
|
75
135
|
|
package/src/config.mjs
CHANGED
|
@@ -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 }}
|