trackfw 2.0.0 → 2.1.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/package.json +1 -1
- package/src/commands/context.js +189 -0
- package/src/commands/index.js +3 -0
- package/src/commands/init.js +11 -2
- package/src/commands/metrics.js +235 -0
- package/src/commands/plugins.js +73 -0
- package/src/commands/roadmap.js +6 -1
- package/src/commands/sync.js +362 -0
- package/src/commands/validate.js +9 -1
- package/src/generators/adr.js +14 -2
- package/src/generators/init.js +44 -1
- package/src/generators/req.js +9 -1
- package/src/generators/roadmap.js +142 -1
- package/src/i18n/locales/en-US.json +9 -1
- package/src/i18n/locales/es-ES.json +9 -1
- package/src/i18n/locales/pt-BR.json +9 -1
- package/src/validator/index.js +188 -15
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander')
|
|
4
|
+
const https = require('https')
|
|
5
|
+
const http = require('http')
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Config helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Lê um campo de trackfw.yaml (parse linha a linha, sem dependências externas).
|
|
15
|
+
* @param {string} field
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function readConfigField(field) {
|
|
19
|
+
try {
|
|
20
|
+
const data = fs.readFileSync('trackfw.yaml', 'utf8')
|
|
21
|
+
const prefix = field + ':'
|
|
22
|
+
for (const line of data.split('\n')) {
|
|
23
|
+
const trimmed = line.trimStart()
|
|
24
|
+
if (trimmed.startsWith(prefix)) {
|
|
25
|
+
let value = trimmed.slice(prefix.length).trim()
|
|
26
|
+
if (value.length >= 2 &&
|
|
27
|
+
((value[0] === '"' && value[value.length - 1] === '"') ||
|
|
28
|
+
(value[0] === "'" && value[value.length - 1] === "'"))) {
|
|
29
|
+
value = value.slice(1, -1)
|
|
30
|
+
}
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch (_) { /* sem arquivo */ }
|
|
35
|
+
return ''
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getConfig(field, envVar) {
|
|
39
|
+
return readConfigField(field) || process.env[envVar] || ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// HTTP helper
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Faz uma requisição HTTP/HTTPS simples com corpo JSON.
|
|
48
|
+
* @returns {Promise<{status: number, body: string}>}
|
|
49
|
+
*/
|
|
50
|
+
function request(url, options, bodyStr) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const parsed = new URL(url)
|
|
53
|
+
const lib = parsed.protocol === 'https:' ? https : http
|
|
54
|
+
const reqOptions = {
|
|
55
|
+
hostname: parsed.hostname,
|
|
56
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
57
|
+
path: parsed.pathname + parsed.search,
|
|
58
|
+
method: options.method || 'GET',
|
|
59
|
+
headers: options.headers || {}
|
|
60
|
+
}
|
|
61
|
+
const req = lib.request(reqOptions, (res) => {
|
|
62
|
+
let data = ''
|
|
63
|
+
res.on('data', (chunk) => { data += chunk })
|
|
64
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }))
|
|
65
|
+
})
|
|
66
|
+
req.on('error', reject)
|
|
67
|
+
if (bodyStr) req.write(bodyStr)
|
|
68
|
+
req.end()
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Linear client
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cria issue no Linear via GraphQL.
|
|
78
|
+
* @param {string} apiKey
|
|
79
|
+
* @param {string} teamId
|
|
80
|
+
* @param {string} title
|
|
81
|
+
* @param {string} description
|
|
82
|
+
* @returns {Promise<string>} issue identifier (ex: "ENG-123")
|
|
83
|
+
*/
|
|
84
|
+
async function linearCreateIssue(apiKey, teamId, title, description) {
|
|
85
|
+
const query = `mutation IssueCreate($title: String!, $description: String!, $teamId: String!) {
|
|
86
|
+
issueCreate(input: {title: $title, description: $description, teamId: $teamId}) {
|
|
87
|
+
success
|
|
88
|
+
issue { id identifier }
|
|
89
|
+
}
|
|
90
|
+
}`
|
|
91
|
+
const payload = JSON.stringify({
|
|
92
|
+
query,
|
|
93
|
+
variables: { title, description, teamId }
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const res = await request('https://api.linear.app/graphql', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
'Authorization': apiKey,
|
|
101
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
102
|
+
}
|
|
103
|
+
}, payload)
|
|
104
|
+
|
|
105
|
+
if (res.status !== 200) {
|
|
106
|
+
throw new Error(`Linear: unexpected status ${res.status}: ${res.body}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const data = JSON.parse(res.body)
|
|
110
|
+
if (data.errors && data.errors.length > 0) {
|
|
111
|
+
throw new Error(`Linear API error: ${data.errors[0].message}`)
|
|
112
|
+
}
|
|
113
|
+
if (!data.data.issueCreate.success) {
|
|
114
|
+
throw new Error('Linear: issueCreate returned success=false')
|
|
115
|
+
}
|
|
116
|
+
return data.data.issueCreate.issue.identifier
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Jira client
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Cria issue no Jira via REST API v3.
|
|
125
|
+
* @returns {Promise<string>} issue key (ex: "ENG-456")
|
|
126
|
+
*/
|
|
127
|
+
async function jiraCreateIssue(baseUrl, email, token, project, title, description) {
|
|
128
|
+
const payload = JSON.stringify({
|
|
129
|
+
fields: {
|
|
130
|
+
project: { key: project },
|
|
131
|
+
summary: title,
|
|
132
|
+
description: {
|
|
133
|
+
type: 'doc',
|
|
134
|
+
version: 1,
|
|
135
|
+
content: [{
|
|
136
|
+
type: 'paragraph',
|
|
137
|
+
content: [{ type: 'text', text: description }]
|
|
138
|
+
}]
|
|
139
|
+
},
|
|
140
|
+
issuetype: { name: 'Story' }
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const creds = Buffer.from(`${email}:${token}`).toString('base64')
|
|
145
|
+
const url = baseUrl.replace(/\/$/, '') + '/rest/api/3/issue'
|
|
146
|
+
|
|
147
|
+
const res = await request(url, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: {
|
|
150
|
+
'Content-Type': 'application/json',
|
|
151
|
+
'Accept': 'application/json',
|
|
152
|
+
'Authorization': `Basic ${creds}`,
|
|
153
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
154
|
+
}
|
|
155
|
+
}, payload)
|
|
156
|
+
|
|
157
|
+
if (res.status !== 201) {
|
|
158
|
+
throw new Error(`Jira: unexpected status ${res.status}: ${res.body}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const data = JSON.parse(res.body)
|
|
162
|
+
if (!data.key) {
|
|
163
|
+
throw new Error('Jira: response missing issue key')
|
|
164
|
+
}
|
|
165
|
+
return data.key
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// REQ file helpers
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function isStatusOpen(text) {
|
|
173
|
+
for (const line of text.split('\n')) {
|
|
174
|
+
if (line.includes('| Status:')) {
|
|
175
|
+
return line.includes('Status: Open')
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function extractField(text, field) {
|
|
182
|
+
const prefix = '| ' + field + ':'
|
|
183
|
+
for (const line of text.split('\n')) {
|
|
184
|
+
const trimmed = line.trim()
|
|
185
|
+
if (trimmed.startsWith(prefix)) {
|
|
186
|
+
return trimmed.slice(prefix.length).trim()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return ''
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractTitle(text) {
|
|
193
|
+
for (const line of text.split('\n')) {
|
|
194
|
+
if (line.startsWith('# REQ: ')) {
|
|
195
|
+
return line.slice('# REQ: '.length)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return ''
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function extractMotivation(text) {
|
|
202
|
+
const lines = text.split('\n')
|
|
203
|
+
let inSection = false
|
|
204
|
+
const parts = []
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
if (line.startsWith('## Motivation') || line.startsWith('## Motivação')) {
|
|
207
|
+
inSection = true
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (inSection) {
|
|
211
|
+
if (line.startsWith('## ')) break
|
|
212
|
+
parts.push(line)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return parts.join('\n').trim()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function injectField(text, field, value) {
|
|
219
|
+
const prefix = '| ' + field + ':'
|
|
220
|
+
const lines = text.split('\n')
|
|
221
|
+
|
|
222
|
+
// se campo já existe, substituir
|
|
223
|
+
for (let i = 0; i < lines.length; i++) {
|
|
224
|
+
if (lines[i].trim().startsWith(prefix)) {
|
|
225
|
+
lines[i] = `| ${field}: ${value}`
|
|
226
|
+
return lines.join('\n')
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// inserir após a linha com | Status:
|
|
231
|
+
for (let i = 0; i < lines.length; i++) {
|
|
232
|
+
if (lines[i].includes('| Status:')) {
|
|
233
|
+
lines.splice(i + 1, 0, `| ${field}: ${value}`)
|
|
234
|
+
return lines.join('\n')
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return text
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Core sync logic
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {Function} createFn (title, desc) => Promise<issueId>
|
|
247
|
+
* @param {string} issueField
|
|
248
|
+
* @returns {Promise<Array<{reqPath, issueId, skipped, error}>>}
|
|
249
|
+
*/
|
|
250
|
+
async function syncToProvider(createFn, issueField) {
|
|
251
|
+
const reqDir = 'docs/req'
|
|
252
|
+
let files = []
|
|
253
|
+
try {
|
|
254
|
+
files = fs.readdirSync(reqDir)
|
|
255
|
+
.filter(f => f.endsWith('.md'))
|
|
256
|
+
.map(f => path.join(reqDir, f))
|
|
257
|
+
} catch (_) {
|
|
258
|
+
return []
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const results = []
|
|
262
|
+
for (const f of files) {
|
|
263
|
+
let text
|
|
264
|
+
try {
|
|
265
|
+
text = fs.readFileSync(f, 'utf8')
|
|
266
|
+
} catch (e) {
|
|
267
|
+
results.push({ reqPath: f, skipped: false, error: e })
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// pular se não é Open
|
|
272
|
+
if (!isStatusOpen(text)) {
|
|
273
|
+
results.push({ reqPath: f, skipped: true })
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// pular se já tem issue vinculado
|
|
278
|
+
if (extractField(text, issueField) !== '') {
|
|
279
|
+
results.push({ reqPath: f, skipped: true })
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const title = extractTitle(text)
|
|
284
|
+
const desc = extractMotivation(text)
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const issueId = await createFn(title, desc)
|
|
288
|
+
const updated = injectField(text, issueField, issueId)
|
|
289
|
+
fs.writeFileSync(f, updated, 'utf8')
|
|
290
|
+
results.push({ reqPath: f, issueId, skipped: false })
|
|
291
|
+
} catch (e) {
|
|
292
|
+
results.push({ reqPath: f, skipped: false, error: e })
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return results
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function syncToLinear() {
|
|
299
|
+
const apiKey = getConfig('linear_api_key', 'LINEAR_API_KEY')
|
|
300
|
+
const teamId = getConfig('linear_team_id', 'LINEAR_TEAM_ID')
|
|
301
|
+
if (!apiKey) throw new Error('Linear API key not found. Set LINEAR_API_KEY env var or linear_api_key in trackfw.yaml')
|
|
302
|
+
if (!teamId) throw new Error('Linear Team ID not found. Set LINEAR_TEAM_ID env var or linear_team_id in trackfw.yaml')
|
|
303
|
+
return syncToProvider((title, desc) => linearCreateIssue(apiKey, teamId, title, desc), 'linear_issue')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function syncToJira() {
|
|
307
|
+
const baseUrl = getConfig('jira_base_url', 'JIRA_BASE_URL')
|
|
308
|
+
const email = getConfig('jira_email', 'JIRA_EMAIL')
|
|
309
|
+
const token = getConfig('jira_token', 'JIRA_TOKEN')
|
|
310
|
+
const project = getConfig('jira_project', 'JIRA_PROJECT')
|
|
311
|
+
if (!baseUrl) throw new Error('Jira base URL not found. Set JIRA_BASE_URL env var or jira_base_url in trackfw.yaml')
|
|
312
|
+
if (!email) throw new Error('Jira email not found. Set JIRA_EMAIL env var or jira_email in trackfw.yaml')
|
|
313
|
+
if (!token) throw new Error('Jira API token not found. Set JIRA_TOKEN env var or jira_token in trackfw.yaml')
|
|
314
|
+
if (!project) throw new Error('Jira project key not found. Set JIRA_PROJECT env var or jira_project in trackfw.yaml')
|
|
315
|
+
return syncToProvider((title, desc) => jiraCreateIssue(baseUrl, email, token, project, title, desc), 'jira_issue')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Commander command
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
const syncCmd = new Command('sync')
|
|
323
|
+
.description('Sync Open REQs to a project management tool')
|
|
324
|
+
.requiredOption('--to <target>', 'Target PM tool: linear or jira')
|
|
325
|
+
.action(async (options) => {
|
|
326
|
+
let results
|
|
327
|
+
try {
|
|
328
|
+
switch (options.to) {
|
|
329
|
+
case 'linear':
|
|
330
|
+
results = await syncToLinear()
|
|
331
|
+
break
|
|
332
|
+
case 'jira':
|
|
333
|
+
results = await syncToJira()
|
|
334
|
+
break
|
|
335
|
+
default:
|
|
336
|
+
console.error(`Unknown target "${options.to}" — use --to=linear or --to=jira`)
|
|
337
|
+
process.exit(1)
|
|
338
|
+
}
|
|
339
|
+
} catch (e) {
|
|
340
|
+
console.error(e.message)
|
|
341
|
+
process.exit(1)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!results || results.length === 0) {
|
|
345
|
+
console.log('No REQs found in docs/req/')
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
console.log(`${'REQ'.padEnd(55)} ISSUE`)
|
|
350
|
+
console.log(`${'-'.repeat(54)} ${'-'.repeat(10)}`)
|
|
351
|
+
for (const r of results) {
|
|
352
|
+
if (r.skipped) {
|
|
353
|
+
console.log(`${r.reqPath.padEnd(55)} (skipped)`)
|
|
354
|
+
} else if (r.error) {
|
|
355
|
+
console.log(`${r.reqPath.padEnd(55)} ERROR: ${r.error.message}`)
|
|
356
|
+
} else {
|
|
357
|
+
console.log(`${r.reqPath.padEnd(55)} ${r.issueId}`)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
module.exports = syncCmd
|
package/src/commands/validate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
const { Command } = require('commander')
|
|
3
|
-
const { validate } = require('../validator')
|
|
3
|
+
const { validate, isLenient, lenientUntilDate } = require('../validator')
|
|
4
4
|
const { t } = require('../i18n')
|
|
5
5
|
|
|
6
6
|
const cmd = new Command('validate')
|
|
@@ -8,6 +8,14 @@ cmd.description(t('validate.description'))
|
|
|
8
8
|
cmd.action(async () => {
|
|
9
9
|
const { violations, warnings } = await validate()
|
|
10
10
|
|
|
11
|
+
// Informar usuário sobre modo lenient
|
|
12
|
+
if (isLenient()) {
|
|
13
|
+
const until = lenientUntilDate()
|
|
14
|
+
if (until) {
|
|
15
|
+
console.log(`[LENIENT MODE] ${t('validate.lenient_mode', { date: until })}`)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
if (violations.length === 0 && warnings.length === 0) {
|
|
12
20
|
console.log(t('validate.ok'))
|
|
13
21
|
return
|
package/src/generators/adr.js
CHANGED
|
@@ -52,7 +52,13 @@ async function newADR(content) {
|
|
|
52
52
|
const consequencesSection = content.consequences || '<!-- What are the positive and negative consequences of this decision? -->'
|
|
53
53
|
const alternativesSection = content.alternatives || '<!-- What other options were evaluated and why were they rejected? -->'
|
|
54
54
|
|
|
55
|
-
const body =
|
|
55
|
+
const body = `---
|
|
56
|
+
status: Proposed
|
|
57
|
+
date: ${date}
|
|
58
|
+
author: ""
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# ADR: ${content.title}
|
|
56
62
|
|
|
57
63
|
> Date: ${date} | Status: Proposed
|
|
58
64
|
|
|
@@ -148,7 +154,13 @@ async function newADRDraft(slug) {
|
|
|
148
154
|
const filepath = path.join(adrDir, filename)
|
|
149
155
|
const title = slugToTitle(slug)
|
|
150
156
|
|
|
151
|
-
const body =
|
|
157
|
+
const body = `---
|
|
158
|
+
status: Draft
|
|
159
|
+
date: ${date}
|
|
160
|
+
author: ""
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
# ADR: ${title}
|
|
152
164
|
|
|
153
165
|
> Date: ${date} | Status: Draft
|
|
154
166
|
|
package/src/generators/init.js
CHANGED
|
@@ -28,6 +28,7 @@ async function scaffold(cfg) {
|
|
|
28
28
|
generateValidateScript(cfg)
|
|
29
29
|
generateCIWorkflow(cfg)
|
|
30
30
|
generateGitHooks(cfg)
|
|
31
|
+
generateCommitMsgHook(cfg)
|
|
31
32
|
generateClaudeMD(cfg)
|
|
32
33
|
if (cfg.backend === 'java') generatePomXml(cfg)
|
|
33
34
|
generateClaudeCommands()
|
|
@@ -39,7 +40,7 @@ async function scaffold(cfg) {
|
|
|
39
40
|
|
|
40
41
|
function writeTrackfwConfig(cfg) {
|
|
41
42
|
const today = new Date().toISOString().slice(0, 10)
|
|
42
|
-
|
|
43
|
+
let content = `# trackfw configuration
|
|
43
44
|
# generated: ${today}
|
|
44
45
|
|
|
45
46
|
frontend: ${cfg.frontend || ''}
|
|
@@ -48,6 +49,7 @@ backend_framework: ${cfg.backendFramework || ''}
|
|
|
48
49
|
pkg_manager: ${cfg.pkgManager || ''}
|
|
49
50
|
hooks: ${cfg.hooks || ''}
|
|
50
51
|
ci: ${cfg.ci || ''}
|
|
52
|
+
require_req_in_commit: ${cfg.requireReqInCommit ? 'true' : 'false'}
|
|
51
53
|
|
|
52
54
|
# governance paths (edit to match your project structure)
|
|
53
55
|
adr_dirs:
|
|
@@ -56,6 +58,10 @@ req_dir: docs/req
|
|
|
56
58
|
roadmap_dir: docs/roadmaps
|
|
57
59
|
roadmap_namespacing: flat
|
|
58
60
|
`
|
|
61
|
+
if (cfg.brownfieldMode) {
|
|
62
|
+
const until = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
|
63
|
+
content += `governance_mode: lenient\nlenient_until: ${until}\n`
|
|
64
|
+
}
|
|
59
65
|
fs.writeFileSync('trackfw.yaml', content, 'utf8')
|
|
60
66
|
console.log(' ✓ trackfw.yaml')
|
|
61
67
|
}
|
|
@@ -202,6 +208,42 @@ function generateLefthookHook() {
|
|
|
202
208
|
console.log(' ✓ lefthook.yml')
|
|
203
209
|
}
|
|
204
210
|
|
|
211
|
+
function generateCommitMsgHook(cfg) {
|
|
212
|
+
if (!cfg.requireReqInCommit || cfg.hooks === 'none') return
|
|
213
|
+
|
|
214
|
+
const script = [
|
|
215
|
+
'#!/bin/sh',
|
|
216
|
+
'# trackfw: require REQ reference in feat/* and fix/* branches',
|
|
217
|
+
'BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
|
|
218
|
+
'case "$BRANCH" in',
|
|
219
|
+
' feat/*|fix/*)',
|
|
220
|
+
' if ! grep -qE "^(REQ|req): " "$1"; then',
|
|
221
|
+
' echo "ERROR: Commits in feat/* and fix/* branches require a REQ reference."',
|
|
222
|
+
' echo " Add to commit body: REQ: REQ-YYYY-MM-DD-your-req-slug"',
|
|
223
|
+
' exit 1',
|
|
224
|
+
' fi',
|
|
225
|
+
' ;;',
|
|
226
|
+
'esac',
|
|
227
|
+
'',
|
|
228
|
+
].join('\n')
|
|
229
|
+
|
|
230
|
+
if (cfg.hooks === 'husky') {
|
|
231
|
+
fs.mkdirSync('.husky', { recursive: true })
|
|
232
|
+
fs.writeFileSync('.husky/commit-msg', script, { encoding: 'utf8', mode: 0o755 })
|
|
233
|
+
console.log(' ✓ .husky/commit-msg')
|
|
234
|
+
} else if (cfg.hooks === 'lefthook') {
|
|
235
|
+
const lefthookPath = 'lefthook.yml'
|
|
236
|
+
const existing = fs.existsSync(lefthookPath) ? fs.readFileSync(lefthookPath, 'utf8') : ''
|
|
237
|
+
if (!existing.includes('commit-msg:')) {
|
|
238
|
+
const addition = '\ncommit-msg:\n scripts:\n "trackfw-req-check.sh":\n runner: sh\n'
|
|
239
|
+
fs.writeFileSync(lefthookPath, existing + addition, 'utf8')
|
|
240
|
+
}
|
|
241
|
+
fs.mkdirSync('.lefthook/commit-msg', { recursive: true })
|
|
242
|
+
fs.writeFileSync('.lefthook/commit-msg/trackfw-req-check.sh', script, { encoding: 'utf8', mode: 0o755 })
|
|
243
|
+
console.log(' ✓ .lefthook/commit-msg/trackfw-req-check.sh')
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
205
247
|
// ---------------------------------------------------------------------------
|
|
206
248
|
// pom.xml (Java / Spring Boot)
|
|
207
249
|
// ---------------------------------------------------------------------------
|
|
@@ -698,6 +740,7 @@ module.exports = {
|
|
|
698
740
|
generateValidateScript,
|
|
699
741
|
generateCIWorkflow,
|
|
700
742
|
generateGitHooks,
|
|
743
|
+
generateCommitMsgHook,
|
|
701
744
|
generateClaudeMD,
|
|
702
745
|
generateClaudeCommands,
|
|
703
746
|
installAgents,
|
package/src/generators/req.js
CHANGED
|
@@ -101,7 +101,15 @@ async function newREQ(content) {
|
|
|
101
101
|
blockedSection = lines.join('\n')
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
const body =
|
|
104
|
+
const body = `---
|
|
105
|
+
status: Open
|
|
106
|
+
date: ${date}
|
|
107
|
+
author: ""
|
|
108
|
+
adr: ""
|
|
109
|
+
roadmap: ""
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
# REQ: ${content.title}
|
|
105
113
|
|
|
106
114
|
${statusLine}
|
|
107
115
|
|
|
@@ -224,7 +224,14 @@ function newRoadmap(title, reqPath) {
|
|
|
224
224
|
const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
|
|
225
225
|
fs.mkdirSync(backlogDir, { recursive: true })
|
|
226
226
|
|
|
227
|
-
const body =
|
|
227
|
+
const body = `---
|
|
228
|
+
status: backlog
|
|
229
|
+
date: ${date}
|
|
230
|
+
req: ""
|
|
231
|
+
squad: ""
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
# Roadmap: ${title}
|
|
228
235
|
|
|
229
236
|
> Created: ${date} | Status: backlog
|
|
230
237
|
|
|
@@ -249,6 +256,139 @@ REQ: ${reqPath || ''}
|
|
|
249
256
|
console.log(`✓ created ${filename}`)
|
|
250
257
|
}
|
|
251
258
|
|
|
259
|
+
/**
|
|
260
|
+
* newRoadmapFromReq — lê uma REQ e gera roadmap pré-preenchido com MLs extraídos
|
|
261
|
+
* dos critérios de aceite.
|
|
262
|
+
*/
|
|
263
|
+
function newRoadmapFromReq(reqPath) {
|
|
264
|
+
let data
|
|
265
|
+
try {
|
|
266
|
+
data = fs.readFileSync(reqPath, 'utf8')
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error(`reading REQ: ${err.message}`)
|
|
269
|
+
process.exitCode = 1
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const { title: parsedTitle, criteria, linkedADR } = parseReqForRoadmap(data)
|
|
274
|
+
const basename = path.basename(reqPath)
|
|
275
|
+
const title = parsedTitle || basename.replace(/\.md$/, '').replace(/^REQ-/, '')
|
|
276
|
+
|
|
277
|
+
const cfg = config.load()
|
|
278
|
+
const now = new Date()
|
|
279
|
+
const yyyy = now.getFullYear()
|
|
280
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0')
|
|
281
|
+
const dd = String(now.getDate()).padStart(2, '0')
|
|
282
|
+
const date = `${yyyy}-${mm}-${dd}`
|
|
283
|
+
const slug = toSlug(title)
|
|
284
|
+
|
|
285
|
+
let backlogDir
|
|
286
|
+
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
287
|
+
backlogDir = agentStateDir(null, 'backlog')
|
|
288
|
+
if (!backlogDir) {
|
|
289
|
+
console.error('cannot resolve backlog dir in by_agent mode')
|
|
290
|
+
process.exitCode = 1
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
backlogDir = cfg.roadmapDir + '/backlog'
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
|
|
298
|
+
try { fs.mkdirSync(backlogDir, { recursive: true }) } catch (_) {}
|
|
299
|
+
|
|
300
|
+
// Gerar seção de MLs a partir dos critérios de aceite
|
|
301
|
+
const mlLines = ['## Wave 1 — Implementation (derived from REQ criteria)', '> Dependencies: none']
|
|
302
|
+
for (let i = 0; i < criteria.length; i++) {
|
|
303
|
+
const mlLabel = `ML-1${String.fromCharCode(65 + i)}`
|
|
304
|
+
const crit = criteria[i]
|
|
305
|
+
mlLines.push(`\n### ${mlLabel} — ${crit}`)
|
|
306
|
+
mlLines.push('**Status:** pending')
|
|
307
|
+
mlLines.push('**Files affected:**')
|
|
308
|
+
mlLines.push('**Actions:**')
|
|
309
|
+
mlLines.push('**Acceptance criteria:**')
|
|
310
|
+
mlLines.push(`- [ ] ${crit}`)
|
|
311
|
+
mlLines.push('- [ ] build passes')
|
|
312
|
+
mlLines.push('- [ ] tests green')
|
|
313
|
+
}
|
|
314
|
+
const mlSection = mlLines.join('\n')
|
|
315
|
+
|
|
316
|
+
const adrRef = linkedADR ? `\nADR: ${linkedADR}` : ''
|
|
317
|
+
|
|
318
|
+
const body = `---
|
|
319
|
+
status: backlog
|
|
320
|
+
date: ${date}
|
|
321
|
+
req: "${basename}"
|
|
322
|
+
squad: ""
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
# Roadmap: ${title}
|
|
326
|
+
|
|
327
|
+
> Created: ${date} | Status: backlog
|
|
328
|
+
|
|
329
|
+
## Context
|
|
330
|
+
<!-- Derived from REQ: ${basename} -->
|
|
331
|
+
REQ: ${reqPath}${adrRef}
|
|
332
|
+
|
|
333
|
+
${mlSection}
|
|
334
|
+
`
|
|
335
|
+
|
|
336
|
+
fs.writeFileSync(filename, body, 'utf8')
|
|
337
|
+
console.log(`✓ created ${filename}`)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* parseReqForRoadmap — extrai título, critérios de aceite e ADR linkada de conteúdo REQ.
|
|
342
|
+
*/
|
|
343
|
+
function parseReqForRoadmap(content) {
|
|
344
|
+
const lines = content.split('\n')
|
|
345
|
+
let title = ''
|
|
346
|
+
let linkedADR = ''
|
|
347
|
+
const criteria = []
|
|
348
|
+
let inCriteria = false
|
|
349
|
+
|
|
350
|
+
for (const line of lines) {
|
|
351
|
+
if (line.startsWith('# REQ: ')) {
|
|
352
|
+
title = line.replace('# REQ: ', '').trim()
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
if (line.startsWith('# REQ — ')) {
|
|
356
|
+
title = line.replace('# REQ — ', '').trim()
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
if (line.startsWith('# REQ - ')) {
|
|
360
|
+
title = line.replace('# REQ - ', '').trim()
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
if (line.startsWith('**ADR:**')) {
|
|
364
|
+
linkedADR = line.replace('**ADR:**', '').trim()
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const lower = line.trim().toLowerCase()
|
|
369
|
+
if (lower === '## critérios de aceite' || lower === '## acceptance criteria') {
|
|
370
|
+
inCriteria = true
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
if (inCriteria && line.startsWith('## ')) {
|
|
374
|
+
inCriteria = false
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
if (inCriteria) {
|
|
378
|
+
const trimmed = line.trim()
|
|
379
|
+
const checkboxPrefixes = ['- [ ]', '- [x]', '- [X]']
|
|
380
|
+
for (const prefix of checkboxPrefixes) {
|
|
381
|
+
if (trimmed.startsWith(prefix)) {
|
|
382
|
+
const item = trimmed.slice(prefix.length).trim().replace(/`/g, '')
|
|
383
|
+
if (item) criteria.push(item)
|
|
384
|
+
break
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { title, criteria, linkedADR }
|
|
390
|
+
}
|
|
391
|
+
|
|
252
392
|
// --- helpers ---
|
|
253
393
|
|
|
254
394
|
/**
|
|
@@ -312,6 +452,7 @@ module.exports = {
|
|
|
312
452
|
moveRoadmap,
|
|
313
453
|
appendTransitionLog,
|
|
314
454
|
newRoadmap,
|
|
455
|
+
newRoadmapFromReq,
|
|
315
456
|
stateDir,
|
|
316
457
|
agentStateDir,
|
|
317
458
|
}
|