@spec-wave/cli 0.26.0 → 0.28.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-rest.mjs +52 -0
- package/src/cli.mjs +12 -0
- package/src/commands/decompose.mjs +166 -39
- package/src/commands/doctor.mjs +214 -3
- package/src/commands/generate-bug.mjs +22 -16
- package/src/commands/generate-plan.mjs +72 -23
- package/src/commands/generate-spec.mjs +19 -15
- package/src/commands/implement.mjs +47 -22
- package/src/commands/install-skill.mjs +18 -8
- package/src/commands/preflight.mjs +322 -0
- package/src/commands/run.mjs +51 -30
- package/src/commands/update.mjs +143 -12
- package/src/commands/validate.mjs +84 -17
- package/src/config.mjs +18 -0
- package/src/lib/artifact-pr.mjs +272 -0
- package/src/lib/artifact-publish.mjs +169 -0
- package/src/lib/doc-availability.mjs +23 -1
- package/src/lib/doc-source.mjs +162 -0
- package/src/lib/flow-run.mjs +9 -218
- package/src/lib/next-step.mjs +27 -4
- package/src/lib/pr-branch.mjs +106 -7
- package/src/lib/repo-links.mjs +8 -2
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/README.md +5 -0
- package/src/plugin/skills/bug/SKILL.md +2 -2
- package/src/plugin/skills/decompose/SKILL.md +4 -4
- package/src/plugin/skills/plan/SKILL.md +1 -1
- package/src/plugin/skills/preparar-feature/SKILL.md +245 -0
- package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
- package/src/plugin/skills/preparar-specs/SKILL.md +171 -0
- package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
- package/src/plugin/skills/preparar-specs/reference/revisao.md +107 -0
- package/src/plugin/skills/run/SKILL.md +3 -1
- package/src/plugin/skills/spec/SKILL.md +4 -4
- package/src/plugin/skills/update/SKILL.md +10 -4
- package/src/plugin/skills/workflow/SKILL.md +8 -3
- package/src/templates/skill/SKILL.md +13 -10
- package/src/templates/workflows/code-review.yml +13 -2
- package/src/templates/workflows/critique.yml +1 -1
- package/src/templates/workflows/decompose.yml +13 -2
- package/src/templates/workflows/generate-bug.yml +17 -6
- package/src/templates/workflows/generate-plan.yml +20 -7
- package/src/templates/workflows/generate-spec.yml +20 -7
- package/src/templates/workflows/qa.yml +13 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Regras PURAS da publicação de artefatos por Pull Request.
|
|
2
|
+
//
|
|
3
|
+
// Os documentos gerados (spec.md, plan.md, bug.md, decomposition.md) iam para a
|
|
4
|
+
// branch default por commit direto. Em repositório com proteção de branch isso
|
|
5
|
+
// nem chega a rodar — `flow-run.mjs` classificava a recusa (GH006) como
|
|
6
|
+
// definitiva e o fluxo morria com a chamada de IA já paga. E, mesmo sem
|
|
7
|
+
// proteção, contornava a revisão que todo o resto do processo tem.
|
|
8
|
+
//
|
|
9
|
+
// Um PR por DOCUMENTO, não por issue: cada documento é revisado e mergeado
|
|
10
|
+
// isoladamente, e o passo seguinte só roda com o anterior na base. É a mesma
|
|
11
|
+
// política do `update --branch` (ver lib/pr-branch.mjs), aplicada ao fluxo.
|
|
12
|
+
//
|
|
13
|
+
// Tudo aqui é puro de propósito, pelo mesmo motivo do pr-branch.mjs: a suíte não
|
|
14
|
+
// tem infraestrutura de mock de HTTP, então as DECISÕES (nome da branch, o que o
|
|
15
|
+
// PR conta a quem revisa) ficam separadas das chamadas de rede, que moram em
|
|
16
|
+
// api/github-rest.mjs.
|
|
17
|
+
|
|
18
|
+
import { resolveBranchName } from './pr-branch.mjs';
|
|
19
|
+
|
|
20
|
+
/** Prefixo de toda branch criada pelo spec-wave — inclusive as do `update --branch`. */
|
|
21
|
+
export const ARTIFACT_BRANCH_PREFIX = 'spec-wave/';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Documento → sufixo da branch e nome de exibição.
|
|
25
|
+
*
|
|
26
|
+
* `decomposition-apply` é um documento à parte de propósito: o `--apply`
|
|
27
|
+
* reescreve o decomposition.md anotado com as issues criadas, e essa anotação
|
|
28
|
+
* NÃO pode ir para a branch do rascunho — mutar um PR que alguém está revisando
|
|
29
|
+
* é o desfecho mais surpreendente disponível aqui.
|
|
30
|
+
*/
|
|
31
|
+
export const ARTIFACT_DOCS = {
|
|
32
|
+
spec: { suffix: 'spec', file: 'spec.md' },
|
|
33
|
+
plan: { suffix: 'plan', file: 'plan.md' },
|
|
34
|
+
bug: { suffix: 'bug', file: 'bug.md' },
|
|
35
|
+
decomposition: { suffix: 'decompose', file: 'decomposition.md' },
|
|
36
|
+
'decomposition-apply': { suffix: 'decompose-apply', file: 'decomposition.md' },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Nomes aceitos em `doc`, na ordem do fluxo. */
|
|
40
|
+
export const ARTIFACT_DOC_NAMES = Object.keys(ARTIFACT_DOCS);
|
|
41
|
+
|
|
42
|
+
function docSpec(doc) {
|
|
43
|
+
const spec = ARTIFACT_DOCS[doc];
|
|
44
|
+
if (!spec) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Documento desconhecido: "${doc}". Use um de: ${ARTIFACT_DOC_NAMES.join(', ')}.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return spec;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Branch do artefato (função PURA).
|
|
54
|
+
*
|
|
55
|
+
* Deliberadamente SEM timestamp e SEM slug do título: rodar o mesmo passo duas
|
|
56
|
+
* vezes tem que reaproveitar o mesmo PR, não espalhar uma branch por tentativa.
|
|
57
|
+
* O par (issue, documento) já identifica o trabalho de forma única — e o slug
|
|
58
|
+
* mudaria se alguém renomeasse a issue no meio, deixando um PR órfão.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} params
|
|
61
|
+
* @param {number|string} params.issueNumber
|
|
62
|
+
* @param {string} params.doc chave de ARTIFACT_DOCS
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function artifactBranch({ issueNumber, doc }) {
|
|
66
|
+
const { suffix } = docSpec(doc);
|
|
67
|
+
// Estrito de propósito: `parseInt` transformaria 1.5 em 1 e "42abc" em 42, ou
|
|
68
|
+
// seja, publicaria numa branch de OUTRA issue sem nenhum sinal. O número vem
|
|
69
|
+
// de argumento de CLI e de payload de evento — os dois chegam como texto.
|
|
70
|
+
const bruto = String(issueNumber ?? '').trim();
|
|
71
|
+
if (!/^\d+$/.test(bruto) || Number.parseInt(bruto, 10) <= 0) {
|
|
72
|
+
throw new Error(`Número de issue inválido para a branch do artefato: "${issueNumber}".`);
|
|
73
|
+
}
|
|
74
|
+
const n = Number.parseInt(bruto, 10);
|
|
75
|
+
const branch = `${ARTIFACT_BRANCH_PREFIX}${n}-${suffix}`;
|
|
76
|
+
// Passa pela mesma validação do `update --branch`: o nome é montado aqui, mas
|
|
77
|
+
// validá-lo é barato e protege contra um sufixo novo entrar quebrado.
|
|
78
|
+
const { error } = resolveBranchName(branch);
|
|
79
|
+
if (error) throw new Error(error);
|
|
80
|
+
return branch;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A branch é de uma publicação do spec-wave? (função PURA)
|
|
85
|
+
*
|
|
86
|
+
* Usado pela guarda do code-review.yml e do qa.yml: um PR de documento não é um
|
|
87
|
+
* PR de implementação, e deixar os dois workflows rodarem em cima dele move o
|
|
88
|
+
* board por engano. Cobre também as branches do `update --branch`
|
|
89
|
+
* (`spec-wave/update-v*`), o que é desejável — elas também não implementam nada.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} name
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function isArtifactBranch(name) {
|
|
95
|
+
return String(name || '').startsWith(ARTIFACT_BRANCH_PREFIX);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Rótulo humano de cada documento, para título e corpo do PR.
|
|
99
|
+
const DOC_LABEL = {
|
|
100
|
+
spec: 'especificação funcional',
|
|
101
|
+
plan: 'plano técnico',
|
|
102
|
+
bug: 'documento de bug',
|
|
103
|
+
decomposition: 'rascunho de decomposição',
|
|
104
|
+
'decomposition-apply': 'registro das issues criadas',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Título do PR — e assunto do commit único (função PURA).
|
|
109
|
+
*
|
|
110
|
+
* A mesma frase nos dois lugares: no modo PR existe um commit só, e ver textos
|
|
111
|
+
* diferentes para a mesma mudança na lista de commits e no título do PR só gera
|
|
112
|
+
* dúvida. Mesmo critério do composePrTitle do update (lib/pr-branch.mjs).
|
|
113
|
+
*
|
|
114
|
+
* Sem `#N` no título de propósito — ver a nota sobre vínculo em
|
|
115
|
+
* `composeArtifactPrBody`.
|
|
116
|
+
*
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function composeArtifactPrTitle({ doc, issueNumber, issueTitle = '' } = {}) {
|
|
120
|
+
docSpec(doc);
|
|
121
|
+
const n = Number.parseInt(issueNumber, 10);
|
|
122
|
+
const titulo = String(issueTitle).trim();
|
|
123
|
+
const alvo = titulo ? `${titulo} (issue ${n})` : `issue ${n}`;
|
|
124
|
+
return `docs(spec-wave): ${DOC_LABEL[doc]} de ${alvo}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Mensagem do commit ÚNICO da branch (função PURA).
|
|
129
|
+
*
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
export function artifactCommitMessage({ doc, issueNumber, pathRel } = {}) {
|
|
133
|
+
docSpec(doc);
|
|
134
|
+
const n = Number.parseInt(issueNumber, 10);
|
|
135
|
+
const acao = doc === 'decomposition-apply'
|
|
136
|
+
? 'registra as issues criadas em'
|
|
137
|
+
: 'gera';
|
|
138
|
+
return `docs: ${acao} ${pathRel} (issue ${n}) [spec-wave]\n`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Corpo do PR (função PURA).
|
|
143
|
+
*
|
|
144
|
+
* **A issue é referenciada SÓ pela URL completa.** Não é preciosismo: o
|
|
145
|
+
* `code-review` extrai o vínculo do corpo do PR e MOVE O BOARD a partir dele
|
|
146
|
+
* (`extractIssueNumbers`, commands/code-review.mjs). São duas regexes:
|
|
147
|
+
*
|
|
148
|
+
* • `EXPLICIT_LINK_RE` exige um verbo (`closes|fixes|resolves|implements`);
|
|
149
|
+
* • `ANY_MENTION_RE`, ativa sob `codeReview.linkMode: "any-mention"`, casa
|
|
150
|
+
* `#N` CRU — então um inocente "issue #42" já bastaria.
|
|
151
|
+
*
|
|
152
|
+
* E o próprio GitHub fecha a issue sozinho diante de `Closes #N`, independente
|
|
153
|
+
* das nossas regexes. A URL completa não casa com nenhuma das duas e não aciona
|
|
154
|
+
* o fechamento automático: um PR de documentação não fecha a Feature nem a move
|
|
155
|
+
* de coluna. A guarda de branch no code-review.yml/qa.yml é a segunda linha;
|
|
156
|
+
* esta é a primeira.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} params
|
|
159
|
+
* @param {string} params.doc
|
|
160
|
+
* @param {string} params.issueUrl URL completa da issue (nunca "#N")
|
|
161
|
+
* @param {string} [params.issueTitle]
|
|
162
|
+
* @param {string} params.pathRel caminho do documento no repositório
|
|
163
|
+
* @param {string} params.base branch de destino
|
|
164
|
+
* @param {string} params.branch branch do PR
|
|
165
|
+
* @param {string} [params.nextLabel] label que destrava o passo seguinte
|
|
166
|
+
* @returns {string} markdown
|
|
167
|
+
*/
|
|
168
|
+
export function composeArtifactPrBody({
|
|
169
|
+
doc, issueUrl = '', issueTitle = '', pathRel = '', base = '?', branch = '?', nextLabel = null,
|
|
170
|
+
} = {}) {
|
|
171
|
+
docSpec(doc);
|
|
172
|
+
const l = [];
|
|
173
|
+
l.push(`Documento gerado pelo spec-wave: **${DOC_LABEL[doc]}**.`);
|
|
174
|
+
l.push('');
|
|
175
|
+
l.push(`- Arquivo: \`${pathRel}\``);
|
|
176
|
+
l.push(`- Issue: ${issueUrl}${issueTitle ? ` — ${issueTitle}` : ''}`);
|
|
177
|
+
l.push(`- Base: \`${base}\` · Branch: \`${branch}\``);
|
|
178
|
+
l.push('');
|
|
179
|
+
l.push('## Como revisar');
|
|
180
|
+
l.push('');
|
|
181
|
+
l.push(
|
|
182
|
+
'Edite o arquivo **neste PR** se precisar corrigir — as edições são ' +
|
|
183
|
+
'preservadas. Regerar o documento por cima descartaria a revisão.'
|
|
184
|
+
);
|
|
185
|
+
if (nextLabel) {
|
|
186
|
+
l.push('');
|
|
187
|
+
l.push(
|
|
188
|
+
`O passo seguinte do fluxo (\`${nextLabel}\`) lê este documento da branch ` +
|
|
189
|
+
`\`${base}\`, então ele só roda **depois do merge** deste PR.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
l.push('');
|
|
193
|
+
l.push('---');
|
|
194
|
+
l.push(
|
|
195
|
+
'_A issue é citada por URL, sem `#número` e sem palavra de fechamento, de ' +
|
|
196
|
+
'propósito: assim este PR não fecha a issue nem move o board._'
|
|
197
|
+
);
|
|
198
|
+
return `${l.join('\n')}\n`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Bloqueio "o documento existe, mas ainda não chegou à base" (função PURA).
|
|
203
|
+
*
|
|
204
|
+
* Mesmo formato dos bloqueios de lib/next-step.mjs: `{code, message, unblock}`.
|
|
205
|
+
* Sem isto, o `run` veria "o documento não está na base" e REGERARIA — pagando
|
|
206
|
+
* a IA de novo a cada execução até alguém mergear.
|
|
207
|
+
*
|
|
208
|
+
* Dois casos, com remédios diferentes — e é por isso que não são um só:
|
|
209
|
+
*
|
|
210
|
+
* • `pending-pr`: há PR aberto. Revisar e mergear.
|
|
211
|
+
* • `branch-only`: o commit passou e o PR NÃO foi aberto. Acontece quando a
|
|
212
|
+
* organização proíbe o Actions de criar PRs ou o `GH_PR_TOKEN` está errado —
|
|
213
|
+
* ou quando alguém fechou o PR sem mergear. Mandar "faça o merge do PR" aqui
|
|
214
|
+
* seria mandar procurar uma tela que não existe.
|
|
215
|
+
*
|
|
216
|
+
* @param {object} params
|
|
217
|
+
* @param {string} params.pathRel
|
|
218
|
+
* @param {string} [params.state] estado devolvido por `loadArtifact`
|
|
219
|
+
* @param {{number: number|string, url?: string}|null} [params.pr]
|
|
220
|
+
* @param {string|null} [params.branch]
|
|
221
|
+
* @returns {{code: 'pr-pending'|'branch-without-pr', message: string, unblock: string}}
|
|
222
|
+
*/
|
|
223
|
+
export function awaitingMergeBlock({ pathRel, state = 'pending-pr', pr = null, branch = null } = {}) {
|
|
224
|
+
// Sem caminho, o texto vira "`undefined` foi commitado" — mensagem de erro que
|
|
225
|
+
// acusa a ferramenta em vez de orientar quem lê.
|
|
226
|
+
const alvo = pathRel ? `\`${pathRel}\`` : 'O documento';
|
|
227
|
+
if (state === 'branch-only' || !pr?.number) {
|
|
228
|
+
const onde = branch ? `\`${branch}\`` : 'uma branch do spec-wave';
|
|
229
|
+
return {
|
|
230
|
+
code: 'branch-without-pr',
|
|
231
|
+
message:
|
|
232
|
+
`${alvo} foi commitado em ${onde}, mas nenhum Pull Request está aberto ` +
|
|
233
|
+
'para ela — na branch base o documento não está.',
|
|
234
|
+
unblock:
|
|
235
|
+
`Abra o PR de ${onde} para a branch base e faça o merge. Se o PR falhou ao ser ` +
|
|
236
|
+
'aberto pelo Actions, rode `spec-wave doctor`: quase sempre é a opção "Allow ' +
|
|
237
|
+
'GitHub Actions to create and approve pull requests" desligada, ou o secret ' +
|
|
238
|
+
'`GH_PR_TOKEN` ausente. Reaplicar o gatilho REGERA o documento.',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
code: 'pr-pending',
|
|
243
|
+
message:
|
|
244
|
+
`${alvo} existe no PR #${pr.number}, que ainda não foi mergeado — ` +
|
|
245
|
+
'na branch base ele não está.',
|
|
246
|
+
unblock:
|
|
247
|
+
`Revise e faça o merge do PR #${pr.number}${pr.url ? ` (${pr.url})` : ''}. ` +
|
|
248
|
+
'Gerar de novo por cima descartaria o que foi revisado.',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Linha do comentário da issue que aponta o Pull Request (função PURA).
|
|
254
|
+
*
|
|
255
|
+
* Três desfechos possíveis, e os três precisam ser distinguíveis por quem lê a
|
|
256
|
+
* issue — dizer "PR aberto" quando não houve PR nenhum manda a pessoa procurar
|
|
257
|
+
* uma tela que não existe.
|
|
258
|
+
*
|
|
259
|
+
* @param {{pr: object|null, branch: string, unchanged: boolean}} published
|
|
260
|
+
* @returns {string}
|
|
261
|
+
*/
|
|
262
|
+
export function renderPrLine({ pr = null, branch = '?', unchanged = false } = {}) {
|
|
263
|
+
if (pr?.number) {
|
|
264
|
+
return pr.created === false
|
|
265
|
+
? `🔀 Pull Request **atualizado**: #${pr.number} — ${pr.url}`
|
|
266
|
+
: `🔀 Pull Request: #${pr.number} — ${pr.url}`;
|
|
267
|
+
}
|
|
268
|
+
if (unchanged) {
|
|
269
|
+
return `♻️ O conteúdo gerado é idêntico ao já publicado em \`${branch}\` — nenhum commit novo.`;
|
|
270
|
+
}
|
|
271
|
+
return `⚠️ Commit enviado para \`${branch}\`, mas o Pull Request não pôde ser aberto — veja o log do run.`;
|
|
272
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Publicação de um documento gerado: branch + commit único + Pull Request.
|
|
2
|
+
//
|
|
3
|
+
// Substitui o `commitGenerated` do lib/flow-run.mjs, que commitava direto na
|
|
4
|
+
// branch default. Em repositório com proteção de branch aquilo nem rodava — a
|
|
5
|
+
// recusa (GH006) era classificada como definitiva e o job morria com a chamada
|
|
6
|
+
// de IA já paga; e, sem proteção, contornava a revisão que todo o resto do
|
|
7
|
+
// fluxo tem.
|
|
8
|
+
//
|
|
9
|
+
// **Por que via API e não via git local.** Os três motivos são independentes:
|
|
10
|
+
//
|
|
11
|
+
// 1. Roda igual nos dois modos. O runner tem checkout; o clone do usuário tem o
|
|
12
|
+
// trabalho DELE. Criar branch, commitar e voltar no clone alheio é uma
|
|
13
|
+
// coreografia que falha com working tree suja — e o `flow-run` já carregava
|
|
14
|
+
// duas exceções (identidade do git, falha de push) só por mexer em git local.
|
|
15
|
+
// 2. Some a corrida de publicação. Cada documento tem branch própria, o pai do
|
|
16
|
+
// commit é a ponta dela, e `updateRef` é fast-forward. O laço de pull+push
|
|
17
|
+
// com backoff deixa de ser necessário.
|
|
18
|
+
// 3. Commit da Git Data API vem verificado, o que satisfaz ruleset que exige
|
|
19
|
+
// assinatura — coisa que o git do runner não conseguiria.
|
|
20
|
+
//
|
|
21
|
+
// O preço: nada é gravado no working tree. É deliberado. Gravar sem commitar
|
|
22
|
+
// deixaria no clone um arquivo que o usuário não pediu e que colide no `git pull`
|
|
23
|
+
// depois do merge — justamente porque o merge pode trazer as edições do revisor.
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
getRepoDefaultBranch, compareBranches, findOpenPR, commitFilesToBranch,
|
|
27
|
+
ensurePullRequest, deleteBranch,
|
|
28
|
+
} from '../api/github-rest.mjs';
|
|
29
|
+
import { resolveBranchName, explainGitWriteError } from './pr-branch.mjs';
|
|
30
|
+
import {
|
|
31
|
+
artifactBranch, composeArtifactPrTitle, composeArtifactPrBody, artifactCommitMessage,
|
|
32
|
+
} from './artifact-pr.mjs';
|
|
33
|
+
import { blobUrl } from './repo-links.mjs';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Token usado para abrir o PR.
|
|
37
|
+
*
|
|
38
|
+
* Nos Actions o `GITHUB_TOKEN` só abre Pull Request se a opção "Allow GitHub
|
|
39
|
+
* Actions to create and approve pull requests" estiver ligada — desligada por
|
|
40
|
+
* padrão em muitas organizações. `GH_PR_TOKEN` é a saída sem depender de uma
|
|
41
|
+
* configuração de organização que nem todo time controla.
|
|
42
|
+
*
|
|
43
|
+
* Mesmo formato do `PROJECT_TOKEN` (commands/qa.mjs, code-review.mjs): variável
|
|
44
|
+
* específica com fallback para o token da execução.
|
|
45
|
+
*/
|
|
46
|
+
export function resolvePrToken(token, env = process.env) {
|
|
47
|
+
return env.SPEC_WAVE_PR_TOKEN || env.GH_PR_TOKEN || token;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A branch é resquício de um PR já mergeado? (função PURA)
|
|
52
|
+
*
|
|
53
|
+
* `identical`/`behind` significam que ela não tem NADA que a base já não tenha.
|
|
54
|
+
* Com PR aberto isso é normal (acabou de ser criada, ou o merge está em curso) e
|
|
55
|
+
* mexer nela seria mutar revisão alheia. Sem PR aberto, é lixo de um merge com
|
|
56
|
+
* squash: empilhar o próximo commit ali produziria um PR que reintroduz estado
|
|
57
|
+
* antigo, porque a ponta não é ancestral da base.
|
|
58
|
+
*
|
|
59
|
+
* @param {'identical'|'ahead'|'behind'|'diverged'|null} status
|
|
60
|
+
* @param {object|null} openPr
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function isStaleArtifactBranch(status, openPr) {
|
|
64
|
+
if (openPr) return false;
|
|
65
|
+
return status === 'identical' || status === 'behind';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Publica o documento e devolve o PR.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} params
|
|
72
|
+
* @param {string} params.token
|
|
73
|
+
* @param {string} params.owner
|
|
74
|
+
* @param {string} params.repo
|
|
75
|
+
* @param {string} params.doc chave de ARTIFACT_DOCS
|
|
76
|
+
* @param {number|string} params.issueNumber
|
|
77
|
+
* @param {string} [params.issueTitle]
|
|
78
|
+
* @param {string} [params.issueUrl] URL da issue (NUNCA "#N" — ver artifact-pr.mjs)
|
|
79
|
+
* @param {string} params.pathRel caminho do documento no repositório
|
|
80
|
+
* @param {string} params.content conteúdo FINAL do documento
|
|
81
|
+
* @param {string|null} [params.base] branch de destino (null = default do repo)
|
|
82
|
+
* @param {string|null} [params.nextLabel] label do passo seguinte, para o corpo do PR
|
|
83
|
+
* @param {object} [params.deps] injeção para teste
|
|
84
|
+
* @returns {Promise<{branch, base, commitSha, unchanged, pr, blobUrl, warning}>}
|
|
85
|
+
*/
|
|
86
|
+
export async function publishArtifact({
|
|
87
|
+
token, owner, repo, doc, issueNumber, issueTitle = '', issueUrl = '',
|
|
88
|
+
pathRel, content, base = null, nextLabel = null, deps = {},
|
|
89
|
+
}) {
|
|
90
|
+
const api = {
|
|
91
|
+
getRepoDefaultBranch, compareBranches, findOpenPR, commitFilesToBranch,
|
|
92
|
+
ensurePullRequest, deleteBranch, ...deps,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const branch = artifactBranch({ issueNumber, doc });
|
|
96
|
+
const { error: branchError } = resolveBranchName(branch);
|
|
97
|
+
if (branchError) throw new Error(branchError);
|
|
98
|
+
|
|
99
|
+
const alvo = base || await api.getRepoDefaultBranch(token, owner, repo);
|
|
100
|
+
if (branch === alvo) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`A branch do artefato ("${branch}") é a própria base — publicar nela seria ` +
|
|
103
|
+
'exatamente o commit direto que este fluxo existe para evitar.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Branch obsoleta ANTES de commitar: depois já seria tarde, o commit teria um
|
|
108
|
+
// pai errado.
|
|
109
|
+
const [status, prAberto] = await Promise.all([
|
|
110
|
+
api.compareBranches(token, owner, repo, alvo, branch),
|
|
111
|
+
api.findOpenPR(token, owner, repo, branch).catch(() => null),
|
|
112
|
+
]);
|
|
113
|
+
if (isStaleArtifactBranch(status, prAberto)) {
|
|
114
|
+
console.warn(
|
|
115
|
+
`Branch "${branch}" sobrou de um merge anterior e não tem PR aberto — ` +
|
|
116
|
+
'recriando a partir da base.'
|
|
117
|
+
);
|
|
118
|
+
await api.deleteBranch(token, owner, repo, branch).catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let commit;
|
|
122
|
+
try {
|
|
123
|
+
commit = await api.commitFilesToBranch(token, owner, repo, {
|
|
124
|
+
branch,
|
|
125
|
+
base: alvo,
|
|
126
|
+
files: [{ path: pathRel, content }],
|
|
127
|
+
message: artifactCommitMessage({ doc, issueNumber, pathRel }),
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
throw new Error(`Falha ao publicar ${pathRel} em "${branch}": ${explainGitWriteError(err)}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// O PR fica num try SEPARADO, pelo mesmo motivo do `update --branch`: um token
|
|
134
|
+
// com "Contents: write" pode não ter "Pull requests: write", e essa falha não
|
|
135
|
+
// invalida o commit — a mensagem precisa dizer que a branch existe.
|
|
136
|
+
let pr = null;
|
|
137
|
+
let warning = null;
|
|
138
|
+
try {
|
|
139
|
+
pr = await api.ensurePullRequest(resolvePrToken(token), owner, repo, {
|
|
140
|
+
branch,
|
|
141
|
+
base: alvo,
|
|
142
|
+
title: composeArtifactPrTitle({ doc, issueNumber, issueTitle }),
|
|
143
|
+
body: composeArtifactPrBody({
|
|
144
|
+
doc, issueUrl, issueTitle, pathRel, base: alvo, branch, nextLabel,
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
if (!pr && !commit.unchanged) {
|
|
148
|
+
warning =
|
|
149
|
+
`A branch "${branch}" não tem nada novo em relação a "${alvo}" e não há PR ` +
|
|
150
|
+
'aberto para ela — nada a revisar.';
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
warning =
|
|
154
|
+
`Commit ${commit.commitSha?.slice(0, 7)} enviado para "${branch}", mas o Pull Request ` +
|
|
155
|
+
`não pôde ser aberto: ${explainGitWriteError(err)}. ` +
|
|
156
|
+
`Abra manualmente: https://github.com/${owner}/${repo}/compare/` +
|
|
157
|
+
`${alvo}...${encodeURIComponent(branch)}?expand=1`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
branch,
|
|
162
|
+
base: alvo,
|
|
163
|
+
commitSha: commit.commitSha,
|
|
164
|
+
unchanged: commit.unchanged,
|
|
165
|
+
pr,
|
|
166
|
+
blobUrl: blobUrl({ owner, repo, ref: branch, pathRel }),
|
|
167
|
+
warning,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -19,9 +19,31 @@
|
|
|
19
19
|
* @param {boolean|null} params.onRemote true = existe no remoto; false = não
|
|
20
20
|
* existe; null = não foi possível consultar (sem token/rede)
|
|
21
21
|
* @param {string} params.fallback o que o comando fará sem o documento
|
|
22
|
+
* @param {string|null} [params.branch] branch do artefato quando NÃO há PR aberto
|
|
23
|
+
* @param {{number: number|string, url?: string}|null} [params.pr] PR ABERTO que
|
|
24
|
+
* contém o documento — a publicação por Pull Request criou este quarto caso, e
|
|
25
|
+
* sem ele a mensagem afirma "não existe no repositório" sobre um documento que
|
|
26
|
+
* está gerado e esperando revisão. Mentira cara: manda o executor refazer o
|
|
27
|
+
* trabalho do zero.
|
|
22
28
|
* @returns {string}
|
|
23
29
|
*/
|
|
24
|
-
export function missingDocMessage({ pathRel, onRemote, fallback }) {
|
|
30
|
+
export function missingDocMessage({ pathRel, onRemote, fallback, pr = null, branch = null }) {
|
|
31
|
+
if (pr?.number) {
|
|
32
|
+
return (
|
|
33
|
+
`${pathRel} está no PR #${pr.number}, ainda NÃO mergeado` +
|
|
34
|
+
`${pr.url ? ` (${pr.url})` : ''} — revise e faça o merge, ou rode ` +
|
|
35
|
+
`\`gh pr checkout ${pr.number}\`. Seguir agora ignoraria o documento já gerado.`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
// Commit publicado, PR não aberto. Sem este ramo a mensagem cairia no "não
|
|
39
|
+
// existe no repositório" — e o documento está lá, numa branch.
|
|
40
|
+
if (branch) {
|
|
41
|
+
return (
|
|
42
|
+
`${pathRel} está na branch \`${branch}\`, sem Pull Request aberto e fora da base — ` +
|
|
43
|
+
`rode \`git fetch origin ${branch} && git checkout ${branch}\` para vê-lo. ` +
|
|
44
|
+
'Seguir agora ignoraria o documento já gerado.'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
25
47
|
if (onRemote === true) {
|
|
26
48
|
return (
|
|
27
49
|
`${pathRel} existe no repositório mas NÃO no seu clone — rode \`git pull\` e ` +
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// De ONDE vem o conteúdo de um documento do fluxo.
|
|
2
|
+
//
|
|
3
|
+
// Enquanto os artefatos eram commitados na branch default, "o documento existe"
|
|
4
|
+
// e "o arquivo está no checkout" eram a mesma pergunta, e todo mundo respondia
|
|
5
|
+
// com `existsSync`. Com a publicação por Pull Request deixam de ser: um
|
|
6
|
+
// documento recém-gerado vive numa branch que ninguém mergeou ainda.
|
|
7
|
+
//
|
|
8
|
+
// Ignorar essa diferença não dá erro — dá coisa pior, em silêncio:
|
|
9
|
+
//
|
|
10
|
+
// • o `decompose` REGENERA um rascunho que um humano já editou (a invariante
|
|
11
|
+
// "um rascunho existente nunca é regenerado" é literalmente um `existsSync`);
|
|
12
|
+
// • o `validate` REPROVA por arquivo ausente e consome a label de gatilho;
|
|
13
|
+
// • o `generate-plan` gera o plano com o placeholder no lugar da spec.
|
|
14
|
+
//
|
|
15
|
+
// Por isso a resolução é uma só, em camadas, e todo leitor passa por aqui.
|
|
16
|
+
//
|
|
17
|
+
// I/O com dependências INJETADAS: é o que permite testar as quatro camadas sem
|
|
18
|
+
// mock de HTTP, que a suíte não tem.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { getFileContent, findOpenPR } from '../api/github-rest.mjs';
|
|
24
|
+
import { artifactBranch } from './artifact-pr.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {'local'|'remote'|'pending-pr'|'branch-only'|'missing'|'unknown'} DocState
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Estados em que o documento EXISTE mas ainda não chegou à branch base (PURA).
|
|
32
|
+
*
|
|
33
|
+
* Os dois significam a mesma coisa para quem depende do documento — o passo
|
|
34
|
+
* seguinte não pode rodar — e coisas diferentes para quem precisa destravar:
|
|
35
|
+
* `pending-pr` espera revisão e merge; `branch-only` espera alguém abrir o PR.
|
|
36
|
+
*
|
|
37
|
+
* Existe como predicado, e não como comparação solta, porque a lista é
|
|
38
|
+
* consultada em seis lugares (validate, generate-plan, decompose, apply,
|
|
39
|
+
* next-step, run) e um deles esquecido é uma porta aberta: foi assim que o
|
|
40
|
+
* `--apply` quase criou issues a partir de um rascunho que ninguém revisou.
|
|
41
|
+
*
|
|
42
|
+
* @param {DocState} state
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isAwaitingMerge(state) {
|
|
46
|
+
return state === 'pending-pr' || state === 'branch-only';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Onde está o documento, e o seu conteúdo (I/O — NUNCA lança).
|
|
51
|
+
*
|
|
52
|
+
* A ordem das camadas é deliberada:
|
|
53
|
+
*
|
|
54
|
+
* 1. **disco** (`local`) — o que o usuário tem à mão vence; é o que ele está
|
|
55
|
+
* editando, e é também o caso do runner logo após o checkout.
|
|
56
|
+
* 2. **branch base** (`remote`) — mantém exatamente o significado que o G6 do
|
|
57
|
+
* next-step já dava a este estado: publicado, mas não no seu clone.
|
|
58
|
+
* 3. **branch do artefato** — e só então se há PR: com PR aberto, `pending-pr`;
|
|
59
|
+
* sem PR, `branch-only`. A ordem é essa porque perguntar pelo PR primeiro
|
|
60
|
+
* tornava invisível o documento cujo commit passou e cujo PR falhou (403 de
|
|
61
|
+
* organização, GH_PR_TOKEN errado): ele era reportado como inexistente, e a
|
|
62
|
+
* execução seguinte REGERAVA, pagando a IA de novo. Consultar a branch antes
|
|
63
|
+
* não custa chamada nenhuma — são duas requisições nos dois desfechos.
|
|
64
|
+
* 4. nada (`missing`); falha de rede em qualquer consulta → `unknown`.
|
|
65
|
+
*
|
|
66
|
+
* `unknown` nunca vira bloqueio: ficar offline não pode travar o fluxo. Quem
|
|
67
|
+
* decide o que fazer com cada estado é o chamador.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} params
|
|
70
|
+
* @param {string} params.token
|
|
71
|
+
* @param {string} params.owner
|
|
72
|
+
* @param {string} params.repo
|
|
73
|
+
* @param {string|null} params.root raiz do repositório (para o caminho absoluto)
|
|
74
|
+
* @param {string} params.pathRel caminho do documento no repositório
|
|
75
|
+
* @param {string} params.doc chave de ARTIFACT_DOCS
|
|
76
|
+
* @param {number|string} params.issueNumber
|
|
77
|
+
* @param {string|null} [params.base] branch base (null = deixa a API escolher)
|
|
78
|
+
* @param {object} [params.deps] injeção para teste
|
|
79
|
+
* @returns {Promise<{content: string|null, state: DocState, ref: string|null,
|
|
80
|
+
* pr: {number: number, url: string}|null}>}
|
|
81
|
+
*/
|
|
82
|
+
export async function loadArtifact({
|
|
83
|
+
token, owner, repo, root, pathRel, doc, issueNumber, base = null, deps = {},
|
|
84
|
+
}) {
|
|
85
|
+
const api = { getFileContent, findOpenPR, ...deps };
|
|
86
|
+
|
|
87
|
+
const abs = path.resolve(root || process.cwd(), pathRel);
|
|
88
|
+
if (existsSync(abs)) {
|
|
89
|
+
return { content: readFileSync(abs, 'utf-8'), state: 'local', ref: null, pr: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Sem token não há como consultar nada — mas "não consegui olhar" não é "não
|
|
93
|
+
// existe": afirmar `missing` aqui faria o chamador regerar por cima.
|
|
94
|
+
if (!token || !owner || !repo) {
|
|
95
|
+
return { content: null, state: 'unknown', ref: null, pr: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let naBase;
|
|
99
|
+
try {
|
|
100
|
+
naBase = await api.getFileContent(token, owner, repo, pathRel, base || undefined);
|
|
101
|
+
} catch {
|
|
102
|
+
return { content: null, state: 'unknown', ref: null, pr: null };
|
|
103
|
+
}
|
|
104
|
+
if (naBase != null) {
|
|
105
|
+
return { content: naBase, state: 'remote', ref: base, pr: null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let branch;
|
|
109
|
+
try {
|
|
110
|
+
branch = artifactBranch({ issueNumber, doc });
|
|
111
|
+
} catch {
|
|
112
|
+
// Documento sem branch de artefato (ou número inválido): não há onde olhar.
|
|
113
|
+
return { content: null, state: 'missing', ref: null, pr: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let naBranch;
|
|
117
|
+
try {
|
|
118
|
+
naBranch = await api.getFileContent(token, owner, repo, pathRel, branch);
|
|
119
|
+
} catch {
|
|
120
|
+
return { content: null, state: 'unknown', ref: null, pr: null };
|
|
121
|
+
}
|
|
122
|
+
// Branch inexistente e arquivo ausente dão o mesmo 404, e é o mesmo desfecho.
|
|
123
|
+
if (naBranch == null) return { content: null, state: 'missing', ref: null, pr: null };
|
|
124
|
+
|
|
125
|
+
// O PR só importa depois de saber que o documento está lá — e o que ele decide
|
|
126
|
+
// é o REMÉDIO, não a existência.
|
|
127
|
+
let pr;
|
|
128
|
+
try {
|
|
129
|
+
pr = await api.findOpenPR(token, owner, repo, branch);
|
|
130
|
+
} catch {
|
|
131
|
+
// O documento existe; não saber se há PR não pode rebaixá-lo a inexistente,
|
|
132
|
+
// que é o estado que autoriza regerar por cima.
|
|
133
|
+
return { content: naBranch, state: 'branch-only', ref: branch, pr: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return pr
|
|
137
|
+
? { content: naBranch, state: 'pending-pr', ref: branch, pr: { number: pr.number, url: pr.url } }
|
|
138
|
+
: { content: naBranch, state: 'branch-only', ref: branch, pr: null };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Estado de VÁRIOS documentos de uma issue (I/O — nunca lança).
|
|
143
|
+
*
|
|
144
|
+
* Devolve o mapa que `nextStep` consome, sem o conteúdo: quem decide o próximo
|
|
145
|
+
* passo não precisa carregar três documentos inteiros na memória.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} params
|
|
148
|
+
* @param {Array<{doc: string, pathRel: string}>} params.docs
|
|
149
|
+
* @returns {Promise<Record<string, {state: DocState, pr: object|null}>>}
|
|
150
|
+
*/
|
|
151
|
+
export async function artifactStates({
|
|
152
|
+
token, owner, repo, root, docs = [], issueNumber, base = null, deps = {},
|
|
153
|
+
}) {
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const { doc, pathRel } of docs) {
|
|
156
|
+
const { state, pr } = await loadArtifact({
|
|
157
|
+
token, owner, repo, root, pathRel, doc, issueNumber, base, deps,
|
|
158
|
+
});
|
|
159
|
+
out[doc] = { state, pr };
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|