diff --git a/source/cli.js b/source/cli.js index c9d8495..9a42d50 100755 --- a/source/cli.js +++ b/source/cli.js @@ -2,7 +2,7 @@ import process from 'node:process'; import {Command} from 'commander'; import packageJSON from '../package.json'; -import {showBanner, showWarning} from './utils/ui.js'; +import {showBanner, showWarning, showSuccess, showError} from './utils/ui.js'; import {commitCommand} from './commands/commit.js'; import { authenticateWithCopilot, @@ -11,6 +11,8 @@ import { logout, } from './commands/auth.js'; import {showConfig, resetConfig} from './commands/config.js'; +import {setConvention} from './utils/config-manager.js'; +import {CONVENTIONS} from './utils/commit-conventions.js'; const program = new Command(); @@ -44,6 +46,10 @@ program .option('--all', 'Process all files at once') .option('--file ', 'Process specific file') .option('--model ', 'Specify AI model (e.g., gpt-4, gpt-3.5-turbo)') + .option( + '--convention ', + 'Commit convention (clean, conventional, gitmoji, simple)', + ) .action(options => { commitCommand(options); }); @@ -83,6 +89,21 @@ authCmd // Config command const configCmd = program.command('config').description('Manage configuration'); +configCmd + .command('set-convention ') + .description('Set default commit convention') + .action(type => { + const validConventions = Object.keys(CONVENTIONS); + if (!validConventions.includes(type)) { + showError(`Invalid convention: ${type}`); + console.log(`Available: ${validConventions.join(', ')}`); + process.exit(1); + } + + setConvention(type); + showSuccess(`Default convention set to: ${type}`); + }); + configCmd .option('--show', 'Show current configuration') .option('--reset', 'Reset configuration to defaults') diff --git a/source/commands/auth.js b/source/commands/auth.js index 15df4f0..d5c3eee 100644 --- a/source/commands/auth.js +++ b/source/commands/auth.js @@ -1,4 +1,5 @@ import process from 'node:process'; +import {execa} from 'execa'; import { setAuthMode, setToken, @@ -18,46 +19,45 @@ import { * Handles GitHub Copilot and OpenAI authentication */ -export async function authenticateWithCopilot(token = null) { +export async function authenticateWithCopilot() { try { - // Check for token in arguments first - let authToken = token; + console.log('๐Ÿ” Setting up GitHub Copilot authentication...\n'); - // If no token provided, check environment variables - if (!authToken) { - authToken = - process.env.COPILOT_GITHUB_TOKEN || - process.env.GH_TOKEN || - process.env.GITHUB_TOKEN; + // Check if gh CLI is installed + try { + await execa('gh', ['--version']); + } catch { + showError('GitHub CLI (gh) is not installed.'); + console.log(''); + console.log('Install it from: https://cli.github.com/'); + console.log('Then run: gh auth login'); + process.exit(1); } - if (!authToken) { - showError('No GitHub token found.'); + // Check if gh is authenticated + try { + await execa('gh', ['auth', 'status']); + } catch { + showError('GitHub CLI is not authenticated.'); console.log(''); - console.log('Please provide a token using one of these methods:'); - console.log(' 1. Pass token: magicc auth copilot --token '); - console.log(' 2. Set environment variable: GITHUB_TOKEN or GH_TOKEN'); - console.log( - ' 3. Use gh CLI: gh auth login (then use: magicc auth copilot)', - ); + console.log('Please authenticate first:'); + console.log(' gh auth login'); console.log(''); - console.log('To create a token:'); - console.log(' Visit: https://github.com/settings/tokens'); - console.log(' Scopes needed: repo, read:user'); + console.log('Make sure you have GitHub Copilot enabled on your account.'); process.exit(1); } - // Store token and set auth mode - setToken('github', authToken); + // Store auth mode setAuthMode('copilot'); - showSuccess('GitHub Copilot authentication successful!'); + showSuccess('GitHub Copilot ready!'); console.log(''); - console.log(`๐Ÿ“ Config stored at: ${getConfigPath()}`); + console.log('โœ… GitHub CLI authenticated'); + console.log('โœ… Copilot SDK will use your CLI session'); console.log(''); console.log('You can now use: magicc commit'); } catch (error) { - showError(`Authentication failed: ${error.message}`); + showError(`Setup failed: ${error.message}`); process.exit(1); } } diff --git a/source/commands/commit.js b/source/commands/commit.js index a9cef47..3952725 100644 --- a/source/commands/commit.js +++ b/source/commands/commit.js @@ -17,7 +17,7 @@ import { showInfo, confirmCommit, } from '../utils/ui.js'; -import {getAuthMode} from '../utils/config-manager.js'; +import {getAuthMode, getConvention} from '../utils/config-manager.js'; /** * Main commit command logic @@ -137,35 +137,66 @@ export async function processFilesInteractively(aiProvider, options = {}) { continue; } - // Generate commit message - try { - const message = await aiProvider.generateCommitMessage( - diff, - file, - options, - ); + let currentConvention = options.convention || getConvention(); + let fileProcessed = false; + let attempts = 0; + const maxAttempts = 5; + + while (!fileProcessed && attempts < maxAttempts) { + try { + const message = await aiProvider.generateCommitMessage(diff, file, { + ...options, + convention: currentConvention, + }); + + const result = await confirmCommit(message, file, currentConvention); + + switch (result.action) { + case 'accept': { + const success = await commit(result.message); + if (success) { + showSuccess(`Committed: ${file}`); + console.log(`๐Ÿ“ ${result.message}`); + committed++; + } else { + showError(`Failed to commit ${file}`); + await unstageFile(file); + skipped++; + } - // Confirm and commit - const result = await confirmCommit(message, file); - - if (result.action === 'accept') { - const success = await commit(result.message); - if (success) { - showSuccess(`Committed: ${file}`); - console.log(`๐Ÿ“ ${result.message}`); - committed++; - } else { - showError(`Failed to commit ${file}`); - await unstageFile(file); - skipped++; + fileProcessed = true; + break; + } + + case 'regenerate': { + currentConvention = result.convention; + attempts++; + // Loop continues to regenerate + break; + } + + case 'skip': { + showInfo(`Skipped: ${file}`); + await unstageFile(file); + skipped++; + fileProcessed = true; + break; + } + // No default } - } else if (result.action === 'skip') { - showInfo(`Skipped: ${file}`); + } catch (error) { + showError(`Error processing ${file}: ${error.message}`); await unstageFile(file); skipped++; + fileProcessed = true; } - } catch (error) { - showError(`Error processing ${file}: ${error.message}`); + } + + // Check if max attempts reached without processing + if (!fileProcessed && attempts >= maxAttempts) { + showWarning( + `Maximum regeneration attempts (${maxAttempts}) reached for ${file}`, + ); await unstageFile(file); skipped++; } @@ -178,7 +209,6 @@ export async function processFilesInteractively(aiProvider, options = {}) { console.log(` โญ๏ธ Skipped: ${skipped} file(s)`); console.log('โ•'.repeat(50) + '\n'); } -/* eslint-enable no-await-in-loop */ export async function processFile(filePath, aiProvider, options = {}) { showInfo(`Processing single file: ${filePath}`); @@ -199,27 +229,55 @@ export async function processFile(filePath, aiProvider, options = {}) { return; } - // Generate commit message - showInfo('Generating commit message...'); - const message = await aiProvider.generateCommitMessage( - diff, - filePath, - options, - ); + let currentConvention = options.convention || getConvention(); + let attempts = 0; + const maxAttempts = 5; - // Confirm and commit - const result = await confirmCommit(message, filePath); + while (attempts < maxAttempts) { + showInfo('Generating commit message...'); - if (result.action === 'accept') { - const success = await commit(result.message); - if (success) { - showSuccess('Changes committed successfully!'); - console.log(`๐Ÿ“ ${result.message}`); - } else { - showError('Failed to commit changes.'); + try { + const message = await aiProvider.generateCommitMessage(diff, filePath, { + ...options, + convention: currentConvention, + }); + + const result = await confirmCommit(message, filePath, currentConvention); + + switch (result.action) { + case 'accept': { + const success = await commit(result.message); + if (success) { + showSuccess('Changes committed successfully!'); + console.log(`๐Ÿ“ ${result.message}`); + } else { + showError('Failed to commit changes.'); + } + + return; + } + + case 'regenerate': { + currentConvention = result.convention; + attempts++; + continue; + } + + case 'skip': { + showInfo('Commit cancelled.'); + await unstageFile(filePath); + return; + } + // No default + } + } catch (error) { + showError(`Error: ${error.message}`); + await unstageFile(filePath); + return; } - } else if (result.action === 'skip') { - showInfo('Commit cancelled.'); - await unstageFile(filePath); } + + showWarning('Maximum regeneration attempts reached.'); + await unstageFile(filePath); } +/* eslint-enable no-await-in-loop */ diff --git a/source/commands/config.js b/source/commands/config.js index 314e00a..b8611d6 100644 --- a/source/commands/config.js +++ b/source/commands/config.js @@ -5,6 +5,7 @@ import { getConfigPath, } from '../utils/config-manager.js'; import {showSuccess, showInfo} from '../utils/ui.js'; +import {getConvention, CONVENTIONS} from '../utils/commit-conventions.js'; /** * Configuration commands @@ -31,6 +32,16 @@ export function showConfig() { displayValue = value.slice(0, 10) + '...'; } + // Show convention name nicely + if (key === 'convention') { + const conv = getConvention(value); + // Check if the stored value matches a valid convention + const validConventions = Object.keys(CONVENTIONS); + displayValue = validConventions.includes(value) + ? `${value} (${conv.name})` + : `${value} (invalid - defaults to ${conv.name})`; + } + console.log(` ${chalk.yellow(key)}: ${chalk.white(displayValue)}`); } } diff --git a/source/providers/ai-provider.js b/source/providers/ai-provider.js index 4d38c37..d42ebe2 100644 --- a/source/providers/ai-provider.js +++ b/source/providers/ai-provider.js @@ -1,6 +1,11 @@ -import process from 'node:process'; +import {CopilotClient} from '@github/copilot-sdk'; import OpenAI from 'openai'; -import {getAuthMode, getToken} from '../utils/config-manager.js'; +import { + getAuthMode, + getToken, + getConvention as getConventionName, +} from '../utils/config-manager.js'; +import {getConvention} from '../utils/commit-conventions.js'; /** * AI Provider abstraction layer @@ -29,7 +34,12 @@ export class AIProvider { return this.generateFallbackMessage(filePath); } - const prompt = this.buildCleanCommitPrompt(diff, filePath); + // Get the convention (from options, config, or default to 'clean') + const conventionName = options.convention || getConventionName() || 'clean'; + const convention = getConvention(conventionName); + + // Build prompt using the selected convention + const prompt = convention.buildPrompt(diff, filePath); try { if (this.authMode === 'copilot') { @@ -51,42 +61,85 @@ export class AIProvider { /** * Generate commit message using GitHub Copilot - * NOTE: This is a simplified implementation. A GitHub token alone cannot authenticate - * with the OpenAI API. In production, this would either: - * 1. Use the GitHub Copilot API endpoint (requires different authentication) - * 2. Require users to have an OpenAI API key separately - * 3. Use a proxy service that bridges GitHub auth to OpenAI - * For now, this serves as a placeholder for the intended Copilot integration. */ async generateWithCopilot(prompt, options = {}) { + let client; + let clientStarted = false; + try { - // Check for GitHub token in environment variables or config - const token = - getToken('github') || - process.env.COPILOT_GITHUB_TOKEN || - process.env.GH_TOKEN || - process.env.GITHUB_TOKEN; - - if (!token) { - throw new Error( - 'GitHub token not found. Please authenticate with "magicc auth copilot"', - ); + // Create and start the Copilot client + client = new CopilotClient(); + await client.start(); + clientStarted = true; + + // Create session with model + const session = await client.createSession({ + model: this.model || options.model || 'gpt-4o', + }); + + // Send the prompt and wait for response + const response = await session.sendAndWait({ + prompt, + }); + + // Extract the content from response, handling multiple possible shapes + // The Copilot SDK response format can vary based on the model and session type. + // We check multiple common response structures to ensure compatibility: + // - Direct string responses + // - Nested under data.content (some SDK versions) + // - OpenAI-style choices array (fallback compatibility) + // - Direct content property on response object + let content = null; + + if (typeof response === 'string') { + content = response; + } else { + content = + // Check data.content first (common in some SDK versions) + response?.data?.content ?? + // Common OpenAI / chat-like shapes under data + response?.data?.choices?.[0]?.message?.content ?? + response?.data?.choices?.[0]?.content ?? + // Or directly on the response object + response?.choices?.[0]?.message?.content ?? + response?.choices?.[0]?.content ?? + response?.content; } - // In a real implementation, this would use the Copilot API endpoint - // For now, if a GitHub token is provided, we fall back to OpenAI - // This allows the structure to be in place for future Copilot integration - throw new Error( - 'GitHub Copilot integration pending - using OpenAI fallback', - ); + if (typeof content === 'string' && content.trim()) { + return content.trim(); + } + + throw new Error('No response from Copilot'); } catch (error) { + console.error('Copilot error:', error.message); + // Fallback to OpenAI if available if (getToken('openai')) { - console.warn('โš ๏ธ Copilot not fully implemented, using OpenAI...'); + console.warn('โš ๏ธ Copilot failed, using OpenAI fallback...'); return this.generateWithOpenAI(prompt, options); } - throw error; + throw new Error( + `GitHub Copilot failed` + + (error.name ? ` (${error.name})` : '') + + `: ${error.message}\n\n` + + 'This may be caused by:\n' + + '1. Missing GitHub Copilot subscription\n' + + '2. Not authenticated in GitHub CLI (try: gh auth login)\n' + + '3. Network issues or GitHub API availability problems\n' + + '4. Incompatible or outdated GitHub Copilot SDK or CLI version\n\n' + + 'If the issue persists, set an OpenAI key as fallback: magicc auth openai ', + ); + } finally { + // Clean up client if it was started + if (client && clientStarted) { + try { + await client.stop(); + } catch { + // Ignore cleanup errors + } + } } } @@ -120,45 +173,6 @@ export class AIProvider { return response.choices[0].message.content.trim(); } - /** - * Build Clean Commit format prompt - */ - buildCleanCommitPrompt(diff, filePath = null) { - return `You are an expert at writing git commit messages following the "Clean Commit" format. - -**Clean Commit Format:** - : - (): - -**The 9 Types:** -| Emoji | Type | What it covers | -|-------|-----------|----------------| -| ๐Ÿ“ฆ | new | Adding new features, files, or capabilities | -| ๐Ÿ”ง | update | Changing existing code, refactoring, improvements | -| ๐Ÿ—‘๏ธ | remove | Removing code, files, features, or dependencies | -| ๐Ÿ”’ | security | Security fixes, patches, vulnerability resolutions | -| โš™๏ธ | setup | Project configs, CI/CD, tooling, build systems | -| โ˜• | chore | Maintenance tasks, dependency updates, housekeeping | -| ๐Ÿงช | test | Adding, updating, or fixing tests | -| ๐Ÿ“– | docs | Documentation changes and updates | -| ๐Ÿš€ | release | Version releases and release preparation | - -**Rules:** -- Use lowercase for type -- Use present tense ("add" not "added") -- No period at the end -- Keep description under 72 characters -- Include scope (filename) if appropriate - -**Git Diff:** -\`\`\`diff -${diff} -\`\`\` - -${filePath ? `**File:** ${filePath}\n` : ''} -Generate a single commit message following Clean Commit format. Return ONLY the commit message, nothing else.`; - } - /** * Fallback message for large diffs */ diff --git a/source/utils/commit-conventions.js b/source/utils/commit-conventions.js new file mode 100644 index 0000000..43ee30e --- /dev/null +++ b/source/utils/commit-conventions.js @@ -0,0 +1,160 @@ +export const CONVENTIONS = { + clean: { + name: 'Clean Commit', + description: 'wgtechlabs Clean Commit format with emojis', + buildPrompt: ( + diff, + filePath, + ) => `You are an expert at writing git commit messages following the "Clean Commit" format. + +**Clean Commit Format:** + : + (): + +**The 9 Types:** +| Emoji | Type | What it covers | +|-------|-----------|----------------| +| ๐Ÿ“ฆ | new | Adding new features, files, or capabilities | +| ๐Ÿ”ง | update | Changing existing code, refactoring, improvements | +| ๐Ÿ—‘๏ธ | remove | Removing code, files, features, or dependencies | +| ๐Ÿ”’ | security | Security fixes, patches, vulnerability resolutions | +| โš™๏ธ | setup | Project configs, CI/CD, tooling, build systems | +| โ˜• | chore | Maintenance tasks, dependency updates, housekeeping | +| ๐Ÿงช | test | Adding, updating, or fixing tests | +| ๐Ÿ“– | docs | Documentation changes and updates | +| ๐Ÿš€ | release | Version releases and release preparation | + +**Rules:** +- Use lowercase for type +- Use present tense ("add" not "added") +- No period at the end +- Keep description under 72 characters +- Include scope (filename) if appropriate + +**Git Diff:** +\`\`\`diff +${diff} +\`\`\` + +${filePath ? `**File:** ${filePath}\n` : ''} +Generate a single commit message following Clean Commit format. Return ONLY the commit message, nothing else.`, + }, + + conventional: { + name: 'Conventional Commits', + description: 'Standard Conventional Commits specification', + buildPrompt: ( + diff, + filePath, + ) => `You are an expert at writing git commit messages following the "Conventional Commits" specification. + +**Format:** +(): + +**Types:** +- feat: A new feature +- fix: A bug fix +- docs: Documentation only changes +- style: Changes that don't affect code meaning (whitespace, formatting) +- refactor: Code change that neither fixes a bug nor adds a feature +- perf: Code change that improves performance +- test: Adding missing tests or correcting existing tests +- build: Changes that affect the build system or external dependencies +- ci: Changes to CI configuration files and scripts +- chore: Other changes that don't modify src or test files + +**Rules:** +- Use lowercase for type +- Use present tense ("add" not "added") +- No period at the end +- Keep description under 72 characters +- Scope is optional but recommended + +**Git Diff:** +\`\`\`diff +${diff} +\`\`\` + +${filePath ? `**File:** ${filePath}\n` : ''} +Generate a single commit message following Conventional Commits format. Return ONLY the commit message, nothing else.`, + }, + + gitmoji: { + name: 'Gitmoji', + description: 'Gitmoji commit convention with emojis', + buildPrompt: ( + diff, + filePath, + ) => `You are an expert at writing git commit messages using the "Gitmoji" convention. + +**Format:** +:: + +**Common Emoji Codes:** +- :sparkles: Introduce new features +- :bug: Fix a bug +- :recycle: Refactor code +- :lipstick: Update UI and style files +- :memo: Add or update documentation +- :rocket: Deploy stuff +- :white_check_mark: Add, update, or pass tests +- :lock: Fix security issues +- :arrow_up: Upgrade dependencies +- :arrow_down: Downgrade dependencies +- :fire: Remove code or files +- :construction: Work in progress + +**Rules:** +- Start with emoji code (e.g., :sparkles:) +- Use present tense +- Keep concise and clear +- No period at the end + +**Git Diff:** +\`\`\`diff +${diff} +\`\`\` + +${filePath ? `**File:** ${filePath}\n` : ''} +Generate a single commit message using Gitmoji format. Return ONLY the commit message, nothing else.`, + }, + + simple: { + name: 'Simple', + description: 'Plain descriptive commit messages', + buildPrompt: ( + diff, + filePath, + ) => `You are an expert at writing clear, concise git commit messages. + +**Format:** +Simple descriptive message in imperative mood + +**Rules:** +- Use imperative mood ("Add feature" not "Added feature") +- Start with capital letter +- No period at the end +- Keep under 72 characters +- Be specific and clear +- No prefixes or emojis + +**Git Diff:** +\`\`\`diff +${diff} +\`\`\` + +${filePath ? `**File:** ${filePath}\n` : ''} +Generate a single, clear commit message. Return ONLY the commit message, nothing else.`, + }, +}; + +export function getConvention(name = 'clean') { + return CONVENTIONS[name] || CONVENTIONS.clean; +} + +export function listConventions() { + return Object.entries(CONVENTIONS).map(([key, conv]) => ({ + value: key, + name: `${conv.name} - ${conv.description}`, + })); +} diff --git a/source/utils/config-manager.js b/source/utils/config-manager.js index 2708a15..6f7579e 100644 --- a/source/utils/config-manager.js +++ b/source/utils/config-manager.js @@ -1,4 +1,5 @@ import Conf from 'conf'; +import {CONVENTIONS} from './commit-conventions.js'; const config = new Conf({projectName: 'magicc'}); @@ -10,6 +11,7 @@ const config = new Conf({projectName: 'magicc'}); // Auth mode management export function setAuthMode(mode) { config.set('authMode', mode); + config.set('authenticatedAt', new Date().toISOString()); } export function getAuthMode() { @@ -18,20 +20,13 @@ export function getAuthMode() { // Token management export function setToken(provider, token) { - if (provider === 'copilot' || provider === 'github') { - config.set('githubToken', token); - } else if (provider === 'openai') { + if (provider === 'openai') { config.set('openai', token); + config.set('authenticatedAt', new Date().toISOString()); } - - config.set('authenticatedAt', new Date().toISOString()); } export function getToken(provider) { - if (provider === 'copilot' || provider === 'github') { - return config.get('githubToken'); - } - if (provider === 'openai') { return config.get('openai'); } @@ -73,3 +68,22 @@ export function setUseGhCli(value) { export function getUseGhCli() { return config.get('useGhCli', false); } + +// Convention management +export function setConvention(convention = 'clean') { + // Validate convention name + const validConventions = Object.keys(CONVENTIONS); + if (!validConventions.includes(convention)) { + const validOptions = validConventions.join(', '); + console.warn( + `Warning: Invalid convention '${convention}'. Valid options: ${validOptions}. Defaulting to 'clean'.`, + ); + convention = 'clean'; + } + + config.set('convention', convention); +} + +export function getConvention() { + return config.get('convention', 'clean'); // Default to 'clean' +} diff --git a/source/utils/ui.js b/source/utils/ui.js index 199d326..a49fc6c 100644 --- a/source/utils/ui.js +++ b/source/utils/ui.js @@ -1,6 +1,7 @@ import chalk from 'chalk'; import figlet from 'figlet'; import inquirer from 'inquirer'; +import {listConventions, getConvention} from '../utils/commit-conventions.js'; /** * UI utilities for magic-commit terminal interface @@ -32,8 +33,33 @@ export function showCommitPreview(message, filePath = null) { console.log(); } -export async function confirmCommit(message, filePath = null) { - showCommitPreview(message, filePath); +export async function promptConventionSelection() { + const answer = await inquirer.prompt([ + { + type: 'list', + name: 'convention', + message: 'Select commit message convention:', + choices: listConventions(), + }, + ]); + return answer.convention; +} + +export async function confirmCommit( + message, + filePath = null, + conventionName = 'clean', +) { + const conv = getConvention(conventionName); + + console.log(chalk.gray(`\n๐Ÿ“‹ Convention: ${conv.name}`)); + console.log(chalk.yellow('๐Ÿ“ Suggested Commit Message:')); + console.log(chalk.white(message)); + if (filePath) { + console.log(chalk.gray(` File: ${filePath}`)); + } + + console.log(); const answer = await inquirer.prompt([ { @@ -43,6 +69,10 @@ export async function confirmCommit(message, filePath = null) { choices: [ {name: 'โœ… Accept and commit', value: 'accept'}, {name: 'โœ๏ธ Edit message', value: 'edit'}, + { + name: '๐Ÿ”„ Regenerate with different convention', + value: 'change-convention', + }, {name: 'โญ๏ธ Skip this file', value: 'skip'}, ], }, @@ -60,6 +90,11 @@ export async function confirmCommit(message, filePath = null) { return {action: 'accept', message: edited.message}; } + if (answer.action === 'change-convention') { + const newConvention = await promptConventionSelection(); + return {action: 'regenerate', convention: newConvention}; + } + return {action: answer.action, message}; } diff --git a/test.js b/test.js index ffb484c..0fe75f3 100644 --- a/test.js +++ b/test.js @@ -5,8 +5,14 @@ import { setToken, getAuthMode, getToken, + setConvention, + getConvention, clearAll, } from './source/utils/config-manager.js'; +import { + getConvention as getConventionDetails, + listConventions, +} from './source/utils/commit-conventions.js'; test('config manager stores and retrieves auth mode', t => { clearAll(); @@ -25,26 +31,83 @@ test('config manager stores and retrieves tokens', t => { setToken('openai', 'sk-test-key'); t.is(getToken('openai'), 'sk-test-key'); - setToken('github', 'ghp-test-token'); - t.is(getToken('github'), 'ghp-test-token'); - clearAll(); t.is(getToken('openai'), undefined); - t.is(getToken('github'), undefined); }); -test('AIProvider builds Clean Commit prompt correctly', t => { - const provider = new AIProvider({authMode: 'openai'}); +test('config manager stores and retrieves convention', t => { + clearAll(); + t.is(getConvention(), 'clean'); // Default + + setConvention('conventional'); + t.is(getConvention(), 'conventional'); + + setConvention('gitmoji'); + t.is(getConvention(), 'gitmoji'); + + clearAll(); + t.is(getConvention(), 'clean'); // Back to default +}); + +test('convention system returns correct convention details', t => { + const cleanConv = getConventionDetails('clean'); + t.is(cleanConv.name, 'Clean Commit'); + t.true(cleanConv.description.includes('emoji')); + + const conventionalConv = getConventionDetails('conventional'); + t.is(conventionalConv.name, 'Conventional Commits'); + + const gitmojiConv = getConventionDetails('gitmoji'); + t.is(gitmojiConv.name, 'Gitmoji'); + + const simpleConv = getConventionDetails('simple'); + t.is(simpleConv.name, 'Simple'); +}); + +test('convention system lists all conventions', t => { + const conventions = listConventions(); + t.is(conventions.length, 4); + t.true(conventions.some(c => c.value === 'clean')); + t.true(conventions.some(c => c.value === 'conventional')); + t.true(conventions.some(c => c.value === 'gitmoji')); + t.true(conventions.some(c => c.value === 'simple')); +}); + +test('convention system builds prompts correctly', t => { const diff = 'test diff content'; const filePath = 'test.js'; - const prompt = provider.buildCleanCommitPrompt(diff, filePath); + // Test Clean Commit + const cleanConv = getConventionDetails('clean'); + const cleanPrompt = cleanConv.buildPrompt(diff, filePath); + t.true(cleanPrompt.includes('Clean Commit')); + t.true(cleanPrompt.includes('test diff content')); + t.true(cleanPrompt.includes('test.js')); + t.true(cleanPrompt.includes('๐Ÿ“ฆ')); + + // Test Conventional Commits + const conventionalConv = getConventionDetails('conventional'); + const conventionalPrompt = conventionalConv.buildPrompt(diff, filePath); + t.true(conventionalPrompt.includes('Conventional Commits')); + t.true(conventionalPrompt.includes('feat:')); + t.true(conventionalPrompt.includes('fix:')); + + // Test Gitmoji + const gitmojiConv = getConventionDetails('gitmoji'); + const gitmojiPrompt = gitmojiConv.buildPrompt(diff, filePath); + t.true(gitmojiPrompt.includes('Gitmoji')); + t.true(gitmojiPrompt.includes(':sparkles:')); + + // Test Simple + const simpleConv = getConventionDetails('simple'); + const simplePrompt = simpleConv.buildPrompt(diff, filePath); + t.true(simplePrompt.includes('imperative mood')); + t.true(simplePrompt.includes('test diff content')); +}); - t.true(prompt.includes('Clean Commit')); - t.true(prompt.includes('test diff content')); - t.true(prompt.includes('test.js')); - t.true(prompt.includes('๐Ÿ“ฆ')); - t.true(prompt.includes('๐Ÿ”ง')); +test('convention system defaults to clean for unknown convention', t => { + const unknownConv = getConventionDetails('unknown-convention'); + t.is(unknownConv.name, 'Clean Commit'); // Should fallback to clean }); test('AIProvider generates fallback message for large diffs', t => {