@spec-wave/cli 0.14.0 → 0.16.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/README.md +1 -0
- package/bin/spec-wave.mjs +44 -2
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-rest.mjs +206 -2
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +11 -49
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +6 -20
- package/src/commands/generate-spec.mjs +6 -22
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +145 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +352 -62
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/pr-branch.mjs +267 -0
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +111 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +53 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +37 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +154 -0
- package/src/templates/skill/SKILL.md +69 -7
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- package/src/ui/wizard.mjs +5 -2
package/src/commands/move.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { loadProjectConfig, resolveField, advanceToStage, resolveStageName } fro
|
|
|
20
20
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
21
21
|
import {
|
|
22
22
|
CONFIG_FILE, PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
23
|
-
isManualStageType, MANUAL_STAGE_TYPES,
|
|
23
|
+
isManualStageType, MANUAL_STAGE_TYPES, isStageInTrack, STAGE_TRACKS,
|
|
24
24
|
} from '../config.mjs';
|
|
25
25
|
|
|
26
26
|
const PROGRESS_VALUES = [PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE];
|
|
@@ -116,6 +116,16 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
|
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Aviso, não bloqueio: a trilha por tipo é convenção (RFC-004 §7.1); a regra
|
|
120
|
+
// dura continua sendo "a Etapa só avança". Um caso legítimo fora da trilha
|
|
121
|
+
// (um Bug que o time decidiu homologar) não deve exigir escape hatch.
|
|
122
|
+
if (type && !isStageInTrack(type, stage)) {
|
|
123
|
+
p.log.warn(
|
|
124
|
+
`${type} normalmente não passa por ${stage} — a trilha do tipo é: ` +
|
|
125
|
+
`${STAGE_TRACKS[type].join(' → ')}.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
120
130
|
if (projectError) {
|
|
121
131
|
p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
|
package/src/commands/qa.mjs
CHANGED
|
@@ -78,6 +78,27 @@ export async function qa({ prNumber }) {
|
|
|
78
78
|
const updated = [];
|
|
79
79
|
|
|
80
80
|
for (const num of issueNums) {
|
|
81
|
+
// Bug é unidade própria: vai para 🧪 QA sozinho, sem arrastar a Feature-pai
|
|
82
|
+
// (que pode ter Stories ainda em desenvolvimento).
|
|
83
|
+
const issue = await getIssue(token, owner, repo, num).catch(() => null);
|
|
84
|
+
if (issue && detectIssueType(issue) === 'Bug') {
|
|
85
|
+
if (seen.has(issue.number)) continue;
|
|
86
|
+
seen.add(issue.number);
|
|
87
|
+
try {
|
|
88
|
+
const moved = await advanceToStage(
|
|
89
|
+
projectToken, project, etapaField, statusField, issue.node_id, QA_STAGE, TODO_STATUS);
|
|
90
|
+
if (moved) {
|
|
91
|
+
updated.push(`#${issue.number} ${issue.title} (bug)`);
|
|
92
|
+
console.log(`Bug #${issue.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log(`Bug #${issue.number} já está em "${QA_STAGE}" ou etapa posterior — mantido.`);
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn(`Falha ao atualizar Bug #${issue.number}: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
const feature = await resolveFeatureIssue(token, owner, repo, num);
|
|
82
103
|
if (!feature || seen.has(feature.number)) continue;
|
|
83
104
|
seen.add(feature.number);
|
|
@@ -99,10 +120,10 @@ export async function qa({ prNumber }) {
|
|
|
99
120
|
await commentOnIssue(
|
|
100
121
|
token, owner, repo, parseInt(prNumber, 10),
|
|
101
122
|
`🧪 **PR aprovado — QA iniciado**\n\n` +
|
|
102
|
-
`
|
|
123
|
+
`Item(ns) movido(s) para **${QA_STAGE}**:\n\n` +
|
|
103
124
|
updated.map(f => `- ${f}`).join('\n')
|
|
104
125
|
).catch(() => {});
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
console.log(`qa: ${updated.length}
|
|
128
|
+
console.log(`qa: ${updated.length} item(ns) atualizado(s).`);
|
|
108
129
|
}
|
package/src/commands/refresh.mjs
CHANGED
|
@@ -4,20 +4,148 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
7
|
-
import { CONFIG_FILE } from '../config.mjs';
|
|
7
|
+
import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
|
|
8
8
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
9
|
-
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
9
|
+
import { getProjectSnapshot, updateStatusField } from '../api/github-graphql.mjs';
|
|
10
10
|
|
|
11
11
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Plano de sincronização das opções do campo "Etapa" (função PURA).
|
|
16
|
+
*
|
|
17
|
+
* A operação é DELIBERADAMENTE aditiva: `ordered` contém tudo que já existe no
|
|
18
|
+
* board mais as etapas canônicas que faltam, cada uma na posição canônica. Nada
|
|
19
|
+
* é removido — nem coluna inventada, nem etapa descontinuada.
|
|
20
|
+
*
|
|
21
|
+
* O motivo é o contrato da API: `updateProjectV2Field` SUBSTITUI o conjunto de
|
|
22
|
+
* opções do single-select, e a preservação do valor de cada item do board
|
|
23
|
+
* depende de a opção continuar existindo com o mesmo nome. Uma opção que some
|
|
24
|
+
* leva junto a Etapa de todo item que estava nela — silenciosamente. Remover
|
|
25
|
+
* coluna continua sendo trabalho manual, feito com os itens já esvaziados.
|
|
26
|
+
*
|
|
27
|
+
* @param {string[]} current nomes das opções hoje no board, na ordem atual
|
|
28
|
+
* @param {Array<{name: string, color: string}>} canonical STATUS_OPTIONS
|
|
29
|
+
* @returns {{ missing: string[], preserved: string[], ordered: Array<{name: string, color: string}> }}
|
|
30
|
+
*/
|
|
31
|
+
export function planStageSync(current, canonical) {
|
|
32
|
+
const have = new Set(current || []);
|
|
33
|
+
const canonicalNames = new Set(canonical.map(o => o.name));
|
|
34
|
+
|
|
35
|
+
const missing = canonical.filter(o => !have.has(o.name)).map(o => o.name);
|
|
36
|
+
// Colunas que o board tem e o fluxo canônico não conhece (inventadas ou
|
|
37
|
+
// descontinuadas). Vão para o fim, preservando a ordem relativa que tinham.
|
|
38
|
+
const preserved = (current || []).filter(name => !canonicalNames.has(name));
|
|
39
|
+
|
|
40
|
+
const ordered = [
|
|
41
|
+
...canonical.map(o => ({ name: o.name, color: o.color })),
|
|
42
|
+
...preserved.map(name => ({ name, color: 'GRAY' })),
|
|
43
|
+
];
|
|
44
|
+
return { missing, preserved, ordered };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Aplica o plano de `planStageSync` no campo "Etapa" do Project.
|
|
49
|
+
*
|
|
50
|
+
* Escreve só quando há etapa canônica faltando, e verifica o resultado relendo
|
|
51
|
+
* o Project: se alguma opção que existia antes sumiu, grita — é o único jeito
|
|
52
|
+
* de o usuário descobrir a tempo que itens perderam a Etapa.
|
|
53
|
+
*
|
|
54
|
+
* @returns {Promise<boolean>} true se o comando deve continuar
|
|
55
|
+
*/
|
|
56
|
+
async function syncStages(token, snapshot, options) {
|
|
57
|
+
const etapa = snapshot.fields?.['Etapa'];
|
|
58
|
+
if (!etapa?.id) {
|
|
59
|
+
p.log.error('Campo "Etapa" não encontrado no Project — nada a sincronizar.');
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const current = Object.keys(etapa.options || {});
|
|
65
|
+
const { missing, preserved, ordered } = planStageSync(current, STATUS_OPTIONS);
|
|
66
|
+
|
|
67
|
+
if (missing.length === 0) {
|
|
68
|
+
p.log.success(`Campo "Etapa" já tem as ${STATUS_OPTIONS.length} etapas canônicas.`);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
p.note(
|
|
73
|
+
`${chalk.green('+')} acrescentar: ${missing.join(', ')}\n` +
|
|
74
|
+
(preserved.length
|
|
75
|
+
? `${chalk.dim('=')} preservar (fora do fluxo canônico): ${preserved.join(', ')}\n`
|
|
76
|
+
: '') +
|
|
77
|
+
`${chalk.dim('=')} preservar (canônicas já presentes): ${current.length - preserved.length}\n\n` +
|
|
78
|
+
chalk.dim(`Resultado: ${ordered.length} opções. Nenhuma é removida.`),
|
|
79
|
+
'Plano para o campo "Etapa"'
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (options.dryRun) {
|
|
83
|
+
p.outro('Dry-run: nada foi enviado ao GitHub.');
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!options.yes) {
|
|
88
|
+
const go = await p.confirm({
|
|
89
|
+
message: 'Reescrever as opções do campo "Etapa" com esse conjunto?',
|
|
90
|
+
initialValue: false,
|
|
91
|
+
});
|
|
92
|
+
if (p.isCancel(go) || !go) {
|
|
93
|
+
p.outro('Cancelado — nada foi alterado.');
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const spinner = p.spinner();
|
|
99
|
+
spinner.start('Atualizando o campo "Etapa"...');
|
|
100
|
+
try {
|
|
101
|
+
await updateStatusField(token, etapa.id, ordered);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
spinner.stop('');
|
|
104
|
+
p.log.error(`Falha ao atualizar o campo "Etapa": ${err.message}`);
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Verificação pós-escrita: releitura, não confiança na mutação.
|
|
110
|
+
let after;
|
|
111
|
+
try {
|
|
112
|
+
after = await getProjectSnapshot(token, snapshot.id);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
spinner.stop('');
|
|
115
|
+
p.log.warn(`Campo atualizado, mas a verificação falhou: ${err.message}`);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
const now = Object.keys(after?.fields?.['Etapa']?.options || {});
|
|
119
|
+
const lost = current.filter(name => !now.includes(name));
|
|
120
|
+
const stillMissing = STATUS_OPTIONS.map(o => o.name).filter(name => !now.includes(name));
|
|
121
|
+
spinner.stop(`Campo "Etapa" com ${now.length} opções.`);
|
|
122
|
+
|
|
123
|
+
if (lost.length > 0) {
|
|
124
|
+
p.log.error(
|
|
125
|
+
`Opções que existiam SUMIRAM: ${lost.join(', ')}. Os itens que estavam nelas ficaram ` +
|
|
126
|
+
'sem Etapa — recrie a coluna com o mesmo nome e reposicione os itens.'
|
|
127
|
+
);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (stillMissing.length > 0) {
|
|
132
|
+
p.log.warn(`Etapas canônicas ainda ausentes: ${stillMissing.join(', ')}.`);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
p.log.success(`Etapas acrescentadas: ${missing.join(', ')}.`);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
14
139
|
// Re-consulta o GitHub Project e reescreve o .spec-wave.json local com os dados
|
|
15
140
|
// atuais (id/number/url/title do Project, IDs do campo Etapa e das opções, e a
|
|
16
141
|
// versão da CLI). Útil para repositórios inicializados antes do enriquecimento,
|
|
17
142
|
// ou quando o Project foi renomeado/teve campos alterados.
|
|
18
143
|
export async function refresh(options = {}) {
|
|
19
|
-
if (!options.config) {
|
|
20
|
-
p.log.error(
|
|
144
|
+
if (!options.config && !options.stages) {
|
|
145
|
+
p.log.error(
|
|
146
|
+
'Nada a fazer. Use `spec-wave refresh --config` para atualizar o .spec-wave.json, ' +
|
|
147
|
+
'ou `--stages` para acrescentar as colunas canônicas que faltam no campo "Etapa".'
|
|
148
|
+
);
|
|
21
149
|
process.exitCode = 1;
|
|
22
150
|
return;
|
|
23
151
|
}
|
|
@@ -57,7 +185,7 @@ export async function refresh(options = {}) {
|
|
|
57
185
|
return;
|
|
58
186
|
}
|
|
59
187
|
|
|
60
|
-
p.intro(chalk.bold(
|
|
188
|
+
p.intro(chalk.bold(`spec-wave refresh${options.stages ? ' --stages' : ' --config'}`));
|
|
61
189
|
|
|
62
190
|
const spinner = p.spinner();
|
|
63
191
|
spinner.start('Consultando o GitHub Project...');
|
|
@@ -78,6 +206,18 @@ export async function refresh(options = {}) {
|
|
|
78
206
|
}
|
|
79
207
|
spinner.stop('Project consultado.');
|
|
80
208
|
|
|
209
|
+
if (options.stages) {
|
|
210
|
+
const ok = await syncStages(token, snapshot, options);
|
|
211
|
+
if (!ok) return;
|
|
212
|
+
// Depois de mexer no board, o .spec-wave.json guarda ids de opção velhos —
|
|
213
|
+
// relê o Project para que o resto do comando grave o estado novo.
|
|
214
|
+
snapshot = await getProjectSnapshot(token, projectId);
|
|
215
|
+
if (!options.config) {
|
|
216
|
+
p.outro('Campo "Etapa" sincronizado. Rode `spec-wave refresh --config` para atualizar o .spec-wave.json.');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
81
221
|
// Remove campos legados (versões anteriores gravavam etapaFieldId/stageOptions soltos).
|
|
82
222
|
const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
|
|
83
223
|
const updated = {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Triagem de Bug pela CLI (RFC-004 §4.1) — o mesmo desfecho que a tela do PM
|
|
2
|
+
// oferece, para quem trabalha no terminal ou automatiza.
|
|
3
|
+
//
|
|
4
|
+
// Três saídas, e nenhuma delas é "editar": aceitar (→ ✅ Ready), rejeitar
|
|
5
|
+
// (fecha) e duplicar (fecha, apontando a original). Corrigir o relato é
|
|
6
|
+
// conversa na issue.
|
|
7
|
+
//
|
|
8
|
+
// Rejeitar e duplicar NÃO mexem na Etapa: ela nunca retrocede, e um bug
|
|
9
|
+
// rejeitado não avançou para lugar nenhum — quem o tira das filas é o estado
|
|
10
|
+
// `closed` da issue.
|
|
11
|
+
import * as p from '@clack/prompts';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
14
|
+
import {
|
|
15
|
+
getIssue, addLabel, removeLabel, commentOnIssue, setIssueState,
|
|
16
|
+
} from '../api/github-rest.mjs';
|
|
17
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
18
|
+
import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
|
|
19
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
20
|
+
import { resolveTriageAction, canAcceptBug } from '../lib/bug-triage.mjs';
|
|
21
|
+
import {
|
|
22
|
+
CONFIG_FILE, STAGE_READY, PROGRESS_TODO, PRIORITY_LABELS,
|
|
23
|
+
LABEL_TRIAGED, LABEL_DUPLICATE, LABEL_WONT_FIX, labelNames,
|
|
24
|
+
} from '../config.mjs';
|
|
25
|
+
|
|
26
|
+
const PRIORITIES = PRIORITY_LABELS.map(l => l.name);
|
|
27
|
+
|
|
28
|
+
export async function triage({ action: actionArg, issue: issueArg, reason, of, severity }) {
|
|
29
|
+
const { action, error: actionError } = resolveTriageAction(actionArg);
|
|
30
|
+
if (actionError) {
|
|
31
|
+
p.log.error(actionError);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
37
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
38
|
+
p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 42 ou #42.`);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (severity && !PRIORITIES.includes(severity)) {
|
|
43
|
+
p.log.error(`Severidade inválida: ${severity}. Use uma de: ${PRIORITIES.join(', ')}.`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
49
|
+
const { config, root } = loadConfig();
|
|
50
|
+
const owner = envOwner || config?.owner;
|
|
51
|
+
const repo = envRepo || config?.repo;
|
|
52
|
+
if (!owner || !repo) {
|
|
53
|
+
p.log.error(
|
|
54
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
55
|
+
`Rode dentro de um repositório com ${CONFIG_FILE} ou defina GITHUB_REPOSITORY=owner/repo.`
|
|
56
|
+
);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let token;
|
|
62
|
+
try {
|
|
63
|
+
token = await resolveToken();
|
|
64
|
+
} catch (err) {
|
|
65
|
+
p.log.error(err.message);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
p.intro(chalk.bold(`spec-wave triage ${action} #${issueNumber}`));
|
|
71
|
+
|
|
72
|
+
let issue;
|
|
73
|
+
try {
|
|
74
|
+
issue = await getIssue(token, owner, repo, issueNumber);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (detectIssueType(issue) !== 'Bug') {
|
|
82
|
+
p.log.error(`#${issueNumber} não é um Bug — a triagem só se aplica a defeitos.`);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const labels = labelNames(issue);
|
|
88
|
+
const severidadeAtual = severity || labels.find(n => PRIORITIES.includes(n)) || null;
|
|
89
|
+
|
|
90
|
+
if (action === 'accept') {
|
|
91
|
+
const { ok, error } = canAcceptBug({ severity: severidadeAtual, labels });
|
|
92
|
+
if (!ok) {
|
|
93
|
+
p.log.error(error);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reclassificação: troca a label em vez de acumular. Duas prioridades na
|
|
99
|
+
// mesma issue tornam a ordem da fila indefinida.
|
|
100
|
+
if (severity) {
|
|
101
|
+
for (const outra of PRIORITIES) {
|
|
102
|
+
if (outra !== severity) await removeLabel(token, owner, repo, issueNumber, outra).catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
await addLabel(token, owner, repo, issueNumber, severity);
|
|
105
|
+
p.log.info(`Severidade → ${severity}.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_TRIAGED);
|
|
109
|
+
await commentOnIssue(token, owner, repo, issueNumber,
|
|
110
|
+
'**Triado:** aceito para a fila técnica.').catch(() => {});
|
|
111
|
+
await moveToReady({ token, issueNumber, issue, root });
|
|
112
|
+
|
|
113
|
+
p.outro(`${chalk.green('✓')} Bug #${issueNumber} aceito → ${STAGE_READY}.`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (action === 'reject') {
|
|
118
|
+
if (!reason || !reason.trim()) {
|
|
119
|
+
p.log.error('Rejeitar exige --reason "<motivo>".');
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_WONT_FIX);
|
|
124
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_TRIAGED);
|
|
125
|
+
await commentOnIssue(token, owner, repo, issueNumber,
|
|
126
|
+
`**Rejeitado na triagem:** ${reason.trim()}`).catch(() => {});
|
|
127
|
+
await setIssueState(token, owner, repo, issueNumber, 'closed');
|
|
128
|
+
p.outro(`${chalk.green('✓')} Bug #${issueNumber} rejeitado e fechado.`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// duplicate
|
|
133
|
+
const original = parseInt(String(of || '').replace('#', ''), 10);
|
|
134
|
+
if (!Number.isInteger(original) || original <= 0) {
|
|
135
|
+
p.log.error('Marcar como duplicata exige --of <número da issue original>.');
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (original === issueNumber) {
|
|
140
|
+
p.log.error('Uma issue não pode ser duplicata de si mesma.');
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_DUPLICATE);
|
|
146
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_TRIAGED);
|
|
147
|
+
await commentOnIssue(token, owner, repo, issueNumber, `**Duplicata de #${original}.**`).catch(() => {});
|
|
148
|
+
// Comentar também na original: sem isso, quem acompanha #original não fica
|
|
149
|
+
// sabendo que há outro relato do mesmo defeito — e o contexto extra está lá.
|
|
150
|
+
await commentOnIssue(token, owner, repo, original,
|
|
151
|
+
`#${issueNumber} foi marcada como duplicata desta issue.`).catch(() => {});
|
|
152
|
+
await setIssueState(token, owner, repo, issueNumber, 'closed');
|
|
153
|
+
|
|
154
|
+
p.outro(`${chalk.green('✓')} Bug #${issueNumber} marcado como duplicata de #${original} e fechado.`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function moveToReady({ token, issueNumber, issue, root }) {
|
|
158
|
+
const { project, error } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
159
|
+
if (error) {
|
|
160
|
+
p.log.warn(`${error} — board não atualizado.`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
165
|
+
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
166
|
+
const moved = await advanceToStage(
|
|
167
|
+
token, project, etapaField, statusField, issue.node_id, STAGE_READY, PROGRESS_TODO);
|
|
168
|
+
if (!moved) {
|
|
169
|
+
p.log.info(`#${issueNumber} já está em ${STAGE_READY} ou etapa posterior — mantido.`);
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
p.log.warn(`Falha ao mover no board: ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|