@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 +37 -0
- package/package.json +4 -1
- package/src/api/github-rest.mjs +60 -1
- package/src/commands/code-review.mjs +13 -52
- package/src/commands/decompose.mjs +288 -61
- package/src/commands/doctor.mjs +415 -0
- package/src/commands/generate-plan.mjs +95 -29
- package/src/commands/generate-spec.mjs +71 -28
- package/src/commands/implement.mjs +118 -3
- package/src/commands/init.mjs +2 -2
- package/src/commands/order.mjs +172 -0
- package/src/commands/qa.mjs +8 -46
- package/src/commands/story.mjs +128 -0
- package/src/commands/task.mjs +183 -0
- package/src/commands/validate.mjs +20 -3
- package/src/config.mjs +37 -0
- package/src/lib/board.mjs +106 -0
- package/src/lib/claude.mjs +138 -13
- package/src/lib/code-digest.mjs +183 -0
- package/src/lib/critique.mjs +160 -0
- package/src/lib/dependencies.mjs +92 -0
- package/src/lib/output-lint.mjs +92 -0
- package/src/lib/usage-report.mjs +167 -0
- package/src/setup/files.mjs +8 -20
- package/src/templates/skill/SKILL.md +104 -12
- package/src/templates/workflows/code-review.yml +4 -0
- package/src/templates/workflows/decompose.yml +7 -0
- package/src/templates/workflows/generate-plan.yml +4 -0
- package/src/templates/workflows/generate-spec.yml +4 -0
- package/src/templates/workflows/qa.yml +4 -0
- package/src/templates/workflows/validate.yml +4 -0
- package/src/ui/wizard.mjs +2 -2
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
|
-
import { getIssue, createIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
-
import { addSubIssue, addProjectItem, setItemSingleSelect, getSingleSelectField } from '../api/github-graphql.mjs';
|
|
4
|
+
import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy } from '../api/github-rest.mjs';
|
|
5
|
+
import { addSubIssue, addProjectItem, setItemSingleSelect, getSingleSelectField, listSubIssues } from '../api/github-graphql.mjs';
|
|
6
6
|
import { generateDocument } from '../lib/claude.mjs';
|
|
7
|
+
import { runCritique } from '../lib/critique.mjs';
|
|
8
|
+
import { recordUsage } from '../lib/usage-report.mjs';
|
|
9
|
+
import { formatDependencyLine } from '../lib/dependencies.mjs';
|
|
10
|
+
import { lintLanguage } from '../lib/output-lint.mjs';
|
|
7
11
|
import { slugify } from '../lib/slugify.mjs';
|
|
8
|
-
import {
|
|
12
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
13
|
+
import { CONFIG_FILE, DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, TARGET_LANGUAGE } from '../config.mjs';
|
|
9
14
|
|
|
10
15
|
const READY_STAGE = 'Todo';
|
|
11
16
|
|
|
@@ -35,7 +40,66 @@ async function moveToStage(token, project, statusField, nodeId, stageName) {
|
|
|
35
40
|
await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
// Extrai JSON da resposta do modelo (tolera texto em volta).
|
|
44
|
+
function parseJson(raw) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw);
|
|
47
|
+
} catch {
|
|
48
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
49
|
+
if (!jsonMatch) throw new Error('Claude did not return valid JSON');
|
|
50
|
+
return JSON.parse(jsonMatch[0]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Prefixo de título das sub-issues geradas por cada tipo decompoível.
|
|
55
|
+
const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Guard de idempotência do decompose (função PURA — testável).
|
|
59
|
+
*
|
|
60
|
+
* Skip se a issue já foi decomposta: label `spec-wave:decomposed` presente OU
|
|
61
|
+
* sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
|
|
62
|
+
* título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} params
|
|
65
|
+
* @param {Array<string|{name: string}>} [params.labels] labels da issue
|
|
66
|
+
* @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
|
|
67
|
+
* @param {string} params.type tipo da issue ('Feature' | 'RFC')
|
|
68
|
+
* @returns {{ skip: boolean, reason: string }}
|
|
69
|
+
*/
|
|
70
|
+
export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
|
|
71
|
+
const names = labels
|
|
72
|
+
.map(l => (typeof l === 'string' ? l : l?.name))
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
if (names.includes(LABEL_DECOMPOSED)) {
|
|
75
|
+
return { skip: true, reason: `a issue já tem a label \`${LABEL_DECOMPOSED}\`` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const prefix = CHILD_PREFIX[type];
|
|
79
|
+
if (prefix) {
|
|
80
|
+
const existing = subIssues.find(s => (s.title || '').includes(prefix));
|
|
81
|
+
if (existing) {
|
|
82
|
+
const ref = existing.number ? ` (ex.: #${existing.number} — ${existing.title})` : '';
|
|
83
|
+
return { skip: true, reason: `a issue já tem sub-issues \`${prefix}\`${ref}` };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { skip: false, reason: '' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
|
|
91
|
+
// anexar ao comentário final ('' se limpo).
|
|
92
|
+
function formatItemsLintWarning(texts) {
|
|
93
|
+
const result = lintLanguage(texts.join('\n\n'), { lang: TARGET_LANGUAGE });
|
|
94
|
+
if (result.ok) return '';
|
|
95
|
+
const excerpts = result.findings
|
|
96
|
+
.slice(0, 5)
|
|
97
|
+
.map(f => `\`${f.excerpt.replace(/\s+/g, ' ').trim()}\``)
|
|
98
|
+
.join(', ');
|
|
99
|
+
return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const FEATURE_SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
|
|
39
103
|
A partir da Feature fornecida (com spec.md e plan.md), gere uma lista de Stories e Tasks.
|
|
40
104
|
|
|
41
105
|
Responda APENAS com JSON válido neste formato:
|
|
@@ -45,6 +109,7 @@ Responda APENAS com JSON válido neste formato:
|
|
|
45
109
|
"title": "Título curto da story (apenas a parte 'quero', sem prefixo)",
|
|
46
110
|
"userStory": "Como <perfil>, quero <objetivo>, para <benefício>",
|
|
47
111
|
"body": "Descrição complementar da story com contexto e critérios de aceite relevantes",
|
|
112
|
+
"dependsOn": [0],
|
|
48
113
|
"tasks": [
|
|
49
114
|
{
|
|
50
115
|
"title": "Título técnico curto da task (sem prefixo)",
|
|
@@ -61,48 +126,41 @@ Regras:
|
|
|
61
126
|
- "body" é texto complementar (contexto, critérios de aceite); não repita o título
|
|
62
127
|
- Cada Story deve ter 2–5 Tasks associadas
|
|
63
128
|
- Tasks devem ser atividades técnicas concretas, com "title" curto e "body" detalhado
|
|
64
|
-
- Gere entre 3 e 7 Stories por Feature
|
|
129
|
+
- Gere entre 3 e 7 Stories por Feature
|
|
130
|
+
- Ordene as stories na sequência de implementação — a ORDEM da lista importa
|
|
131
|
+
- "dependsOn" (opcional): índices 0-based das stories ANTERIORES na lista das quais esta story depende. Referencie apenas índices menores que o da própria story. Use [] quando a story puder ser feita em paralelo (sem dependências); se omitido, assume-se dependência da story anterior (sequencial)`;
|
|
65
132
|
|
|
66
|
-
|
|
67
|
-
const token = await resolveToken();
|
|
68
|
-
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
69
|
-
// Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
|
|
70
|
-
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
71
|
-
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
133
|
+
const RFC_SYSTEM_PROMPT = `Você é um Tech Lead experiente. A partir do RFC fornecido (proposta técnica/de processo), gere a lista de Tasks técnicas concretas necessárias para implementá-lo.
|
|
72
134
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
135
|
+
Responda APENAS com JSON válido neste formato:
|
|
136
|
+
{
|
|
137
|
+
"tasks": [
|
|
138
|
+
{
|
|
139
|
+
"title": "Título técnico curto da task (sem prefixo)",
|
|
140
|
+
"body": "Descrição técnica detalhada (o que fazer, áreas/arquivos afetados, critério de pronto)"
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
80
144
|
|
|
81
|
-
|
|
82
|
-
|
|
145
|
+
Regras:
|
|
146
|
+
- "title" CURTO (máx. ~60 caracteres), sem prefixo.
|
|
147
|
+
- "body" detalhado e acionável.
|
|
148
|
+
- Gere entre 3 e 10 Tasks concretas que, juntas, cubram o RFC.`;
|
|
83
149
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
statusField = await resolveStatusField(projectToken, project);
|
|
90
|
-
} catch (err) {
|
|
91
|
-
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
150
|
+
// Decompõe uma Feature em Stories (+ Tasks), cada uma vinculada como sub-issue.
|
|
151
|
+
async function decomposeFeature(ctx) {
|
|
152
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, statusField, usage } = ctx;
|
|
153
|
+
const slug = slugify(issue.title);
|
|
94
154
|
const featureDir = `docs/features/${slug}`;
|
|
95
155
|
|
|
96
156
|
const planContent = existsSync(`${featureDir}/plan.md`)
|
|
97
157
|
? readFileSync(`${featureDir}/plan.md`, 'utf-8')
|
|
98
158
|
: '(plan.md não encontrado)';
|
|
99
|
-
|
|
100
159
|
const specContent = existsSync(`${featureDir}/spec.md`)
|
|
101
160
|
? readFileSync(`${featureDir}/spec.md`, 'utf-8')
|
|
102
161
|
: '(spec.md não encontrado)';
|
|
103
162
|
|
|
104
|
-
console.log(`Decompondo
|
|
105
|
-
|
|
163
|
+
console.log(`Decompondo Feature: ${issue.title}`);
|
|
106
164
|
const userContent = [
|
|
107
165
|
`Feature: ${issue.title}`,
|
|
108
166
|
`Issue #${issueNumber}`,
|
|
@@ -110,42 +168,80 @@ export async function decompose({ issueNumber }) {
|
|
|
110
168
|
`\n## plan.md\n${planContent}`,
|
|
111
169
|
].join('\n');
|
|
112
170
|
|
|
113
|
-
const
|
|
171
|
+
const decomposition = parseJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
|
|
114
172
|
|
|
115
|
-
|
|
173
|
+
// Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
|
|
174
|
+
// a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
|
|
175
|
+
let critique = null;
|
|
116
176
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
177
|
+
critique = await runCritique({
|
|
178
|
+
kind: 'stories',
|
|
179
|
+
spec: existsSync(`${featureDir}/spec.md`) ? specContent : null,
|
|
180
|
+
plan: existsSync(`${featureDir}/plan.md`) ? planContent : null,
|
|
181
|
+
stories: decomposition.stories,
|
|
182
|
+
usage,
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.warn(`Crítica adversarial indisponível: ${err.message}`);
|
|
186
|
+
await commentOnIssue(
|
|
187
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
188
|
+
`⚠️ crítica adversarial indisponível (erro: ${err.message}) — prosseguindo com a decomposição.`
|
|
189
|
+
).catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
if (critique) {
|
|
192
|
+
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown)
|
|
193
|
+
.catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
|
|
194
|
+
if (critique.grave) {
|
|
195
|
+
console.log('Crítica adversarial apontou findings GRAVES — nenhuma story foi criada.');
|
|
196
|
+
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
|
|
197
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
123
200
|
}
|
|
124
201
|
|
|
125
|
-
// node id da Feature — necessário para vincular as stories como sub-issues.
|
|
126
202
|
const featureNodeId = issue.node_id;
|
|
127
|
-
|
|
128
203
|
const created = [];
|
|
204
|
+
const createdStories = []; // issues criadas, na ordem dos índices das stories
|
|
205
|
+
const generatedTexts = []; // títulos+corpos para o lint de idioma final
|
|
129
206
|
|
|
130
|
-
for (
|
|
207
|
+
for (let i = 0; i < decomposition.stories.length; i++) {
|
|
208
|
+
const story = decomposition.stories[i];
|
|
131
209
|
console.log(`Criando story: ${story.title}`);
|
|
132
210
|
const storyTitle = `[STORY] ${story.title}`;
|
|
133
|
-
|
|
134
|
-
const storyBody = [story.userStory, story.body]
|
|
211
|
+
let storyBody = [story.userStory, story.body]
|
|
135
212
|
.map(s => (s || '').trim())
|
|
136
213
|
.filter(Boolean)
|
|
137
214
|
.join('\n\n') || '_(sem descrição)_';
|
|
215
|
+
|
|
216
|
+
// Dependências: índices 0-based de stories anteriores. Default quando o
|
|
217
|
+
// campo está ausente: sequencial (story i depende da i-1). [] explícito =
|
|
218
|
+
// sem dependências. Índices inválidos/futuros são ignorados.
|
|
219
|
+
const depIndexes = Array.isArray(story.dependsOn)
|
|
220
|
+
? [...new Set(story.dependsOn.filter(d => Number.isInteger(d) && d >= 0 && d < i))]
|
|
221
|
+
: (i > 0 ? [i - 1] : []);
|
|
222
|
+
const depIssues = depIndexes.map(idx => createdStories[idx]).filter(Boolean);
|
|
223
|
+
const depLine = formatDependencyLine(depIssues.map(d => d.number));
|
|
224
|
+
if (depLine) storyBody += `\n\n${depLine}`;
|
|
225
|
+
|
|
138
226
|
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
|
|
139
|
-
created.push({
|
|
227
|
+
created.push({ title: storyTitle, url: createdStory.url });
|
|
228
|
+
createdStories.push(createdStory);
|
|
229
|
+
generatedTexts.push(storyTitle, storyBody);
|
|
230
|
+
|
|
231
|
+
// Relação nativa blocked_by (best-effort — a linha "Depende de" já basta).
|
|
232
|
+
for (const dep of depIssues) {
|
|
233
|
+
try {
|
|
234
|
+
await addBlockedBy(token, owner, repo, createdStory.number, dep.id);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.warn(` Falha ao marcar story #${createdStory.number} como bloqueada por #${dep.number}: ${err.message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
140
239
|
|
|
141
|
-
// Vincula a story como sub-issue da Feature (relação nativa do GitHub).
|
|
142
240
|
try {
|
|
143
241
|
await addSubIssue(token, featureNodeId, createdStory.nodeId);
|
|
144
242
|
} catch (err) {
|
|
145
243
|
console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
|
|
146
244
|
}
|
|
147
|
-
|
|
148
|
-
// Move story para Ready no board.
|
|
149
245
|
try {
|
|
150
246
|
await moveToStage(projectToken, project, statusField, createdStory.nodeId, READY_STAGE);
|
|
151
247
|
} catch (err) {
|
|
@@ -157,15 +253,12 @@ export async function decompose({ issueNumber }) {
|
|
|
157
253
|
const taskTitle = `[TASK] ${task.title}`;
|
|
158
254
|
const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
|
|
159
255
|
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
160
|
-
|
|
161
|
-
// Vincula a task como sub-issue da Story.
|
|
256
|
+
generatedTexts.push(taskTitle, taskBody);
|
|
162
257
|
try {
|
|
163
258
|
await addSubIssue(token, createdStory.nodeId, createdTask.nodeId);
|
|
164
259
|
} catch (err) {
|
|
165
260
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
|
|
166
261
|
}
|
|
167
|
-
|
|
168
|
-
// Move task para Ready no board.
|
|
169
262
|
try {
|
|
170
263
|
await moveToStage(projectToken, project, statusField, createdTask.nodeId, READY_STAGE);
|
|
171
264
|
} catch (err) {
|
|
@@ -174,7 +267,6 @@ export async function decompose({ issueNumber }) {
|
|
|
174
267
|
}
|
|
175
268
|
}
|
|
176
269
|
|
|
177
|
-
// Move a própria Feature para Ready no board.
|
|
178
270
|
try {
|
|
179
271
|
await moveToStage(projectToken, project, statusField, featureNodeId, READY_STAGE);
|
|
180
272
|
if (project?.id && statusField) console.log(`Feature movida para "${READY_STAGE}" no board.`);
|
|
@@ -182,16 +274,151 @@ export async function decompose({ issueNumber }) {
|
|
|
182
274
|
console.warn(`Falha ao mover Feature para "${READY_STAGE}": ${err.message}`);
|
|
183
275
|
}
|
|
184
276
|
|
|
185
|
-
//
|
|
277
|
+
// Marca a Feature como decomposta (guard de idempotência em runs futuros).
|
|
278
|
+
try {
|
|
279
|
+
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
|
|
282
|
+
}
|
|
186
283
|
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
187
|
-
|
|
188
|
-
const storyList = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
|
|
284
|
+
const list = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
|
|
189
285
|
await commentOnIssue(
|
|
190
286
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
191
287
|
`🔀 **Decomposição concluída!**\n\n` +
|
|
192
|
-
`Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${
|
|
193
|
-
`Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.`
|
|
288
|
+
`Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${list}\n\n` +
|
|
289
|
+
`Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.` +
|
|
290
|
+
formatItemsLintWarning(generatedTexts)
|
|
194
291
|
);
|
|
195
|
-
|
|
196
292
|
console.log(`Decomposição concluída: ${decomposition.stories.length} stories criadas.`);
|
|
197
293
|
}
|
|
294
|
+
|
|
295
|
+
// Decompõe um RFC diretamente em Tasks (sem Stories), cada uma vinculada como
|
|
296
|
+
// sub-issue do RFC.
|
|
297
|
+
async function decomposeRFC(ctx) {
|
|
298
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, statusField, usage } = ctx;
|
|
299
|
+
console.log(`Decompondo RFC: ${issue.title}`);
|
|
300
|
+
|
|
301
|
+
const userContent = [
|
|
302
|
+
`RFC: ${issue.title}`,
|
|
303
|
+
`Issue #${issueNumber}`,
|
|
304
|
+
`\n## Descrição\n${issue.body || '(sem descrição)'}`,
|
|
305
|
+
].join('\n');
|
|
306
|
+
|
|
307
|
+
const decomposition = parseJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent, { action: 'decompose', usage }));
|
|
308
|
+
const rfcNodeId = issue.node_id;
|
|
309
|
+
const tasks = decomposition.tasks || [];
|
|
310
|
+
const created = [];
|
|
311
|
+
const generatedTexts = []; // títulos+corpos para o lint de idioma final
|
|
312
|
+
|
|
313
|
+
for (const task of tasks) {
|
|
314
|
+
console.log(`Criando task: ${task.title}`);
|
|
315
|
+
const taskTitle = `[TASK] ${task.title}`;
|
|
316
|
+
const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
|
|
317
|
+
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
318
|
+
created.push({ title: taskTitle, url: createdTask.url });
|
|
319
|
+
generatedTexts.push(taskTitle, taskBody);
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
await addSubIssue(token, rfcNodeId, createdTask.nodeId);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
await moveToStage(projectToken, project, statusField, createdTask.nodeId, READY_STAGE);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
console.warn(` Falha ao mover task #${createdTask.number} para "${READY_STAGE}": ${err.message}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Marca o RFC como decomposto (guard de idempotência em runs futuros).
|
|
334
|
+
try {
|
|
335
|
+
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
|
|
338
|
+
}
|
|
339
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
340
|
+
const list = created.map(t => `- ${t.url} — ${t.title}`).join('\n');
|
|
341
|
+
await commentOnIssue(
|
|
342
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
343
|
+
`🔀 **Decomposição do RFC concluída!**\n\n` +
|
|
344
|
+
`Foram criadas ${created.length} tasks:\n\n${list}\n\n` +
|
|
345
|
+
`Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.` +
|
|
346
|
+
formatItemsLintWarning(generatedTexts)
|
|
347
|
+
);
|
|
348
|
+
console.log(`Decomposição concluída: ${created.length} tasks criadas.`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function decompose({ issueNumber }) {
|
|
352
|
+
const token = await resolveToken();
|
|
353
|
+
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
354
|
+
// Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
|
|
355
|
+
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
356
|
+
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
357
|
+
|
|
358
|
+
if (!owner || !repo) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
361
|
+
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
362
|
+
' GITHUB_REPOSITORY=owner/repo spec-wave decompose --issue-number 1'
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
367
|
+
const type = detectIssueType(issue);
|
|
368
|
+
|
|
369
|
+
// Só Feature (→ Stories) e RFC (→ Tasks) podem ser decompostos.
|
|
370
|
+
if (!DECOMPOSE_TARGETS[type]) {
|
|
371
|
+
console.log(`Issue #${issueNumber} é ${type || 'desconhecido'} — decompose não se aplica.`);
|
|
372
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
373
|
+
await commentOnIssue(
|
|
374
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
375
|
+
`ℹ️ **decompose não se aplica a ${type || 'este tipo'}.** ` +
|
|
376
|
+
`Use em **Features** (gera Stories + Tasks) ou **RFCs** (gera Tasks).`
|
|
377
|
+
).catch(() => {});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Guard de idempotência: label spec-wave:decomposed ou sub-issues do
|
|
382
|
+
// tipo-alvo já existentes → não re-decompõe (evita duplicar stories/tasks).
|
|
383
|
+
let subIssues = [];
|
|
384
|
+
try {
|
|
385
|
+
subIssues = await listSubIssues(token, issue.node_id);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
|
|
388
|
+
subIssues = [];
|
|
389
|
+
}
|
|
390
|
+
const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type });
|
|
391
|
+
if (guard.skip) {
|
|
392
|
+
console.log(`Decompose ignorado: ${guard.reason}.`);
|
|
393
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
|
|
394
|
+
await commentOnIssue(
|
|
395
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
396
|
+
`⏭️ **decompose ignorado:** ${guard.reason}. Para forçar, remova a label ` +
|
|
397
|
+
`\`${LABEL_DECOMPOSED}\` (e apague as sub-issues antigas se quiser re-gerar).`
|
|
398
|
+
).catch(() => {});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Projeto + campo Status (reutilizado em todos os itens).
|
|
403
|
+
const project = loadProject();
|
|
404
|
+
let statusField = null;
|
|
405
|
+
if (project?.id) {
|
|
406
|
+
try {
|
|
407
|
+
statusField = await resolveStatusField(projectToken, project);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Coletor de uso de IA — o finally registra o custo já incorrido mesmo nos
|
|
414
|
+
// fluxos que retornam cedo (ex.: abort da crítica grave) ou que falham.
|
|
415
|
+
const usageEntries = [];
|
|
416
|
+
const ctx = { token, projectToken, owner, repo, issue, issueNumber, project, statusField, usage: usageEntries };
|
|
417
|
+
try {
|
|
418
|
+
if (type === 'Feature') await decomposeFeature(ctx);
|
|
419
|
+
else if (type === 'RFC') await decomposeRFC(ctx);
|
|
420
|
+
} finally {
|
|
421
|
+
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
422
|
+
await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
|
|
423
|
+
}
|
|
424
|
+
}
|