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/README.md +195 -0
- package/black-favicon.svg +42 -0
- package/package.json +34 -0
- package/src/index.js +1161 -0
- package/white-favicon.svg +42 -0
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
|
+
}
|