diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index a4cf02a..0000000 --- a/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const AMPLITUDE_API_KEY = process.env.AMPLITUDE_API_KEY || ''; \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 964692d..1956265 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,10 +2,9 @@ import * as vscode from 'vscode'; import { GitService } from './services/gitService'; import { Logger } from './utils/logger'; import { ConfigService } from './utils/configService'; -import { generateAndSetCommitMessage } from './services/aiService'; +import { CommitMessageUI } from './services/aiService'; import { SettingsValidator } from './services/settingsValidator'; import { TelemetryService } from './services/telemetryService'; -import { messages } from './utils/constants'; export async function activate(context: vscode.ExtensionContext): Promise { void Logger.log('Starting extension activation'); @@ -29,7 +28,7 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push( vscode.commands.registerCommand('geminicommit.generateCommitMessage', async () => { try { - await generateAndSetCommitMessage(); + await CommitMessageUI.generateAndSetCommitMessage(); } catch (error) { void Logger.error('Error in generateCommitMessage command:', error as Error); void vscode.window.showErrorMessage(`Error: ${(error as Error).message}`); diff --git a/src/services/aiService.ts b/src/services/aiService.ts index b368e33..491c6c2 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import * as path from 'path'; import { ConfigService } from '../utils/configService'; import { Logger } from '../utils/logger'; @@ -7,15 +7,14 @@ import { CommitMessage, ProgressReporter } from '../models/types'; import { CustomEndpointService } from './customEndpointService'; import { PromptService } from './promptService'; import { GitService } from './gitService'; -import { analyzeFileChanges } from './gitBlameAnalyzer'; +import { GitBlameAnalyzer } from './gitBlameAnalyzer'; import { SettingsValidator } from './settingsValidator'; import { TelemetryService } from '../services/telemetryService'; import { errorMessages } from '../utils/constants'; -const MAX_RETRIES = 3; -const INITIAL_RETRY_DELAY_MS = 1000; const MAX_DIFF_LENGTH = 100000; const GEMINI_API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models'; +const MAX_RETRY_BACKOFF = 10000; // Maximum retry delay in milliseconds interface GeminiResponse { candidates: Array<{ @@ -32,10 +31,24 @@ interface ApiErrorResponse { data: unknown; } -type ErrorWithResponse = Error & { +type ErrorWithResponse = AxiosError & { response?: ApiErrorResponse; }; +interface GenerationConfig { + temperature: number; + topK: number; + topP: number; + maxOutputTokens: number; +} + +const DEFAULT_GENERATION_CONFIG: GenerationConfig = { + temperature: 0.7, + topK: 40, + topP: 0.95, + maxOutputTokens: 1024, +}; + export class AIService { static async generateCommitMessage( diff: string, @@ -75,60 +88,78 @@ export class AIService { ): Promise { const apiKey = await ConfigService.getApiKey(); const model = ConfigService.getGeminiModel(); - const GEMINI_API_URL = `${GEMINI_API_BASE_URL}/${model}:generateContent`; + const apiUrl = `${GEMINI_API_BASE_URL}/${model}:generateContent`; - const headers = { - 'content-type': 'application/json', - 'x-goog-api-key': apiKey + const requestConfig = { + headers: { + 'content-type': 'application/json', + 'x-goog-api-key': apiKey + }, + timeout: 30000 // 30 seconds timeout }; const payload = { contents: [{ parts: [{ text: prompt }] }], - generationConfig: { - temperature: 0.7, - topK: 40, - topP: 0.95, - maxOutputTokens: 1024, - }, + generationConfig: DEFAULT_GENERATION_CONFIG, }; try { void Logger.log(`Attempt ${attempt}: Sending request to Gemini API`); - const progressMessage = attempt === 1 - ? "Generating commit message..." - : `Retry attempt ${attempt}/${MAX_RETRIES}...`; - progress.report({ message: progressMessage, increment: 10 }); + await this.updateProgressForAttempt(progress, attempt); - const response = await axios.post(GEMINI_API_URL, payload, { headers }); + const response = await axios.post(apiUrl, payload, requestConfig); void Logger.log('Gemini API response received successfully'); progress.report({ message: "Processing generated message...", increment: 100 }); - const responseData = response.data; - if (!responseData.candidates?.[0]?.content?.parts?.[0]?.text) { - throw new Error("Invalid response format from Gemini API"); - } - - const commitMessage = this.cleanCommitMessage(responseData.candidates[0].content.parts[0].text); - if (!commitMessage.trim()) { - throw new Error("Generated commit message is empty."); - } + const commitMessage = this.extractCommitMessage(response.data); + void Logger.log(`Commit message generated using ${model} model`); + void TelemetryService.sendEvent('message_generation_completed'); return { message: commitMessage, model }; } catch (error) { - void Logger.error(`Generation attempt ${attempt} failed:`, error as Error); - const { errorMessage, shouldRetry } = this.handleApiError(error as ErrorWithResponse); - - if (shouldRetry && attempt < MAX_RETRIES) { - const delayMs = this.calculateRetryDelay(attempt); - void Logger.log(`Retrying in ${delayMs / 1000} seconds...`); - progress.report({ message: `Waiting ${delayMs / 1000} seconds before retry...`, increment: 0 }); - await this.delay(delayMs); - return this.generateWithGemini(prompt, progress, attempt + 1); - } + return await this.handleGenerationError(error as ErrorWithResponse, prompt, progress, attempt); + } + } + + private static async updateProgressForAttempt(progress: ProgressReporter, attempt: number): Promise { + const progressMessage = attempt === 1 + ? "Generating commit message..." + : `Retry attempt ${attempt}/${ConfigService.getMaxRetries()}...`; + progress.report({ message: progressMessage, increment: 10 }); + } + + private static extractCommitMessage(response: GeminiResponse): string { + if (!response.candidates?.[0]?.content?.parts?.[0]?.text) { + throw new Error("Invalid response format from Gemini API"); + } - throw new Error(`Failed to generate commit message: ${errorMessage}`); + const commitMessage = this.cleanCommitMessage(response.candidates[0].content.parts[0].text); + if (!commitMessage.trim()) { + throw new Error("Generated commit message is empty."); } + + return commitMessage; + } + + private static async handleGenerationError( + error: ErrorWithResponse, + prompt: string, + progress: ProgressReporter, + attempt: number + ): Promise { + void Logger.error(`Generation attempt ${attempt} failed:`, error); + const { errorMessage, shouldRetry } = this.handleApiError(error); + + if (shouldRetry && attempt < ConfigService.getMaxRetries()) { + const delayMs = this.calculateRetryDelay(attempt); + void Logger.log(`Retrying in ${delayMs / 1000} seconds...`); + progress.report({ message: `Waiting ${delayMs / 1000} seconds before retry...`, increment: 0 }); + await this.delay(delayMs); + return this.generateWithGemini(prompt, progress, attempt + 1); + } + + throw new Error(`Failed to generate commit message: ${errorMessage}`); } private static handleApiError(error: ErrorWithResponse): { errorMessage: string; shouldRetry: boolean } { @@ -182,7 +213,8 @@ export class AIService { } private static calculateRetryDelay(attempt: number): number { - return Math.min(INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1), 10000); + const initialDelay = ConfigService.getInitialRetryDelay(); + return Math.min(initialDelay * Math.pow(2, attempt - 1), MAX_RETRY_BACKOFF); } private static delay(ms: number): Promise { @@ -190,180 +222,138 @@ export class AIService { } } -export async function generateAndSetCommitMessage(): Promise { - let notificationHandle: vscode.Progress<{ message?: string; increment?: number }> | undefined; +// Separate the UI handling logic +export class CommitMessageUI { + static async generateAndSetCommitMessage(): Promise { + let model = 'unknown'; + try { + await this.initializeAndValidate(); + await this.executeWithProgress(async progress => { + const result = await this.generateAndApplyMessage(progress); + model = result.model; + }); + void vscode.window.showInformationMessage(`Message generated using ${model}`); + } catch (error) { + await this.handleError(error as Error); + } + } - try { + private static async initializeAndValidate(): Promise { await SettingsValidator.validateAllSettings(); void Logger.log('Starting commit message generation process'); void TelemetryService.sendEvent('generate_message_started'); + } - await vscode.window.withProgress({ + private static async executeWithProgress( + action: (progress: vscode.Progress<{ message?: string; increment?: number }>) => Promise + ): Promise { + return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Generating Commit Message", cancellable: false }, async (progress) => { - notificationHandle = progress; + try { + await action(progress); + progress.report({ increment: 100 }); + } finally { + // Ensure progress is completed + progress.report({ increment: 100 }); + } + }); + } - const repos = await GitService.getRepositories(); - const selectedRepo = await GitService.selectRepository(repos); + private static async generateAndApplyMessage( + progress: vscode.Progress<{ message?: string; increment?: number }> + ): Promise<{ model: string }> { + try { + progress.report({ message: "Fetching Git changes...", increment: 0 }); - if (!selectedRepo || !selectedRepo.rootUri) { - void Logger.error('Repository selection failed: No repository or root URI'); - throw new Error('No repository selected or repository has no root URI.'); + const repo = await GitService.getActiveRepository(); + if (!repo?.rootUri) { + throw new Error('No active repository found'); } - const repoPath = selectedRepo.rootUri.fsPath; - void Logger.log(`Selected repository: ${repoPath}`); - + const repoPath = repo.rootUri.fsPath; const onlyStagedChanges = ConfigService.getOnlyStagedChanges(); + void Logger.log(`Selected repository: ${repoPath}`); void Logger.log(`Only staged changes mode: ${onlyStagedChanges}`); - const hasStagedChanges = await GitService.hasChanges(repoPath, 'staged'); - void Logger.log(`Has staged changes: ${hasStagedChanges}`); + const diff = await GitService.getDiff(repoPath, onlyStagedChanges); + void Logger.log(`Git diff fetched successfully. Length: ${diff.length} characters`); - progress.report({ message: `Fetching Git diff${onlyStagedChanges ? ' (staged changes only)' : ''}...`, increment: 0 }); + progress.report({ message: "Analyzing code changes...", increment: 25 }); + const changedFiles = await GitService.getChangedFiles(repoPath, onlyStagedChanges); + void Logger.log(`Analyzing ${changedFiles.length} changed files`); - try { - const diff = await GitService.getDiff(repoPath, onlyStagedChanges); - void Logger.log(`Git diff fetched successfully. Length: ${diff.length} characters`); - - progress.report({ message: "Analyzing changes...", increment: 25 }); - const changedFiles = await GitService.getChangedFiles(repoPath, onlyStagedChanges); - void Logger.log(`Analyzing ${changedFiles.length} changed files`); - - let blameAnalysis = ''; - for (const file of changedFiles) { - const filePath = vscode.Uri.file(path.join(repoPath, file)); - try { - const fileBlameAnalysis = await analyzeFileChanges(filePath.fsPath); - blameAnalysis += `File: ${file}\n${fileBlameAnalysis}\n\n`; - void Logger.log(`Blame analysis completed for: ${file}`); - } catch (error) { - void Logger.error(`Error analyzing file ${file}:`, error as Error); - blameAnalysis += `File: ${file}\nUnable to analyze: ${(error as Error).message}\n\n`; - } - } + const blameAnalysis = await this.analyzeChanges(repoPath, changedFiles); + void Logger.log(`Diff length: ${diff.length} characters`); - progress.report({ message: "Generating commit message...", increment: 50 }); - const { message: commitMessage, model } = await AIService.generateCommitMessage(diff, blameAnalysis, progress); - void Logger.log(`Commit message generated using ${model} model`); - void TelemetryService.sendEvent('message_generated', { - model, - diffLength: diff.length, - messageLength: commitMessage.length, - changedFilesCount: changedFiles.length - }); - - let finalMessage = commitMessage; - - if (ConfigService.shouldPromptForRefs()) { - void Logger.log('Prompting for references'); - const refs = await vscode.window.showInputBox({ - prompt: "Enter references (e.g., issue numbers) to be added below the commit message", - placeHolder: "e.g., #123, JIRA-456" - }); - - if (refs) { - finalMessage += `\n\n${refs}`; - void Logger.log('References added to commit message'); - } - } + const { message, model } = await AIService.generateCommitMessage(diff, blameAnalysis, progress); - progress.report({ message: "Setting commit message...", increment: 75 }); - selectedRepo.inputBox.value = finalMessage; - void Logger.log('Commit message set in input box'); - - if (ConfigService.getAutoCommitEnabled()) { - void Logger.log('Auto-commit is enabled, proceeding with commit'); - if (!finalMessage.trim()) { - void Logger.error('Empty commit message detected, aborting auto-commit'); - return; - } - - progress.report({ message: "Checking Git configuration...", increment: 80 }); - await GitService.checkGitConfig(repoPath); - void Logger.log('Git configuration validated'); - - progress.report({ message: "Committing changes...", increment: 85 }); - await GitService.commitChanges(finalMessage); - void Logger.log('Changes committed successfully'); - - if (ConfigService.getAutoPushEnabled()) { - void Logger.log('Auto-push is enabled, proceeding with push'); - progress.report({ message: "Pushing changes...", increment: 95 }); - await GitService.pushChanges(); - void Logger.log('Changes pushed successfully'); - } - - void TelemetryService.sendEvent('commit_message_applied', { - model, - messageLength: finalMessage.length, - autoPushEnabled: ConfigService.getAutoPushEnabled() - }); - - progress.report({ message: "Done!", increment: 100 }); - } + progress.report({ message: "Setting commit message...", increment: 90 }); + await this.setCommitMessage(message); - progress.report({ message: "", increment: 100 }); + void Logger.log(`Commit message generated using ${model} model`); + void TelemetryService.sendEvent('message_generation_completed'); - void vscode.window.showInformationMessage( - `Message generated using ${model}`, - { modal: false } - ); - } catch (error) { - const errorMessage = (error as Error).message; - if (errorMessage.includes('No staged changes detected') && !onlyStagedChanges) { - void Logger.log('No staged changes detected, proceeding with -a commit'); - } else { - throw error; + return { model }; + } catch (error) { + void Logger.error('Error in generateAndApplyMessage:', error as Error); + throw error; + } + } + + private static async handleError(error: Error): Promise { + void Logger.error('Error generating commit message:', error); + void TelemetryService.sendEvent('message_generation_failed', { + error: error.message + }); + void vscode.window.showErrorMessage(`Error: ${error.message}`); + } + + private static async analyzeChanges(repoPath: string, changedFiles: string[]): Promise { + let blameAnalysis = ''; + for (const file of changedFiles) { + const filePath = vscode.Uri.file(path.join(repoPath, file)); + try { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(filePath); + if (!workspaceFolder) { + void Logger.error(`File is not part of workspace: ${file}`); + blameAnalysis += `File: ${file}\nUnable to analyze: File is not part of workspace\n\n`; + continue; } + const fileBlameAnalysis = await GitBlameAnalyzer.analyzeChanges(workspaceFolder.uri.fsPath, filePath.fsPath); + blameAnalysis += `File: ${file}\n${fileBlameAnalysis}\n\n`; + void Logger.log(`Blame analysis completed for: ${file}`); + } catch (error) { + void Logger.error(`Error analyzing file ${file}:`, error as Error); + blameAnalysis += `File: ${file}\nUnable to analyze: ${(error as Error).message}\n\n`; } - }); - } catch (error) { - void TelemetryService.sendEvent('generate_message_failed', { - error: (error as Error).message - }); - if (notificationHandle) { - notificationHandle.report({ message: "" }); } + return blameAnalysis; + } - const errorMessage = (error as Error).message; - void Logger.error('Error in command execution:', error as Error); + private static async setCommitMessage(message: string): Promise { + const repo = await GitService.getActiveRepository(); + if (!repo?.rootUri) { + throw new Error('No active repository found'); + } + repo.inputBox.value = message; + void Logger.log('Commit message set in input box'); - if (errorMessage.includes('No staged changes to commit') && ConfigService.getOnlyStagedChanges()) { - const message = await vscode.window.showErrorMessage( - 'No staged changes to commit. Please stage your changes first.', - { modal: false }, - 'Try Again' - ); + if (ConfigService.getAutoCommitEnabled()) { + await this.handleAutoCommit(repo.rootUri.fsPath, message); + } + } - if (message === 'Try Again') { - void generateAndSetCommitMessage(); - } - } else if (errorMessage.includes('Git user.name or user.email is not configured')) { - const message = await vscode.window.showErrorMessage( - 'Git user.name or user.email is not configured. Please configure Git before committing.', - { modal: false }, - 'Open Git Config', - 'Try Again' - ); - - if (message === 'Open Git Config') { - void vscode.commands.executeCommand('workbench.action.openGlobalSettings', 'git.user'); - } else if (message === 'Try Again') { - void generateAndSetCommitMessage(); - } - } else { - const message = await vscode.window.showErrorMessage( - `Error: ${errorMessage}`, - { modal: false }, - 'Try Again' - ); - - if (message === 'Try Again') { - void generateAndSetCommitMessage(); - } + private static async handleAutoCommit(repoPath: string, message: string): Promise { + void Logger.log('Auto commit enabled, committing changes'); + await GitService.commitChanges(message); + + if (ConfigService.getAutoPushEnabled()) { + void Logger.log('Auto push enabled, pushing changes'); + await GitService.pushChanges(); + void Logger.log('Changes pushed successfully'); } } } diff --git a/src/services/customEndpointService.ts b/src/services/customEndpointService.ts index 3c921e2..9dde63d 100644 --- a/src/services/customEndpointService.ts +++ b/src/services/customEndpointService.ts @@ -11,10 +11,7 @@ interface CustomApiResponse { }>; } -interface ApiHeaders { - contentType: string; - authBearer: string; -} +type ApiHeaders = Record; export class CustomEndpointService { static async generateCommitMessage( @@ -31,25 +28,15 @@ export class CustomEndpointService { }; const headers: ApiHeaders = { - contentType: 'application/json', - authBearer: `Bearer ${apiKey}` + 'content-type': 'application/json', + 'authorization': `Bearer ${apiKey}` }; try { void Logger.log('Sending request to custom endpoint'); progress.report({ message: 'Generating commit message...', increment: 50 }); - const requestHeaders = { - contentType: headers.contentType, - authorization: headers.authBearer - }; - - const response = await axios.post(endpoint, payload, { - headers: { - 'content-type': requestHeaders.contentType, - 'authorization': requestHeaders.authorization - } - }); + const response = await axios.post(endpoint, payload, { headers }); void Logger.log('Custom endpoint response received successfully'); progress.report({ message: 'Commit message generated successfully', increment: 100 }); diff --git a/src/services/gitBlameAnalyzer.ts b/src/services/gitBlameAnalyzer.ts index 81cf187..50f4d5f 100644 --- a/src/services/gitBlameAnalyzer.ts +++ b/src/services/gitBlameAnalyzer.ts @@ -2,6 +2,8 @@ import * as vscode from 'vscode'; import { spawn } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; +import { Logger } from '../utils/logger'; +import { errorMessages } from '../utils/constants'; interface BlameInfo { commit: string; @@ -10,95 +12,125 @@ interface BlameInfo { line: string; } +interface GitProcessResult { + data: T; + stderr: string[]; +} + export class GitBlameAnalyzer { - private static async getGitBlame(filePath: string): Promise { - return new Promise((resolve, reject) => { - if (!fs.existsSync(filePath)) { - reject(new Error(`File does not exist: ${filePath}`)); - return; - } + private static async executeGitCommand( + command: string[], + filePath: string, + processOutput: (data: Buffer) => T, + ignoreFileNotFound = false + ): Promise> { + if (!ignoreFileNotFound && !fs.existsSync(filePath)) { + throw new Error(`${errorMessages.fileNotFound}: ${filePath}`); + } - const blame: BlameInfo[] = []; - const gitProcess = spawn('git', ['blame', '--line-porcelain', path.basename(filePath)], { + return new Promise((resolve, reject) => { + const gitProcess = spawn('git', command, { cwd: path.dirname(filePath) }); - let currentBlame: Partial = {}; + let result: T; + const stderr: string[] = []; gitProcess.stdout.on('data', (data: Buffer) => { - const lines = data.toString().split('\n'); - lines.forEach((line: string) => { - if (line.startsWith('author ')) { - currentBlame.author = line.substring(7); - } else if (line.startsWith('committer-time ')) { - currentBlame.date = new Date(parseInt(line.substring(15)) * 1000).toISOString(); - } else if (line.startsWith('\t')) { - currentBlame.line = line.substring(1); - blame.push(currentBlame as BlameInfo); - currentBlame = {}; - } else if (line.match(/^[0-9a-f]{40}/)) { - currentBlame.commit = line.split(' ')[0]; - } - }); + result = processOutput(data); }); gitProcess.stderr.on('data', (data: Buffer) => { - console.error(`Git blame stderr: ${data.toString()}`); + const errorMessage = data.toString(); + stderr.push(errorMessage); + void Logger.error(`Git command stderr: ${errorMessage}`); }); gitProcess.on('close', (code) => { - if (code === 0) { - resolve(blame); + if (code === 0 || (ignoreFileNotFound && code === 128)) { + resolve({ data: result, stderr }); } else { - reject(new Error(`Git blame process exited with code ${code}`)); + reject(new Error(`Git process exited with code ${code}`)); } }); }); } - private static async getDiff(repoPath: string, filePath: string): Promise { - return new Promise((resolve, reject) => { - const gitProcess = spawn('git', ['diff', '--', path.basename(filePath)], { cwd: path.dirname(filePath) }); - let diff = ''; - - gitProcess.stdout.on('data', (data: Buffer) => { - diff += data.toString(); - }); + private static async getGitBlame(filePath: string): Promise { + if (!fs.existsSync(filePath)) { + void Logger.log(`Skipping blame for non-existent file: ${filePath}`); + return []; + } - gitProcess.stderr.on('data', (data: Buffer) => { - console.error(`Git diff stderr: ${data.toString()}`); - }); + const processBlameOutput = (data: Buffer): BlameInfo[] => { + const blame: BlameInfo[] = []; + let currentBlame: Partial = {}; - gitProcess.on('close', (code) => { - if (code === 0) { - resolve(diff); - } else { - reject(new Error(`Git diff process exited with code ${code}`)); + const lines = data.toString().split('\n'); + lines.forEach((line: string) => { + if (line.startsWith('author ')) { + currentBlame.author = line.substring(7); + } else if (line.startsWith('committer-time ')) { + currentBlame.date = new Date(parseInt(line.substring(15)) * 1000).toISOString(); + } else if (line.startsWith('\t')) { + currentBlame.line = line.substring(1); + blame.push(currentBlame as BlameInfo); + currentBlame = {}; + } else if (line.match(/^[0-9a-f]{40}/)) { + currentBlame.commit = line.split(' ')[0]; } }); - }); + + return blame; + }; + + const { data } = await this.executeGitCommand( + ['blame', '--line-porcelain', path.basename(filePath)], + filePath, + processBlameOutput + ); + + return data; } - static async analyzeChanges(repoPath: string, filePath: string): Promise { + private static async getDiff(repoPath: string, filePath: string): Promise { + const processDiffOutput = (data: Buffer): string => data.toString(); + try { - console.log(`Analyzing changes for file: ${filePath}`); + const { data } = await this.executeGitCommand( + ['diff', '--', path.basename(filePath)], + filePath, + processDiffOutput, + true // Ignore if file doesn't exist (for moved/deleted files) + ); + + return data; + } catch (error) { + void Logger.log(`Could not get diff for ${filePath}, might be moved/deleted`); + return ''; + } + } - if (!fs.existsSync(filePath)) { - throw new Error(`File does not exist: ${filePath}`); - } + static async analyzeChanges(repoPath: string, filePath: string): Promise { + try { + void Logger.log(`Analyzing changes for file: ${filePath}`); const blame = await this.getGitBlame(filePath); - console.log(`Git blame completed for ${filePath}`); + void Logger.log(`Git blame completed for ${filePath}`); const diff = await this.getDiff(repoPath, filePath); - console.log(`Git diff completed for ${filePath}`); + void Logger.log(`Git diff completed for ${filePath}`); + + if (!blame.length && !diff) { + return `File ${path.basename(filePath)} was moved or deleted`; + } const changedLines = this.parseChangedLines(diff); const blameAnalysis = this.analyzeBlameInfo(blame, changedLines); return this.formatAnalysis(blameAnalysis); } catch (error) { - console.error('Error in GitBlameAnalyzer:', error); + void Logger.error('Error in GitBlameAnalyzer:', error as Error); throw error; } } @@ -152,19 +184,4 @@ export class GitBlameAnalyzer { return result; } -} - -// Usage in the extension -export async function analyzeFileChanges(filePath: string): Promise { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)); - if (!workspaceFolder) { - throw new Error('File is not part of a workspace'); - } - - try { - return await GitBlameAnalyzer.analyzeChanges(workspaceFolder.uri.fsPath, filePath); - } catch (error) { - console.error(`Error analyzing file changes for ${filePath}:`, error); - return `Unable to analyze changes for ${filePath}: ${(error as Error).message}`; - } } \ No newline at end of file diff --git a/src/services/gitService.ts b/src/services/gitService.ts index 5d52149..289f33e 100644 --- a/src/services/gitService.ts +++ b/src/services/gitService.ts @@ -9,6 +9,24 @@ import { } from '../models/errors'; import { TelemetryService } from './telemetryService'; +const GIT_STATUS_CODES = { + MODIFIED: 'M', + ADDED: 'A', + DELETED: 'D', + RENAMED: 'R', + UNTRACKED: '??' +} as const; + +type GitStatusCode = typeof GIT_STATUS_CODES[keyof typeof GIT_STATUS_CODES]; +type GitChangeType = 'staged' | 'untracked'; + +const STAGED_STATUS_CODES: GitStatusCode[] = [ + GIT_STATUS_CODES.MODIFIED, + GIT_STATUS_CODES.ADDED, + GIT_STATUS_CODES.DELETED, + GIT_STATUS_CODES.RENAMED +]; + export class GitService { static async initialize(context: vscode.ExtensionContext): Promise { try { @@ -78,49 +96,61 @@ export class GitService { static async getDiff(repoPath: string, onlyStaged: boolean = false): Promise { void Logger.log(`Getting diff for ${repoPath} (onlyStaged: ${onlyStaged})`); - const stagedDiff = await this.executeGitCommand(['diff', '--staged'], repoPath); - - if (stagedDiff.trim()) { - return stagedDiff; - } + try { + // Get staged changes first + const stagedDiff = await this.executeGitCommand(['diff', '--staged'], repoPath); + if (stagedDiff.trim()) { + return stagedDiff; + } - if (onlyStaged) { - throw new NoChangesDetectedError('No staged changes detected.'); - } + if (onlyStaged) { + throw new NoChangesDetectedError('No staged changes detected.'); + } - const unstaged = await this.executeGitCommand(['diff'], repoPath); - const untrackedFiles = await this.getUntrackedFiles(repoPath); - - let untrackedContent = ''; - if (untrackedFiles.length > 0) { - for (const file of untrackedFiles) { - untrackedContent += `diff --git a/${file} b/${file}\n`; - untrackedContent += `new file mode 100644\n`; - untrackedContent += `--- /dev/null\n`; - untrackedContent += `+++ b/${file}\n`; - - try { - const fileContent = await this.executeGitCommand(['show', `:${file}`], repoPath).catch(() => ''); - if (fileContent) { - untrackedContent += fileContent.split('\n') - .map(line => `+${line}`) - .join('\n'); - untrackedContent += '\n'; + // Get unstaged changes + const [unstaged, untrackedFiles] = await Promise.all([ + this.executeGitCommand(['diff'], repoPath), + this.getUntrackedFiles(repoPath) + ]); + + // Process untracked files in parallel + const untrackedContents = await Promise.all( + untrackedFiles.map(async file => { + try { + const fileContent = await this.executeGitCommand(['show', `:${file}`], repoPath) + .catch(() => ''); + + if (!fileContent) return ''; + + return [ + `diff --git a/${file} b/${file}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${file}`, + ...fileContent.split('\n').map(line => `+${line}`) + ].join('\n') + '\n'; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + void Logger.log(`Error reading content of ${file}: ${errorMessage}`); + return ''; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - void Logger.log(`Error reading content of ${file}: ${errorMessage}`); - } - } - } + }) + ); - const combinedDiff = unstaged + (untrackedContent ? '\n' + untrackedContent : ''); + const combinedDiff = [ + unstaged, + ...untrackedContents.filter(content => content.length > 0) + ].join('\n').trim(); - if (!combinedDiff.trim()) { - throw new NoChangesDetectedError('No changes detected.'); - } + if (!combinedDiff) { + throw new NoChangesDetectedError('No changes detected.'); + } - return combinedDiff; + return combinedDiff; + } catch (error) { + void Logger.error('Error getting diff:', error as Error); + throw error; + } } private static async getUntrackedFiles(repoPath: string): Promise { @@ -129,14 +159,17 @@ export class GitService { return output.split('\n').filter(line => line.trim() !== ''); } - static async hasChanges(repoPath: string, type: 'staged' | 'untracked'): Promise { + static async hasChanges(repoPath: string, type: GitChangeType): Promise { try { const statusOutput = await this.executeGitCommand(['status', '--porcelain'], repoPath); return statusOutput.split('\n').some(line => { + const trimmedLine = line.trim(); + if (!trimmedLine) return false; + if (type === 'staged') { - return line.trim() !== '' && ['M', 'A', 'D', 'R'].includes(line[0]); + return STAGED_STATUS_CODES.includes(line[0] as GitStatusCode); } - return line.trim() !== '' && line.startsWith('??'); + return line.startsWith(GIT_STATUS_CODES.UNTRACKED); }); } catch (error) { void Logger.error(`Error checking ${type} changes:`, error as Error); @@ -149,7 +182,7 @@ export class GitService { const output = await this.executeGitCommand(statusCommand, repoPath); return output.split('\n') .filter(line => line.trim() !== '') - .filter(line => !onlyStaged || line[0] === 'M' || line[0] === 'A' || line[0] === 'D' || line[0] === 'R') + .filter(line => !onlyStaged || STAGED_STATUS_CODES.includes(line[0] as GitStatusCode)) .map(line => line.substring(3).trim()); } @@ -169,10 +202,21 @@ export class GitService { let stderr = ''; childProcess.stdout.on('data', (data) => { stdout += data; }); - childProcess.stderr.on('data', (data) => { stderr += data; }); + childProcess.stderr.on('data', (data) => { + stderr += data; + void Logger.error(`Git command stderr: ${data}`); + }); + + childProcess.on('error', (error) => { + void Logger.error(`Git command error: ${error.message}`); + reject(new Error(`Git command failed: ${error.message}`)); + }); + childProcess.on('close', (code) => { if (code !== 0) { - reject(new Error(`Git ${args.join(' ')} failed with code ${code}: ${stderr}`)); + const errorMessage = `Git ${args.join(' ')} failed with code ${code}${stderr ? ': ' + stderr : ''}`; + void Logger.error(errorMessage); + reject(new Error(errorMessage)); } else { resolve(stdout); } diff --git a/src/services/promptService.ts b/src/services/promptService.ts index db71e38..d198803 100644 --- a/src/services/promptService.ts +++ b/src/services/promptService.ts @@ -34,16 +34,4 @@ Please provide ONLY the commit message, without any additional text or explanati return 'Please write the commit message in English.'; } } - - static getCommitFormatPrompt(format: string): string { - switch (format.toLowerCase()) { - case 'conventional': - return 'Use Conventional Commits format: type(scope): description'; - case 'gitmoji': - return 'Use Gitmoji format: :emoji: description'; - case 'basic': - default: - return 'Use basic format: description'; - } - } } \ No newline at end of file diff --git a/src/services/settingsValidator.ts b/src/services/settingsValidator.ts index 2e420b3..e35ce30 100644 --- a/src/services/settingsValidator.ts +++ b/src/services/settingsValidator.ts @@ -125,18 +125,4 @@ export class SettingsValidator { } } } - - static validateCommitLanguage(language: string): void { - const validLanguages = [ - 'english', - 'russian', - 'chinese', - 'japanese' - ]; - - if (!validLanguages.includes(language)) { - throw new Error(`Invalid commit language: ${language}. Valid options are: ${validLanguages.join(', ')}`); - } - } - } \ No newline at end of file diff --git a/src/services/telemetryService.ts b/src/services/telemetryService.ts index 4e0edf2..0d53dd7 100644 --- a/src/services/telemetryService.ts +++ b/src/services/telemetryService.ts @@ -1,71 +1,199 @@ import * as vscode from 'vscode'; import * as amplitude from '@amplitude/analytics-node'; import { Logger } from '../utils/logger'; +import { ConfigService } from '../utils/configService'; -const AMPLITUDE_API_KEY = process.env.AMPLITUDE_API_KEY || ''; +const TELEMETRY_CONFIG = { + MAX_RETRIES: 3, + RETRY_DELAY: 1000, + QUEUE_SIZE_LIMIT: 100, + FLUSH_INTERVAL: 30000, // 30 seconds +} as const; + +type TelemetryEventName = + | 'extension_activated' + | 'extension_deactivated' + | 'message_generation_started' + | 'message_generation_completed' + | 'message_generation_failed' + | 'commit_started' + | 'commit_completed' + | 'commit_failed' + | 'settings_changed' + | 'generate_message_started'; + +interface TelemetryEventProperties { + vsCodeVersion: string; + extensionVersion: string | undefined; + platform: string; + [key: string]: any; +} + +interface QueuedEvent { + eventName: TelemetryEventName; + properties: TelemetryEventProperties; + retryCount: number; + timestamp: number; +} export class TelemetryService { private static disposables: vscode.Disposable[] = []; private static enabled: boolean = true; + private static initialized: boolean = false; + private static eventQueue: QueuedEvent[] = []; + private static flushInterval: NodeJS.Timeout | null = null; static async initialize(context: vscode.ExtensionContext): Promise { void Logger.log('Initializing telemetry service'); - if (!AMPLITUDE_API_KEY) { + const amplitudeApiKey = process.env.AMPLITUDE_API_KEY; + if (!amplitudeApiKey) { void Logger.error('Amplitude API key not found'); return; } try { - amplitude.init(AMPLITUDE_API_KEY, { + amplitude.init(amplitudeApiKey, { serverZone: 'EU', flushQueueSize: 1, - flushIntervalMillis: 0 + flushIntervalMillis: 0, + optOut: !this.enabled }); + + this.initialized = true; void Logger.log('Amplitude service initialized'); + // Set initial telemetry state + this.enabled = vscode.env.isTelemetryEnabled && ConfigService.isTelemetryEnabled(); + + // Listen for telemetry setting changes + this.disposables.push( + vscode.env.onDidChangeTelemetryEnabled(this.handleTelemetryStateChange.bind(this)), + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('geminiCommit.telemetry.enabled')) { + this.handleTelemetryStateChange(ConfigService.isTelemetryEnabled()); + } + }) + ); + + // Start queue processing + this.startQueueProcessor(); + + context.subscriptions.push(...this.disposables); + void Logger.log('Telemetry service initialized'); + + // Send initialization event + this.sendEvent('extension_activated'); } catch (error) { void Logger.error('Failed to initialize Amplitude:', error as Error); + this.initialized = false; + } + } + + static sendEvent(eventName: TelemetryEventName, customProperties: Record = {}): void { + if (!this.enabled) { + void Logger.log('Telemetry disabled, skipping event'); return; } - this.enabled = vscode.env.isTelemetryEnabled; + const properties: TelemetryEventProperties = { + vsCodeVersion: vscode.version, + extensionVersion: vscode.extensions.getExtension('VizzleTF.geminicommit')?.packageJSON.version, + platform: process.platform, + ...customProperties + }; - this.disposables.push( - vscode.env.onDidChangeTelemetryEnabled(enabled => { - this.enabled = enabled; - void Logger.log(`Telemetry enabled state changed to: ${enabled}`); - }) - ); + const queuedEvent: QueuedEvent = { + eventName, + properties, + retryCount: 0, + timestamp: Date.now() + }; - context.subscriptions.push(...this.disposables); - void Logger.log('Telemetry service initialized'); + this.queueEvent(queuedEvent); } - static sendEvent(eventName: string, properties?: Record): void { - if (!this.enabled) { - void Logger.log('Telemetry disabled, skipping event'); + private static queueEvent(event: QueuedEvent): void { + // Remove old events if queue is full + if (this.eventQueue.length >= TELEMETRY_CONFIG.QUEUE_SIZE_LIMIT) { + this.eventQueue.shift(); + void Logger.warn('Telemetry event queue full, removing oldest event'); + } + + this.eventQueue.push(event); + void Logger.log(`Event queued: ${event.eventName}`); + + // Try to process immediately if possible + if (this.initialized) { + void this.processEventQueue(); + } + } + + private static async processEventQueue(): Promise { + if (!this.initialized || !this.enabled || this.eventQueue.length === 0) { return; } + const currentEvent = this.eventQueue[0]; + try { - void Logger.log(`Sending telemetry event: ${eventName}`); - amplitude.track(eventName, { - ...properties, - vsCodeVersion: vscode.version, - extensionVersion: vscode.extensions.getExtension('VizzleTF.geminicommit')?.packageJSON.version, - platform: process.platform - }, { - device_id: vscode.env.machineId + void Logger.log(`Processing telemetry event: ${currentEvent.eventName}`); + + await amplitude.track(currentEvent.eventName, currentEvent.properties, { + device_id: vscode.env.machineId, + time: currentEvent.timestamp }); - void Logger.log(`Telemetry event sent successfully: ${eventName}`); + + // Remove successfully sent event + this.eventQueue.shift(); + void Logger.log(`Telemetry event sent successfully: ${currentEvent.eventName}`); } catch (error) { void Logger.error('Failed to send telemetry:', error as Error); + + if (currentEvent.retryCount < TELEMETRY_CONFIG.MAX_RETRIES) { + currentEvent.retryCount++; + void Logger.log(`Retrying event ${currentEvent.eventName} (attempt ${currentEvent.retryCount}/${TELEMETRY_CONFIG.MAX_RETRIES})`); + await this.delay(TELEMETRY_CONFIG.RETRY_DELAY * currentEvent.retryCount); + } else { + void Logger.error(`Failed to send event ${currentEvent.eventName} after ${TELEMETRY_CONFIG.MAX_RETRIES} attempts, discarding`); + this.eventQueue.shift(); + } } } + private static startQueueProcessor(): void { + if (this.flushInterval) { + clearInterval(this.flushInterval); + } + + this.flushInterval = setInterval(() => { + void this.processEventQueue(); + }, TELEMETRY_CONFIG.FLUSH_INTERVAL); + } + + private static handleTelemetryStateChange(enabled: boolean): void { + this.enabled = enabled; + amplitude.setOptOut(!enabled); + void Logger.log(`Telemetry enabled state changed to: ${enabled}`); + } + + private static delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + static dispose(): void { + if (this.flushInterval) { + clearInterval(this.flushInterval); + } + + // Try to send any remaining events + if (this.eventQueue.length > 0) { + void Logger.log(`Attempting to send ${this.eventQueue.length} remaining telemetry events`); + void this.processEventQueue(); + } + this.disposables.forEach(d => void d.dispose()); + this.initialized = false; void Logger.log('Telemetry service disposed'); } } diff --git a/src/templates/index.ts b/src/templates/index.ts index 40ea6e7..4cab4fa 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -6,11 +6,15 @@ import { emojiTemplate } from './formats/emoji'; import type { CommitLanguage } from '../utils/configService'; export interface CommitTemplate { - [key: string]: string; + english: string; + russian: string; + chinese: string; + japanese: string; } export type CommitFormat = 'conventional' | 'angular' | 'karma' | 'semantic' | 'emoji'; -export type Language = CommitLanguage; + +const SUPPORTED_LANGUAGES = ['english', 'russian', 'chinese', 'japanese'] as const; const templates: Record = { conventional: conventionalTemplate, @@ -18,8 +22,28 @@ const templates: Record = { karma: karmaTemplate, semantic: semanticTemplate, emoji: emojiTemplate -}; +} as const; + +const isValidFormat = (format: string): format is CommitFormat => + Object.keys(templates).includes(format); + +const isValidLanguage = (language: string): language is CommitLanguage => + SUPPORTED_LANGUAGES.includes(language as CommitLanguage); + +export function getTemplate(format: CommitFormat, language: CommitLanguage): string { + // Validate format + if (!isValidFormat(format)) { + console.warn(`Invalid format "${format}", falling back to conventional`); + format = 'conventional'; + } + + const template = templates[format]; + + // Validate language + if (!isValidLanguage(language)) { + console.warn(`Invalid language "${language}", falling back to english`); + language = 'english'; + } -export function getTemplate(format: CommitFormat, language: Language): string { - return templates[format][language] || templates.conventional.en; + return template[language]; } \ No newline at end of file diff --git a/src/utils/apiKeyValidator.ts b/src/utils/apiKeyValidator.ts index 4ffcc4f..78610ae 100644 --- a/src/utils/apiKeyValidator.ts +++ b/src/utils/apiKeyValidator.ts @@ -2,133 +2,100 @@ import axios, { AxiosError } from 'axios'; import { Logger } from './logger'; import { AiServiceError } from '../models/errors'; -interface ApiHeaders { - xGoogApiKey: string; -} +const API_VALIDATION = { + MIN_KEY_LENGTH: 32, + KEY_FORMAT: /^[A-Za-z0-9_-]+$/, + GEMINI_TEST_ENDPOINT: 'https://generativelanguage.googleapis.com/v1beta/models', + ERROR_MESSAGES: { + EMPTY_KEY: 'API key cannot be empty', + SHORT_KEY: 'API key must be at least 32 characters long', + INVALID_CHARS: 'API key contains invalid characters', + INVALID_FORMAT: 'Invalid API key format', + INVALID_KEY: 'Invalid API key', + RATE_LIMIT: 'Rate limit exceeded', + INVALID_ENDPOINT: 'Invalid endpoint URL', + VALIDATION_FAILED: (status: number) => `API validation failed: ${status}`, + CUSTOM_VALIDATION_FAILED: (status: number) => `Custom API validation failed: ${status}` + } +} as const; -interface AuthHeaders { - authBearer: string; +interface ApiResponse { + status: number; + data: unknown; } -export class ApiKeyValidator { - private static readonly geminiTestEndpoint = 'https://generativelanguage.googleapis.com/v1beta/models'; - private static readonly minKeyLength = 32; - private static readonly keyFormatRegex = /^[A-Za-z0-9_-]+$/; +type ValidationResult = { + isValid: boolean; + error?: string; +}; +export class ApiKeyValidator { static async validateGeminiApiKey(key: string): Promise { - if (!this.isKeyFormatValid(key)) { - throw new AiServiceError('Invalid API key format'); + const formatValidation = this.validateKeyFormat(key); + if (!formatValidation.isValid) { + throw new AiServiceError(formatValidation.error || API_VALIDATION.ERROR_MESSAGES.INVALID_FORMAT); } try { - const headers: ApiHeaders = { - xGoogApiKey: key - }; - - const requestHeaders = { - apiKey: headers.xGoogApiKey - }; - - const response = await axios.get(this.geminiTestEndpoint, { + const response = await axios.get(API_VALIDATION.GEMINI_TEST_ENDPOINT, { headers: { - 'x-goog-api-key': requestHeaders.apiKey + 'x-goog-api-key': key } }); return response.status === 200; } catch (error) { - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError; - if (axiosError.response) { - switch (axiosError.response.status) { - case 401: - case 403: - throw new AiServiceError('Invalid API key'); - case 429: - throw new AiServiceError('Rate limit exceeded'); - default: - throw new AiServiceError(`API validation failed: ${axiosError.response.status}`); - } - } - } - throw error; + this.handleApiError(error as AxiosError, false); + return false; } } static async validateCustomApiKey(key: string, endpoint: string): Promise { - if (!this.isKeyFormatValid(key)) { - throw new AiServiceError('Invalid API key format'); + const formatValidation = this.validateKeyFormat(key); + if (!formatValidation.isValid) { + throw new AiServiceError(formatValidation.error || API_VALIDATION.ERROR_MESSAGES.INVALID_FORMAT); } if (!this.validateEndpointUrl(endpoint)) { - throw new AiServiceError('Invalid endpoint URL'); + throw new AiServiceError(API_VALIDATION.ERROR_MESSAGES.INVALID_ENDPOINT); } try { - const headers: AuthHeaders = { - authBearer: `Bearer ${key}` - }; - - const requestHeaders = { - authorization: headers.authBearer - }; - - const response = await axios.get(endpoint, { + const response = await axios.get(endpoint, { headers: { - 'Authorization': requestHeaders.authorization + 'Authorization': `Bearer ${key}` } }); return response.status === 200; } catch (error) { - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError; - if (axiosError.response) { - switch (axiosError.response.status) { - case 401: - case 403: - throw new AiServiceError('Invalid custom API key'); - case 429: - throw new AiServiceError('Rate limit exceeded'); - default: - throw new AiServiceError(`Custom API validation failed: ${axiosError.response.status}`); - } - } - } - throw error; + this.handleApiError(error as AxiosError, true); + return false; } } static validateApiKey(value: string): string | null { - if (!value) { - return 'API key cannot be empty'; - } - if (value.length < 32) { - return 'API key is too short'; - } - if (!/^[A-Za-z0-9_-]+$/.test(value)) { - return 'API key contains invalid characters'; - } - return null; + const validation = this.validateKeyFormat(value); + return validation.error || null; } - private static isKeyFormatValid(key: string): boolean { + private static validateKeyFormat(key: string): ValidationResult { if (!key || typeof key !== 'string') { - void Logger.error('API key must be a non-empty string'); - return false; + void Logger.error(API_VALIDATION.ERROR_MESSAGES.EMPTY_KEY); + return { isValid: false, error: API_VALIDATION.ERROR_MESSAGES.EMPTY_KEY }; } - if (key.length < this.minKeyLength) { - void Logger.error(`API key must be at least ${this.minKeyLength} characters long`); - return false; + if (key.length < API_VALIDATION.MIN_KEY_LENGTH) { + void Logger.error(API_VALIDATION.ERROR_MESSAGES.SHORT_KEY); + return { isValid: false, error: API_VALIDATION.ERROR_MESSAGES.SHORT_KEY }; } - if (!this.keyFormatRegex.test(key)) { - void Logger.error('API key contains invalid characters'); - return false; + if (!API_VALIDATION.KEY_FORMAT.test(key)) { + void Logger.error(API_VALIDATION.ERROR_MESSAGES.INVALID_CHARS); + return { isValid: false, error: API_VALIDATION.ERROR_MESSAGES.INVALID_CHARS }; } - return true; + return { isValid: true }; } private static validateEndpointUrl(url: string): boolean { @@ -136,7 +103,30 @@ export class ApiKeyValidator { new URL(url); return true; } catch { + void Logger.error(API_VALIDATION.ERROR_MESSAGES.INVALID_ENDPOINT); return false; } } + + private static handleApiError(error: AxiosError, isCustomEndpoint: boolean): never { + if (error.response) { + const { status } = error.response; + switch (status) { + case 401: + case 403: + throw new AiServiceError( + isCustomEndpoint ? 'Invalid custom API key' : API_VALIDATION.ERROR_MESSAGES.INVALID_KEY + ); + case 429: + throw new AiServiceError(API_VALIDATION.ERROR_MESSAGES.RATE_LIMIT); + default: + throw new AiServiceError( + isCustomEndpoint + ? API_VALIDATION.ERROR_MESSAGES.CUSTOM_VALIDATION_FAILED(status) + : API_VALIDATION.ERROR_MESSAGES.VALIDATION_FAILED(status) + ); + } + } + throw error; + } } \ No newline at end of file diff --git a/src/utils/configService.ts b/src/utils/configService.ts index 3ecef1a..c3df1e0 100644 --- a/src/utils/configService.ts +++ b/src/utils/configService.ts @@ -5,11 +5,8 @@ import { AiServiceError, ConfigurationError } from '../models/errors'; type CacheValue = string | boolean | number; -export type CommitLanguage = - | 'english' - | 'russian' - | 'chinese' - | 'japanese'; +export type CommitLanguage = typeof SUPPORTED_LANGUAGES[number]; +const SUPPORTED_LANGUAGES = ['english', 'russian', 'chinese', 'japanese'] as const; export class ConfigService { private static cache = new Map(); @@ -304,4 +301,8 @@ export class ConfigService { await this.setCustomApiKey(key); } } + + static isTelemetryEnabled(): boolean { + return this.getConfig('telemetry', 'enabled', true); + } } \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 0f06e32..ae78781 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -5,13 +5,11 @@ export const messages = { settingMessage: "Setting commit message...", done: "Done!", success: "Commit message generated using {0} model", - commandExecution: "Error in command execution:", - generateCommitMessage: "Failed to generate commit message", + noStagedChanges: "No staged changes to commit. Please stage your changes first.", + gitConfigError: "Git user.name or user.email is not configured. Please configure Git before committing.", checkingGitConfig: "Checking Git configuration...", committing: "Committing changes...", - pushing: "Pushing changes...", - noStagedChanges: "No staged changes to commit. Please stage your changes first.", - gitConfigError: "Git user.name or user.email is not configured. Please configure Git before committing." + pushing: "Pushing changes..." }; export const errorMessages = { @@ -19,5 +17,6 @@ export const errorMessages = { generateCommitMessage: 'Failed to generate commit message', apiError: 'API error: {0}', networkError: 'Network error: {0}', - configError: 'Configuration error: {0}' + configError: 'Configuration error: {0}', + fileNotFound: 'File does not exist' }; \ No newline at end of file diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 7807c05..b55f4be 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -9,15 +9,13 @@ export class Logger { static log(message: string): void { const timestamp = new Date().toISOString(); - this.outputChannel.appendLine(`[${timestamp}] ${message}`); + this.outputChannel.appendLine(`[${timestamp}] [INFO] ${message}`); } static async error(message: string, error?: Error): Promise { const timestamp = new Date().toISOString(); - this.outputChannel.appendLine(`[${timestamp}] ERROR: ${message}`); - if (error) { - this.outputChannel.appendLine(`Stack trace: ${error.stack}`); - } + const errorMessage = error ? `: ${error.message}\n${error.stack}` : ''; + this.outputChannel.appendLine(`[${timestamp}] [ERROR] ${message}${errorMessage}`); await vscode.window.showErrorMessage( `GeminiCommit: ${message}`, @@ -31,12 +29,13 @@ export class Logger { }); } - static show(): void { - this.outputChannel.show(); + static warn(message: string): void { + const timestamp = new Date().toISOString(); + this.outputChannel.appendLine(`[${timestamp}] [WARN] ${message}`); } - static clear(): void { - this.outputChannel.clear(); + static show(): void { + this.outputChannel.show(); } static dispose(): void {