@spec-wave/cli 0.27.0 → 0.29.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 +1 -1
- package/src/api/github-graphql.mjs +37 -0
- package/src/api/github-rest.mjs +48 -0
- package/src/cli.mjs +51 -2
- package/src/commands/audit.mjs +280 -0
- package/src/commands/doctor.mjs +40 -16
- package/src/commands/implement.mjs +19 -2
- package/src/commands/install-skill.mjs +18 -8
- package/src/commands/merge.mjs +292 -0
- package/src/commands/move.mjs +26 -11
- package/src/commands/order.mjs +42 -0
- package/src/commands/preflight.mjs +322 -0
- package/src/commands/run.mjs +4 -3
- package/src/commands/update.mjs +143 -12
- package/src/lib/board.mjs +18 -2
- package/src/lib/critique.mjs +64 -8
- package/src/lib/pr-branch.mjs +96 -7
- package/src/lib/pr-step.mjs +12 -7
- package/src/lib/spec-audit.mjs +372 -0
- package/src/lib/tech-context.mjs +20 -14
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/README.md +5 -0
- package/src/plugin/skills/audit/SKILL.md +34 -0
- package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
- package/src/plugin/skills/merge/SKILL.md +34 -0
- package/src/plugin/skills/order/SKILL.md +1 -0
- package/src/plugin/skills/plan/model-prompt.md +1 -0
- package/src/plugin/skills/plan/reference/tech-context.md +6 -0
- package/src/plugin/skills/preparar-feature/SKILL.md +247 -0
- package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
- package/src/plugin/skills/preparar-specs/SKILL.md +191 -0
- package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
- package/src/plugin/skills/preparar-specs/reference/revisao.md +110 -0
- package/src/plugin/skills/update/SKILL.md +10 -4
- package/src/plugin/skills/workflow/SKILL.md +6 -1
- package/src/templates/config/tech_context.yml +13 -0
- package/src/templates/skill/SKILL.md +36 -7
- package/src/templates/workflows/qa.yml +9 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Mergeia os PRs empilhados das Stories de uma Feature, na ordem topológica —
|
|
2
|
+
// o passo final do fluxo, que até aqui era inteiramente manual e frágil.
|
|
3
|
+
//
|
|
4
|
+
// O implement empilha de propósito (cada Story revisável sozinha, diff limpo),
|
|
5
|
+
// mas a pilha torna o merge ordem-dependente: `--delete-branch` no primeiro PR
|
|
6
|
+
// já fechou o segundo sem volta. A sequência segura, que este comando encapsula:
|
|
7
|
+
//
|
|
8
|
+
// para cada PR, na ordem das dependências:
|
|
9
|
+
// 1. reaponta a base para a default (o anterior já mergeou; com merge
|
|
10
|
+
// commit o retarget é limpo — por isso o método é `merge`, não squash)
|
|
11
|
+
// 2. mergeia
|
|
12
|
+
// 3. atualiza o board (code-review + qa — merge move até 🧪 QA)
|
|
13
|
+
// e SÓ NO FIM apaga as branches, quando nenhum PR aberto depende delas.
|
|
14
|
+
//
|
|
15
|
+
// PR em rascunho BLOQUEIA o plano inteiro: marcar pronto é o ato de revisão
|
|
16
|
+
// humana (e o que dispara o CI) — mergear rascunho por cima seria pular a única
|
|
17
|
+
// aprovação que o fluxo tem. Falha no meio para a fila e explica: os merges já
|
|
18
|
+
// feitos ficam, rodar de novo retoma de onde parou (PR mergeado sai do plano).
|
|
19
|
+
import * as p from '@clack/prompts';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
|
|
22
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
23
|
+
import {
|
|
24
|
+
getIssue, getPR, getRepoDefaultBranch, updatePRBase, mergePR, deleteBranch, listBlockedBy,
|
|
25
|
+
} from '../api/github-rest.mjs';
|
|
26
|
+
import { listSubIssues, listIssuePullRequests } from '../api/github-graphql.mjs';
|
|
27
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
28
|
+
import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
|
|
29
|
+
import { resolveRepoContext } from '../lib/project-root.mjs';
|
|
30
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
31
|
+
import { acquireLock, releaseLock } from './run.mjs';
|
|
32
|
+
import { codeReview } from './code-review.mjs';
|
|
33
|
+
import { qa } from './qa.mjs';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* O plano de merge (função PURA — é onde mora toda a decisão).
|
|
37
|
+
*
|
|
38
|
+
* Separa cada Story em um destino: `fila` (PR aberto, pronto, entra na ordem),
|
|
39
|
+
* `concluidas` (PR já mergeado — é o que torna rodar de novo uma RETOMADA),
|
|
40
|
+
* `bloqueios` (rascunho, mais de um PR aberto — parar antes de mergear
|
|
41
|
+
* qualquer coisa, porque merge parcial de uma pilha é o pior estado) e
|
|
42
|
+
* `avisos` (Story sem PR nenhum — fica de fora, mas quem depende dela pode
|
|
43
|
+
* carregar commits que não existem na base).
|
|
44
|
+
*
|
|
45
|
+
* @param {object} params
|
|
46
|
+
* @param {number[]} params.sorted ordem topológica (números de Story)
|
|
47
|
+
* @param {Map<number, {title: string}>} params.byNumber
|
|
48
|
+
* @param {Map<number, Array<{number, state, merged, isDraft, baseRefName, headRefName}>>} params.prsByStory
|
|
49
|
+
* @param {string} params.defaultBranch
|
|
50
|
+
* @returns {{ fila: Array<{story:number, pr:object}>, concluidas: Array<{story:number, pr:object}>,
|
|
51
|
+
* semPr: number[], bloqueios: string[], avisos: string[] }}
|
|
52
|
+
*/
|
|
53
|
+
export function planMerge({ sorted = [], byNumber = new Map(), prsByStory = new Map(), defaultBranch } = {}) {
|
|
54
|
+
const fila = [];
|
|
55
|
+
const concluidas = [];
|
|
56
|
+
const semPr = [];
|
|
57
|
+
const bloqueios = [];
|
|
58
|
+
const avisos = [];
|
|
59
|
+
const headsNoPlano = new Set();
|
|
60
|
+
|
|
61
|
+
for (const story of sorted) {
|
|
62
|
+
const prs = prsByStory.get(story) || [];
|
|
63
|
+
const mergeados = prs.filter(pr => pr.merged);
|
|
64
|
+
const abertos = prs.filter(pr => pr.state === 'open');
|
|
65
|
+
|
|
66
|
+
if (mergeados.length > 0 && abertos.length === 0) {
|
|
67
|
+
concluidas.push({ story, pr: mergeados[0] });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (abertos.length === 0) {
|
|
71
|
+
semPr.push(story);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (abertos.length > 1) {
|
|
75
|
+
bloqueios.push(
|
|
76
|
+
`Story #${story} tem ${abertos.length} PRs abertos (${abertos.map(x => `#${x.number}`).join(', ')}) — ` +
|
|
77
|
+
'ambíguo; feche o que não vale antes de mergear.'
|
|
78
|
+
);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const pr = abertos[0];
|
|
82
|
+
if (pr.isDraft) {
|
|
83
|
+
bloqueios.push(
|
|
84
|
+
`PR #${pr.number} (Story #${story}) está em RASCUNHO. Marcar pronto é a revisão humana ` +
|
|
85
|
+
'e o que dispara o CI — revise e marque pronto antes de mergear.'
|
|
86
|
+
);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Base que não é a default nem a head de alguém que mergeia antes: ou a
|
|
90
|
+
// pilha está fora de ordem, ou aponta para algo que este plano não conhece.
|
|
91
|
+
if (pr.baseRefName !== defaultBranch && !headsNoPlano.has(pr.baseRefName)) {
|
|
92
|
+
avisos.push(
|
|
93
|
+
`PR #${pr.number} (Story #${story}) tem base "${pr.baseRefName}", que não é a default ` +
|
|
94
|
+
'nem a branch de um PR anterior da fila — confira a pilha antes de confirmar.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
headsNoPlano.add(pr.headRefName);
|
|
98
|
+
fila.push({ story, pr });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (semPr.length > 0) {
|
|
102
|
+
avisos.push(
|
|
103
|
+
`Story(ies) sem PR: ${semPr.map(n => `#${n}`).join(', ')} — ficam de fora; ` +
|
|
104
|
+
'se um PR da fila depender do código delas, o merge vai levar esse código junto.'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { fila, concluidas, semPr, bloqueios, avisos };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function merge({ feature: featureArg, yes = false, dryRun = false, keepBranches = false } = {}) {
|
|
112
|
+
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
113
|
+
p.log.error('`spec-wave merge` é um comando local — dentro do Actions o merge é decisão humana.');
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
|
|
119
|
+
if (!Number.isInteger(featureNumber) || featureNumber <= 0) {
|
|
120
|
+
p.log.error(`Feature inválida: "${featureArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { owner, repo, root } = resolveRepoContext();
|
|
126
|
+
if (!owner || !repo) {
|
|
127
|
+
p.log.error(
|
|
128
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
129
|
+
`Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
|
|
130
|
+
);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let token;
|
|
136
|
+
try {
|
|
137
|
+
token = await resolveToken();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
p.log.error(err.message);
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
p.intro(chalk.bold(`spec-wave merge #${featureNumber}`));
|
|
145
|
+
const s = p.spinner();
|
|
146
|
+
s.start('Montando o plano de merge...');
|
|
147
|
+
|
|
148
|
+
let featureIssue;
|
|
149
|
+
let defaultBranch;
|
|
150
|
+
try {
|
|
151
|
+
featureIssue = await getIssue(token, owner, repo, featureNumber);
|
|
152
|
+
defaultBranch = await getRepoDefaultBranch(token, owner, repo);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
s.stop('');
|
|
155
|
+
p.log.error(`Não foi possível ler ${owner}/${repo}#${featureNumber}: ${err.message}`);
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (detectIssueType(featureIssue) !== 'Feature') {
|
|
160
|
+
s.stop('');
|
|
161
|
+
p.log.error(`\`spec-wave merge\` só aceita Features. Issue #${featureNumber} é ${detectIssueType(featureIssue) || 'de tipo desconhecido'}.`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// As Stories e a ordem — as mesmas fontes do `order`.
|
|
167
|
+
let subs;
|
|
168
|
+
try {
|
|
169
|
+
subs = await listSubIssues(token, featureIssue.node_id);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
s.stop('');
|
|
172
|
+
p.log.error(`Não foi possível listar as sub-issues: ${err.message}`);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const stories = subs.filter(x => detectIssueType({ title: x.title, labels: x.labels }) === 'Story');
|
|
177
|
+
if (stories.length === 0) {
|
|
178
|
+
s.stop('');
|
|
179
|
+
p.log.info(`Feature #${featureNumber} não tem Stories — nada a mergear.`);
|
|
180
|
+
p.outro('Nada a fazer.');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const enriched = await Promise.all(stories.map(async (st) => {
|
|
185
|
+
const fromBody = parseDependencies(st.body);
|
|
186
|
+
const fromBlockedBy = (await listBlockedBy(token, owner, repo, st.number).catch(() => []))
|
|
187
|
+
.map(b => b.number);
|
|
188
|
+
return { number: st.number, title: st.title, nodeId: st.nodeId, dependsOn: [...new Set([...fromBody, ...fromBlockedBy])] };
|
|
189
|
+
}));
|
|
190
|
+
const byNumber = new Map(enriched.map(x => [x.number, x]));
|
|
191
|
+
const { order: sorted, cycle } = orderStories(
|
|
192
|
+
enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
|
|
193
|
+
if (cycle.length > 0) {
|
|
194
|
+
s.stop('');
|
|
195
|
+
p.log.error(
|
|
196
|
+
`Ciclo de dependências entre Stories (${cycle.map(n => `#${n}`).join(', ')}) — ` +
|
|
197
|
+
'não existe ordem de merge. Corrija as linhas "Depende de" e rode de novo.'
|
|
198
|
+
);
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const prsByStory = new Map();
|
|
204
|
+
for (const story of sorted) {
|
|
205
|
+
prsByStory.set(story, await listIssuePullRequests(token, byNumber.get(story).nodeId).catch(() => []));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const plano = planMerge({ sorted, byNumber, prsByStory, defaultBranch });
|
|
209
|
+
s.stop(`${plano.fila.length} PR(s) a mergear · ${plano.concluidas.length} já mergeado(s).`);
|
|
210
|
+
|
|
211
|
+
const linha = ({ story, pr }) => {
|
|
212
|
+
const retarget = pr.baseRefName !== defaultBranch ? ` ${chalk.dim(`base ${pr.baseRefName} → ${defaultBranch}`)}` : '';
|
|
213
|
+
return ` PR #${pr.number} · Story #${story} ${byNumber.get(story)?.title || ''}${retarget}`;
|
|
214
|
+
};
|
|
215
|
+
if (plano.fila.length > 0) {
|
|
216
|
+
p.note(plano.fila.map(linha).join('\n'), `Ordem de merge (base: ${defaultBranch}, método: merge commit)`);
|
|
217
|
+
}
|
|
218
|
+
for (const a of plano.avisos) p.log.warn(a);
|
|
219
|
+
for (const b of plano.bloqueios) p.log.error(b);
|
|
220
|
+
|
|
221
|
+
if (plano.bloqueios.length > 0) {
|
|
222
|
+
p.outro('Bloqueado — merge parcial de pilha é o pior estado; resolva e rode de novo.');
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (plano.fila.length === 0) {
|
|
227
|
+
p.outro(plano.concluidas.length > 0 ? 'Tudo já mergeado.' : 'Nenhum PR aberto para mergear.');
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (dryRun || !yes) {
|
|
231
|
+
p.outro(dryRun ? 'Dry-run: nada foi mergeado.' : 'Confirme com `--yes` para mergear nesta ordem.');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------- Execução ----------
|
|
236
|
+
const lock = acquireLock(root, `merge-${featureNumber}`);
|
|
237
|
+
const mergedHeads = [];
|
|
238
|
+
try {
|
|
239
|
+
for (const { story, pr } of plano.fila) {
|
|
240
|
+
// Estado fresco: a fila pode ter envelhecido entre o plano e este ponto.
|
|
241
|
+
const atual = await getPR(token, owner, repo, pr.number);
|
|
242
|
+
if (atual.merged_at) {
|
|
243
|
+
p.log.info(`PR #${pr.number} já estava mergeado — seguindo.`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (atual.base?.ref !== defaultBranch) {
|
|
247
|
+
p.log.step(`PR #${pr.number}: base ${atual.base?.ref} → ${defaultBranch}`);
|
|
248
|
+
await updatePRBase(token, owner, repo, pr.number, defaultBranch);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
await mergePR(token, owner, repo, pr.number, { method: 'merge' });
|
|
252
|
+
} catch (err) {
|
|
253
|
+
// 405 = draft/check pendente/conflito. Parar AQUI preserva a ordem: os
|
|
254
|
+
// dependentes continuam com branch e PR intactos, e rodar de novo retoma.
|
|
255
|
+
p.log.error(
|
|
256
|
+
`Merge do PR #${pr.number} (Story #${story}) recusado: ${err.message}\n` +
|
|
257
|
+
'Nada depois dele foi mergeado. Resolva (checks, conflito) e rode o comando de novo — ' +
|
|
258
|
+
'os já mergeados saem do plano sozinhos.'
|
|
259
|
+
);
|
|
260
|
+
process.exitCode = 1;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
p.log.success(`PR #${pr.number} mergeado (Story #${story}).`);
|
|
264
|
+
mergedHeads.push(pr.headRefName);
|
|
265
|
+
|
|
266
|
+
// Board: merge move até 🧪 QA. Falha aqui não desfaz merge — avisa e segue.
|
|
267
|
+
try {
|
|
268
|
+
await codeReview({ prNumber: String(pr.number) });
|
|
269
|
+
await qa({ prNumber: String(pr.number) });
|
|
270
|
+
} catch (err) {
|
|
271
|
+
p.log.warn(`Board não atualizado para o PR #${pr.number}: ${err.message} — rode \`spec-wave run --pr ${pr.number}\` depois.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Branches só no FIM, quando nenhum PR aberto depende delas — apagar antes
|
|
276
|
+
// do retarget do dependente foi o que fechou um PR empilhado sem volta.
|
|
277
|
+
if (!keepBranches) {
|
|
278
|
+
for (const head of mergedHeads) {
|
|
279
|
+
if (!head) continue;
|
|
280
|
+
await deleteBranch(token, owner, repo, head).catch(err =>
|
|
281
|
+
p.log.warn(`Branch ${head} não apagada: ${err.message}`));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
releaseLock(lock);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
p.outro(
|
|
289
|
+
`${chalk.green('✓')} ${mergedHeads.length} PR(s) mergeado(s) na ordem` +
|
|
290
|
+
(keepBranches ? ' (branches mantidas).' : ', branches apagadas.')
|
|
291
|
+
);
|
|
292
|
+
}
|
package/src/commands/move.mjs
CHANGED
|
@@ -191,6 +191,14 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
|
|
|
191
191
|
// mesmo quando a Etapa não avança — ver ensureWorkItemType.
|
|
192
192
|
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
193
193
|
|
|
194
|
+
// A Etapa de PARTIDA, lida antes de escrever: a mensagem de sucesso imprime a
|
|
195
|
+
// transição real ("✅ Ready → 🧪 QA"), não a intenção do comando.
|
|
196
|
+
const lerEtapa = async () => {
|
|
197
|
+
const itemId = await addProjectItem(token, project.id, issue.node_id);
|
|
198
|
+
return etapaField?.id ? await getItemSingleSelectValue(token, itemId, etapaField.id) : null;
|
|
199
|
+
};
|
|
200
|
+
const antes = await lerEtapa().catch(() => null);
|
|
201
|
+
|
|
194
202
|
let moved;
|
|
195
203
|
try {
|
|
196
204
|
moved = await advanceToStage(
|
|
@@ -203,24 +211,31 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
|
|
|
203
211
|
}
|
|
204
212
|
|
|
205
213
|
if (moved) {
|
|
214
|
+
// Confirmação por leitura — era o usuário quem tinha que "confirmar lendo o
|
|
215
|
+
// board de volta"; agora o comando lê, e uma escrita que não pegou vira
|
|
216
|
+
// erro visível em vez de ✅ mentiroso.
|
|
217
|
+
const depois = await lerEtapa().catch(() => null);
|
|
218
|
+
if (depois !== null && depois !== stage) {
|
|
219
|
+
p.log.error(
|
|
220
|
+
`A escrita não confirmou: o board ainda mostra ${chalk.bold(depois)} ` +
|
|
221
|
+
`(esperado ${chalk.bold(stage)}). Verifique permissões do token no Project.`
|
|
222
|
+
);
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const confirmada = depois !== null;
|
|
206
227
|
p.log.success(
|
|
207
|
-
`${type || 'Issue'} #${issueNumber}
|
|
228
|
+
`${type || 'Issue'} #${issueNumber}: ${chalk.bold(antes || '—')} → ${chalk.bold(stage)}` +
|
|
229
|
+
` / Status ${chalk.bold(status)}` +
|
|
230
|
+
(confirmada ? ' (confirmado por leitura do board)' : ' (leitura de confirmação falhou — confira o board)')
|
|
208
231
|
);
|
|
209
232
|
p.outro(`${chalk.green('✓')} ${issue.title}`);
|
|
210
233
|
return;
|
|
211
234
|
}
|
|
212
235
|
|
|
213
236
|
// false = já está nessa Etapa ou adiante (ou numa coluna fora da ordem
|
|
214
|
-
// canônica). A Etapa NUNCA retrocede —
|
|
215
|
-
|
|
216
|
-
if (etapaField?.id) {
|
|
217
|
-
try {
|
|
218
|
-
const itemId = await addProjectItem(token, project.id, issue.node_id);
|
|
219
|
-
current = await getItemSingleSelectValue(token, itemId, etapaField.id);
|
|
220
|
-
} catch {
|
|
221
|
-
// sem leitura da Etapa — segue com o aviso genérico
|
|
222
|
-
}
|
|
223
|
-
}
|
|
237
|
+
// canônica). A Etapa NUNCA retrocede — informa a atual, já lida antes.
|
|
238
|
+
const current = antes ?? await lerEtapa().catch(() => null);
|
|
224
239
|
p.log.info(
|
|
225
240
|
`#${issueNumber} não foi movida: a Etapa nunca retrocede, e ela já está em ` +
|
|
226
241
|
`${chalk.bold(current || `"${stage}" ou etapa posterior`)}.`
|
package/src/commands/order.mjs
CHANGED
|
@@ -39,6 +39,37 @@ export function featureLabel(feature) {
|
|
|
39
39
|
return `#${feature?.number} ${curto}`.trim();
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Divergências de milestone entre a Feature e suas Stories (função PURA).
|
|
44
|
+
*
|
|
45
|
+
* O `order` é o detector do pós-apply, e até aqui ele só checava a Etapa
|
|
46
|
+
* (`Etapa: —`). Milestone tem o mesmo modo de falha: o apply herda a do pai
|
|
47
|
+
* (resolveInheritedMilestone), mas issues criadas antes dessa herança — ou com
|
|
48
|
+
* ela falhando — nascem sem milestone, invisíveis em qualquer visão de release,
|
|
49
|
+
* e ninguém percebe porque o passo sai verde. Foi assim que ~230 issues
|
|
50
|
+
* nasceram órfãs.
|
|
51
|
+
*
|
|
52
|
+
* Feature sem milestone não gera aviso: não há referência de comparação, e o
|
|
53
|
+
* repositório pode simplesmente não usar milestones.
|
|
54
|
+
*
|
|
55
|
+
* @param {{number?: number, title?: string}|null} featureMilestone
|
|
56
|
+
* @param {Array<{number: number, milestone?: {number: number, title: string}|null}>} stories
|
|
57
|
+
* @returns {string[]} uma linha de aviso por Story divergente
|
|
58
|
+
*/
|
|
59
|
+
export function milestoneMismatches(featureMilestone, stories = []) {
|
|
60
|
+
if (!Number.isInteger(featureMilestone?.number)) return [];
|
|
61
|
+
const ref = featureMilestone.title || `#${featureMilestone.number}`;
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const s of stories) {
|
|
64
|
+
if (!Number.isInteger(s?.milestone?.number)) {
|
|
65
|
+
out.push(`#${s.number} está SEM milestone — a Feature está em "${ref}".`);
|
|
66
|
+
} else if (s.milestone.number !== featureMilestone.number) {
|
|
67
|
+
out.push(`#${s.number} está em "${s.milestone.title || `#${s.milestone.number}`}", mas a Feature está em "${ref}".`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
42
73
|
/**
|
|
43
74
|
* O mapa de execução de várias Features (função PURA).
|
|
44
75
|
*
|
|
@@ -350,5 +381,16 @@ export async function order({ feature: featureArg } = {}) {
|
|
|
350
381
|
p.log.warn('Dependências fora de ordem:\n' + outOfOrder.map(w => ` • ${w}`).join('\n'));
|
|
351
382
|
}
|
|
352
383
|
|
|
384
|
+
// 7. Milestone das Stories contra a da Feature — a outra órfã do pós-apply.
|
|
385
|
+
const milestoneWarns = milestoneMismatches(featureIssue.milestone, stories);
|
|
386
|
+
if (milestoneWarns.length > 0) {
|
|
387
|
+
p.log.warn(
|
|
388
|
+
chalk.yellow.bold('⚠ Milestone divergente da Feature:') + '\n' +
|
|
389
|
+
milestoneWarns.map(w => ` • ${w}`).join('\n') + '\n' +
|
|
390
|
+
'Story fora do milestone da Feature some de toda visão de release. ' +
|
|
391
|
+
'Corrija na issue (gh issue edit <n> --milestone "<título>").'
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
353
395
|
p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
|
|
354
396
|
}
|