@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
 
@@ -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 (15 labels)');
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
+ }
@@ -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 === 'Feature' ? `Próximo: \`/spec-wave plan ${created.number}\` para o planejamento técnico.` : ''}`
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
- `A Feature está pronta para decomposição. Mova o card para **📋 Backlog Técnico** ou use:\n` +
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 }}