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