bodhi-commit-genius-js
Version:
🚀 Smart commit message generator with AI - supports local LLMs and cloud APIs
287 lines (284 loc) • 11.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateCommitMessage = generateCommitMessage;
const node_fetch_1 = __importDefault(require("node-fetch"));
const openai_1 = __importDefault(require("openai"));
const config_1 = require("../config");
const config = new config_1.Config();
const COMMIT_TYPES = [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore'
];
const PROMPT_TEMPLATE = `
Analyze the following git diff and generate a concise, meaningful commit message following the Conventional Commits specification.
Focus on the main changes and their purpose.
Diff:
{diff}
Requirements:
1. Use one of these types: ${COMMIT_TYPES.join(', ')}
2. Format: <type>: <description>
3. Keep the description clear and concise
4. Use present tense, imperative mood
5. Focus on WHY and WHAT, not HOW
`;
async function generateWithOllama(diff, model) {
const response = await (0, node_fetch_1.default)('http://localhost:11434/api/generate', {
method: 'POST',
body: JSON.stringify({
model,
prompt: PROMPT_TEMPLATE.replace('{diff}', diff),
stream: false
})
});
const data = await response.json();
return data.response;
}
async function generateWithOpenAI(diff, apiKey) {
const openai = new openai_1.default({ apiKey });
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: 'You are a helpful assistant that generates meaningful git commit messages following the Conventional Commits specification.'
},
{
role: 'user',
content: PROMPT_TEMPLATE.replace('{diff}', diff)
}
]
});
return response.choices[0]?.message?.content || 'chore: update code';
}
function analyzeFileChanges(diff) {
const files = [];
let currentFile = null;
const lines = diff.split('\n');
for (const line of lines) {
if (line.startsWith('diff --git')) {
if (currentFile)
files.push(currentFile);
currentFile = {
path: line.split(' b/')[1],
addedLines: [],
removedLines: [],
modifiedLines: []
};
}
else if (currentFile) {
if (line.startsWith('+') && !line.startsWith('+++')) {
currentFile.addedLines.push(line.substring(1).trim());
}
else if (line.startsWith('-') && !line.startsWith('---')) {
currentFile.removedLines.push(line.substring(1).trim());
}
else if (line.startsWith(' ')) {
currentFile.modifiedLines.push(line.substring(1).trim());
}
}
}
if (currentFile)
files.push(currentFile);
return files;
}
function inferScope(files) {
const paths = files.map(f => f.path);
// Common scopes
if (paths.some(p => p.includes('/api/') || p.includes('/routes/')))
return 'api';
if (paths.some(p => p.includes('/ui/') || p.includes('/components/')))
return 'ui';
if (paths.some(p => p.includes('/db/') || p.includes('/models/')))
return 'db';
if (paths.some(p => p.includes('/auth/') || p.includes('security')))
return 'auth';
if (paths.some(p => p.includes('/test/')))
return 'tests';
if (paths.some(p => p.includes('/docs/')))
return 'docs';
// Infer from file types
if (paths.every(p => p.endsWith('.css') || p.endsWith('.scss')))
return 'styles';
if (paths.every(p => p.endsWith('.test.ts') || p.endsWith('.spec.ts')))
return 'tests';
if (paths.every(p => p.endsWith('.md')))
return 'docs';
return '';
}
function inferType(files) {
const totalAdded = files.reduce((sum, f) => sum + f.addedLines.length, 0);
const totalRemoved = files.reduce((sum, f) => sum + f.removedLines.length, 0);
// Check file patterns
const paths = files.map(f => f.path);
const content = files.flatMap(f => [...f.addedLines, ...f.removedLines, ...f.modifiedLines]).join(' ');
// Check for documentation changes
const hasDocsChanges = paths.some(p => p.includes('/docs/') || p.endsWith('.md'));
const isDocsOnly = hasDocsChanges && files.every(f => f.path.includes('/docs/') || f.path.endsWith('.md'));
// Check for test changes
const hasTestChanges = paths.some(p => p.includes('/tests/') || p.includes('.test.') || p.includes('.spec.'));
const isTestOnly = hasTestChanges && files.every(f => f.path.includes('/tests/') || f.path.includes('.test.') || f.path.includes('.spec.'));
// Check for route-related changes
const hasRouteChanges = paths.some(p => p.includes('/routes/') || p.includes('Router'));
const isRouteRefactor = hasRouteChanges && content.includes('router.') && files.length > 1;
if (paths.some(p => p.includes('/test/') || p.endsWith('.test.ts') || p.endsWith('.spec.ts'))) {
return 'test';
}
if (paths.some(p => p.endsWith('.md') || p.includes('README'))) {
return 'docs';
}
if (paths.some(p => p.endsWith('.css') || p.endsWith('.scss'))) {
return 'style';
}
if (paths.some(p => p.includes('package.json') || p.includes('package-lock.json'))) {
return 'build';
}
// Check for documentation
if (isDocsOnly) {
return 'docs';
}
// Check for tests
if (isTestOnly) {
return 'test';
}
// Check for route refactoring
if (isRouteRefactor) {
return 'refactor';
}
// Check content patterns
if (content.toLowerCase().includes('fix') || content.toLowerCase().includes('bug')) {
return 'fix';
}
if (content.toLowerCase().includes('refactor') || content.toLowerCase().includes('cleanup')) {
return 'refactor';
}
if (content.toLowerCase().includes('perf') || content.toLowerCase().includes('performance')) {
return 'perf';
}
// Analyze changes
if (totalAdded > totalRemoved * 2) {
return 'feat';
}
if (totalRemoved > totalAdded * 2) {
return 'refactor';
}
if (totalAdded > 0 || totalRemoved > 0) {
return 'fix';
}
return 'chore';
}
function generateDescription(files) {
const totalAdded = files.reduce((sum, f) => sum + f.addedLines.length, 0);
const totalRemoved = files.reduce((sum, f) => sum + f.removedLines.length, 0);
const changes = [];
// Check for documentation changes
const hasDocsChanges = files.some(f => f.path.includes('/docs/') || f.path.endsWith('.md'));
if (hasDocsChanges) {
const docTypes = files
.filter(f => f.path.includes('/docs/') || f.path.endsWith('.md'))
.map(f => f.path.split('/').pop()?.replace('.md', ''))
.filter(Boolean);
changes.push(`add ${docTypes.join(', ')} documentation`);
}
// Check for test changes
const hasTestChanges = files.some(f => f.path.includes('/tests/') || f.path.includes('.test.') || f.path.includes('.spec.'));
if (hasTestChanges) {
const testTypes = files
.filter(f => f.path.includes('/tests/') || f.path.includes('.test.') || f.path.includes('.spec.'))
.map(f => f.path.split('/').pop()?.replace('.test.js', '').replace('.spec.js', ''))
.filter(Boolean);
changes.push(`add tests for ${testTypes.join(', ')}`);
}
// Check for route refactoring
const hasRouteChanges = files.some(f => f.path.includes('/routes/') || files.some(f => f.addedLines.join(' ').includes('router.')));
if (hasRouteChanges && files.length > 1) {
changes.push('move API routes to dedicated module');
}
return changes.length > 0 ? changes.join(' and ') : 'update code';
// Try to find meaningful function or class names in added lines
const codePatterns = [
/(?:function|class|const|let|var)\s+([a-zA-Z][a-zA-Z0-9]*)\s*[({=]/,
/(?:public|private|protected)\s+([a-zA-Z][a-zA-Z0-9]*)\s*[({]/,
/(?:describe|it|test)\s*\(['"](.+?)['"]/ // Test descriptions
];
for (const file of files) {
for (const line of file.addedLines) {
for (const pattern of codePatterns) {
const match = line.match(pattern);
const name = match?.[1];
if (name) {
if (file.path.includes('/test/')) {
return `add tests for ${name}`;
}
return `add ${name}`;
}
}
}
}
// Generate description based on changes
const scope = inferScope(files);
const paths = files.map(f => f.path);
if (totalAdded > 0 && totalRemoved === 0) {
if (paths.length === 1) {
return `add ${paths[0].split('/').pop()}`;
}
return scope ? `add new ${scope} functionality` : 'add new functionality';
}
if (totalRemoved > 0 && totalAdded === 0) {
if (paths.length === 1) {
return `remove ${paths[0].split('/').pop()}`;
}
return scope ? `remove unused ${scope} code` : 'remove unused code';
}
if (totalAdded > 0 && totalRemoved > 0) {
if (paths.length === 1) {
return `update ${paths[0].split('/').pop()}`;
}
return scope ? `update ${scope} implementation` : 'update implementation';
}
return 'update code';
}
function generateSimpleCommitMessage(diff) {
const files = analyzeFileChanges(diff);
const type = inferType(files);
const scope = inferScope(files);
const description = generateDescription(files);
return scope ? `${type}(${scope}): ${description}` : `${type}: ${description}`;
}
async function generateCommitMessage(diff, options) {
const provider = options.provider || config.get('provider') || 'simple';
try {
if (provider === 'simple') {
return generateSimpleCommitMessage(diff);
}
else if (provider === 'ollama') {
try {
const model = options.model || config.get('model');
return await generateWithOllama(diff, model);
}
catch (error) {
console.warn('Ollama error, falling back to simple mode:', error);
return generateSimpleCommitMessage(diff);
}
}
else {
try {
const apiKey = options.apiKey || config.get('apiKey');
if (!apiKey) {
throw new Error('OpenAI API key is required');
}
return await generateWithOpenAI(diff, apiKey);
}
catch (error) {
console.warn('OpenAI error, falling back to simple mode:', error);
return generateSimpleCommitMessage(diff);
}
}
}
catch (error) {
console.error('Error generating commit message:', error);
return generateSimpleCommitMessage(diff);
}
}