mkpr-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,1161 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const inquirer = require('inquirer');
5
+ const chalk = require('chalk');
6
+ const ora = require('ora');
7
+ const Conf = require('conf');
8
+ const fetch = require('node-fetch');
9
+ const { execSync } = require('child_process');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ // ============================================
14
+ // CONFIGURATION
15
+ // ============================================
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
+ ]
38
+ }
39
+ });
40
+
41
+ // ============================================
42
+ // EXCLUSION CONSTANTS
43
+ // ============================================
44
+
45
+ const DEFAULT_EXCLUDES = [
46
+ 'package-lock.json',
47
+ 'yarn.lock',
48
+ 'pnpm-lock.yaml',
49
+ 'bun.lockb',
50
+ 'composer.lock',
51
+ 'Gemfile.lock',
52
+ 'poetry.lock',
53
+ 'Cargo.lock',
54
+ 'pubspec.lock',
55
+ 'packages.lock.json',
56
+ 'gradle.lockfile',
57
+ 'flake.lock'
58
+ ];
59
+
60
+ const FIXED_EXCLUDE_PATTERNS = [
61
+ // Minified files
62
+ '*.min.js',
63
+ '*.min.css',
64
+ '*.bundle.js',
65
+ '*.chunk.js',
66
+ // Build directories
67
+ 'dist/*',
68
+ 'build/*',
69
+ '.next/*',
70
+ '.nuxt/*',
71
+ '.output/*',
72
+ // Source maps
73
+ '*.map',
74
+ // Generated files
75
+ '*.generated.*',
76
+ // Binaries and heavy assets
77
+ '*.woff',
78
+ '*.woff2',
79
+ '*.ttf',
80
+ '*.eot',
81
+ '*.ico',
82
+ // Yarn PnP
83
+ '.pnp.cjs',
84
+ '.pnp.loader.mjs',
85
+ '.yarn/cache/*',
86
+ '.yarn/install-state.gz'
87
+ ];
88
+
89
+ // ============================================
90
+ // PR CHANGE TYPES
91
+ // ============================================
92
+
93
+ const PR_TYPES = [
94
+ 'feature', // New feature
95
+ 'fix', // Bug fix
96
+ 'refactor', // Refactoring
97
+ 'docs', // Documentation
98
+ 'test', // Tests
99
+ 'chore', // Maintenance
100
+ 'perf', // Performance improvement
101
+ 'style', // Style/formatting changes
102
+ 'ci', // CI/CD
103
+ 'breaking' // Breaking change
104
+ ];
105
+
106
+ // ============================================
107
+ // JSON SCHEMA FOR PR
108
+ // ============================================
109
+
110
+ const PR_SCHEMA = {
111
+ type: "object",
112
+ properties: {
113
+ title: {
114
+ type: "string",
115
+ description: "A clear, concise PR title (max 72 chars)"
116
+ },
117
+ type: {
118
+ type: "string",
119
+ enum: PR_TYPES,
120
+ description: "The type of change this PR introduces"
121
+ },
122
+ summary: {
123
+ type: "string",
124
+ description: "A 2-3 sentence summary of what this PR does and why"
125
+ },
126
+ changes: {
127
+ type: "array",
128
+ items: { type: "string" },
129
+ description: "List of specific changes made in this PR"
130
+ },
131
+ breaking_changes: {
132
+ type: "array",
133
+ items: { type: "string" },
134
+ description: "List of breaking changes, if any. Empty array if none."
135
+ },
136
+ testing: {
137
+ type: "string",
138
+ description: "How the changes were tested or should be tested"
139
+ },
140
+ notes: {
141
+ type: "string",
142
+ description: "Any additional notes for reviewers. Optional."
143
+ }
144
+ },
145
+ required: ["title", "type", "summary", "changes"]
146
+ };
147
+
148
+ // ============================================
149
+ // PROMPT BUILDER
150
+ // ============================================
151
+
152
+ function buildSystemPrompt() {
153
+ return `You are a PR description generator. Analyze git diffs and generate clear, professional Pull Request descriptions.
154
+
155
+ RULES:
156
+ 1. Title must be clear, concise, and under 72 characters
157
+ 2. Summary should explain WHAT the PR does and WHY (not HOW)
158
+ 3. Changes should be specific, actionable items
159
+ 4. Identify breaking changes if any
160
+ 5. Be professional but concise
161
+
162
+ PR TYPES:
163
+ - feature: New functionality for users
164
+ - fix: Bug fix
165
+ - refactor: Code restructuring without behavior change
166
+ - docs: Documentation changes only
167
+ - test: Adding or updating tests
168
+ - chore: Maintenance tasks, dependencies
169
+ - perf: Performance improvements
170
+ - style: Code style/formatting changes
171
+ - ci: CI/CD configuration changes
172
+ - breaking: Changes that break backward compatibility
173
+
174
+ OUTPUT FORMAT:
175
+ Respond ONLY with a valid JSON object matching this schema:
176
+ ${JSON.stringify(PR_SCHEMA, null, 2)}
177
+
178
+ EXAMPLES:
179
+
180
+ Input: Branch "feature/user-auth" with changes to login system
181
+ Output: {
182
+ "title": "Add OAuth2 authentication support",
183
+ "type": "feature",
184
+ "summary": "Implements OAuth2 authentication flow allowing users to sign in with Google and GitHub. This replaces the legacy session-based auth system.",
185
+ "changes": [
186
+ "Add OAuth2 provider configuration",
187
+ "Implement callback handlers for Google and GitHub",
188
+ "Create user linking for existing accounts",
189
+ "Add logout flow for OAuth sessions"
190
+ ],
191
+ "breaking_changes": [
192
+ "Session-based auth endpoints are deprecated",
193
+ "User table schema updated with provider columns"
194
+ ],
195
+ "testing": "Tested OAuth flow manually with test accounts. Added integration tests for callback handlers.",
196
+ "notes": "Requires OAUTH_CLIENT_ID and OAUTH_SECRET env vars to be set."
197
+ }
198
+
199
+ Input: Branch "fix/null-pointer" fixing a crash
200
+ Output: {
201
+ "title": "Fix null pointer exception in user profile",
202
+ "type": "fix",
203
+ "summary": "Fixes a crash that occurred when viewing profiles of deleted users. The issue was caused by missing null checks.",
204
+ "changes": [
205
+ "Add null check before accessing user.profile",
206
+ "Return 404 for deleted user profiles",
207
+ "Add defensive coding in ProfileService"
208
+ ],
209
+ "breaking_changes": [],
210
+ "testing": "Added unit test for deleted user edge case. Verified fix in staging.",
211
+ "notes": ""
212
+ }`;
213
+ }
214
+
215
+ function buildUserPrompt(context) {
216
+ const { currentBranch, baseBranch, diff, commits, changedFiles, stats } = context;
217
+
218
+ const filesSummary = changedFiles
219
+ .map(f => `${f.status[0].toUpperCase()} ${f.file}`)
220
+ .join('\n');
221
+
222
+ const commitsSummary = commits
223
+ .slice(0, 20)
224
+ .join('\n');
225
+
226
+ // Smart diff truncation
227
+ const truncatedDiff = truncateDiffSmart(diff, 8000);
228
+
229
+ return `BRANCH INFO:
230
+ Current branch: ${currentBranch}
231
+ Base branch: ${baseBranch}
232
+
233
+ COMMITS (${commits.length}):
234
+ ${commitsSummary}
235
+ ${commits.length > 20 ? `\n... and ${commits.length - 20} more commits` : ''}
236
+
237
+ FILES CHANGED (${changedFiles.length}):
238
+ ${filesSummary}
239
+
240
+ STATS:
241
+ ${stats}
242
+
243
+ DIFF:
244
+ ${truncatedDiff}
245
+
246
+ Generate a PR description for these changes. Respond with JSON only.`;
247
+ }
248
+
249
+ function truncateDiffSmart(diff, maxLength) {
250
+ if (diff.length <= maxLength) {
251
+ return diff;
252
+ }
253
+
254
+ const lines = diff.split('\n');
255
+ const importantLines = [];
256
+ let currentLength = 0;
257
+
258
+ 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;
270
+ }
271
+ }
272
+ }
273
+
274
+ let result = importantLines.join('\n');
275
+ if (result.length < diff.length) {
276
+ result += '\n\n[... diff truncated for length ...]';
277
+ }
278
+
279
+ return result;
280
+ }
281
+
282
+ // ============================================
283
+ // PR GENERATION
284
+ // ============================================
285
+
286
+ async function generatePRDescriptionText(context) {
287
+ const port = config.get('ollamaPort');
288
+ const model = config.get('ollamaModel');
289
+
290
+ const systemPrompt = buildSystemPrompt();
291
+ const userPrompt = buildUserPrompt(context);
292
+
293
+ // Use /api/chat instead of /api/generate
294
+ const response = await fetch(`http://localhost:${port}/api/chat`, {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({
298
+ model: model,
299
+ messages: [
300
+ { role: 'system', content: systemPrompt },
301
+ { role: 'user', content: userPrompt }
302
+ ],
303
+ stream: false,
304
+ format: 'json',
305
+ options: {
306
+ temperature: 0.2,
307
+ num_predict: 1500,
308
+ top_p: 0.9
309
+ }
310
+ })
311
+ });
312
+
313
+ if (!response.ok) {
314
+ const errorText = await response.text();
315
+ throw new Error(`Ollama error: ${errorText}`);
316
+ }
317
+
318
+ const data = await response.json();
319
+ const rawResponse = data.message?.content || data.response || '';
320
+
321
+ // Parse and validate JSON
322
+ const prData = parsePRResponse(rawResponse);
323
+
324
+ // Format to Markdown
325
+ return formatPRMarkdown(prData, context);
326
+ }
327
+
328
+ function parsePRResponse(rawResponse) {
329
+ let jsonStr = rawResponse.trim();
330
+
331
+ // Clean artifacts
332
+ jsonStr = jsonStr.replace(/^```json\s*/i, '');
333
+ jsonStr = jsonStr.replace(/^```\s*/i, '');
334
+ jsonStr = jsonStr.replace(/```\s*$/i, '');
335
+ jsonStr = jsonStr.trim();
336
+
337
+ // Extract JSON if there's text before/after
338
+ const jsonMatch = jsonStr.match(/\{[\s\S]*\}/);
339
+ if (jsonMatch) {
340
+ jsonStr = jsonMatch[0];
341
+ }
342
+
343
+ try {
344
+ 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');
349
+ }
350
+
351
+ // Validate type
352
+ if (!PR_TYPES.includes(parsed.type)) {
353
+ const typeMap = {
354
+ 'feat': 'feature',
355
+ 'bug': 'fix',
356
+ 'bugfix': 'fix',
357
+ 'doc': 'docs',
358
+ 'documentation': 'docs',
359
+ 'tests': 'test',
360
+ 'testing': 'test',
361
+ 'performance': 'perf',
362
+ 'maintenance': 'chore',
363
+ 'build': 'chore'
364
+ };
365
+ parsed.type = typeMap[parsed.type.toLowerCase()] || 'chore';
366
+ }
367
+
368
+ // Ensure arrays
369
+ if (!Array.isArray(parsed.changes)) {
370
+ parsed.changes = parsed.changes ? [parsed.changes] : [];
371
+ }
372
+ if (!Array.isArray(parsed.breaking_changes)) {
373
+ parsed.breaking_changes = parsed.breaking_changes ? [parsed.breaking_changes] : [];
374
+ }
375
+
376
+ return parsed;
377
+
378
+ } catch (parseError) {
379
+ console.log(chalk.yellow('\nāš ļø Could not parse JSON, using fallback...'));
380
+ return extractPRFromText(rawResponse);
381
+ }
382
+ }
383
+
384
+ function extractPRFromText(text) {
385
+ // Fallback for when the model doesn't return valid JSON
386
+ const lines = text.split('\n').filter(l => l.trim());
387
+
388
+ return {
389
+ title: lines[0]?.substring(0, 72) || 'Update code',
390
+ type: 'chore',
391
+ summary: lines.slice(0, 3).join(' ').substring(0, 500),
392
+ changes: lines.filter(l => l.startsWith('-') || l.startsWith('*'))
393
+ .map(l => l.replace(/^[-*]\s*/, '')),
394
+ breaking_changes: [],
395
+ testing: '',
396
+ notes: ''
397
+ };
398
+ }
399
+
400
+ function formatPRMarkdown(prData, context) {
401
+ const { title, type, summary, changes, breaking_changes, testing, notes } = prData;
402
+ const { currentBranch, baseBranch, changedFiles, commits } = context;
403
+
404
+ let md = `# ${title}\n\n`;
405
+
406
+ // Type badge
407
+ const typeEmoji = {
408
+ 'feature': '✨',
409
+ 'fix': 'šŸ›',
410
+ 'refactor': 'ā™»ļø',
411
+ 'docs': 'šŸ“š',
412
+ 'test': '🧪',
413
+ 'chore': 'šŸ”§',
414
+ 'perf': '⚔',
415
+ 'style': 'šŸ’„',
416
+ 'ci': 'šŸ‘·',
417
+ 'breaking': 'šŸ’„'
418
+ };
419
+
420
+ md += `**Type:** ${typeEmoji[type] || 'šŸ“¦'} \`${type}\`\n\n`;
421
+ md += `**Branch:** \`${currentBranch}\` → \`${baseBranch}\`\n\n`;
422
+
423
+ // Description
424
+ md += `## Description\n\n${summary}\n\n`;
425
+
426
+ // Changes
427
+ md += `## Changes\n\n`;
428
+ if (changes && changes.length > 0) {
429
+ changes.forEach(change => {
430
+ md += `- ${change}\n`;
431
+ });
432
+ } else {
433
+ md += `- General code update\n`;
434
+ }
435
+ md += '\n';
436
+
437
+ // Breaking changes
438
+ if (breaking_changes && breaking_changes.length > 0) {
439
+ md += `## āš ļø Breaking Changes\n\n`;
440
+ breaking_changes.forEach(bc => {
441
+ md += `- ${bc}\n`;
442
+ });
443
+ md += '\n';
444
+ }
445
+
446
+ // Testing
447
+ if (testing) {
448
+ md += `## Testing\n\n${testing}\n\n`;
449
+ }
450
+
451
+ // Stats
452
+ md += `## Stats\n\n`;
453
+ md += `- **Commits:** ${commits.length}\n`;
454
+ md += `- **Files changed:** ${changedFiles.length}\n`;
455
+
456
+ const added = changedFiles.filter(f => f.status === 'added').length;
457
+ const modified = changedFiles.filter(f => f.status === 'modified').length;
458
+ const deleted = changedFiles.filter(f => f.status === 'deleted').length;
459
+
460
+ if (added) md += `- **Files added:** ${added}\n`;
461
+ if (modified) md += `- **Files modified:** ${modified}\n`;
462
+ if (deleted) md += `- **Files deleted:** ${deleted}\n`;
463
+ md += '\n';
464
+
465
+ // Notes
466
+ if (notes) {
467
+ md += `## Additional Notes\n\n${notes}\n\n`;
468
+ }
469
+
470
+ // Checklist
471
+ md += `## Checklist\n\n`;
472
+ md += `- [ ] Code follows project standards\n`;
473
+ md += `- [ ] Tests have been added (if applicable)\n`;
474
+ md += `- [ ] Documentation has been updated (if applicable)\n`;
475
+ md += `- [ ] Changes have been tested locally\n`;
476
+
477
+ return md;
478
+ }
479
+
480
+ // ============================================
481
+ // EXCLUDED FILES MANAGEMENT
482
+ // ============================================
483
+
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
+ function getExcludedFiles() {
539
+ return config.get('excludeFiles');
540
+ }
541
+
542
+ function getAllExcludePatterns() {
543
+ const configExcludes = getExcludedFiles();
544
+ return [...configExcludes, ...FIXED_EXCLUDE_PATTERNS];
545
+ }
546
+
547
+ function shouldExcludeFile(filename, excludePatterns) {
548
+ return excludePatterns.some(pattern => {
549
+ // Exact pattern
550
+ if (pattern === filename) return true;
551
+
552
+ // Pattern with wildcard at start (*.min.js)
553
+ if (pattern.startsWith('*')) {
554
+ const suffix = pattern.slice(1);
555
+ if (filename.endsWith(suffix)) return true;
556
+ }
557
+
558
+ // Pattern with wildcard at end (dist/*)
559
+ if (pattern.endsWith('/*')) {
560
+ const prefix = pattern.slice(0, -2);
561
+ if (filename.startsWith(prefix + '/') || filename === prefix) return true;
562
+ }
563
+
564
+ // Pattern with wildcard in middle (*.generated.*)
565
+ if (pattern.includes('*')) {
566
+ const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
567
+ if (regex.test(filename)) return true;
568
+ }
569
+
570
+ // Match by filename (without path)
571
+ const basename = filename.split('/').pop();
572
+ if (pattern === basename) return true;
573
+
574
+ return false;
575
+ });
576
+ }
577
+
578
+ function filterDiff(diff, excludePatterns) {
579
+ const lines = diff.split('\n');
580
+ const filteredLines = [];
581
+ let currentFile = null;
582
+ let excludingCurrentFile = false;
583
+
584
+ for (const line of lines) {
585
+ // Detect start of new file
586
+ if (line.startsWith('diff --git')) {
587
+ // Extract filename: diff --git a/path/file b/path/file
588
+ const match = line.match(/diff --git a\/(.+) b\/(.+)/);
589
+ if (match) {
590
+ currentFile = match[2]; // Use destination file (b/)
591
+ excludingCurrentFile = shouldExcludeFile(currentFile, excludePatterns);
592
+ }
593
+ }
594
+
595
+ // Only include lines if we're not excluding the current file
596
+ if (!excludingCurrentFile) {
597
+ filteredLines.push(line);
598
+ }
599
+ }
600
+
601
+ return filteredLines.join('\n');
602
+ }
603
+
604
+ // ============================================
605
+ // GIT FUNCTIONS
606
+ // ============================================
607
+
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.');
613
+ }
614
+ }
615
+
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
+ }
627
+ }
628
+ }
629
+
630
+ function getBranchDiff(baseBranch) {
631
+ try {
632
+ execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
633
+
634
+ const currentBranch = getCurrentBranch();
635
+ 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, {
641
+ encoding: 'utf-8',
642
+ maxBuffer: 1024 * 1024 * 10
643
+ });
644
+
645
+ if (!diff.trim()) {
646
+ return null;
647
+ }
648
+
649
+ // Filter excluded files programmatically
650
+ const excludePatterns = getAllExcludePatterns();
651
+ diff = filterDiff(diff, excludePatterns);
652
+
653
+ if (!diff.trim()) {
654
+ return null;
655
+ }
656
+
657
+ return {
658
+ diff,
659
+ currentBranch,
660
+ baseBranch: remoteBranch
661
+ };
662
+
663
+ } catch (error) {
664
+ if (error.message.includes('not a git repository')) {
665
+ throw new Error('You are not in a git repository.');
666
+ }
667
+ if (error.message.includes('ENOBUFS') || error.message.includes('maxBuffer')) {
668
+ throw new Error('The diff is too large. Consider splitting the PR.');
669
+ }
670
+ throw error;
671
+ }
672
+ }
673
+
674
+ function getCommitsList(baseBranch) {
675
+ try {
676
+ const remoteBranch = getRemoteBaseBranch(baseBranch);
677
+ const commits = execSync(`git log ${remoteBranch}..HEAD --oneline --no-decorate`, {
678
+ encoding: 'utf-8',
679
+ maxBuffer: 1024 * 1024
680
+ });
681
+ return commits.trim().split('\n').filter(c => c);
682
+ } catch {
683
+ return [];
684
+ }
685
+ }
686
+
687
+ function getChangedFiles(baseBranch) {
688
+ try {
689
+ const remoteBranch = getRemoteBaseBranch(baseBranch);
690
+ const excludePatterns = getAllExcludePatterns();
691
+
692
+ const files = execSync(`git diff ${remoteBranch}...HEAD --name-status`, {
693
+ encoding: 'utf-8',
694
+ maxBuffer: 1024 * 1024
695
+ });
696
+
697
+ return files.trim().split('\n').filter(f => f).map(line => {
698
+ const [status, ...fileParts] = line.split('\t');
699
+ const file = fileParts.join('\t');
700
+ const statusMap = { 'A': 'added', 'M': 'modified', 'D': 'deleted', 'R': 'renamed' };
701
+ return {
702
+ status: statusMap[status[0]] || status,
703
+ statusCode: status[0],
704
+ file,
705
+ excluded: shouldExcludeFile(file, excludePatterns)
706
+ };
707
+ });
708
+ } catch {
709
+ return [];
710
+ }
711
+ }
712
+
713
+ function getFilesStats(baseBranch) {
714
+ try {
715
+ const remoteBranch = getRemoteBaseBranch(baseBranch);
716
+ const stats = execSync(`git diff ${remoteBranch}...HEAD --stat`, {
717
+ encoding: 'utf-8',
718
+ maxBuffer: 1024 * 1024
719
+ });
720
+ return stats.trim();
721
+ } catch {
722
+ return '';
723
+ }
724
+ }
725
+
726
+ function sanitizeBranchName(branchName) {
727
+ return branchName.replace(/[\/\\:*?"<>|]/g, '_');
728
+ }
729
+
730
+ function savePRDescription(content, branchName, outputDir) {
731
+ const sanitizedName = sanitizeBranchName(branchName);
732
+ const fileName = `${sanitizedName}_pr.md`;
733
+ const filePath = path.join(outputDir, fileName);
734
+
735
+ if (!fs.existsSync(outputDir)) {
736
+ fs.mkdirSync(outputDir, { recursive: true });
737
+ }
738
+
739
+ fs.writeFileSync(filePath, content, 'utf-8');
740
+ return filePath;
741
+ }
742
+
743
+ // ============================================
744
+ // CLI
745
+ // ============================================
746
+
747
+ const program = new Command();
748
+
749
+ program
750
+ .name('mkpr')
751
+ .description(chalk.cyan('šŸš€ CLI to generate PR descriptions using Ollama AI'))
752
+ .version('1.0.0');
753
+
754
+ program
755
+ .option('--set-model <model>', 'Set the Ollama model to use')
756
+ .option('--set-port <port>', 'Set the Ollama port')
757
+ .option('--set-base <branch>', 'Set the base branch for comparison (default: main)')
758
+ .option('--set-output <dir>', 'Set output directory for PR files')
759
+ .option('--show-config', 'Show current configuration')
760
+ .option('--list-models', 'List available models in Ollama')
761
+ .option('--add-exclude <file>', 'Add file to exclusion list')
762
+ .option('--remove-exclude <file>', 'Remove file from exclusion list')
763
+ .option('--list-excludes', 'List excluded files')
764
+ .option('--reset-excludes', 'Reset exclusion list to defaults')
765
+ .option('-b, --base <branch>', 'Base branch for this run (not saved)')
766
+ .option('-o, --output <dir>', 'Output directory for this run (not saved)')
767
+ .option('--dry-run', 'Only show description without saving file')
768
+ .action(async (options) => {
769
+ try {
770
+ if (options.showConfig) {
771
+ showConfig();
772
+ return;
773
+ }
774
+
775
+ if (options.listModels) {
776
+ await listModels();
777
+ return;
778
+ }
779
+
780
+ if (options.listExcludes) {
781
+ listExcludes();
782
+ return;
783
+ }
784
+
785
+ if (options.addExclude) {
786
+ addExclude(options.addExclude);
787
+ return;
788
+ }
789
+
790
+ if (options.removeExclude) {
791
+ removeExclude(options.removeExclude);
792
+ return;
793
+ }
794
+
795
+ if (options.resetExcludes) {
796
+ resetExcludes();
797
+ return;
798
+ }
799
+
800
+ if (options.setPort) {
801
+ const port = parseInt(options.setPort);
802
+ if (isNaN(port) || port < 1 || port > 65535) {
803
+ console.log(chalk.red('āŒ Invalid port. Must be a number between 1 and 65535.'));
804
+ process.exit(1);
805
+ }
806
+ config.set('ollamaPort', port);
807
+ console.log(chalk.green(`āœ… Port set to: ${port}`));
808
+ }
809
+
810
+ if (options.setModel) {
811
+ await setModel(options.setModel);
812
+ }
813
+
814
+ if (options.setBase) {
815
+ config.set('baseBranch', options.setBase);
816
+ console.log(chalk.green(`āœ… Base branch set to: ${options.setBase}`));
817
+ }
818
+
819
+ if (options.setOutput) {
820
+ config.set('outputDir', options.setOutput);
821
+ console.log(chalk.green(`āœ… Output directory set to: ${options.setOutput}`));
822
+ }
823
+
824
+ if (options.setPort || options.setModel || options.setBase || options.setOutput) {
825
+ return;
826
+ }
827
+
828
+ const baseBranch = options.base || config.get('baseBranch');
829
+ const outputDir = options.output || config.get('outputDir');
830
+ const dryRun = options.dryRun || false;
831
+
832
+ await generatePRDescription(baseBranch, outputDir, dryRun);
833
+
834
+ } catch (error) {
835
+ console.error(chalk.red(`āŒ Error: ${error.message}`));
836
+ process.exit(1);
837
+ }
838
+ });
839
+
840
+ program.parse();
841
+
842
+ // ============================================
843
+ // CONFIGURATION FUNCTIONS
844
+ // ============================================
845
+
846
+ function showConfig() {
847
+ console.log(chalk.cyan('\nšŸ“‹ Current configuration:\n'));
848
+ console.log(chalk.white(` Ollama Port: ${chalk.yellow(config.get('ollamaPort'))}`));
849
+ console.log(chalk.white(` Model: ${chalk.yellow(config.get('ollamaModel'))}`));
850
+ console.log(chalk.white(` Base branch: ${chalk.yellow(config.get('baseBranch'))}`));
851
+ console.log(chalk.white(` Output directory: ${chalk.yellow(config.get('outputDir'))}`));
852
+ console.log(chalk.white(` Excluded files: ${chalk.gray(config.get('excludeFiles').length + ' files')}`));
853
+ console.log();
854
+ }
855
+
856
+ async function getAvailableModels() {
857
+ const port = config.get('ollamaPort');
858
+ const response = await fetch(`http://localhost:${port}/api/tags`);
859
+
860
+ if (!response.ok) {
861
+ throw new Error(`Could not connect to Ollama on port ${port}`);
862
+ }
863
+
864
+ const data = await response.json();
865
+ return data.models || [];
866
+ }
867
+
868
+ async function listModels() {
869
+ const spinner = ora('Getting model list...').start();
870
+
871
+ try {
872
+ const models = await getAvailableModels();
873
+ spinner.stop();
874
+
875
+ if (models.length === 0) {
876
+ console.log(chalk.yellow('\nāš ļø No models installed in Ollama.'));
877
+ console.log(chalk.white(' Run: ollama pull <model> to download one.\n'));
878
+ return;
879
+ }
880
+
881
+ console.log(chalk.cyan('\nšŸ“¦ Available models in Ollama:\n'));
882
+ models.forEach((model, index) => {
883
+ const name = model.name || model.model;
884
+ const size = model.size ? formatSize(model.size) : 'N/A';
885
+ const current = name === config.get('ollamaModel') ? chalk.green(' ← current') : '';
886
+ console.log(chalk.white(` ${index + 1}. ${chalk.yellow(name)} ${chalk.gray(`(${size})`)}${current}`));
887
+ });
888
+ console.log();
889
+
890
+ } catch (error) {
891
+ spinner.fail('Error connecting to Ollama');
892
+ console.log(chalk.red(`\nāŒ ${error.message}`));
893
+ console.log(chalk.white(' Make sure Ollama is running.\n'));
894
+ }
895
+ }
896
+
897
+ function formatSize(bytes) {
898
+ const sizes = ['B', 'KB', 'MB', 'GB'];
899
+ if (bytes === 0) return '0 B';
900
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
901
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
902
+ }
903
+
904
+ async function setModel(modelName) {
905
+ const spinner = ora('Verifying model...').start();
906
+
907
+ try {
908
+ const models = await getAvailableModels();
909
+ const modelNames = models.map(m => m.name || m.model);
910
+
911
+ const exactMatch = modelNames.find(name => name === modelName);
912
+ const partialMatch = modelNames.find(name => name.startsWith(modelName + ':') || name.split(':')[0] === modelName);
913
+
914
+ if (exactMatch) {
915
+ config.set('ollamaModel', exactMatch);
916
+ spinner.succeed(`Model set to: ${chalk.yellow(exactMatch)}`);
917
+ } else if (partialMatch) {
918
+ config.set('ollamaModel', partialMatch);
919
+ spinner.succeed(`Model set to: ${chalk.yellow(partialMatch)}`);
920
+ } else {
921
+ spinner.fail('Model not found');
922
+ console.log(chalk.red(`\nāŒ Model "${modelName}" is not available.\n`));
923
+ console.log(chalk.cyan('šŸ“¦ Available models:'));
924
+ modelNames.forEach(name => {
925
+ console.log(chalk.white(` • ${chalk.yellow(name)}`));
926
+ });
927
+ console.log();
928
+ process.exit(1);
929
+ }
930
+
931
+ } catch (error) {
932
+ spinner.fail('Error verifying model');
933
+ console.log(chalk.red(`\nāŒ ${error.message}`));
934
+ process.exit(1);
935
+ }
936
+ }
937
+
938
+ async function changeModelInteractive() {
939
+ const spinner = ora('Getting available models...').start();
940
+
941
+ try {
942
+ const models = await getAvailableModels();
943
+ spinner.stop();
944
+
945
+ if (models.length === 0) {
946
+ console.log(chalk.yellow('\nāš ļø No models installed in Ollama.\n'));
947
+ return;
948
+ }
949
+
950
+ const currentModel = config.get('ollamaModel');
951
+ const choices = models.map(model => {
952
+ const name = model.name || model.model;
953
+ const size = model.size ? formatSize(model.size) : '';
954
+ const isCurrent = name === currentModel;
955
+ return {
956
+ name: `${name} ${chalk.gray(size)}${isCurrent ? chalk.green(' ← current') : ''}`,
957
+ value: name,
958
+ short: name
959
+ };
960
+ });
961
+
962
+ const { selectedModel } = await inquirer.prompt([
963
+ {
964
+ type: 'list',
965
+ name: 'selectedModel',
966
+ message: 'Select the model:',
967
+ choices,
968
+ default: currentModel
969
+ }
970
+ ]);
971
+
972
+ config.set('ollamaModel', selectedModel);
973
+ console.log(chalk.green(`\nāœ… Model changed to: ${chalk.yellow(selectedModel)}`));
974
+
975
+ } catch (error) {
976
+ spinner.fail('Error getting models');
977
+ console.log(chalk.red(`\nāŒ ${error.message}`));
978
+ console.log(chalk.white(' Make sure Ollama is running.\n'));
979
+ }
980
+ }
981
+
982
+ // ============================================
983
+ // MAIN FLOW
984
+ // ============================================
985
+
986
+ async function generatePRDescription(baseBranch, outputDir, dryRun) {
987
+ console.log(chalk.cyan('\nšŸ” Analyzing differences with base branch...\n'));
988
+
989
+ // Fetch to ensure we have the latest version
990
+ const fetchSpinner = ora('Getting latest changes from origin...').start();
991
+ try {
992
+ execSync('git fetch origin', { stdio: 'pipe' });
993
+ fetchSpinner.succeed('Repository updated');
994
+ } catch {
995
+ fetchSpinner.warn('Could not fetch (continuing with local data)');
996
+ }
997
+
998
+ const diffData = getBranchDiff(baseBranch);
999
+
1000
+ if (!diffData) {
1001
+ console.log(chalk.yellow('āš ļø No differences with base branch.'));
1002
+ console.log(chalk.white(` Your branch is up to date with ${baseBranch}.\n`));
1003
+ process.exit(0);
1004
+ }
1005
+
1006
+ const commits = getCommitsList(baseBranch);
1007
+ const changedFiles = getChangedFiles(baseBranch);
1008
+ const stats = getFilesStats(baseBranch);
1009
+
1010
+ // Filter excluded files for display
1011
+ const includedFiles = changedFiles.filter(f => !f.excluded);
1012
+ const excludedFiles = changedFiles.filter(f => f.excluded);
1013
+
1014
+ console.log(chalk.white(`šŸ“Œ Current branch: ${chalk.yellow(diffData.currentBranch)}`));
1015
+ console.log(chalk.white(`šŸ“Œ Base branch: ${chalk.yellow(diffData.baseBranch)}`));
1016
+ console.log(chalk.white(`šŸ“ Commits: ${chalk.yellow(commits.length)}`));
1017
+ console.log(chalk.white(`šŸ“ Files: ${chalk.yellow(includedFiles.length)} ${excludedFiles.length > 0 ? chalk.gray(`(${excludedFiles.length} excluded)`) : ''}`));
1018
+ console.log();
1019
+
1020
+ // Show changed files
1021
+ console.log(chalk.white('šŸ“ Modified files:'));
1022
+ includedFiles.slice(0, 10).forEach(f => {
1023
+ const statusColor = f.status === 'added' ? chalk.green :
1024
+ f.status === 'deleted' ? chalk.red : chalk.yellow;
1025
+ console.log(chalk.gray(` ${statusColor(`[${f.statusCode}]`)} ${f.file}`));
1026
+ });
1027
+ if (includedFiles.length > 10) {
1028
+ console.log(chalk.gray(` ... and ${includedFiles.length - 10} more files`));
1029
+ }
1030
+
1031
+ // Show excluded files
1032
+ if (excludedFiles.length > 0) {
1033
+ console.log(chalk.gray(`\n🚫 Excluded from analysis (${excludedFiles.length}):`));
1034
+ excludedFiles.slice(0, 5).forEach(f => {
1035
+ console.log(chalk.gray(` • ${f.file}`));
1036
+ });
1037
+ if (excludedFiles.length > 5) {
1038
+ console.log(chalk.gray(` ... and ${excludedFiles.length - 5} more`));
1039
+ }
1040
+ }
1041
+ console.log();
1042
+
1043
+ const context = {
1044
+ currentBranch: diffData.currentBranch,
1045
+ baseBranch: diffData.baseBranch,
1046
+ diff: diffData.diff,
1047
+ commits,
1048
+ changedFiles: includedFiles,
1049
+ stats
1050
+ };
1051
+
1052
+ let continueLoop = true;
1053
+
1054
+ while (continueLoop) {
1055
+ const spinner = ora({
1056
+ text: `Generating description with ${chalk.yellow(config.get('ollamaModel'))}...`,
1057
+ spinner: 'dots'
1058
+ }).start();
1059
+
1060
+ let prDescription;
1061
+ try {
1062
+ prDescription = await generatePRDescriptionText(context);
1063
+ spinner.succeed('Description generated');
1064
+ } catch (error) {
1065
+ spinner.fail('Error generating description');
1066
+ console.log(chalk.red(`\nāŒ ${error.message}`));
1067
+ console.log(chalk.white(' Verify that Ollama is running and the model is available.\n'));
1068
+ process.exit(1);
1069
+ }
1070
+
1071
+ console.log(chalk.cyan('\nšŸ“ Proposed PR description:\n'));
1072
+ console.log(chalk.gray('─'.repeat(60)));
1073
+ console.log(prDescription);
1074
+ console.log(chalk.gray('─'.repeat(60)));
1075
+ console.log();
1076
+
1077
+ const choices = [
1078
+ { name: chalk.green('āœ… Accept and save file'), value: 'accept' },
1079
+ { name: chalk.yellow('šŸ”„ Generate another description'), value: 'regenerate' },
1080
+ { name: chalk.blue('āœļø Edit title manually'), value: 'edit' },
1081
+ new inquirer.Separator(),
1082
+ { name: chalk.magenta('šŸ¤– Change model'), value: 'change-model' },
1083
+ new inquirer.Separator(),
1084
+ { name: chalk.red('āŒ Cancel'), value: 'cancel' }
1085
+ ];
1086
+
1087
+ if (dryRun) {
1088
+ choices[0] = { name: chalk.green('āœ… Accept (dry-run, will not save)'), value: 'accept' };
1089
+ }
1090
+
1091
+ const { action } = await inquirer.prompt([
1092
+ {
1093
+ type: 'list',
1094
+ name: 'action',
1095
+ message: 'What would you like to do?',
1096
+ choices
1097
+ }
1098
+ ]);
1099
+
1100
+ switch (action) {
1101
+ case 'accept':
1102
+ if (dryRun) {
1103
+ console.log(chalk.yellow('\nšŸƒ Dry-run: description NOT saved.\n'));
1104
+ } else {
1105
+ const saveSpinner = ora('Saving file...').start();
1106
+ try {
1107
+ const filePath = savePRDescription(prDescription, diffData.currentBranch, outputDir);
1108
+ saveSpinner.succeed(`File saved: ${chalk.green(filePath)}`);
1109
+ console.log(chalk.cyan('\nšŸ’” Tip: You can copy the file content for your PR.\n'));
1110
+ } catch (error) {
1111
+ saveSpinner.fail('Error saving file');
1112
+ console.log(chalk.red(`\nāŒ ${error.message}\n`));
1113
+ }
1114
+ }
1115
+ continueLoop = false;
1116
+ break;
1117
+
1118
+ case 'regenerate':
1119
+ console.log(chalk.cyan('\nšŸ”„ Generating new description...\n'));
1120
+ break;
1121
+
1122
+ case 'edit':
1123
+ const { editedTitle } = await inquirer.prompt([
1124
+ {
1125
+ type: 'input',
1126
+ name: 'editedTitle',
1127
+ message: 'Edit the PR title:',
1128
+ default: diffData.currentBranch.replace(/[-_]/g, ' ')
1129
+ }
1130
+ ]);
1131
+
1132
+ // Replace title in markdown
1133
+ const finalDescription = prDescription.replace(/^# .+$/m, `# ${editedTitle}`);
1134
+
1135
+ if (!dryRun) {
1136
+ const editSaveSpinner = ora('Saving file...').start();
1137
+ try {
1138
+ const filePath = savePRDescription(finalDescription, diffData.currentBranch, outputDir);
1139
+ editSaveSpinner.succeed(`File saved: ${chalk.green(filePath)}`);
1140
+ } catch (error) {
1141
+ editSaveSpinner.fail('Error saving file');
1142
+ console.log(chalk.red(`\nāŒ ${error.message}\n`));
1143
+ }
1144
+ } else {
1145
+ console.log(chalk.yellow('\nšŸƒ Dry-run: description NOT saved.\n'));
1146
+ }
1147
+ continueLoop = false;
1148
+ break;
1149
+
1150
+ case 'change-model':
1151
+ await changeModelInteractive();
1152
+ console.log(chalk.cyan('\nšŸ”„ Regenerating description with new model...\n'));
1153
+ break;
1154
+
1155
+ case 'cancel':
1156
+ console.log(chalk.yellow('\nšŸ‘‹ Operation cancelled.\n'));
1157
+ continueLoop = false;
1158
+ break;
1159
+ }
1160
+ }
1161
+ }