@pasosdejesus/m 0.18.0 → 0.18.2
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/dist/m.js +108 -19
- package/dist/m.js.map +1 -1
- package/dist/tasks/req.d.ts +10 -0
- package/dist/tasks/req.d.ts.map +1 -0
- package/dist/tasks/req.js +1199 -0
- package/dist/tasks/req.js.map +1 -0
- package/package.json +1 -1
- package/src/tasks/README.md +23 -9
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
// src/tasks/req.ts
|
|
2
|
+
// req:* commands — manage cross-project requirements from REQ/ directory.
|
|
3
|
+
// Compares local REQ/*.md files with remote GitLab/GitHub issues,
|
|
4
|
+
// syncs in both directions, and detects cross-project impact.
|
|
5
|
+
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
import { success, problem, info } from '../util/colors.js';
|
|
11
|
+
import { createTranslator } from '../i18n/createTranslator.js';
|
|
12
|
+
import { detectLocale } from '../util/locale.js';
|
|
13
|
+
const reqT = createTranslator(detectLocale(), {
|
|
14
|
+
en: {
|
|
15
|
+
'col.type': 'Type',
|
|
16
|
+
'col.title': 'Title',
|
|
17
|
+
'col.status': 'Status',
|
|
18
|
+
'compare.col_type': 'Type',
|
|
19
|
+
'compare.col_title': 'Title',
|
|
20
|
+
'compare.col_local': 'Loc.',
|
|
21
|
+
'compare.col_remote': 'Rem.',
|
|
22
|
+
'compare.col_sync': 'Sync',
|
|
23
|
+
'compare.sync_legend': 'Sync: sync=synchronized loc+=local more recent rem+=remote more recent \u2014=one side only',
|
|
24
|
+
'compare.no_issues': 'No remote issues or local REQs found.',
|
|
25
|
+
'compare.remote_count': '{{0}} local, {{1}} remote ({{2}} open), {{3}} remote-only.',
|
|
26
|
+
'compare.remote_only_hint': 'Use req:sync-from-repo <N> to pull them locally.',
|
|
27
|
+
'compare.identical': '\u2705 Local REQ and remote issue are identical.',
|
|
28
|
+
'compare.diff_lines': 'Diffs: {{0}} lines.',
|
|
29
|
+
},
|
|
30
|
+
es: {
|
|
31
|
+
'col.type': 'Tipo',
|
|
32
|
+
'col.title': 'Título',
|
|
33
|
+
'col.status': 'Estado',
|
|
34
|
+
'compare.col_type': 'Tipo',
|
|
35
|
+
'compare.col_title': 'Título',
|
|
36
|
+
'compare.col_local': 'Loc.',
|
|
37
|
+
'compare.col_remote': 'Rem.',
|
|
38
|
+
'compare.col_sync': 'Sinc',
|
|
39
|
+
'compare.sync_legend': 'Sinc: sync=sincronizados loc+=local m\u00e1s reciente rem+=remoto m\u00e1s reciente \u2014=solo un lado',
|
|
40
|
+
'compare.no_issues': 'No hay issues remotos ni REQs locales.',
|
|
41
|
+
'compare.remote_count': '{{0}} locales, {{1}} remotos ({{2}} abiertos), {{3}} solo-remotos.',
|
|
42
|
+
'compare.remote_only_hint': 'Usa req:sync-from-repo <N> para traerlos localmente.',
|
|
43
|
+
'compare.identical': '\u2705 REQ local y issue remoto son id\u00e9nticos.',
|
|
44
|
+
'compare.diff_lines': 'Diferencias: {{0}} l\u00edneas.',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const CWD = process.cwd();
|
|
48
|
+
// ── Helpers ─────────────────────────────────────────────
|
|
49
|
+
async function findReqDir() {
|
|
50
|
+
// Search for REQ/ directory from CWD up to project root
|
|
51
|
+
let dir = CWD;
|
|
52
|
+
while (true) {
|
|
53
|
+
const reqPath = join(dir, 'REQ');
|
|
54
|
+
if (existsSync(reqPath) && (await readdir(reqPath)).some(f => f.match(/^\d+\.md$/))) {
|
|
55
|
+
return reqPath;
|
|
56
|
+
}
|
|
57
|
+
const parent = resolve(dir, '..');
|
|
58
|
+
if (parent === dir)
|
|
59
|
+
break;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
return join(CWD, 'REQ');
|
|
63
|
+
}
|
|
64
|
+
function parseReqFile(content, number) {
|
|
65
|
+
const lines = content.split('\n');
|
|
66
|
+
// ── Header: # TYPE-#N: Title ─────────────────────
|
|
67
|
+
let title = '';
|
|
68
|
+
let issueType = 'R';
|
|
69
|
+
const firstLine = lines[0] || '';
|
|
70
|
+
const headerMatch = firstLine.match(/^#\s*([RFIPE])-#(\d+):?\s*(.*)/);
|
|
71
|
+
if (headerMatch) {
|
|
72
|
+
issueType = headerMatch[1];
|
|
73
|
+
title = headerMatch[3]?.trim().replace(/`/g, '') || '';
|
|
74
|
+
// Strip any embedded old prefix (e.g., "I-#1 Titulo" → "Titulo")
|
|
75
|
+
title = title.replace(/^[RFIPE]-#\d+:\s*/, '');
|
|
76
|
+
}
|
|
77
|
+
// ── Fallback: old YAML frontmatter ───────────────
|
|
78
|
+
if (!title && lines[0]?.trim() === '---') {
|
|
79
|
+
const endIdx = lines.slice(1).findIndex(l => l.trim() === '---');
|
|
80
|
+
if (endIdx > 0) {
|
|
81
|
+
for (let i = 1; i < endIdx + 1; i++) {
|
|
82
|
+
const titleMatch = lines[i].match(/^title:\s*"?(.+?)"?\s*$/);
|
|
83
|
+
if (titleMatch) {
|
|
84
|
+
title = titleMatch[1]
|
|
85
|
+
.replace(/^[RFIPE]-#\d+:\s*/, '')
|
|
86
|
+
.replace(/`/g, '')
|
|
87
|
+
.trim();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// ── Fallback: old format first-line ──────────────
|
|
93
|
+
if (!title) {
|
|
94
|
+
const oldMatch = firstLine.match(/^(?:#\s*)?[RFIPE]-#\d+\s*(.*)/);
|
|
95
|
+
if (oldMatch) {
|
|
96
|
+
title = (oldMatch[1] || '').trim().replace(/`/g, '');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// ── Fallback: any line with title: ───────────────
|
|
100
|
+
if (!title) {
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
const m = line.match(/^title:\s*"?(.+?)"?\s*$/);
|
|
103
|
+
if (m) {
|
|
104
|
+
title = m[1].replace(/^[RFIPE]-#\d+:\s*/, '').replace(/`/g, '').trim();
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// ── Fallback: derive from first line ──────────────
|
|
110
|
+
if (!title) {
|
|
111
|
+
// Use whatever is after # TYPE-#N in the first line, or just "Requerimiento #N"
|
|
112
|
+
const bare = firstLine.replace(/^#\s*/, '').trim();
|
|
113
|
+
title = bare || `${issueType}-#${number}`;
|
|
114
|
+
}
|
|
115
|
+
// ── Status ───────────────────────────────────────
|
|
116
|
+
let status = '—';
|
|
117
|
+
for (let i = 0; i < lines.length; i++) {
|
|
118
|
+
const s = lines[i].match(/^\*\*Status:\*\*\s*(.+)/);
|
|
119
|
+
if (s) {
|
|
120
|
+
status = s[1].trim()
|
|
121
|
+
.replace(/`/g, '')
|
|
122
|
+
.replace(/—.*/, '')
|
|
123
|
+
.trim();
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// ── Cross-project ────────────────────────────────
|
|
128
|
+
const crossProjects = [];
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
const m = line.match(/###\s*🔲\s*Pendiente\s+en\s+`?(\S+?)`?(?:\s|$)/);
|
|
131
|
+
if (m) {
|
|
132
|
+
const proj = m[1].replace(/[`']/g, '').replace(/[.,;:]$/, '');
|
|
133
|
+
if (proj && proj !== 'm' && proj !== '`m`' && !crossProjects.includes(proj)) {
|
|
134
|
+
crossProjects.push(proj);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { number, type: issueType, title, status, crossProjects };
|
|
139
|
+
}
|
|
140
|
+
async function loadAllReqs(reqDir) {
|
|
141
|
+
if (!existsSync(reqDir))
|
|
142
|
+
return [];
|
|
143
|
+
const files = (await readdir(reqDir))
|
|
144
|
+
.filter(f => /^\d+\.md$/.test(f))
|
|
145
|
+
.map(f => parseInt(f.replace('.md', ''), 10))
|
|
146
|
+
.filter(n => !isNaN(n))
|
|
147
|
+
.sort((a, b) => a - b);
|
|
148
|
+
const reqs = [];
|
|
149
|
+
for (const n of files) {
|
|
150
|
+
const content = await readFile(join(reqDir, `${n}.md`), 'utf-8');
|
|
151
|
+
reqs.push(parseReqFile(content, n));
|
|
152
|
+
}
|
|
153
|
+
return reqs;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Translate status to English when LANG=en, keep Spanish otherwise.
|
|
157
|
+
*/
|
|
158
|
+
function translateStatus(status) {
|
|
159
|
+
if (reqT('col.status') === 'Status') {
|
|
160
|
+
const map = {
|
|
161
|
+
'Pendiente': 'Pending',
|
|
162
|
+
'En progreso': 'In progress',
|
|
163
|
+
'🔧 En progreso': '🔧 In progress',
|
|
164
|
+
'Completado': 'Completed',
|
|
165
|
+
'Bloqueado': 'Blocked',
|
|
166
|
+
'Cancelado': 'Cancelled',
|
|
167
|
+
};
|
|
168
|
+
// Try exact match first, then check if status starts with a known prefix
|
|
169
|
+
if (map[status])
|
|
170
|
+
return map[status];
|
|
171
|
+
for (const [es, en] of Object.entries(map)) {
|
|
172
|
+
if (status.startsWith(es))
|
|
173
|
+
return status.replace(es, en);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return status;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Parse a list of requirement numbers from args: "1 2 3" or "1,2,3"
|
|
180
|
+
*/
|
|
181
|
+
function parseNumberList(arg) {
|
|
182
|
+
if (!arg)
|
|
183
|
+
return [];
|
|
184
|
+
return arg.split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !isNaN(n) && n > 0);
|
|
185
|
+
}
|
|
186
|
+
// ── req:list ────────────────────────────────────────────
|
|
187
|
+
export async function runReqList() {
|
|
188
|
+
const reqDir = await findReqDir();
|
|
189
|
+
const reqs = await loadAllReqs(reqDir);
|
|
190
|
+
if (reqs.length === 0) {
|
|
191
|
+
info('No se encontraron requerimientos en REQ/.');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const hdrType = reqT('col.type').padEnd(4);
|
|
195
|
+
const hdrTitle = reqT('col.title').padEnd(34);
|
|
196
|
+
const hdrStatus = reqT('col.status').padEnd(14);
|
|
197
|
+
console.log(` # │ ${hdrType} │ ${hdrTitle} │ ${hdrStatus}`);
|
|
198
|
+
console.log(` ──────┼──────┼${'─'.repeat(36)}┼${'─'.repeat(16)}`);
|
|
199
|
+
for (const r of reqs) {
|
|
200
|
+
const status = translateStatus(r.status);
|
|
201
|
+
console.log(` ${String(r.number).padEnd(4)} │ ${r.type.padEnd(4)} │ ${r.title.slice(0, 34).padEnd(34)} │ ${status.slice(0, 14).padEnd(14)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ── req:show N ──────────────────────────────────────────
|
|
205
|
+
export async function runReqShow(number) {
|
|
206
|
+
const reqDir = await findReqDir();
|
|
207
|
+
const filePath = join(reqDir, `${number}.md`);
|
|
208
|
+
if (!existsSync(filePath)) {
|
|
209
|
+
problem(`No se encontró REQ/${number}.md`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const content = await readFile(filePath, 'utf-8');
|
|
213
|
+
console.log(content);
|
|
214
|
+
}
|
|
215
|
+
// ── req:cross ───────────────────────────────────────────
|
|
216
|
+
export async function runReqCross() {
|
|
217
|
+
const reqDir = await findReqDir();
|
|
218
|
+
const reqs = await loadAllReqs(reqDir);
|
|
219
|
+
const cross = reqs.filter(r => r.crossProjects.length > 0);
|
|
220
|
+
if (cross.length === 0) {
|
|
221
|
+
info('No se encontraron requerimientos cross-project.');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
console.log(` # │ Título │ Proyectos afectados`);
|
|
225
|
+
console.log(` ──────┼──────────────────────────────────────────────┼────────────────────`);
|
|
226
|
+
for (const r of cross) {
|
|
227
|
+
console.log(` ${String(r.number).padEnd(4)} │ ${r.title.slice(0, 38).padEnd(38)} │ ${r.crossProjects.join(', ')}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function parseGitRemote(url) {
|
|
231
|
+
// SSH: git@gitlab.com:owner/repo.git or git@github.com:owner/repo.git
|
|
232
|
+
// HTTPS: https://gitlab.com/owner/repo.git or https://github.com/owner/repo.git
|
|
233
|
+
const sshMatch = url.match(/^git@(gitlab\.com|github\.com):(.+?)(?:\.git)?$/);
|
|
234
|
+
if (sshMatch) {
|
|
235
|
+
const host = sshMatch[1];
|
|
236
|
+
const path = sshMatch[2]; // e.g. "pasosdejesus/m" or "owner/repo"
|
|
237
|
+
const parts = path.split('/');
|
|
238
|
+
if (parts.length >= 2) {
|
|
239
|
+
const owner = parts.slice(0, -1).join('/');
|
|
240
|
+
const repo = parts[parts.length - 1];
|
|
241
|
+
if (host === 'gitlab.com') {
|
|
242
|
+
return { provider: 'gitlab', project: `${owner}/${repo}`, type: 'issue' };
|
|
243
|
+
}
|
|
244
|
+
return { provider: 'github', owner, repo };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const httpsMatch = url.match(/^https:\/\/(gitlab\.com|github\.com)\/(.+?)(?:\.git)?$/);
|
|
248
|
+
if (httpsMatch) {
|
|
249
|
+
const host = httpsMatch[1];
|
|
250
|
+
const path = httpsMatch[2];
|
|
251
|
+
const parts = path.split('/');
|
|
252
|
+
if (parts.length >= 2) {
|
|
253
|
+
const owner = parts.slice(0, -1).join('/');
|
|
254
|
+
const repo = parts[parts.length - 1];
|
|
255
|
+
if (host === 'gitlab.com') {
|
|
256
|
+
return { provider: 'gitlab', project: `${owner}/${repo}`, type: 'issue' };
|
|
257
|
+
}
|
|
258
|
+
return { provider: 'github', owner, repo };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
async function loadReposConfig() {
|
|
264
|
+
const configPath = join(homedir(), '.config', 'm', 'req-repos.json');
|
|
265
|
+
if (existsSync(configPath)) {
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(await readFile(configPath, 'utf-8'));
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// fall through to git remote deduction
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Fallback: deduce from git remote origin
|
|
274
|
+
try {
|
|
275
|
+
const remoteUrl = execSync('git remote get-url origin', {
|
|
276
|
+
encoding: 'utf-8',
|
|
277
|
+
cwd: CWD,
|
|
278
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
279
|
+
}).trim();
|
|
280
|
+
if (remoteUrl) {
|
|
281
|
+
const deduced = parseGitRemote(remoteUrl);
|
|
282
|
+
if (deduced)
|
|
283
|
+
return { m: deduced };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// git not available or no remote
|
|
288
|
+
}
|
|
289
|
+
return {};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Resuelve la configuración del repo actual combinando:
|
|
293
|
+
* 1. `req-repos.json` — busca entrada que coincida con el repo actual
|
|
294
|
+
* 2. `git remote get-url origin` — fallback deducido
|
|
295
|
+
*
|
|
296
|
+
* Si req-repos.json tiene una entrada cuyo repo/project coincide con
|
|
297
|
+
* el nombre del repo deducido de git remote, usa esa entrada (permite
|
|
298
|
+
* override de provider, e.g. sivel3 con mirror GitLab → issues GitHub).
|
|
299
|
+
*/
|
|
300
|
+
async function getCurrentRepoConfig() {
|
|
301
|
+
const repos = await loadReposConfig();
|
|
302
|
+
// Deduce current repo from git remote
|
|
303
|
+
let deduced = null;
|
|
304
|
+
try {
|
|
305
|
+
const remoteUrl = execSync('git remote get-url origin', {
|
|
306
|
+
encoding: 'utf-8',
|
|
307
|
+
cwd: CWD,
|
|
308
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
309
|
+
}).trim();
|
|
310
|
+
deduced = parseGitRemote(remoteUrl);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// git not available
|
|
314
|
+
}
|
|
315
|
+
// If we have req-repos.json, try to match the current repo
|
|
316
|
+
if (deduced) {
|
|
317
|
+
const deducedName = deduced.repo || deduced.project?.split('/').pop();
|
|
318
|
+
for (const [key, cfg] of Object.entries(repos)) {
|
|
319
|
+
const cfgName = cfg.repo || cfg.project?.split('/').pop();
|
|
320
|
+
if (cfgName && deducedName && cfgName.toLowerCase() === deducedName.toLowerCase()) {
|
|
321
|
+
return cfg;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Last resort: deduced from git remote
|
|
326
|
+
return deduced;
|
|
327
|
+
}
|
|
328
|
+
async function readToken(provider) {
|
|
329
|
+
const home = homedir();
|
|
330
|
+
if (provider === 'gitlab') {
|
|
331
|
+
const file = join(home, '.config', 'm', 'gitlab-token');
|
|
332
|
+
if (existsSync(file))
|
|
333
|
+
return (await readFile(file, 'utf-8')).trim();
|
|
334
|
+
return process.env.GITLAB_TOKEN || null;
|
|
335
|
+
}
|
|
336
|
+
const file = join(home, '.config', 'm', 'github-token');
|
|
337
|
+
if (existsSync(file))
|
|
338
|
+
return (await readFile(file, 'utf-8')).trim();
|
|
339
|
+
return process.env.GITHUB_TOKEN || null;
|
|
340
|
+
}
|
|
341
|
+
async function fetchRemoteIssue(config, iid, token) {
|
|
342
|
+
if (config.provider === 'gitlab' && config.project) {
|
|
343
|
+
const ep = config.type === 'work_item' ? 'issues' : 'issues';
|
|
344
|
+
const url = `https://gitlab.com/api/v4/projects/${encodeURIComponent(config.project)}/${ep}/${iid}`;
|
|
345
|
+
const headers = token ? { 'PRIVATE-TOKEN': token } : {};
|
|
346
|
+
const res = await fetch(url, { headers });
|
|
347
|
+
if (res.status < 200 || res.status >= 300)
|
|
348
|
+
throw new Error(`GitLab API error ${res.status}: ${res.statusText}`);
|
|
349
|
+
return res.json();
|
|
350
|
+
}
|
|
351
|
+
if (config.provider === 'github' && config.owner && config.repo) {
|
|
352
|
+
const url = `https://api.github.com/repos/${config.owner}/${config.repo}/issues/${iid}`;
|
|
353
|
+
const headers = token ? { Authorization: `token ${token}` } : {};
|
|
354
|
+
const res = await fetch(url, { headers });
|
|
355
|
+
if (res.status < 200 || res.status >= 300)
|
|
356
|
+
throw new Error(`GitHub API error ${res.status}: ${res.statusText}`);
|
|
357
|
+
return res.json();
|
|
358
|
+
}
|
|
359
|
+
throw new Error('Configuración de repositorio incompleta');
|
|
360
|
+
}
|
|
361
|
+
async function fetchRemoteIssueBody(config, iid, token) {
|
|
362
|
+
try {
|
|
363
|
+
const issue = await fetchRemoteIssue(config, iid, token);
|
|
364
|
+
return (issue.body || issue.description || '').toString();
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return '';
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async function updateRemoteIssue(config, iid, title, description, token) {
|
|
371
|
+
console.log(`[updateRemoteIssue] provider=${config.provider} iid=${iid} title="${title.slice(0, 60)}" descLen=${description.length} tokenLen=${token.length}`);
|
|
372
|
+
if (config.provider === 'gitlab' && config.project) {
|
|
373
|
+
const ep = config.type === 'work_item' ? 'issues' : 'issues';
|
|
374
|
+
const url = `https://gitlab.com/api/v4/projects/${encodeURIComponent(config.project)}/${ep}/${iid}`;
|
|
375
|
+
console.log(`[updateRemoteIssue] PUT ${url}`);
|
|
376
|
+
const body = JSON.stringify({ title, description });
|
|
377
|
+
const { request: httpsRequest } = await import('node:https');
|
|
378
|
+
const res = await new Promise((resolve, reject) => {
|
|
379
|
+
const u = new URL(url);
|
|
380
|
+
const req = httpsRequest({
|
|
381
|
+
hostname: u.hostname,
|
|
382
|
+
port: u.port || 443,
|
|
383
|
+
path: u.pathname + u.search,
|
|
384
|
+
method: 'PUT',
|
|
385
|
+
headers: {
|
|
386
|
+
'PRIVATE-TOKEN': token,
|
|
387
|
+
'Content-Type': 'application/json',
|
|
388
|
+
'Content-Length': Buffer.byteLength(body),
|
|
389
|
+
},
|
|
390
|
+
}, (resp) => {
|
|
391
|
+
let data = '';
|
|
392
|
+
resp.on('data', (chunk) => data += chunk);
|
|
393
|
+
resp.on('end', () => resolve({
|
|
394
|
+
status: resp.statusCode || 500,
|
|
395
|
+
statusText: resp.statusMessage || 'Unknown',
|
|
396
|
+
text: async () => data,
|
|
397
|
+
json: async () => JSON.parse(data),
|
|
398
|
+
}));
|
|
399
|
+
});
|
|
400
|
+
req.on('error', reject);
|
|
401
|
+
req.write(body);
|
|
402
|
+
req.end();
|
|
403
|
+
});
|
|
404
|
+
if (res.status < 200 || res.status >= 300) {
|
|
405
|
+
const text = await res.text();
|
|
406
|
+
throw new Error(`GitLab API error ${res.status}: ${res.statusText} — ${text.slice(0, 200)}`);
|
|
407
|
+
}
|
|
408
|
+
return res.json();
|
|
409
|
+
}
|
|
410
|
+
if (config.provider === 'github' && config.owner && config.repo) {
|
|
411
|
+
const url = `https://api.github.com/repos/${config.owner}/${config.repo}/issues/${iid}`;
|
|
412
|
+
const body = JSON.stringify({ title, body: description });
|
|
413
|
+
const { request: httpsRequest } = await import('node:https');
|
|
414
|
+
const res = await new Promise((resolve, reject) => {
|
|
415
|
+
const u = new URL(url);
|
|
416
|
+
const req = httpsRequest({
|
|
417
|
+
hostname: u.hostname, port: u.port || 443,
|
|
418
|
+
path: u.pathname + u.search, method: 'PATCH',
|
|
419
|
+
headers: {
|
|
420
|
+
'Authorization': `token ${token}`,
|
|
421
|
+
'Content-Type': 'application/json',
|
|
422
|
+
'Content-Length': Buffer.byteLength(body),
|
|
423
|
+
'User-Agent': 'm-cli',
|
|
424
|
+
'Accept': 'application/vnd.github+json',
|
|
425
|
+
},
|
|
426
|
+
}, (resp) => {
|
|
427
|
+
let data = '';
|
|
428
|
+
resp.on('data', (chunk) => data += chunk);
|
|
429
|
+
resp.on('end', () => resolve({
|
|
430
|
+
status: resp.statusCode || 500,
|
|
431
|
+
statusText: resp.statusMessage || 'Unknown',
|
|
432
|
+
text: async () => data,
|
|
433
|
+
json: async () => JSON.parse(data),
|
|
434
|
+
}));
|
|
435
|
+
});
|
|
436
|
+
req.on('error', reject);
|
|
437
|
+
req.write(body);
|
|
438
|
+
req.end();
|
|
439
|
+
});
|
|
440
|
+
if (res.status < 200 || res.status >= 300) {
|
|
441
|
+
const text = await res.text();
|
|
442
|
+
throw new Error(`GitHub API error ${res.status}: ${res.statusText} — ${text.slice(0, 200)}`);
|
|
443
|
+
}
|
|
444
|
+
return res.json();
|
|
445
|
+
}
|
|
446
|
+
throw new Error('Configuración de repositorio incompleta');
|
|
447
|
+
}
|
|
448
|
+
async function fetchRepoIssues(config, token) {
|
|
449
|
+
if (config.provider === 'gitlab' && config.project) {
|
|
450
|
+
const ep = config.type === 'work_item' ? 'issues' : 'issues';
|
|
451
|
+
const url = `https://gitlab.com/api/v4/projects/${encodeURIComponent(config.project)}/${ep}?state=opened&per_page=100`;
|
|
452
|
+
const headers = token ? { 'PRIVATE-TOKEN': token } : {};
|
|
453
|
+
const res = await fetch(url, { headers });
|
|
454
|
+
if (res.status < 200 || res.status >= 300)
|
|
455
|
+
throw new Error(`GitLab API error ${res.status}: ${res.statusText}`);
|
|
456
|
+
const issues = await res.json();
|
|
457
|
+
return issues.map(i => ({
|
|
458
|
+
iid: i.iid,
|
|
459
|
+
title: i.title,
|
|
460
|
+
state: i.state,
|
|
461
|
+
labels: i.labels || [],
|
|
462
|
+
web_url: i.web_url || '',
|
|
463
|
+
updated_at: i.updated_at || null,
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
if (config.provider === 'github' && config.owner && config.repo) {
|
|
467
|
+
const url = `https://api.github.com/repos/${config.owner}/${config.repo}/issues?state=open&per_page=100`;
|
|
468
|
+
const headers = token ? { Authorization: `token ${token}` } : {};
|
|
469
|
+
const res = await fetch(url, { headers });
|
|
470
|
+
if (res.status < 200 || res.status >= 300)
|
|
471
|
+
throw new Error(`GitHub API error ${res.status}: ${res.statusText}`);
|
|
472
|
+
const issues = await res.json();
|
|
473
|
+
return issues
|
|
474
|
+
.filter((i) => !i.pull_request) // exclude PRs
|
|
475
|
+
.map((i) => ({
|
|
476
|
+
iid: i.number,
|
|
477
|
+
title: i.title,
|
|
478
|
+
state: i.state,
|
|
479
|
+
labels: (i.labels || []).map((l) => l.name),
|
|
480
|
+
web_url: i.html_url || '',
|
|
481
|
+
updated_at: i.updated_at || null,
|
|
482
|
+
}));
|
|
483
|
+
}
|
|
484
|
+
throw new Error('Configuración de repositorio incompleta');
|
|
485
|
+
}
|
|
486
|
+
export async function runReqListRepo() {
|
|
487
|
+
const config = await getCurrentRepoConfig();
|
|
488
|
+
if (!config) {
|
|
489
|
+
problem('No se encontró configuración de repositorio. Asegúrate de tener git remote configurado.');
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const token = await readToken(config.provider);
|
|
493
|
+
if (!token) {
|
|
494
|
+
info('[req] Sin token — intentando acceso público (solo lectura).');
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
const issues = await fetchRepoIssues(config, token || '');
|
|
498
|
+
// Load local REQs for comparison
|
|
499
|
+
let localNums = new Set();
|
|
500
|
+
try {
|
|
501
|
+
const reqDir = await findReqDir();
|
|
502
|
+
const localReqs = await loadAllReqs(reqDir);
|
|
503
|
+
localNums = new Set(localReqs.map(r => r.number));
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// No local REQs
|
|
507
|
+
}
|
|
508
|
+
if (issues.length === 0) {
|
|
509
|
+
info('No hay issues abiertos en el repositorio remoto.');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
console.log(` # │ Título │ Local │ Labels`);
|
|
513
|
+
console.log(` ──────┼────────┼───────┼──────`);
|
|
514
|
+
for (const issue of issues) {
|
|
515
|
+
const local = localNums.has(issue.iid) ? '✓' : '✗';
|
|
516
|
+
const labels = issue.labels.slice(0, 3).join(', ');
|
|
517
|
+
console.log(` ${String(issue.iid).padEnd(4)}│ ${issue.title.slice(0, 36).padEnd(36)} │ ${local} │ ${labels}`);
|
|
518
|
+
}
|
|
519
|
+
info(`\nTotal: ${issues.length} issues abiertos.`);
|
|
520
|
+
info(`✓ = tiene REQ local, ✗ = sin REQ local.`);
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
problem(`Error al listar issues: ${err.message}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// ── req:compare N / req:compare (all) ──────────────────
|
|
527
|
+
async function runCompareAll(config, token) {
|
|
528
|
+
try {
|
|
529
|
+
const issues = await fetchRepoIssues(config, token || '');
|
|
530
|
+
const reqDir = await findReqDir();
|
|
531
|
+
const localReqs = await loadAllReqs(reqDir);
|
|
532
|
+
const localNums = new Set(localReqs.map(r => r.number));
|
|
533
|
+
// Pre-load local mtimes, content fingerprints, and title format
|
|
534
|
+
const localMtimeMap = new Map();
|
|
535
|
+
const localFingerprintMap = new Map();
|
|
536
|
+
const localHasPrefix = new Map();
|
|
537
|
+
for (const r of localReqs) {
|
|
538
|
+
try {
|
|
539
|
+
const s = await stat(join(reqDir, `${r.number}.md`));
|
|
540
|
+
localMtimeMap.set(r.number, s.mtime);
|
|
541
|
+
const content = await readFile(join(reqDir, `${r.number}.md`), 'utf-8');
|
|
542
|
+
const normalized = content.trim().replace(/\r/g, '').replace(/\n{3,}/g, '\n\n');
|
|
543
|
+
localFingerprintMap.set(r.number, `${normalized.length}:${normalized.slice(0, 200)}`);
|
|
544
|
+
// Check if local REQ has proper R-#N: / F-#N: / I-#N: / P-#N: title format
|
|
545
|
+
localHasPrefix.set(r.number, /^#\s*[RFIPE]-#\d+:/.test(content));
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// file inaccessible
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (issues.length === 0 && localReqs.length === 0) {
|
|
552
|
+
info(reqT('compare.no_issues'));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const titleW = 34;
|
|
556
|
+
const stateW = 4;
|
|
557
|
+
const localW = 5;
|
|
558
|
+
const colType = reqT('compare.col_type'); // Tipo / Type
|
|
559
|
+
const colTitle = reqT('compare.col_title'); // Título / Title
|
|
560
|
+
const colLocal = reqT('compare.col_local'); // Loc.
|
|
561
|
+
const colRemote = reqT('compare.col_remote'); // Rem.
|
|
562
|
+
const colSync = reqT('compare.col_sync'); // Sinc / Sync
|
|
563
|
+
const hdr = ` # │ ${colType.padEnd(4)} │ ${colTitle.padEnd(titleW)} │ ${colLocal.padEnd(localW)} │ ${colRemote.padEnd(stateW)} │ ${colSync.padEnd(4)}`;
|
|
564
|
+
const sep = hdr.replace(/[^│]/g, '─').replace(/│/g, '┼');
|
|
565
|
+
console.log(hdr);
|
|
566
|
+
console.log(sep);
|
|
567
|
+
function shortState(s) {
|
|
568
|
+
if (s === 'opened' || s === 'open')
|
|
569
|
+
return 'open';
|
|
570
|
+
if (s === 'closed')
|
|
571
|
+
return 'clsd';
|
|
572
|
+
return s.slice(0, stateW);
|
|
573
|
+
}
|
|
574
|
+
// Sort issues by iid for consistent display
|
|
575
|
+
issues.sort((a, b) => a.iid - b.iid);
|
|
576
|
+
// Show remote issues (with local status)
|
|
577
|
+
const shown = new Set();
|
|
578
|
+
for (const issue of issues) {
|
|
579
|
+
const hasLocal = localNums.has(issue.iid);
|
|
580
|
+
const localReq = localReqs.find(r => r.number === issue.iid);
|
|
581
|
+
const typeMatch = issue.title.match(/^([RFIPE])-#[?\d]/);
|
|
582
|
+
const issueType = (hasLocal && localReq) ? localReq.type : (typeMatch ? typeMatch[1] : 'R');
|
|
583
|
+
shown.add(issue.iid);
|
|
584
|
+
let sync = '—';
|
|
585
|
+
if (hasLocal && issue.updated_at) {
|
|
586
|
+
const localMtime = localMtimeMap.get(issue.iid);
|
|
587
|
+
if (localMtime) {
|
|
588
|
+
const diffMs = new Date(issue.updated_at).getTime() - localMtime.getTime();
|
|
589
|
+
const absMin = Math.abs(Math.round(diffMs / 60000));
|
|
590
|
+
if (absMin < 1) {
|
|
591
|
+
sync = 'sync';
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
// Check content fingerprint — if identical, it's synced regardless of timestamp
|
|
595
|
+
const localFp = localFingerprintMap.get(issue.iid);
|
|
596
|
+
const remoteBody = (await fetchRemoteIssueBody(config, issue.iid, token)).trim()
|
|
597
|
+
.replace(/\r/g, '').replace(/\n{3,}/g, '\n\n');
|
|
598
|
+
const remoteFp = `${remoteBody.length}:${remoteBody.slice(0, 200)}`;
|
|
599
|
+
if (localFp && localFp === remoteFp) {
|
|
600
|
+
sync = 'sync';
|
|
601
|
+
}
|
|
602
|
+
else if (diffMs > 0) {
|
|
603
|
+
sync = 'rem+';
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
sync = 'loc+';
|
|
607
|
+
}
|
|
608
|
+
// Title format check: if local has R-#N: prefix and remote doesn't, local wins
|
|
609
|
+
if (sync === 'sync' && localHasPrefix.get(issue.iid)) {
|
|
610
|
+
const remoteHasPrefix = /^[RFIPE]-#[?\d]+:/.test(issue.title);
|
|
611
|
+
if (!remoteHasPrefix)
|
|
612
|
+
sync = 'loc+';
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const displayTitle = issue.title.replace(/^[RFIPE]-#[?\d]+:?\s*/, '').replace(/`/g, '').trim();
|
|
618
|
+
console.log(` ${String(issue.iid).padEnd(4)} │ ${issueType.padEnd(4)} │ ${displayTitle.slice(0, titleW).padEnd(titleW)} │ ${hasLocal ? ' ✓' : ' ✗'} │ ${shortState(issue.state).padEnd(stateW)} │ ${sync}`);
|
|
619
|
+
}
|
|
620
|
+
// Show local-only REQs (not in remote open issues)
|
|
621
|
+
// Try to fetch individually — they might be closed
|
|
622
|
+
for (const r of localReqs) {
|
|
623
|
+
if (!shown.has(r.number)) {
|
|
624
|
+
let remoteState = '—';
|
|
625
|
+
let remoteTitle = '';
|
|
626
|
+
let sync = '—';
|
|
627
|
+
let remoteUpdatedAt = null;
|
|
628
|
+
try {
|
|
629
|
+
const issue = await fetchRemoteIssue(config, r.number, token);
|
|
630
|
+
remoteState = issue.state || 'unknown';
|
|
631
|
+
remoteTitle = (issue.title || '').replace(/^[RFIPE]-#[?\d]+:?\s*/, '').trim();
|
|
632
|
+
remoteUpdatedAt = issue.updated_at || issue.updatedAt || null;
|
|
633
|
+
shown.add(r.number);
|
|
634
|
+
if (remoteUpdatedAt) {
|
|
635
|
+
const localMtime = localMtimeMap.get(r.number);
|
|
636
|
+
if (localMtime) {
|
|
637
|
+
const diffMs = new Date(remoteUpdatedAt).getTime() - localMtime.getTime();
|
|
638
|
+
const absMin = Math.abs(Math.round(diffMs / 60000));
|
|
639
|
+
if (absMin < 1) {
|
|
640
|
+
sync = 'sync';
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
const localFp = localFingerprintMap.get(r.number);
|
|
644
|
+
const remoteBody = (await fetchRemoteIssueBody(config, r.number, token)).trim()
|
|
645
|
+
.replace(/\r/g, '').replace(/\n{3,}/g, '\n\n');
|
|
646
|
+
const remoteFp = `${remoteBody.length}:${remoteBody.slice(0, 200)}`;
|
|
647
|
+
if (localFp && localFp === remoteFp) {
|
|
648
|
+
sync = 'sync';
|
|
649
|
+
}
|
|
650
|
+
else if (diffMs > 0) {
|
|
651
|
+
sync = 'rem+';
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
sync = 'loc+';
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
// Issue doesn't exist remotely
|
|
662
|
+
}
|
|
663
|
+
// Title format check: if local has prefix and remote doesn't, local wins
|
|
664
|
+
if (sync === 'sync' && localHasPrefix.get(r.number)) {
|
|
665
|
+
const remoteHasPrefix = /^[RFIPE]-#[?\d]+:/.test(remoteTitle || '');
|
|
666
|
+
if (!remoteHasPrefix && remoteState !== '—')
|
|
667
|
+
sync = 'loc+';
|
|
668
|
+
}
|
|
669
|
+
const title = remoteTitle || r.title;
|
|
670
|
+
console.log(` ${String(r.number).padEnd(4)} │ ${r.type.padEnd(4)} │ ${title.slice(0, titleW).padEnd(titleW)} │ ✓ │ ${shortState(remoteState).padEnd(stateW)} │ ${sync}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const localCount = localNums.size;
|
|
674
|
+
const remoteCount = shown.size;
|
|
675
|
+
const remoteOnly = [...shown].filter(n => !localNums.has(n)).length;
|
|
676
|
+
info(`\n${reqT('compare.remote_count', String(localReqs.length), String(remoteCount), String(issues.length), String(remoteOnly))}`);
|
|
677
|
+
info(reqT('compare.sync_legend'));
|
|
678
|
+
if (remoteOnly > 0) {
|
|
679
|
+
info(reqT('compare.remote_only_hint'));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch (err) {
|
|
683
|
+
problem(`Error al comparar: ${err.message}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
export async function runReqCompare(number) {
|
|
687
|
+
const config = await getCurrentRepoConfig();
|
|
688
|
+
if (!config) {
|
|
689
|
+
problem('No se encontró configuración de repositorio. Asegúrate de tener git remote configurado.');
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const token = await readToken(config.provider);
|
|
693
|
+
if (!token) {
|
|
694
|
+
info('[req] Sin token — intentando acceso público (solo lectura).');
|
|
695
|
+
}
|
|
696
|
+
// ── No number: compare all ─────────────────────
|
|
697
|
+
if (number === undefined) {
|
|
698
|
+
return runCompareAll(config, token);
|
|
699
|
+
}
|
|
700
|
+
// ── Single number: compare one ─────────────────
|
|
701
|
+
const reqDir = await findReqDir();
|
|
702
|
+
const filePath = join(reqDir, `${number}.md`);
|
|
703
|
+
if (!existsSync(filePath)) {
|
|
704
|
+
problem(`No se encontró REQ/${number}.md`);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const localContent = await readFile(filePath, 'utf-8');
|
|
708
|
+
// Diagnostic
|
|
709
|
+
const apiUrl = config.provider === 'gitlab'
|
|
710
|
+
? `https://gitlab.com/api/v4/projects/${encodeURIComponent(config.project)}/issues/${number}`
|
|
711
|
+
: `https://api.github.com/repos/${config.owner}/${config.repo}/issues/${number}`;
|
|
712
|
+
info(`[req] provider=${config.provider} url=${apiUrl}`);
|
|
713
|
+
try {
|
|
714
|
+
const issue = await fetchRemoteIssue(config, number, token);
|
|
715
|
+
const remoteDesc = issue.description || issue.body || '';
|
|
716
|
+
const remoteTitle = (issue.title || '').replace(/^[RFIPE]-#\d+:\s*/, '').trim();
|
|
717
|
+
const remoteState = issue.state || 'unknown';
|
|
718
|
+
const remoteUpdatedAt = issue.updated_at || issue.updatedAt || null;
|
|
719
|
+
// ── Header info ───────────────────────────────
|
|
720
|
+
const localMeta = parseReqFile(localContent, number);
|
|
721
|
+
const fileStat = await stat(filePath);
|
|
722
|
+
const localMtime = fileStat.mtime;
|
|
723
|
+
console.log(`\n Local : ${localMeta.type}-#${number}: ${localMeta.title}`);
|
|
724
|
+
console.log(` Remoto: #${number}: ${remoteTitle} (${remoteState})`);
|
|
725
|
+
// ── Date comparison ───────────────────────────
|
|
726
|
+
console.log(`\n 📅 Local: ${localMtime.toISOString().replace('T', ' ').slice(0, 19)}`);
|
|
727
|
+
if (remoteUpdatedAt) {
|
|
728
|
+
console.log(` 📅 Remoto: ${remoteUpdatedAt}`);
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
console.log(` 📅 Remoto: (desconocida)`);
|
|
732
|
+
}
|
|
733
|
+
// ── Sync recommendation ───────────────────────
|
|
734
|
+
if (remoteUpdatedAt) {
|
|
735
|
+
const remoteDate = new Date(remoteUpdatedAt);
|
|
736
|
+
const diffMs = remoteDate.getTime() - localMtime.getTime();
|
|
737
|
+
const diffMin = Math.round(diffMs / 60000);
|
|
738
|
+
const absMin = Math.abs(diffMin);
|
|
739
|
+
if (absMin < 1) {
|
|
740
|
+
console.log(`\n ⏱️ Diferencia: < 1 minuto — efectivamente sincronizados.`);
|
|
741
|
+
}
|
|
742
|
+
else if (diffMs > 0) {
|
|
743
|
+
const ago = absMin < 60 ? `${absMin} min` : absMin < 1440 ? `${Math.round(absMin / 60)} h` : `${Math.round(absMin / 1440)} d`;
|
|
744
|
+
console.log(`\n ⬇️ Remoto ${ago} más reciente → considera: req:sync-from-repo ${number}`);
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
const ago = absMin < 60 ? `${absMin} min` : absMin < 1440 ? `${Math.round(absMin / 60)} h` : `${Math.round(absMin / 1440)} d`;
|
|
748
|
+
console.log(`\n ⬆️ Local ${ago} más reciente → considera: req:sync-to-repo ${number}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// ── Simple diff ───────────────────────────────
|
|
752
|
+
const localLines = localContent.split('\n');
|
|
753
|
+
const remoteLines = remoteDesc.split('\n');
|
|
754
|
+
const maxLen = Math.max(localLines.length, remoteLines.length);
|
|
755
|
+
let diffs = 0;
|
|
756
|
+
// Compare titles (body diff below ignores the header line)
|
|
757
|
+
if (localMeta.title !== remoteTitle) {
|
|
758
|
+
diffs++;
|
|
759
|
+
console.log(`- title: ${remoteTitle}`);
|
|
760
|
+
console.log(`+ title: ${localMeta.title}`);
|
|
761
|
+
}
|
|
762
|
+
for (let i = 0; i < maxLen; i++) {
|
|
763
|
+
const l = localLines[i] || '';
|
|
764
|
+
const r = remoteLines[i] || '';
|
|
765
|
+
if (l !== r) {
|
|
766
|
+
diffs++;
|
|
767
|
+
if (l)
|
|
768
|
+
console.log(`- ${l}`);
|
|
769
|
+
if (r)
|
|
770
|
+
console.log(`+ ${r}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (diffs === 0) {
|
|
774
|
+
success('\n✅ REQ local y issue remoto son idénticos.');
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
console.log(`\n Diferencias: ${diffs} líneas.`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
problem(`Error al comparar: ${err.message}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
// ── req:sync-from-repo N ────────────────────────────────
|
|
785
|
+
export async function runReqSyncFromRepo(arg) {
|
|
786
|
+
const numbers = parseNumberList(arg);
|
|
787
|
+
if (numbers.length === 0) {
|
|
788
|
+
problem('Uso: req:sync-from-repo <N> [N2 N3 ...]');
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
for (const number of numbers) {
|
|
792
|
+
info(`Procesando #${number}...`);
|
|
793
|
+
await syncFromRepoOne(number);
|
|
794
|
+
}
|
|
795
|
+
success(`✅ ${numbers.length} requerimiento(s) procesado(s).`);
|
|
796
|
+
}
|
|
797
|
+
async function syncFromRepoOne(number) {
|
|
798
|
+
const reqDir = await findReqDir();
|
|
799
|
+
const config = await getCurrentRepoConfig();
|
|
800
|
+
if (!config) {
|
|
801
|
+
problem('No se encontró configuración de repositorio. Asegúrate de tener git remote configurado.');
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const token = await readToken(config.provider);
|
|
805
|
+
if (!token) {
|
|
806
|
+
info('[req] Sin token — intentando acceso público (solo lectura).');
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
const issue = await fetchRemoteIssue(config, number, token);
|
|
810
|
+
const rawBody = issue.description || issue.body || '';
|
|
811
|
+
const issueTitle = (issue.title || '').replace(/^[RFIPE]-#\d+:\s*/, '').replace(/`/g, '').trim();
|
|
812
|
+
const issueTypeMatch = (issue.title || '').match(/^([RFIPE])-#\d+:/);
|
|
813
|
+
const issueType = issueTypeMatch ? issueTypeMatch[1] : 'R';
|
|
814
|
+
const status = issue.state === 'closed' ? 'Cerrado' : 'Pendiente';
|
|
815
|
+
const createdAt = (issue.created_at || issue.createdAt || new Date().toISOString()).slice(0, 10);
|
|
816
|
+
// Build content: use raw body if it already has our header, otherwise wrap
|
|
817
|
+
let content;
|
|
818
|
+
if (rawBody.trimStart().match(/^(?:---|#\s*[RFIPE]-#)/)) {
|
|
819
|
+
content = rawBody;
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
content = `# ${issueType}-#${number}: ${issueTitle}
|
|
823
|
+
|
|
824
|
+
${rawBody || issueTitle}
|
|
825
|
+
|
|
826
|
+
---
|
|
827
|
+
|
|
828
|
+
**Created:** ${createdAt}
|
|
829
|
+
**Status:** ${status}
|
|
830
|
+
`;
|
|
831
|
+
}
|
|
832
|
+
const filePath = join(reqDir, `${number}.md`);
|
|
833
|
+
if (existsSync(filePath)) {
|
|
834
|
+
// Existing file: backup then overwrite
|
|
835
|
+
const bakPath = `${filePath}.bak`;
|
|
836
|
+
await writeFile(bakPath, await readFile(filePath, 'utf-8'));
|
|
837
|
+
info(`Respaldo creado: ${bakPath}`);
|
|
838
|
+
await writeFile(filePath, content);
|
|
839
|
+
success(`✅ REQ/${number}.md actualizado desde el repositorio remoto.`);
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
// New file: create from remote
|
|
843
|
+
await writeFile(filePath, content);
|
|
844
|
+
success(`✅ REQ/${number}.md creado desde issue remoto #${number}.`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
catch (err) {
|
|
848
|
+
problem(`Error al sincronizar: ${err.message}`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
async function createRemoteIssue(config, title, description, token) {
|
|
852
|
+
if (config.provider === 'gitlab' && config.project) {
|
|
853
|
+
const ep = config.type === 'work_item' ? 'issues' : 'issues';
|
|
854
|
+
const url = `https://gitlab.com/api/v4/projects/${encodeURIComponent(config.project)}/${ep}`;
|
|
855
|
+
const body = JSON.stringify({ title, description });
|
|
856
|
+
const { request: httpsRequest } = await import('node:https');
|
|
857
|
+
const res = await new Promise((resolve, reject) => {
|
|
858
|
+
const u = new URL(url);
|
|
859
|
+
const req = httpsRequest({
|
|
860
|
+
hostname: u.hostname, port: u.port || 443,
|
|
861
|
+
path: u.pathname + u.search, method: 'POST',
|
|
862
|
+
headers: {
|
|
863
|
+
'PRIVATE-TOKEN': token, 'Content-Type': 'application/json',
|
|
864
|
+
'Content-Length': Buffer.byteLength(body),
|
|
865
|
+
},
|
|
866
|
+
}, (resp) => {
|
|
867
|
+
let data = '';
|
|
868
|
+
resp.on('data', (chunk) => data += chunk);
|
|
869
|
+
resp.on('end', () => resolve({
|
|
870
|
+
status: resp.statusCode || 500,
|
|
871
|
+
statusText: resp.statusMessage || 'Unknown',
|
|
872
|
+
text: async () => data,
|
|
873
|
+
json: async () => JSON.parse(data),
|
|
874
|
+
}));
|
|
875
|
+
});
|
|
876
|
+
req.on('error', reject);
|
|
877
|
+
req.write(body);
|
|
878
|
+
req.end();
|
|
879
|
+
});
|
|
880
|
+
if (res.status < 200 || res.status >= 300) {
|
|
881
|
+
const text = await res.text();
|
|
882
|
+
throw new Error(`GitLab API error ${res.status}: ${res.statusText} — ${text.slice(0, 200)}`);
|
|
883
|
+
}
|
|
884
|
+
return res.json();
|
|
885
|
+
}
|
|
886
|
+
if (config.provider === 'github' && config.owner && config.repo) {
|
|
887
|
+
const url = `https://api.github.com/repos/${config.owner}/${config.repo}/issues`;
|
|
888
|
+
const body = JSON.stringify({ title, body: description });
|
|
889
|
+
const { request: httpsRequest } = await import('node:https');
|
|
890
|
+
const res = await new Promise((resolve, reject) => {
|
|
891
|
+
const u = new URL(url);
|
|
892
|
+
const req = httpsRequest({
|
|
893
|
+
hostname: u.hostname, port: u.port || 443,
|
|
894
|
+
path: u.pathname + u.search, method: 'POST',
|
|
895
|
+
headers: {
|
|
896
|
+
'Authorization': `token ${token}`,
|
|
897
|
+
'Content-Type': 'application/json',
|
|
898
|
+
'Content-Length': Buffer.byteLength(body),
|
|
899
|
+
'User-Agent': 'm-cli',
|
|
900
|
+
'Accept': 'application/vnd.github+json',
|
|
901
|
+
},
|
|
902
|
+
}, (resp) => {
|
|
903
|
+
let data = '';
|
|
904
|
+
resp.on('data', (chunk) => data += chunk);
|
|
905
|
+
resp.on('end', () => resolve({
|
|
906
|
+
status: resp.statusCode || 500,
|
|
907
|
+
statusText: resp.statusMessage || 'Unknown',
|
|
908
|
+
text: async () => data,
|
|
909
|
+
json: async () => JSON.parse(data),
|
|
910
|
+
}));
|
|
911
|
+
});
|
|
912
|
+
req.on('error', reject);
|
|
913
|
+
req.write(body);
|
|
914
|
+
req.end();
|
|
915
|
+
});
|
|
916
|
+
if (res.status < 200 || res.status >= 300) {
|
|
917
|
+
const text = await res.text();
|
|
918
|
+
throw new Error(`GitHub API error ${res.status}: ${res.statusText} — ${text.slice(0, 200)}`);
|
|
919
|
+
}
|
|
920
|
+
return res.json();
|
|
921
|
+
}
|
|
922
|
+
throw new Error('Configuración de repositorio incompleta');
|
|
923
|
+
}
|
|
924
|
+
// ── req:sync-to-repo N ──────────────────────────────────
|
|
925
|
+
export async function runReqSyncToRepo(arg) {
|
|
926
|
+
const numbers = parseNumberList(arg);
|
|
927
|
+
if (numbers.length === 0) {
|
|
928
|
+
problem('Uso: req:sync-to-repo <N> [N2 N3 ...]');
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
for (const number of numbers) {
|
|
932
|
+
info(`Procesando #${number}...`);
|
|
933
|
+
await syncToRepoOne(number);
|
|
934
|
+
}
|
|
935
|
+
success(`✅ ${numbers.length} requerimiento(s) procesado(s).`);
|
|
936
|
+
}
|
|
937
|
+
async function syncToRepoOne(number) {
|
|
938
|
+
const reqDir = await findReqDir();
|
|
939
|
+
const filePath = join(reqDir, `${number}.md`);
|
|
940
|
+
if (!existsSync(filePath)) {
|
|
941
|
+
problem(`No se encontró REQ/${number}.md`);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const config = await getCurrentRepoConfig();
|
|
945
|
+
if (!config) {
|
|
946
|
+
problem('No se encontró configuración de repositorio. Asegúrate de tener git remote configurado.');
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const token = await readToken(config.provider);
|
|
950
|
+
if (!token) {
|
|
951
|
+
problem('No se encontró token. Configúralo en ~/.config/m/github-token o variable GITHUB_TOKEN');
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const localContent = await readFile(filePath, 'utf-8');
|
|
955
|
+
const meta = parseReqFile(localContent, number);
|
|
956
|
+
console.log(`[sync-to-repo] title="${meta.title}" type=${meta.type}`);
|
|
957
|
+
console.log(`[sync-to-repo] provider=${config.provider} project=${config.project || config.owner + '/' + config.repo}`);
|
|
958
|
+
console.log(`[sync-to-repo] firstLine=${localContent.split('\n')[0]?.substring(0, 80)}`);
|
|
959
|
+
try {
|
|
960
|
+
// Try to update existing issue
|
|
961
|
+
try {
|
|
962
|
+
const meta = parseReqFile(localContent, number);
|
|
963
|
+
const fullTitle = `${meta.type}-#${number}: ${meta.title}`;
|
|
964
|
+
await updateRemoteIssue(config, number, fullTitle, localContent, token);
|
|
965
|
+
success(`✅ Issue remoto #${number} actualizado desde REQ/${number}.md.`);
|
|
966
|
+
}
|
|
967
|
+
catch (updateErr) {
|
|
968
|
+
if (updateErr.message?.includes('404')) {
|
|
969
|
+
// Issue doesn't exist — create it
|
|
970
|
+
const meta = parseReqFile(localContent, number);
|
|
971
|
+
const newIssue = await createRemoteIssue(config, `${meta.type}-#?: ${meta.title}`, localContent, token);
|
|
972
|
+
const remoteIid = newIssue.iid || newIssue.number;
|
|
973
|
+
info(`✅ Issue creado en remoto como #${remoteIid}.`);
|
|
974
|
+
if (remoteIid && remoteIid !== number) {
|
|
975
|
+
// Rename local file to match remote IID
|
|
976
|
+
const newPath = join(reqDir, `${remoteIid}.md`);
|
|
977
|
+
const { rename } = await import('node:fs/promises');
|
|
978
|
+
let newContent = localContent.replace(new RegExp(`([RFIPE])-#${number}\\b`, 'g'), `$1-#${remoteIid}`);
|
|
979
|
+
await writeFile(newPath, newContent);
|
|
980
|
+
const { unlink } = await import('node:fs/promises');
|
|
981
|
+
await unlink(filePath);
|
|
982
|
+
info(`📝 REQ renumerado: ${number}.md → ${remoteIid}.md`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
throw updateErr;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
catch (err) {
|
|
991
|
+
problem(`Error al sincronizar: ${err.message}`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// ── req:create "título" ─────────────────────────────────
|
|
995
|
+
export async function runReqCreate(title, typeArg) {
|
|
996
|
+
if (!title) {
|
|
997
|
+
problem('Uso: req:create "título" [tipo] — tipo: R (req), F (falla), I (investigación), P (prueba). Default: R');
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
// Type: first char of typeArg if it's R/F/I/P, otherwise default to R
|
|
1001
|
+
let issueType = 'R';
|
|
1002
|
+
if (typeArg) {
|
|
1003
|
+
const t = typeArg.toUpperCase().charAt(0);
|
|
1004
|
+
if (['R', 'F', 'I', 'P'].includes(t)) {
|
|
1005
|
+
issueType = t;
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
problem(`Tipo inválido: "${typeArg}". Use R, F, I o P.`);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
const config = await getCurrentRepoConfig();
|
|
1013
|
+
if (!config) {
|
|
1014
|
+
problem('No se encontró configuración de repositorio. Asegúrate de tener git remote configurado.');
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const token = await readToken(config.provider);
|
|
1018
|
+
if (!token) {
|
|
1019
|
+
problem('No se encontró token. Configúralo en ~/.config/m/github-token o variable GITHUB_TOKEN');
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
// 1. Create remote issue first — repo assigns the IID
|
|
1024
|
+
const fullTitle = `${issueType}-#?: ${title}`;
|
|
1025
|
+
const desc = `${title}\n`;
|
|
1026
|
+
const newIssue = await createRemoteIssue(config, fullTitle, desc, token);
|
|
1027
|
+
const remoteIid = newIssue.iid || newIssue.number;
|
|
1028
|
+
if (!remoteIid)
|
|
1029
|
+
throw new Error('No se pudo obtener el IID del issue creado');
|
|
1030
|
+
// 1b. Update title replacing #? with the assigned IID
|
|
1031
|
+
try {
|
|
1032
|
+
const correctTitle = `${issueType}-#${remoteIid}: ${title}`;
|
|
1033
|
+
if (config.provider === 'gitlab' && config.project) {
|
|
1034
|
+
const ep = config.type === 'work_item' ? 'issues' : 'issues';
|
|
1035
|
+
const url = `https://gitlab.com/api/v4/projects/${encodeURIComponent(config.project)}/${ep}/${remoteIid}`;
|
|
1036
|
+
await fetch(url, {
|
|
1037
|
+
method: 'PUT',
|
|
1038
|
+
headers: { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' },
|
|
1039
|
+
body: JSON.stringify({ title: correctTitle }),
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
else if (config.provider === 'github' && config.owner && config.repo) {
|
|
1043
|
+
const url = `https://api.github.com/repos/${config.owner}/${config.repo}/issues/${remoteIid}`;
|
|
1044
|
+
await fetch(url, {
|
|
1045
|
+
method: 'PATCH',
|
|
1046
|
+
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
|
|
1047
|
+
body: JSON.stringify({ title: correctTitle }),
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
// Non-critical: title update failed, issue was created with #? placeholder
|
|
1053
|
+
}
|
|
1054
|
+
// 2. Create local REQ with the assigned IID
|
|
1055
|
+
const reqDir = await findReqDir();
|
|
1056
|
+
const filePath = join(reqDir, `${remoteIid}.md`);
|
|
1057
|
+
if (existsSync(filePath)) {
|
|
1058
|
+
problem(`Ya existe REQ/${remoteIid}.md`);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const template = `# ${issueType}-#${remoteIid}: ${title}
|
|
1062
|
+
|
|
1063
|
+
${title}
|
|
1064
|
+
|
|
1065
|
+
---
|
|
1066
|
+
|
|
1067
|
+
**Created:** ${new Date().toISOString().slice(0, 10)}
|
|
1068
|
+
**Status:** Pendiente
|
|
1069
|
+
`;
|
|
1070
|
+
await writeFile(filePath, template);
|
|
1071
|
+
success(`✅ Issue #${remoteIid} creado en remoto y REQ/${remoteIid}.md local.`);
|
|
1072
|
+
}
|
|
1073
|
+
catch (err) {
|
|
1074
|
+
problem(`Error al crear: ${err.message}`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
// ── req:close N ─────────────────────────────────────────
|
|
1078
|
+
async function closeRemoteIssue(config, iid, title, token) {
|
|
1079
|
+
if (config.provider === 'gitlab' && config.project) {
|
|
1080
|
+
const ep = config.type === 'work_item' ? 'issues' : 'issues';
|
|
1081
|
+
const url = `https://gitlab.com/api/v4/projects/${encodeURIComponent(config.project)}/${ep}/${iid}`;
|
|
1082
|
+
const body = JSON.stringify({ title, state_event: 'close' });
|
|
1083
|
+
const { request: httpsRequest } = await import('node:https');
|
|
1084
|
+
const res = await new Promise((resolve, reject) => {
|
|
1085
|
+
const u = new URL(url);
|
|
1086
|
+
const req = httpsRequest({
|
|
1087
|
+
hostname: u.hostname, port: u.port || 443,
|
|
1088
|
+
path: u.pathname + u.search, method: 'PUT',
|
|
1089
|
+
headers: {
|
|
1090
|
+
'PRIVATE-TOKEN': token, 'Content-Type': 'application/json',
|
|
1091
|
+
'Content-Length': Buffer.byteLength(body),
|
|
1092
|
+
},
|
|
1093
|
+
}, (resp) => {
|
|
1094
|
+
let data = '';
|
|
1095
|
+
resp.on('data', (chunk) => data += chunk);
|
|
1096
|
+
resp.on('end', () => resolve({
|
|
1097
|
+
status: resp.statusCode || 500,
|
|
1098
|
+
statusText: resp.statusMessage || 'Unknown',
|
|
1099
|
+
text: async () => data,
|
|
1100
|
+
json: async () => JSON.parse(data),
|
|
1101
|
+
}));
|
|
1102
|
+
});
|
|
1103
|
+
req.on('error', reject);
|
|
1104
|
+
req.write(body);
|
|
1105
|
+
req.end();
|
|
1106
|
+
});
|
|
1107
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1108
|
+
const text = await res.text();
|
|
1109
|
+
throw new Error(`GitLab API error ${res.status}: ${res.statusText} — ${text.slice(0, 200)}`);
|
|
1110
|
+
}
|
|
1111
|
+
return res.json();
|
|
1112
|
+
}
|
|
1113
|
+
if (config.provider === 'github' && config.owner && config.repo) {
|
|
1114
|
+
const url = `https://api.github.com/repos/${config.owner}/${config.repo}/issues/${iid}`;
|
|
1115
|
+
const body = JSON.stringify({ title, state: 'closed' });
|
|
1116
|
+
const { request: httpsRequest } = await import('node:https');
|
|
1117
|
+
const res = await new Promise((resolve, reject) => {
|
|
1118
|
+
const u = new URL(url);
|
|
1119
|
+
const req = httpsRequest({
|
|
1120
|
+
hostname: u.hostname, port: u.port || 443,
|
|
1121
|
+
path: u.pathname + u.search, method: 'PATCH',
|
|
1122
|
+
headers: {
|
|
1123
|
+
'Authorization': `token ${token}`,
|
|
1124
|
+
'Content-Type': 'application/json',
|
|
1125
|
+
'Content-Length': Buffer.byteLength(body),
|
|
1126
|
+
'User-Agent': 'm-cli',
|
|
1127
|
+
'Accept': 'application/vnd.github+json',
|
|
1128
|
+
},
|
|
1129
|
+
}, (resp) => {
|
|
1130
|
+
let data = '';
|
|
1131
|
+
resp.on('data', (chunk) => data += chunk);
|
|
1132
|
+
resp.on('end', () => resolve({
|
|
1133
|
+
status: resp.statusCode || 500,
|
|
1134
|
+
statusText: resp.statusMessage || 'Unknown',
|
|
1135
|
+
text: async () => data,
|
|
1136
|
+
json: async () => JSON.parse(data),
|
|
1137
|
+
}));
|
|
1138
|
+
});
|
|
1139
|
+
req.on('error', reject);
|
|
1140
|
+
req.write(body);
|
|
1141
|
+
req.end();
|
|
1142
|
+
});
|
|
1143
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1144
|
+
const text = await res.text();
|
|
1145
|
+
throw new Error(`GitHub API error ${res.status}: ${res.statusText} — ${text.slice(0, 200)}`);
|
|
1146
|
+
}
|
|
1147
|
+
return res.json();
|
|
1148
|
+
}
|
|
1149
|
+
throw new Error('Configuración de repositorio incompleta');
|
|
1150
|
+
}
|
|
1151
|
+
export async function runReqClose(arg) {
|
|
1152
|
+
const numbers = parseNumberList(arg);
|
|
1153
|
+
if (numbers.length === 0) {
|
|
1154
|
+
problem('Uso: req:close <N> [N2 N3 ...]');
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
for (const number of numbers) {
|
|
1158
|
+
info(`Cerrando #${number}...`);
|
|
1159
|
+
await closeOne(number);
|
|
1160
|
+
}
|
|
1161
|
+
success(`✅ ${numbers.length} requerimiento(s) cerrado(s).`);
|
|
1162
|
+
}
|
|
1163
|
+
async function closeOne(number) {
|
|
1164
|
+
const reqDir = await findReqDir();
|
|
1165
|
+
const filePath = join(reqDir, `${number}.md`);
|
|
1166
|
+
if (!existsSync(filePath)) {
|
|
1167
|
+
problem(`No se encontró REQ/${number}.md`);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
const config = await getCurrentRepoConfig();
|
|
1171
|
+
if (!config) {
|
|
1172
|
+
problem('No se encontró configuración de repositorio. Asegúrate de tener git remote configurado.');
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const token = await readToken(config.provider);
|
|
1176
|
+
if (!token) {
|
|
1177
|
+
problem('No se encontró token. Configúralo en ~/.config/m/github-token o variable GITHUB_TOKEN');
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
// 1. Sync local → remote
|
|
1182
|
+
const localContent = await readFile(filePath, 'utf-8');
|
|
1183
|
+
const meta = parseReqFile(localContent, number);
|
|
1184
|
+
const fullTitle = `${meta.type}-#${number}: ${meta.title}`;
|
|
1185
|
+
await updateRemoteIssue(config, number, fullTitle, localContent, token);
|
|
1186
|
+
info('✓ Sincronizado local → remoto.');
|
|
1187
|
+
// 2. Close remote issue
|
|
1188
|
+
await closeRemoteIssue(config, number, fullTitle, token);
|
|
1189
|
+
info('✓ Issue cerrado en el repositorio remoto.');
|
|
1190
|
+
// 3. Delete local REQ
|
|
1191
|
+
const { unlink } = await import('node:fs/promises');
|
|
1192
|
+
await unlink(filePath);
|
|
1193
|
+
success(`✅ REQ/${number}.md cerrado y eliminado.`);
|
|
1194
|
+
}
|
|
1195
|
+
catch (err) {
|
|
1196
|
+
problem(`Error al cerrar #${number}: ${err.message}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
//# sourceMappingURL=req.js.map
|