@spec-wave/cli 0.26.0 → 0.27.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 +25 -0
- package/src/commands/decompose.mjs +166 -39
- package/src/commands/doctor.mjs +190 -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 +40 -20
- package/src/commands/run.mjs +51 -30
- 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 +10 -0
- package/src/lib/repo-links.mjs +8 -2
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- 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/run/SKILL.md +3 -1
- package/src/plugin/skills/spec/SKILL.md +4 -4
- package/src/plugin/skills/workflow/SKILL.md +2 -2
- package/src/templates/skill/SKILL.md +7 -7
- 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
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
2
|
import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
3
|
import { slugify } from '../lib/slugify.mjs';
|
|
@@ -14,6 +12,48 @@ import { detectIssueType } from '../lib/issue-type.mjs';
|
|
|
14
12
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
15
13
|
import { executionMode } from '../lib/flow-run.mjs';
|
|
16
14
|
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
15
|
+
import { loadArtifact } from '../lib/doc-source.mjs';
|
|
16
|
+
import { awaitingMergeBlock } from '../lib/artifact-pr.mjs';
|
|
17
|
+
import { isAwaitingMerge } from '../lib/doc-source.mjs';
|
|
18
|
+
import { getRepoDefaultBranch } from '../api/github-rest.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Aborto por documento ainda em Pull Request.
|
|
22
|
+
*
|
|
23
|
+
* Este é o portão mais importante do `validate`, e existe por um motivo
|
|
24
|
+
* assimétrico: a reprova NÃO é inócua. Ela remove `spec-wave:ready` (o gatilho,
|
|
25
|
+
* que `issues: [labeled]` não redispara sozinho) e comenta a falha na issue —
|
|
26
|
+
* ou seja, danifica estado COMPARTILHADO. Fazer isso porque o documento está num
|
|
27
|
+
* PR não mergeado seria reprovar por uma condição que não é do documento.
|
|
28
|
+
*
|
|
29
|
+
* É exatamente o incidente que o comentário do `readsByType` em
|
|
30
|
+
* lib/next-step.mjs já registra ter acontecido antes, com clone desatualizado.
|
|
31
|
+
*
|
|
32
|
+
* Aborta ANTES do removeLabel: a label fica, e reaplicá-la depois do merge
|
|
33
|
+
* simplesmente funciona.
|
|
34
|
+
*/
|
|
35
|
+
async function abortIfPendingPr({ token, owner, repo, n, docs }) {
|
|
36
|
+
const pendentes = docs.filter(d => isAwaitingMerge(d.state));
|
|
37
|
+
if (pendentes.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
// Cada documento carrega o SEU desbloqueio: um está esperando merge, outro
|
|
40
|
+
// esperando alguém abrir o PR. Um texto único ("faça o merge do PR") mandaria
|
|
41
|
+
// metade dos casos procurar uma tela que não existe.
|
|
42
|
+
const blocos = pendentes.map(d => awaitingMergeBlock({
|
|
43
|
+
pathRel: d.pathRel, state: d.state, pr: d.pr, branch: d.ref,
|
|
44
|
+
}));
|
|
45
|
+
const linhas = blocos.map(b => `- ${b.message}\n ${b.unblock}`);
|
|
46
|
+
|
|
47
|
+
await commentOnIssue(token, owner, repo, n,
|
|
48
|
+
'⏸️ **Validação adiada — documento ainda não está na branch base.**\n\n' +
|
|
49
|
+
`${linhas.join('\n')}\n\n` +
|
|
50
|
+
'Resolvido isso, reaplique `spec-wave:ready` — a label foi **mantida** na issue, ' +
|
|
51
|
+
'e nada foi reprovado.'
|
|
52
|
+
).catch(() => {});
|
|
53
|
+
|
|
54
|
+
console.error(`Validação adiada: ${pendentes.map(d => d.pathRel).join(', ')} fora da base.`);
|
|
55
|
+
return { ok: false, errors: linhas, awaitingMerge: true };
|
|
56
|
+
}
|
|
17
57
|
|
|
18
58
|
/**
|
|
19
59
|
* Validação do bug.md (RFC-004 §5).
|
|
@@ -23,11 +63,19 @@ import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
|
23
63
|
* um único arquivo, e a falha NÃO devolve o item para a etapa de spec (Bug não
|
|
24
64
|
* tem etapa de spec). Reaplicar `spec-wave:bug` é o caminho de retomada.
|
|
25
65
|
*/
|
|
26
|
-
async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
66
|
+
async function validateBug({ token, owner, repo, issue, issueNumber, root, base }) {
|
|
27
67
|
const n = parseInt(issueNumber, 10);
|
|
28
|
-
const { fileRel
|
|
68
|
+
const { fileRel } = bugDocPaths(issue.title, root);
|
|
29
69
|
const errors = [];
|
|
30
70
|
|
|
71
|
+
const bug = await loadArtifact({
|
|
72
|
+
token, owner, repo, root, pathRel: fileRel, doc: 'bug', issueNumber: n, base,
|
|
73
|
+
});
|
|
74
|
+
const adiado = await abortIfPendingPr({
|
|
75
|
+
token, owner, repo, n, docs: [{ ...bug, pathRel: fileRel }],
|
|
76
|
+
});
|
|
77
|
+
if (adiado) return adiado;
|
|
78
|
+
|
|
31
79
|
const names = labelNames(issue);
|
|
32
80
|
const critiqueFailed = names.includes(LABEL_CRITIQUE_FAILED);
|
|
33
81
|
const needsHuman = names.includes(LABEL_NEEDS_HUMAN);
|
|
@@ -44,12 +92,12 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
44
92
|
);
|
|
45
93
|
}
|
|
46
94
|
|
|
47
|
-
if (
|
|
95
|
+
if (bug.content == null) {
|
|
48
96
|
errors.push(
|
|
49
97
|
`❌ \`bug.md\` não encontrado em \`${fileRel}\` — aplique \`${LABEL_BUG}\` para gerá-lo.`
|
|
50
98
|
);
|
|
51
99
|
} else {
|
|
52
|
-
const content =
|
|
100
|
+
const content = bug.content;
|
|
53
101
|
for (const faltante of describeMissingSections(content, REQUIRED_BUG_SECTIONS)) {
|
|
54
102
|
errors.push(renderMissingSection('bug.md', faltante));
|
|
55
103
|
}
|
|
@@ -124,19 +172,23 @@ export async function validate({ issueNumber }) {
|
|
|
124
172
|
);
|
|
125
173
|
}
|
|
126
174
|
|
|
127
|
-
const
|
|
175
|
+
const n = parseInt(issueNumber, 10);
|
|
176
|
+
const issue = await getIssue(token, owner, repo, n);
|
|
177
|
+
|
|
178
|
+
// Branch base para a leitura em camadas. Best-effort: sem ela o resolvedor cai
|
|
179
|
+
// no default da API, que é a mesma branch.
|
|
180
|
+
const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
|
|
128
181
|
|
|
129
182
|
// Bug tem artefato próprio (bug.md) e caminho próprio de validação — não passa
|
|
130
183
|
// pelo par spec.md + plan.md, que é exclusivo de Feature.
|
|
131
184
|
if (detectIssueType(issue) === 'Bug') {
|
|
132
|
-
return await validateBug({ token, owner, repo, issue, issueNumber, root });
|
|
185
|
+
return await validateBug({ token, owner, repo, issue, issueNumber, root, base });
|
|
133
186
|
}
|
|
134
187
|
|
|
135
188
|
const slug = slugify(issue.title);
|
|
136
189
|
// Ancorado na RAIZ do repo, não no cwd: rodar de um subdiretório encontra o
|
|
137
190
|
// config subindo na árvore e precisa encontrar os documentos no mesmo lugar.
|
|
138
191
|
const featureRel = `docs/features/${slug}`;
|
|
139
|
-
const featureDir = path.resolve(root || process.cwd(), featureRel);
|
|
140
192
|
|
|
141
193
|
const errors = [];
|
|
142
194
|
|
|
@@ -158,12 +210,28 @@ export async function validate({ issueNumber }) {
|
|
|
158
210
|
);
|
|
159
211
|
}
|
|
160
212
|
|
|
213
|
+
// Os dois documentos vêm do resolvedor em camadas (disco → base → PR aberto):
|
|
214
|
+
// ler só o disco reprovaria uma Feature cujo plan.md está publicado, o que
|
|
215
|
+
// custa a label de gatilho e um comentário de falha na issue.
|
|
216
|
+
const planRel = `${featureRel}/plan.md`;
|
|
217
|
+
const specRel = `${featureRel}/spec.md`;
|
|
218
|
+
const ler = (doc, pathRel) => loadArtifact({
|
|
219
|
+
token, owner, repo, root, pathRel, doc, issueNumber: n, base,
|
|
220
|
+
});
|
|
221
|
+
const plan = await ler('plan', planRel);
|
|
222
|
+
const spec = await ler('spec', specRel);
|
|
223
|
+
|
|
224
|
+
const adiado = await abortIfPendingPr({
|
|
225
|
+
token, owner, repo, n,
|
|
226
|
+
docs: [{ ...plan, pathRel: planRel }, { ...spec, pathRel: specRel }],
|
|
227
|
+
});
|
|
228
|
+
if (adiado) return adiado;
|
|
229
|
+
|
|
161
230
|
// Check plan.md
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
|
|
231
|
+
if (plan.content == null) {
|
|
232
|
+
errors.push('❌ `plan.md` não encontrado em `' + planRel + '`');
|
|
165
233
|
} else {
|
|
166
|
-
const planContent =
|
|
234
|
+
const planContent = plan.content;
|
|
167
235
|
for (const faltante of describeMissingSections(planContent, REQUIRED_PLAN_SECTIONS)) {
|
|
168
236
|
errors.push(renderMissingSection('plan.md', faltante));
|
|
169
237
|
}
|
|
@@ -173,11 +241,10 @@ export async function validate({ issueNumber }) {
|
|
|
173
241
|
}
|
|
174
242
|
|
|
175
243
|
// Check spec.md
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
|
|
244
|
+
if (spec.content == null) {
|
|
245
|
+
errors.push('❌ `spec.md` não encontrado em `' + specRel + '`');
|
|
179
246
|
} else {
|
|
180
|
-
const specContent =
|
|
247
|
+
const specContent = spec.content;
|
|
181
248
|
for (const faltante of describeMissingSections(specContent, REQUIRED_SPEC_SECTIONS)) {
|
|
182
249
|
errors.push(renderMissingSection('spec.md', faltante));
|
|
183
250
|
}
|
package/src/config.mjs
CHANGED
|
@@ -519,6 +519,24 @@ export const WORKFLOW_FILES = [
|
|
|
519
519
|
'qa.yml',
|
|
520
520
|
];
|
|
521
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Workflows que PUBLICAM um documento gerado (branch própria + Pull Request).
|
|
524
|
+
*
|
|
525
|
+
* Lista explícita de propósito. O teste que verifica permissões selecionava os
|
|
526
|
+
* workflows por `content.includes('contents: write')` — um predicado que passa
|
|
527
|
+
* VACUAMENTE se o marcador sumir do YAML: nenhum arquivo é selecionado, o laço
|
|
528
|
+
* não roda, e o teste fica verde justamente no cenário que ele deveria pegar.
|
|
529
|
+
*
|
|
530
|
+
* Estes quatro precisam de `contents: write` (o commit vai por Git Data API) E
|
|
531
|
+
* de `pull-requests: write` (sem ela o commit existe e o PR não).
|
|
532
|
+
*/
|
|
533
|
+
export const ARTIFACT_WORKFLOW_FILES = [
|
|
534
|
+
'generate-spec.yml',
|
|
535
|
+
'generate-plan.yml',
|
|
536
|
+
'generate-bug.yml',
|
|
537
|
+
'decompose.yml',
|
|
538
|
+
];
|
|
539
|
+
|
|
522
540
|
export const ISSUE_TEMPLATE_FILES = [
|
|
523
541
|
'plan-template.md',
|
|
524
542
|
'spec-template.md',
|
|
@@ -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 ` +
|