@spec-wave/cli 0.6.0 → 0.7.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 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.6.0",
3
+ "version": "0.7.1",
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,63 @@ 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: [{ id, author, body, createdAt }].
154
+ // Paginado — traz o histórico completo (usado pela crítica adversarial e pelo
155
+ // comentário acumulado de uso de IA). `id` é o database id do comentário,
156
+ // exigido pelo updateComment.
157
+ export async function listIssueComments(token, owner, repo, issueNumber) {
158
+ const octokit = makeOctokit(token);
159
+ const comments = await octokit.paginate(octokit.rest.issues.listComments, {
160
+ owner,
161
+ repo,
162
+ issue_number: issueNumber,
163
+ per_page: 100,
164
+ });
165
+ return comments.map(c => ({
166
+ id: c.id,
167
+ author: c.user?.login || '',
168
+ body: c.body || '',
169
+ createdAt: c.created_at,
170
+ }));
171
+ }
172
+
173
+ // Atualiza o corpo de um comentário existente. `commentId` é o database id
174
+ // retornado por listIssueComments.
175
+ export async function updateComment(token, owner, repo, commentId, body) {
176
+ const octokit = makeOctokit(token);
177
+ await octokit.rest.issues.updateComment({
178
+ owner,
179
+ repo,
180
+ comment_id: commentId,
181
+ body,
182
+ });
183
+ }
184
+
185
+ // Marca uma issue como bloqueada por outra (relação nativa do GitHub).
186
+ // `blockingIssueId` é o DATABASE id da issue bloqueadora (não o number nem o
187
+ // node id — ver createIssue). Erros propagam: o chamador usa como fallback.
188
+ export async function addBlockedBy(token, owner, repo, issueNumber, blockingIssueId) {
189
+ const octokit = makeOctokit(token);
190
+ await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by', {
191
+ owner,
192
+ repo,
193
+ issue_number: issueNumber,
194
+ issue_id: blockingIssueId,
195
+ });
196
+ }
197
+
198
+ // Lista as issues que bloqueiam `issueNumber`: [{ number, title, state, id }].
199
+ export async function listBlockedBy(token, owner, repo, issueNumber) {
200
+ const octokit = makeOctokit(token);
201
+ const issues = await octokit.paginate('GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by', {
202
+ owner,
203
+ repo,
204
+ issue_number: issueNumber,
205
+ per_page: 100,
206
+ });
207
+ return issues.map(i => ({ number: i.number, title: i.title, state: i.state, id: i.id }));
208
+ }
209
+
151
210
  export async function commentOnIssue(token, owner, repo, issueNumber, body) {
152
211
  const octokit = makeOctokit(token);
153
212
  await octokit.rest.issues.createComment({
@@ -2,9 +2,10 @@ 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, STAGE_DONE, PROGRESS_TODO, PROGRESS_DONE } 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
10
  // Ao abrir o PR: Stories/Feature → Etapa "👀 Code Review" (Status "Todo");
10
11
  // Tasks → Etapa "🎉 Done" (Status "Done"), pois a implementação da task terminou.
@@ -25,15 +26,6 @@ function extractIssueNumbers(body) {
25
26
  return [...nums];
26
27
  }
27
28
 
28
- // Resolve o campo SINGLE_SELECT pelo nome: usa .spec-wave.json, legado ou API.
29
- async function resolveField(token, project, name) {
30
- if (project.fields?.[name]) return project.fields[name];
31
- if (name === 'Etapa' && project.etapaFieldId) {
32
- return { id: project.etapaFieldId, options: project.stageOptions || {} };
33
- }
34
- return await getSingleSelectField(token, project.id, name);
35
- }
36
-
37
29
  // A partir de qualquer issue (Feature/Story/Task), sobe a hierarquia e retorna a Feature.
38
30
  async function resolveFeatureIssue(token, owner, repo, issueNumber) {
39
31
  let issue;
@@ -77,25 +69,29 @@ async function collectReviewUnit(token, owner, repo, issueNumber) {
77
69
  ? { number: featureIssue.number, nodeId: featureIssue.node_id, title: featureIssue.title }
78
70
  : null;
79
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
+
80
75
  if (type === 'Feature') {
81
76
  // Referência direta à Feature: toda a subárvore (Stories + Tasks).
82
77
  const subs = await listSubIssues(token, issue.node_id).catch(() => []);
83
78
  for (const st of subs) {
79
+ if (isManual(st)) continue;
84
80
  addStory(st.number, st.nodeId, st.title);
85
81
  const tks = await listSubIssues(token, st.nodeId).catch(() => []);
86
- for (const t of tks) addTask(t.number, t.nodeId, t.title);
82
+ for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
87
83
  }
88
84
  } else if (type === 'Story') {
89
85
  addStory(issue.number, issue.node_id, issue.title);
90
86
  const tks = await listSubIssues(token, issue.node_id).catch(() => []);
91
- for (const t of tks) addTask(t.number, t.nodeId, t.title);
87
+ for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
92
88
  } else { // Task → inclui a Story pai e as Tasks irmãs.
93
89
  addTask(issue.number, issue.node_id, issue.title);
94
90
  const parent = await getIssueParent(token, issue.node_id).catch(() => null);
95
91
  if (parent && detectIssueType({ title: parent.title }) === 'Story') {
96
92
  addStory(parent.number, parent.nodeId, parent.title);
97
93
  const tks = await listSubIssues(token, parent.nodeId).catch(() => []);
98
- for (const t of tks) addTask(t.number, t.nodeId, t.title);
94
+ for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
99
95
  }
100
96
  }
101
97
  return { feature, stories, tasks };
@@ -118,30 +114,6 @@ async function allStoriesReadyForReview(readToken, projToken, project, etapaFiel
118
114
  return true;
119
115
  }
120
116
 
121
- // Avança um item do board para `targetStage` (Etapa) e define o Status para
122
- // `targetStatus`. Uma issue só AVANÇA: se já estiver em `targetStage` ou em uma
123
- // etapa posterior, não é tocada (retorna false). Retorna true se avançou.
124
- async function advanceToStage(token, project, etapaField, statusField, nodeId, targetStage, targetStatus) {
125
- const itemId = await addProjectItem(token, project.id, nodeId);
126
-
127
- if (etapaField?.id && targetStage) {
128
- // Nunca retroceder: compara a etapa atual com a de destino na ordem canônica.
129
- const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
130
- const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
131
- const tgtIdx = STAGE_ORDER.indexOf(targetStage);
132
- if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
133
- return false; // já está nessa etapa ou adiante — não retrocede
134
- }
135
- const optionId = etapaField.options?.[targetStage];
136
- if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
137
- }
138
- if (statusField?.id && targetStatus) {
139
- const optionId = statusField.options?.[targetStatus];
140
- if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
141
- }
142
- return true;
143
- }
144
-
145
117
  export async function codeReview({ prNumber }) {
146
118
  const token = await resolveToken();
147
119
  const projectToken = process.env.PROJECT_TOKEN || token;
@@ -168,20 +140,9 @@ export async function codeReview({ prNumber }) {
168
140
  }
169
141
 
170
142
  // Carrega projeto do .spec-wave.json
171
- const configPath = path.join(process.cwd(), CONFIG_FILE);
172
- if (!existsSync(configPath)) {
173
- console.warn(`${CONFIG_FILE} não encontrado — board não atualizado.`);
174
- return;
175
- }
176
- let project;
177
- try {
178
- project = JSON.parse(readFileSync(configPath, 'utf-8')).project || {};
179
- } catch (err) {
180
- console.warn(`${CONFIG_FILE} corrompido (${err.message}) — board não atualizado.`);
181
- return;
182
- }
183
- if (!project.id) {
184
- 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.`);
185
146
  return;
186
147
  }
187
148