@spec-wave/cli 0.2.0 → 0.2.1
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 +11 -0
- package/package.json +1 -1
- package/src/api/github-graphql.mjs +50 -0
- package/src/commands/decompose.mjs +38 -20
- package/src/commands/implement.mjs +222 -0
- package/src/lib/issue-type.mjs +37 -0
package/bin/spec-wave.mjs
CHANGED
|
@@ -124,4 +124,15 @@ program
|
|
|
124
124
|
await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
program
|
|
128
|
+
.command('implement')
|
|
129
|
+
.description('Aciona o spec-kit implement para uma Story (todas as tasks) ou uma Task')
|
|
130
|
+
.argument('<issue>', 'Número da issue (Story ou Task), ex.: 12 ou #12')
|
|
131
|
+
.option('--feature-dir <path>', 'Caminho do docs/features/<slug> (sobrescreve a resolução automática)')
|
|
132
|
+
.option('--dry-run', 'Monta o contexto e imprime o comando sem executar o spec-kit')
|
|
133
|
+
.action(async (issue, options) => {
|
|
134
|
+
const { implement } = await import('../src/commands/implement.mjs');
|
|
135
|
+
await implement({ issue, ...options }).catch(err => { console.error(err.message); process.exit(1); });
|
|
136
|
+
});
|
|
137
|
+
|
|
127
138
|
program.parse();
|
package/package.json
CHANGED
|
@@ -183,6 +183,56 @@ export async function addSubIssue(token, parentIssueId, childIssueId) {
|
|
|
183
183
|
`, { issueId: parentIssueId, subIssueId: childIssueId });
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
// Lista as sub-issues de uma issue (pelo node id da issue pai). Retorna
|
|
187
|
+
// [{ number, title, body, nodeId, labels: [nome...] }]. Usado pelo comando
|
|
188
|
+
// `implement` para coletar as Tasks de uma Story.
|
|
189
|
+
export async function listSubIssues(token, issueNodeId) {
|
|
190
|
+
const client = makeClient(token);
|
|
191
|
+
const result = await client(`
|
|
192
|
+
query SubIssues($id: ID!) {
|
|
193
|
+
node(id: $id) {
|
|
194
|
+
... on Issue {
|
|
195
|
+
subIssues(first: 100) {
|
|
196
|
+
nodes {
|
|
197
|
+
id
|
|
198
|
+
number
|
|
199
|
+
title
|
|
200
|
+
body
|
|
201
|
+
labels(first: 20) { nodes { name } }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
`, { id: issueNodeId });
|
|
208
|
+
const nodes = result.node?.subIssues?.nodes || [];
|
|
209
|
+
return nodes.map(n => ({
|
|
210
|
+
number: n.number,
|
|
211
|
+
title: n.title,
|
|
212
|
+
body: n.body || '',
|
|
213
|
+
nodeId: n.id,
|
|
214
|
+
labels: (n.labels?.nodes || []).map(l => l.name),
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Lê o parent (issue pai) de uma sub-issue. Retorna { number, title, nodeId }
|
|
219
|
+
// ou null se a issue não tiver pai. Usado para subir a cadeia Task→Story→Feature.
|
|
220
|
+
export async function getIssueParent(token, issueNodeId) {
|
|
221
|
+
const client = makeClient(token);
|
|
222
|
+
const result = await client(`
|
|
223
|
+
query IssueParent($id: ID!) {
|
|
224
|
+
node(id: $id) {
|
|
225
|
+
... on Issue {
|
|
226
|
+
parent { id number title }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
`, { id: issueNodeId });
|
|
231
|
+
const parent = result.node?.parent;
|
|
232
|
+
if (!parent) return null;
|
|
233
|
+
return { number: parent.number, title: parent.title, nodeId: parent.id };
|
|
234
|
+
}
|
|
235
|
+
|
|
186
236
|
// Define o valor de um campo SINGLE_SELECT para um item do Project.
|
|
187
237
|
export async function setItemSingleSelect(token, projectId, itemId, fieldId, optionId) {
|
|
188
238
|
const client = makeClient(token);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
2
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
|
-
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
3
|
+
import { getIssue, createIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
4
|
+
import { addSubIssue } from '../api/github-graphql.mjs';
|
|
5
5
|
import { generateDocument } from '../lib/claude.mjs';
|
|
6
6
|
import { slugify } from '../lib/slugify.mjs';
|
|
7
7
|
|
|
@@ -12,11 +12,12 @@ Responda APENAS com JSON válido neste formato:
|
|
|
12
12
|
{
|
|
13
13
|
"stories": [
|
|
14
14
|
{
|
|
15
|
-
"title": "
|
|
16
|
-
"
|
|
15
|
+
"title": "Título curto da story (apenas a parte 'quero', sem prefixo)",
|
|
16
|
+
"userStory": "Como <perfil>, quero <objetivo>, para <benefício>",
|
|
17
|
+
"body": "Descrição complementar da story com contexto e critérios de aceite relevantes",
|
|
17
18
|
"tasks": [
|
|
18
19
|
{
|
|
19
|
-
"title": "
|
|
20
|
+
"title": "Título técnico curto da task (sem prefixo)",
|
|
20
21
|
"body": "Descrição técnica detalhada"
|
|
21
22
|
}
|
|
22
23
|
]
|
|
@@ -25,9 +26,11 @@ Responda APENAS com JSON válido neste formato:
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
Regras:
|
|
29
|
+
- "title" deve ser CURTO (máx. ~60 caracteres): apenas a parte "quero" da user story, sem o "Como" nem o "para", e sem prefixo. Ex.: "visualizar meus repositórios em layout responsivo"
|
|
30
|
+
- "userStory" deve trazer a user story completa no formato "Como <perfil>, quero <objetivo>, para <benefício>"
|
|
31
|
+
- "body" é texto complementar (contexto, critérios de aceite); não repita o título
|
|
28
32
|
- Cada Story deve ter 2–5 Tasks associadas
|
|
29
|
-
-
|
|
30
|
-
- Tasks devem ser atividades técnicas concretas
|
|
33
|
+
- Tasks devem ser atividades técnicas concretas, com "title" curto e "body" detalhado
|
|
31
34
|
- Gere entre 3 e 7 Stories por Feature`;
|
|
32
35
|
|
|
33
36
|
export async function decompose({ issueNumber }) {
|
|
@@ -75,26 +78,41 @@ export async function decompose({ issueNumber }) {
|
|
|
75
78
|
decomposition = JSON.parse(jsonMatch[0]);
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
// node id da Feature — necessário para vincular as stories como sub-issues.
|
|
82
|
+
const featureNodeId = issue.node_id;
|
|
83
|
+
|
|
78
84
|
const created = [];
|
|
79
85
|
|
|
80
86
|
for (const story of decomposition.stories) {
|
|
81
87
|
console.log(`Criando story: ${story.title}`);
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
const storyTitle = `[STORY] ${story.title}`;
|
|
89
|
+
// Corpo: user story completa (Como/quero/para) + texto complementar.
|
|
90
|
+
const storyBody = [story.userStory, story.body]
|
|
91
|
+
.map(s => (s || '').trim())
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join('\n\n') || '_(sem descrição)_';
|
|
94
|
+
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
|
|
95
|
+
created.push({ type: 'story', title: storyTitle, url: createdStory.url });
|
|
96
|
+
|
|
97
|
+
// Vincula a story como sub-issue da Feature (relação nativa do GitHub).
|
|
98
|
+
try {
|
|
99
|
+
await addSubIssue(token, featureNodeId, createdStory.nodeId);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
|
|
102
|
+
}
|
|
89
103
|
|
|
90
104
|
for (const task of story.tasks || []) {
|
|
91
105
|
console.log(` Criando task: ${task.title}`);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
const taskTitle = `[TASK] ${task.title}`;
|
|
107
|
+
const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
|
|
108
|
+
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
109
|
+
|
|
110
|
+
// Vincula a task como sub-issue da Story.
|
|
111
|
+
try {
|
|
112
|
+
await addSubIssue(token, createdStory.nodeId, createdTask.nodeId);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
|
|
115
|
+
}
|
|
98
116
|
}
|
|
99
117
|
}
|
|
100
118
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
7
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
8
|
+
import { getIssue } from '../api/github-rest.mjs';
|
|
9
|
+
import { listSubIssues, getIssueParent } from '../api/github-graphql.mjs';
|
|
10
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
11
|
+
import { slugify } from '../lib/slugify.mjs';
|
|
12
|
+
|
|
13
|
+
// Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
|
|
14
|
+
const WORK_DIR = '.spec-wave';
|
|
15
|
+
|
|
16
|
+
// Sobe a cadeia de pais (Task → Story → Feature) até achar uma issue do tipo
|
|
17
|
+
// "Feature" e devolve seu título (para resolver docs/features/<slug>). Limita a
|
|
18
|
+
// profundidade para evitar loops em dados inconsistentes.
|
|
19
|
+
async function resolveFeatureTitle(token, startNodeId) {
|
|
20
|
+
let current = startNodeId;
|
|
21
|
+
for (let depth = 0; depth < 5 && current; depth++) {
|
|
22
|
+
const parent = await getIssueParent(token, current);
|
|
23
|
+
if (!parent) return null;
|
|
24
|
+
if (detectIssueType({ title: parent.title }) === 'Feature') return parent.title;
|
|
25
|
+
current = parent.nodeId;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Lê spec.md/plan.md de um docs/features/<slug> se existirem.
|
|
31
|
+
function readSpecPlan(featureDir) {
|
|
32
|
+
const specPath = path.join(featureDir, 'spec.md');
|
|
33
|
+
const planPath = path.join(featureDir, 'plan.md');
|
|
34
|
+
return {
|
|
35
|
+
specPath,
|
|
36
|
+
planPath,
|
|
37
|
+
spec: existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null,
|
|
38
|
+
plan: existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Monta o markdown de contexto que será entregue ao spec-kit implement.
|
|
43
|
+
function buildContext({ type, issue, tasks, spec, plan, specPath, planPath }) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(`# Contexto de implementação — ${type} #${issue.number}`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push(`**${type}:** ${issue.title}`);
|
|
48
|
+
if (issue.body && issue.body.trim()) {
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push(issue.body.trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push(`## Tasks a implementar (${tasks.length})`);
|
|
55
|
+
for (const t of tasks) {
|
|
56
|
+
lines.push('');
|
|
57
|
+
lines.push(`### #${t.number} ${t.title}`);
|
|
58
|
+
if (t.body && t.body.trim()) lines.push(t.body.trim());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (spec) {
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push(`## spec.md (${specPath})`);
|
|
64
|
+
lines.push(spec.trim());
|
|
65
|
+
}
|
|
66
|
+
if (plan) {
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push(`## plan.md (${planPath})`);
|
|
69
|
+
lines.push(plan.trim());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push('');
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Substitui os placeholders do template de comando do spec-kit.
|
|
77
|
+
function renderCommand(template, vars) {
|
|
78
|
+
return template.replace(/\{(\w+)\}/g, (m, key) => (key in vars ? vars[key] : m));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function implement({ issue: issueArg, featureDir: featureDirOpt, dryRun }) {
|
|
82
|
+
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
83
|
+
if (!Number.isInteger(issueNumber)) {
|
|
84
|
+
p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 1. Config local (.spec-wave.json) — owner/repo e bloco opcional specKit.
|
|
90
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
91
|
+
if (!existsSync(configPath)) {
|
|
92
|
+
p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let config;
|
|
97
|
+
try {
|
|
98
|
+
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
99
|
+
} catch (err) {
|
|
100
|
+
p.log.error(`${CONFIG_FILE} corrompido: ${err.message}`);
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const { owner, repo } = config;
|
|
105
|
+
if (!owner || !repo) {
|
|
106
|
+
p.log.error(`${CONFIG_FILE} não contém owner/repo. Rode \`spec-wave init\` novamente.`);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let token;
|
|
112
|
+
try {
|
|
113
|
+
token = await resolveToken();
|
|
114
|
+
} catch (err) {
|
|
115
|
+
p.log.error(err.message);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
p.intro(chalk.bold(`spec-wave implement #${issueNumber}`));
|
|
121
|
+
|
|
122
|
+
// 2. Lê a issue alvo.
|
|
123
|
+
let issue;
|
|
124
|
+
try {
|
|
125
|
+
issue = await getIssue(token, owner, repo, issueNumber);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 3. Detecta o tipo e monta a lista de tasks.
|
|
133
|
+
const type = detectIssueType(issue);
|
|
134
|
+
let tasks;
|
|
135
|
+
if (type === 'Story') {
|
|
136
|
+
const subs = await listSubIssues(token, issue.node_id);
|
|
137
|
+
tasks = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Task');
|
|
138
|
+
if (tasks.length === 0) {
|
|
139
|
+
p.log.error(`Story #${issueNumber} não tem Tasks (sub-issues) para implementar.`);
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
p.log.info(`Story com ${tasks.length} task(s): ${tasks.map(t => `#${t.number}`).join(', ')}`);
|
|
144
|
+
} else if (type === 'Task') {
|
|
145
|
+
tasks = [{ number: issue.number, title: issue.title, body: issue.body || '' }];
|
|
146
|
+
p.log.info(`Task única #${issueNumber}.`);
|
|
147
|
+
} else {
|
|
148
|
+
p.log.error(
|
|
149
|
+
`implement só aceita Story ou Task. Issue #${issueNumber} é do tipo ${type || 'desconhecido'}.`
|
|
150
|
+
);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4. Resolve spec.md/plan.md da Feature (enriquecimento opcional).
|
|
156
|
+
let featureDir = featureDirOpt;
|
|
157
|
+
if (!featureDir) {
|
|
158
|
+
const featureTitle = await resolveFeatureTitle(token, issue.node_id);
|
|
159
|
+
if (featureTitle) featureDir = path.join('docs', 'features', slugify(featureTitle));
|
|
160
|
+
}
|
|
161
|
+
let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
|
|
162
|
+
if (featureDir && existsSync(featureDir)) {
|
|
163
|
+
specPlan = readSpecPlan(featureDir);
|
|
164
|
+
} else if (featureDir) {
|
|
165
|
+
p.log.warn(`Diretório da feature não encontrado (${featureDir}); seguindo só com as tasks.`);
|
|
166
|
+
} else {
|
|
167
|
+
p.log.warn('Não foi possível resolver a Feature; seguindo só com as tasks (use --feature-dir).');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 5. Monta e grava o arquivo de contexto.
|
|
171
|
+
const context = buildContext({ type, issue, tasks, ...specPlan });
|
|
172
|
+
mkdirSync(WORK_DIR, { recursive: true });
|
|
173
|
+
const tasksFile = path.join(WORK_DIR, `implement-${issueNumber}.md`);
|
|
174
|
+
writeFileSync(tasksFile, context);
|
|
175
|
+
p.log.success(`Contexto montado em ${chalk.cyan(tasksFile)}.`);
|
|
176
|
+
|
|
177
|
+
// 6. Aciona o spec-kit (comando configurável).
|
|
178
|
+
const template = process.env.SPEC_WAVE_IMPLEMENT_CMD || config.specKit?.command;
|
|
179
|
+
const vars = {
|
|
180
|
+
tasksFile,
|
|
181
|
+
specFile: specPlan.specPath || '',
|
|
182
|
+
planFile: specPlan.planPath || '',
|
|
183
|
+
issue: String(issueNumber),
|
|
184
|
+
type,
|
|
185
|
+
title: issue.title,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (!template) {
|
|
189
|
+
p.log.warn('Comando do spec-kit não configurado.');
|
|
190
|
+
p.note(
|
|
191
|
+
`Configure em ${CONFIG_FILE}:\n` +
|
|
192
|
+
` "specKit": { "command": "<comando do spec-kit com placeholders>" }\n` +
|
|
193
|
+
'ou defina a env SPEC_WAVE_IMPLEMENT_CMD.\n\n' +
|
|
194
|
+
'Placeholders: {tasksFile} {specFile} {planFile} {issue} {type} {title}',
|
|
195
|
+
'Como acionar o spec-kit'
|
|
196
|
+
);
|
|
197
|
+
p.outro(`Contexto pronto em ${tasksFile}. Acione o spec-kit manualmente com esse arquivo.`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const command = renderCommand(template, vars);
|
|
202
|
+
|
|
203
|
+
if (dryRun) {
|
|
204
|
+
p.note(command, 'Comando que seria executado (--dry-run)');
|
|
205
|
+
p.outro(`Dry-run: nada executado. Contexto em ${tasksFile}.`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
p.log.step(`Executando: ${chalk.dim(command)}`);
|
|
210
|
+
try {
|
|
211
|
+
execSync(command, { stdio: 'inherit' });
|
|
212
|
+
} catch (err) {
|
|
213
|
+
p.log.error(`spec-kit implement falhou: ${err.message}`);
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
p.outro(
|
|
219
|
+
`${chalk.green('✓')} Implementação acionada para ${type} #${issueNumber}.\n` +
|
|
220
|
+
' Próximo: revise as mudanças, abra o PR e mova o card para 👀 Code Review.'
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { TYPE_LABELS } from '../config.mjs';
|
|
2
|
+
|
|
3
|
+
// Mapa de prefixo de label (ex.: "[STORY]") → nome canônico do tipo ("Story").
|
|
4
|
+
// Derivado de TYPE_LABELS para manter uma única fonte da verdade.
|
|
5
|
+
const TAG_TO_TYPE = Object.fromEntries(
|
|
6
|
+
TYPE_LABELS.map(l => [l.name.toUpperCase(), capitalize(l.name.replace(/[[\]]/g, ''))])
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
function capitalize(s) {
|
|
10
|
+
const lower = s.toLowerCase();
|
|
11
|
+
// RFC fica em caixa alta; os demais tipos seguem Capitalizado (Story, Task...).
|
|
12
|
+
return lower === 'rfc' ? 'RFC' : lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Detecta o tipo canônico de uma issue ('Story', 'Task', 'Feature', ...) a partir
|
|
16
|
+
// do prefixo do título ([STORY], [TASK], ...) com fallback nas labels. Retorna
|
|
17
|
+
// null quando não é possível determinar.
|
|
18
|
+
export function detectIssueType(issue) {
|
|
19
|
+
if (!issue) return null;
|
|
20
|
+
|
|
21
|
+
// 1. Prefixo do título: "[STORY] ..." → "Story".
|
|
22
|
+
const titleMatch = String(issue.title || '').match(/^\s*(\[[^\]]+\])/);
|
|
23
|
+
if (titleMatch) {
|
|
24
|
+
const type = TAG_TO_TYPE[titleMatch[1].toUpperCase()];
|
|
25
|
+
if (type) return type;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 2. Fallback: varrer as labels da issue procurando um dos prefixos de tipo.
|
|
29
|
+
const labels = Array.isArray(issue.labels) ? issue.labels : [];
|
|
30
|
+
for (const label of labels) {
|
|
31
|
+
const name = (typeof label === 'string' ? label : label?.name) || '';
|
|
32
|
+
const type = TAG_TO_TYPE[name.toUpperCase()];
|
|
33
|
+
if (type) return type;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return null;
|
|
37
|
+
}
|