@spec-wave/cli 0.18.1 → 0.20.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 +34 -3
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +10 -1
- package/src/agent/errors.mjs +57 -3
- package/src/agent/openrouter-agent.mjs +12 -2
- package/src/api/auth.mjs +217 -9
- package/src/api/github-graphql.mjs +33 -0
- package/src/commands/code-review.mjs +186 -31
- package/src/commands/decompose.mjs +199 -14
- package/src/commands/doctor.mjs +195 -35
- package/src/commands/generate-bug.mjs +18 -15
- package/src/commands/generate-plan.mjs +93 -5
- package/src/commands/generate-spec.mjs +7 -4
- package/src/commands/repair-stage.mjs +232 -0
- package/src/commands/update.mjs +13 -5
- package/src/config.mjs +43 -0
- package/src/lib/board.mjs +44 -0
- package/src/lib/claude.mjs +59 -9
- package/src/lib/critique.mjs +186 -14
- package/src/lib/decomposition-doc.mjs +59 -7
- package/src/lib/dependencies.mjs +49 -0
- package/src/lib/flow-run.mjs +134 -5
- package/src/lib/prompt-loader.mjs +30 -3
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/move/SKILL.md +29 -0
- package/src/plugin/skills/plan/SKILL.md +13 -0
- package/src/plugin/skills/spec/model-prompt.critique.md +58 -0
- package/src/setup/labels.mjs +10 -6
- package/src/templates/workflows/code-review.yml +34 -1
- package/src/templates/workflows/decompose.yml +12 -1
- package/src/templates/workflows/generate-bug.yml +8 -1
- package/src/templates/workflows/generate-plan.yml +16 -1
- package/src/templates/workflows/generate-spec.yml +16 -1
package/bin/spec-wave.mjs
CHANGED
|
@@ -11,7 +11,19 @@ const pkg = JSON.parse(readFileSync(path.join(__dir, '..', 'package.json'), 'utf
|
|
|
11
11
|
program
|
|
12
12
|
.name('spec-wave')
|
|
13
13
|
.description('Setup spec-driven GitHub workflow with Projects v2')
|
|
14
|
-
.version(pkg.version)
|
|
14
|
+
.version(pkg.version)
|
|
15
|
+
// Conta do `gh` a usar nesta execução. Existe porque um GH_TOKEN exportado no
|
|
16
|
+
// shell (um `.envrc` na raiz de um diretório de projetos, por exemplo) vale
|
|
17
|
+
// para repositórios de QUALQUER org abaixo dele — e o GraphQL do GitHub
|
|
18
|
+
// mascara o 403 resultante como "Could not resolve to a Repository".
|
|
19
|
+
.option('--account <login>', 'Conta do gh a usar (vence GITHUB_TOKEN/GH_TOKEN fora do CI)')
|
|
20
|
+
.hook('preAction', async (thisCommand) => {
|
|
21
|
+
const { account } = thisCommand.opts();
|
|
22
|
+
if (account) {
|
|
23
|
+
const { setAccountOverride } = await import('../src/api/auth.mjs');
|
|
24
|
+
setAccountOverride(account);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
15
27
|
|
|
16
28
|
program
|
|
17
29
|
.command('init')
|
|
@@ -172,8 +184,11 @@ program
|
|
|
172
184
|
|
|
173
185
|
program
|
|
174
186
|
.command('critique')
|
|
175
|
-
.description('
|
|
176
|
-
.
|
|
187
|
+
.description('Critica um documento COMO ESTÁ, sem regerar — para depois de corrigi-lo à mão')
|
|
188
|
+
.option('--issue-number <n>', 'Número da issue: critica o plan.md dela e comenta (fluxo canônico)')
|
|
189
|
+
.option('--file <caminho>', 'Critica ESTE arquivo e imprime o resultado — sem issue, sem label, sem contar tentativa')
|
|
190
|
+
.option('--kind <tipo>', 'plan | spec | stories | bug (default: inferido do nome do arquivo)')
|
|
191
|
+
.option('--fail-on-grave', 'Sai com código 1 se houver finding grave (útil em script)')
|
|
177
192
|
.action(async (options) => {
|
|
178
193
|
const { critique } = await import('../src/commands/generate-plan.mjs');
|
|
179
194
|
await critique(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
@@ -220,6 +235,7 @@ program
|
|
|
220
235
|
.command('code-review')
|
|
221
236
|
.description('Move Feature para Code Review ao abrir um PR (usado pelo GitHub Action)')
|
|
222
237
|
.requiredOption('--pr-number <n>', 'Número do Pull Request')
|
|
238
|
+
.option('--resolve-only', 'Só descobre a Feature-alvo e imprime feature=<n>, sem tocar no board')
|
|
223
239
|
.action(async (options) => {
|
|
224
240
|
const { codeReview } = await import('../src/commands/code-review.mjs');
|
|
225
241
|
await codeReview(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
@@ -276,6 +292,21 @@ program
|
|
|
276
292
|
.catch(err => { console.error(err.message); process.exit(1); });
|
|
277
293
|
});
|
|
278
294
|
|
|
295
|
+
program
|
|
296
|
+
.command('repair-stage')
|
|
297
|
+
.description('Corrige a Etapa de itens que a automação errou — inclusive retrocedendo. Exige --yes e --reason, e registra o reparo na issue')
|
|
298
|
+
.argument('<issues>', 'Número(s) da(s) issue(s), ex.: 529 ou 529,530,531')
|
|
299
|
+
.argument('<etapa>', 'Etapa correta, com ou sem emoji, ex.: "ready", "✅ Ready"')
|
|
300
|
+
.option('--reason <motivo>', 'Por que o reparo é necessário (vai para o comentário de auditoria)')
|
|
301
|
+
.option('--status <valor>', 'Também corrige o Status: Todo, In Progress ou Done (default: não mexe)')
|
|
302
|
+
.option('--yes', 'Confirma o reparo (obrigatório)')
|
|
303
|
+
.option('--dry-run', 'Mostra o que seria reparado sem alterar nada')
|
|
304
|
+
.action(async (issues, etapa, options) => {
|
|
305
|
+
const { repairStage } = await import('../src/commands/repair-stage.mjs');
|
|
306
|
+
await repairStage(issues, etapa, options)
|
|
307
|
+
.catch(err => { console.error(err.message); process.exit(1); });
|
|
308
|
+
});
|
|
309
|
+
|
|
279
310
|
program
|
|
280
311
|
.command('story')
|
|
281
312
|
.description('Gerencia uma Story no board: review (move para Code Review)')
|
package/package.json
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
|
|
27
27
|
import { ENV_FILE_PATTERN } from './tools.mjs';
|
|
28
|
+
import { summarizeToolCalls } from './errors.mjs';
|
|
28
29
|
import { TruncatedOutputError } from './errors.mjs';
|
|
29
30
|
|
|
30
31
|
const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
|
|
@@ -68,6 +69,9 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
68
69
|
numTurns: 0,
|
|
69
70
|
structured: null,
|
|
70
71
|
usage: { inputTokens: 0, outputTokens: 0 },
|
|
72
|
+
// Simétrico ao backend openrouter: sem isto, um run que estoura o teto de
|
|
73
|
+
// turnos não deixa registro do que o modelo esteve fazendo.
|
|
74
|
+
toolCalls: [],
|
|
71
75
|
};
|
|
72
76
|
const quiet = options.quiet !== false;
|
|
73
77
|
|
|
@@ -154,6 +158,7 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
154
158
|
};
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
runResult.toolCalls.push(input.tool_name);
|
|
157
162
|
if (options.verbose) {
|
|
158
163
|
console.log(`\n[Tool] ${input.tool_name}(${JSON.stringify(input.tool_input)})`);
|
|
159
164
|
}
|
|
@@ -288,7 +293,10 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
288
293
|
}
|
|
289
294
|
} else {
|
|
290
295
|
output = `[${message.subtype}]`;
|
|
291
|
-
console.warn(
|
|
296
|
+
console.warn(
|
|
297
|
+
`\n[Agent] Run ended without success: ${message.subtype} — ` +
|
|
298
|
+
`ferramentas chamadas: ${summarizeToolCalls(runResult.toolCalls) || 'nenhuma'}`
|
|
299
|
+
);
|
|
292
300
|
}
|
|
293
301
|
updateSpan(span, {
|
|
294
302
|
output,
|
|
@@ -328,6 +336,7 @@ export async function runAnthropicAgent(prompt, options) {
|
|
|
328
336
|
maxTokens: options.maxTokens ?? null,
|
|
329
337
|
reason: lastStopReason,
|
|
330
338
|
chars: runResult.outputText.length,
|
|
339
|
+
action: options.action ?? null,
|
|
331
340
|
});
|
|
332
341
|
}
|
|
333
342
|
},
|
package/src/agent/errors.mjs
CHANGED
|
@@ -19,15 +19,69 @@ export function isTruncationReason(reason) {
|
|
|
19
19
|
// custo. Por isso NÃO é marcado como transitório: o erro sobe, o Action falha
|
|
20
20
|
// visível, destrava a label e comenta na issue o que ajustar.
|
|
21
21
|
export class TruncatedOutputError extends Error {
|
|
22
|
-
constructor({ provider, model, maxTokens, reason, chars }) {
|
|
22
|
+
constructor({ provider, model, maxTokens, reason, chars, action }) {
|
|
23
|
+
// O remédio depende da AÇÃO: dizer "aumente ai.maxTokens" a quem viu a
|
|
24
|
+
// crítica cortar manda mexer no teto errado se houver
|
|
25
|
+
// `maxTokensByAction.critique` configurado — e é o teto por ação que está
|
|
26
|
+
// apertando. Nomear a chave exata poupa uma rodada de tentativa e erro.
|
|
27
|
+
const knob = action
|
|
28
|
+
? `\`ai.maxTokensByAction.${action}\` (ou \`ai.maxTokens\`)`
|
|
29
|
+
: '`ai.maxTokens` (ou `ai.maxTokensByAction`)';
|
|
30
|
+
const extra = action === 'critique'
|
|
31
|
+
? ' Em modelo que raciocina por padrão, o raciocínio consome o teto antes de a resposta sair — ' +
|
|
32
|
+
'a crítica não conclui e a rodada se perde.'
|
|
33
|
+
: '';
|
|
23
34
|
super(
|
|
24
35
|
`Saída truncada pelo teto de tokens (${provider} · ${model} · max_tokens=${maxTokens} · ` +
|
|
25
|
-
`motivo=${reason}
|
|
26
|
-
|
|
36
|
+
`motivo=${reason}${action ? ` · ação=${action}` : ''}). ` +
|
|
37
|
+
`Foram gerados ~${chars} caracteres antes do corte.${extra} ` +
|
|
38
|
+
`Aumente ${knob} no .spec-wave.json, ou reduza o ` +
|
|
27
39
|
'tamanho da issue de origem. O documento NÃO foi gravado — um documento cortado ' +
|
|
28
40
|
'passaria na validação de seções e valeria menos que nenhum.'
|
|
29
41
|
);
|
|
30
42
|
this.name = 'TruncatedOutputError';
|
|
31
43
|
this.truncated = true;
|
|
44
|
+
this.action = action || null;
|
|
32
45
|
}
|
|
33
46
|
}
|
|
47
|
+
|
|
48
|
+
// Teto de turnos esgotado: o modelo respondeu com tool calls em TODOS os turnos
|
|
49
|
+
// e nunca produziu o documento. Como o truncamento acima, NÃO é transitório —
|
|
50
|
+
// repetir a mesma requisição dá a mesma perambulação, só que mais cara.
|
|
51
|
+
//
|
|
52
|
+
// Marcá-lo como transitório custou 55 minutos de Action num caso real: três
|
|
53
|
+
// tentativas de 25 turnos (4min → 19min → 32min) para a mesma falha
|
|
54
|
+
// determinística, que devia ter aparecido na primeira.
|
|
55
|
+
export class MaxTurnsError extends Error {
|
|
56
|
+
constructor({ provider, model, turns, action, toolCalls = [] }) {
|
|
57
|
+
// O que o modelo ficou fazendo é a informação que decide o remédio: muitas
|
|
58
|
+
// leituras distintas sugerem teto baixo; a mesma chamada repetida é loop
|
|
59
|
+
// degenerado, e aí subir o teto só encarece.
|
|
60
|
+
const resumo = toolCalls.length > 0
|
|
61
|
+
? ` Ferramentas mais chamadas: ${summarizeToolCalls(toolCalls)}.`
|
|
62
|
+
: '';
|
|
63
|
+
super(
|
|
64
|
+
`O modelo esgotou o teto de ${turns} turnos sem produzir o documento ` +
|
|
65
|
+
`(${provider} · ${model}${action ? ` · ação=${action}` : ''}): respondeu com chamadas de ` +
|
|
66
|
+
`ferramenta em todos eles.${resumo} ` +
|
|
67
|
+
'Repetir não ajuda — o erro é determinístico. Ou o modelo não fecha o loop de ' +
|
|
68
|
+
'ferramentas nesta tarefa (troque-o, por `ai.models` ou pela label ' +
|
|
69
|
+
'`spec-wave:model:<apelido>`), ou a exploração precisa de mais turnos.'
|
|
70
|
+
);
|
|
71
|
+
this.name = 'MaxTurnsError';
|
|
72
|
+
this.maxTurns = true;
|
|
73
|
+
this.turns = turns;
|
|
74
|
+
this.action = action || null;
|
|
75
|
+
this.toolCalls = toolCalls;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Contagem por nome de ferramenta, da mais chamada para a menos (função PURA). */
|
|
80
|
+
export function summarizeToolCalls(toolCalls) {
|
|
81
|
+
const counts = new Map();
|
|
82
|
+
for (const name of toolCalls) counts.set(name, (counts.get(name) || 0) + 1);
|
|
83
|
+
return [...counts.entries()]
|
|
84
|
+
.sort((a, b) => b[1] - a[1])
|
|
85
|
+
.map(([name, n]) => `${name}×${n}`)
|
|
86
|
+
.join(', ');
|
|
87
|
+
}
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
toolDefinitionsFor,
|
|
23
23
|
} from './tools.mjs';
|
|
24
24
|
import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
|
|
25
|
-
import { TruncatedOutputError, isTruncationReason } from './errors.mjs';
|
|
25
|
+
import { TruncatedOutputError, isTruncationReason, summarizeToolCalls } from './errors.mjs';
|
|
26
26
|
|
|
27
27
|
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
28
28
|
const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
|
|
@@ -179,6 +179,11 @@ export async function runOpenRouterAgent(prompt, options) {
|
|
|
179
179
|
numTurns: 0,
|
|
180
180
|
structured: null,
|
|
181
181
|
usage: { inputTokens: 0, outputTokens: 0 },
|
|
182
|
+
// Nomes das ferramentas chamadas, na ordem. Só o `verbose` imprimia as tool
|
|
183
|
+
// calls, e ele fica DESLIGADO no Action — quando um modelo queimou os 25
|
|
184
|
+
// turnos sem escrever nada, o log não dizia no que ele se perdeu, e o
|
|
185
|
+
// diagnóstico exigiu reconstruir o run à mão.
|
|
186
|
+
toolCalls: [],
|
|
182
187
|
};
|
|
183
188
|
const quiet = options.quiet !== false;
|
|
184
189
|
|
|
@@ -279,6 +284,7 @@ export async function runOpenRouterAgent(prompt, options) {
|
|
|
279
284
|
// corte, que é o que distingue teto nosso de teto do provider.
|
|
280
285
|
reason: `${choice.finish_reason}${choice.native_finish_reason ? `/${choice.native_finish_reason}` : ''}`,
|
|
281
286
|
chars: (runResult.outputText + text).length,
|
|
287
|
+
action: options.action ?? null,
|
|
282
288
|
});
|
|
283
289
|
}
|
|
284
290
|
|
|
@@ -326,6 +332,7 @@ export async function runOpenRouterAgent(prompt, options) {
|
|
|
326
332
|
parseError = `Could not parse tool arguments as JSON: ${errorMessage(err)}`;
|
|
327
333
|
}
|
|
328
334
|
|
|
335
|
+
runResult.toolCalls.push(call.function.name);
|
|
329
336
|
if (options.verbose) console.log(`\n[Tool] ${call.function.name}(${call.function.arguments})`);
|
|
330
337
|
|
|
331
338
|
const observation = startObservation(
|
|
@@ -351,7 +358,10 @@ export async function runOpenRouterAgent(prompt, options) {
|
|
|
351
358
|
|
|
352
359
|
if (runResult.resultSubtype === null) {
|
|
353
360
|
runResult.resultSubtype = 'error_max_turns';
|
|
354
|
-
console.warn(
|
|
361
|
+
console.warn(
|
|
362
|
+
`\n[Agent] Run ended without success: error_max_turns (${maxTurns} turns) — ` +
|
|
363
|
+
`ferramentas chamadas: ${summarizeToolCalls(runResult.toolCalls) || 'nenhuma'}`
|
|
364
|
+
);
|
|
355
365
|
}
|
|
356
366
|
|
|
357
367
|
updateSpan(span, {
|
package/src/api/auth.mjs
CHANGED
|
@@ -1,22 +1,230 @@
|
|
|
1
|
+
// Resolução do token do GitHub.
|
|
2
|
+
//
|
|
3
|
+
// A precedência é env-first porque o CI depende disso: no GitHub Actions o
|
|
4
|
+
// `GITHUB_TOKEN` é injetado e não existe `gh` para consultar. Mas fora do CI a
|
|
5
|
+
// mesma regra já causou estrago: um `export GH_TOKEN=$(gh auth token -u fulano)`
|
|
6
|
+
// num `.envrc` vale para TODOS os repositórios abaixo daquele diretório,
|
|
7
|
+
// inclusive os de outra org. O token responde no REST (ele existe e tem escopo
|
|
8
|
+
// `repo`), e o GraphQL devolve "Could not resolve to a Repository" — que é como
|
|
9
|
+
// o GitHub mascara 403. O diagnóstico fica caríssimo para o sintoma.
|
|
10
|
+
//
|
|
11
|
+
// A saída não é inverter a precedência (quebraria o CI), e sim deixar a conta
|
|
12
|
+
// ser DECLARADA: `--account`, `SPEC_WAVE_GH_ACCOUNT` ou `github.account` no
|
|
13
|
+
// .spec-wave.json. Havendo conta declarada, ela vence o env fora do CI — o
|
|
14
|
+
// repositório passa a dizer de quem é a conta, em vez de depender do ambiente.
|
|
1
15
|
import { execSync } from 'node:child_process';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { findConfigPath } from '../lib/project-root.mjs';
|
|
2
18
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
19
|
+
// Conta escolhida via `spec-wave --account <login>`. O bin grava aqui antes de
|
|
20
|
+
// despachar o comando; passar por parâmetro exigiria mudar toda a cadeia de
|
|
21
|
+
// chamadas por um caso que é global à execução.
|
|
22
|
+
let accountOverride = null;
|
|
23
|
+
|
|
24
|
+
/** Define a conta do `gh` a usar nesta execução (flag global `--account`). */
|
|
25
|
+
export function setAccountOverride(login) {
|
|
26
|
+
accountOverride = typeof login === 'string' && login.trim() ? login.trim() : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function gh(args) {
|
|
30
|
+
return execSync(`gh ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Conta declarada, na ordem: flag → env → .spec-wave.json.
|
|
34
|
+
function declaredAccount(cwd = process.cwd()) {
|
|
35
|
+
if (accountOverride) return { login: accountOverride, source: '--account' };
|
|
36
|
+
const fromEnv = (process.env.SPEC_WAVE_GH_ACCOUNT || '').trim();
|
|
37
|
+
if (fromEnv) return { login: fromEnv, source: 'SPEC_WAVE_GH_ACCOUNT' };
|
|
38
|
+
try {
|
|
39
|
+
const configPath = findConfigPath(cwd);
|
|
40
|
+
if (configPath) {
|
|
41
|
+
const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
42
|
+
const login = (cfg?.github?.account || '').trim();
|
|
43
|
+
if (login) return { login, source: 'github.account' };
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// config ausente ou ilegível — segue sem conta declarada
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Conta ativa do `gh` (função quase pura — só lê `gh auth status`).
|
|
53
|
+
*
|
|
54
|
+
* @returns {string|null} login, ou null se o gh não estiver disponível/logado
|
|
55
|
+
*/
|
|
56
|
+
export function activeGhAccount() {
|
|
57
|
+
try {
|
|
58
|
+
return parseActiveAccount(gh('auth status'));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Conta ativa a partir da saída de `gh auth status` (função PURA).
|
|
66
|
+
*
|
|
67
|
+
* Formato: "✓ Logged in to github.com account <login> (...)" seguido de
|
|
68
|
+
* "- Active account: true". Com VÁRIAS contas, a primeira listada não é
|
|
69
|
+
* necessariamente a ativa — pegar a primeira é o erro que faria este módulo
|
|
70
|
+
* avisar sobre a conta errada, que é pior que não avisar.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} out saída de `gh auth status`
|
|
73
|
+
* @returns {string|null}
|
|
74
|
+
*/
|
|
75
|
+
export function parseActiveAccount(out) {
|
|
76
|
+
let active = null;
|
|
77
|
+
let last = null;
|
|
78
|
+
for (const line of String(out || '').split('\n')) {
|
|
79
|
+
const m = line.match(/account\s+(\S+)/);
|
|
80
|
+
if (m) {
|
|
81
|
+
last = m[1];
|
|
82
|
+
if (!active) active = last; // versões antigas não têm "Active account"
|
|
83
|
+
}
|
|
84
|
+
if (/Active account:\s*true/i.test(line) && last) active = last;
|
|
85
|
+
}
|
|
86
|
+
return active;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decide a ORIGEM do token (função PURA — testável sem env nem gh).
|
|
91
|
+
*
|
|
92
|
+
* @param {object} params
|
|
93
|
+
* @param {object} params.env objeto tipo process.env
|
|
94
|
+
* @param {{login:string,source:string}|null} params.account conta declarada
|
|
95
|
+
* @returns {{ kind: 'env'|'gh-account'|'gh-active', name?: string, login?: string, source?: string }}
|
|
96
|
+
*/
|
|
97
|
+
export function resolveTokenSource({ env = {}, account = null } = {}) {
|
|
98
|
+
// Em CI o ambiente é a autoridade: é ele que injeta o GITHUB_TOKEN do run, e
|
|
99
|
+
// não há `gh` instalado para consultar.
|
|
100
|
+
const inCi = Boolean(env.GITHUB_ACTIONS || env.CI);
|
|
101
|
+
if (inCi) {
|
|
102
|
+
if (env.GITHUB_TOKEN) return { kind: 'env', name: 'GITHUB_TOKEN' };
|
|
103
|
+
if (env.GH_TOKEN) return { kind: 'env', name: 'GH_TOKEN' };
|
|
104
|
+
return { kind: 'gh-active' };
|
|
105
|
+
}
|
|
106
|
+
// Fora do CI, conta declarada vence a variável de ambiente — é a declaração
|
|
107
|
+
// explícita ganhando de um default herdado do shell.
|
|
108
|
+
if (account?.login) return { kind: 'gh-account', login: account.login, source: account.source };
|
|
109
|
+
if (env.GITHUB_TOKEN) return { kind: 'env', name: 'GITHUB_TOKEN' };
|
|
110
|
+
if (env.GH_TOKEN) return { kind: 'env', name: 'GH_TOKEN' };
|
|
111
|
+
return { kind: 'gh-active' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Aviso de divergência entre a conta ativa do `gh` e a do token (função PURA).
|
|
116
|
+
*
|
|
117
|
+
* A comparação é entre os TOKENS, não entre logins: descobrir o login de um
|
|
118
|
+
* token exige uma chamada à API, e um aviso que custa rede em todo comando não
|
|
119
|
+
* se paga. O token da conta ativa vem de `gh auth token` (execução local), e se
|
|
120
|
+
* for igual ao do ambiente não há divergência nenhuma — é o caso comum de quem
|
|
121
|
+
* exportou a variável a partir da própria conta ativa.
|
|
122
|
+
*
|
|
123
|
+
* Sem os dois tokens para comparar, NÃO avisa: um aviso que dispara sempre vira
|
|
124
|
+
* ruído, e ruído em aviso de diagnóstico é pior que silêncio.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} params
|
|
127
|
+
* @param {{kind:string,name?:string}} params.source origem do token em uso
|
|
128
|
+
* @param {string} [params.tokenValue] token que será usado
|
|
129
|
+
* @param {string} [params.activeToken] token da conta ativa do gh
|
|
130
|
+
* @param {string} [params.activeLogin] login da conta ativa do gh
|
|
131
|
+
* @returns {string|null} a linha de aviso, ou null quando não há o que dizer
|
|
132
|
+
*/
|
|
133
|
+
export function accountMismatchWarning({
|
|
134
|
+
source, tokenValue, activeToken, activeLogin,
|
|
135
|
+
} = {}) {
|
|
136
|
+
if (source?.kind !== 'env') return null;
|
|
137
|
+
if (!activeLogin || !activeToken || !tokenValue) return null;
|
|
138
|
+
if (activeToken === tokenValue) return null; // mesma credencial — nada a dizer
|
|
139
|
+
return (
|
|
140
|
+
`⚠️ Usando o token da variável ${source.name}, que NÃO é o da conta ativa do gh (${activeLogin}).\n` +
|
|
141
|
+
` Se o comando falhar com "Could not resolve to a Repository", é isto: o token não enxerga o repo.\n` +
|
|
142
|
+
` Corrija com \`--account ${activeLogin}\` ou grave \`"github": { "account": "${activeLogin}" }\` no .spec-wave.json.`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Token da conta ativa do gh — usado só para comparar com o do ambiente.
|
|
147
|
+
// Execução local, sem rede; sem gh, devolve null e o aviso não sai.
|
|
148
|
+
function activeGhToken() {
|
|
6
149
|
try {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
150
|
+
return gh('auth token') || null;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Token do GitHub para esta execução.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} [opts]
|
|
160
|
+
* @param {boolean} [opts.quiet=false] não imprime o aviso de divergência
|
|
161
|
+
* @returns {Promise<string>}
|
|
162
|
+
*/
|
|
163
|
+
export async function resolveToken({ quiet = false } = {}) {
|
|
164
|
+
const source = resolveTokenSource({ env: process.env, account: declaredAccount() });
|
|
165
|
+
|
|
166
|
+
if (source.kind === 'env') {
|
|
167
|
+
const token = process.env[source.name];
|
|
168
|
+
// O aviso custa duas execuções locais do gh (sem rede). Só fora do CI.
|
|
169
|
+
if (!quiet && !process.env.GITHUB_ACTIONS && !process.env.CI) {
|
|
170
|
+
const warning = accountMismatchWarning({
|
|
171
|
+
source,
|
|
172
|
+
tokenValue: token,
|
|
173
|
+
activeToken: activeGhToken(),
|
|
174
|
+
activeLogin: activeGhAccount(),
|
|
175
|
+
});
|
|
176
|
+
if (warning) console.warn(warning);
|
|
177
|
+
}
|
|
178
|
+
return token;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (source.kind === 'gh-account') {
|
|
182
|
+
try {
|
|
183
|
+
const token = gh(`auth token -u ${source.login}`);
|
|
184
|
+
if (token) return token;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Não consegui obter o token da conta "${source.login}" (declarada em ${source.source}).\n` +
|
|
188
|
+
`Rode \`gh auth login -u ${source.login}\` ou ajuste a conta declarada.\n` +
|
|
189
|
+
`Detalhe: ${err.message.split('\n')[0]}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`\`gh auth token -u ${source.login}\` não devolveu token.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const token = gh('auth token');
|
|
10
197
|
if (token) return token;
|
|
11
198
|
} catch {
|
|
12
|
-
// gh
|
|
199
|
+
// gh não instalado ou sem login
|
|
13
200
|
}
|
|
14
201
|
throw new Error(
|
|
15
|
-
'GitHub
|
|
16
|
-
'
|
|
202
|
+
'Token do GitHub não encontrado.\n' +
|
|
203
|
+
'Defina GITHUB_TOKEN/GH_TOKEN, rode `gh auth login`, ou declare a conta com ' +
|
|
204
|
+
'`--account <login>` (ou "github": { "account": "<login>" } no .spec-wave.json).'
|
|
17
205
|
);
|
|
18
206
|
}
|
|
19
207
|
|
|
208
|
+
/** De onde o token desta execução viria — usado pelo `doctor` no relatório. */
|
|
209
|
+
export function tokenMismatchWarning() {
|
|
210
|
+
if (process.env.GITHUB_ACTIONS || process.env.CI) return null;
|
|
211
|
+
const source = resolveTokenSource({ env: process.env, account: declaredAccount() });
|
|
212
|
+
if (source.kind !== 'env') return null;
|
|
213
|
+
return accountMismatchWarning({
|
|
214
|
+
source,
|
|
215
|
+
tokenValue: process.env[source.name],
|
|
216
|
+
activeToken: activeGhToken(),
|
|
217
|
+
activeLogin: activeGhAccount(),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function describeTokenSource(cwd = process.cwd()) {
|
|
222
|
+
const source = resolveTokenSource({ env: process.env, account: declaredAccount(cwd) });
|
|
223
|
+
if (source.kind === 'env') return `variável ${source.name}`;
|
|
224
|
+
if (source.kind === 'gh-account') return `gh auth token -u ${source.login} (${source.source})`;
|
|
225
|
+
return 'gh auth token (conta ativa)';
|
|
226
|
+
}
|
|
227
|
+
|
|
20
228
|
export async function verifyTokenScopes(token) {
|
|
21
229
|
const { Octokit } = await import('@octokit/rest');
|
|
22
230
|
const octokit = new Octokit({ auth: token });
|
|
@@ -220,6 +220,9 @@ export async function listSubIssues(token, issueNodeId) {
|
|
|
220
220
|
number
|
|
221
221
|
title
|
|
222
222
|
body
|
|
223
|
+
createdAt
|
|
224
|
+
state
|
|
225
|
+
stateReason
|
|
223
226
|
labels(first: 20) { nodes { name } }
|
|
224
227
|
}
|
|
225
228
|
}
|
|
@@ -233,10 +236,40 @@ export async function listSubIssues(token, issueNodeId) {
|
|
|
233
236
|
title: n.title,
|
|
234
237
|
body: n.body || '',
|
|
235
238
|
nodeId: n.id,
|
|
239
|
+
// `createdAt` é o que permite ao code-review ignorar sub-issues nascidas
|
|
240
|
+
// depois da abertura do PR — ver partitionByCreation em code-review.mjs.
|
|
241
|
+
createdAt: n.createdAt || null,
|
|
242
|
+
// OPEN/CLOSED e COMPLETED/NOT_PLANNED/REOPENED — o doctor usa o par para
|
|
243
|
+
// distinguir "entregue" de "descartada no rescopo".
|
|
244
|
+
state: (n.state || '').toLowerCase() || null,
|
|
245
|
+
stateReason: (n.stateReason || '').toLowerCase() || null,
|
|
236
246
|
labels: (n.labels?.nodes || []).map(l => l.name),
|
|
237
247
|
}));
|
|
238
248
|
}
|
|
239
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Issues que o GitHub reconhece como fechadas por este PR
|
|
252
|
+
* (`Closes #N` e o vínculo feito pela UI). É a MESMA lista que fecha as issues
|
|
253
|
+
* no merge — por isso é a fonte primária de vínculo do code-review, acima do
|
|
254
|
+
* parse do corpo.
|
|
255
|
+
*
|
|
256
|
+
* @returns {Promise<number[]>} números das issues vinculadas
|
|
257
|
+
*/
|
|
258
|
+
export async function getPRClosingIssues(token, owner, repo, prNumber) {
|
|
259
|
+
const client = makeClient(token);
|
|
260
|
+
const result = await client(`
|
|
261
|
+
query ClosingIssues($owner: String!, $repo: String!, $number: Int!) {
|
|
262
|
+
repository(owner: $owner, name: $repo) {
|
|
263
|
+
pullRequest(number: $number) {
|
|
264
|
+
closingIssuesReferences(first: 50) { nodes { number } }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
`, { owner, repo, number: prNumber });
|
|
269
|
+
const nodes = result.repository?.pullRequest?.closingIssuesReferences?.nodes || [];
|
|
270
|
+
return nodes.map(n => n.number).filter(Boolean);
|
|
271
|
+
}
|
|
272
|
+
|
|
240
273
|
// Lê o parent (issue pai) de uma sub-issue. Retorna { number, title, nodeId }
|
|
241
274
|
// ou null se a issue não tiver pai. Usado para subir a cadeia Task→Story→Feature.
|
|
242
275
|
export async function getIssueParent(token, issueNodeId) {
|