@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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/api/github-rest.mjs +25 -0
  3. package/src/commands/decompose.mjs +166 -39
  4. package/src/commands/doctor.mjs +190 -3
  5. package/src/commands/generate-bug.mjs +22 -16
  6. package/src/commands/generate-plan.mjs +72 -23
  7. package/src/commands/generate-spec.mjs +19 -15
  8. package/src/commands/implement.mjs +40 -20
  9. package/src/commands/run.mjs +51 -30
  10. package/src/commands/validate.mjs +84 -17
  11. package/src/config.mjs +18 -0
  12. package/src/lib/artifact-pr.mjs +272 -0
  13. package/src/lib/artifact-publish.mjs +169 -0
  14. package/src/lib/doc-availability.mjs +23 -1
  15. package/src/lib/doc-source.mjs +162 -0
  16. package/src/lib/flow-run.mjs +9 -218
  17. package/src/lib/next-step.mjs +27 -4
  18. package/src/lib/pr-branch.mjs +10 -0
  19. package/src/lib/repo-links.mjs +8 -2
  20. package/src/plugin/.claude-plugin/plugin.json +1 -1
  21. package/src/plugin/skills/bug/SKILL.md +2 -2
  22. package/src/plugin/skills/decompose/SKILL.md +4 -4
  23. package/src/plugin/skills/plan/SKILL.md +1 -1
  24. package/src/plugin/skills/run/SKILL.md +3 -1
  25. package/src/plugin/skills/spec/SKILL.md +4 -4
  26. package/src/plugin/skills/workflow/SKILL.md +2 -2
  27. package/src/templates/skill/SKILL.md +7 -7
  28. package/src/templates/workflows/code-review.yml +13 -2
  29. package/src/templates/workflows/critique.yml +1 -1
  30. package/src/templates/workflows/decompose.yml +13 -2
  31. package/src/templates/workflows/generate-bug.yml +17 -6
  32. package/src/templates/workflows/generate-plan.yml +20 -7
  33. package/src/templates/workflows/generate-spec.yml +20 -7
  34. 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, fileAbs } = bugDocPaths(issue.title, root);
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 (!existsSync(fileAbs)) {
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 = readFileSync(fileAbs, 'utf-8');
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 issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
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
- const planPath = path.join(featureDir, 'plan.md');
163
- if (!existsSync(planPath)) {
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 = readFileSync(planPath, 'utf-8');
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
- const specPath = path.join(featureDir, 'spec.md');
177
- if (!existsSync(specPath)) {
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 = readFileSync(specPath, 'utf-8');
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 ` +