mkpr-cli 1.0.2 → 1.0.3

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.
Files changed (2) hide show
  1. package/package.json +1 -2
  2. package/src/index.js +678 -398
package/src/index.js CHANGED
@@ -5,41 +5,29 @@ const inquirer = require('inquirer');
5
5
  const chalk = require('chalk');
6
6
  const ora = require('ora');
7
7
  const Conf = require('conf');
8
- const fetch = require('node-fetch');
9
8
  const { execSync } = require('child_process');
9
+ const { randomUUID } = require('crypto');
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
 
13
13
  // ============================================
14
- // CONFIGURATION
14
+ // FETCH COMPATIBILITY (Node 18+ native or node-fetch@2)
15
15
  // ============================================
16
16
 
17
- const config = new Conf({
18
- projectName: 'mkpr',
19
- defaults: {
20
- ollamaPort: 11434,
21
- ollamaModel: 'llama3.2',
22
- baseBranch: 'main',
23
- outputDir: '.',
24
- excludeFiles: [
25
- 'package-lock.json',
26
- 'yarn.lock',
27
- 'pnpm-lock.yaml',
28
- 'bun.lockb',
29
- 'composer.lock',
30
- 'Gemfile.lock',
31
- 'poetry.lock',
32
- 'Cargo.lock',
33
- 'pubspec.lock',
34
- 'packages.lock.json',
35
- 'gradle.lockfile',
36
- 'flake.lock'
37
- ]
17
+ let fetch;
18
+ if (globalThis.fetch) {
19
+ fetch = globalThis.fetch;
20
+ } else {
21
+ try {
22
+ fetch = require('node-fetch');
23
+ } catch {
24
+ console.error(chalk.red('❌ fetch not available. Use Node 18+ or install node-fetch@2'));
25
+ process.exit(1);
38
26
  }
39
- });
27
+ }
40
28
 
41
29
  // ============================================
42
- // EXCLUSION CONSTANTS
30
+ // CONSTANTS (Single source of truth)
43
31
  // ============================================
44
32
 
45
33
  const DEFAULT_EXCLUDES = [
@@ -86,10 +74,6 @@ const FIXED_EXCLUDE_PATTERNS = [
86
74
  '.yarn/install-state.gz'
87
75
  ];
88
76
 
89
- // ============================================
90
- // PR CHANGE TYPES
91
- // ============================================
92
-
93
77
  const PR_TYPES = [
94
78
  'feature', // New feature
95
79
  'fix', // Bug fix
@@ -99,10 +83,101 @@ const PR_TYPES = [
99
83
  'chore', // Maintenance
100
84
  'perf', // Performance improvement
101
85
  'style', // Style/formatting changes
102
- 'ci', // CI/CD
103
- 'breaking' // Breaking change
86
+ 'ci' // CI/CD
104
87
  ];
105
88
 
89
+ const FETCH_TIMEOUT_MS = 180000; // 3 minutes for PR generation (larger context)
90
+ const MAX_DIFF_LENGTH = 8000;
91
+ const MAX_BUFFER_SIZE = 1024 * 1024 * 20; // 20MB for large PRs
92
+
93
+ // ============================================
94
+ // CONFIGURATION
95
+ // ============================================
96
+
97
+ const config = new Conf({
98
+ projectName: 'mkpr',
99
+ defaults: {
100
+ ollamaPort: 11434,
101
+ ollamaModel: 'llama3.2',
102
+ baseBranch: 'main',
103
+ outputDir: '.',
104
+ excludeFiles: [...DEFAULT_EXCLUDES],
105
+ debug: false
106
+ }
107
+ });
108
+
109
+ // ============================================
110
+ // UTILITY FUNCTIONS
111
+ // ============================================
112
+
113
+ function debugLog(...args) {
114
+ if (config.get('debug')) {
115
+ console.log(chalk.gray('[DEBUG]'), ...args);
116
+ }
117
+ }
118
+
119
+ function formatSize(bytes) {
120
+ const sizes = ['B', 'KB', 'MB', 'GB'];
121
+ if (bytes === 0) return '0 B';
122
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
123
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
124
+ }
125
+
126
+ /**
127
+ * Fetch with timeout using AbortController
128
+ */
129
+ async function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) {
130
+ const controller = new AbortController();
131
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
132
+
133
+ try {
134
+ const response = await fetch(url, {
135
+ ...options,
136
+ signal: controller.signal
137
+ });
138
+ return response;
139
+ } catch (error) {
140
+ if (error.name === 'AbortError') {
141
+ throw new Error(`Request timeout after ${timeoutMs / 1000}s. The model may be too slow or Ollama is unresponsive.`);
142
+ }
143
+ throw error;
144
+ } finally {
145
+ clearTimeout(timeout);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Sanitize branch name for safe filesystem usage
151
+ */
152
+ function sanitizeBranchName(branchName) {
153
+ return branchName
154
+ .replace(/[\/\\:*?"<>|]/g, '_') // Invalid filesystem chars
155
+ .replace(/\s+/g, '_') // Spaces
156
+ .replace(/^\.+/, '') // Leading dots
157
+ .replace(/\.+$/, '') // Trailing dots
158
+ .replace(/_+/g, '_') // Multiple underscores
159
+ .substring(0, 100); // Max length
160
+ }
161
+
162
+ /**
163
+ * Validate branch name to prevent command injection
164
+ */
165
+ function isValidBranchName(branchName) {
166
+ // Git branch names can't contain: space, ~, ^, :, ?, *, [, \, or start with -
167
+ // Also reject anything that looks like command injection
168
+ const invalidPatterns = [
169
+ /\s/, // spaces
170
+ /[~^:?*\[\]\\]/, // git invalid chars
171
+ /^-/, // starts with dash
172
+ /\.\./, // double dots
173
+ /\/\//, // double slashes
174
+ /[@{}$`|;&]/, // shell dangerous chars
175
+ /^$/ // empty
176
+ ];
177
+
178
+ return !invalidPatterns.some(pattern => pattern.test(branchName));
179
+ }
180
+
106
181
  // ============================================
107
182
  // JSON SCHEMA FOR PR
108
183
  // ============================================
@@ -169,7 +244,6 @@ PR TYPES:
169
244
  - perf: Performance improvements
170
245
  - style: Code style/formatting changes
171
246
  - ci: CI/CD configuration changes
172
- - breaking: Changes that break backward compatibility
173
247
 
174
248
  OUTPUT FORMAT:
175
249
  Respond ONLY with a valid JSON object matching this schema:
@@ -214,18 +288,18 @@ Output: {
214
288
 
215
289
  function buildUserPrompt(context) {
216
290
  const { currentBranch, baseBranch, diff, commits, changedFiles, stats } = context;
217
-
291
+
218
292
  const filesSummary = changedFiles
219
293
  .map(f => `${f.status[0].toUpperCase()} ${f.file}`)
220
294
  .join('\n');
221
-
295
+
222
296
  const commitsSummary = commits
223
297
  .slice(0, 20)
224
298
  .join('\n');
225
-
299
+
226
300
  // Smart diff truncation
227
- const truncatedDiff = truncateDiffSmart(diff, 8000);
228
-
301
+ const truncatedDiff = truncateDiffSmart(diff);
302
+
229
303
  return `BRANCH INFO:
230
304
  Current branch: ${currentBranch}
231
305
  Base branch: ${baseBranch}
@@ -246,37 +320,66 @@ ${truncatedDiff}
246
320
  Generate a PR description for these changes. Respond with JSON only.`;
247
321
  }
248
322
 
249
- function truncateDiffSmart(diff, maxLength) {
250
- if (diff.length <= maxLength) {
323
+ /**
324
+ * Smart diff truncation that preserves file context
325
+ */
326
+ function truncateDiffSmart(diff) {
327
+ if (diff.length <= MAX_DIFF_LENGTH) {
251
328
  return diff;
252
329
  }
253
-
330
+
254
331
  const lines = diff.split('\n');
255
- const importantLines = [];
256
- let currentLength = 0;
257
-
332
+ const chunks = [];
333
+ let currentChunk = { header: '', file: '', lines: [] };
334
+
258
335
  for (const line of lines) {
259
- // Prioritize file headers and changes
260
- if (line.startsWith('diff --git') ||
261
- line.startsWith('+++') ||
262
- line.startsWith('---') ||
263
- line.startsWith('+') ||
264
- line.startsWith('-') ||
265
- line.startsWith('@@')) {
266
-
267
- if (currentLength + line.length < maxLength) {
268
- importantLines.push(line);
269
- currentLength += line.length + 1;
336
+ // New file header
337
+ if (line.startsWith('diff --git')) {
338
+ if (currentChunk.header) {
339
+ chunks.push(currentChunk);
270
340
  }
341
+ const match = line.match(/diff --git a\/(.+) b\/(.+)/);
342
+ currentChunk = {
343
+ header: line,
344
+ file: match ? match[2] : '',
345
+ lines: []
346
+ };
347
+ } else if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@')) {
348
+ currentChunk.lines.push(line);
349
+ } else if ((line.startsWith('+') || line.startsWith('-')) &&
350
+ !line.startsWith('+++') && !line.startsWith('---')) {
351
+ currentChunk.lines.push(line);
271
352
  }
272
353
  }
273
-
274
- let result = importantLines.join('\n');
275
- if (result.length < diff.length) {
276
- result += '\n\n[... diff truncated for length ...]';
354
+
355
+ // Don't forget last chunk
356
+ if (currentChunk.header) {
357
+ chunks.push(currentChunk);
277
358
  }
278
-
279
- return result;
359
+
360
+ // Build truncated diff prioritizing all files with some changes
361
+ const result = [];
362
+ const maxLinesPerFile = Math.max(10, Math.floor(MAX_DIFF_LENGTH / (chunks.length || 1) / 60));
363
+ let totalLength = 0;
364
+
365
+ for (const chunk of chunks) {
366
+ if (totalLength > MAX_DIFF_LENGTH) {
367
+ result.push(`\n[... ${chunks.length - result.length} more files not shown ...]`);
368
+ break;
369
+ }
370
+
371
+ result.push(chunk.header);
372
+ const importantLines = chunk.lines.slice(0, maxLinesPerFile);
373
+ result.push(...importantLines);
374
+
375
+ if (chunk.lines.length > importantLines.length) {
376
+ result.push(` ... (${chunk.lines.length - importantLines.length} more lines in ${chunk.file})`);
377
+ }
378
+
379
+ totalLength = result.join('\n').length;
380
+ }
381
+
382
+ return result.join('\n');
280
383
  }
281
384
 
282
385
  // ============================================
@@ -286,12 +389,14 @@ function truncateDiffSmart(diff, maxLength) {
286
389
  async function generatePRDescriptionText(context) {
287
390
  const port = config.get('ollamaPort');
288
391
  const model = config.get('ollamaModel');
289
-
392
+
290
393
  const systemPrompt = buildSystemPrompt();
291
394
  const userPrompt = buildUserPrompt(context);
292
-
293
- // Use /api/chat instead of /api/generate
294
- const response = await fetch(`http://localhost:${port}/api/chat`, {
395
+
396
+ debugLog('Sending request to Ollama...');
397
+ debugLog(`Model: ${model}, Port: ${port}`);
398
+
399
+ const response = await fetchWithTimeout(`http://localhost:${port}/api/chat`, {
295
400
  method: 'POST',
296
401
  headers: { 'Content-Type': 'application/json' },
297
402
  body: JSON.stringify({
@@ -309,46 +414,51 @@ async function generatePRDescriptionText(context) {
309
414
  }
310
415
  })
311
416
  });
312
-
417
+
313
418
  if (!response.ok) {
314
419
  const errorText = await response.text();
315
- throw new Error(`Ollama error: ${errorText}`);
420
+ throw new Error(`Ollama error (${response.status}): ${errorText}`);
316
421
  }
317
-
422
+
318
423
  const data = await response.json();
319
424
  const rawResponse = data.message?.content || data.response || '';
320
-
321
- // Parse and validate JSON
425
+
426
+ debugLog('Raw response:', rawResponse.substring(0, 500) + '...');
427
+
322
428
  const prData = parsePRResponse(rawResponse);
323
-
324
- // Format to Markdown
325
429
  return formatPRMarkdown(prData, context);
326
430
  }
327
431
 
328
432
  function parsePRResponse(rawResponse) {
329
433
  let jsonStr = rawResponse.trim();
330
-
434
+
331
435
  // Clean artifacts
332
436
  jsonStr = jsonStr.replace(/^```json\s*/i, '');
333
437
  jsonStr = jsonStr.replace(/^```\s*/i, '');
334
438
  jsonStr = jsonStr.replace(/```\s*$/i, '');
335
439
  jsonStr = jsonStr.trim();
336
-
440
+
337
441
  // Extract JSON if there's text before/after
338
442
  const jsonMatch = jsonStr.match(/\{[\s\S]*\}/);
339
443
  if (jsonMatch) {
340
444
  jsonStr = jsonMatch[0];
341
445
  }
342
-
446
+
343
447
  try {
344
448
  const parsed = JSON.parse(jsonStr);
345
-
346
- // Validate required fields
347
- if (!parsed.title || !parsed.type || !parsed.summary) {
348
- throw new Error('Missing required fields');
449
+
450
+ // Validate required fields with type checking
451
+ if (!parsed.title || typeof parsed.title !== 'string') {
452
+ throw new Error('Missing or invalid "title" field');
453
+ }
454
+ if (!parsed.type || typeof parsed.type !== 'string') {
455
+ throw new Error('Missing or invalid "type" field');
456
+ }
457
+ if (!parsed.summary || typeof parsed.summary !== 'string') {
458
+ throw new Error('Missing or invalid "summary" field');
349
459
  }
350
-
351
- // Validate type
460
+
461
+ // Validate and correct type
352
462
  if (!PR_TYPES.includes(parsed.type)) {
353
463
  const typeMap = {
354
464
  'feat': 'feature',
@@ -360,31 +470,54 @@ function parsePRResponse(rawResponse) {
360
470
  'testing': 'test',
361
471
  'performance': 'perf',
362
472
  'maintenance': 'chore',
363
- 'build': 'chore'
473
+ 'build': 'chore',
474
+ 'breaking': 'feature' // Breaking is indicated in breaking_changes array
364
475
  };
365
476
  parsed.type = typeMap[parsed.type.toLowerCase()] || 'chore';
366
477
  }
367
-
368
- // Ensure arrays
478
+
479
+ // Ensure arrays with type checking
369
480
  if (!Array.isArray(parsed.changes)) {
370
- parsed.changes = parsed.changes ? [parsed.changes] : [];
481
+ if (typeof parsed.changes === 'string') {
482
+ parsed.changes = [parsed.changes];
483
+ } else {
484
+ parsed.changes = [];
485
+ }
371
486
  }
487
+ parsed.changes = parsed.changes.filter(c => typeof c === 'string' && c.trim());
488
+
372
489
  if (!Array.isArray(parsed.breaking_changes)) {
373
- parsed.breaking_changes = parsed.breaking_changes ? [parsed.breaking_changes] : [];
490
+ if (typeof parsed.breaking_changes === 'string' && parsed.breaking_changes.trim()) {
491
+ parsed.breaking_changes = [parsed.breaking_changes];
492
+ } else {
493
+ parsed.breaking_changes = [];
494
+ }
495
+ }
496
+ parsed.breaking_changes = parsed.breaking_changes.filter(c => typeof c === 'string' && c.trim());
497
+
498
+ // Clean optional string fields
499
+ if (parsed.testing && typeof parsed.testing !== 'string') {
500
+ parsed.testing = '';
501
+ }
502
+ if (parsed.notes && typeof parsed.notes !== 'string') {
503
+ parsed.notes = '';
374
504
  }
375
-
505
+
506
+ // Truncate title if too long
507
+ parsed.title = parsed.title.substring(0, 72);
508
+
376
509
  return parsed;
377
-
510
+
378
511
  } catch (parseError) {
379
512
  console.log(chalk.yellow('\n⚠️ Could not parse JSON, using fallback...'));
513
+ debugLog('Parse error:', parseError.message);
380
514
  return extractPRFromText(rawResponse);
381
515
  }
382
516
  }
383
517
 
384
518
  function extractPRFromText(text) {
385
- // Fallback for when the model doesn't return valid JSON
386
519
  const lines = text.split('\n').filter(l => l.trim());
387
-
520
+
388
521
  return {
389
522
  title: lines[0]?.substring(0, 72) || 'Update code',
390
523
  type: 'chore',
@@ -400,9 +533,9 @@ function extractPRFromText(text) {
400
533
  function formatPRMarkdown(prData, context) {
401
534
  const { title, type, summary, changes, breaking_changes, testing, notes } = prData;
402
535
  const { currentBranch, baseBranch, changedFiles, commits } = context;
403
-
536
+
404
537
  let md = `# ${title}\n\n`;
405
-
538
+
406
539
  // Type badge
407
540
  const typeEmoji = {
408
541
  'feature': '✨',
@@ -413,16 +546,15 @@ function formatPRMarkdown(prData, context) {
413
546
  'chore': '🔧',
414
547
  'perf': '⚡',
415
548
  'style': '💄',
416
- 'ci': '👷',
417
- 'breaking': '💥'
549
+ 'ci': '👷'
418
550
  };
419
-
551
+
420
552
  md += `**Type:** ${typeEmoji[type] || '📦'} \`${type}\`\n\n`;
421
553
  md += `**Branch:** \`${currentBranch}\` → \`${baseBranch}\`\n\n`;
422
-
554
+
423
555
  // Description
424
556
  md += `## Description\n\n${summary}\n\n`;
425
-
557
+
426
558
  // Changes
427
559
  md += `## Changes\n\n`;
428
560
  if (changes && changes.length > 0) {
@@ -433,7 +565,7 @@ function formatPRMarkdown(prData, context) {
433
565
  md += `- General code update\n`;
434
566
  }
435
567
  md += '\n';
436
-
568
+
437
569
  // Breaking changes
438
570
  if (breaking_changes && breaking_changes.length > 0) {
439
571
  md += `## ⚠️ Breaking Changes\n\n`;
@@ -442,38 +574,38 @@ function formatPRMarkdown(prData, context) {
442
574
  });
443
575
  md += '\n';
444
576
  }
445
-
577
+
446
578
  // Testing
447
579
  if (testing) {
448
580
  md += `## Testing\n\n${testing}\n\n`;
449
581
  }
450
-
582
+
451
583
  // Stats
452
584
  md += `## Stats\n\n`;
453
585
  md += `- **Commits:** ${commits.length}\n`;
454
586
  md += `- **Files changed:** ${changedFiles.length}\n`;
455
-
587
+
456
588
  const added = changedFiles.filter(f => f.status === 'added').length;
457
589
  const modified = changedFiles.filter(f => f.status === 'modified').length;
458
590
  const deleted = changedFiles.filter(f => f.status === 'deleted').length;
459
-
591
+
460
592
  if (added) md += `- **Files added:** ${added}\n`;
461
593
  if (modified) md += `- **Files modified:** ${modified}\n`;
462
594
  if (deleted) md += `- **Files deleted:** ${deleted}\n`;
463
595
  md += '\n';
464
-
596
+
465
597
  // Notes
466
598
  if (notes) {
467
599
  md += `## Additional Notes\n\n${notes}\n\n`;
468
600
  }
469
-
601
+
470
602
  // Checklist
471
603
  md += `## Checklist\n\n`;
472
604
  md += `- [ ] Code follows project standards\n`;
473
605
  md += `- [ ] Tests have been added (if applicable)\n`;
474
606
  md += `- [ ] Documentation has been updated (if applicable)\n`;
475
607
  md += `- [ ] Changes have been tested locally\n`;
476
-
608
+
477
609
  return md;
478
610
  }
479
611
 
@@ -481,60 +613,6 @@ function formatPRMarkdown(prData, context) {
481
613
  // EXCLUDED FILES MANAGEMENT
482
614
  // ============================================
483
615
 
484
- function listExcludes() {
485
- const excludes = config.get('excludeFiles');
486
- console.log(chalk.cyan('\n🚫 Files excluded from analysis:\n'));
487
-
488
- if (excludes.length === 0) {
489
- console.log(chalk.yellow(' (none)'));
490
- } else {
491
- excludes.forEach((file, index) => {
492
- const isDefault = DEFAULT_EXCLUDES.includes(file);
493
- const tag = isDefault ? chalk.gray(' (default)') : '';
494
- console.log(chalk.white(` ${index + 1}. ${chalk.yellow(file)}${tag}`));
495
- });
496
- }
497
-
498
- console.log(chalk.cyan('\n📁 Fixed patterns (always excluded):\n'));
499
- FIXED_EXCLUDE_PATTERNS.forEach(pattern => {
500
- console.log(chalk.gray(` • ${pattern}`));
501
- });
502
- console.log();
503
- }
504
-
505
- function addExclude(file) {
506
- const excludes = config.get('excludeFiles');
507
-
508
- if (excludes.includes(file)) {
509
- console.log(chalk.yellow(`\n⚠️ "${file}" is already in the exclusion list.\n`));
510
- return;
511
- }
512
-
513
- excludes.push(file);
514
- config.set('excludeFiles', excludes);
515
- console.log(chalk.green(`\n✅ Added to exclusions: ${chalk.yellow(file)}\n`));
516
- }
517
-
518
- function removeExclude(file) {
519
- const excludes = config.get('excludeFiles');
520
- const index = excludes.indexOf(file);
521
-
522
- if (index === -1) {
523
- console.log(chalk.yellow(`\n⚠️ "${file}" is not in the exclusion list.\n`));
524
- console.log(chalk.white(' Use --list-excludes to see the current list.\n'));
525
- return;
526
- }
527
-
528
- excludes.splice(index, 1);
529
- config.set('excludeFiles', excludes);
530
- console.log(chalk.green(`\n✅ Removed from exclusions: ${chalk.yellow(file)}\n`));
531
- }
532
-
533
- function resetExcludes() {
534
- config.set('excludeFiles', [...DEFAULT_EXCLUDES]);
535
- console.log(chalk.green('\n✅ Exclusion list reset to defaults.\n'));
536
- }
537
-
538
616
  function getExcludedFiles() {
539
617
  return config.get('excludeFiles');
540
618
  }
@@ -548,29 +626,29 @@ function shouldExcludeFile(filename, excludePatterns) {
548
626
  return excludePatterns.some(pattern => {
549
627
  // Exact pattern
550
628
  if (pattern === filename) return true;
551
-
629
+
552
630
  // Pattern with wildcard at start (*.min.js)
553
631
  if (pattern.startsWith('*')) {
554
632
  const suffix = pattern.slice(1);
555
633
  if (filename.endsWith(suffix)) return true;
556
634
  }
557
-
635
+
558
636
  // Pattern with wildcard at end (dist/*)
559
637
  if (pattern.endsWith('/*')) {
560
638
  const prefix = pattern.slice(0, -2);
561
639
  if (filename.startsWith(prefix + '/') || filename === prefix) return true;
562
640
  }
563
-
641
+
564
642
  // Pattern with wildcard in middle (*.generated.*)
565
643
  if (pattern.includes('*')) {
566
644
  const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
567
645
  if (regex.test(filename)) return true;
568
646
  }
569
-
647
+
570
648
  // Match by filename (without path)
571
649
  const basename = filename.split('/').pop();
572
650
  if (pattern === basename) return true;
573
-
651
+
574
652
  return false;
575
653
  });
576
654
  }
@@ -578,315 +656,341 @@ function shouldExcludeFile(filename, excludePatterns) {
578
656
  function filterDiff(diff, excludePatterns) {
579
657
  const lines = diff.split('\n');
580
658
  const filteredLines = [];
581
- let currentFile = null;
582
659
  let excludingCurrentFile = false;
583
-
660
+
584
661
  for (const line of lines) {
585
662
  // Detect start of new file
586
663
  if (line.startsWith('diff --git')) {
587
- // Extract filename: diff --git a/path/file b/path/file
588
664
  const match = line.match(/diff --git a\/(.+) b\/(.+)/);
589
665
  if (match) {
590
- currentFile = match[2]; // Use destination file (b/)
666
+ const currentFile = match[2];
591
667
  excludingCurrentFile = shouldExcludeFile(currentFile, excludePatterns);
668
+ debugLog(`File: ${currentFile}, excluded: ${excludingCurrentFile}`);
592
669
  }
593
670
  }
594
-
595
- // Only include lines if we're not excluding the current file
671
+
596
672
  if (!excludingCurrentFile) {
597
673
  filteredLines.push(line);
598
674
  }
599
675
  }
600
-
676
+
601
677
  return filteredLines.join('\n');
602
678
  }
603
679
 
604
- // ============================================
605
- // GIT FUNCTIONS
606
- // ============================================
680
+ function listExcludes() {
681
+ const excludes = config.get('excludeFiles');
682
+ console.log(chalk.cyan('\n🚫 Files excluded from analysis:\n'));
607
683
 
608
- function getCurrentBranch() {
609
- try {
610
- return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
611
- } catch (error) {
612
- throw new Error('Could not get current branch.');
684
+ if (excludes.length === 0) {
685
+ console.log(chalk.yellow(' (none)'));
686
+ } else {
687
+ excludes.forEach((file, index) => {
688
+ const isDefault = DEFAULT_EXCLUDES.includes(file);
689
+ const tag = isDefault ? chalk.gray(' (default)') : '';
690
+ console.log(chalk.white(` ${index + 1}. ${chalk.yellow(file)}${tag}`));
691
+ });
613
692
  }
693
+
694
+ console.log(chalk.cyan('\n📁 Fixed patterns (always excluded):\n'));
695
+ FIXED_EXCLUDE_PATTERNS.forEach(pattern => {
696
+ console.log(chalk.gray(` • ${pattern}`));
697
+ });
698
+ console.log();
614
699
  }
615
700
 
616
- function getRemoteBaseBranch(baseBranch) {
617
- try {
618
- execSync(`git rev-parse origin/${baseBranch}`, { stdio: 'pipe' });
619
- return `origin/${baseBranch}`;
620
- } catch {
621
- try {
622
- execSync(`git rev-parse ${baseBranch}`, { stdio: 'pipe' });
623
- return baseBranch;
624
- } catch {
625
- throw new Error(`Base branch '${baseBranch}' not found. Verify it exists or use --base to specify another.`);
626
- }
701
+ function addExclude(file) {
702
+ const excludes = config.get('excludeFiles');
703
+
704
+ if (excludes.includes(file)) {
705
+ console.log(chalk.yellow(`\n⚠️ "${file}" is already in the exclusion list.\n`));
706
+ return;
627
707
  }
708
+
709
+ excludes.push(file);
710
+ config.set('excludeFiles', excludes);
711
+ console.log(chalk.green(`\n✅ Added to exclusions: ${chalk.yellow(file)}\n`));
628
712
  }
629
713
 
630
- function getBranchDiff(baseBranch) {
631
- try {
632
- execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
633
-
714
+ function removeExclude(file) {
715
+ const excludes = config.get('excludeFiles');
716
+ const index = excludes.indexOf(file);
717
+
718
+ if (index === -1) {
719
+ console.log(chalk.yellow(`\n⚠️ "${file}" is not in the exclusion list.\n`));
720
+ console.log(chalk.white(' Use --list-excludes to see the current list.\n'));
721
+ return;
722
+ }
723
+
724
+ excludes.splice(index, 1);
725
+ config.set('excludeFiles', excludes);
726
+ console.log(chalk.green(`\n✅ Removed from exclusions: ${chalk.yellow(file)}\n`));
727
+ }
728
+
729
+ function resetExcludes() {
730
+ config.set('excludeFiles', [...DEFAULT_EXCLUDES]);
731
+ console.log(chalk.green('\n✅ Exclusion list reset to defaults.\n'));
732
+ }
733
+
734
+ // ============================================
735
+ // GIT FUNCTIONS
736
+ // ============================================
737
+
738
+ /**
739
+ * Check if we're inside a valid git repository
740
+ */
741
+ function isGitRepository() {
742
+ try {
743
+ const result = execSync('git rev-parse --is-inside-work-tree', {
744
+ encoding: 'utf-8',
745
+ stdio: ['pipe', 'pipe', 'pipe']
746
+ }).trim();
747
+ return result === 'true';
748
+ } catch {
749
+ return false;
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Get current branch name
755
+ */
756
+ function getCurrentBranch() {
757
+ try {
758
+ return execSync('git rev-parse --abbrev-ref HEAD', {
759
+ encoding: 'utf-8',
760
+ stdio: ['pipe', 'pipe', 'pipe']
761
+ }).trim();
762
+ } catch (error) {
763
+ debugLog('Error getting current branch:', error.message);
764
+ throw new Error('Could not get current branch.');
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Validate and get remote base branch
770
+ */
771
+ function getRemoteBaseBranch(baseBranch) {
772
+ // Validate branch name first
773
+ if (!isValidBranchName(baseBranch)) {
774
+ throw new Error(`Invalid branch name: "${baseBranch}"`);
775
+ }
776
+
777
+ try {
778
+ // Try origin/branch first
779
+ execSync(`git rev-parse --verify origin/${baseBranch}`, {
780
+ stdio: ['pipe', 'pipe', 'pipe']
781
+ });
782
+ return `origin/${baseBranch}`;
783
+ } catch {
784
+ try {
785
+ // Try local branch
786
+ execSync(`git rev-parse --verify ${baseBranch}`, {
787
+ stdio: ['pipe', 'pipe', 'pipe']
788
+ });
789
+ return baseBranch;
790
+ } catch {
791
+ throw new Error(`Base branch '${baseBranch}' not found. Verify it exists or use --base to specify another.`);
792
+ }
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Get diff between current branch and base
798
+ */
799
+ function getBranchDiff(baseBranch) {
800
+ if (!isGitRepository()) {
801
+ throw new Error('You are not in a git repository. Run this command from within a git project.');
802
+ }
803
+
804
+ try {
634
805
  const currentBranch = getCurrentBranch();
635
806
  const remoteBranch = getRemoteBaseBranch(baseBranch);
636
-
637
- // Get diff without exclusions
638
- const diffCommand = `git diff ${remoteBranch}...HEAD --no-color`;
639
-
640
- let diff = execSync(diffCommand, {
807
+
808
+ debugLog(`Current branch: ${currentBranch}`);
809
+ debugLog(`Remote branch: ${remoteBranch}`);
810
+
811
+ // Get diff
812
+ let diff = execSync(`git diff ${remoteBranch}...HEAD --no-color`, {
641
813
  encoding: 'utf-8',
642
- maxBuffer: 1024 * 1024 * 10
814
+ maxBuffer: MAX_BUFFER_SIZE,
815
+ stdio: ['pipe', 'pipe', 'pipe']
643
816
  });
644
-
817
+
645
818
  if (!diff.trim()) {
646
819
  return null;
647
820
  }
648
-
821
+
649
822
  // Filter excluded files programmatically
650
823
  const excludePatterns = getAllExcludePatterns();
651
824
  diff = filterDiff(diff, excludePatterns);
652
-
825
+
653
826
  if (!diff.trim()) {
654
827
  return null;
655
828
  }
656
-
829
+
657
830
  return {
658
831
  diff,
659
832
  currentBranch,
660
833
  baseBranch: remoteBranch
661
834
  };
662
-
835
+
663
836
  } catch (error) {
664
- if (error.message.includes('not a git repository')) {
837
+ const errorMsg = error.message || error.stderr || String(error);
838
+
839
+ if (errorMsg.includes('not a git repository')) {
665
840
  throw new Error('You are not in a git repository.');
666
841
  }
667
- if (error.message.includes('ENOBUFS') || error.message.includes('maxBuffer')) {
842
+ if (errorMsg.includes('ENOBUFS') || errorMsg.includes('maxBuffer')) {
668
843
  throw new Error('The diff is too large. Consider splitting the PR.');
669
844
  }
845
+
846
+ debugLog('Error getting branch diff:', errorMsg);
670
847
  throw error;
671
848
  }
672
849
  }
673
850
 
851
+ /**
852
+ * Get list of commits between branches
853
+ */
674
854
  function getCommitsList(baseBranch) {
675
855
  try {
676
856
  const remoteBranch = getRemoteBaseBranch(baseBranch);
677
- const commits = execSync(`git log ${remoteBranch}..HEAD --oneline --no-decorate`, {
857
+ const commits = execSync(`git log ${remoteBranch}..HEAD --oneline --no-decorate`, {
678
858
  encoding: 'utf-8',
679
- maxBuffer: 1024 * 1024
859
+ maxBuffer: MAX_BUFFER_SIZE,
860
+ stdio: ['pipe', 'pipe', 'pipe']
680
861
  });
681
862
  return commits.trim().split('\n').filter(c => c);
682
- } catch {
863
+ } catch (error) {
864
+ debugLog('Error getting commits:', error.message);
683
865
  return [];
684
866
  }
685
867
  }
686
868
 
869
+ /**
870
+ * Get list of changed files with status
871
+ */
687
872
  function getChangedFiles(baseBranch) {
688
873
  try {
689
874
  const remoteBranch = getRemoteBaseBranch(baseBranch);
690
875
  const excludePatterns = getAllExcludePatterns();
691
-
692
- const files = execSync(`git diff ${remoteBranch}...HEAD --name-status`, {
876
+
877
+ const files = execSync(`git diff ${remoteBranch}...HEAD --name-status`, {
693
878
  encoding: 'utf-8',
694
- maxBuffer: 1024 * 1024
879
+ maxBuffer: MAX_BUFFER_SIZE,
880
+ stdio: ['pipe', 'pipe', 'pipe']
695
881
  });
696
-
882
+
697
883
  return files.trim().split('\n').filter(f => f).map(line => {
698
884
  const [status, ...fileParts] = line.split('\t');
699
885
  const file = fileParts.join('\t');
700
886
  const statusMap = { 'A': 'added', 'M': 'modified', 'D': 'deleted', 'R': 'renamed' };
701
- return {
702
- status: statusMap[status[0]] || status,
887
+ return {
888
+ status: statusMap[status[0]] || status,
703
889
  statusCode: status[0],
704
890
  file,
705
891
  excluded: shouldExcludeFile(file, excludePatterns)
706
892
  };
707
893
  });
708
- } catch {
894
+ } catch (error) {
895
+ debugLog('Error getting changed files:', error.message);
709
896
  return [];
710
897
  }
711
898
  }
712
899
 
900
+ /**
901
+ * Get diff statistics
902
+ */
713
903
  function getFilesStats(baseBranch) {
714
904
  try {
715
905
  const remoteBranch = getRemoteBaseBranch(baseBranch);
716
- const stats = execSync(`git diff ${remoteBranch}...HEAD --stat`, {
906
+ const stats = execSync(`git diff ${remoteBranch}...HEAD --stat`, {
717
907
  encoding: 'utf-8',
718
- maxBuffer: 1024 * 1024
908
+ maxBuffer: MAX_BUFFER_SIZE,
909
+ stdio: ['pipe', 'pipe', 'pipe']
719
910
  });
720
911
  return stats.trim();
721
- } catch {
722
- return '';
912
+ } catch (error) {
913
+ debugLog('Error getting stats:', error.message);
914
+ return '(stats unavailable)';
723
915
  }
724
916
  }
725
917
 
726
- function sanitizeBranchName(branchName) {
727
- return branchName.replace(/[\/\\:*?"<>|]/g, '_');
918
+ /**
919
+ * Fetch latest from origin
920
+ */
921
+ function fetchOrigin() {
922
+ try {
923
+ execSync('git fetch origin', {
924
+ stdio: ['pipe', 'pipe', 'pipe'],
925
+ timeout: 30000 // 30 second timeout
926
+ });
927
+ return { success: true };
928
+ } catch (error) {
929
+ const errorMsg = error.message || '';
930
+ debugLog('Fetch error:', errorMsg);
931
+
932
+ // Categorize the error
933
+ if (errorMsg.includes('Could not resolve host') || errorMsg.includes('unable to access')) {
934
+ return { success: false, reason: 'network', message: 'No network connection' };
935
+ }
936
+ if (errorMsg.includes('Authentication failed') || errorMsg.includes('Permission denied')) {
937
+ return { success: false, reason: 'auth', message: 'Authentication failed' };
938
+ }
939
+ if (errorMsg.includes('timeout')) {
940
+ return { success: false, reason: 'timeout', message: 'Connection timeout' };
941
+ }
942
+
943
+ return { success: false, reason: 'unknown', message: 'Unknown error' };
944
+ }
728
945
  }
729
946
 
947
+ /**
948
+ * Save PR description to file
949
+ */
730
950
  function savePRDescription(content, branchName, outputDir) {
731
951
  const sanitizedName = sanitizeBranchName(branchName);
732
952
  const fileName = `${sanitizedName}_pr.md`;
733
-
734
- // Resolver el directorio de salida respecto al directorio de trabajo actual
953
+
735
954
  const resolvedOutputDir = path.resolve(process.cwd(), outputDir);
736
955
  const filePath = path.join(resolvedOutputDir, fileName);
737
-
956
+
738
957
  if (!fs.existsSync(resolvedOutputDir)) {
739
958
  fs.mkdirSync(resolvedOutputDir, { recursive: true });
740
959
  }
741
-
960
+
742
961
  fs.writeFileSync(filePath, content, 'utf-8');
743
962
  return filePath;
744
963
  }
745
964
 
746
965
  // ============================================
747
- // CLI
966
+ // OLLAMA FUNCTIONS
748
967
  // ============================================
749
968
 
750
- const program = new Command();
751
-
752
- program
753
- .name('mkpr')
754
- .description(chalk.cyan('🚀 CLI to generate PR descriptions using Ollama AI'))
755
- .version('1.0.0');
756
-
757
- program
758
- .option('--set-model [model]', 'Set the Ollama model to use (interactive if omitted)')
759
- .option('--set-port <port>', 'Set the Ollama port')
760
- .option('--set-base <branch>', 'Set the base branch for comparison (default: main)')
761
- .option('--set-output <dir>', 'Set output directory for PR files')
762
- .option('--show-config', 'Show current configuration')
763
- .option('--list-models', 'List available models in Ollama')
764
- .option('--add-exclude <file>', 'Add file to exclusion list')
765
- .option('--remove-exclude <file>', 'Remove file from exclusion list')
766
- .option('--list-excludes', 'List excluded files')
767
- .option('--reset-excludes', 'Reset exclusion list to defaults')
768
- .option('-b, --base <branch>', 'Base branch for this run (not saved)')
769
- .option('-o, --output <dir>', 'Output directory for this run (not saved)')
770
- .option('--dry-run', 'Only show description without saving file')
771
- .action(async (options) => {
772
- try {
773
- if (options.showConfig) {
774
- showConfig();
775
- return;
776
- }
777
-
778
- if (options.listModels) {
779
- await listModels();
780
- return;
781
- }
782
-
783
- if (options.listExcludes) {
784
- listExcludes();
785
- return;
786
- }
787
-
788
- if (options.addExclude) {
789
- addExclude(options.addExclude);
790
- return;
791
- }
792
-
793
- if (options.removeExclude) {
794
- removeExclude(options.removeExclude);
795
- return;
796
- }
797
-
798
- if (options.resetExcludes) {
799
- resetExcludes();
800
- return;
801
- }
802
-
803
- if (options.setPort) {
804
- const port = parseInt(options.setPort);
805
- if (isNaN(port) || port < 1 || port > 65535) {
806
- console.log(chalk.red('❌ Invalid port. Must be a number between 1 and 65535.'));
807
- process.exit(1);
808
- }
809
- config.set('ollamaPort', port);
810
- console.log(chalk.green(`✅ Port set to: ${port}`));
811
- }
812
-
813
- if (options.setModel !== undefined) {
814
- if (options.setModel === true) {
815
- // --set-model sin valor = modo interactivo
816
- await changeModelInteractive();
817
- } else {
818
- // --set-model <valor> = establecer directamente
819
- await setModel(options.setModel);
820
- }
821
- }
822
-
823
- if (options.setBase) {
824
- config.set('baseBranch', options.setBase);
825
- console.log(chalk.green(`✅ Base branch set to: ${options.setBase}`));
826
- }
827
-
828
- if (options.setOutput) {
829
- config.set('outputDir', options.setOutput);
830
- console.log(chalk.green(`✅ Output directory set to: ${options.setOutput}`));
831
- }
832
-
833
- if (options.setPort || options.setModel !== undefined || options.setBase || options.setOutput) {
834
- return;
835
- }
836
-
837
- const baseBranch = options.base || config.get('baseBranch');
838
- const outputDir = options.output || config.get('outputDir');
839
- const dryRun = options.dryRun || false;
840
-
841
- await generatePRDescription(baseBranch, outputDir, dryRun);
842
-
843
- } catch (error) {
844
- console.error(chalk.red(`❌ Error: ${error.message}`));
845
- process.exit(1);
846
- }
847
- });
848
-
849
- program.parse();
850
-
851
- // ============================================
852
- // CONFIGURATION FUNCTIONS
853
- // ============================================
854
-
855
- function showConfig() {
856
- console.log(chalk.cyan('\n📋 Current configuration:\n'));
857
- console.log(chalk.white(` Ollama Port: ${chalk.yellow(config.get('ollamaPort'))}`));
858
- console.log(chalk.white(` Model: ${chalk.yellow(config.get('ollamaModel'))}`));
859
- console.log(chalk.white(` Base branch: ${chalk.yellow(config.get('baseBranch'))}`));
860
- console.log(chalk.white(` Output directory: ${chalk.yellow(config.get('outputDir'))}`));
861
- console.log(chalk.white(` Excluded files: ${chalk.gray(config.get('excludeFiles').length + ' files')}`));
862
- console.log();
863
- }
864
-
865
969
  async function getAvailableModels() {
866
970
  const port = config.get('ollamaPort');
867
- const response = await fetch(`http://localhost:${port}/api/tags`);
868
-
971
+ const response = await fetchWithTimeout(`http://localhost:${port}/api/tags`, {}, 10000);
972
+
869
973
  if (!response.ok) {
870
974
  throw new Error(`Could not connect to Ollama on port ${port}`);
871
975
  }
872
-
976
+
873
977
  const data = await response.json();
874
978
  return data.models || [];
875
979
  }
876
980
 
877
981
  async function listModels() {
878
982
  const spinner = ora('Getting model list...').start();
879
-
983
+
880
984
  try {
881
985
  const models = await getAvailableModels();
882
986
  spinner.stop();
883
-
987
+
884
988
  if (models.length === 0) {
885
989
  console.log(chalk.yellow('\n⚠️ No models installed in Ollama.'));
886
990
  console.log(chalk.white(' Run: ollama pull <model> to download one.\n'));
887
991
  return;
888
992
  }
889
-
993
+
890
994
  console.log(chalk.cyan('\n📦 Available models in Ollama:\n'));
891
995
  models.forEach((model, index) => {
892
996
  const name = model.name || model.model;
@@ -895,7 +999,7 @@ async function listModels() {
895
999
  console.log(chalk.white(` ${index + 1}. ${chalk.yellow(name)} ${chalk.gray(`(${size})`)}${current}`));
896
1000
  });
897
1001
  console.log();
898
-
1002
+
899
1003
  } catch (error) {
900
1004
  spinner.fail('Error connecting to Ollama');
901
1005
  console.log(chalk.red(`\n❌ ${error.message}`));
@@ -903,23 +1007,18 @@ async function listModels() {
903
1007
  }
904
1008
  }
905
1009
 
906
- function formatSize(bytes) {
907
- const sizes = ['B', 'KB', 'MB', 'GB'];
908
- if (bytes === 0) return '0 B';
909
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
910
- return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
911
- }
912
-
913
1010
  async function setModel(modelName) {
914
1011
  const spinner = ora('Verifying model...').start();
915
-
1012
+
916
1013
  try {
917
1014
  const models = await getAvailableModels();
918
1015
  const modelNames = models.map(m => m.name || m.model);
919
-
1016
+
920
1017
  const exactMatch = modelNames.find(name => name === modelName);
921
- const partialMatch = modelNames.find(name => name.startsWith(modelName + ':') || name.split(':')[0] === modelName);
922
-
1018
+ const partialMatch = modelNames.find(name =>
1019
+ name.startsWith(modelName + ':') || name.split(':')[0] === modelName
1020
+ );
1021
+
923
1022
  if (exactMatch) {
924
1023
  config.set('ollamaModel', exactMatch);
925
1024
  spinner.succeed(`Model set to: ${chalk.yellow(exactMatch)}`);
@@ -936,7 +1035,7 @@ async function setModel(modelName) {
936
1035
  console.log();
937
1036
  process.exit(1);
938
1037
  }
939
-
1038
+
940
1039
  } catch (error) {
941
1040
  spinner.fail('Error verifying model');
942
1041
  console.log(chalk.red(`\n❌ ${error.message}`));
@@ -946,16 +1045,16 @@ async function setModel(modelName) {
946
1045
 
947
1046
  async function changeModelInteractive() {
948
1047
  const spinner = ora('Getting available models...').start();
949
-
1048
+
950
1049
  try {
951
1050
  const models = await getAvailableModels();
952
1051
  spinner.stop();
953
-
1052
+
954
1053
  if (models.length === 0) {
955
1054
  console.log(chalk.yellow('\n⚠️ No models installed in Ollama.\n'));
956
1055
  return;
957
1056
  }
958
-
1057
+
959
1058
  const currentModel = config.get('ollamaModel');
960
1059
  const choices = models.map(model => {
961
1060
  const name = model.name || model.model;
@@ -967,7 +1066,7 @@ async function changeModelInteractive() {
967
1066
  short: name
968
1067
  };
969
1068
  });
970
-
1069
+
971
1070
  const { selectedModel } = await inquirer.prompt([
972
1071
  {
973
1072
  type: 'list',
@@ -977,10 +1076,10 @@ async function changeModelInteractive() {
977
1076
  default: currentModel
978
1077
  }
979
1078
  ]);
980
-
1079
+
981
1080
  config.set('ollamaModel', selectedModel);
982
1081
  console.log(chalk.green(`\n✅ Model changed to: ${chalk.yellow(selectedModel)}`));
983
-
1082
+
984
1083
  } catch (error) {
985
1084
  spinner.fail('Error getting models');
986
1085
  console.log(chalk.red(`\n❌ ${error.message}`));
@@ -988,55 +1087,76 @@ async function changeModelInteractive() {
988
1087
  }
989
1088
  }
990
1089
 
1090
+ // ============================================
1091
+ // CONFIGURATION DISPLAY
1092
+ // ============================================
1093
+
1094
+ function showConfig() {
1095
+ console.log(chalk.cyan('\n📋 Current configuration:\n'));
1096
+ console.log(chalk.white(` Ollama Port: ${chalk.yellow(config.get('ollamaPort'))}`));
1097
+ console.log(chalk.white(` Model: ${chalk.yellow(config.get('ollamaModel'))}`));
1098
+ console.log(chalk.white(` Base branch: ${chalk.yellow(config.get('baseBranch'))}`));
1099
+ console.log(chalk.white(` Output directory: ${chalk.yellow(config.get('outputDir'))}`));
1100
+ console.log(chalk.white(` Debug: ${chalk.yellow(config.get('debug') ? 'enabled' : 'disabled')}`));
1101
+ console.log(chalk.white(` Excluded files: ${chalk.gray(config.get('excludeFiles').length + ' files')}`));
1102
+ console.log();
1103
+ }
1104
+
991
1105
  // ============================================
992
1106
  // MAIN FLOW
993
1107
  // ============================================
994
1108
 
995
1109
  async function generatePRDescription(baseBranch, outputDir, dryRun) {
996
1110
  console.log(chalk.cyan('\n🔍 Analyzing differences with base branch...\n'));
997
-
1111
+
998
1112
  // Fetch to ensure we have the latest version
999
1113
  const fetchSpinner = ora('Getting latest changes from origin...').start();
1000
- try {
1001
- execSync('git fetch origin', { stdio: 'pipe' });
1114
+ const fetchResult = fetchOrigin();
1115
+
1116
+ if (fetchResult.success) {
1002
1117
  fetchSpinner.succeed('Repository updated');
1003
- } catch {
1004
- fetchSpinner.warn('Could not fetch (continuing with local data)');
1118
+ } else {
1119
+ if (fetchResult.reason === 'auth') {
1120
+ fetchSpinner.fail(`Could not fetch: ${fetchResult.message}`);
1121
+ console.log(chalk.yellow(' Continuing with local data, but results may be outdated.\n'));
1122
+ } else {
1123
+ fetchSpinner.warn(`Could not fetch (${fetchResult.message}), continuing with local data`);
1124
+ }
1005
1125
  }
1006
-
1126
+
1007
1127
  const diffData = getBranchDiff(baseBranch);
1008
-
1128
+
1009
1129
  if (!diffData) {
1010
1130
  console.log(chalk.yellow('⚠️ No differences with base branch.'));
1011
1131
  console.log(chalk.white(` Your branch is up to date with ${baseBranch}.\n`));
1012
1132
  process.exit(0);
1013
1133
  }
1014
-
1134
+
1015
1135
  const commits = getCommitsList(baseBranch);
1016
1136
  const changedFiles = getChangedFiles(baseBranch);
1017
1137
  const stats = getFilesStats(baseBranch);
1018
-
1138
+
1019
1139
  // Filter excluded files for display
1020
1140
  const includedFiles = changedFiles.filter(f => !f.excluded);
1021
1141
  const excludedFiles = changedFiles.filter(f => f.excluded);
1022
-
1142
+
1023
1143
  console.log(chalk.white(`📌 Current branch: ${chalk.yellow(diffData.currentBranch)}`));
1024
1144
  console.log(chalk.white(`📌 Base branch: ${chalk.yellow(diffData.baseBranch)}`));
1025
1145
  console.log(chalk.white(`📝 Commits: ${chalk.yellow(commits.length)}`));
1026
1146
  console.log(chalk.white(`📁 Files: ${chalk.yellow(includedFiles.length)} ${excludedFiles.length > 0 ? chalk.gray(`(${excludedFiles.length} excluded)`) : ''}`));
1027
1147
  console.log();
1028
-
1148
+
1029
1149
  // Show changed files
1030
1150
  console.log(chalk.white('📁 Modified files:'));
1031
1151
  includedFiles.slice(0, 10).forEach(f => {
1032
- const statusColor = f.status === 'added' ? chalk.green :
1033
- f.status === 'deleted' ? chalk.red : chalk.yellow;
1152
+ const statusColor = f.status === 'added' ? chalk.green :
1153
+ f.status === 'deleted' ? chalk.red : chalk.yellow;
1034
1154
  console.log(chalk.gray(` ${statusColor(`[${f.statusCode}]`)} ${f.file}`));
1035
1155
  });
1036
1156
  if (includedFiles.length > 10) {
1037
1157
  console.log(chalk.gray(` ... and ${includedFiles.length - 10} more files`));
1038
1158
  }
1039
-
1159
+
1040
1160
  // Show excluded files
1041
1161
  if (excludedFiles.length > 0) {
1042
1162
  console.log(chalk.gray(`\n🚫 Excluded from analysis (${excludedFiles.length}):`));
@@ -1048,7 +1168,7 @@ async function generatePRDescription(baseBranch, outputDir, dryRun) {
1048
1168
  }
1049
1169
  }
1050
1170
  console.log();
1051
-
1171
+
1052
1172
  const context = {
1053
1173
  currentBranch: diffData.currentBranch,
1054
1174
  baseBranch: diffData.baseBranch,
@@ -1057,15 +1177,15 @@ async function generatePRDescription(baseBranch, outputDir, dryRun) {
1057
1177
  changedFiles: includedFiles,
1058
1178
  stats
1059
1179
  };
1060
-
1180
+
1061
1181
  let continueLoop = true;
1062
-
1182
+
1063
1183
  while (continueLoop) {
1064
1184
  const spinner = ora({
1065
1185
  text: `Generating description with ${chalk.yellow(config.get('ollamaModel'))}...`,
1066
1186
  spinner: 'dots'
1067
1187
  }).start();
1068
-
1188
+
1069
1189
  let prDescription;
1070
1190
  try {
1071
1191
  prDescription = await generatePRDescriptionText(context);
@@ -1076,27 +1196,28 @@ async function generatePRDescription(baseBranch, outputDir, dryRun) {
1076
1196
  console.log(chalk.white(' Verify that Ollama is running and the model is available.\n'));
1077
1197
  process.exit(1);
1078
1198
  }
1079
-
1199
+
1080
1200
  console.log(chalk.cyan('\n📝 Proposed PR description:\n'));
1081
1201
  console.log(chalk.gray('─'.repeat(60)));
1082
1202
  console.log(prDescription);
1083
1203
  console.log(chalk.gray('─'.repeat(60)));
1084
1204
  console.log();
1085
-
1205
+
1086
1206
  const choices = [
1087
1207
  { name: chalk.green('✅ Accept and save file'), value: 'accept' },
1088
1208
  { name: chalk.yellow('🔄 Generate another description'), value: 'regenerate' },
1089
1209
  { name: chalk.blue('✏️ Edit title manually'), value: 'edit' },
1210
+ { name: chalk.cyan('📋 Copy to clipboard'), value: 'copy' },
1090
1211
  new inquirer.Separator(),
1091
1212
  { name: chalk.magenta('🤖 Change model'), value: 'change-model' },
1092
1213
  new inquirer.Separator(),
1093
1214
  { name: chalk.red('❌ Cancel'), value: 'cancel' }
1094
1215
  ];
1095
-
1216
+
1096
1217
  if (dryRun) {
1097
1218
  choices[0] = { name: chalk.green('✅ Accept (dry-run, will not save)'), value: 'accept' };
1098
1219
  }
1099
-
1220
+
1100
1221
  const { action } = await inquirer.prompt([
1101
1222
  {
1102
1223
  type: 'list',
@@ -1105,7 +1226,7 @@ async function generatePRDescription(baseBranch, outputDir, dryRun) {
1105
1226
  choices
1106
1227
  }
1107
1228
  ]);
1108
-
1229
+
1109
1230
  switch (action) {
1110
1231
  case 'accept':
1111
1232
  if (dryRun) {
@@ -1123,11 +1244,11 @@ async function generatePRDescription(baseBranch, outputDir, dryRun) {
1123
1244
  }
1124
1245
  continueLoop = false;
1125
1246
  break;
1126
-
1247
+
1127
1248
  case 'regenerate':
1128
1249
  console.log(chalk.cyan('\n🔄 Generating new description...\n'));
1129
1250
  break;
1130
-
1251
+
1131
1252
  case 'edit':
1132
1253
  const { editedTitle } = await inquirer.prompt([
1133
1254
  {
@@ -1137,10 +1258,9 @@ async function generatePRDescription(baseBranch, outputDir, dryRun) {
1137
1258
  default: diffData.currentBranch.replace(/[-_]/g, ' ')
1138
1259
  }
1139
1260
  ]);
1140
-
1141
- // Replace title in markdown
1261
+
1142
1262
  const finalDescription = prDescription.replace(/^# .+$/m, `# ${editedTitle}`);
1143
-
1263
+
1144
1264
  if (!dryRun) {
1145
1265
  const editSaveSpinner = ora('Saving file...').start();
1146
1266
  try {
@@ -1155,16 +1275,176 @@ async function generatePRDescription(baseBranch, outputDir, dryRun) {
1155
1275
  }
1156
1276
  continueLoop = false;
1157
1277
  break;
1158
-
1278
+
1279
+ case 'copy':
1280
+ try {
1281
+ // Try to copy to clipboard using available system commands
1282
+ const copyCommands = [
1283
+ { cmd: 'pbcopy', platform: 'darwin' },
1284
+ { cmd: 'xclip -selection clipboard', platform: 'linux' },
1285
+ { cmd: 'xsel --clipboard --input', platform: 'linux' },
1286
+ { cmd: 'clip', platform: 'win32' }
1287
+ ];
1288
+
1289
+ const platform = process.platform;
1290
+ let copied = false;
1291
+
1292
+ for (const { cmd, platform: p } of copyCommands) {
1293
+ if (p === platform || (p === 'linux' && platform === 'linux')) {
1294
+ try {
1295
+ execSync(cmd, {
1296
+ input: prDescription,
1297
+ stdio: ['pipe', 'pipe', 'pipe']
1298
+ });
1299
+ copied = true;
1300
+ console.log(chalk.green('\n✅ Copied to clipboard!\n'));
1301
+ break;
1302
+ } catch {
1303
+ continue;
1304
+ }
1305
+ }
1306
+ }
1307
+
1308
+ if (!copied) {
1309
+ console.log(chalk.yellow('\n⚠️ Could not copy to clipboard. Save the file instead.\n'));
1310
+ }
1311
+ } catch {
1312
+ console.log(chalk.yellow('\n⚠️ Clipboard not available on this system.\n'));
1313
+ }
1314
+ break;
1315
+
1159
1316
  case 'change-model':
1160
1317
  await changeModelInteractive();
1161
1318
  console.log(chalk.cyan('\n🔄 Regenerating description with new model...\n'));
1162
1319
  break;
1163
-
1320
+
1164
1321
  case 'cancel':
1165
1322
  console.log(chalk.yellow('\n👋 Operation cancelled.\n'));
1166
1323
  continueLoop = false;
1167
1324
  break;
1168
1325
  }
1169
1326
  }
1170
- }
1327
+ }
1328
+
1329
+ // ============================================
1330
+ // CLI DEFINITION
1331
+ // ============================================
1332
+
1333
+ const program = new Command();
1334
+
1335
+ program
1336
+ .name('mkpr')
1337
+ .description(chalk.cyan('🚀 CLI to generate PR descriptions using Ollama AI'))
1338
+ .version('1.1.0');
1339
+
1340
+ program
1341
+ .option('--set-model [model]', 'Set the Ollama model to use (interactive if omitted)')
1342
+ .option('--set-port <port>', 'Set the Ollama port')
1343
+ .option('--set-base <branch>', 'Set the base branch for comparison (default: main)')
1344
+ .option('--set-output <dir>', 'Set output directory for PR files')
1345
+ .option('--show-config', 'Show current configuration')
1346
+ .option('--list-models', 'List available models in Ollama')
1347
+ .option('--add-exclude <file>', 'Add file to exclusion list')
1348
+ .option('--remove-exclude <file>', 'Remove file from exclusion list')
1349
+ .option('--list-excludes', 'List excluded files')
1350
+ .option('--reset-excludes', 'Reset exclusion list to defaults')
1351
+ .option('-b, --base <branch>', 'Base branch for this run (not saved)')
1352
+ .option('-o, --output <dir>', 'Output directory for this run (not saved)')
1353
+ .option('--dry-run', 'Only show description without saving file')
1354
+ .option('--debug', 'Enable debug mode')
1355
+ .action(async (options) => {
1356
+ try {
1357
+ // Handle debug flag
1358
+ if (options.debug) {
1359
+ config.set('debug', true);
1360
+ console.log(chalk.gray('[DEBUG] Debug mode enabled'));
1361
+ }
1362
+
1363
+ if (options.showConfig) {
1364
+ showConfig();
1365
+ return;
1366
+ }
1367
+
1368
+ if (options.listModels) {
1369
+ await listModels();
1370
+ return;
1371
+ }
1372
+
1373
+ if (options.listExcludes) {
1374
+ listExcludes();
1375
+ return;
1376
+ }
1377
+
1378
+ if (options.addExclude) {
1379
+ addExclude(options.addExclude);
1380
+ return;
1381
+ }
1382
+
1383
+ if (options.removeExclude) {
1384
+ removeExclude(options.removeExclude);
1385
+ return;
1386
+ }
1387
+
1388
+ if (options.resetExcludes) {
1389
+ resetExcludes();
1390
+ return;
1391
+ }
1392
+
1393
+ if (options.setPort) {
1394
+ const port = parseInt(options.setPort);
1395
+ if (isNaN(port) || port < 1 || port > 65535) {
1396
+ console.log(chalk.red('❌ Invalid port. Must be a number between 1 and 65535.'));
1397
+ process.exit(1);
1398
+ }
1399
+ config.set('ollamaPort', port);
1400
+ console.log(chalk.green(`✅ Port set to: ${port}`));
1401
+ }
1402
+
1403
+ if (options.setModel !== undefined) {
1404
+ if (options.setModel === true) {
1405
+ await changeModelInteractive();
1406
+ } else {
1407
+ await setModel(options.setModel);
1408
+ }
1409
+ }
1410
+
1411
+ if (options.setBase) {
1412
+ if (!isValidBranchName(options.setBase)) {
1413
+ console.log(chalk.red(`❌ Invalid branch name: "${options.setBase}"`));
1414
+ process.exit(1);
1415
+ }
1416
+ config.set('baseBranch', options.setBase);
1417
+ console.log(chalk.green(`✅ Base branch set to: ${options.setBase}`));
1418
+ }
1419
+
1420
+ if (options.setOutput) {
1421
+ config.set('outputDir', options.setOutput);
1422
+ console.log(chalk.green(`✅ Output directory set to: ${options.setOutput}`));
1423
+ }
1424
+
1425
+ if (options.setPort || options.setModel !== undefined || options.setBase || options.setOutput) {
1426
+ return;
1427
+ }
1428
+
1429
+ // Validate base branch from options
1430
+ const baseBranch = options.base || config.get('baseBranch');
1431
+ if (!isValidBranchName(baseBranch)) {
1432
+ console.log(chalk.red(`❌ Invalid base branch name: "${baseBranch}"`));
1433
+ process.exit(1);
1434
+ }
1435
+
1436
+ const outputDir = options.output || config.get('outputDir');
1437
+ const dryRun = options.dryRun || false;
1438
+
1439
+ await generatePRDescription(baseBranch, outputDir, dryRun);
1440
+
1441
+ } catch (error) {
1442
+ console.error(chalk.red(`❌ Error: ${error.message}`));
1443
+ if (config.get('debug')) {
1444
+ console.error(error.stack);
1445
+ }
1446
+ process.exit(1);
1447
+ }
1448
+ });
1449
+
1450
+ program.parse();