@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.
@@ -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