@spec-wave/cli 0.5.11 → 0.7.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
@@ -193,4 +193,41 @@ program
193
193
  await implement({ issue, ...options }).catch(err => { console.error(err.message); process.exit(1); });
194
194
  });
195
195
 
196
+ program
197
+ .command('order')
198
+ .description('Ordena as Stories de uma Feature pelas dependências (topológica)')
199
+ .argument('<feature>', 'Número da issue da Feature, ex.: 12 ou #12')
200
+ .action(async (feature) => {
201
+ const { order } = await import('../src/commands/order.mjs');
202
+ await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
203
+ });
204
+
205
+ program
206
+ .command('task')
207
+ .description('Gerencia uma Task no board: start (Status "In Progress") ou done (Done)')
208
+ .argument('<action>', 'Ação: start ou done')
209
+ .argument('<n>', 'Número da issue da Task, ex.: 12 ou #12')
210
+ .action(async (action, n) => {
211
+ const { task } = await import('../src/commands/task.mjs');
212
+ await task({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
213
+ });
214
+
215
+ program
216
+ .command('story')
217
+ .description('Gerencia uma Story no board: review (move para Code Review)')
218
+ .argument('<action>', 'Ação: review')
219
+ .argument('<n>', 'Número da issue da Story, ex.: 12 ou #12')
220
+ .action(async (action, n) => {
221
+ const { story } = await import('../src/commands/story.mjs');
222
+ await story({ action, issue: n }).catch(err => { console.error(err.message); process.exit(1); });
223
+ });
224
+
225
+ program
226
+ .command('doctor')
227
+ .description('Diagnostica a configuração do spec-wave no repositório atual')
228
+ .action(async () => {
229
+ const { doctor } = await import('../src/commands/doctor.mjs');
230
+ await doctor().catch(err => { console.error(err.message); process.exit(1); });
231
+ });
232
+
196
233
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.5.11",
3
+ "version": "0.7.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": {
@@ -13,6 +13,9 @@
13
13
  "bin",
14
14
  "src"
15
15
  ],
16
+ "scripts": {
17
+ "test": "node --test test/*.test.mjs"
18
+ },
16
19
  "engines": {
17
20
  "node": ">=20"
18
21
  },
@@ -87,10 +87,12 @@ export async function upsertFile(token, owner, repo, path, content, message) {
87
87
  });
88
88
  }
89
89
 
90
+ // `id` é o database id da issue — exigido pela API de dependências
91
+ // (blocked_by), que não aceita number nem node id.
90
92
  export async function createIssue(token, owner, repo, title, body, labels) {
91
93
  const octokit = makeOctokit(token);
92
94
  const res = await octokit.rest.issues.create({ owner, repo, title, body, labels });
93
- return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url };
95
+ return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url, id: res.data.id };
94
96
  }
95
97
 
96
98
  export async function getIssue(token, owner, repo, issueNumber) {
@@ -148,6 +150,48 @@ export async function removeLabel(token, owner, repo, issueNumber, labelName) {
148
150
  }
149
151
  }
150
152
 
153
+ // Lista todos os comentários de uma issue/PR: [{ author, body, createdAt }].
154
+ // Paginado — traz o histórico completo (usado pela crítica adversarial).
155
+ export async function listIssueComments(token, owner, repo, issueNumber) {
156
+ const octokit = makeOctokit(token);
157
+ const comments = await octokit.paginate(octokit.rest.issues.listComments, {
158
+ owner,
159
+ repo,
160
+ issue_number: issueNumber,
161
+ per_page: 100,
162
+ });
163
+ return comments.map(c => ({
164
+ author: c.user?.login || '',
165
+ body: c.body || '',
166
+ createdAt: c.created_at,
167
+ }));
168
+ }
169
+
170
+ // Marca uma issue como bloqueada por outra (relação nativa do GitHub).
171
+ // `blockingIssueId` é o DATABASE id da issue bloqueadora (não o number nem o
172
+ // node id — ver createIssue). Erros propagam: o chamador usa como fallback.
173
+ export async function addBlockedBy(token, owner, repo, issueNumber, blockingIssueId) {
174
+ const octokit = makeOctokit(token);
175
+ await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by', {
176
+ owner,
177
+ repo,
178
+ issue_number: issueNumber,
179
+ issue_id: blockingIssueId,
180
+ });
181
+ }
182
+
183
+ // Lista as issues que bloqueiam `issueNumber`: [{ number, title, state, id }].
184
+ export async function listBlockedBy(token, owner, repo, issueNumber) {
185
+ const octokit = makeOctokit(token);
186
+ const issues = await octokit.paginate('GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by', {
187
+ owner,
188
+ repo,
189
+ issue_number: issueNumber,
190
+ per_page: 100,
191
+ });
192
+ return issues.map(i => ({ number: i.number, title: i.title, state: i.state, id: i.id }));
193
+ }
194
+
151
195
  export async function commentOnIssue(token, owner, repo, issueNumber, body) {
152
196
  const octokit = makeOctokit(token);
153
197
  await octokit.rest.issues.createComment({
@@ -2,13 +2,17 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
4
  import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
5
- import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
5
+ import { addProjectItem, getIssueParent, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
6
6
  import { detectIssueType } from '../lib/issue-type.mjs';
7
- import { CONFIG_FILE, STATUS_OPTIONS, STAGE_ORDER, PROGRESS_TODO } from '../config.mjs';
7
+ import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
8
+ import { CONFIG_FILE, STATUS_OPTIONS, STAGE_ORDER, STAGE_DONE, PROGRESS_TODO, PROGRESS_DONE, isManualStageType } from '../config.mjs';
8
9
 
9
- // Campo "Etapa" (custom) → "👀 Code Review". Campo "Status" (nativo) → "Todo".
10
+ // Ao abrir o PR: Stories/Feature Etapa "👀 Code Review" (Status "Todo");
11
+ // Tasks → Etapa "🎉 Done" (Status "Done"), pois a implementação da task terminou.
10
12
  const CODE_REVIEW_STAGE = STATUS_OPTIONS.find(s => s.name.includes('Code Review'))?.name;
13
+ const DONE_STAGE = STAGE_DONE;
11
14
  const TODO_STATUS = PROGRESS_TODO;
15
+ const DONE_STATUS = PROGRESS_DONE;
12
16
 
13
17
  // Extrai números de issues referenciadas no corpo do PR (Closes #N, Fixes #N, #N solto).
14
18
  function extractIssueNumbers(body) {
@@ -22,15 +26,6 @@ function extractIssueNumbers(body) {
22
26
  return [...nums];
23
27
  }
24
28
 
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
29
  // A partir de qualquer issue (Feature/Story/Task), sobe a hierarquia e retorna a Feature.
35
30
  async function resolveFeatureIssue(token, owner, repo, issueNumber) {
36
31
  let issue;
@@ -54,47 +49,52 @@ async function resolveFeatureIssue(token, owner, repo, issueNumber) {
54
49
  return null;
55
50
  }
56
51
 
57
- // Coleta a "unidade de review" de uma issue referenciada no PR. Separa a
58
- // Feature dos demais itens, porque a Feature tem regra própria (só avança
59
- // quando TODAS as suas Stories estiverem em Code Review). Retorna
60
- // { feature: {number,nodeId,title}|null, items: Map(number -> {nodeId,title}) }
61
- // onde `items` são as Stories + Tasks que andam juntas neste PR.
52
+ // Coleta a "unidade de review" de uma issue referenciada no PR, separando por
53
+ // tipo porque cada um tem destino próprio: Tasks Done, Stories → Code Review,
54
+ // Feature → Code Review (só quando todas as Stories estiverem prontas). Retorna
55
+ // { feature:{number,nodeId,title}|null, stories:Map, tasks:Map }.
62
56
  async function collectReviewUnit(token, owner, repo, issueNumber) {
63
- const items = new Map();
64
- const add = (n, nodeId, title) => { if (n && nodeId && !items.has(n)) items.set(n, { nodeId, title }); };
57
+ const stories = new Map();
58
+ const tasks = new Map();
59
+ const addStory = (n, nodeId, title) => { if (n && nodeId && !stories.has(n)) stories.set(n, { nodeId, title }); };
60
+ const addTask = (n, nodeId, title) => { if (n && nodeId && !tasks.has(n)) tasks.set(n, { nodeId, title }); };
65
61
 
66
62
  let issue;
67
- try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return { feature: null, items }; }
63
+ try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return { feature: null, stories, tasks }; }
68
64
  const type = detectIssueType(issue);
69
- if (type !== 'Feature' && type !== 'Story' && type !== 'Task') return { feature: null, items };
65
+ if (type !== 'Feature' && type !== 'Story' && type !== 'Task') return { feature: null, stories, tasks };
70
66
 
71
67
  const featureIssue = await resolveFeatureIssue(token, owner, repo, issueNumber);
72
68
  const feature = featureIssue
73
69
  ? { number: featureIssue.number, nodeId: featureIssue.node_id, title: featureIssue.title }
74
70
  : null;
75
71
 
72
+ // Spikes (tipos manuais) nunca são movidos automaticamente — pulados aqui.
73
+ const isManual = (sub) => isManualStageType(detectIssueType({ title: sub.title, labels: sub.labels }));
74
+
76
75
  if (type === 'Feature') {
77
76
  // Referência direta à Feature: toda a subárvore (Stories + Tasks).
78
- const stories = await listSubIssues(token, issue.node_id).catch(() => []);
79
- for (const st of stories) {
80
- add(st.number, st.nodeId, st.title);
81
- const tasks = await listSubIssues(token, st.nodeId).catch(() => []);
82
- for (const t of tasks) add(t.number, t.nodeId, t.title);
77
+ const subs = await listSubIssues(token, issue.node_id).catch(() => []);
78
+ for (const st of subs) {
79
+ if (isManual(st)) continue;
80
+ addStory(st.number, st.nodeId, st.title);
81
+ const tks = await listSubIssues(token, st.nodeId).catch(() => []);
82
+ for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
83
83
  }
84
84
  } else if (type === 'Story') {
85
- add(issue.number, issue.node_id, issue.title);
86
- const tasks = await listSubIssues(token, issue.node_id).catch(() => []);
87
- for (const t of tasks) add(t.number, t.nodeId, t.title);
85
+ addStory(issue.number, issue.node_id, issue.title);
86
+ const tks = await listSubIssues(token, issue.node_id).catch(() => []);
87
+ for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
88
88
  } else { // Task → inclui a Story pai e as Tasks irmãs.
89
- add(issue.number, issue.node_id, issue.title);
89
+ addTask(issue.number, issue.node_id, issue.title);
90
90
  const parent = await getIssueParent(token, issue.node_id).catch(() => null);
91
91
  if (parent && detectIssueType({ title: parent.title }) === 'Story') {
92
- add(parent.number, parent.nodeId, parent.title);
93
- const tasks = await listSubIssues(token, parent.nodeId).catch(() => []);
94
- for (const t of tasks) add(t.number, t.nodeId, t.title);
92
+ addStory(parent.number, parent.nodeId, parent.title);
93
+ const tks = await listSubIssues(token, parent.nodeId).catch(() => []);
94
+ for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
95
95
  }
96
96
  }
97
- return { feature, items };
97
+ return { feature, stories, tasks };
98
98
  }
99
99
 
100
100
  // A Feature só avança quando TODAS as suas Stories já estiverem em Code Review
@@ -114,31 +114,6 @@ async function allStoriesReadyForReview(readToken, projToken, project, etapaFiel
114
114
  return true;
115
115
  }
116
116
 
117
- // Avança um item do board para a Etapa "👀 Code Review" e reinicia o Status
118
- // (nativo) para "Todo". Uma issue só AVANÇA: se já estiver em Code Review ou em
119
- // uma etapa posterior, não é tocada (retorna false). Retorna true se avançou.
120
- async function setCodeReview(token, project, etapaField, statusField, nodeId) {
121
- const itemId = await addProjectItem(token, project.id, nodeId);
122
-
123
- if (etapaField?.id && CODE_REVIEW_STAGE) {
124
- // Nunca retroceder: compara a etapa atual com a de destino na ordem canônica.
125
- const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
126
- const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
127
- const tgtIdx = STAGE_ORDER.indexOf(CODE_REVIEW_STAGE);
128
- if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
129
- return false; // já está em Code Review ou adiante — não retrocede
130
- }
131
- const optionId = etapaField.options?.[CODE_REVIEW_STAGE];
132
- if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
133
- }
134
- // Ao avançar de etapa, o Status reinicia em "Todo".
135
- if (statusField?.id) {
136
- const optionId = statusField.options?.[TODO_STATUS];
137
- if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
138
- }
139
- return true;
140
- }
141
-
142
117
  export async function codeReview({ prNumber }) {
143
118
  const token = await resolveToken();
144
119
  const projectToken = process.env.PROJECT_TOKEN || token;
@@ -165,20 +140,9 @@ export async function codeReview({ prNumber }) {
165
140
  }
166
141
 
167
142
  // Carrega projeto do .spec-wave.json
168
- const configPath = path.join(process.cwd(), CONFIG_FILE);
169
- if (!existsSync(configPath)) {
170
- console.warn(`${CONFIG_FILE} não encontrado — board não atualizado.`);
171
- return;
172
- }
173
- let project;
174
- try {
175
- project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
176
- } catch (err) {
177
- console.warn(`${CONFIG_FILE} corrompido (${err.message}) — board não atualizado.`);
178
- return;
179
- }
180
- if (!project.id) {
181
- console.warn(`Project não configurado em ${CONFIG_FILE} — board não atualizado.`);
143
+ const { project, error: projectError } = loadProjectConfig();
144
+ if (projectError) {
145
+ console.warn(`${projectError} — board não atualizado.`);
182
146
  return;
183
147
  }
184
148
 
@@ -190,18 +154,37 @@ export async function codeReview({ prNumber }) {
190
154
  const updated = [];
191
155
  const featuresChecked = new Set();
192
156
 
193
- // Para cada issue referenciada: move a Story + Tasks para Code Review. A
194
- // Feature avança quando TODAS as suas Stories estiverem em Code Review.
157
+ // Regras ao abrir o PR: Tasks 🎉 Done (implementação concluída);
158
+ // Stories 👀 Code Review; Feature Code Review quando TODAS as suas
159
+ // Stories já estiverem em Code Review.
195
160
  for (const num of issueNums) {
196
- const { feature, items } = await collectReviewUnit(token, owner, repo, num);
161
+ const { feature, stories, tasks } = await collectReviewUnit(token, owner, repo, num);
162
+
163
+ // Tasks → Done (Status Done).
164
+ for (const [n, info] of tasks) {
165
+ if (seen.has(n)) continue;
166
+ seen.add(n);
167
+ try {
168
+ const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, DONE_STAGE, DONE_STATUS);
169
+ if (moved) {
170
+ updated.push(`#${n} ${info.title} → ${DONE_STAGE}`);
171
+ console.log(`#${n} → "${DONE_STAGE}" / Status "${DONE_STATUS}".`);
172
+ } else {
173
+ console.log(`#${n} já está em "${DONE_STAGE}" ou etapa posterior — mantido (não retrocede).`);
174
+ }
175
+ } catch (err) {
176
+ console.warn(`Falha ao atualizar #${n}: ${err.message}`);
177
+ }
178
+ }
197
179
 
198
- for (const [n, info] of items) {
180
+ // Stories Code Review (Status Todo).
181
+ for (const [n, info] of stories) {
199
182
  if (seen.has(n)) continue;
200
183
  seen.add(n);
201
184
  try {
202
- const moved = await setCodeReview(projectToken, project, etapaField, statusField, info.nodeId);
185
+ const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
203
186
  if (moved) {
204
- updated.push(`#${n} ${info.title}`);
187
+ updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
205
188
  console.log(`#${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
206
189
  } else {
207
190
  console.log(`#${n} já está em "${CODE_REVIEW_STAGE}" ou etapa posterior — mantido (não retrocede).`);
@@ -220,9 +203,9 @@ export async function codeReview({ prNumber }) {
220
203
  console.log(`Feature #${feature.number} mantida em desenvolvimento — ainda há Stories pendentes (fora de "${CODE_REVIEW_STAGE}").`);
221
204
  } else if (!seen.has(feature.number)) {
222
205
  seen.add(feature.number);
223
- const moved = await setCodeReview(projectToken, project, etapaField, statusField, feature.nodeId);
206
+ const moved = await advanceToStage(projectToken, project, etapaField, statusField, feature.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
224
207
  if (moved) {
225
- updated.push(`#${feature.number} ${feature.title} (Feature)`);
208
+ updated.push(`#${feature.number} ${feature.title} (Feature) → ${CODE_REVIEW_STAGE}`);
226
209
  console.log(`Feature #${feature.number} → "${CODE_REVIEW_STAGE}" (todas as Stories concluídas).`);
227
210
  } else {
228
211
  console.log(`Feature #${feature.number} já está em "${CODE_REVIEW_STAGE}" ou etapa posterior.`);
@@ -238,10 +221,10 @@ export async function codeReview({ prNumber }) {
238
221
  await commentOnIssue(
239
222
  token, owner, repo, parseInt(prNumber, 10),
240
223
  `🔍 **Code Review iniciado**\n\n` +
241
- `Movidos para **${CODE_REVIEW_STAGE}** (a Feature só avança quando todas as suas Stories concluírem):\n\n` +
224
+ `Board atualizado (Tasks → **${DONE_STAGE}**; Story → **${CODE_REVIEW_STAGE}**; Feature só avança quando todas as Stories concluírem):\n\n` +
242
225
  updated.map(f => `- ${f}`).join('\n')
243
226
  ).catch(() => {});
244
227
  }
245
228
 
246
- console.log(`code-review: ${updated.length} item(ns) movido(s) para "${CODE_REVIEW_STAGE}".`);
229
+ console.log(`code-review: ${updated.length} item(ns) atualizado(s) no board.`);
247
230
  }