@spec-wave/cli 0.14.0 → 0.15.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/bin/spec-wave.mjs +3 -0
- package/package.json +1 -1
- package/src/api/github-rest.mjs +198 -2
- package/src/commands/update.mjs +336 -59
- package/src/lib/pr-branch.mjs +267 -0
- package/src/templates/skill/SKILL.md +15 -3
package/bin/spec-wave.mjs
CHANGED
|
@@ -93,6 +93,9 @@ program
|
|
|
93
93
|
.option('--skip-skill', 'Não verifica/atualiza a skill instalada')
|
|
94
94
|
.option('--skip-config', 'Não verifica/atualiza o .spec-wave.json local')
|
|
95
95
|
.option('--skip-repo', 'Não verifica/atualiza workflows e labels do repo')
|
|
96
|
+
.option('--branch [nome]', 'Envia os arquivos do repo como Pull Request numa branch, em um único commit (sem valor: spec-wave/update-v<versão>)')
|
|
97
|
+
.option('--config-in-pr', 'Força incluir o .spec-wave.json no Pull Request')
|
|
98
|
+
.option('--no-config-in-pr', 'Força manter o .spec-wave.json fora do Pull Request')
|
|
96
99
|
.option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
|
|
97
100
|
.option('--yes', 'Aplica sem pedir confirmação')
|
|
98
101
|
.action(async (options) => {
|
package/package.json
CHANGED
package/src/api/github-rest.mjs
CHANGED
|
@@ -4,6 +4,15 @@ function makeOctokit(token) {
|
|
|
4
4
|
return new Octokit({ auth: token });
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
// Para as consultas em que 404 é um RESULTADO ESPERADO ("a branch ainda não
|
|
8
|
+
// existe", "não há o que comparar") e não um erro: o logger padrão do Octokit
|
|
9
|
+
// imprime a linha `GET ... - 404` no stderr, que no meio de um spinner parece
|
|
10
|
+
// falha. Mesmo padrão do doctor.mjs, onde 404/403 também são respostas válidas.
|
|
11
|
+
const silentLog = { debug() {}, info() {}, warn() {}, error() {} };
|
|
12
|
+
function makeQuietOctokit(token) {
|
|
13
|
+
return new Octokit({ auth: token, log: silentLog });
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
export async function getOwnerNodeId(token, owner) {
|
|
8
17
|
const octokit = makeOctokit(token);
|
|
9
18
|
try {
|
|
@@ -87,6 +96,189 @@ export async function upsertFile(token, owner, repo, path, content, message) {
|
|
|
87
96
|
});
|
|
88
97
|
}
|
|
89
98
|
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Git Data API — commit ATÔMICO + Pull Request
|
|
101
|
+
//
|
|
102
|
+
// O upsertFile acima faz UM COMMIT POR ARQUIVO direto na branch default: oito
|
|
103
|
+
// arquivos = oito commits, e uma falha no quinto deixa o repositório num estado
|
|
104
|
+
// intermediário que ninguém pediu. Aqui a árvore inteira é montada primeiro e só
|
|
105
|
+
// então nascem o commit e a ref — antes da createRef/updateRef nada é
|
|
106
|
+
// ALCANÇÁVEL (os objetos existem no banco, mas nenhuma ref aponta para eles),
|
|
107
|
+
// então uma falha no meio não muda o repositório observável.
|
|
108
|
+
//
|
|
109
|
+
// Três pegadinhas da API, todas documentadas porque custam tempo de depuração:
|
|
110
|
+
// • createTree SEM `base_tree` produz uma árvore com SÓ os arquivos enviados —
|
|
111
|
+
// o commit resultante APAGA todo o resto do repositório, e a API responde 201.
|
|
112
|
+
// • getRef/getCommit/updateRef querem o ref SEM o prefixo `refs/`
|
|
113
|
+
// ('heads/main'); createRef quer COM ('refs/heads/x'). Trocar dá 404/422 sem
|
|
114
|
+
// explicação.
|
|
115
|
+
// • pulls.list exige `head` qualificado com o owner ('owner:branch'): sem o
|
|
116
|
+
// prefixo a API IGNORA o filtro e devolve todos os PRs abertos.
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Lê a ponta de uma branch: sha do commit e sha da ÁRVORE desse commit.
|
|
121
|
+
*
|
|
122
|
+
* A árvore é o que interessa para o `base_tree` e para detectar "nada mudou":
|
|
123
|
+
* git é endereçado por conteúdo, então árvore igual = conteúdo igual, sem
|
|
124
|
+
* precisar pedir diff.
|
|
125
|
+
*
|
|
126
|
+
* @returns {Promise<{commitSha: string, treeSha: string}|null>} null quando a
|
|
127
|
+
* branch não existe (404) — o chamador decide criar vs. atualizar a ref.
|
|
128
|
+
*/
|
|
129
|
+
export async function getBranchHead(token, owner, repo, branch) {
|
|
130
|
+
const octokit = makeQuietOctokit(token); // 404 = branch inexistente, não erro
|
|
131
|
+
let commitSha;
|
|
132
|
+
try {
|
|
133
|
+
const ref = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${branch}` });
|
|
134
|
+
commitSha = ref.data.object.sha;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.status === 404) return null;
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
const commit = await octokit.rest.git.getCommit({ owner, repo, commit_sha: commitSha });
|
|
140
|
+
return { commitSha, treeSha: commit.data.tree.sha };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Compara `base...branch`.
|
|
145
|
+
*
|
|
146
|
+
* 'behind'/'identical' significa que a branch não tem nenhum commit que a base
|
|
147
|
+
* já não tenha — sinal típico de branch de um PR já mergeado com squash. Serve
|
|
148
|
+
* só para AVISAR antes de empilhar um commit numa branch obsoleta; nunca para
|
|
149
|
+
* decidir um force-push.
|
|
150
|
+
*
|
|
151
|
+
* @returns {Promise<'identical'|'ahead'|'behind'|'diverged'|null>} null se a
|
|
152
|
+
* branch não existe ou a comparação falha (é só um aviso — não pode
|
|
153
|
+
* derrubar o fluxo).
|
|
154
|
+
*/
|
|
155
|
+
export async function compareBranches(token, owner, repo, base, branch) {
|
|
156
|
+
const octokit = makeQuietOctokit(token); // 404 = branch inexistente, não erro
|
|
157
|
+
try {
|
|
158
|
+
const res = await octokit.rest.repos.compareCommitsWithBasehead({
|
|
159
|
+
owner, repo, basehead: `${base}...${branch}`,
|
|
160
|
+
});
|
|
161
|
+
return res.data.status;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Grava N arquivos em UM único commit numa branch (criando-a a partir de `base`
|
|
169
|
+
* quando ainda não existe).
|
|
170
|
+
*
|
|
171
|
+
* Idempotente de propósito: rodar `update --branch fix/x` duas vezes não gera um
|
|
172
|
+
* segundo commit. Quando a branch já existe o pai é a PONTA DELA — não a base —
|
|
173
|
+
* então a atualização da ref é sempre fast-forward e `force` fica em false (uma
|
|
174
|
+
* corrida com outro push falha em vez de sobrescrever trabalho alheio).
|
|
175
|
+
*
|
|
176
|
+
* Se a árvore montada tiver o MESMO sha da árvore do pai, nada mudou de fato e o
|
|
177
|
+
* commit é pulado: commit vazio num PR só confunde quem revisa.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} opts
|
|
180
|
+
* @param {string} opts.branch branch de trabalho (head do PR)
|
|
181
|
+
* @param {string} opts.base branch de partida quando `branch` ainda não existe
|
|
182
|
+
* @param {Array<{path: string, content: string}>} opts.files conteúdo FINAL de cada arquivo
|
|
183
|
+
* @param {string} opts.message mensagem do commit único
|
|
184
|
+
* @returns {Promise<{branch, base, commitSha, createdBranch, unchanged, baseSha}>}
|
|
185
|
+
*/
|
|
186
|
+
export async function commitFilesToBranch(token, owner, repo, { branch, base, files, message }) {
|
|
187
|
+
const octokit = makeOctokit(token);
|
|
188
|
+
if (!files?.length) throw new Error('commitFilesToBranch: nenhum arquivo a enviar.');
|
|
189
|
+
|
|
190
|
+
const baseHead = await getBranchHead(token, owner, repo, base);
|
|
191
|
+
if (!baseHead) throw new Error(`Branch base "${base}" não encontrada em ${owner}/${repo}.`);
|
|
192
|
+
|
|
193
|
+
const head = await getBranchHead(token, owner, repo, branch);
|
|
194
|
+
const parent = head ?? baseHead;
|
|
195
|
+
|
|
196
|
+
// `content` inline: a própria API cria o blob, poupando um createBlob por
|
|
197
|
+
// arquivo. Vale só para texto UTF-8 — todos os nossos templates são.
|
|
198
|
+
// mode: 100644 = arquivo comum (100755 executável, 040000 subárvore).
|
|
199
|
+
const tree = await octokit.rest.git.createTree({
|
|
200
|
+
owner,
|
|
201
|
+
repo,
|
|
202
|
+
base_tree: parent.treeSha, // SEM isto o commit apagaria o repositório inteiro
|
|
203
|
+
tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (tree.data.sha === parent.treeSha) {
|
|
207
|
+
return {
|
|
208
|
+
branch, base, commitSha: parent.commitSha,
|
|
209
|
+
createdBranch: false, unchanged: true, baseSha: baseHead.commitSha,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const commit = await octokit.rest.git.createCommit({
|
|
214
|
+
owner, repo, message, tree: tree.data.sha, parents: [parent.commitSha],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (head) {
|
|
218
|
+
await octokit.rest.git.updateRef({
|
|
219
|
+
owner, repo, ref: `heads/${branch}`, sha: commit.data.sha, force: false,
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
await octokit.rest.git.createRef({
|
|
223
|
+
owner, repo, ref: `refs/heads/${branch}`, sha: commit.data.sha,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
branch, base, commitSha: commit.data.sha,
|
|
229
|
+
createdBranch: !head, unchanged: false, baseSha: baseHead.commitSha,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* PR ABERTO cuja head é `branch` neste mesmo repositório.
|
|
235
|
+
*
|
|
236
|
+
* @returns {Promise<{number, url, title, base}|null>}
|
|
237
|
+
*/
|
|
238
|
+
export async function findOpenPR(token, owner, repo, branch) {
|
|
239
|
+
const octokit = makeOctokit(token);
|
|
240
|
+
const res = await octokit.rest.pulls.list({
|
|
241
|
+
owner, repo, state: 'open', head: `${owner}:${branch}`, per_page: 1,
|
|
242
|
+
});
|
|
243
|
+
const pr = res.data[0];
|
|
244
|
+
return pr
|
|
245
|
+
? { number: pr.number, url: pr.html_url, title: pr.title, base: pr.base.ref }
|
|
246
|
+
: null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Abre o PR da branch — ou devolve o que já está aberto para ela.
|
|
251
|
+
*
|
|
252
|
+
* Rodar o update duas vezes não pode virar dois PRs. O findOpenPR cobre o caso
|
|
253
|
+
* normal; o catch do 422 cobre a corrida e o "No commits between base and head"
|
|
254
|
+
* (branch sem nada novo em relação à base → não existe PR a abrir → null).
|
|
255
|
+
* Qualquer outro 422 propaga com a mensagem real da API, senão uma base inválida
|
|
256
|
+
* viraria um silencioso "nada a revisar".
|
|
257
|
+
*
|
|
258
|
+
* @returns {Promise<{number, url, created}|null>}
|
|
259
|
+
*/
|
|
260
|
+
export async function ensurePullRequest(
|
|
261
|
+
token, owner, repo, { branch, base, title, body, draft = false }
|
|
262
|
+
) {
|
|
263
|
+
const octokit = makeOctokit(token);
|
|
264
|
+
const existing = await findOpenPR(token, owner, repo, branch);
|
|
265
|
+
if (existing) return { ...existing, created: false };
|
|
266
|
+
try {
|
|
267
|
+
const res = await octokit.rest.pulls.create({
|
|
268
|
+
owner, repo, title, body, head: branch, base, draft,
|
|
269
|
+
});
|
|
270
|
+
return { number: res.data.number, url: res.data.html_url, created: true };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err.status !== 422) throw err;
|
|
273
|
+
const again = await findOpenPR(token, owner, repo, branch);
|
|
274
|
+
if (again) return { ...again, created: false };
|
|
275
|
+
const detail = err.response?.data?.errors?.map(e => e.message).filter(Boolean).join('; ')
|
|
276
|
+
|| err.message;
|
|
277
|
+
if (/No commits between/i.test(detail)) return null;
|
|
278
|
+
throw new Error(detail);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
90
282
|
// `id` é o database id da issue — exigido pela API de dependências
|
|
91
283
|
// (blocked_by), que não aceita number nem node id.
|
|
92
284
|
export async function createIssue(token, owner, repo, title, body, labels) {
|
|
@@ -235,10 +427,14 @@ export async function getPR(token, owner, repo, prNumber) {
|
|
|
235
427
|
return res.data;
|
|
236
428
|
}
|
|
237
429
|
|
|
238
|
-
|
|
430
|
+
// `ref` é opcional (compatível com os chamadores de 4 argumentos): o modo PR do
|
|
431
|
+
// `update` precisa ler o .spec-wave.json na BASE, não no que a API escolher.
|
|
432
|
+
export async function getFileContent(token, owner, repo, path, ref) {
|
|
239
433
|
const octokit = makeOctokit(token);
|
|
240
434
|
try {
|
|
241
|
-
const res = await octokit.rest.repos.getContent({
|
|
435
|
+
const res = await octokit.rest.repos.getContent({
|
|
436
|
+
owner, repo, path, ...(ref ? { ref } : {}),
|
|
437
|
+
});
|
|
242
438
|
return Buffer.from(res.data.content, 'base64').toString('utf-8');
|
|
243
439
|
} catch (err) {
|
|
244
440
|
if (err.status === 404) return null;
|
package/src/commands/update.mjs
CHANGED
|
@@ -8,7 +8,12 @@ import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '.
|
|
|
8
8
|
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
9
9
|
import {
|
|
10
10
|
getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
|
|
11
|
+
getRepoDefaultBranch, compareBranches, commitFilesToBranch, ensurePullRequest,
|
|
11
12
|
} from '../api/github-rest.mjs';
|
|
13
|
+
import {
|
|
14
|
+
resolveBranchName, composePrTitle, composePrBody, buildCommitMessage,
|
|
15
|
+
decideConfigInPr, explainGitWriteError,
|
|
16
|
+
} from '../lib/pr-branch.mjs';
|
|
12
17
|
// MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
|
|
13
18
|
// byte com o remoto. Se só o init resolvesse, todo update veria os workflows
|
|
14
19
|
// como desatualizados para sempre.
|
|
@@ -85,12 +90,102 @@ export function diffLabels(existing) {
|
|
|
85
90
|
return { missing, changed, orphan };
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Regenera o conteúdo do .spec-wave.json a partir do Project remoto SEM GRAVAR.
|
|
95
|
+
*
|
|
96
|
+
* A geração foi separada da gravação porque no modo --branch esse conteúdo pode
|
|
97
|
+
* precisar entrar na árvore do commit — e a árvore é montada antes de QUALQUER
|
|
98
|
+
* escrita, para que uma falha no caminho não deixe nada aplicado, nem local nem
|
|
99
|
+
* remoto.
|
|
100
|
+
*
|
|
101
|
+
* @returns {Promise<{ content: string|null, error: string|null }>}
|
|
102
|
+
*/
|
|
103
|
+
async function buildConfigContent(token, config) {
|
|
104
|
+
const snapshot = await getProjectSnapshot(token, config.project.id);
|
|
105
|
+
if (!snapshot) return { content: null, error: 'Project não encontrado' };
|
|
106
|
+
const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
|
|
107
|
+
const updated = {
|
|
108
|
+
...config,
|
|
109
|
+
version: CLI_VERSION,
|
|
110
|
+
project: {
|
|
111
|
+
...projectRest,
|
|
112
|
+
title: snapshot.title,
|
|
113
|
+
url: snapshot.url,
|
|
114
|
+
id: snapshot.id,
|
|
115
|
+
number: snapshot.number,
|
|
116
|
+
fields: snapshot.fields,
|
|
117
|
+
},
|
|
118
|
+
refreshedAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
// MESMA serialização do init (2 espaços + \n final): este texto é comparado
|
|
121
|
+
// byte a byte com o remoto em decideConfigInPr, e um \n de diferença faria o
|
|
122
|
+
// config entrar no PR em toda execução.
|
|
123
|
+
return { content: `${JSON.stringify(updated, null, 2)}\n`, error: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Aplica o diff de labels direto na base.
|
|
128
|
+
*
|
|
129
|
+
* Label NUNCA entra num PR: é metadado do repositório, não arquivo versionado —
|
|
130
|
+
* não existe forma de propor a mudança para revisão.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<{created: string[], updated: string[], removed: string[]}>}
|
|
133
|
+
* só o que REALMENTE passou — é o que o corpo do PR vai afirmar a quem
|
|
134
|
+
* revisa, e ele não pode prometer o que falhou.
|
|
135
|
+
*/
|
|
136
|
+
async function applyLabels(token, owner, repo, labelDiff) {
|
|
137
|
+
const done = { created: [], updated: [], removed: [] };
|
|
138
|
+
for (const label of labelDiff.missing) {
|
|
139
|
+
try {
|
|
140
|
+
await createLabel(token, owner, repo, label);
|
|
141
|
+
done.created.push(label.name);
|
|
142
|
+
p.log.success(`Label criada: ${label.name}`);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
p.log.error(`Falha ao criar label ${label.name}: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const label of labelDiff.changed) {
|
|
148
|
+
try {
|
|
149
|
+
await updateLabel(token, owner, repo, label);
|
|
150
|
+
done.updated.push(label.name);
|
|
151
|
+
p.log.success(`Label atualizada: ${label.name}`);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const label of labelDiff.orphan) {
|
|
157
|
+
try {
|
|
158
|
+
await deleteLabel(token, owner, repo, label.name);
|
|
159
|
+
done.removed.push(label.name);
|
|
160
|
+
p.log.success(`Label descontinuada removida: ${label.name}`);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
p.log.error(`Falha ao remover label ${label.name}: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return done;
|
|
166
|
+
}
|
|
167
|
+
|
|
88
168
|
export async function update(options = {}) {
|
|
89
169
|
p.intro(chalk.bold(`spec-wave update (CLI v${CLI_VERSION})`));
|
|
90
170
|
|
|
91
171
|
const isGlobal = !!options.global;
|
|
92
172
|
const baseDir = isGlobal ? homedir() : process.cwd();
|
|
93
173
|
|
|
174
|
+
// --branch: valida o nome ANTES de qualquer rede. Descobrir um espaço no nome
|
|
175
|
+
// só no createRef, depois de meia dúzia de requisições, é o pior desperdício
|
|
176
|
+
// possível — e a mensagem crua da API não aponta a causa.
|
|
177
|
+
const prRequested = options.branch !== undefined && options.branch !== false;
|
|
178
|
+
let branch = null;
|
|
179
|
+
if (prRequested) {
|
|
180
|
+
const r = resolveBranchName(options.branch, { version: CLI_VERSION });
|
|
181
|
+
if (r.error) {
|
|
182
|
+
p.log.error(r.error);
|
|
183
|
+
p.cancel('Update cancelado.');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
branch = r.branch;
|
|
187
|
+
}
|
|
188
|
+
|
|
94
189
|
// ---------- Detecção ----------
|
|
95
190
|
// 1) Skill (por agente detectado).
|
|
96
191
|
const parsed = parseSkill(readFileSync(SKILL_SOURCE, 'utf-8'));
|
|
@@ -99,9 +194,11 @@ export async function update(options = {}) {
|
|
|
99
194
|
// 2) Config + repo dependem do .spec-wave.json local do repo atual.
|
|
100
195
|
const configPath = findConfigPath();
|
|
101
196
|
let config = null;
|
|
197
|
+
let rawConfig = null; // texto cru: comparado com o remoto em decideConfigInPr
|
|
102
198
|
if (configPath) {
|
|
103
199
|
try {
|
|
104
|
-
|
|
200
|
+
rawConfig = readFileSync(configPath, 'utf-8');
|
|
201
|
+
config = JSON.parse(rawConfig);
|
|
105
202
|
} catch (err) {
|
|
106
203
|
p.log.warn(`${CONFIG_FILE} corrompido (${err.message}); pulando config/repo.`);
|
|
107
204
|
}
|
|
@@ -136,6 +233,8 @@ export async function update(options = {}) {
|
|
|
136
233
|
}
|
|
137
234
|
|
|
138
235
|
// 2b) Arquivos do repo e labels divergentes (exige token + rede).
|
|
236
|
+
const owner = config?.owner;
|
|
237
|
+
const repo = config?.repo;
|
|
139
238
|
let repoFiles = [];
|
|
140
239
|
let labelDiff = { missing: [], changed: [], orphan: [] };
|
|
141
240
|
let repoChecked = false;
|
|
@@ -147,7 +246,6 @@ export async function update(options = {}) {
|
|
|
147
246
|
s.stop('');
|
|
148
247
|
p.log.warn(`Sem token do GitHub (${tokenError?.message ?? 'indisponível'}); pulando verificação do repo.`);
|
|
149
248
|
} else {
|
|
150
|
-
const { owner, repo } = config;
|
|
151
249
|
try {
|
|
152
250
|
for (const f of REPO_FILES) {
|
|
153
251
|
const remote = await getFileContent(tk, owner, repo, f.repoPath);
|
|
@@ -165,9 +263,67 @@ export async function update(options = {}) {
|
|
|
165
263
|
}
|
|
166
264
|
}
|
|
167
265
|
|
|
266
|
+
// 2c) Modo PR: base remota, estado do config na base e situação da branch.
|
|
267
|
+
// TUDO leitura — precisa acontecer aqui porque o resumo e o --dry-run já
|
|
268
|
+
// mostram a branch, a base e se o config vai no PR.
|
|
269
|
+
let prMode = prRequested && doRepo && repoChecked;
|
|
270
|
+
let base = null;
|
|
271
|
+
let remoteConfigRaw = null;
|
|
272
|
+
let branchExists = false;
|
|
273
|
+
if (prMode) {
|
|
274
|
+
const tk = await getToken();
|
|
275
|
+
try {
|
|
276
|
+
base = await getRepoDefaultBranch(tk, owner, repo);
|
|
277
|
+
if (branch === base) {
|
|
278
|
+
p.log.error(
|
|
279
|
+
`--branch "${branch}" é a própria branch default do repositório; ` +
|
|
280
|
+
'um Pull Request precisa de uma branch diferente da base.'
|
|
281
|
+
);
|
|
282
|
+
p.cancel('Update cancelado.');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
remoteConfigRaw = await getFileContent(tk, owner, repo, CONFIG_FILE, base);
|
|
286
|
+
const status = await compareBranches(tk, owner, repo, base, branch);
|
|
287
|
+
branchExists = status !== null;
|
|
288
|
+
if (status === 'behind' || status === 'identical') {
|
|
289
|
+
// Não fazemos force-push: só avisamos. Empilhar um commit numa branch já
|
|
290
|
+
// mergeada funciona, mas o diff do PR pode reexibir arquivos que já estão
|
|
291
|
+
// na base (caso do squash merge).
|
|
292
|
+
p.log.warn(
|
|
293
|
+
`A branch "${branch}" já existe e não tem nenhum commit além de "${base}" ` +
|
|
294
|
+
'(provavelmente de um PR já mergeado). O commit novo seria empilhado nela — ' +
|
|
295
|
+
'use `--branch <outro-nome>` para começar do zero.'
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
p.log.warn(
|
|
300
|
+
`Não foi possível preparar o modo PR: ${err.message} — ` +
|
|
301
|
+
'os arquivos seguiriam por commit direto.'
|
|
302
|
+
);
|
|
303
|
+
prMode = false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Decisão preliminar sobre o config. O conteúdo regenerado ainda não existe;
|
|
308
|
+
// `willRegenerate` cobre isso, porque o regenerado SEMPRE difere do remoto
|
|
309
|
+
// (refreshedAt muda a cada execução). Na aplicação a decisão é recalculada com
|
|
310
|
+
// o conteúdo real.
|
|
311
|
+
const configDecision = prMode && doConfig
|
|
312
|
+
? decideConfigInPr({
|
|
313
|
+
remote: remoteConfigRaw,
|
|
314
|
+
desired: rawConfig,
|
|
315
|
+
willRegenerate: !!configStale?.canApply,
|
|
316
|
+
force: options.configInPr,
|
|
317
|
+
})
|
|
318
|
+
: { included: false, reason: '' };
|
|
319
|
+
|
|
168
320
|
// ---------- Resumo ----------
|
|
169
321
|
const labelTotal = labelDiff.missing.length + labelDiff.changed.length + labelDiff.orphan.length;
|
|
170
|
-
|
|
322
|
+
// Config em dia pela versão, mas divergente do que está na base: antes isso dava
|
|
323
|
+
// "tudo atualizado" e o repositório ficava com um config velho indefinidamente.
|
|
324
|
+
const configOnlyPr = configDecision.included && !configStale;
|
|
325
|
+
const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal
|
|
326
|
+
+ (configOnlyPr ? 1 : 0);
|
|
171
327
|
|
|
172
328
|
if (total === 0) {
|
|
173
329
|
p.log.success('Tudo já está atualizado para a versão atual da CLI.');
|
|
@@ -186,11 +342,22 @@ export async function update(options = {}) {
|
|
|
186
342
|
(configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
|
|
187
343
|
}
|
|
188
344
|
if (repoFiles.length) {
|
|
189
|
-
lines.push(
|
|
345
|
+
lines.push(prMode
|
|
346
|
+
? chalk.bold(`Arquivos do repo → Pull Request (${branch} → ${base}):`)
|
|
347
|
+
: chalk.bold('Arquivos do repo:'));
|
|
190
348
|
for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
|
|
191
349
|
}
|
|
350
|
+
if (prMode && configDecision.included) {
|
|
351
|
+
if (!repoFiles.length) lines.push(chalk.bold(`Pull Request (${branch} → ${base}):`));
|
|
352
|
+
lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} (${configDecision.reason})`);
|
|
353
|
+
}
|
|
354
|
+
if (prMode && doConfig && !configDecision.included && configDecision.reason) {
|
|
355
|
+
lines.push(chalk.dim(` · ${CONFIG_FILE} fora do PR — ${configDecision.reason}`));
|
|
356
|
+
}
|
|
192
357
|
if (labelTotal) {
|
|
193
|
-
lines.push(
|
|
358
|
+
lines.push(prMode
|
|
359
|
+
? chalk.bold(`Labels (direto em ${base} — metadado do repo, não versionável):`)
|
|
360
|
+
: chalk.bold('Labels:'));
|
|
194
361
|
if (labelDiff.missing.length) lines.push(` ${chalk.yellow('+')} criar: ${labelDiff.missing.map(l => l.name).join(', ')}`);
|
|
195
362
|
if (labelDiff.changed.length) lines.push(` ${chalk.yellow('↻')} atualizar: ${labelDiff.changed.map(l => l.name).join(', ')}`);
|
|
196
363
|
if (labelDiff.orphan.length) {
|
|
@@ -199,13 +366,48 @@ export async function update(options = {}) {
|
|
|
199
366
|
}
|
|
200
367
|
p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
|
|
201
368
|
|
|
369
|
+
// --branch sem efeito: melhor dizer POR QUE do que criar uma branch inútil.
|
|
370
|
+
const prHasPayload = prMode && (repoFiles.length > 0 || configDecision.included);
|
|
371
|
+
if (prRequested && options.skipRepo) {
|
|
372
|
+
p.log.warn(
|
|
373
|
+
'--branch ignorado junto com --skip-repo: o Pull Request existe justamente para ' +
|
|
374
|
+
'levar os arquivos do repo.'
|
|
375
|
+
);
|
|
376
|
+
} else if (prRequested && !prMode) {
|
|
377
|
+
p.log.warn(
|
|
378
|
+
'--branch ignorado: sem token, sem owner/repo no .spec-wave.json ou a comparação com ' +
|
|
379
|
+
'o repositório falhou — não há como abrir o Pull Request.'
|
|
380
|
+
);
|
|
381
|
+
} else if (prMode && !prHasPayload) {
|
|
382
|
+
p.log.warn(
|
|
383
|
+
'--branch sem efeito: nenhum arquivo do repositório para enviar (as mudanças são ' +
|
|
384
|
+
'locais e/ou de labels). Nenhuma branch será criada.'
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
202
388
|
if (options.dryRun) {
|
|
389
|
+
if (prHasPayload) {
|
|
390
|
+
const n = repoFiles.length + (configDecision.included ? 1 : 0);
|
|
391
|
+
p.note(
|
|
392
|
+
`Branch: ${branch}${branchExists ? ' (já existe — o commit seria empilhado nela)' : ' (seria criada)'}\n` +
|
|
393
|
+
`Base: ${base}\n` +
|
|
394
|
+
`Título: ${composePrTitle({ version: CLI_VERSION })}\n` +
|
|
395
|
+
`Arquivos: ${n} em 1 commit único\n\n` +
|
|
396
|
+
'Nada foi criado: nem branch, nem commit, nem Pull Request.',
|
|
397
|
+
'Dry-run: modo Pull Request'
|
|
398
|
+
);
|
|
399
|
+
}
|
|
203
400
|
p.outro('Dry-run: nada foi alterado.');
|
|
204
401
|
return;
|
|
205
402
|
}
|
|
206
403
|
|
|
207
404
|
if (!options.yes) {
|
|
208
|
-
const ok = await p.confirm({
|
|
405
|
+
const ok = await p.confirm({
|
|
406
|
+
message: prHasPayload
|
|
407
|
+
? `Aplicar as ${total} atualização(ões) e abrir um Pull Request em "${branch}"?`
|
|
408
|
+
: `Aplicar as ${total} atualização(ões)?`,
|
|
409
|
+
initialValue: true,
|
|
410
|
+
});
|
|
209
411
|
if (p.isCancel(ok) || !ok) {
|
|
210
412
|
p.cancel('Update cancelado.');
|
|
211
413
|
return;
|
|
@@ -213,6 +415,14 @@ export async function update(options = {}) {
|
|
|
213
415
|
}
|
|
214
416
|
|
|
215
417
|
// ---------- Aplicação ----------
|
|
418
|
+
// Ordem: skill (local) → config GERADO em memória → labels (sempre direto) →
|
|
419
|
+
// arquivos do repo (PR ou commits diretos) → gravação local do config.
|
|
420
|
+
//
|
|
421
|
+
// As labels vêm ANTES dos arquivos porque o corpo do PR precisa dizer quais
|
|
422
|
+
// labels JÁ mudaram na base — é a única coisa que o update faz e o diff do PR
|
|
423
|
+
// não mostra. (Isso muda a ordem dos logs também no modo direto; os dois blocos
|
|
424
|
+
// são independentes.)
|
|
425
|
+
|
|
216
426
|
// Skill
|
|
217
427
|
for (const job of skillJobs) {
|
|
218
428
|
try {
|
|
@@ -223,7 +433,8 @@ export async function update(options = {}) {
|
|
|
223
433
|
}
|
|
224
434
|
}
|
|
225
435
|
|
|
226
|
-
// Config (.spec-wave.json) —
|
|
436
|
+
// Config (.spec-wave.json) — regenera o conteúdo, ainda sem tocar o disco.
|
|
437
|
+
let configContent = null;
|
|
227
438
|
if (configStale) {
|
|
228
439
|
if (!configStale.canApply) {
|
|
229
440
|
p.log.warn(`${CONFIG_FILE}: sem project.id — pulei. Rode \`npx @spec-wave/cli@latest init\` (sem --skip-project).`);
|
|
@@ -233,83 +444,149 @@ export async function update(options = {}) {
|
|
|
233
444
|
p.log.warn(`${CONFIG_FILE}: sem token — pulei. (${tokenError?.message ?? ''})`);
|
|
234
445
|
} else {
|
|
235
446
|
const s = p.spinner();
|
|
236
|
-
s.start('
|
|
447
|
+
s.start('Consultando o Project para atualizar o .spec-wave.json...');
|
|
237
448
|
try {
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
...config,
|
|
243
|
-
version: CLI_VERSION,
|
|
244
|
-
project: {
|
|
245
|
-
...projectRest,
|
|
246
|
-
title: snapshot.title,
|
|
247
|
-
url: snapshot.url,
|
|
248
|
-
id: snapshot.id,
|
|
249
|
-
number: snapshot.number,
|
|
250
|
-
fields: snapshot.fields,
|
|
251
|
-
},
|
|
252
|
-
refreshedAt: new Date().toISOString(),
|
|
253
|
-
};
|
|
254
|
-
writeFileSync(configPath, JSON.stringify(updated, null, 2) + '\n');
|
|
255
|
-
s.stop(`${CONFIG_FILE} atualizado (v${CLI_VERSION}).`);
|
|
449
|
+
const { content, error } = await buildConfigContent(tk, config);
|
|
450
|
+
if (error) throw new Error(error);
|
|
451
|
+
configContent = content;
|
|
452
|
+
s.stop(`${CONFIG_FILE} regenerado (v${CLI_VERSION}).`);
|
|
256
453
|
} catch (err) {
|
|
257
454
|
s.stop('');
|
|
258
|
-
p.log.error(`Falha ao
|
|
455
|
+
p.log.error(`Falha ao regerar ${CONFIG_FILE}: ${err.message}`);
|
|
259
456
|
}
|
|
260
457
|
}
|
|
261
458
|
}
|
|
262
459
|
}
|
|
263
460
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
461
|
+
// Recalcula a decisão com o conteúdo REAL (a preliminar usava willRegenerate).
|
|
462
|
+
const finalConfigDecision = prMode && doConfig
|
|
463
|
+
? decideConfigInPr({
|
|
464
|
+
remote: remoteConfigRaw,
|
|
465
|
+
desired: configContent ?? rawConfig,
|
|
466
|
+
force: options.configInPr,
|
|
467
|
+
})
|
|
468
|
+
: { included: false, reason: '' };
|
|
469
|
+
|
|
470
|
+
// Gravação local do config, feita UMA vez só. No modo PR com o config a bordo
|
|
471
|
+
// ela é adiada para depois do commit remoto: assim uma falha no envio não deixa
|
|
472
|
+
// o arquivo local adiantado em relação ao repositório.
|
|
473
|
+
let configFlushed = false;
|
|
474
|
+
const flushConfig = () => {
|
|
475
|
+
if (configFlushed || !configContent) return;
|
|
476
|
+
try {
|
|
477
|
+
writeFileSync(configPath, configContent);
|
|
478
|
+
configFlushed = true;
|
|
479
|
+
p.log.success(`${CONFIG_FILE} atualizado localmente (v${CLI_VERSION}).`);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
p.log.error(`Falha ao gravar ${CONFIG_FILE}: ${err.message}`);
|
|
275
482
|
}
|
|
276
|
-
}
|
|
483
|
+
};
|
|
484
|
+
if (!finalConfigDecision.included) flushConfig();
|
|
277
485
|
|
|
278
|
-
// Labels
|
|
486
|
+
// Labels — sempre direto na base (metadado do repo, não versionável).
|
|
487
|
+
let labelResult = { created: [], updated: [], removed: [] };
|
|
279
488
|
if (labelTotal) {
|
|
489
|
+
labelResult = await applyLabels(await getToken(), owner, repo, labelDiff);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Arquivos do repo
|
|
493
|
+
let prUrl = null;
|
|
494
|
+
if (prMode) {
|
|
280
495
|
const tk = await getToken();
|
|
281
|
-
const
|
|
282
|
-
|
|
496
|
+
const treeFiles = [
|
|
497
|
+
...repoFiles.map(f => ({ path: f.repoPath, reason: f.reason, content: f.local })),
|
|
498
|
+
...(finalConfigDecision.included
|
|
499
|
+
? [{
|
|
500
|
+
path: CONFIG_FILE,
|
|
501
|
+
reason: finalConfigDecision.reason,
|
|
502
|
+
content: configContent ?? rawConfig,
|
|
503
|
+
}]
|
|
504
|
+
: []),
|
|
505
|
+
];
|
|
506
|
+
if (!treeFiles.length) {
|
|
507
|
+
p.log.warn('Nada para enviar em um Pull Request: nenhum arquivo do repositório mudou.');
|
|
508
|
+
} else {
|
|
509
|
+
const s = p.spinner();
|
|
510
|
+
s.start(`Enviando ${treeFiles.length} arquivo(s) em um único commit para "${branch}"...`);
|
|
511
|
+
let commitOk = false;
|
|
283
512
|
try {
|
|
284
|
-
await
|
|
285
|
-
|
|
513
|
+
const res = await commitFilesToBranch(tk, owner, repo, {
|
|
514
|
+
branch,
|
|
515
|
+
base,
|
|
516
|
+
files: treeFiles.map(f => ({ path: f.path, content: f.content })),
|
|
517
|
+
message: buildCommitMessage({ version: CLI_VERSION, files: treeFiles }),
|
|
518
|
+
});
|
|
519
|
+
commitOk = true;
|
|
520
|
+
s.stop(res.unchanged
|
|
521
|
+
? `"${branch}" já contém estas alterações — nenhum commit novo.`
|
|
522
|
+
: `Commit ${res.commitSha.slice(0, 7)} enviado para "${branch}" (${treeFiles.length} arquivo(s)).`);
|
|
286
523
|
} catch (err) {
|
|
287
|
-
|
|
524
|
+
s.stop('');
|
|
525
|
+
p.log.error(`Falha ao enviar os arquivos para "${branch}": ${explainGitWriteError(err)}`);
|
|
288
526
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
527
|
+
|
|
528
|
+
// O PR fica num try SEPARADO: a falha aqui não invalida o commit, e a
|
|
529
|
+
// mensagem precisa dizer que a branch existe. Um token com "Contents:
|
|
530
|
+
// write" pode não ter "Pull requests: write" — permissão NOVA neste modo.
|
|
531
|
+
if (commitOk) {
|
|
532
|
+
try {
|
|
533
|
+
const pr = await ensurePullRequest(tk, owner, repo, {
|
|
534
|
+
branch,
|
|
535
|
+
base,
|
|
536
|
+
title: composePrTitle({ version: CLI_VERSION }),
|
|
537
|
+
body: composePrBody({
|
|
538
|
+
version: CLI_VERSION,
|
|
539
|
+
base,
|
|
540
|
+
branch,
|
|
541
|
+
files: treeFiles,
|
|
542
|
+
config: doConfig ? finalConfigDecision : null,
|
|
543
|
+
labels: labelResult,
|
|
544
|
+
skill: skillJobs.map(j => j.target.name),
|
|
545
|
+
}),
|
|
546
|
+
});
|
|
547
|
+
if (!pr) {
|
|
548
|
+
p.log.warn(
|
|
549
|
+
`A branch "${branch}" está em dia com "${base}" e não há PR aberto para ela — ` +
|
|
550
|
+
'nada a revisar.'
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
prUrl = pr.url;
|
|
554
|
+
p.log.success(pr.created
|
|
555
|
+
? `Pull Request aberto: #${pr.number} — ${pr.url}`
|
|
556
|
+
: `Pull Request já aberto para "${branch}": #${pr.number} — ${pr.url}`);
|
|
557
|
+
}
|
|
558
|
+
flushConfig();
|
|
559
|
+
} catch (err) {
|
|
560
|
+
p.log.error(`Commit enviado, mas não foi possível abrir o PR: ${explainGitWriteError(err)}`);
|
|
561
|
+
p.log.info(
|
|
562
|
+
'Abra manualmente: ' +
|
|
563
|
+
`https://github.com/${owner}/${repo}/compare/${base}...${encodeURIComponent(branch)}?expand=1`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
296
566
|
}
|
|
297
567
|
}
|
|
298
|
-
|
|
568
|
+
} else if (repoFiles.length) {
|
|
569
|
+
const tk = await getToken();
|
|
570
|
+
for (const f of repoFiles) {
|
|
299
571
|
try {
|
|
300
|
-
await
|
|
301
|
-
p.log.success(`
|
|
572
|
+
await upsertFile(tk, owner, repo, f.repoPath, f.local, `chore: update ${path.basename(f.repoPath)} [spec-wave]`);
|
|
573
|
+
p.log.success(`Arquivo atualizado no repo: ${f.repoPath}`);
|
|
302
574
|
} catch (err) {
|
|
303
|
-
p.log.error(`Falha ao
|
|
575
|
+
p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
|
|
304
576
|
}
|
|
305
577
|
}
|
|
306
578
|
}
|
|
307
579
|
|
|
308
|
-
const
|
|
580
|
+
const configPending = configFlushed && !finalConfigDecision.included;
|
|
309
581
|
p.outro(
|
|
310
582
|
'Update concluído.' +
|
|
311
583
|
(skillJobs.length ? ' Recarregue o agente para pegar a skill nova.' : '') +
|
|
312
|
-
(
|
|
313
|
-
(
|
|
584
|
+
(prUrl ? ` Revise e faça o merge do Pull Request: ${prUrl}` : '') +
|
|
585
|
+
(!prMode && repoFiles.length ? ' Arquivos do repo foram commitados no remoto.' : '') +
|
|
586
|
+
(configPending ? ` Faça commit do ${CONFIG_FILE}.` : '') +
|
|
587
|
+
(finalConfigDecision.included
|
|
588
|
+
? ` O ${CONFIG_FILE} local ficou igual ao do PR — depois do merge, descarte a cópia ` +
|
|
589
|
+
`local com \`git checkout -- ${CONFIG_FILE}\`.`
|
|
590
|
+
: '')
|
|
314
591
|
);
|
|
315
592
|
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Regras PURAS do modo `--branch` do update (arquivos do repo via Pull Request).
|
|
2
|
+
//
|
|
3
|
+
// Até aqui o `update` era a única exceção ao fluxo de PR: commitava os arquivos
|
|
4
|
+
// do repo direto na branch default, um commit por arquivo. Em repositório com
|
|
5
|
+
// proteção de branch isso falha no meio da execução e deixa parte aplicada; e
|
|
6
|
+
// mesmo sem proteção, contorna a revisão que todo o resto do fluxo tem.
|
|
7
|
+
//
|
|
8
|
+
// Tudo aqui é puro de propósito. A suíte não tem nenhuma infraestrutura de mock
|
|
9
|
+
// de HTTP, então a única forma de testar o modo PR é manter as DECISÕES (nome da
|
|
10
|
+
// branch, o que entra no PR, o que o corpo do PR conta a quem revisa) separadas
|
|
11
|
+
// das chamadas de rede, que ficam em api/github-rest.mjs.
|
|
12
|
+
|
|
13
|
+
import { CLI_VERSION } from './templates.mjs';
|
|
14
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Nome automático da branch quando `--branch` vem sem valor.
|
|
18
|
+
*
|
|
19
|
+
* Deliberadamente SEM timestamp: rodar o update duas vezes para a MESMA versão
|
|
20
|
+
* da CLI tem que reaproveitar o mesmo PR, não espalhar uma branch nova por
|
|
21
|
+
* tentativa. O bump de versão é o que separa uma atualização da próxima, e ele
|
|
22
|
+
* já está no nome.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} [version]
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function autoBranchName(version = CLI_VERSION) {
|
|
28
|
+
return `spec-wave/update-v${version}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Proibições de `git check-ref-format` para refs/heads/<nome>: caractere
|
|
32
|
+
// de controle e espaço (\u0000-\u0020), DEL (\u007f) e os
|
|
33
|
+
// metacaracteres de revisão. Escapes explícitos de propósito: caractere de
|
|
34
|
+
// controle literal no fonte é invisível e não sobrevive a copiar/colar.
|
|
35
|
+
const REF_FORBIDDEN = /[\u0000-\u0020\u007f~^:?*[\\]/;
|
|
36
|
+
|
|
37
|
+
const MAX_BRANCH_LENGTH = 200;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve e VALIDA o nome da branch (função PURA).
|
|
41
|
+
*
|
|
42
|
+
* Validar aqui, antes de qualquer chamada de rede, evita o pior desperdício
|
|
43
|
+
* possível: gastar meia dúzia de requisições de comparação e só descobrir no
|
|
44
|
+
* `createRef` que o nome tinha um espaço — com a mensagem crua da API, longe da
|
|
45
|
+
* causa.
|
|
46
|
+
*
|
|
47
|
+
* Mesmo contrato de `resolveStageName` em lib/board.mjs: `{valor, error}`.
|
|
48
|
+
*
|
|
49
|
+
* @param {string|boolean|undefined|null} value valor de `options.branch` — o
|
|
50
|
+
* commander entrega `true` para `--branch` sem valor
|
|
51
|
+
* @param {object} [opts]
|
|
52
|
+
* @param {string} [opts.version] versão usada no nome automático
|
|
53
|
+
* @returns {{ branch: string|null, error: string|null }}
|
|
54
|
+
*/
|
|
55
|
+
export function resolveBranchName(value, { version = CLI_VERSION } = {}) {
|
|
56
|
+
if (value === true || value === undefined || value === null || String(value).trim() === '') {
|
|
57
|
+
return { branch: autoBranchName(version), error: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let branch = String(value).trim();
|
|
61
|
+
// Erro comum: colar o ref completo em vez do nome. Corrigir é mais útil que recusar.
|
|
62
|
+
if (branch.startsWith('refs/heads/')) branch = branch.slice('refs/heads/'.length);
|
|
63
|
+
|
|
64
|
+
const bad = (why) => ({ branch: null, error: `Nome de branch inválido ("${branch}"): ${why}.` });
|
|
65
|
+
|
|
66
|
+
if (!branch) return bad('nome vazio');
|
|
67
|
+
if (branch.length > MAX_BRANCH_LENGTH) {
|
|
68
|
+
return bad(`nome longo demais (máximo ${MAX_BRANCH_LENGTH} caracteres)`);
|
|
69
|
+
}
|
|
70
|
+
if (REF_FORBIDDEN.test(branch)) {
|
|
71
|
+
return bad('não pode conter espaço, caractere de controle ou ~ ^ : ? * [ \\');
|
|
72
|
+
}
|
|
73
|
+
if (branch.includes('..')) return bad('não pode conter ".."');
|
|
74
|
+
if (branch.includes('@{')) return bad('não pode conter "@{"');
|
|
75
|
+
if (branch === '@') return bad('não pode ser apenas "@"');
|
|
76
|
+
if (branch.startsWith('/') || branch.endsWith('/')) return bad('não pode começar nem terminar com "/"');
|
|
77
|
+
if (branch.includes('//')) return bad('não pode conter "//"');
|
|
78
|
+
if (branch.startsWith('-')) return bad('não pode começar com "-"');
|
|
79
|
+
if (branch.endsWith('.')) return bad('não pode terminar com "."');
|
|
80
|
+
if (branch.toUpperCase() === 'HEAD') return bad('"HEAD" é reservado');
|
|
81
|
+
for (const part of branch.split('/')) {
|
|
82
|
+
if (part.startsWith('.')) return bad('nenhum componente pode começar com "."');
|
|
83
|
+
if (part.endsWith('.lock')) return bad('nenhum componente pode terminar com ".lock"');
|
|
84
|
+
}
|
|
85
|
+
return { branch, error: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Título do PR — e assunto do commit único (função PURA).
|
|
90
|
+
*
|
|
91
|
+
* A mesma frase nos dois lugares de propósito: no modo PR existe um commit só, e
|
|
92
|
+
* ver textos diferentes para a mesma mudança na lista de commits e no título do
|
|
93
|
+
* PR só gera dúvida.
|
|
94
|
+
*/
|
|
95
|
+
export function composePrTitle({ version = CLI_VERSION } = {}) {
|
|
96
|
+
return `chore(spec-wave): atualiza arquivos do repo para v${version}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mensagem do commit ÚNICO (função PURA).
|
|
101
|
+
*
|
|
102
|
+
* O corpo lista arquivo + motivo porque este é o único commit da branch: ele
|
|
103
|
+
* precisa se explicar sozinho num `git log` feito seis meses depois, sem o PR
|
|
104
|
+
* aberto ao lado.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} [a]
|
|
107
|
+
* @param {string} [a.version]
|
|
108
|
+
* @param {Array<{path: string, reason: string}>} [a.files]
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function buildCommitMessage({ version = CLI_VERSION, files = [] } = {}) {
|
|
112
|
+
const subject = composePrTitle({ version });
|
|
113
|
+
if (!files.length) return `${subject}\n`;
|
|
114
|
+
return `${subject}\n\n${files.map(f => `- ${f.path} (${f.reason})`).join('\n')}\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Corpo do PR (função PURA).
|
|
119
|
+
*
|
|
120
|
+
* Quem revisa precisa saber DUAS coisas que o diff não mostra:
|
|
121
|
+
* • as labels já foram aplicadas direto na base — label é metadado do
|
|
122
|
+
* repositório, não existe forma de versioná-la num PR, então ela mudou ANTES
|
|
123
|
+
* de alguém revisar isto;
|
|
124
|
+
* • a skill dos agentes foi atualizada em máquina local, fora do repositório.
|
|
125
|
+
* Sem esses dois blocos o PR parece ser a totalidade do que o update fez — e não é.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} [a]
|
|
128
|
+
* @param {Array<{path: string, reason: string}>} [a.files] arquivos que ENTRARAM no commit
|
|
129
|
+
* @param {{included: boolean, reason: string}|null} [a.config] decisão sobre o .spec-wave.json
|
|
130
|
+
* @param {{created: string[], updated: string[], removed: string[]}|null} [a.labels]
|
|
131
|
+
* labels EFETIVAMENTE aplicadas (não o diff detectado — o corpo não pode
|
|
132
|
+
* prometer o que falhou)
|
|
133
|
+
* @param {string[]} [a.skill] nomes dos agentes cuja skill foi atualizada localmente
|
|
134
|
+
* @returns {string} markdown
|
|
135
|
+
*/
|
|
136
|
+
export function composePrBody({
|
|
137
|
+
version = CLI_VERSION, base = '?', branch = '?',
|
|
138
|
+
files = [], config = null, labels = null, skill = [],
|
|
139
|
+
} = {}) {
|
|
140
|
+
const l = [];
|
|
141
|
+
l.push(`Atualização gerada por \`spec-wave update\` (CLI v${version}).`);
|
|
142
|
+
l.push('');
|
|
143
|
+
l.push(`Base: \`${base}\` · Branch: \`${branch}\``);
|
|
144
|
+
l.push('');
|
|
145
|
+
l.push('## Arquivos do repositório');
|
|
146
|
+
l.push('');
|
|
147
|
+
if (files.length) for (const f of files) l.push(`- \`${f.path}\` — ${f.reason}`);
|
|
148
|
+
else l.push('_Nenhum._');
|
|
149
|
+
|
|
150
|
+
if (config) {
|
|
151
|
+
l.push('');
|
|
152
|
+
l.push(`## ${CONFIG_FILE}`);
|
|
153
|
+
l.push('');
|
|
154
|
+
l.push(config.included
|
|
155
|
+
? `Incluído neste PR — ${config.reason}.`
|
|
156
|
+
: `**Fora** deste PR — ${config.reason}.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const labelTotal = labels
|
|
160
|
+
? labels.created.length + labels.updated.length + labels.removed.length
|
|
161
|
+
: 0;
|
|
162
|
+
if (labelTotal) {
|
|
163
|
+
l.push('');
|
|
164
|
+
l.push(`## Labels — JÁ aplicadas em \`${base}\`, fora deste PR`);
|
|
165
|
+
l.push('');
|
|
166
|
+
l.push(
|
|
167
|
+
'Label é metadado do repositório e não pode ser versionada: estas mudanças já ' +
|
|
168
|
+
'valem, com ou sem o merge deste PR.'
|
|
169
|
+
);
|
|
170
|
+
l.push('');
|
|
171
|
+
if (labels.created.length) l.push(`- criadas: ${labels.created.map(n => `\`${n}\``).join(', ')}`);
|
|
172
|
+
if (labels.updated.length) l.push(`- atualizadas: ${labels.updated.map(n => `\`${n}\``).join(', ')}`);
|
|
173
|
+
if (labels.removed.length) l.push(`- removidas (descontinuadas): ${labels.removed.map(n => `\`${n}\``).join(', ')}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (skill.length) {
|
|
177
|
+
l.push('');
|
|
178
|
+
l.push('## Skill dos agentes — fora deste PR');
|
|
179
|
+
l.push('');
|
|
180
|
+
l.push(
|
|
181
|
+
`Atualizada localmente em: ${skill.map(n => `\`${n}\``).join(', ')}. ` +
|
|
182
|
+
'Não faz parte do repositório.'
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
l.push('');
|
|
187
|
+
l.push('---');
|
|
188
|
+
l.push(`Depois do merge, \`npx @spec-wave/cli@${version} update --dry-run\` deve reportar tudo em dia.`);
|
|
189
|
+
return `${l.join('\n')}\n`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* O .spec-wave.json entra no PR? (função PURA)
|
|
194
|
+
*
|
|
195
|
+
* Política herdada do init (ver o comentário em commands/init.mjs): o config é
|
|
196
|
+
* gravado LOCAL de propósito. Quem não o commitou escolheu mantê-lo fora do
|
|
197
|
+
* versionamento, e passar a versioná-lo é decisão de projeto — não pode ser
|
|
198
|
+
* efeito colateral de um `update`. Logo: só entra se JÁ estiver versionado na
|
|
199
|
+
* base. `--config-in-pr` / `--no-config-in-pr` forçam os dois lados.
|
|
200
|
+
*
|
|
201
|
+
* Cobre também o caso em que o config NÃO está desatualizado pela versão mas o
|
|
202
|
+
* arquivo local difere do que está na base (alguém regenerou e esqueceu de
|
|
203
|
+
* commitar): entra igual, porque é exatamente para isso que o PR serve.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} [a]
|
|
206
|
+
* @param {string|null|undefined} [a.remote] conteúdo do config na base (null/undefined = não versionado)
|
|
207
|
+
* @param {string|null} [a.desired] conteúdo que deveria estar lá
|
|
208
|
+
* @param {boolean} [a.willRegenerate] o conteúdo ainda vai ser regenerado (no
|
|
209
|
+
* resumo e no dry-run ele não existe); o regenerado SEMPRE difere do
|
|
210
|
+
* remoto, porque `refreshedAt` muda a cada execução
|
|
211
|
+
* @param {boolean|undefined} [a.force] undefined | true (--config-in-pr) | false (--no-config-in-pr)
|
|
212
|
+
* @returns {{ included: boolean, reason: string }}
|
|
213
|
+
*/
|
|
214
|
+
export function decideConfigInPr({ remote, desired, willRegenerate = false, force } = {}) {
|
|
215
|
+
const versioned = remote !== null && remote !== undefined;
|
|
216
|
+
if (force === false) return { included: false, reason: '--no-config-in-pr' };
|
|
217
|
+
if (!desired && !willRegenerate) {
|
|
218
|
+
return { included: false, reason: 'sem conteúdo local para enviar' };
|
|
219
|
+
}
|
|
220
|
+
if (force === true) {
|
|
221
|
+
return versioned
|
|
222
|
+
? { included: true, reason: '--config-in-pr' }
|
|
223
|
+
: { included: true, reason: `--config-in-pr (passa a versionar o ${CONFIG_FILE})` };
|
|
224
|
+
}
|
|
225
|
+
if (!versioned) {
|
|
226
|
+
return {
|
|
227
|
+
included: false,
|
|
228
|
+
reason: 'não está versionado na base, segue apenas local ' +
|
|
229
|
+
'(use --config-in-pr para versioná-lo)',
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (!willRegenerate && remote === desired) {
|
|
233
|
+
return { included: false, reason: 'já idêntico na base' };
|
|
234
|
+
}
|
|
235
|
+
return { included: true, reason: 'versionado na base e divergente do local' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Traduz o erro cru da Git Data API para uma dica acionável (função PURA).
|
|
240
|
+
*
|
|
241
|
+
* Sem isto o usuário vê "Not Found" num POST e conclui que o repositório não
|
|
242
|
+
* existe, quando o problema é um token sem escrita em Contents — e "Resource not
|
|
243
|
+
* accessible" não diz QUAL permissão falta.
|
|
244
|
+
*
|
|
245
|
+
* @param {{status?: number, message?: string}} err
|
|
246
|
+
* @returns {string}
|
|
247
|
+
*/
|
|
248
|
+
export function explainGitWriteError(err) {
|
|
249
|
+
const msg = err?.message || 'erro desconhecido';
|
|
250
|
+
switch (err?.status) {
|
|
251
|
+
case 401:
|
|
252
|
+
return `${msg} — token inválido ou expirado.`;
|
|
253
|
+
case 403:
|
|
254
|
+
return `${msg} — o token não tem permissão de escrita. Precisa de "Contents: write" e ` +
|
|
255
|
+
'"Pull requests: write" (fine-grained) ou do escopo `repo` (classic).';
|
|
256
|
+
case 404:
|
|
257
|
+
return `${msg} — repositório ou branch inexistente, OU token sem acesso a este ` +
|
|
258
|
+
'repositório (o GitHub responde 404 em vez de 403 para não revelar repos privados).';
|
|
259
|
+
case 409:
|
|
260
|
+
return `${msg} — repositório vazio (sem nenhum commit): rode \`spec-wave init\` antes.`;
|
|
261
|
+
case 422:
|
|
262
|
+
return `${msg} — o GitHub recusou a operação. Causas comuns: regra de proteção/ruleset ` +
|
|
263
|
+
'na branch (commits da API não são assinados) ou branch já em dia.';
|
|
264
|
+
default:
|
|
265
|
+
return msg;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -180,11 +180,19 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
|
|
|
180
180
|
|------|------|-----------|
|
|
181
181
|
| `--global` | flag | Verifica a skill no escopo do usuário (padrão: projeto). |
|
|
182
182
|
| `--skip-skill` / `--skip-config` / `--skip-repo` | flag | Pula a categoria correspondente. |
|
|
183
|
+
| `--branch [nome]` | flag/string | Envia os arquivos do repo como **Pull Request** numa branch, em **um único commit**, em vez de commitar direto na branch default. Sem valor, usa `spec-wave/update-v<versão>`. |
|
|
184
|
+
| `--config-in-pr` / `--no-config-in-pr` | flag | Força incluir/excluir o `.spec-wave.json` do PR (o default decide sozinho — veja abaixo). |
|
|
183
185
|
| `--dry-run` | flag | Mostra o que seria atualizado sem alterar nada. |
|
|
184
186
|
| `--yes` | flag | Aplica sem pedir confirmação. |
|
|
185
187
|
|
|
186
188
|
> Detecta e atualiza **somente o que divergiu** da versão atual da CLI: a **skill** instalada (por agente), o **`.spec-wave.json`** local (se versão/formato divergir) e os **workflows/labels** do repo (compara com os templates empacotados). Interativo por padrão (mostra o plano e confirma). É o atalho recomendado após atualizar a CLI.
|
|
187
189
|
|
|
190
|
+
> **`--branch` (modo Pull Request).** Sem a flag, os arquivos do repo vão em **commits diretos na branch default** — o que falha no meio da execução em repositório com proteção de branch, deixando parte aplicada, e contorna a revisão. Com a flag, todos os arquivos vão em **um commit atômico** numa branch nova e um PR é aberto: se algo falhar antes da criação da branch, **nada** é alterado no repositório. É **idempotente** — rodar duas vezes não gera um segundo commit nem um segundo PR.
|
|
191
|
+
>
|
|
192
|
+
> Duas coisas **não** entram no PR, porque não são versionáveis: as **labels** (metadado do repositório — já valem na base, com ou sem merge) e a **skill** (arquivo em máquina local). O corpo do PR diz isso explicitamente a quem revisa.
|
|
193
|
+
>
|
|
194
|
+
> ⚠️ O modo PR exige **`pull_requests: write`** além de `contents: write` (ou o escopo `repo` num PAT classic). Sem essa permissão, a branch é criada e o PR falha — a mensagem oferece a URL de `compare` para abrir à mão.
|
|
195
|
+
|
|
188
196
|
### `@spec-wave/cli generate-plan` · `generate-spec` · `validate` · `decompose`
|
|
189
197
|
| Flag | Tipo | Descrição |
|
|
190
198
|
|------|------|-----------|
|
|
@@ -417,12 +425,16 @@ Traz tudo para a versão atual da CLI, atualizando **só o que mudou**: a skill
|
|
|
417
425
|
npx @spec-wave/cli@latest update --dry-run
|
|
418
426
|
```
|
|
419
427
|
2. Mostre ao usuário o resumo (skill / config / arquivos do repo / labels que divergiram). Se **nada** estiver desatualizado, informe que já está tudo na versão atual e encerre.
|
|
420
|
-
3.
|
|
428
|
+
3. **Pergunte como os arquivos do repo devem sair** — e prefira o Pull Request:
|
|
421
429
|
```bash
|
|
422
|
-
npx @spec-wave/cli@latest update --yes
|
|
430
|
+
npx @spec-wave/cli@latest update --yes --branch # 1 commit atômico + PR (recomendado)
|
|
431
|
+
npx @spec-wave/cli@latest update --yes # commits diretos na branch default
|
|
423
432
|
```
|
|
424
433
|
- Escopos podem ser limitados com `--skip-skill`, `--skip-config`, `--skip-repo`.
|
|
425
|
-
-
|
|
434
|
+
- **Com `--branch`:** os arquivos do repo vão em **um único commit** numa branch nova e um PR é aberto — nada é escrito na branch default. Passe o link do PR ao usuário e lembre que o merge é dele. Exige `pull_requests: write` no token.
|
|
435
|
+
- **Sem `--branch`:** cada arquivo é um commit direto na branch default. Em repositório com **proteção de branch** isso falha no meio e deixa parte aplicada — nesses casos use `--branch`.
|
|
436
|
+
- **Nos dois modos**, as **labels** são aplicadas direto na base (metadado do repositório, não versionável) e a **skill** é gravada em máquina local. Nenhuma das duas entra no PR.
|
|
437
|
+
- **`.spec-wave.json`:** com `--branch`, ele entra no PR se o repositório **já o versiona**; se não versiona, segue apenas local e o usuário precisa commitá-lo (ou use `--config-in-pr` para passar a versioná-lo). Sem `--branch`, é sempre só local.
|
|
426
438
|
4. Se a skill foi atualizada, oriente recarregar/reiniciar o agente para pegar a nova versão.
|
|
427
439
|
|
|
428
440
|
---
|