@spec-wave/cli 0.30.0 → 0.32.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/package.json +5 -3
- package/protocol/qa-result.v1.json +62 -0
- package/protocol/qa-trail-report.v1.json +113 -0
- package/src/api/github-graphql.mjs +6 -1
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +80 -5
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +102 -3
- package/src/commands/implement.mjs +56 -44
- package/src/commands/merge.mjs +43 -14
- package/src/commands/order.mjs +350 -96
- package/src/commands/qa-lead.mjs +748 -0
- package/src/commands/qa-run.mjs +104 -25
- package/src/config.mjs +15 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +4 -0
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/qa-exec.mjs +23 -2
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-report.mjs +65 -9
- package/src/lib/skill-compose.mjs +234 -0
- package/src/lib/story-graph.mjs +256 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/order/SKILL.md +21 -5
- package/src/plugin/skills/qa/SKILL.md +3 -1
- package/src/plugin/skills/qa-executor/SKILL.md +76 -0
- package/src/plugin/skills/qa-lead/SKILL.md +89 -0
- package/src/templates/skill/SKILL.md +953 -298
- package/src/templates/skill/core.md +584 -0
package/src/commands/order.mjs
CHANGED
|
@@ -1,33 +1,44 @@
|
|
|
1
1
|
// Ordena as Stories pelas dependências (topológica, Kahn —
|
|
2
|
-
// ver orderStories em src/lib/dependencies.mjs).
|
|
3
|
-
// fontes, mescladas: a linha "Depende de: #N" no corpo da Story e a relação
|
|
4
|
-
// nativa blocked_by do GitHub.
|
|
2
|
+
// ver orderStories em src/lib/dependencies.mjs).
|
|
5
3
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// De onde vêm as ARESTAS (lib/story-graph.mjs): do dependency-map.json
|
|
5
|
+
// commitado (escrito pelo decompose --apply), do decomposition.md aplicado e
|
|
6
|
+
// das linhas "Depende de: #N" do corpo das sub-issues — fontes locais/grátis,
|
|
7
|
+
// sempre unidas. `--remote` acrescenta o blocked_by nativo da API (1 chamada
|
|
8
|
+
// por Story — a única fonte que enxerga aresta criada só pela UI); `--sync`
|
|
9
|
+
// grava essa leitura de volta no decomposition.md e regenera o mapa, para que
|
|
10
|
+
// as consultas seguintes voltem a ser locais.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// A Etapa de cada Story vem de UM `listProjectItems` paginado, cacheado com
|
|
13
|
+
// TTL (staleness sempre visível) — nunca mais do par addProjectItem +
|
|
14
|
+
// getItemSingleSelectValue por Story, que além de custar 2 chamadas por item
|
|
15
|
+
// era MUTAÇÃO em caminho de leitura.
|
|
15
16
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
17
|
+
// Dois escopos: `order <feature>` (uma Feature) e `order` sem argumento — o
|
|
18
|
+
// mapa de TODAAS as Features com trabalho no board (`--milestone` filtra).
|
|
19
|
+
// `--json` emite o contrato estável de lib/dependency-map.mjs para consumo
|
|
20
|
+
// programático (dev-agent e afins), sem ANSI para filtrar.
|
|
21
|
+
import { execSync } from 'node:child_process';
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
18
24
|
import * as p from '@clack/prompts';
|
|
19
25
|
import chalk from 'chalk';
|
|
20
26
|
import { resolveToken } from '../api/auth.mjs';
|
|
21
27
|
import { getIssue, listBlockedBy } from '../api/github-rest.mjs';
|
|
22
|
-
import {
|
|
23
|
-
addProjectItem, listSubIssues, getItemSingleSelectValue, listProjectItems,
|
|
24
|
-
} from '../api/github-graphql.mjs';
|
|
28
|
+
import { listSubIssues } from '../api/github-graphql.mjs';
|
|
25
29
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
26
30
|
import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
|
|
31
|
+
import { loadProjectConfig, selectOpenFeatures } from '../lib/board.mjs';
|
|
32
|
+
import { resolveRepoContext, loadConfig } from '../lib/project-root.mjs';
|
|
33
|
+
import { featureDocPaths } from '../lib/doc-paths.mjs';
|
|
34
|
+
import { parseDecompositionDoc, renderDecompositionDoc } from '../lib/decomposition-doc.mjs';
|
|
27
35
|
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
buildDependencyMap, syncDocDependencies, mergeDependencyEdges,
|
|
37
|
+
filterItemsByMilestone, renderOrderJson, stalenessNotice,
|
|
38
|
+
} from '../lib/dependency-map.mjs';
|
|
39
|
+
import { loadFeatureStories, loadBoardSnapshot, externalIssueInfo } from '../lib/story-graph.mjs';
|
|
40
|
+
import { resolveCacheTtl } from '../lib/net-cache.mjs';
|
|
41
|
+
import { pushWithRebase } from '../lib/git-retry.mjs';
|
|
31
42
|
import { CONFIG_FILE, STAGE_ORDER, STAGE_DEVELOPMENT, STAGE_DONE } from '../config.mjs';
|
|
32
43
|
|
|
33
44
|
/**
|
|
@@ -96,18 +107,26 @@ export function renderBoardOrder({ sorted, byNumber, featureOf, stageOf }) {
|
|
|
96
107
|
}).join('\n');
|
|
97
108
|
}
|
|
98
109
|
|
|
110
|
+
// A nota de origem das arestas: quem lê a saída precisa saber SE o blocked_by
|
|
111
|
+
// só-de-UI entrou — é a única aresta que as fontes locais não enxergam.
|
|
112
|
+
function edgesNote(origins, { remote }) {
|
|
113
|
+
const fontes = [...new Set(origins)].join(' · ');
|
|
114
|
+
if (remote) return `Arestas: ${fontes} (inclui blocked_by remoto).`;
|
|
115
|
+
return `Arestas: ${fontes} — blocked_by criado SÓ pela UI não entra (use --remote, ou --sync para gravá-lo no doc).`;
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
/**
|
|
100
119
|
* `spec-wave order` sem argumento: o grafo de TODAS as Features com trabalho.
|
|
101
120
|
*
|
|
102
121
|
* Fonte do conjunto é o BOARD, não os arquivos — `decomposition.md` registra o
|
|
103
|
-
* que foi proposto, a issue é o que existe.
|
|
104
|
-
*
|
|
105
|
-
*
|
|
122
|
+
* que foi proposto, a issue é o que existe. (As ARESTAS, essas sim, vêm dos
|
|
123
|
+
* artefatos locais — ver o cabeçalho.) Uma query paginada de itens do project
|
|
124
|
+
* resolve Etapa e tipo de todo mundo de uma vez.
|
|
106
125
|
*/
|
|
107
|
-
async function orderBoard({ token, owner, repo }) {
|
|
126
|
+
async function orderBoard({ token, owner, repo, root, ttlSec, opts }) {
|
|
108
127
|
const { project, error: projectError } = loadProjectConfig();
|
|
109
128
|
if (projectError) {
|
|
110
|
-
|
|
129
|
+
emitError(opts,
|
|
111
130
|
`${projectError} — sem board não há como saber quais Features têm trabalho. ` +
|
|
112
131
|
'Use `spec-wave order <feature>` para ordenar uma Feature específica.'
|
|
113
132
|
);
|
|
@@ -115,50 +134,60 @@ async function orderBoard({ token, owner, repo }) {
|
|
|
115
134
|
return;
|
|
116
135
|
}
|
|
117
136
|
|
|
118
|
-
let
|
|
137
|
+
let snapshot;
|
|
119
138
|
try {
|
|
120
|
-
|
|
139
|
+
snapshot = await loadBoardSnapshot({ token, project, root, refresh: opts.refresh, ttlSec });
|
|
121
140
|
} catch (err) {
|
|
122
|
-
|
|
141
|
+
emitError(opts, `Não foi possível ler os itens do Project: ${err.message}`);
|
|
123
142
|
process.exitCode = 1;
|
|
124
143
|
return;
|
|
125
144
|
}
|
|
126
|
-
|
|
127
|
-
|
|
145
|
+
|
|
146
|
+
let features = selectOpenFeatures(snapshot.items);
|
|
147
|
+
if (opts.milestone) {
|
|
148
|
+
features = filterItemsByMilestone(features, opts.milestone);
|
|
149
|
+
if (features.length === 0) {
|
|
150
|
+
emitInfo(opts, `Nenhuma Feature aberta na milestone "${opts.milestone}" — nada a ordenar.`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
128
154
|
if (features.length === 0) {
|
|
129
|
-
|
|
130
|
-
p.outro('Nada a fazer.');
|
|
155
|
+
emitInfo(opts, `Nenhuma Feature aberta fora de "${STAGE_DONE}" no board — nada a ordenar.`);
|
|
131
156
|
return;
|
|
132
157
|
}
|
|
133
158
|
|
|
159
|
+
const indice = snapshot.index;
|
|
134
160
|
const enriched = [];
|
|
135
161
|
const featureOf = new Map();
|
|
136
162
|
const stageOf = new Map();
|
|
137
163
|
const semStories = [];
|
|
164
|
+
const origins = new Set();
|
|
165
|
+
const warnings = [];
|
|
138
166
|
for (const feature of features) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
167
|
+
let carga;
|
|
168
|
+
try {
|
|
169
|
+
carga = await loadFeatureStories({
|
|
170
|
+
token, owner, repo, root, feature,
|
|
171
|
+
remote: opts.remote, refresh: opts.refresh, ttlSec,
|
|
172
|
+
});
|
|
173
|
+
} catch (err) {
|
|
174
|
+
warnings.push(`Stories da Feature #${feature.number} não legíveis: ${err.message}.`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
origins.add(carga.origin.edges);
|
|
178
|
+
warnings.push(...carga.warnings);
|
|
179
|
+
if (carga.stories.length === 0) { semStories.push(feature); continue; }
|
|
180
|
+
for (const story of carga.stories) {
|
|
144
181
|
// Story concluída não entra: o mapa é do que falta fazer.
|
|
145
182
|
if (indice.get(story.number)?.fields?.Etapa === STAGE_DONE) continue;
|
|
146
|
-
|
|
147
|
-
const fromBlockedBy = (await listBlockedBy(token, owner, repo, story.number).catch(() => []))
|
|
148
|
-
.map(b => b.number);
|
|
149
|
-
enriched.push({
|
|
150
|
-
number: story.number,
|
|
151
|
-
title: story.title,
|
|
152
|
-
dependsOn: [...new Set([...fromBody, ...fromBlockedBy])],
|
|
153
|
-
});
|
|
183
|
+
enriched.push({ number: story.number, title: story.title, dependsOn: story.dependsOn });
|
|
154
184
|
featureOf.set(story.number, feature);
|
|
155
185
|
stageOf.set(story.number, indice.get(story.number)?.fields?.Etapa || null);
|
|
156
186
|
}
|
|
157
187
|
}
|
|
158
188
|
|
|
159
189
|
if (enriched.length === 0) {
|
|
160
|
-
|
|
161
|
-
p.outro('Nada a fazer.');
|
|
190
|
+
emitInfo(opts, `${features.length} Feature(s) aberta(s), nenhuma com Story pendente.`);
|
|
162
191
|
return;
|
|
163
192
|
}
|
|
164
193
|
|
|
@@ -166,6 +195,21 @@ async function orderBoard({ token, owner, repo }) {
|
|
|
166
195
|
const { order: sorted, cycle, external } = orderStories(
|
|
167
196
|
enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
|
|
168
197
|
|
|
198
|
+
const stale = snapshot.fromCache ? stalenessNotice(snapshot.fetchedAt) : null;
|
|
199
|
+
|
|
200
|
+
if (opts.json) {
|
|
201
|
+
console.log(JSON.stringify(renderOrderJson({
|
|
202
|
+
sorted, byNumber, featureOf, stageOf, external, cycle,
|
|
203
|
+
meta: {
|
|
204
|
+
edges: [...origins].join(','),
|
|
205
|
+
stages: snapshot.fromCache ? 'cache' : 'board',
|
|
206
|
+
fetchedAt: snapshot.fetchedAt,
|
|
207
|
+
warnings: [...warnings, ...(stale ? [stale] : [])],
|
|
208
|
+
},
|
|
209
|
+
}), null, 2));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
169
213
|
if (cycle.length > 0) {
|
|
170
214
|
p.log.warn(
|
|
171
215
|
chalk.yellow.bold('⚠ CICLO DE DEPENDÊNCIAS detectado!') + '\n' +
|
|
@@ -194,57 +238,94 @@ async function orderBoard({ token, owner, repo }) {
|
|
|
194
238
|
if (semStories.length > 0) {
|
|
195
239
|
p.log.info(`Sem Stories (ainda não decompostas): ${semStories.map(f => `#${f.number}`).join(', ')}.`);
|
|
196
240
|
}
|
|
241
|
+
for (const w of warnings) p.log.warn(w);
|
|
242
|
+
p.log.info(chalk.dim(edgesNote(origins, opts)));
|
|
243
|
+
if (stale) p.log.warn(chalk.yellow(stale));
|
|
197
244
|
p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
|
|
198
245
|
}
|
|
199
246
|
|
|
200
|
-
|
|
247
|
+
// Saída de erro/info compatível com --json (que não pode misturar clack com o
|
|
248
|
+
// contrato): em json, erros vão para stderr e o stdout fica só com o JSON.
|
|
249
|
+
function emitError(opts, message) {
|
|
250
|
+
if (opts.json) console.error(message);
|
|
251
|
+
else p.log.error(message);
|
|
252
|
+
}
|
|
253
|
+
function emitInfo(opts, message) {
|
|
254
|
+
if (opts.json) {
|
|
255
|
+
console.log(JSON.stringify(renderOrderJson({ meta: { warnings: [message] } }), null, 2));
|
|
256
|
+
} else {
|
|
257
|
+
p.log.info(message);
|
|
258
|
+
p.outro('Nada a fazer.');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function order({
|
|
263
|
+
feature: featureArg, json = false, milestone = null, remote = false,
|
|
264
|
+
refresh = false, sync = false,
|
|
265
|
+
} = {}) {
|
|
201
266
|
const semArgumento = featureArg === undefined || featureArg === null
|
|
202
267
|
|| String(featureArg).trim() === '';
|
|
203
268
|
const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
|
|
269
|
+
const opts = { json, milestone, remote, refresh, sync };
|
|
204
270
|
if (!semArgumento && (!Number.isInteger(featureNumber) || featureNumber <= 0)) {
|
|
205
|
-
|
|
271
|
+
emitError(opts, `Feature inválida: "${featureArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
206
272
|
process.exitCode = 1;
|
|
207
273
|
return;
|
|
208
274
|
}
|
|
209
275
|
|
|
210
276
|
const { owner, repo } = resolveRepoContext();
|
|
211
277
|
if (!owner || !repo) {
|
|
212
|
-
|
|
278
|
+
emitError(opts,
|
|
213
279
|
'Não foi possível determinar owner/repo.\n' +
|
|
214
280
|
`Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
|
|
215
281
|
);
|
|
216
282
|
process.exitCode = 1;
|
|
217
283
|
return;
|
|
218
284
|
}
|
|
285
|
+
const { config, root } = loadConfig();
|
|
286
|
+
const ttlSec = resolveCacheTtl(config);
|
|
219
287
|
|
|
220
288
|
let token;
|
|
221
289
|
try {
|
|
222
290
|
token = await resolveToken();
|
|
223
291
|
} catch (err) {
|
|
224
|
-
|
|
292
|
+
emitError(opts, err.message);
|
|
225
293
|
process.exitCode = 1;
|
|
226
294
|
return;
|
|
227
295
|
}
|
|
228
296
|
|
|
297
|
+
if (sync) {
|
|
298
|
+
if (!root) {
|
|
299
|
+
emitError(opts, `--sync precisa de um clone com ${CONFIG_FILE} (o doc é reescrito no disco).`);
|
|
300
|
+
process.exitCode = 1;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (!json) p.intro(chalk.bold('spec-wave order --sync — dependências da API de volta ao doc'));
|
|
304
|
+
return await syncDependencies({
|
|
305
|
+
token, owner, repo, root, opts,
|
|
306
|
+
featureNumber: semArgumento ? null : featureNumber,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
229
310
|
if (semArgumento) {
|
|
230
|
-
p.intro(chalk.bold('spec-wave order — todas as Features com trabalho'));
|
|
231
|
-
return await orderBoard({ token, owner, repo });
|
|
311
|
+
if (!json) p.intro(chalk.bold('spec-wave order — todas as Features com trabalho'));
|
|
312
|
+
return await orderBoard({ token, owner, repo, root, ttlSec, opts });
|
|
232
313
|
}
|
|
233
314
|
|
|
234
|
-
p.intro(chalk.bold(`spec-wave order #${featureNumber}`));
|
|
315
|
+
if (!json) p.intro(chalk.bold(`spec-wave order #${featureNumber}`));
|
|
235
316
|
|
|
236
|
-
// 1. Lê a Feature e valida o tipo.
|
|
317
|
+
// 1. Lê a Feature e valida o tipo (1 chamada — a issue-alvo nunca vem de cache).
|
|
237
318
|
let featureIssue;
|
|
238
319
|
try {
|
|
239
320
|
featureIssue = await getIssue(token, owner, repo, featureNumber);
|
|
240
321
|
} catch (err) {
|
|
241
|
-
|
|
322
|
+
emitError(opts, `Não foi possível ler a issue #${featureNumber}: ${err.message}`);
|
|
242
323
|
process.exitCode = 1;
|
|
243
324
|
return;
|
|
244
325
|
}
|
|
245
326
|
const type = detectIssueType(featureIssue);
|
|
246
327
|
if (type !== 'Feature') {
|
|
247
|
-
|
|
328
|
+
emitError(opts,
|
|
248
329
|
`\`spec-wave order\` só aceita issues do tipo Feature. ` +
|
|
249
330
|
`Issue #${featureNumber} é do tipo ${type || 'desconhecido'} (${featureIssue.title}).`
|
|
250
331
|
);
|
|
@@ -252,53 +333,47 @@ export async function order({ feature: featureArg } = {}) {
|
|
|
252
333
|
return;
|
|
253
334
|
}
|
|
254
335
|
|
|
255
|
-
// 2.
|
|
256
|
-
let
|
|
336
|
+
// 2-3. Stories + arestas — fontes locais (mapa/doc/body), `--remote` opcional.
|
|
337
|
+
let carga;
|
|
257
338
|
try {
|
|
258
|
-
|
|
339
|
+
carga = await loadFeatureStories({
|
|
340
|
+
token, owner, repo, root,
|
|
341
|
+
feature: { number: featureNumber, nodeId: featureIssue.node_id, title: featureIssue.title },
|
|
342
|
+
remote, refresh, ttlSec,
|
|
343
|
+
});
|
|
259
344
|
} catch (err) {
|
|
260
|
-
|
|
345
|
+
emitError(opts, `Não foi possível listar as sub-issues da Feature #${featureNumber}: ${err.message}`);
|
|
261
346
|
process.exitCode = 1;
|
|
262
347
|
return;
|
|
263
348
|
}
|
|
264
|
-
const stories =
|
|
349
|
+
const stories = carga.stories;
|
|
265
350
|
if (stories.length === 0) {
|
|
266
|
-
|
|
267
|
-
p.outro('Nada a fazer.');
|
|
351
|
+
emitInfo(opts, `Feature #${featureNumber} não tem Stories (sub-issues) — nada a ordenar. Rode \`spec-wave decompose\` antes.`);
|
|
268
352
|
return;
|
|
269
353
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// corpo via getIssue quando listSubIssues não o trouxer) mesclada com a
|
|
273
|
-
// relação nativa blocked_by (falha na API → sem bloqueios, não interrompe).
|
|
274
|
-
const enriched = await Promise.all(stories.map(async (s) => {
|
|
275
|
-
let body = s.body;
|
|
276
|
-
if (!body) {
|
|
277
|
-
body = (await getIssue(token, owner, repo, s.number).catch(() => null))?.body || '';
|
|
278
|
-
}
|
|
279
|
-
const fromBody = parseDependencies(body);
|
|
280
|
-
const fromBlockedBy = (await listBlockedBy(token, owner, repo, s.number).catch(() => []))
|
|
281
|
-
.map(b => b.number);
|
|
282
|
-
const dependsOn = [...new Set([...fromBody, ...fromBlockedBy])];
|
|
283
|
-
return { number: s.number, title: s.title, nodeId: s.nodeId, dependsOn };
|
|
354
|
+
const enriched = stories.map(s => ({
|
|
355
|
+
number: s.number, title: s.title, nodeId: s.nodeId, dependsOn: s.dependsOn,
|
|
284
356
|
}));
|
|
285
357
|
|
|
286
|
-
// 4. Etapa
|
|
358
|
+
// 4. Etapa de cada Story — snapshot único do board, com TTL (nunca mais a
|
|
359
|
+
// mutação addProjectItem por Story: além do custo, ADICIONAVA a issue ao
|
|
360
|
+
// Project como efeito colateral de uma leitura).
|
|
287
361
|
const stageOf = new Map();
|
|
362
|
+
let boardMeta = { stages: null, fetchedAt: null, stale: null };
|
|
288
363
|
const { project, error: projectError } = loadProjectConfig();
|
|
289
364
|
if (projectError) {
|
|
290
|
-
p.log.warn(`${projectError} — Etapas do board não consultadas.`);
|
|
365
|
+
if (!json) p.log.warn(`${projectError} — Etapas do board não consultadas.`);
|
|
291
366
|
} else {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
})
|
|
367
|
+
try {
|
|
368
|
+
const snapshot = await loadBoardSnapshot({ token, project, root, refresh, ttlSec });
|
|
369
|
+
for (const s of enriched) stageOf.set(s.number, snapshot.index.get(s.number)?.fields?.Etapa ?? null);
|
|
370
|
+
boardMeta = {
|
|
371
|
+
stages: snapshot.fromCache ? 'cache' : 'board',
|
|
372
|
+
fetchedAt: snapshot.fetchedAt,
|
|
373
|
+
stale: snapshot.fromCache ? stalenessNotice(snapshot.fetchedAt) : null,
|
|
374
|
+
};
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (!json) p.log.warn(`Etapas do board não consultáveis agora: ${err.message}`);
|
|
302
377
|
}
|
|
303
378
|
}
|
|
304
379
|
|
|
@@ -307,17 +382,34 @@ export async function order({ feature: featureArg } = {}) {
|
|
|
307
382
|
const { order: sorted, cycle, external } = orderStories(
|
|
308
383
|
enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
|
|
309
384
|
|
|
310
|
-
// Uma leitura por issue externa DISTINTA —
|
|
311
|
-
//
|
|
385
|
+
// Uma leitura por issue externa DISTINTA — cacheada (`issue-<n>`): é exibição,
|
|
386
|
+
// não decisão de escrita.
|
|
312
387
|
const foraDoConjunto = [...new Set([...external.values()].flat())];
|
|
313
388
|
const externasInfo = new Map();
|
|
314
389
|
await Promise.all(foraDoConjunto.map(async (n) => {
|
|
315
|
-
|
|
316
|
-
externasInfo.set(n, issue
|
|
317
|
-
? { title: issue.title, state: issue.state, aberta: issue.state === 'open' }
|
|
318
|
-
: { title: '(não foi possível ler)', state: null, aberta: true });
|
|
390
|
+
externasInfo.set(n, await externalIssueInfo({ token, owner, repo, root, number: n, refresh, ttlSec }));
|
|
319
391
|
}));
|
|
320
392
|
|
|
393
|
+
if (opts.json) {
|
|
394
|
+
console.log(JSON.stringify(renderOrderJson({
|
|
395
|
+
sorted,
|
|
396
|
+
byNumber,
|
|
397
|
+
featureOf: new Map(enriched.map(s => [s.number, { number: featureNumber, title: featureIssue.title }])),
|
|
398
|
+
stageOf, external, cycle,
|
|
399
|
+
meta: {
|
|
400
|
+
edges: carga.origin.edges,
|
|
401
|
+
stages: boardMeta.stages,
|
|
402
|
+
fetchedAt: boardMeta.fetchedAt,
|
|
403
|
+
warnings: [
|
|
404
|
+
...carga.warnings,
|
|
405
|
+
...milestoneMismatches(featureIssue.milestone, stories),
|
|
406
|
+
...(boardMeta.stale ? [boardMeta.stale] : []),
|
|
407
|
+
],
|
|
408
|
+
},
|
|
409
|
+
}), null, 2));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
321
413
|
if (cycle.length > 0) {
|
|
322
414
|
p.log.warn(
|
|
323
415
|
chalk.yellow.bold('⚠ CICLO DE DEPENDÊNCIAS detectado!') + '\n' +
|
|
@@ -341,7 +433,8 @@ export async function order({ feature: featureArg } = {}) {
|
|
|
341
433
|
const detalhe = deps.map(d => {
|
|
342
434
|
const info = externasInfo.get(d);
|
|
343
435
|
const marca = info?.aberta ? chalk.yellow('aberta') : chalk.green('fechada');
|
|
344
|
-
|
|
436
|
+
const cacheTag = info?.fromCache ? chalk.dim(' (cache)') : '';
|
|
437
|
+
return `#${d} (${marca}${cacheTag}) ${info?.title || ''}`.trim();
|
|
345
438
|
}).join('\n ');
|
|
346
439
|
return ` #${n} ${byNumber.get(n)?.title || ''}\n ← ${detalhe}`;
|
|
347
440
|
});
|
|
@@ -392,5 +485,166 @@ export async function order({ feature: featureArg } = {}) {
|
|
|
392
485
|
);
|
|
393
486
|
}
|
|
394
487
|
|
|
488
|
+
for (const w of carga.warnings) p.log.warn(w);
|
|
489
|
+
p.log.info(chalk.dim(edgesNote([carga.origin.edges], opts)));
|
|
490
|
+
if (boardMeta.stale) p.log.warn(chalk.yellow(boardMeta.stale));
|
|
395
491
|
p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
|
|
396
492
|
}
|
|
493
|
+
|
|
494
|
+
// ── `order --sync`: API → decomposition.md + dependency-map.json ────────────
|
|
495
|
+
|
|
496
|
+
// Commit LOCAL escopado aos artefatos reescritos + push com retry — mesmo
|
|
497
|
+
// padrão do bug.md do qa (git-retry). Falha de push degrada para aviso: os
|
|
498
|
+
// arquivos já estão certos no disco.
|
|
499
|
+
function commitSyncedFiles(root, filesRel, featureNumber) {
|
|
500
|
+
try {
|
|
501
|
+
for (const rel of filesRel) {
|
|
502
|
+
execSync(`git add -- ${JSON.stringify(rel)}`, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
503
|
+
}
|
|
504
|
+
execSync(
|
|
505
|
+
`git commit -m ${JSON.stringify(`docs: sync de dependências da Feature #${featureNumber} [spec-wave]`)} ` +
|
|
506
|
+
filesRel.map(r => `-- ${JSON.stringify(r)}`).join(' '),
|
|
507
|
+
{ cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
508
|
+
return true;
|
|
509
|
+
} catch (err) {
|
|
510
|
+
p.log.warn(`⚠️ Arquivos escritos, mas o commit falhou (${String(err.message).split('\n')[0]}) — commite-os você mesmo.`);
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function syncDependencies({ token, owner, repo, root, opts, featureNumber }) {
|
|
516
|
+
// Alvos: a Feature pedida, ou as Features abertas do board (--milestone filtra).
|
|
517
|
+
let targets = [];
|
|
518
|
+
if (featureNumber) {
|
|
519
|
+
const issue = await getIssue(token, owner, repo, featureNumber).catch((err) => {
|
|
520
|
+
emitError(opts, `Não foi possível ler a issue #${featureNumber}: ${err.message}`);
|
|
521
|
+
return null;
|
|
522
|
+
});
|
|
523
|
+
if (!issue) { process.exitCode = 1; return; }
|
|
524
|
+
if (detectIssueType(issue) !== 'Feature') {
|
|
525
|
+
emitError(opts, `--sync só aceita Feature. Issue #${featureNumber} é ${detectIssueType(issue) || 'de tipo desconhecido'}.`);
|
|
526
|
+
process.exitCode = 1;
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
targets = [{ number: featureNumber, nodeId: issue.node_id, title: issue.title }];
|
|
530
|
+
} else {
|
|
531
|
+
const { project, error } = loadProjectConfig();
|
|
532
|
+
if (error) {
|
|
533
|
+
emitError(opts, `${error} — sem board não há como enumerar as Features. Use \`order <feature> --sync\`.`);
|
|
534
|
+
process.exitCode = 1;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// Sync é reconciliação: sempre parte de um snapshot FRESCO.
|
|
538
|
+
const snapshot = await loadBoardSnapshot({ token, project, root, refresh: true });
|
|
539
|
+
let features = selectOpenFeatures(snapshot.items);
|
|
540
|
+
if (opts.milestone) features = filterItemsByMilestone(features, opts.milestone);
|
|
541
|
+
targets = features;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
let synced = 0;
|
|
545
|
+
let untouched = 0;
|
|
546
|
+
for (const feature of targets) {
|
|
547
|
+
const resultado = await syncOneFeature({ token, owner, repo, root, feature });
|
|
548
|
+
if (resultado === 'synced') synced += 1;
|
|
549
|
+
else if (resultado === 'unchanged') untouched += 1;
|
|
550
|
+
}
|
|
551
|
+
p.outro(`${chalk.green('✓')} sync: ${synced} Feature(s) atualizada(s), ${untouched} já em dia.`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function syncOneFeature({ token, owner, repo, root, feature }) {
|
|
555
|
+
let paths;
|
|
556
|
+
try {
|
|
557
|
+
paths = featureDocPaths(root, { title: feature.title }, 'Feature');
|
|
558
|
+
} catch {
|
|
559
|
+
p.log.warn(`#${feature.number}: título sem slug utilizável — pulada.`);
|
|
560
|
+
return 'skipped';
|
|
561
|
+
}
|
|
562
|
+
const docAbs = path.join(root, paths.decomposition.rel);
|
|
563
|
+
if (!existsSync(docAbs)) {
|
|
564
|
+
p.log.info(chalk.dim(`#${feature.number}: sem ${paths.decomposition.rel} — nada a sincronizar.`));
|
|
565
|
+
return 'skipped';
|
|
566
|
+
}
|
|
567
|
+
let doc;
|
|
568
|
+
try {
|
|
569
|
+
doc = parseDecompositionDoc(readFileSync(docAbs, 'utf-8'));
|
|
570
|
+
} catch (err) {
|
|
571
|
+
p.log.warn(`#${feature.number}: ${paths.decomposition.rel} ilegível (${err.message}) — pulada.`);
|
|
572
|
+
return 'skipped';
|
|
573
|
+
}
|
|
574
|
+
if (!doc.appliedAt || doc.kind !== 'stories') {
|
|
575
|
+
p.log.info(chalk.dim(`#${feature.number}: decomposition.md ainda é proposta (ou é de RFC) — nada a sincronizar.`));
|
|
576
|
+
return 'skipped';
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Leitura VIVA: body ∪ blocked_by, sempre frescos — sync que lê cache
|
|
580
|
+
// reconciliaria o doc com ele mesmo.
|
|
581
|
+
const subs = (await listSubIssues(token, feature.nodeId || feature.node_id))
|
|
582
|
+
.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
|
|
583
|
+
const remoteEdges = [];
|
|
584
|
+
for (const s of subs) {
|
|
585
|
+
const blockedBy = (await listBlockedBy(token, owner, repo, s.number).catch(() => []))
|
|
586
|
+
.map(b => b.number);
|
|
587
|
+
remoteEdges.push({ number: s.number, dependsOn: blockedBy });
|
|
588
|
+
}
|
|
589
|
+
const liveDeps = mergeDependencyEdges(
|
|
590
|
+
subs.map(s => ({ number: s.number, dependsOn: parseDependencies(s.body) })),
|
|
591
|
+
remoteEdges,
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
const { doc: updated, changed, changes } = syncDocDependencies(doc, liveDeps);
|
|
595
|
+
|
|
596
|
+
// O mapa é SEMPRE regenerado ("forçar update dos artefatos"): mesmo sem
|
|
597
|
+
// aresta nova, ele pode nem existir ainda (repo de antes desta versão).
|
|
598
|
+
const mapRel = paths['dependency-map'].rel;
|
|
599
|
+
const mapAbs = path.join(root, mapRel);
|
|
600
|
+
const generatedAt = new Date().toISOString();
|
|
601
|
+
const mapStories = (updated.stories || []).map((s, i) => ({
|
|
602
|
+
number: s.issue,
|
|
603
|
+
title: s.title,
|
|
604
|
+
dependsOn: [...new Set([
|
|
605
|
+
...(s.dependsOn || []).map(idx => updated.stories[idx]?.issue).filter(Number.isInteger),
|
|
606
|
+
...(s.dependsOnIssues || []),
|
|
607
|
+
])],
|
|
608
|
+
tasks: (s.tasks || []).map(t => t.issue).filter(Number.isInteger),
|
|
609
|
+
}));
|
|
610
|
+
const mapAntes = existsSync(mapAbs) ? readFileSync(mapAbs, 'utf-8') : null;
|
|
611
|
+
const mapConteudo = `${JSON.stringify(buildDependencyMap({
|
|
612
|
+
featureNumber: feature.number, stories: mapStories, source: 'sync', generatedAt,
|
|
613
|
+
}), null, 2)}\n`;
|
|
614
|
+
// Compara sem o generatedAt: regravar SÓ por causa do timestamp criaria um
|
|
615
|
+
// commit de ruído por sync.
|
|
616
|
+
const semTimestamp = (txt) => String(txt ?? '').replace(/"generatedAt": "[^"]*"/, '"generatedAt": "-"')
|
|
617
|
+
.replace(/"source": "[^"]*"/, '"source": "-"');
|
|
618
|
+
const mapMudou = semTimestamp(mapAntes) !== semTimestamp(mapConteudo);
|
|
619
|
+
|
|
620
|
+
if (!changed && !mapMudou) {
|
|
621
|
+
p.log.info(chalk.dim(`#${feature.number}: dependências já em dia.`));
|
|
622
|
+
return 'unchanged';
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const escritos = [];
|
|
626
|
+
if (changed) {
|
|
627
|
+
writeFileSync(docAbs, renderDecompositionDoc(updated));
|
|
628
|
+
escritos.push(paths.decomposition.rel);
|
|
629
|
+
for (const c of changes) {
|
|
630
|
+
p.log.info(
|
|
631
|
+
`#${feature.number} · Story #${c.story}: Depende de ` +
|
|
632
|
+
`${c.before.map(n => `#${n}`).join(', ') || '—'} → ${c.after.map(n => `#${n}`).join(', ') || '—'}`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (mapMudou) {
|
|
637
|
+
writeFileSync(mapAbs, mapConteudo);
|
|
638
|
+
escritos.push(mapRel);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const committed = commitSyncedFiles(root, escritos, feature.number);
|
|
642
|
+
if (committed) {
|
|
643
|
+
const push = await pushWithRebase({ cwd: root });
|
|
644
|
+
if (!push.ok) {
|
|
645
|
+
p.log.warn(`⚠️ Sync commitado, mas o push falhou: ${String(push.error).split('\n')[0]} — pushe manualmente.`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
p.log.success(`#${feature.number}: ${escritos.join(' + ')} atualizado(s).`);
|
|
649
|
+
return 'synced';
|
|
650
|
+
}
|