@spec-wave/cli 0.18.1 → 0.20.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 +34 -3
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +10 -1
- package/src/agent/errors.mjs +57 -3
- package/src/agent/openrouter-agent.mjs +12 -2
- package/src/api/auth.mjs +217 -9
- package/src/api/github-graphql.mjs +33 -0
- package/src/commands/code-review.mjs +186 -31
- package/src/commands/decompose.mjs +199 -14
- package/src/commands/doctor.mjs +195 -35
- package/src/commands/generate-bug.mjs +18 -15
- package/src/commands/generate-plan.mjs +93 -5
- package/src/commands/generate-spec.mjs +7 -4
- package/src/commands/repair-stage.mjs +232 -0
- package/src/commands/update.mjs +13 -5
- package/src/config.mjs +43 -0
- package/src/lib/board.mjs +44 -0
- package/src/lib/claude.mjs +59 -9
- package/src/lib/critique.mjs +186 -14
- package/src/lib/decomposition-doc.mjs +59 -7
- package/src/lib/dependencies.mjs +49 -0
- package/src/lib/flow-run.mjs +134 -5
- package/src/lib/prompt-loader.mjs +30 -3
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/move/SKILL.md +29 -0
- package/src/plugin/skills/plan/SKILL.md +13 -0
- package/src/plugin/skills/spec/model-prompt.critique.md +58 -0
- package/src/setup/labels.mjs +10 -6
- package/src/templates/workflows/code-review.yml +34 -1
- package/src/templates/workflows/decompose.yml +12 -1
- package/src/templates/workflows/generate-bug.yml +8 -1
- package/src/templates/workflows/generate-plan.yml +16 -1
- package/src/templates/workflows/generate-spec.yml +16 -1
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
1
2
|
import { resolveToken } from '../api/auth.mjs';
|
|
2
3
|
import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
addProjectItem, getIssueParent, listSubIssues, getItemSingleSelectValue, getPRClosingIssues,
|
|
6
|
+
} from '../api/github-graphql.mjs';
|
|
4
7
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
5
8
|
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
6
9
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
7
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
STATUS_OPTIONS, STAGE_ORDER, STAGE_DONE, PROGRESS_TODO, PROGRESS_DONE, isManualStageType,
|
|
12
|
+
LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY, labelNames,
|
|
13
|
+
} from '../config.mjs';
|
|
8
14
|
|
|
9
15
|
// Ao abrir o PR: Stories/Feature → Etapa "👀 Code Review" (Status "Todo");
|
|
10
16
|
// Tasks → Etapa "🎉 Done" (Status "Done"), pois a implementação da task terminou.
|
|
@@ -13,18 +19,76 @@ const DONE_STAGE = STAGE_DONE;
|
|
|
13
19
|
const TODO_STATUS = PROGRESS_TODO;
|
|
14
20
|
const DONE_STATUS = PROGRESS_DONE;
|
|
15
21
|
|
|
16
|
-
//
|
|
17
|
-
|
|
22
|
+
// Vínculo EXPLÍCITO: o verbo é obrigatório. `Relacionado: #10` e `#10` solto
|
|
23
|
+
// deixaram de contar — referência cruzada em PR é prática comum (uma PR de
|
|
24
|
+
// documentação citando três Features moveu as três para 👀 Code Review, e uma
|
|
25
|
+
// delas nem plano tinha). Aceita `#N`, `owner/repo#N` e a URL completa da issue.
|
|
26
|
+
const EXPLICIT_LINK_RE =
|
|
27
|
+
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|implement(?:s|ed)?)\b\s*:?\s+(?:https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/(\d+)|[\w.-]+\/[\w.-]+#(\d+)|#(\d+))/gi;
|
|
28
|
+
|
|
29
|
+
// Forma antiga, atrás do knob `codeReview.linkMode: "any-mention"`.
|
|
30
|
+
const ANY_MENTION_RE = /(?:closes?|fixes?|resolves?)\s+#(\d+)|(?<![/\w#])#(\d+)/gi;
|
|
31
|
+
|
|
32
|
+
// Blocos de código não são vínculo: um `Closes #12` dentro de um exemplo de
|
|
33
|
+
// commit message é documentação, não promessa.
|
|
34
|
+
function stripCodeBlocks(body) {
|
|
35
|
+
return String(body)
|
|
36
|
+
.replace(/^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[ \t]*$/gm, '\n')
|
|
37
|
+
.replace(/`[^`\n]*`/g, ' ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Números de issues que o PR declara implementar (função PURA — testável).
|
|
42
|
+
*
|
|
43
|
+
* @param {string} body corpo do PR
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {'explicit'|'any-mention'} [opts.linkMode='explicit']
|
|
46
|
+
* @returns {number[]} na ordem em que aparecem, sem repetição
|
|
47
|
+
*/
|
|
48
|
+
export function extractIssueNumbers(body, { linkMode = 'explicit' } = {}) {
|
|
18
49
|
if (!body) return [];
|
|
19
50
|
const nums = new Set();
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
51
|
+
const text = stripCodeBlocks(body);
|
|
52
|
+
const re = linkMode === 'any-mention' ? ANY_MENTION_RE : EXPLICIT_LINK_RE;
|
|
53
|
+
re.lastIndex = 0;
|
|
54
|
+
for (const m of text.matchAll(re)) {
|
|
55
|
+
const n = parseInt(m.slice(1).find(Boolean) || '', 10);
|
|
23
56
|
if (n) nums.add(n);
|
|
24
57
|
}
|
|
25
58
|
return [...nums];
|
|
26
59
|
}
|
|
27
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Recorte temporal da subárvore (função PURA — testável).
|
|
63
|
+
*
|
|
64
|
+
* Uma sub-issue criada DEPOIS que o PR foi aberto não faz parte daquele PR. Foi
|
|
65
|
+
* assim que o `decompose-apply` da #10 e o `code-review` da PR #528 se
|
|
66
|
+
* atropelaram: o comando leu a subárvore no meio da criação das issues e moveu
|
|
67
|
+
* o que existia naquele instante — duas Stories fundacionais e duas Tasks
|
|
68
|
+
* nasceram concluídas.
|
|
69
|
+
*
|
|
70
|
+
* `pr.created_at` é a referência (e não o relógio do run) porque é determinístico:
|
|
71
|
+
* reexecutar o mesmo PR dá o mesmo recorte.
|
|
72
|
+
*
|
|
73
|
+
* @param {Array<{number,createdAt?}>} subs sub-issues como vêm do listSubIssues
|
|
74
|
+
* @param {string|null} notAfter ISO-8601; null desliga o recorte
|
|
75
|
+
* @returns {{ kept: Array, skipped: Array }}
|
|
76
|
+
*/
|
|
77
|
+
export function partitionByCreation(subs = [], notAfter = null) {
|
|
78
|
+
if (!notAfter) return { kept: [...subs], skipped: [] };
|
|
79
|
+
const limit = Date.parse(notAfter);
|
|
80
|
+
if (Number.isNaN(limit)) return { kept: [...subs], skipped: [] };
|
|
81
|
+
const kept = [];
|
|
82
|
+
const skipped = [];
|
|
83
|
+
for (const sub of subs) {
|
|
84
|
+
const at = sub?.createdAt ? Date.parse(sub.createdAt) : NaN;
|
|
85
|
+
// Sem data legível não dá para afirmar que nasceu depois — mantém.
|
|
86
|
+
if (Number.isNaN(at) || at <= limit) kept.push(sub);
|
|
87
|
+
else skipped.push(sub);
|
|
88
|
+
}
|
|
89
|
+
return { kept, skipped };
|
|
90
|
+
}
|
|
91
|
+
|
|
28
92
|
// A partir de qualquer issue (Feature/Story/Task), sobe a hierarquia e retorna a Feature.
|
|
29
93
|
async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
30
94
|
let issue;
|
|
@@ -70,21 +134,32 @@ export function classifyReviewTarget(type) {
|
|
|
70
134
|
return 'ignore';
|
|
71
135
|
}
|
|
72
136
|
|
|
73
|
-
|
|
137
|
+
// Labels que dizem "há uma decomposição em curso nesta Feature". Descer para a
|
|
138
|
+
// subárvore no meio disso é a corrida do item 3 — melhor não tocar em nada e
|
|
139
|
+
// deixar o humano reexecutar.
|
|
140
|
+
const DECOMPOSING_LABELS = [LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY];
|
|
141
|
+
|
|
142
|
+
function isDecomposing(issue) {
|
|
143
|
+
return labelNames(issue?.labels || []).some(n => DECOMPOSING_LABELS.includes(n));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function collectReviewUnit(token, owner, repo, issueNumber, { notAfter = null } = {}) {
|
|
74
147
|
const stories = new Map();
|
|
75
148
|
const tasks = new Map();
|
|
76
149
|
const bugs = new Map();
|
|
150
|
+
const skipped = [];
|
|
77
151
|
const addStory = (n, nodeId, title) => { if (n && nodeId && !stories.has(n)) stories.set(n, { nodeId, title }); };
|
|
78
152
|
const addTask = (n, nodeId, title) => { if (n && nodeId && !tasks.has(n)) tasks.set(n, { nodeId, title }); };
|
|
153
|
+
const empty = (extra = {}) => ({ feature: null, stories, tasks, bugs, skipped, deferred: null, ...extra });
|
|
79
154
|
|
|
80
155
|
let issue;
|
|
81
|
-
try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return
|
|
156
|
+
try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return empty(); }
|
|
82
157
|
const type = detectIssueType(issue);
|
|
83
158
|
const kind = classifyReviewTarget(type);
|
|
84
|
-
if (kind === 'ignore') return
|
|
159
|
+
if (kind === 'ignore') return empty();
|
|
85
160
|
if (kind === 'bug') {
|
|
86
161
|
bugs.set(issue.number, { nodeId: issue.node_id, title: issue.title });
|
|
87
|
-
return {
|
|
162
|
+
return empty({ bugs });
|
|
88
163
|
}
|
|
89
164
|
|
|
90
165
|
const featureIssue = await resolveFeatureIssue(token, owner, repo, issueNumber);
|
|
@@ -92,32 +167,42 @@ async function collectReviewUnit(token, owner, repo, issueNumber) {
|
|
|
92
167
|
? { number: featureIssue.number, nodeId: featureIssue.node_id, title: featureIssue.title }
|
|
93
168
|
: null;
|
|
94
169
|
|
|
170
|
+
// Decomposição em curso: as sub-issues estão sendo criadas AGORA. Mover o que
|
|
171
|
+
// já existe deixa o board mentindo sobre metade da árvore.
|
|
172
|
+
if (isDecomposing(issue) || (featureIssue && isDecomposing(featureIssue))) {
|
|
173
|
+
return empty({ deferred: (featureIssue || issue).number });
|
|
174
|
+
}
|
|
175
|
+
|
|
95
176
|
// Spikes (tipos manuais) nunca são movidos automaticamente — pulados aqui.
|
|
96
177
|
const isManual = (sub) => isManualStageType(detectIssueType({ title: sub.title, labels: sub.labels }));
|
|
97
178
|
|
|
179
|
+
// Desce um nível aplicando o recorte temporal, registrando o que ficou de fora.
|
|
180
|
+
const children = async (nodeId) => {
|
|
181
|
+
const all = await listSubIssues(token, nodeId).catch(() => []);
|
|
182
|
+
const { kept, skipped: out } = partitionByCreation(all, notAfter);
|
|
183
|
+
skipped.push(...out);
|
|
184
|
+
return kept;
|
|
185
|
+
};
|
|
186
|
+
|
|
98
187
|
if (type === 'Feature') {
|
|
99
188
|
// Referência direta à Feature: toda a subárvore (Stories + Tasks).
|
|
100
|
-
const
|
|
101
|
-
for (const st of subs) {
|
|
189
|
+
for (const st of await children(issue.node_id)) {
|
|
102
190
|
if (isManual(st)) continue;
|
|
103
191
|
addStory(st.number, st.nodeId, st.title);
|
|
104
|
-
const
|
|
105
|
-
for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
192
|
+
for (const t of await children(st.nodeId)) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
106
193
|
}
|
|
107
194
|
} else if (type === 'Story') {
|
|
108
195
|
addStory(issue.number, issue.node_id, issue.title);
|
|
109
|
-
const
|
|
110
|
-
for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
196
|
+
for (const t of await children(issue.node_id)) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
111
197
|
} else { // Task → inclui a Story pai e as Tasks irmãs.
|
|
112
198
|
addTask(issue.number, issue.node_id, issue.title);
|
|
113
199
|
const parent = await getIssueParent(token, issue.node_id).catch(() => null);
|
|
114
200
|
if (parent && detectIssueType({ title: parent.title }) === 'Story') {
|
|
115
201
|
addStory(parent.number, parent.nodeId, parent.title);
|
|
116
|
-
const
|
|
117
|
-
for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
202
|
+
for (const t of await children(parent.nodeId)) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
118
203
|
}
|
|
119
204
|
}
|
|
120
|
-
return { feature, stories, tasks, bugs };
|
|
205
|
+
return { feature, stories, tasks, bugs, skipped, deferred: null };
|
|
121
206
|
}
|
|
122
207
|
|
|
123
208
|
// A Feature só avança quando TODAS as suas Stories já estiverem em Code Review
|
|
@@ -137,7 +222,7 @@ async function allStoriesReadyForReview(readToken, projToken, project, etapaFiel
|
|
|
137
222
|
return true;
|
|
138
223
|
}
|
|
139
224
|
|
|
140
|
-
export async function codeReview({ prNumber }) {
|
|
225
|
+
export async function codeReview({ prNumber, resolveOnly = false }) {
|
|
141
226
|
const token = await resolveToken();
|
|
142
227
|
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
143
228
|
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
@@ -153,10 +238,37 @@ export async function codeReview({ prNumber }) {
|
|
|
153
238
|
}
|
|
154
239
|
|
|
155
240
|
const pr = await getPR(token, owner, repo, parseInt(prNumber, 10));
|
|
156
|
-
const
|
|
241
|
+
const linkMode = cfg?.codeReview?.linkMode === 'any-mention' ? 'any-mention' : 'explicit';
|
|
242
|
+
|
|
243
|
+
// Duas fontes de vínculo, nesta ordem: o que o GitHub reconhece nativamente
|
|
244
|
+
// (o mesmo que fecha a issue no merge, e que cobre o vínculo feito pela UI) e
|
|
245
|
+
// o que está escrito no corpo. `Implements #N` só existe na segunda.
|
|
246
|
+
const linked = await getPRClosingIssues(token, owner, repo, parseInt(prNumber, 10)).catch(err => {
|
|
247
|
+
console.warn(`Não foi possível ler os vínculos nativos do PR: ${err.message}`);
|
|
248
|
+
return [];
|
|
249
|
+
});
|
|
250
|
+
const issueNums = [...new Set([...linked, ...extractIssueNumbers(pr.body || '', { linkMode })])];
|
|
157
251
|
|
|
158
252
|
if (issueNums.length === 0) {
|
|
159
|
-
console.log(
|
|
253
|
+
console.log(
|
|
254
|
+
linkMode === 'explicit'
|
|
255
|
+
? 'PR sem vínculo explícito (Closes/Fixes/Resolves/Implements #N) — board não atualizado. ' +
|
|
256
|
+
'Menção solta a uma issue é referência cruzada, não implementação.'
|
|
257
|
+
: 'PR sem referências a issues — nenhuma Feature atualizada.'
|
|
258
|
+
);
|
|
259
|
+
if (resolveOnly) emitResolved(null);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// `--resolve-only`: só descobre a Feature-alvo e imprime, para o workflow
|
|
264
|
+
// poder declarar a concurrency por ITEM antes de tocar no board (o job que
|
|
265
|
+
// move fica atrás da mesma trava do decompose).
|
|
266
|
+
if (resolveOnly) {
|
|
267
|
+
for (const num of issueNums) {
|
|
268
|
+
const feature = await resolveFeatureIssue(token, owner, repo, num).catch(() => null);
|
|
269
|
+
if (feature) { emitResolved(feature.number); return; }
|
|
270
|
+
}
|
|
271
|
+
emitResolved(issueNums[0]);
|
|
160
272
|
return;
|
|
161
273
|
}
|
|
162
274
|
|
|
@@ -174,12 +286,26 @@ export async function codeReview({ prNumber }) {
|
|
|
174
286
|
const seen = new Set();
|
|
175
287
|
const updated = [];
|
|
176
288
|
const featuresChecked = new Set();
|
|
289
|
+
const deferred = new Set();
|
|
290
|
+
const ignored = [];
|
|
177
291
|
|
|
178
292
|
// Regras ao abrir o PR: Tasks → 🎉 Done (implementação concluída);
|
|
179
293
|
// Stories → 👀 Code Review; Feature → Code Review só quando TODAS as suas
|
|
180
294
|
// Stories já estiverem em Code Review.
|
|
181
295
|
for (const num of issueNums) {
|
|
182
|
-
const
|
|
296
|
+
const unit = await collectReviewUnit(token, owner, repo, num, { notAfter: pr.created_at });
|
|
297
|
+
const { feature, stories, tasks, bugs } = unit;
|
|
298
|
+
if (unit.deferred) {
|
|
299
|
+
deferred.add(unit.deferred);
|
|
300
|
+
console.log(
|
|
301
|
+
`#${unit.deferred} está com decomposição em andamento — board não atualizado para esta unidade.`
|
|
302
|
+
);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
for (const sub of unit.skipped) {
|
|
306
|
+
ignored.push(`#${sub.number} ${sub.title}`);
|
|
307
|
+
console.log(`#${sub.number} criada depois da abertura do PR (${sub.createdAt}) — fora desta review.`);
|
|
308
|
+
}
|
|
183
309
|
|
|
184
310
|
// Tasks → Done (Status Done).
|
|
185
311
|
for (const [n, info] of tasks) {
|
|
@@ -255,14 +381,43 @@ export async function codeReview({ prNumber }) {
|
|
|
255
381
|
}
|
|
256
382
|
}
|
|
257
383
|
|
|
258
|
-
if (updated.length > 0) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
384
|
+
if (updated.length > 0 || deferred.size > 0) {
|
|
385
|
+
const partes = [];
|
|
386
|
+
if (updated.length > 0) {
|
|
387
|
+
partes.push(
|
|
388
|
+
`🔍 **Code Review iniciado**\n\n` +
|
|
389
|
+
`Board atualizado (Tasks → **${DONE_STAGE}**; Story → **${CODE_REVIEW_STAGE}**; Feature só avança quando todas as Stories concluírem):\n\n` +
|
|
390
|
+
updated.map(f => `- ${f}`).join('\n')
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
// O que ficou de fora precisa ser dito: silêncio aqui vira "o board está
|
|
394
|
+
// certo" na cabeça de quem lê, que é exatamente o erro que se quer evitar.
|
|
395
|
+
if (ignored.length > 0) {
|
|
396
|
+
partes.push(
|
|
397
|
+
`_Fora desta review (criadas depois da abertura do PR):_\n\n${ignored.map(i => `- ${i}`).join('\n')}`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (deferred.size > 0) {
|
|
401
|
+
partes.push(
|
|
402
|
+
`⏳ **Board não atualizado** para ${[...deferred].map(n => `#${n}`).join(', ')}: ` +
|
|
403
|
+
'há decomposição em andamento. Depois que ela terminar, rode ' +
|
|
404
|
+
`\`npx @spec-wave/cli@latest code-review --pr-number ${prNumber}\`.`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
await commentOnIssue(token, owner, repo, parseInt(prNumber, 10), partes.join('\n\n')).catch(() => {});
|
|
265
408
|
}
|
|
266
409
|
|
|
267
410
|
console.log(`code-review: ${updated.length} item(ns) atualizado(s) no board.`);
|
|
268
411
|
}
|
|
412
|
+
|
|
413
|
+
// Saída do `--resolve-only`: linha legível + `feature=<n>` no GITHUB_OUTPUT,
|
|
414
|
+
// que é o que o job seguinte usa como chave de concurrency.
|
|
415
|
+
function emitResolved(number) {
|
|
416
|
+
const value = number ? String(number) : '';
|
|
417
|
+
console.log(`feature=${value}`);
|
|
418
|
+
const out = process.env.GITHUB_OUTPUT;
|
|
419
|
+
if (out) {
|
|
420
|
+
try { appendFileSync(out, `feature=${value}\n`); }
|
|
421
|
+
catch (err) { console.warn(`Falha ao escrever em GITHUB_OUTPUT: ${err.message}`); }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -23,7 +23,7 @@ import { resolveToken } from '../api/auth.mjs';
|
|
|
23
23
|
import {
|
|
24
24
|
getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, listIssueComments,
|
|
25
25
|
} from '../api/github-rest.mjs';
|
|
26
|
-
import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
|
|
26
|
+
import { addSubIssue, listSubIssues, getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
27
27
|
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
28
28
|
import { generateDocument } from '../lib/claude.mjs';
|
|
29
29
|
import {
|
|
@@ -31,13 +31,13 @@ import {
|
|
|
31
31
|
parseCritiqueDecisions, applyCritiqueDecisions, renderRiskAcceptedComment,
|
|
32
32
|
} from '../lib/critique.mjs';
|
|
33
33
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
34
|
-
import { formatDependencyLine } from '../lib/dependencies.mjs';
|
|
34
|
+
import { formatDependencyLine, orderStories, renderOrderComment } from '../lib/dependencies.mjs';
|
|
35
35
|
import { lintLanguage } from '../lib/output-lint.mjs';
|
|
36
36
|
import { slugify } from '../lib/slugify.mjs';
|
|
37
37
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
38
38
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
39
39
|
import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
|
|
40
|
-
import { loadPrompt,
|
|
40
|
+
import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
|
|
41
41
|
import {
|
|
42
42
|
renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
|
|
43
43
|
} from '../lib/decomposition-doc.mjs';
|
|
@@ -54,6 +54,24 @@ async function moveToReady(token, project, etapaField, statusField, nodeId) {
|
|
|
54
54
|
await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Board inalcançável no apply — parada ANTES de criar qualquer issue.
|
|
59
|
+
*
|
|
60
|
+
* Separado de DecomposeBlockedError porque o remédio é outro: aqui nada foi
|
|
61
|
+
* criado e o problema é de credencial/config, não do conteúdo do rascunho.
|
|
62
|
+
*/
|
|
63
|
+
class BoardUnreachableError extends Error {
|
|
64
|
+
constructor(motivo) {
|
|
65
|
+
super(
|
|
66
|
+
`Board inalcançável: ${motivo}. Nenhuma issue foi criada — ` +
|
|
67
|
+
'sem escrita no board elas nasceriam sem Etapa e ficariam invisíveis em todas as telas. ' +
|
|
68
|
+
'Verifique se o GH_PROJECT_TOKEN tem scope `project` e acesso ao Project da organização.'
|
|
69
|
+
);
|
|
70
|
+
this.name = 'BoardUnreachableError';
|
|
71
|
+
this.boardUnreachable = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
/**
|
|
58
76
|
* Parada por decisão do fluxo (crítica grave, crítica indisponível, teto de
|
|
59
77
|
* tentativas): o comentário já foi postado, o catch externo não deve duplicá-lo,
|
|
@@ -252,9 +270,9 @@ async function draftDecomposition(ctx) {
|
|
|
252
270
|
{ cwd: root },
|
|
253
271
|
);
|
|
254
272
|
const generated = parseModelJson(await generateDocument(
|
|
255
|
-
|
|
273
|
+
systemPromptWithTools(decomposePrompt),
|
|
256
274
|
userContent,
|
|
257
|
-
{ action: 'decompose', labels, usage }
|
|
275
|
+
{ action: 'decompose', labels, usage, maxTurns: decomposePrompt.maxTurns }
|
|
258
276
|
));
|
|
259
277
|
|
|
260
278
|
markdown = renderDecompositionDoc({
|
|
@@ -438,16 +456,89 @@ async function applyDecomposition(ctx) {
|
|
|
438
456
|
}
|
|
439
457
|
|
|
440
458
|
// Campos do board só são resolvidos aqui: a etapa de rascunho não toca o board.
|
|
441
|
-
|
|
459
|
+
// `strict`: no apply, board inalcançável ABORTA antes da primeira criação.
|
|
460
|
+
const { project, etapaField, statusField } = await resolveBoard(ctx, { strict: true });
|
|
442
461
|
const applyCtx = { ...ctx, project, etapaField, statusField };
|
|
443
462
|
|
|
444
|
-
|
|
445
|
-
|
|
463
|
+
// As falhas de board voltam em vez de subir na hora: a exceção precisa esperar
|
|
464
|
+
// a contabilidade de labels abaixo. Lançar aqui deixaria o gatilho
|
|
465
|
+
// `spec-wave:decompose-apply` preso na issue — e `issues: [labeled]` não
|
|
466
|
+
// redispara com a label ainda aplicada, o mesmo beco sem saída que o
|
|
467
|
+
// generate-plan já evita.
|
|
468
|
+
const boardFailures = doc.kind === 'tasks'
|
|
469
|
+
? await createTasksFromDoc(applyCtx, doc)
|
|
470
|
+
: await createStoriesFromDoc(applyCtx, doc);
|
|
471
|
+
|
|
472
|
+
// O arquivo passa a REGISTRAR o que foi criado, em vez de continuar
|
|
473
|
+
// descrevendo só a proposta. Sem isto, rescopar as issues à mão (o que é
|
|
474
|
+
// legítimo e acontece) deixa o decomposition.md como registro enganoso, e nada
|
|
475
|
+
// no fluxo percebe: o guard `spec-wave:decomposed` impede duplicar, não
|
|
476
|
+
// desatualizar.
|
|
477
|
+
//
|
|
478
|
+
// Depois da criação de propósito: se este commit falhar, as issues já existem
|
|
479
|
+
// e o pior caso é o comportamento anterior (arquivo sem anotação), avisado no
|
|
480
|
+
// log. O contrário — anotar antes e falhar na criação — inventaria issues.
|
|
481
|
+
try {
|
|
482
|
+
commitFile(
|
|
483
|
+
docPath,
|
|
484
|
+
renderDecompositionDoc({ ...doc, appliedAt: new Date().toISOString() }),
|
|
485
|
+
`docs: registra as issues criadas em ${docRel} [spec-wave]`,
|
|
486
|
+
ctx.runMode,
|
|
487
|
+
);
|
|
488
|
+
console.log(`${docRel} anotado com as issues criadas.`);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
console.warn(
|
|
491
|
+
`⚠️ Issues criadas, mas ${docRel} não foi anotado (${err.message}). ` +
|
|
492
|
+
'O arquivo segue descrevendo a proposta, não o que existe.'
|
|
493
|
+
);
|
|
494
|
+
}
|
|
446
495
|
|
|
447
496
|
await addLabel(token, owner, repo, number, LABEL_DECOMPOSED)
|
|
448
497
|
.catch(err => console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`));
|
|
449
498
|
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY);
|
|
450
499
|
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
|
|
500
|
+
|
|
501
|
+
// Por último, com as labels já consistentes: escrita de board incompleta
|
|
502
|
+
// termina o run em VERMELHO.
|
|
503
|
+
failIfBoardIncomplete(boardFailures);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Bloco do comentário quando itens ficaram sem Etapa (função PURA).
|
|
508
|
+
*
|
|
509
|
+
* O comentário é postado ANTES de a exceção subir: quem for reparar precisa da
|
|
510
|
+
* lista, e ela não pode depender de alguém abrir o log do Action.
|
|
511
|
+
*/
|
|
512
|
+
export function renderBoardFailures(failures) {
|
|
513
|
+
const linhas = failures.map(f => `- ${f.kind} #${f.number}`).join('\n');
|
|
514
|
+
const motivo = failures[0]?.reason || 'erro desconhecido';
|
|
515
|
+
return (
|
|
516
|
+
`⚠️ **${failures.length} item(ns) ficaram SEM Etapa no board** — eles existem, mas ` +
|
|
517
|
+
'não aparecem em nenhuma tela (o inbox do PM lista Features, a tela do Dev lê ' +
|
|
518
|
+
'🚧 Desenvolvimento, a fila do TL lê ✅ Ready):\n\n' +
|
|
519
|
+
`${linhas}\n\n` +
|
|
520
|
+
`Motivo: \`${motivo}\`\n\n` +
|
|
521
|
+
'Verifique o `GH_PROJECT_TOKEN` (scope `project` e acesso ao Project da organização) e ' +
|
|
522
|
+
'reposicione os itens com `npx @spec-wave/cli@latest repair-stage <issue>`.'
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Escrita do board incompleta = run VERMELHO.
|
|
528
|
+
*
|
|
529
|
+
* O apply criava as issues e engolia TODA falha de board, terminando com exit 0:
|
|
530
|
+
* 27 issues nasceram sem Etapa, invisíveis em todas as telas, com o Action ✓ —
|
|
531
|
+
* só se descobriu rodando `order` por acaso. É o mesmo princípio que o fluxo já
|
|
532
|
+
* aplica à crítica ("um pipeline bloqueado nunca fica verde") e ao truncamento
|
|
533
|
+
* ("documento cortado vale menos que documento nenhum").
|
|
534
|
+
*/
|
|
535
|
+
function failIfBoardIncomplete(failures) {
|
|
536
|
+
if (failures.length === 0) return;
|
|
537
|
+
const err = new DecomposeBlockedError(
|
|
538
|
+
`${failures.length} item(ns) criados ficaram sem Etapa no board — ` +
|
|
539
|
+
'as issues existem mas estão invisíveis nas telas. Ver o comentário na issue para reparar.'
|
|
540
|
+
);
|
|
541
|
+
throw err;
|
|
451
542
|
}
|
|
452
543
|
|
|
453
544
|
async function createStoriesFromDoc(ctx, doc) {
|
|
@@ -456,6 +547,9 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
456
547
|
const created = [];
|
|
457
548
|
const createdStories = []; // issues criadas, na ordem dos índices das stories
|
|
458
549
|
const generatedTexts = []; // títulos+corpos para o lint de idioma final
|
|
550
|
+
// Item que não consegue Etapa fica invisível em todas as telas. Coletar em vez
|
|
551
|
+
// de só avisar é o que permite o run terminar VERMELHO — ver reportBoardFailures.
|
|
552
|
+
const boardFailures = [];
|
|
459
553
|
|
|
460
554
|
for (let i = 0; i < doc.stories.length; i++) {
|
|
461
555
|
const story = doc.stories[i];
|
|
@@ -472,6 +566,9 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
472
566
|
if (depLine) storyBody += `\n\n${depLine}`;
|
|
473
567
|
|
|
474
568
|
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
|
|
569
|
+
// Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
|
|
570
|
+
// arquivo no fim do apply.
|
|
571
|
+
story.issue = createdStory.number;
|
|
475
572
|
created.push({ title: storyTitle, url: createdStory.url });
|
|
476
573
|
createdStories.push(createdStory);
|
|
477
574
|
generatedTexts.push(storyTitle, storyBody);
|
|
@@ -494,6 +591,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
494
591
|
await moveToReady(projectToken, project, etapaField, statusField, createdStory.nodeId);
|
|
495
592
|
} catch (err) {
|
|
496
593
|
console.warn(` Falha ao mover story #${createdStory.number} para "${STAGE_READY}": ${err.message}`);
|
|
594
|
+
boardFailures.push({ number: createdStory.number, kind: 'Story', reason: err.message });
|
|
497
595
|
}
|
|
498
596
|
|
|
499
597
|
for (const task of story.tasks || []) {
|
|
@@ -501,6 +599,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
501
599
|
const taskTitle = `[TASK] ${task.title}`;
|
|
502
600
|
const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
|
|
503
601
|
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
602
|
+
task.issue = createdTask.number;
|
|
504
603
|
generatedTexts.push(taskTitle, taskBody);
|
|
505
604
|
try {
|
|
506
605
|
await addSubIssue(token, createdStory.nodeId, createdTask.nodeId);
|
|
@@ -511,6 +610,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
511
610
|
await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
|
|
512
611
|
} catch (err) {
|
|
513
612
|
console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
|
|
613
|
+
boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
|
|
514
614
|
}
|
|
515
615
|
}
|
|
516
616
|
}
|
|
@@ -520,16 +620,68 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
520
620
|
if (project?.id && etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
|
|
521
621
|
} catch (err) {
|
|
522
622
|
console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
|
|
623
|
+
boardFailures.push({ number: parseInt(issueNumber, 10), kind: 'Feature', reason: err.message });
|
|
523
624
|
}
|
|
524
625
|
|
|
525
626
|
const list = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
|
|
627
|
+
const posicionamento = boardFailures.length === 0
|
|
628
|
+
? `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement ${issueNumber}\` (Stories em ordem de dependência).`
|
|
629
|
+
: renderBoardFailures(boardFailures);
|
|
526
630
|
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
527
631
|
`🔀 **Decomposição aplicada!**\n\n` +
|
|
528
632
|
`A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
|
|
529
|
-
|
|
633
|
+
posicionamento +
|
|
530
634
|
formatItemsLintWarning(generatedTexts)
|
|
531
|
-
);
|
|
635
|
+
).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
|
|
636
|
+
|
|
637
|
+
await commentStoryOrder({ token, owner, repo, issueNumber, doc, createdStories });
|
|
638
|
+
|
|
532
639
|
console.log(`Decomposição aplicada: ${created.length} stories criadas.`);
|
|
640
|
+
return boardFailures;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Publica a ordem de execução das Stories como um segundo comentário.
|
|
645
|
+
*
|
|
646
|
+
* Os dados saem do que ACABOU de ser criado — `createdStories` (índice → issue)
|
|
647
|
+
* e `doc.stories[i].dependsOn` (0-based, já validado pelo parser). Não consulta
|
|
648
|
+
* o board: reconstruir isso pela API custaria ~4 chamadas por Story para chegar
|
|
649
|
+
* ao que já está em memória, e a Etapa é ✅ Ready por construção — se não fosse,
|
|
650
|
+
* `failIfBoardIncomplete` já teria derrubado o run.
|
|
651
|
+
*
|
|
652
|
+
* BEST-EFFORT, como o comentário que o antecede: a decomposição está feita e
|
|
653
|
+
* paga, e falhar ao comentar não pode desfazê-la nem mascarar o que deu certo.
|
|
654
|
+
*/
|
|
655
|
+
async function commentStoryOrder({ token, owner, repo, issueNumber, doc, createdStories }) {
|
|
656
|
+
try {
|
|
657
|
+
const stories = doc.stories
|
|
658
|
+
.map((story, i) => {
|
|
659
|
+
const criada = createdStories[i];
|
|
660
|
+
if (!criada) return null; // criação falhou no meio: não inventa a Story
|
|
661
|
+
return {
|
|
662
|
+
number: criada.number,
|
|
663
|
+
title: story.title,
|
|
664
|
+
// dependsOn vem 0-based (índice no doc) e vira número de issue.
|
|
665
|
+
dependsOn: (story.dependsOn || [])
|
|
666
|
+
.map(idx => createdStories[idx]?.number)
|
|
667
|
+
.filter(Boolean),
|
|
668
|
+
};
|
|
669
|
+
})
|
|
670
|
+
.filter(Boolean);
|
|
671
|
+
|
|
672
|
+
if (stories.length === 0) return;
|
|
673
|
+
|
|
674
|
+
const byNumber = new Map(stories.map(s => [s.number, s]));
|
|
675
|
+
const { order: sorted, cycle } = orderStories(
|
|
676
|
+
stories.map(({ number, dependsOn }) => ({ number, dependsOn }))
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
680
|
+
renderOrderComment({ sorted, cycle, byNumber }));
|
|
681
|
+
console.log(`Ordem de execução comentada: ${sorted.length} story(ies).`);
|
|
682
|
+
} catch (err) {
|
|
683
|
+
console.warn(`Falha ao comentar a ordem de execução: ${err.message}`);
|
|
684
|
+
}
|
|
533
685
|
}
|
|
534
686
|
|
|
535
687
|
async function createTasksFromDoc(ctx, doc) {
|
|
@@ -537,12 +689,14 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
537
689
|
const parentNodeId = issue.node_id;
|
|
538
690
|
const created = [];
|
|
539
691
|
const generatedTexts = [];
|
|
692
|
+
const boardFailures = [];
|
|
540
693
|
|
|
541
694
|
for (const task of doc.tasks) {
|
|
542
695
|
console.log(`Criando task: ${task.title}`);
|
|
543
696
|
const taskTitle = `[TASK] ${task.title}`;
|
|
544
697
|
const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
|
|
545
698
|
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
699
|
+
task.issue = createdTask.number;
|
|
546
700
|
created.push({ title: taskTitle, url: createdTask.url });
|
|
547
701
|
generatedTexts.push(taskTitle, taskBody);
|
|
548
702
|
|
|
@@ -555,34 +709,65 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
555
709
|
await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
|
|
556
710
|
} catch (err) {
|
|
557
711
|
console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
|
|
712
|
+
boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
|
|
558
713
|
}
|
|
559
714
|
}
|
|
560
715
|
|
|
561
716
|
const list = created.map(t => `- ${t.url} — ${t.title}`).join('\n');
|
|
717
|
+
const posicionamento = boardFailures.length === 0
|
|
718
|
+
? `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement <task>\`.`
|
|
719
|
+
: renderBoardFailures(boardFailures);
|
|
562
720
|
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
563
721
|
`🔀 **Decomposição do RFC aplicada!**\n\n` +
|
|
564
722
|
`A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
|
|
565
|
-
|
|
723
|
+
posicionamento +
|
|
566
724
|
formatItemsLintWarning(generatedTexts)
|
|
567
|
-
);
|
|
725
|
+
).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
|
|
568
726
|
console.log(`Decomposição aplicada: ${created.length} tasks criadas.`);
|
|
727
|
+
return boardFailures;
|
|
569
728
|
}
|
|
570
729
|
|
|
571
730
|
// Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
|
|
572
|
-
async function resolveBoard({ projectToken, root }) {
|
|
731
|
+
async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
|
|
573
732
|
const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
574
|
-
if (projectError)
|
|
733
|
+
if (projectError) {
|
|
734
|
+
if (strict) throw new BoardUnreachableError(projectError);
|
|
735
|
+
console.warn(`${projectError} — itens criados não serão posicionados no board.`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// PREFLIGHT do apply: resolver o NODE do project antes de criar qualquer issue.
|
|
739
|
+
//
|
|
740
|
+
// `resolveField` não serve como preflight: ele devolve do cache do config
|
|
741
|
+
// (`project.fields`) sem tocar a rede, então um token sem acesso ao Project
|
|
742
|
+
// passava aqui limpo e só falhava na primeira mutação — depois de 27 issues
|
|
743
|
+
// criadas. Elas nascem sem Etapa, e sem Etapa somem de TODAS as telas (o inbox
|
|
744
|
+
// do PM lista Features, a tela do Dev lê 🚧 Desenvolvimento, a fila do TL lê
|
|
745
|
+
// ✅ Ready). Vinte e sete issues existindo e nenhuma aparecendo, com o Action
|
|
746
|
+
// verde. `getProjectSnapshot` faz `node(id:)` e é exatamente o que falha
|
|
747
|
+
// quando o token não alcança o Project da organização.
|
|
748
|
+
if (strict && project?.id) {
|
|
749
|
+
try {
|
|
750
|
+
await getProjectSnapshot(projectToken, project.id);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
throw new BoardUnreachableError(
|
|
753
|
+
`o Project ${project.id} não pôde ser resolvido com o token disponível (${err.message})`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
575
758
|
let etapaField = null;
|
|
576
759
|
let statusField = null;
|
|
577
760
|
if (project?.id) {
|
|
578
761
|
try {
|
|
579
762
|
etapaField = await resolveField(projectToken, project, 'Etapa');
|
|
580
763
|
} catch (err) {
|
|
764
|
+
if (strict) throw new BoardUnreachableError(`campo Etapa não resolvido (${err.message})`);
|
|
581
765
|
console.warn(`Não foi possível resolver campo Etapa do board: ${err.message}`);
|
|
582
766
|
}
|
|
583
767
|
try {
|
|
584
768
|
statusField = await resolveField(projectToken, project, 'Status');
|
|
585
769
|
} catch (err) {
|
|
770
|
+
if (strict) throw new BoardUnreachableError(`campo Status não resolvido (${err.message})`);
|
|
586
771
|
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
587
772
|
}
|
|
588
773
|
}
|