From 89732bb370188dc2475439c47867b6c9a773009a Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 10:22:38 +1000 Subject: [PATCH 01/15] feat(ollama): add support for local Ollama models This commit adds support for using local Ollama models as an alternative to Anthropic's Claude models. Key changes include: - Added configuration option to select between Anthropic and Ollama providers - Implemented new commands for selecting and changing Ollama models - Created OllamaManager class to handle model selection and server communication - Added OllamaCommitMessageGenerator class for generating commit messages using Ollama - Updated extension.ts to support both providers - Added configuration options for Ollama hostname and model selection - Updated package.json with new commands and configuration properties --- package.json | 35 +++++++- src/configManager.ts | 9 ++ src/extension.ts | 39 +++++++-- src/ollamaCommitMessageGenerator.ts | 127 +++++++++++++++++++++++++++ src/ollamaManager.ts | 128 ++++++++++++++++++++++++++++ 5 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 src/ollamaCommitMessageGenerator.ts create mode 100644 src/ollamaManager.ts diff --git a/package.json b/package.json index 528a9e1..81de6a4 100644 --- a/package.json +++ b/package.json @@ -55,11 +55,34 @@ "command": "diffCommit.deleteAPIKey", "title": "DiffCommit: Delete stored API Key", "icon": "$(trash)" + }, + { + "command": "diffCommit.selectOllamaModel", + "title": "DiffCommit: Configure Ollama Model", + "icon": "$(server-environment)" + }, + { + "command": "diffCommit.changeOllamaModel", + "title": "DiffCommit: Change Ollama Model", + "icon": "$(settings-gear)" } ], "configuration": { "title": "Diff Commit", "properties": { + "diffCommit.provider": { + "type": "string", + "default": "anthropic", + "enum": [ + "anthropic", + "ollama" + ], + "enumDescriptions": [ + "Use Anthropic's Claude models (requires API key)", + "Use local Ollama models (requires Ollama server to be running)" + ], + "description": "Choose the AI provider for generating commit messages." + }, "diffCommit.model": { "type": "string", "default": "claude-sonnet-4-0", @@ -71,7 +94,17 @@ "claude-opus-4-0", "claude-3-opus-latest" ], - "description": "The Anthropic AI model to use for generating commit messages." + "description": "The Anthropic AI model to use for generating commit messages. This value is only used when provider is set as 'anthropic')." + }, + "diffCommit.ollamaHostname": { + "type": "string", + "default": "http://localhost:11434", + "description": "The URL of the local Ollama server (eg http://localhost:11434). This value is only used when provider is set as 'ollama'." + }, + "diffCommit.ollamaModel": { + "type": "string", + "default": "", + "description": "The Ollama model to use for generating commit messages. Use the 'Configure Ollama Model' command to configure this. This value is only used when provider is set as 'ollama'." }, "diffCommit.maxTokens": { "type": "number", diff --git a/src/configManager.ts b/src/configManager.ts index 7b84b71..df598e3 100644 --- a/src/configManager.ts +++ b/src/configManager.ts @@ -6,6 +6,9 @@ export interface CommitConfig { maxTokens: number model: string temperature: number + provider: "anthropic" | "ollama" + ollamaHostname: string + ollamaModel: string } export class ConfigManager { @@ -23,6 +26,9 @@ export class ConfigManager { private static readonly defaultMaxTokens = 1024 private static readonly defaultModel = "claude-sonnet-4-0" private static readonly defaultTemperature = 0.3 + private static readonly defaultProvider = "anthropic" + private static readonly defaultOllamaHostname = "http://localhost:11434" + private static readonly defaultOllamaModel = "" getConfig(): CommitConfig { const config = workspace.getConfiguration("diffCommit") @@ -33,6 +39,9 @@ export class ConfigManager { maxTokens: config.get("maxTokens") || ConfigManager.defaultMaxTokens, model: config.get("model") || ConfigManager.defaultModel, temperature: config.get("temperature") || ConfigManager.defaultTemperature, + provider: config.get<"anthropic" | "ollama">("provider") || ConfigManager.defaultProvider, + ollamaHostname: config.get("ollamaHostname") || ConfigManager.defaultOllamaHostname, + ollamaModel: config.get("ollamaModel") || ConfigManager.defaultOllamaModel, } } } diff --git a/src/extension.ts b/src/extension.ts index 02c074e..1774641 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,8 @@ import { APIKeyManager } from "./apiKeyManager" import { CommitMessageGenerator } from "./commitMessageGenerator" import { ConfigManager } from "./configManager" import { GitManager } from "./gitManager" +import { OllamaCommitMessageGenerator } from "./ollamaCommitMessageGenerator" +import { OllamaManager } from "./ollamaManager" export function activate(context: ExtensionContext) { let previewDocument: TextDocument | undefined @@ -10,6 +12,7 @@ export function activate(context: ExtensionContext) { const apiKeyManager = new APIKeyManager(context) const gitManager = new GitManager() const configManager = new ConfigManager() + const ollamaManager = new OllamaManager(context) async function generateCommitMessage(): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri?.fsPath @@ -32,16 +35,28 @@ export function activate(context: ExtensionContext) { return undefined } - progress.report({ message: "Validating API key..." }) - const apiKey = (await apiKeyManager.getAPIKey()) ?? (await apiKeyManager.setAPIKey()) - if (!apiKey) { - window.showErrorMessage("API Key is required") - return undefined - } + const config = configManager.getConfig() + let generator: CommitMessageGenerator | OllamaCommitMessageGenerator + progress.report({ message: "Validating configuration..." }) + if (config.provider === "ollama") { + // Validate Ollama configuration + if (!config.ollamaModel) { + window.showErrorMessage("No Ollama model selected. Please configure an Ollama model first.") + return undefined + } + + generator = new OllamaCommitMessageGenerator(config.ollamaHostname, config.ollamaModel) + } else { + // Anthropic provider + const apiKey = (await apiKeyManager.getAPIKey()) ?? (await apiKeyManager.setAPIKey()) + if (!apiKey) { + window.showErrorMessage("API Key is required") + return undefined + } + generator = new CommitMessageGenerator(apiKey) + } progress.report({ message: "Generating commit message..." }) - const config = configManager.getConfig() - const generator = new CommitMessageGenerator(apiKey) return await generator.generateMessage(diff, config) }, ) @@ -51,6 +66,12 @@ export function activate(context: ExtensionContext) { const cmdUpdateAPIKey = commands.registerCommand("diffCommit.updateAPIKey", () => apiKeyManager.setAPIKey()) const cmdGetAPIKey = commands.registerCommand("diffCommit.getAPIKey", () => apiKeyManager.getAPIKey()) const cmdDeleteAPIKey = commands.registerCommand("diffCommit.deleteAPIKey", () => apiKeyManager.deleteAPIKey()) + const cmdSelectOllamaModel = commands.registerCommand("diffCommit.selectOllamaModel", () => + ollamaManager.selectOllamaModel(), + ) + const cmdChangeOllamaModel = commands.registerCommand("diffCommit.changeOllamaModel", () => + ollamaManager.changeOllamaModel(), + ) const cmdGenerateCommitMessage = commands.registerCommand("diffCommit.generateCommitMessage", async () => { try { @@ -104,6 +125,8 @@ export function activate(context: ExtensionContext) { cmdUpdateAPIKey, cmdGetAPIKey, cmdDeleteAPIKey, + cmdSelectOllamaModel, + cmdChangeOllamaModel, onSave, onClose, ) diff --git a/src/ollamaCommitMessageGenerator.ts b/src/ollamaCommitMessageGenerator.ts new file mode 100644 index 0000000..6d05bf3 --- /dev/null +++ b/src/ollamaCommitMessageGenerator.ts @@ -0,0 +1,127 @@ +import { window } from "vscode" +import type { CommitConfig } from "./configManager" + +interface OllamaResponse { + model: string + created_at: string + response: string + done: boolean +} + +export class OllamaCommitMessageGenerator { + constructor( + private hostname: string, + private modelName: string, + ) {} + + async generateMessage(diff: string, config: CommitConfig): Promise { + const systemPrompt = + "You are a seasoned software engineer with more than 25 years of experience with an extraordinary ability for assessing and interpreting git diffs and writing detailed conventional commit messages and following 'instructions' and 'customInstructions' when generating them." + + const prompt = ` + +Generate a detailed conventional commit message for the following Git diff: + +${diff} + + +- Use ONLY ${config.allowedTypes.map((val) => `'${val}'`).join(" | ")} as appropriate for the type of change. +- When assessing the commit type, consider actual impact of the commit. Refer to the "type-table" below for further guidance on the default commit types. +- Always include a scope. +- Never use '!' or 'BREAKING CHANGE' in the commit message. +- Avoid unnecessary and excessive adjectives use. (eg 'enhance', 'comprehensive', etc.) +- Output will use markdown formatting for lists etc. +- Output will ONLY contain the commit message. +- Do not explain the output. +- "customInstructions" override these instructions if they are provided and conflict. + + +\`\`\`markdown +| Commit Type | Typical Use Case | When to Use | +| ----------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| chore | Routine maintenance or updates | Use when updating configs or non-code changes. (eg updating dependencies, modifying configs, updating types, etc.) | +| ci | Continuous integration adjustments | Use when updating CI/CD config files. (eg GitHub Actions, Workflows, Pipelines, etc.) | +| docs | Documentation-only changes | Use only when updating or adding documentation, comments, or README files. (Do NOT use when adding or updating page content in web apps. eg Astro content collections.) | +| feat | New feature | Use only when adding new, user-facing feature or functionality or a fundamental change in an existing feature's functionality. | +| fix | Bug fix | Use when fixing a bug or issue in code that may or may not affect functionality. | +| perf | Performance improvement | Use when improving performance. (eg by optimising code.) | +| refactor | Code restructuring | Use when restructuring code without changing functionality or fixing bugs. (This can include significant code changes like abstracting code to its own component.) | +| style | Code formatting or styling | Use when code changes do not affect functionality. (eg linting, formatting adjustments, colour, margin, padding, etc.) | +| test | Adding or updating tests | Use when adding, updating, or removing tests. | +\`\`\` + + +${config.customInstructions ? `\n${config.customInstructions}\n` : ""} + +System prompt: ${systemPrompt} +`.trim() + + try { + const requestBody = { + model: this.modelName, + prompt: prompt, + stream: false, + options: { + temperature: config.temperature, + num_predict: config.maxTokens, + }, + } + + console.log(`[DiffCommit] Making request to Ollama at ${this.hostname}/api/generate`) + + const response = await fetch(`${this.hostname}/api/generate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + throw new Error(`Ollama API request failed: ${response.status} ${response.statusText}`) + } + + const data: OllamaResponse = await response.json() + + let commitMessage = data.response + .replace(/\n{3,}/g, "\n\n") // Replace 3 or more newlines with 2 newlines + .replace(/(? { + try { + const response = await fetch(`${hostname}/api/tags`) + + if (!response.ok) { + throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`) + } + + const data: OllamaTagsResponse = await response.json() + return data.models.map((model) => model.name) + } catch (error) { + console.error("Failed to fetch Ollama models:", error) + throw error + } + } + + async selectOllamaModel(): Promise { + // Get hostname from user or use default + const hostname = await window.showInputBox({ + prompt: "Enter Ollama hostname", + value: "http://localhost:11434", + placeHolder: "http://localhost:11434", + }) + + if (!hostname) { + return false + } + + try { + // Test connection and get available models + const models = await this.getAvailableModels(hostname) + + if (models.length === 0) { + window.showWarningMessage("No models found on the Ollama server. Please pull a model first.") + return false + } + + // Let user select a model + const selectedModel = await window.showQuickPick(models, { + placeHolder: "Select an Ollama model", + title: "Choose Ollama Model", + }) + + if (!selectedModel) { + return false + } + + // Save configuration + const config = workspace.getConfiguration("diffCommit") + await config.update("provider", "ollama", true) + await config.update("ollamaHostname", hostname, true) + await config.update("ollamaModel", selectedModel, true) + + window.showInformationMessage(`Ollama model '${selectedModel}' selected successfully`) + return true + } catch (error) { + console.error("Error selecting Ollama model:", error) + if (error instanceof TypeError && error.message.includes("fetch")) { + window.showErrorMessage( + `Unable to connect to Ollama server at ${hostname}. Please ensure that the Ollama server is running and accessible.`, + ) + } else { + window.showErrorMessage( + `Failed to connect to Ollama: ${error instanceof Error ? error.message : String(error)}`, + ) + } + return false + } + } + + async changeOllamaModel(): Promise { + const config = workspace.getConfiguration("diffCommit") + const currentHostname = config.get("ollamaHostname") || "http://localhost:11434" + + try { + // Get available models from current hostname + const models = await this.getAvailableModels(currentHostname) + + if (models.length === 0) { + window.showWarningMessage("No models found on the Ollama server. Please pull a model first.") + return false + } + + const currentModel = config.get("ollamaModel") + const selectedModel = await window.showQuickPick(models, { + placeHolder: `Select an Ollama model (current: ${currentModel || "none"})`, + title: "Change Ollama Model", + }) + + if (!selectedModel) { + return false + } + + // Update only the model selection + await config.update("ollamaModel", selectedModel, true) + + window.showInformationMessage(`Ollama model changed to '${selectedModel}'`) + return true + } catch (error) { + console.error("Error changing Ollama model:", error) + if (error instanceof TypeError && error.message.includes("fetch")) { + window.showErrorMessage( + `Unable to connect to Ollama server at ${currentHostname}. Please ensure Ollama is running and accessible.`, + ) + } else { + window.showErrorMessage( + `Failed to connect to Ollama: ${error instanceof Error ? error.message : String(error)}`, + ) + } + return false + } + } +} From e5116d86d088eaed4fdc64d28340f3254adabbb7 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 21:46:26 +1000 Subject: [PATCH 02/15] refactor(generator): consolidate Anthropic and Ollama generators into unified class - Merged separate Ollama generator into main CommitMessageGenerator class - Added constructor overloads to support both Anthropic and Ollama providers - Implemented provider-specific message generation methods - Improved error handling for both providers - Enhanced prompt building with shared logic between providers - Added response text normalisation to ensure consistent output formatting - Removed redundant ollamaCommitMessageGenerator.ts file - Updated logging to provide more detailed token usage information --- src/commitMessageGenerator.ts | 261 ++++++++++++++++++++++------ src/ollamaCommitMessageGenerator.ts | 127 -------------- 2 files changed, 211 insertions(+), 177 deletions(-) delete mode 100644 src/ollamaCommitMessageGenerator.ts diff --git a/src/commitMessageGenerator.ts b/src/commitMessageGenerator.ts index ac82e6e..e44c667 100644 --- a/src/commitMessageGenerator.ts +++ b/src/commitMessageGenerator.ts @@ -1,53 +1,69 @@ import Anthropic from "@anthropic-ai/sdk" +import { + Ollama, + type GenerateRequest as OllamaGenerateRequest, + type GenerateResponse as OllamaGenerateResponse, +} from "ollama" import { window } from "vscode" import type { CommitConfig } from "./configManager" export class CommitMessageGenerator { - constructor(private apiKey: string) {} + private apiKey: string | undefined + private ollamaModel: string | undefined + private ollamaHost: string | undefined + + // Constructor overload for Anthropic (API key only) + constructor(apiKey: string) + // Constructor overload for Ollama (hostname and model) + constructor(hostname: string, ollamaModel: string) + // Implementation + constructor() { + try { + if ( + arguments.length === 1 && + arguments[0] && + typeof arguments[0] === "string" && + arguments[0].startsWith("sk-") + ) { + // Anthropic constructor + this.apiKey = arguments[0] + } else if ( + arguments.length === 2 && + arguments[0] && + typeof arguments[0] === "string" && + arguments[0].startsWith("http") && + arguments[1] && + typeof arguments[1] === "string" + ) { + // Ollama constructor + this.ollamaHost = arguments[0] + this.ollamaModel = arguments[1] + } else { + throw new Error( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + } + } catch (error) { + console.error("Error initializing CommitMessageGenerator:", error) + window.showErrorMessage("Failed to initialize CommitMessageGenerator. Please check your configuration.") + throw error + } + } async generateMessage(diff: string, config: CommitConfig): Promise { + if (config.provider === "ollama") { + return this.generateOllamaMessage(diff, config) + } else { + return this.generateAnthropicMessage(diff, config) + } + } + + private async generateAnthropicMessage(diff: string, config: CommitConfig): Promise { const anthropic = new Anthropic({ apiKey: this.apiKey, }) - const systemPrompt = - "You are a seasoned software engineer with more than 25 years of experience with an extraordinary ability for assessing and interpreting git diffs and writing detailed conventional commit messages and following 'instructions' and 'customInstructions' when generating them." - - const prompt = ` - -Generate a detailed conventional commit message for the following Git diff: - -${diff} - - -- Use ONLY ${config.allowedTypes.map((val) => `'${val}'`).join(" | ")} as appropriate for the type of change. -- When assessing the commit type, consider actual impact of the commit. Refer to the "type-table" below for further guidance on the default commit types. -- Always include a scope. -- Never use '!' or 'BREAKING CHANGE' in the commit message. -- Avoid unnecessary and excessive adjectives use. (eg 'enhance', 'comprehensive', etc.) -- Output will use markdown formatting for lists etc. -- Output will ONLY contain the commit message. -- Do not explain the output. -- "customInstructions" override these instructions if they are provided and conflict. - - -\`\`\`markdown -| Commit Type | Typical Use Case | When to Use | -| ----------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| chore | Routine maintenance or updates | Use when updating configs or non-code changes. (eg updating dependencies, modifying configs, updating types, etc.) | -| ci | Continuous integration adjustments | Use when updating CI/CD config files. (eg GitHub Actions, Workflows, Pipelines, etc.) | -| docs | Documentation-only changes | Use only when updating or adding documentation, comments, or README files. (Do NOT use when adding or updating page content in web apps. eg Astro content collections.) | -| feat | New feature | Use only when adding new, user-facing feature or functionality or a fundamental change in an existing feature's functionality. | -| fix | Bug fix | Use when fixing a bug or issue in code that may or may not affect functionality. | -| perf | Performance improvement | Use when improving performance. (eg by optimising code.) | -| refactor | Code restructuring | Use when restructuring code without changing functionality or fixing bugs. (This can include significant code changes like abstracting code to its own component.) | -| style | Code formatting or styling | Use when code changes do not affect functionality. (eg linting, formatting adjustments, colour, margin, padding, etc.) | -| test | Adding or updating tests | Use when adding, updating, or removing tests. | -\`\`\` - - -${config.customInstructions ? `\n${config.customInstructions}\n` : ""} -`.trim() + const { systemPrompt, prompt } = this.buildPrompts(diff, config) let message: Anthropic.Message | undefined = undefined try { @@ -64,31 +80,152 @@ ${config.customInstructions ? `\n${config.customInstructions ], }) - let commitMessage = message.content - .filter((msg) => msg.type === "text" && "text" in msg) - .map((msg) => msg.text) - .join("\n") - .replace(/\n{3,}/g, "\n\n") // Replace 3 or more newlines with 2 newlines - .replace(/(? { + if (!this.ollamaModel) { + window.showErrorMessage("Ollama model not specified") + return undefined + } + + let response: OllamaGenerateResponse | undefined = undefined + const { systemPrompt, prompt } = this.buildPrompts(diff, config, true) + const ollama = new Ollama({ + host: this.ollamaHost, + }) + const requestBody: OllamaGenerateRequest & { stream: false } = { + model: this.ollamaModel, + system: systemPrompt, + prompt: prompt, + think: false, + stream: false, + options: { + temperature: config.temperature, + num_predict: config.maxTokens, + }, + } + + console.log(`[DiffCommit] Making request to Ollama at ${this.ollamaHost}/api/generate`) + try { + response = await ollama.generate(requestBody) + + const commitMessage = this.normaliseResponseText(response.response) + + if (!commitMessage) { + window.showWarningMessage("No commit message was generated") + return undefined + } else if (commitMessage && !response.done) { + window.showWarningMessage( + "Ollama response was marked as incomplete. Review the commit message and ensure it meets your requirements.", + ) + return commitMessage + } + + return commitMessage + } catch (error) { + this.handleOllamaError(error) + return undefined + } finally { + console.log("[DiffCommit] Ollama Stop Reason: ", response?.done_reason) + console.log("[DiffCommit] Ollama Input Tokens: ", response?.prompt_eval_count) + console.log("[DiffCommit] Ollama Output Tokens: ", response?.eval_count) } } - private handleError(error: unknown): void { + private buildPrompts( + diff: string, + config: CommitConfig, + isIdiotOllamaModel: boolean = false, + ): { systemPrompt: string; prompt: string } { + const systemPrompt = + "You are a seasoned software engineer with more than 25 years of experience with an extraordinary ability for assessing and interpreting git diffs and writing detailed conventional commit messages and following 'instructions' and 'customInstructions' when generating them." + + const idiotOllamaAdditionalInstructions = `- Follow the following 'commit-format' strictly, where anything surrounded by << and >> is a placeholder that should be replaced with the actual content. + +` + const idiotOllamaCommitFormat = ` +<>(<>): <> + +<> + + +` + const prompt = ` + +Generate a detailed conventional commit message summarising the following Git diff: + +${diff} + + +- Use ONLY ${config.allowedTypes.map((val) => `'${val}'`).join(" | ")} as appropriate for the type of change. +- When assessing the commit type, consider actual impact of the commit. Refer to the "type-table" below for further guidance on the default commit types. +- ALWAYS infer and include a scope. +- Never use '!' or 'BREAKING CHANGE' in the commit message. +- Avoid unnecessary and excessive adjectives use. (eg 'enhance', 'comprehensive', etc.) +- 'customInstructions' override any conflicting instructions if they are provided. +- Your response MUST include ONLY the commit message - NOTHING ELSE! +- Use plain text output with markdown-like list formatting for lists etc. +${isIdiotOllamaModel ? idiotOllamaAdditionalInstructions : ""} +${isIdiotOllamaModel ? idiotOllamaCommitFormat : ""} + + +\`\`\`markdown +| Commit Type | When to Use | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| chore | Use when updating configs or non-code changes. (eg updating dependencies, modifying configs, updating types, etc.) | +| ci | Use when updating CI/CD config files. (eg GitHub Actions, Workflows, Pipelines, etc.) | +| docs | Use only when updating or adding documentation, comments, or README files. (Do NOT use when adding or updating page content in web apps. eg Astro content collections.) | +| feat | Use only when adding new, user-facing feature or functionality or a fundamental change in an existing feature's functionality. | +| fix | Use when fixing a bug or issue in code that may or may not affect functionality. | +| perf | Use when improving performance. (eg by optimising code.) | +| refactor | Use when restructuring code without changing functionality or fixing bugs. (This can include significant code changes like abstracting code to its own component.) | +| style | Use when code changes do not affect functionality. (eg linting, formatting adjustments, colour, margin, padding, etc.) | +| test | Use when adding, updating, or removing tests. | +\`\`\` + + +${config.customInstructions ? `\n${config.customInstructions}\n` : ""} +`.trim() + + return { systemPrompt, prompt } + } + + private processAnthropicResponse(message: Anthropic.Message): string { + return this.normaliseResponseText( + message.content + .filter((msg) => msg.type === "text" && "text" in msg) + .map((msg) => msg.text) + .join("\n"), + ) + } + + private normaliseResponseText(text: string): string { + return text + .replace(/\`\`\`.*?\n?/g, "") // Remove code block markers if present + .replace(/\n{3,}/g, "\n\n") // Replace 3 or more newlines with 2 newlines + .replace(/(?\n${config.customInstructions ) } } + + private handleOllamaError(error: unknown): void { + console.error(`Ollama API Error:\n\n${error}`) + + if (error instanceof TypeError && error.message.includes("fetch")) { + window.showErrorMessage( + `Unable to connect to Ollama server at ${this.ollamaHost}. Please ensure that the Ollama server is running and accessible.`, + ) + } else if (error instanceof Error) { + if (error.message.includes("404")) { + window.showErrorMessage( + `Model '${this.ollamaModel}' not found. Please check if the model is available in Ollama.`, + ) + } else if (error.message.includes("500")) { + window.showErrorMessage("Ollama server error. Please try again later.") + } else { + window.showErrorMessage(`Failed to generate commit message with Ollama:\n\n${error.message}`) + } + } else { + window.showErrorMessage( + `Unknown error generating commit message with Ollama: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } } diff --git a/src/ollamaCommitMessageGenerator.ts b/src/ollamaCommitMessageGenerator.ts deleted file mode 100644 index 6d05bf3..0000000 --- a/src/ollamaCommitMessageGenerator.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { window } from "vscode" -import type { CommitConfig } from "./configManager" - -interface OllamaResponse { - model: string - created_at: string - response: string - done: boolean -} - -export class OllamaCommitMessageGenerator { - constructor( - private hostname: string, - private modelName: string, - ) {} - - async generateMessage(diff: string, config: CommitConfig): Promise { - const systemPrompt = - "You are a seasoned software engineer with more than 25 years of experience with an extraordinary ability for assessing and interpreting git diffs and writing detailed conventional commit messages and following 'instructions' and 'customInstructions' when generating them." - - const prompt = ` - -Generate a detailed conventional commit message for the following Git diff: - -${diff} - - -- Use ONLY ${config.allowedTypes.map((val) => `'${val}'`).join(" | ")} as appropriate for the type of change. -- When assessing the commit type, consider actual impact of the commit. Refer to the "type-table" below for further guidance on the default commit types. -- Always include a scope. -- Never use '!' or 'BREAKING CHANGE' in the commit message. -- Avoid unnecessary and excessive adjectives use. (eg 'enhance', 'comprehensive', etc.) -- Output will use markdown formatting for lists etc. -- Output will ONLY contain the commit message. -- Do not explain the output. -- "customInstructions" override these instructions if they are provided and conflict. - - -\`\`\`markdown -| Commit Type | Typical Use Case | When to Use | -| ----------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| chore | Routine maintenance or updates | Use when updating configs or non-code changes. (eg updating dependencies, modifying configs, updating types, etc.) | -| ci | Continuous integration adjustments | Use when updating CI/CD config files. (eg GitHub Actions, Workflows, Pipelines, etc.) | -| docs | Documentation-only changes | Use only when updating or adding documentation, comments, or README files. (Do NOT use when adding or updating page content in web apps. eg Astro content collections.) | -| feat | New feature | Use only when adding new, user-facing feature or functionality or a fundamental change in an existing feature's functionality. | -| fix | Bug fix | Use when fixing a bug or issue in code that may or may not affect functionality. | -| perf | Performance improvement | Use when improving performance. (eg by optimising code.) | -| refactor | Code restructuring | Use when restructuring code without changing functionality or fixing bugs. (This can include significant code changes like abstracting code to its own component.) | -| style | Code formatting or styling | Use when code changes do not affect functionality. (eg linting, formatting adjustments, colour, margin, padding, etc.) | -| test | Adding or updating tests | Use when adding, updating, or removing tests. | -\`\`\` - - -${config.customInstructions ? `\n${config.customInstructions}\n` : ""} - -System prompt: ${systemPrompt} -`.trim() - - try { - const requestBody = { - model: this.modelName, - prompt: prompt, - stream: false, - options: { - temperature: config.temperature, - num_predict: config.maxTokens, - }, - } - - console.log(`[DiffCommit] Making request to Ollama at ${this.hostname}/api/generate`) - - const response = await fetch(`${this.hostname}/api/generate`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }) - - if (!response.ok) { - throw new Error(`Ollama API request failed: ${response.status} ${response.statusText}`) - } - - const data: OllamaResponse = await response.json() - - let commitMessage = data.response - .replace(/\n{3,}/g, "\n\n") // Replace 3 or more newlines with 2 newlines - .replace(/(? Date: Thu, 5 Jun 2025 21:49:35 +1000 Subject: [PATCH 03/15] refactor(ollama): consolidate commit message generation into a single class - Remove `OllamaCommitMessageGenerator` import and class usage - Update `OllamaManager` constructor to no longer require context parameter - Modify `generateCommitMessage` function to use the main `CommitMessageGenerator` for both providers - Rename command reference from `selectOllamaModel` to `changeOllamaModel` for consistency --- src/extension.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 1774641..c0dae7c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,7 +3,6 @@ import { APIKeyManager } from "./apiKeyManager" import { CommitMessageGenerator } from "./commitMessageGenerator" import { ConfigManager } from "./configManager" import { GitManager } from "./gitManager" -import { OllamaCommitMessageGenerator } from "./ollamaCommitMessageGenerator" import { OllamaManager } from "./ollamaManager" export function activate(context: ExtensionContext) { @@ -12,7 +11,7 @@ export function activate(context: ExtensionContext) { const apiKeyManager = new APIKeyManager(context) const gitManager = new GitManager() const configManager = new ConfigManager() - const ollamaManager = new OllamaManager(context) + const ollamaManager = new OllamaManager() async function generateCommitMessage(): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri?.fsPath @@ -36,7 +35,7 @@ export function activate(context: ExtensionContext) { } const config = configManager.getConfig() - let generator: CommitMessageGenerator | OllamaCommitMessageGenerator + let generator: CommitMessageGenerator progress.report({ message: "Validating configuration..." }) if (config.provider === "ollama") { // Validate Ollama configuration @@ -45,7 +44,7 @@ export function activate(context: ExtensionContext) { return undefined } - generator = new OllamaCommitMessageGenerator(config.ollamaHostname, config.ollamaModel) + generator = new CommitMessageGenerator(config.ollamaHostname, config.ollamaModel) } else { // Anthropic provider const apiKey = (await apiKeyManager.getAPIKey()) ?? (await apiKeyManager.setAPIKey()) @@ -67,7 +66,7 @@ export function activate(context: ExtensionContext) { const cmdGetAPIKey = commands.registerCommand("diffCommit.getAPIKey", () => apiKeyManager.getAPIKey()) const cmdDeleteAPIKey = commands.registerCommand("diffCommit.deleteAPIKey", () => apiKeyManager.deleteAPIKey()) const cmdSelectOllamaModel = commands.registerCommand("diffCommit.selectOllamaModel", () => - ollamaManager.selectOllamaModel(), + ollamaManager.changeOllamaModel(), ) const cmdChangeOllamaModel = commands.registerCommand("diffCommit.changeOllamaModel", () => ollamaManager.changeOllamaModel(), From e444dfebb396510bfd877df8b38d7ad31f4a6b31 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 21:50:11 +1000 Subject: [PATCH 04/15] refactor(ollama): improve OllamaManager with official client library - Replace custom fetch implementation with official Ollama client library - Consolidate model selection logic into a single configurable method - Improve error handling with more specific error messages - Enhance user feedback with status bar messages - Simplify class by removing unnecessary context dependency - Add convenience methods for initial setup and model changes - Improve hostname validation with URL constructor --- src/ollamaManager.ts | 156 ++++++++++++++++++++----------------------- 1 file changed, 74 insertions(+), 82 deletions(-) diff --git a/src/ollamaManager.ts b/src/ollamaManager.ts index 4d27a6b..e3083ed 100644 --- a/src/ollamaManager.ts +++ b/src/ollamaManager.ts @@ -1,44 +1,33 @@ -import { type ExtensionContext, window, workspace } from "vscode" - -interface OllamaModel { - name: string - modified_at: string - size: number -} - -interface OllamaTagsResponse { - models: OllamaModel[] -} +import console from "console" +import { Ollama } from "ollama" +import { window, workspace } from "vscode" export class OllamaManager { - constructor(private context: ExtensionContext) {} + constructor() {} - async getAvailableModels(hostname: string): Promise { - try { - const response = await fetch(`${hostname}/api/tags`) + private async ollamaModelConfig(includeHostnameSelection: boolean = true): Promise { + const config = workspace.getConfiguration("diffCommit") + let hostname = config.get("ollamaHostname") || "http://localhost:11434" + + // Only prompt for hostname if this is initial setup or explicitly requested + if (includeHostnameSelection) { + const inputHostname = await window.showInputBox({ + prompt: "Enter Ollama hostname", + value: hostname, + placeHolder: "http://localhost:11434", + }) - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`) + if (!inputHostname) { + return false } - const data: OllamaTagsResponse = await response.json() - return data.models.map((model) => model.name) - } catch (error) { - console.error("Failed to fetch Ollama models:", error) - throw error - } - } - - async selectOllamaModel(): Promise { - // Get hostname from user or use default - const hostname = await window.showInputBox({ - prompt: "Enter Ollama hostname", - value: "http://localhost:11434", - placeHolder: "http://localhost:11434", - }) - - if (!hostname) { - return false + try { + const newHost = new URL(inputHostname) + hostname = newHost.toString() + } catch (error) { + window.showErrorMessage("Invalid hostname URL. Please enter a valid URL (eg http://localhost:11434).") + return this.ollamaModelConfig(includeHostnameSelection) + } } try { @@ -51,9 +40,16 @@ export class OllamaManager { } // Let user select a model + const currentModel = config.get("ollamaModel") + const placeHolder = includeHostnameSelection + ? "Select an Ollama model" + : `Select an Ollama model (current: ${currentModel || "none"})` + + const title = includeHostnameSelection ? "Choose Ollama Model" : "Change Ollama Model" + const selectedModel = await window.showQuickPick(models, { - placeHolder: "Select an Ollama model", - title: "Choose Ollama Model", + placeHolder, + title, }) if (!selectedModel) { @@ -61,19 +57,31 @@ export class OllamaManager { } // Save configuration - const config = workspace.getConfiguration("diffCommit") - await config.update("provider", "ollama", true) - await config.update("ollamaHostname", hostname, true) + if (includeHostnameSelection) { + await config.update("provider", "ollama", true) + await config.update("ollamaHostname", hostname, true) + } await config.update("ollamaModel", selectedModel, true) - window.showInformationMessage(`Ollama model '${selectedModel}' selected successfully`) + const action = includeHostnameSelection ? "selected" : "changed to" + + window.setStatusBarMessage(`✓ Ollama model '${selectedModel}' ${action} successfully`, 4000) + return true } catch (error) { - console.error("Error selecting Ollama model:", error) - if (error instanceof TypeError && error.message.includes("fetch")) { - window.showErrorMessage( - `Unable to connect to Ollama server at ${hostname}. Please ensure that the Ollama server is running and accessible.`, - ) + const operation = includeHostnameSelection ? "selecting" : "changing" + console.error(`Error ${operation} Ollama model:`, error) + + if (error instanceof Error) { + if (error.message.includes("ECONNREFUSED") || error.message.includes("fetch")) { + window.showErrorMessage( + `Unable to connect to Ollama server at ${hostname}. Please ensure that the Ollama server is running and accessible.`, + ) + } else if (error.message.includes("404")) { + window.showErrorMessage(`Ollama server not found at ${hostname}. Please check the hostname and try again.`) + } else { + window.showErrorMessage(`Failed to connect to Ollama: ${error.message}`) + } } else { window.showErrorMessage( `Failed to connect to Ollama: ${error instanceof Error ? error.message : String(error)}`, @@ -83,46 +91,30 @@ export class OllamaManager { } } - async changeOllamaModel(): Promise { - const config = workspace.getConfiguration("diffCommit") - const currentHostname = config.get("ollamaHostname") || "http://localhost:11434" - + async getAvailableModels(hostname: string): Promise { try { - // Get available models from current hostname - const models = await this.getAvailableModels(currentHostname) - - if (models.length === 0) { - window.showWarningMessage("No models found on the Ollama server. Please pull a model first.") - return false - } - - const currentModel = config.get("ollamaModel") - const selectedModel = await window.showQuickPick(models, { - placeHolder: `Select an Ollama model (current: ${currentModel || "none"})`, - title: "Change Ollama Model", + const ollama = new Ollama({ + host: hostname, }) - if (!selectedModel) { - return false - } - - // Update only the model selection - await config.update("ollamaModel", selectedModel, true) - - window.showInformationMessage(`Ollama model changed to '${selectedModel}'`) - return true + const response = await ollama.list() + return response.models.map((model) => model.name) } catch (error) { - console.error("Error changing Ollama model:", error) - if (error instanceof TypeError && error.message.includes("fetch")) { - window.showErrorMessage( - `Unable to connect to Ollama server at ${currentHostname}. Please ensure Ollama is running and accessible.`, - ) - } else { - window.showErrorMessage( - `Failed to connect to Ollama: ${error instanceof Error ? error.message : String(error)}`, - ) - } - return false + console.error("Failed to fetch Ollama models:", error) + window.showErrorMessage( + `Failed to fetch Ollama models: ${error instanceof Error ? error.message : String(error)}`, + ) + throw error } } + + // Convenience method for initial setup + async configureOllamaModel(): Promise { + return this.ollamaModelConfig(true) + } + + // Convenience method for changing existing model + async changeOllamaModel(): Promise { + return this.ollamaModelConfig(false) + } } From e92b0a43764b05073bb4bf21e00a0703c50e54af Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 21:52:00 +1000 Subject: [PATCH 05/15] tests(ollama): add tests for Ollama integration This commit adds comprehensive tests to support the Ollama feature addition as an alternative to Anthropic's Claude API: - Add new `CommitMessageGenerator` class that supports both Anthropic and Ollama providers - Implement `OllamaManager` for managing Ollama model selection and configuration - Add extensive test coverage for Ollama integration - Update configuration handling to support provider selection - Improve error handling for both Anthropic and Ollama API calls - Update token usage logging to be more detailed and consistent - Update model name from `claude-3-5-sonnet-latest` to `claude-sonnet-4-0` - Reduce default temperature from 0.4 to 0.2 for more consistent results --- test/anthropicResponseHandling.test.ts | 14 +- test/commitMessageGenerator.test.ts | 434 +++++++++++++++++++++++ test/configurationHandling.test.ts | 59 +++- test/errorHandling.test.ts | 163 ++++++++- test/extension.test.ts | 471 +++++++++++++++++++++++++ test/gitAndCommands.test.ts | 28 +- test/gitIntegration.test.ts | 18 +- test/messageHandling.test.ts | 14 + test/ollamaManager.test.ts | 261 ++++++++++++++ test/withProgressAPI.test.ts | 18 +- 10 files changed, 1442 insertions(+), 38 deletions(-) create mode 100644 test/commitMessageGenerator.test.ts create mode 100644 test/extension.test.ts create mode 100644 test/ollamaManager.test.ts diff --git a/test/anthropicResponseHandling.test.ts b/test/anthropicResponseHandling.test.ts index 07e08fc..10f4574 100644 --- a/test/anthropicResponseHandling.test.ts +++ b/test/anthropicResponseHandling.test.ts @@ -68,13 +68,19 @@ jest.mock("vscode", () => { get: jest.fn((key: string) => { switch (key) { case "model": - return "claude-3-5-sonnet-latest" + return "claude-sonnet-4-0" case "maxTokens": return 1024 case "temperature": - return 0.4 + return 0.2 case "allowedTypes": return ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"] + case "provider": + return "anthropic" + case "ollamaHostname": + return "http://localhost:11434" + case "ollamaModel": + return "" default: return undefined } @@ -272,7 +278,9 @@ describe("Anthropic API Response Handling", () => { const generateCommitMessage = getCommand("diffCommit.generateCommitMessage") await generateCommitMessage() - expect(consoleSpy).toHaveBeenCalledWith("[DiffCommit] Usage: ", { input_tokens: 100, output_tokens: 50 }) + expect(consoleSpy).toHaveBeenCalledWith("[DiffCommit] Stop Reason: ", "end_turn") + expect(consoleSpy).toHaveBeenCalledWith("[DiffCommit] Input Tokens: ", 100) + expect(consoleSpy).toHaveBeenCalledWith("[DiffCommit] Output Tokens: ", 50) }) }) diff --git a/test/commitMessageGenerator.test.ts b/test/commitMessageGenerator.test.ts new file mode 100644 index 0000000..c16dce5 --- /dev/null +++ b/test/commitMessageGenerator.test.ts @@ -0,0 +1,434 @@ +import * as vscode from "vscode" + +jest.mock("vscode") +jest.mock("@anthropic-ai/sdk") +jest.mock("ollama") + +import { CommitMessageGenerator } from "../src/commitMessageGenerator" +import type { CommitConfig } from "../src/configManager" + +describe("CommitMessageGenerator Constructor", () => { + let mockShowErrorMessage: jest.Mock + let mockConsoleError: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + + // Mock vscode.window.showErrorMessage + mockShowErrorMessage = jest.fn() + ;(vscode.window as any).showErrorMessage = mockShowErrorMessage + + // Mock console.error + mockConsoleError = jest.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + mockConsoleError.mockRestore() + }) + + describe("Anthropic Constructor", () => { + it("should initialise successfully with valid API key", () => { + expect(() => { + new CommitMessageGenerator("sk-ant-api-valid-key") + }).not.toThrow() + + expect(mockShowErrorMessage).not.toHaveBeenCalled() + expect(mockConsoleError).not.toHaveBeenCalled() + }) + + it("should throw error with API key that doesn't start with 'sk-'", () => { + expect(() => { + new CommitMessageGenerator("invalid-api-key") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with empty API key", () => { + expect(() => { + new CommitMessageGenerator("") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with null argument", () => { + expect(() => { + new CommitMessageGenerator(null as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with undefined argument", () => { + expect(() => { + new CommitMessageGenerator(undefined as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + }) + + describe("Ollama Constructor", () => { + it("should initialise successfully with valid hostname and model", () => { + expect(() => { + new CommitMessageGenerator("http://localhost:11434", "llama3.2") + }).not.toThrow() + + expect(mockShowErrorMessage).not.toHaveBeenCalled() + expect(mockConsoleError).not.toHaveBeenCalled() + }) + + it("should initialise successfully with https hostname", () => { + expect(() => { + new CommitMessageGenerator("https://ollama.example.com", "codellama") + }).not.toThrow() + + expect(mockShowErrorMessage).not.toHaveBeenCalled() + expect(mockConsoleError).not.toHaveBeenCalled() + }) + + it("should throw error with hostname that doesn't start with 'http'", () => { + expect(() => { + new CommitMessageGenerator("localhost:11434", "llama3.2") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with empty hostname", () => { + expect(() => { + new CommitMessageGenerator("", "llama3.2") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with empty model", () => { + expect(() => { + new CommitMessageGenerator("http://localhost:11434", "") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with null hostname", () => { + expect(() => { + new CommitMessageGenerator(null as any, "llama3.2") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with null model", () => { + expect(() => { + new CommitMessageGenerator("http://localhost:11434", null as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + }) + + describe("Invalid Constructor Arguments", () => { + it("should throw error with no arguments", () => { + expect(() => { + new (CommitMessageGenerator as any)() + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with too many arguments", () => { + expect(() => { + new (CommitMessageGenerator as any)("arg1", "arg2", "arg3") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with mixed invalid arguments", () => { + expect(() => { + new CommitMessageGenerator("sk-api-key", "extra-argument" as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with non-string arguments", () => { + expect(() => { + new CommitMessageGenerator(123 as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error when first argument is object", () => { + expect(() => { + new CommitMessageGenerator({} as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + }) + + describe("Error Handling", () => { + it("should log error and show error message when exception occurs", () => { + expect(() => { + new CommitMessageGenerator("invalid-key") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith( + "Error initializing CommitMessageGenerator:", + expect.objectContaining({ + message: + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + }), + ) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + }) +}) + +describe("CommitMessageGenerator Message Generation", () => { + let mockShowErrorMessage: jest.Mock + let mockShowWarningMessage: jest.Mock + let generator: CommitMessageGenerator + + beforeEach(() => { + jest.clearAllMocks() + + // Mock vscode.window methods + mockShowErrorMessage = jest.fn() + mockShowWarningMessage = jest.fn() + ;(vscode.window as any).showErrorMessage = mockShowErrorMessage + ;(vscode.window as any).showWarningMessage = mockShowWarningMessage + }) + + describe("Ollama Message Generation", () => { + let mockOllamaGenerate: jest.Mock + + beforeEach(() => { + // Mock the Ollama class and its generate method + const { Ollama } = jest.requireMock("ollama") + mockOllamaGenerate = jest.fn() + Ollama.mockImplementation(() => ({ + generate: mockOllamaGenerate, + })) + }) + + it("should show error message and return undefined when Ollama model is not specified", async () => { + // Create generator with valid hostname but then set ollamaModel to undefined + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + // Manually set ollamaModel to undefined to simulate the error condition + ;(generator as any).ollamaModel = undefined + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", // This is ignored for Ollama + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "", // This should trigger the error + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBeUndefined() + expect(mockShowErrorMessage).toHaveBeenCalledWith("Ollama model not specified") + expect(mockShowWarningMessage).not.toHaveBeenCalled() + }) + + it("should show warning message and return undefined when Ollama returns empty response", async () => { + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + // Mock Ollama to return empty response + mockOllamaGenerate.mockResolvedValue({ + response: "", + done: true, + done_reason: "stop", + }) + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "test-model", + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBeUndefined() + expect(mockShowWarningMessage).toHaveBeenCalledWith("No commit message was generated") + expect(mockShowErrorMessage).not.toHaveBeenCalled() + }) + + it("should show warning message and return commit message when Ollama response is incomplete", async () => { + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + const incompleteMessage = "feat(api): add user authentication" + + // Mock Ollama to return incomplete response + mockOllamaGenerate.mockResolvedValue({ + response: incompleteMessage, + done: false, // This indicates incomplete response + done_reason: "length", + }) + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "test-model", + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBe(incompleteMessage) + expect(mockShowWarningMessage).toHaveBeenCalledWith( + "Ollama response was marked as incomplete. Review the commit message and ensure it meets your requirements.", + ) + expect(mockShowErrorMessage).not.toHaveBeenCalled() + }) + + it("should return commit message successfully when Ollama response is complete", async () => { + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + const completeMessage = + "feat(auth): implement user authentication system\n\n- Add login and registration endpoints\n- Implement JWT token handling" + + // Mock Ollama to return complete response + mockOllamaGenerate.mockResolvedValue({ + response: completeMessage, + done: true, + done_reason: "stop", + prompt_eval_count: 100, + eval_count: 50, + }) + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "test-model", + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBe(completeMessage) + expect(mockShowWarningMessage).not.toHaveBeenCalled() + expect(mockShowErrorMessage).not.toHaveBeenCalled() + }) + + it("should handle whitespace-only response as empty", async () => { + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + // Mock Ollama to return whitespace-only response + mockOllamaGenerate.mockResolvedValue({ + response: " \n\n \t ", + done: true, + done_reason: "stop", + }) + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "test-model", + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBeUndefined() + expect(mockShowWarningMessage).toHaveBeenCalledWith("No commit message was generated") + expect(mockShowErrorMessage).not.toHaveBeenCalled() + }) + }) +}) diff --git a/test/configurationHandling.test.ts b/test/configurationHandling.test.ts index 1508890..c6cdd46 100644 --- a/test/configurationHandling.test.ts +++ b/test/configurationHandling.test.ts @@ -67,7 +67,7 @@ describe("Configuration Handling", () => { ;(vscode.workspace.workspaceFolders as any) = [{ uri: { fsPath: "/test/workspace" } }] }) - test("should use configured values when available", async () => { + it("should use configured values when available", async () => { configGetMock.mockImplementation((key: string) => { const config: { [key: string]: any } = { model: "claude-3", @@ -75,6 +75,9 @@ describe("Configuration Handling", () => { temperature: 0.5, allowedTypes: ["feat", "fix"], customInstructions: "custom instructions", + provider: "anthropic", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", } return config[key] }) @@ -87,9 +90,12 @@ describe("Configuration Handling", () => { expect(configGetMock).toHaveBeenCalledWith("temperature") expect(configGetMock).toHaveBeenCalledWith("allowedTypes") expect(configGetMock).toHaveBeenCalledWith("customInstructions") + expect(configGetMock).toHaveBeenCalledWith("provider") + expect(configGetMock).toHaveBeenCalledWith("ollamaHostname") + expect(configGetMock).toHaveBeenCalledWith("ollamaModel") }) - test("should use default values when configuration is missing", async () => { + it("should use default values when configuration is missing", async () => { configGetMock.mockReturnValue(undefined) activate(mockContext) @@ -98,7 +104,7 @@ describe("Configuration Handling", () => { expect(configGetMock).toHaveBeenCalled() }) - test("should handle custom instructions when provided", async () => { + it("should handle custom instructions when provided", async () => { configGetMock.mockImplementation((key: string) => { if (key === "customInstructions") { return "custom instructions" @@ -112,7 +118,46 @@ describe("Configuration Handling", () => { expect(configGetMock).toHaveBeenCalledWith("customInstructions") }) - test("should handle missing workspace folder", async () => { + it("should handle Ollama provider configuration", async () => { + configGetMock.mockImplementation((key: string) => { + const config: { [key: string]: any } = { + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "llama2", + model: "claude-sonnet-4-0", // Not used for Ollama but still configured + maxTokens: 1000, + temperature: 0.3, + } + return config[key] + }) + + activate(mockContext) + await vscode.commands.executeCommand("diffCommit.generateCommitMessage") + + expect(configGetMock).toHaveBeenCalledWith("provider") + expect(configGetMock).toHaveBeenCalledWith("ollamaHostname") + expect(configGetMock).toHaveBeenCalledWith("ollamaModel") + }) + + it("should show error when Ollama model is not configured", async () => { + configGetMock.mockImplementation((key: string) => { + const config: { [key: string]: any } = { + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", // Empty model + } + return config[key] + }) + + activate(mockContext) + await vscode.commands.executeCommand("diffCommit.generateCommitMessage") + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "No Ollama model selected. Please configure an Ollama model first.", + ) + }) + + it("should handle missing workspace folder", async () => { ;(vscode.workspace.workspaceFolders as any) = undefined activate(mockContext) @@ -121,7 +166,7 @@ describe("Configuration Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No workspace folder found") }) - test("should handle missing Git extension", async () => { + it("should handle missing Git extension", async () => { ;(vscode.extensions.getExtension as jest.Mock).mockReturnValue(undefined) activate(mockContext) @@ -130,7 +175,7 @@ describe("Configuration Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Git extension not found") }) - test("should handle empty Git repositories", async () => { + it("should handle empty Git repositories", async () => { const mockGitExtension = { exports: { getAPI: (version: number) => ({ @@ -152,7 +197,7 @@ describe("Configuration Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found") }) - test("should handle no Git changes", async () => { + it("should handle no Git changes", async () => { const mockGitRepo = { state: { HEAD: { diff --git a/test/errorHandling.test.ts b/test/errorHandling.test.ts index f260639..998170c 100644 --- a/test/errorHandling.test.ts +++ b/test/errorHandling.test.ts @@ -1,6 +1,9 @@ // Mock Anthropic API at the top level const mockAnthropicCreate = jest.fn() +// Mock Ollama API at the top level +const mockOllamaGenerate = jest.fn() + // Create mock APIError class class MockAPIError extends Error { constructor( @@ -24,11 +27,26 @@ function MockAnthropic() { } MockAnthropic.APIError = MockAPIError +// Create mock Ollama constructor +function MockOllama() { + return { + generate: mockOllamaGenerate, + } +} + jest.mock("@anthropic-ai/sdk", () => ({ __esModule: true, default: MockAnthropic, })) +jest.mock("ollama", () => ({ + Ollama: MockOllama, +})) + +import { APIKeyManager } from "../src/apiKeyManager" + +jest.mock("../src/apiKeyManager") + import Anthropic from "@anthropic-ai/sdk" import * as vscode from "vscode" import { activate } from "../src/extension" @@ -133,9 +151,12 @@ describe("Error Handling", () => { ;(vscode.workspace as any).getConfiguration = () => ({ get: (key: string) => { const defaults: { [key: string]: any } = { - model: "claude-3-5-sonnet-latest", + model: "claude-sonnet-4-0", maxTokens: 1024, - temperature: 0.4, + temperature: 0.2, + provider: "anthropic", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", } return defaults[key] }, @@ -167,19 +188,22 @@ describe("Error Handling", () => { usage: { input_tokens: 100, output_tokens: 50 }, } mockAnthropicCreate.mockResolvedValue(mockMessage) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() // Verify console.log was called with the expected arguments expect(console.log).toHaveBeenCalledWith("[DiffCommit] Stop Reason: ", mockMessage.stop_reason) - expect(console.log).toHaveBeenCalledWith("[DiffCommit] Usage: ", mockMessage.usage) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Input Tokens: ", mockMessage.usage.input_tokens) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Output Tokens: ", mockMessage.usage.output_tokens) }) describe("Anthropic API Error Handling", () => { it("handles 400 Bad Request error", async () => { const apiError = new Anthropic.APIError(400, "Bad request", "api_error", {}) mockAnthropicCreate.mockRejectedValue(apiError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() @@ -191,6 +215,7 @@ describe("Error Handling", () => { it("handles 401 Unauthorised error", async () => { const apiError = new Anthropic.APIError(401, "Invalid API key", "api_error", {}) mockAnthropicCreate.mockRejectedValue(apiError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() @@ -204,6 +229,7 @@ describe("Error Handling", () => { it("handles 403 Forbidden error", async () => { const apiError = new Anthropic.APIError(403, "Permission denied", "api_error", {}) mockAnthropicCreate.mockRejectedValue(apiError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() @@ -217,6 +243,7 @@ describe("Error Handling", () => { it("handles 429 Rate Limit error", async () => { const apiError = new Anthropic.APIError(429, "Too many requests", "api_error", {}) mockAnthropicCreate.mockRejectedValue(apiError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() @@ -248,6 +275,34 @@ describe("Error Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to generate commit message:\n\nUnknown error") expect(console.error).toHaveBeenCalledWith("Anthropic API Error (418):\n\nUnknown error") }) + + it("handles non-APIError exceptions", async () => { + const regularError = new Error("Network connection failed") + mockAnthropicCreate.mockRejectedValue(regularError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unknown error generating commit message: Network connection failed", + ) + expect(console.error).toHaveBeenCalledWith("Unknown error: Network connection failed") + }) + + it("handles non-Error objects", async () => { + const stringError = "Something went wrong" + mockAnthropicCreate.mockRejectedValue(stringError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unknown error generating commit message: Something went wrong", + ) + expect(console.error).toHaveBeenCalledWith("Unknown error: Something went wrong") + }) }) it("logs error when writing commit message to SCM fails", async () => { @@ -288,7 +343,8 @@ describe("Error Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to write to SCM:\n\nSCM error") // Verify finally block still logs expect(console.log).toHaveBeenCalledWith("[DiffCommit] Stop Reason: ", mockMessage.stop_reason) - expect(console.log).toHaveBeenCalledWith("[DiffCommit] Usage: ", mockMessage.usage) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Input Tokens: ", mockMessage.usage.input_tokens) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Output Tokens: ", mockMessage.usage.output_tokens) }) it("logs error when opening commit message preview fails", async () => { @@ -328,6 +384,103 @@ describe("Error Handling", () => { ) // Verify finally block still logs expect(console.log).toHaveBeenCalledWith("[DiffCommit] Stop Reason: ", mockMessage.stop_reason) - expect(console.log).toHaveBeenCalledWith("[DiffCommit] Usage: ", mockMessage.usage) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Input Tokens: ", mockMessage.usage.input_tokens) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Output Tokens: ", mockMessage.usage.output_tokens) + }) + + describe("Ollama Error Handling", () => { + beforeEach(() => { + // Override the default configuration mock for Ollama tests + ;(vscode.workspace as any).getConfiguration = () => ({ + get: (key: string) => { + const config: { [key: string]: any } = { + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "llama3.2", + model: "claude-sonnet-4-0", + maxTokens: 1000, + temperature: 0.3, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + } + return config[key] + }, + }) + }) + + it("handles TypeError with fetch error (connection errors)", async () => { + const fetchError = new TypeError("fetch failed") + mockOllamaGenerate.mockRejectedValue(fetchError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unable to connect to Ollama server at http://localhost:11434. Please ensure that the Ollama server is running and accessible.", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nTypeError: fetch failed") + }) + + it("handles 404 model not found error", async () => { + const notFoundError = new Error("404 model not found") + mockOllamaGenerate.mockRejectedValue(notFoundError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Model 'llama3.2' not found. Please check if the model is available in Ollama.", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nError: 404 model not found") + }) + + it("handles 500 server error", async () => { + const serverError = new Error("500 internal server error") + mockOllamaGenerate.mockRejectedValue(serverError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Ollama server error. Please try again later.") + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nError: 500 internal server error") + }) + + it("handles generic Error with custom message", async () => { + const genericError = new Error("Something went wrong with Ollama") + mockOllamaGenerate.mockRejectedValue(genericError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to generate commit message with Ollama:\n\nSomething went wrong with Ollama", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nError: Something went wrong with Ollama") + }) + + it("handles unknown error type (not Error instance)", async () => { + const unknownError = "Some string error" + mockOllamaGenerate.mockRejectedValue(unknownError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unknown error generating commit message with Ollama: Some string error", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nSome string error") + }) + + it("handles unknown error with object type", async () => { + const unknownError = { message: "Object error" } + mockOllamaGenerate.mockRejectedValue(unknownError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unknown error generating commit message with Ollama: [object Object]", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\n[object Object]") + }) }) }) diff --git a/test/extension.test.ts b/test/extension.test.ts new file mode 100644 index 0000000..4d3e028 --- /dev/null +++ b/test/extension.test.ts @@ -0,0 +1,471 @@ +import * as vscode from "vscode" +import { APIKeyManager } from "../src/apiKeyManager" +import { CommitMessageGenerator } from "../src/commitMessageGenerator" +import { ConfigManager } from "../src/configManager" +import { activate, deactivate } from "../src/extension" +import { GitManager } from "../src/gitManager" +import { OllamaManager } from "../src/ollamaManager" + +jest.mock("../src/apiKeyManager") +jest.mock("../src/commitMessageGenerator") +jest.mock("../src/configManager") +jest.mock("../src/gitManager") +jest.mock("../src/ollamaManager") +jest.mock("vscode") + +describe("Extension Core Functionality", () => { + let mockContext: vscode.ExtensionContext + let mockApiKeyManager: jest.Mocked + let mockGitManager: jest.Mocked + let mockConfigManager: jest.Mocked + let mockOllamaManager: jest.Mocked + let mockCommitMessageGenerator: jest.Mocked + let registeredCommands: Map + + beforeEach(() => { + jest.clearAllMocks() + registeredCommands = new Map() + + // Mock console methods + jest.spyOn(console, "error").mockImplementation() + + // Mock VS Code APIs + ;(vscode.commands.registerCommand as jest.Mock).mockImplementation((commandId: string, callback: Function) => { + registeredCommands.set(commandId, callback) + return { dispose: jest.fn() } + }) + ;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) + ;(vscode.workspace.onDidCloseTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) + ;(vscode.workspace.workspaceFolders as any) = [{ uri: { fsPath: "/test/workspace" } }] + ;(vscode.window.withProgress as jest.Mock).mockImplementation(async (options, task) => { + return await task({ report: jest.fn() }) + }) + + // Create mock context + mockContext = { + subscriptions: [], + secrets: { + get: jest.fn(), + store: jest.fn(), + delete: jest.fn(), + }, + } as unknown as vscode.ExtensionContext + + // Mock manager instances + mockApiKeyManager = { + getAPIKey: jest.fn(), + setAPIKey: jest.fn(), + deleteAPIKey: jest.fn(), + } as unknown as jest.Mocked + + mockGitManager = { + getDiff: jest.fn(), + setCommitMessage: jest.fn(), + } as unknown as jest.Mocked + + mockConfigManager = { + getConfig: jest.fn().mockReturnValue({ + provider: "anthropic", + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + ollamaHostname: "http://localhost:11434", + ollamaModel: "llama2", + }), + } as unknown as jest.Mocked + + mockOllamaManager = { + changeOllamaModel: jest.fn(), + } as unknown as jest.Mocked + + mockCommitMessageGenerator = { + generateMessage: jest.fn(), + } as unknown as jest.Mocked + + // Mock constructor implementations + ;(APIKeyManager as jest.MockedClass).mockImplementation(() => mockApiKeyManager) + ;(GitManager as jest.MockedClass).mockImplementation(() => mockGitManager) + ;(ConfigManager as jest.MockedClass).mockImplementation(() => mockConfigManager) + ;(OllamaManager as jest.MockedClass).mockImplementation(() => mockOllamaManager) + ;(CommitMessageGenerator as jest.MockedClass).mockImplementation( + () => mockCommitMessageGenerator, + ) + }) + + describe("Extension Activation", () => { + it("should create all manager instances on activation", () => { + activate(mockContext) + + expect(APIKeyManager).toHaveBeenCalledWith(mockContext) + expect(GitManager).toHaveBeenCalled() + expect(ConfigManager).toHaveBeenCalled() + expect(OllamaManager).toHaveBeenCalled() + }) + + it("should register all expected commands", () => { + activate(mockContext) + + const expectedCommands = [ + "diffCommit.updateAPIKey", + "diffCommit.getAPIKey", + "diffCommit.deleteAPIKey", + "diffCommit.selectOllamaModel", + "diffCommit.changeOllamaModel", + "diffCommit.generateCommitMessage", + "diffCommit.previewCommitMessage", + ] + + expectedCommands.forEach((commandId) => { + expect(registeredCommands.has(commandId)).toBe(true) + }) + }) + + it("should setup workspace event listeners", () => { + activate(mockContext) + + expect(vscode.workspace.onDidSaveTextDocument).toHaveBeenCalled() + expect(vscode.workspace.onDidCloseTextDocument).toHaveBeenCalled() + }) + + it("should add all subscriptions to context", () => { + activate(mockContext) + + // 7 commands + 2 workspace event handlers = 9 subscriptions + expect(mockContext.subscriptions).toHaveLength(9) + }) + }) + + describe("generateCommitMessage function", () => { + beforeEach(() => { + activate(mockContext) + }) + + it("should show error when no workspace folder is found", async () => { + ;(vscode.workspace.workspaceFolders as any) = undefined + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No workspace folder found") + }) + + it("should show error when no changes are detected", async () => { + mockGitManager.getDiff.mockResolvedValue(undefined) + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No changes detected") + }) + + it("should handle Ollama provider with missing model", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockConfigManager.getConfig.mockReturnValue({ + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + }) + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "No Ollama model selected. Please configure an Ollama model first.", + ) + }) + + it("should create Ollama CommitMessageGenerator for ollama provider", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockConfigManager.getConfig.mockReturnValue({ + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "llama2", + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + }) + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(CommitMessageGenerator).toHaveBeenCalledWith("http://localhost:11434", "llama2") + expect(mockCommitMessageGenerator.generateMessage).toHaveBeenCalled() + }) + + it("should handle Anthropic provider with missing API key", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue(undefined) + mockApiKeyManager.setAPIKey.mockResolvedValue(undefined) + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("API Key is required") + }) + + it("should create Anthropic CommitMessageGenerator for anthropic provider", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(CommitMessageGenerator).toHaveBeenCalledWith("sk-test-key") + expect(mockCommitMessageGenerator.generateMessage).toHaveBeenCalled() + expect(mockGitManager.setCommitMessage).toHaveBeenCalledWith("feat: test commit") + }) + + it("should fall back to setAPIKey when getAPIKey returns null", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue(undefined) + mockApiKeyManager.setAPIKey.mockResolvedValue("sk-new-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(mockApiKeyManager.setAPIKey).toHaveBeenCalled() + expect(CommitMessageGenerator).toHaveBeenCalledWith("sk-new-key") + }) + + it("should show progress with correct options", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.withProgress).toHaveBeenCalledWith( + { + location: vscode.ProgressLocation.Notification, + title: "Diff Commit", + cancellable: true, + }, + expect.any(Function), + ) + }) + + it("should handle errors gracefully", async () => { + mockGitManager.getDiff.mockRejectedValue(new Error("Git error")) + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(console.error).toHaveBeenCalledWith("Error writing commit message to SCM:\n\nError: Git error") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to write to SCM:\n\nGit error") + }) + }) + + describe("Preview Command", () => { + beforeEach(() => { + activate(mockContext) + }) + + it("should open preview document for generated commit message", async () => { + const mockDocument = { getText: jest.fn().mockReturnValue("feat: test commit") } + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue(mockDocument) + ;(vscode.window.showTextDocument as jest.Mock).mockResolvedValue(undefined) + + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + await previewCommand() + + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith({ + content: "feat: test commit", + language: "markdown", + }) + expect(vscode.window.showTextDocument).toHaveBeenCalledWith(mockDocument) + }) + + it("should handle preview errors gracefully", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockRejectedValue(new Error("Preview error")) + + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + await previewCommand() + + expect(console.error).toHaveBeenCalledWith("Error opening commit message preview:\n\nError: Preview error") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to open commit message preview:\n\nPreview error", + ) + }) + + it("should not create preview if no commit message is generated", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue(undefined) + + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + await previewCommand() + + expect(vscode.workspace.openTextDocument).not.toHaveBeenCalled() + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + }) + }) + + describe("Document Event Handlers", () => { + let saveCallback: Function + let closeCallback: Function + let mockPreviewDocument: any + + beforeEach(() => { + ;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockImplementation((callback) => { + saveCallback = callback + return { dispose: jest.fn() } + }) + ;(vscode.workspace.onDidCloseTextDocument as jest.Mock).mockImplementation((callback) => { + closeCallback = callback + return { dispose: jest.fn() } + }) + + mockPreviewDocument = { getText: jest.fn().mockReturnValue("feat: updated commit") } + + activate(mockContext) + }) + + it("should update git commit message when preview document is saved", () => { + // First create a preview document + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue(mockPreviewDocument) + + // Trigger preview command to set previewDocument + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + return previewCommand().then(() => { + // Now trigger save event + saveCallback(mockPreviewDocument) + + expect(mockGitManager.setCommitMessage).toHaveBeenCalledWith("feat: updated commit") + }) + }) + + it("should not update git commit message when other document is saved", () => { + const otherDocument = { getText: jest.fn().mockReturnValue("other content") } + + saveCallback(otherDocument) + + expect(mockGitManager.setCommitMessage).not.toHaveBeenCalled() + }) + + it("should clear preview document reference when preview document is closed", () => { + // First create a preview document + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue(mockPreviewDocument) + + // Trigger preview command to set previewDocument + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + return previewCommand().then(() => { + // Now trigger close event + closeCallback(mockPreviewDocument) + + // Subsequent save should not trigger setCommitMessage + saveCallback(mockPreviewDocument) + expect(mockGitManager.setCommitMessage).not.toHaveBeenCalled() + }) + }) + + it("should not clear preview document reference when other document is closed", () => { + const otherDocument = { getText: jest.fn().mockReturnValue("other content") } + + // First create a preview document + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue(mockPreviewDocument) + + // Trigger preview command to set previewDocument + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + return previewCommand().then(() => { + // Now close other document + closeCallback(otherDocument) + + // Save of preview document should still work + saveCallback(mockPreviewDocument) + expect(mockGitManager.setCommitMessage).toHaveBeenCalledWith("feat: updated commit") + }) + }) + }) + + describe("Command Delegation", () => { + beforeEach(() => { + activate(mockContext) + }) + + it("should delegate API key management commands correctly", () => { + const updateKeyCommand = registeredCommands.get("diffCommit.updateAPIKey")! + const getKeyCommand = registeredCommands.get("diffCommit.getAPIKey")! + const deleteKeyCommand = registeredCommands.get("diffCommit.deleteAPIKey")! + + updateKeyCommand() + getKeyCommand() + deleteKeyCommand() + + expect(mockApiKeyManager.setAPIKey).toHaveBeenCalled() + expect(mockApiKeyManager.getAPIKey).toHaveBeenCalled() + expect(mockApiKeyManager.deleteAPIKey).toHaveBeenCalled() + }) + + it("should delegate Ollama model commands correctly", () => { + const selectModelCommand = registeredCommands.get("diffCommit.selectOllamaModel")! + const changeModelCommand = registeredCommands.get("diffCommit.changeOllamaModel")! + + selectModelCommand() + changeModelCommand() + + expect(mockOllamaManager.changeOllamaModel).toHaveBeenCalledTimes(2) + }) + }) + + describe("Error Handling Edge Cases", () => { + beforeEach(() => { + activate(mockContext) + }) + + it("should handle non-Error objects in generateCommitMessage", async () => { + mockGitManager.getDiff.mockRejectedValue("String error") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(console.error).toHaveBeenCalledWith("Error writing commit message to SCM:\n\nString error") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to write to SCM:\n\nString error") + }) + + it("should handle non-Error objects in previewCommitMessage", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockRejectedValue("String error") + + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + await previewCommand() + + expect(console.error).toHaveBeenCalledWith("Error opening commit message preview:\n\nString error") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to open commit message preview:\n\nString error", + ) + }) + }) + + describe("Extension Deactivation", () => { + it("should handle deactivation gracefully", () => { + expect(() => deactivate()).not.toThrow() + }) + }) +}) diff --git a/test/gitAndCommands.test.ts b/test/gitAndCommands.test.ts index 29e5716..6c8850c 100644 --- a/test/gitAndCommands.test.ts +++ b/test/gitAndCommands.test.ts @@ -29,13 +29,15 @@ describe("Command Registration and Lifecycle", () => { }) describe("Command Registration", () => { - test("should register all commands in correct order", () => { + it("should register all commands in correct order", () => { activate(mockContext) const expectedCommands = [ "diffCommit.updateAPIKey", "diffCommit.getAPIKey", "diffCommit.deleteAPIKey", + "diffCommit.selectOllamaModel", + "diffCommit.changeOllamaModel", "diffCommit.generateCommitMessage", "diffCommit.previewCommitMessage", ] @@ -46,11 +48,11 @@ describe("Command Registration and Lifecycle", () => { }) }) - test("should add all commands to subscriptions", () => { + it("should add all commands to subscriptions", () => { activate(mockContext) - // 7 subscriptions: 5 commands + 2 workspace event handlers - expect(mockContext.subscriptions).toHaveLength(7) + // 9 subscriptions: 7 commands + 2 workspace event handlers + expect(mockContext.subscriptions).toHaveLength(9) mockContext.subscriptions.forEach((subscription: any) => { expect(subscription).toHaveProperty("dispose") expect(typeof subscription.dispose).toBe("function") @@ -59,7 +61,7 @@ describe("Command Registration and Lifecycle", () => { }) describe("Command Lifecycle", () => { - test("should properly dispose commands on deactivation", () => { + it("should properly dispose commands on deactivation", () => { activate(mockContext) const disposeMocks = mockContext.subscriptions.map((sub: any) => sub.dispose as jest.Mock) @@ -71,7 +73,7 @@ describe("Command Registration and Lifecycle", () => { }) }) - test("should maintain command registration if one fails", () => { + it("should maintain command registration if one fails", () => { // Mock console.error const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}) @@ -90,8 +92,8 @@ describe("Command Registration and Lifecycle", () => { activate(mockContext) // Should still register remaining commands - expect(registeredCommands.size).toBe(4) - expect(mockContext.subscriptions.length).toBe(7) // All commands get registered + expect(registeredCommands.size).toBe(6) + expect(mockContext.subscriptions.length).toBe(9) // All commands get registered expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(Error)) consoleErrorSpy.mockRestore() @@ -99,7 +101,7 @@ describe("Command Registration and Lifecycle", () => { }) describe("Subscription Management", () => { - test("should not add duplicate commands to subscriptions", () => { + it("should not add duplicate commands to subscriptions", () => { // First activation activate(mockContext) const firstSubscriptionsLength = mockContext.subscriptions.length @@ -112,10 +114,10 @@ describe("Command Registration and Lifecycle", () => { // Should have same number of registrations as first activation expect(mockContext.subscriptions.length).toBe(firstSubscriptionsLength) - expect(registeredCommands.size).toBe(5) // Only command registrations + expect(registeredCommands.size).toBe(7) // Only command registrations }) - test("should handle disposal of invalid subscriptions", () => { + it("should handle disposal of invalid subscriptions", () => { activate(mockContext) // Add an invalid subscription @@ -131,9 +133,9 @@ describe("Command Registration and Lifecycle", () => { }).not.toThrow() }) - test("should handle empty subscriptions array", () => { + it("should handle empty subscriptions array", () => { expect(() => activate(mockContext)).not.toThrow() - expect(mockContext.subscriptions.length).toBe(7) // 5 commands + 2 workspace event handlers + expect(mockContext.subscriptions.length).toBe(9) // 7 commands + 2 workspace event handlers }) }) }) diff --git a/test/gitIntegration.test.ts b/test/gitIntegration.test.ts index a39f011..52e1e4b 100644 --- a/test/gitIntegration.test.ts +++ b/test/gitIntegration.test.ts @@ -48,13 +48,19 @@ jest.mock("vscode", () => { get: jest.fn((key: string) => { switch (key) { case "model": - return "claude-3-5-sonnet-20241022" + return "claude-sonnet-4-0" case "maxTokens": return 1024 case "temperature": - return 0.4 + return 0.2 case "allowedTypes": return ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"] + case "provider": + return "anthropic" + case "ollamaHostname": + return "http://localhost:11434" + case "ollamaModel": + return "" default: return undefined } @@ -209,9 +215,9 @@ describe("Git Integration", () => { expect(mockGitRepo.diff).toHaveBeenCalledWith(true) expect(mockAnthropicCreate).toHaveBeenCalledWith({ - model: "claude-3-5-sonnet-20241022", + model: "claude-sonnet-4-0", max_tokens: 1024, - temperature: 0.4, + temperature: 0.2, system: expect.any(String), messages: expect.arrayContaining([ expect.objectContaining({ @@ -231,9 +237,9 @@ describe("Git Integration", () => { expect(mockGitRepo.diff).toHaveBeenCalledWith(true) expect(mockAnthropicCreate).toHaveBeenCalledWith({ - model: "claude-3-5-sonnet-20241022", + model: "claude-sonnet-4-0", max_tokens: 1024, - temperature: 0.4, + temperature: 0.2, system: expect.any(String), messages: expect.arrayContaining([ expect.objectContaining({ diff --git a/test/messageHandling.test.ts b/test/messageHandling.test.ts index 66c56d9..011686b 100644 --- a/test/messageHandling.test.ts +++ b/test/messageHandling.test.ts @@ -81,6 +81,20 @@ describe("Message Handling", () => { ;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) ;(vscode.workspace.onDidCloseTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) + // Set up default configuration + configGetMock.mockImplementation((key: string) => { + const defaults: { [key: string]: any } = { + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + provider: "anthropic", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", + } + return defaults[key] + }) + // Mock workspace folders ;(vscode.workspace.workspaceFolders as any) = [{ uri: { fsPath: "/test/workspace" } }] }) diff --git a/test/ollamaManager.test.ts b/test/ollamaManager.test.ts new file mode 100644 index 0000000..12d5305 --- /dev/null +++ b/test/ollamaManager.test.ts @@ -0,0 +1,261 @@ +const mockOllamaList = jest.fn() + +// Create mock Ollama constructor +function MockOllama() { + return { + list: mockOllamaList, + } +} + +jest.mock("ollama", () => ({ + Ollama: MockOllama, +})) + +// Mock console +const mockConsoleError = jest.fn() +jest.mock("console", () => ({ + error: mockConsoleError, +})) + +// Mock vscode +import * as vscode from "vscode" + +jest.mock("vscode") + +import { OllamaManager } from "../src/ollamaManager" + +describe("OllamaManager", () => { + let ollamaManager: OllamaManager + let mockShowErrorMessage: jest.Mock + let mockShowWarningMessage: jest.Mock + let mockShowInputBox: jest.Mock + let mockShowQuickPick: jest.Mock + let mockSetStatusBarMessage: jest.Mock + let mockGetConfiguration: jest.Mock + let mockConfigGet: jest.Mock + let mockConfigUpdate: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + + // Mock vscode functions + mockShowErrorMessage = jest.fn() + mockShowWarningMessage = jest.fn() + mockShowInputBox = jest.fn() + mockShowQuickPick = jest.fn() + mockSetStatusBarMessage = jest.fn() + mockConfigGet = jest.fn() + mockConfigUpdate = jest.fn().mockResolvedValue(undefined) + ;(vscode.window as any).showErrorMessage = mockShowErrorMessage + ;(vscode.window as any).showWarningMessage = mockShowWarningMessage + ;(vscode.window as any).showInputBox = mockShowInputBox + ;(vscode.window as any).showQuickPick = mockShowQuickPick + ;(vscode.window as any).setStatusBarMessage = mockSetStatusBarMessage + + mockGetConfiguration = jest.fn().mockReturnValue({ + get: mockConfigGet, + update: mockConfigUpdate, + }) + ;(vscode.workspace as any).getConfiguration = mockGetConfiguration + + ollamaManager = new OllamaManager() + }) + + describe("getAvailableModels", () => { + it("should return model names successfully", async () => { + const mockModels = [{ name: "llama3.2" }, { name: "codellama" }, { name: "mistral" }] + mockOllamaList.mockResolvedValue({ models: mockModels }) + + const result = await ollamaManager.getAvailableModels("http://localhost:11434") + + expect(result).toEqual(["llama3.2", "codellama", "mistral"]) + expect(mockOllamaList).toHaveBeenCalled() + }) + + it("should handle errors and show error message", async () => { + const testError = new Error("Connection failed") + mockOllamaList.mockRejectedValue(testError) + + await expect(ollamaManager.getAvailableModels("http://localhost:11434")).rejects.toThrow("Connection failed") + + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to fetch Ollama models: Connection failed") + }) + + it("should handle non-Error objects", async () => { + const testError = "String error" + mockOllamaList.mockRejectedValue(testError) + + await expect(ollamaManager.getAvailableModels("http://localhost:11434")).rejects.toBe("String error") + + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to fetch Ollama models: String error") + }) + }) + + describe("configureOllamaModel", () => { + beforeEach(() => { + mockConfigGet.mockImplementation((key: string) => { + if (key === "ollamaHostname") return "http://localhost:11434" + if (key === "ollamaModel") return "llama3.2" + return undefined + }) + }) + + it("should configure successfully with valid hostname and models", async () => { + const mockModels = [{ name: "llama3.2" }, { name: "codellama" }] + mockOllamaList.mockResolvedValue({ models: mockModels }) + mockShowInputBox.mockResolvedValue("http://localhost:11434") + mockShowQuickPick.mockResolvedValue("llama3.2") + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(true) + expect(mockConfigUpdate).toHaveBeenCalledWith("provider", "ollama", true) + expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaHostname", "http://localhost:11434/", true) + expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaModel", "llama3.2", true) + expect(mockSetStatusBarMessage).toHaveBeenCalledWith("✓ Ollama model 'llama3.2' selected successfully", 4000) + }) + + it("should return false when user cancels hostname input", async () => { + mockShowInputBox.mockResolvedValue(undefined) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockConfigUpdate).not.toHaveBeenCalled() + }) + + it("should handle invalid hostname URL and retry", async () => { + // First call with invalid URL, second call cancels + mockShowInputBox.mockResolvedValueOnce("invalid-url").mockResolvedValueOnce(undefined) + + const result = await ollamaManager.configureOllamaModel() + + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Invalid hostname URL. Please enter a valid URL (eg http://localhost:11434).", + ) + expect(result).toBe(false) + }) + + it("should show warning when no models found", async () => { + mockOllamaList.mockResolvedValue({ models: [] }) + mockShowInputBox.mockResolvedValue("http://localhost:11434") + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowWarningMessage).toHaveBeenCalledWith( + "No models found on the Ollama server. Please pull a model first.", + ) + }) + + it("should return false when user cancels model selection", async () => { + const mockModels = [{ name: "llama3.2" }] + mockOllamaList.mockResolvedValue({ models: mockModels }) + mockShowInputBox.mockResolvedValue("http://localhost:11434") + mockShowQuickPick.mockResolvedValue(undefined) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockConfigUpdate).not.toHaveBeenCalled() + }) + + describe("error handling", () => { + beforeEach(() => { + mockShowInputBox.mockResolvedValue("http://localhost:11434") + }) + + it("should handle ECONNREFUSED error", async () => { + const connectionError = new Error("ECONNREFUSED - connection refused") + mockOllamaList.mockRejectedValue(connectionError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Unable to connect to Ollama server at http://localhost:11434/. Please ensure that the Ollama server is running and accessible.", + ) + }) + + it("should handle fetch error", async () => { + const fetchError = new Error("fetch failed") + mockOllamaList.mockRejectedValue(fetchError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Unable to connect to Ollama server at http://localhost:11434/. Please ensure that the Ollama server is running and accessible.", + ) + }) + + it("should handle 404 error", async () => { + const notFoundError = new Error("404 not found") + mockOllamaList.mockRejectedValue(notFoundError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Ollama server not found at http://localhost:11434/. Please check the hostname and try again.", + ) + }) + + it("should handle generic Error", async () => { + const genericError = new Error("Something went wrong") + mockOllamaList.mockRejectedValue(genericError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to connect to Ollama: Something went wrong") + }) + + it("should handle non-Error objects", async () => { + const stringError = "String error" + mockOllamaList.mockRejectedValue(stringError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to connect to Ollama: String error") + }) + }) + }) + + describe("changeOllamaModel", () => { + beforeEach(() => { + mockConfigGet.mockImplementation((key: string) => { + if (key === "ollamaHostname") return "http://localhost:11434" + if (key === "ollamaModel") return "llama3.2" + return undefined + }) + }) + + it("should change model successfully without changing hostname", async () => { + const mockModels = [{ name: "llama3.2" }, { name: "codellama" }] + mockOllamaList.mockResolvedValue({ models: mockModels }) + mockShowQuickPick.mockResolvedValue("codellama") + + const result = await ollamaManager.changeOllamaModel() + + expect(result).toBe(true) + expect(mockConfigUpdate).not.toHaveBeenCalledWith("provider", "ollama", true) + expect(mockConfigUpdate).not.toHaveBeenCalledWith("ollamaHostname", expect.anything(), true) + expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaModel", "codellama", true) + expect(mockSetStatusBarMessage).toHaveBeenCalledWith("✓ Ollama model 'codellama' changed to successfully", 4000) + }) + + it("should handle errors during model changing", async () => { + const connectionError = new Error("ECONNREFUSED - connection refused") + mockOllamaList.mockRejectedValue(connectionError) + + const result = await ollamaManager.changeOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Unable to connect to Ollama server at http://localhost:11434. Please ensure that the Ollama server is running and accessible.", + ) + }) + }) +}) diff --git a/test/withProgressAPI.test.ts b/test/withProgressAPI.test.ts index 6c732ef..6dbcc72 100644 --- a/test/withProgressAPI.test.ts +++ b/test/withProgressAPI.test.ts @@ -41,6 +41,16 @@ describe("Progress API Integration", () => { // Mock the constructor ;(GitManager as jest.MockedClass).mockImplementation(() => mockGitManager) + // Mock ConfigManager + ;(ConfigManager.prototype.getConfig as jest.Mock).mockReturnValue({ + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + provider: "anthropic", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", + }) + // Mock workspace folders ;(workspace.workspaceFolders as any) = [ { @@ -88,7 +98,7 @@ describe("Progress API Integration", () => { // Verify progress messages in order expect(mockProgress.report.mock.calls).toEqual([ [{ message: "Getting git diff..." }], - [{ message: "Validating API key..." }], + [{ message: "Validating configuration..." }], [{ message: "Generating commit message..." }], ]) }) @@ -120,7 +130,7 @@ describe("Progress API Integration", () => { // Should show first two progress messages expect(mockProgress.report.mock.calls).toEqual([ [{ message: "Getting git diff..." }], - [{ message: "Validating API key..." }], + [{ message: "Validating configuration..." }], ]) // Should show error message @@ -139,7 +149,7 @@ describe("Progress API Integration", () => { // Should show first two progress messages expect(mockProgress.report.mock.calls).toEqual([ [{ message: "Getting git diff..." }], - [{ message: "Validating API key..." }], + [{ message: "Validating configuration..." }], ]) // Should show error message @@ -166,7 +176,7 @@ describe("Progress API Integration", () => { // Verify all progress messages shown expect(mockProgress.report.mock.calls).toEqual([ [{ message: "Getting git diff..." }], - [{ message: "Validating API key..." }], + [{ message: "Validating configuration..." }], [{ message: "Generating commit message..." }], ]) From 8bad0fc532e5356ad039b02215dd796160445ec4 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 21:54:01 +1000 Subject: [PATCH 06/15] chore(config): add support for local Ollama AI models This commit adds support for using local Ollama models as an alternative to cloud-based Anthropic models: - Updated package description to mention Ollama support for offline usage - Added keywords related to Ollama and local/offline AI capabilities - Renamed command from `diffCommit.selectOllamaModel` to `diffCommit.configureOllamaModel` - Added Ollama dependency (version 0.5.16) to package.json --- package-lock.json | 18 +++++++++++++++++- package.json | 10 +++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 47cd76c..e38def9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.3.8", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.39.0" + "@anthropic-ai/sdk": "^0.39.0", + "ollama": "^0.5.16" }, "devDependencies": { "@types/jest": "^29.5.14", @@ -6045,6 +6046,15 @@ "node": ">= 6" } }, + "node_modules/ollama": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.16.tgz", + "integrity": "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7812,6 +7822,12 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index 81de6a4..3fff813 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "diff-commit", "displayName": "Diff Commit", "version": "0.3.8", - "description": "Generate detailed Git commit messages following the conventional commit specification using Anthropic's AI models like Claude 4 Sonnet.", + "description": "Generate detailed Git commit messages following the conventional commit specification using AI models. Choose between Anthropic's cloud-based models like Claude 4 Sonnet or local Ollama models for offline usage.", "categories": [ "AI", "SCM Providers" @@ -15,6 +15,9 @@ "Anthropic", "Claude Sonnet", "Sonnet", + "Ollama", + "Local AI", + "Offline AI", "Diff Commit", "tpsTech", "tsdevau" @@ -57,7 +60,7 @@ "icon": "$(trash)" }, { - "command": "diffCommit.selectOllamaModel", + "command": "diffCommit.configureOllamaModel", "title": "DiffCommit: Configure Ollama Model", "icon": "$(server-environment)" }, @@ -174,7 +177,8 @@ } }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0" + "@anthropic-ai/sdk": "^0.39.0", + "ollama": "^0.5.16" }, "devDependencies": { "@types/jest": "^29.5.14", From f481f46d584f08760f6531035c02857aa227a056 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 22:14:14 +1000 Subject: [PATCH 07/15] refactor(commands): rename selectOllamaModel to configureOllamaModel for clarity The commit renames the command from "selectOllamaModel" to "configureOllamaModel" to better reflect its purpose, updating both the command registration and its reference in the subscriptions list. --- src/extension.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index c0dae7c..c217dbd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -65,8 +65,8 @@ export function activate(context: ExtensionContext) { const cmdUpdateAPIKey = commands.registerCommand("diffCommit.updateAPIKey", () => apiKeyManager.setAPIKey()) const cmdGetAPIKey = commands.registerCommand("diffCommit.getAPIKey", () => apiKeyManager.getAPIKey()) const cmdDeleteAPIKey = commands.registerCommand("diffCommit.deleteAPIKey", () => apiKeyManager.deleteAPIKey()) - const cmdSelectOllamaModel = commands.registerCommand("diffCommit.selectOllamaModel", () => - ollamaManager.changeOllamaModel(), + const cmdConfigureOllamaModel = commands.registerCommand("diffCommit.configureOllamaModel", () => + ollamaManager.configureOllamaModel(), ) const cmdChangeOllamaModel = commands.registerCommand("diffCommit.changeOllamaModel", () => ollamaManager.changeOllamaModel(), @@ -124,7 +124,7 @@ export function activate(context: ExtensionContext) { cmdUpdateAPIKey, cmdGetAPIKey, cmdDeleteAPIKey, - cmdSelectOllamaModel, + cmdConfigureOllamaModel, cmdChangeOllamaModel, onSave, onClose, From 17c6daae2bba0b59c6bc4d94ec88a260edb3459b Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 22:14:40 +1000 Subject: [PATCH 08/15] test(extension): rename selectOllamaModel to configureOllamaModel Renames the command from `selectOllamaModel` to `configureOllamaModel` and adds the corresponding mock function. Updates tests to properly verify that each function is called exactly once instead of assuming both commands use the same underlying function. --- test/extension.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/extension.test.ts b/test/extension.test.ts index 4d3e028..9fdfcd3 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -76,6 +76,7 @@ describe("Extension Core Functionality", () => { } as unknown as jest.Mocked mockOllamaManager = { + configureOllamaModel: jest.fn(), changeOllamaModel: jest.fn(), } as unknown as jest.Mocked @@ -110,7 +111,7 @@ describe("Extension Core Functionality", () => { "diffCommit.updateAPIKey", "diffCommit.getAPIKey", "diffCommit.deleteAPIKey", - "diffCommit.selectOllamaModel", + "diffCommit.configureOllamaModel", "diffCommit.changeOllamaModel", "diffCommit.generateCommitMessage", "diffCommit.previewCommitMessage", @@ -422,13 +423,14 @@ describe("Extension Core Functionality", () => { }) it("should delegate Ollama model commands correctly", () => { - const selectModelCommand = registeredCommands.get("diffCommit.selectOllamaModel")! + const configureModelCommand = registeredCommands.get("diffCommit.configureOllamaModel")! const changeModelCommand = registeredCommands.get("diffCommit.changeOllamaModel")! - selectModelCommand() + configureModelCommand() changeModelCommand() - expect(mockOllamaManager.changeOllamaModel).toHaveBeenCalledTimes(2) + expect(mockOllamaManager.configureOllamaModel).toHaveBeenCalledTimes(1) + expect(mockOllamaManager.changeOllamaModel).toHaveBeenCalledTimes(1) }) }) From bc6a52b1b8b3138a4490882a9b351c958c5d23bc Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 22:14:59 +1000 Subject: [PATCH 09/15] tests(commands): rename Ollama command from selectOllamaModel to configureOllamaModel --- test/gitAndCommands.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/gitAndCommands.test.ts b/test/gitAndCommands.test.ts index 6c8850c..81da22f 100644 --- a/test/gitAndCommands.test.ts +++ b/test/gitAndCommands.test.ts @@ -36,7 +36,7 @@ describe("Command Registration and Lifecycle", () => { "diffCommit.updateAPIKey", "diffCommit.getAPIKey", "diffCommit.deleteAPIKey", - "diffCommit.selectOllamaModel", + "diffCommit.configureOllamaModel", "diffCommit.changeOllamaModel", "diffCommit.generateCommitMessage", "diffCommit.previewCommitMessage", From 68d9ad88959eb5891b817227e01c17faafe77713 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 22:15:26 +1000 Subject: [PATCH 10/15] chore(config): alphabetise default commit types in package.json --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 3fff813..1ae30f8 100644 --- a/package.json +++ b/package.json @@ -134,15 +134,15 @@ "type": "string" }, "default": [ + "chore", + "ci", + "docs", "feat", "fix", + "perf", "refactor", - "chore", - "docs", "style", - "test", - "perf", - "ci" + "test" ], "description": "List of allowed commit types. If provided, this replaces the default options. So you need to ensure all required commit types are included in the list." } From 37688283372cf13c491a7d5adfe1cc157e2df1ce Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 22:16:30 +1000 Subject: [PATCH 11/15] docs(readme): update README for Ollama integration - Added dual provider support with a new `diffCommit.provider` configuration option - Implemented Ollama integration with model selection and server configuration - Added new commands for Ollama setup and model switching: - `DiffCommit: Configure Ollama Model` - `DiffCommit: Change Ollama Model` - Added new configuration settings: - `diffCommit.ollamaHostname` for server connection - `diffCommit.ollamaModel` for model selection - Updated documentation with Ollama requirements and setup instructions - Enhanced error handling for Ollama-specific scenarios - Updated workflow documentation to include provider selection --- README.md | 146 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 118 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8173cea..a6faf1b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Diff Commit -Diff Commit is a VSCode extension that helps you generate commit messages following the conventional commits specification using Anthropic's AI models like Claude 3.5 Sonnet. Commit messages are generated using the diff of staged changes and entered directly into the SCM message input or previewed in a new editor window. +Diff Commit is a VSCode extension that helps you generate commit messages following the conventional commits specification using AI models. Choose between Anthropic's cloud-based models like Claude 4 Sonnet or local Ollama models for offline usage. Commit messages are generated using the diff of staged changes and entered directly into the SCM message input or previewed in a new editor window. The generated commit messages are compatible with [googleapis/release-please](https://github.com/googleapis/release-please) and other tools that use conventional commits. @@ -19,10 +19,15 @@ The generated commit messages are compatible with [googleapis/release-please](ht - [`DiffCommit: Preview Commit Message`](#diffcommit-preview-commit-message) - [`DiffCommit: Update API Key`](#diffcommit-update-api-key) - [`DiffCommit: Delete API Key`](#diffcommit-delete-api-key) + - [`DiffCommit: Configure Ollama Model`](#diffcommit-configure-ollama-model) + - [`DiffCommit: Change Ollama Model`](#diffcommit-change-ollama-model) - [Configuration](#configuration) + - [`diffCommit.provider`](#diffcommitprovider) - [`diffCommit.allowedTypes`](#diffcommitallowedtypes) - [`diffCommit.customInstructions`](#diffcommitcustominstructions) - [`diffCommit.model`](#diffcommitmodel) + - [`diffCommit.ollamaHostname`](#diffcommitollamahostname) + - [`diffCommit.ollamaModel`](#diffcommitollamamodel) - [`diffCommit.maxTokens`](#diffcommitmaxtokens) - [`diffCommit.temperature`](#diffcommittemperature) - [Error Handling](#error-handling) @@ -33,7 +38,10 @@ The generated commit messages are compatible with [googleapis/release-please](ht ## Features -- Generate commit messages based on the diff of staged changes using Anthropic's AI models like Claude 4 Sonnet +- **Dual Provider Support**: Choose between Anthropic's cloud-based models or local Ollama models for offline usage +- Generate commit messages based on the diff of staged changes using AI models +- **Anthropic Integration**: Access to Claude 4 Sonnet and other powerful cloud models with an API key +- **Ollama Integration**: Use local models for complete offline functionality and privacy - Preview and edit generated commit messages before applying the commit - Uses markdown formatting for commit messages - Implements conventional commit format with type, scope, subject, and body @@ -42,10 +50,16 @@ The generated commit messages are compatible with [googleapis/release-please](ht ## Requirements -- VSCode 1.9.4 or higher +- VSCode 1.93.1 or higher - Git installed and configured in your workspace + +**For Anthropic Provider:** - An [Anthropic API key](https://console.anthropic.com/settings/keys) +**For Ollama Provider:** +- [Ollama](https://ollama.com/) installed and running locally +- At least one model pulled (eg `ollama pull llama3.2` or `ollama pull mistral`) + ## Installation Install the extension directly from the [Visual Studio Code Marketplace](https://marketplace.visualstudio.com/items?itemName=tpsTech.diff-commit) or: @@ -56,14 +70,17 @@ Install the extension directly from the [Visual Studio Code Marketplace](https:/ ## Typical Workflow -1. Stage the changes that you wish to commit as normal -2. Run the command: "DiffCommit: Generate Commit Message" (Cmd/Ctrl + KCmd/Ctrl + G) -3. Monitor progress in the notification area as the extension: +1. **Choose your provider** (first-time setup): + - For **Anthropic**: Set up your API key using "DiffCommit: Update stored API Key" + - For **Ollama**: Configure your local model using "DiffCommit: Configure Ollama Model" +2. Stage the changes that you wish to commit as normal +3. Run the command: "DiffCommit: Generate Commit Message" (Cmd/Ctrl + KCmd/Ctrl + G) +4. Monitor progress in the notification area as the extension: - Retrieves the git diff - - Validates your API key + - Validates your configuration (API key or Ollama connection) - Generates the commit message -4. Review the generated message in the Source Control message input -5. Click 'Commit' to commit the changes with the generated message +5. Review the generated message in the Source Control message input +6. Click 'Commit' to commit the changes with the generated message ## Commands @@ -111,10 +128,50 @@ This will delete the stored API key from your system and prevent DiffCommit from ![Demo video of Diff Commit: Delete API Key](screenshots/diff-commit-cmd-delete.gif) +### `DiffCommit: Configure Ollama Model` + +This command allows you to set up Ollama as your provider for the first time or switch from Anthropic to Ollama. It will: + +1. Prompt you to enter the Ollama server hostname (defaults to `http://localhost:11434`) +2. Test the connection to your Ollama server +3. Display available models for selection +4. Set the provider to Ollama and save your configuration + +> [!NOTE] +> Before using this command, ensure that: +> - Ollama is installed and running on your system +> - You have pulled at least one model (eg `ollama pull llama3.2`) + +### `DiffCommit: Change Ollama Model` + +This command allows you to change your currently selected Ollama model without changing the hostname. It will: + +1. Connect to your configured Ollama server +2. Display available models for selection +3. Update your model configuration + +> [!TIP] +> Use this command when you want to try a different local model or have pulled new models to your Ollama installation. + ## Configuration DiffCommit provides the following settings to customise its behavior. +### `diffCommit.provider` + +Choose the AI provider for generating commit messages. You can use either Anthropic's cloud-based models or local Ollama models. + +| **Type** | **Options** | **Default Value** | +| :------- | :---------- | :---------------- | +| enum | "anthropic" | "anthropic" | +| | "ollama" | | + +- **"anthropic"**: Use Anthropic's Claude models (requires API key and internet connection) +- **"ollama"**: Use local Ollama models (requires Ollama server to be running, works offline) + +> [!TIP] +> Use **Anthropic** for the most advanced language understanding and consistency, or **Ollama** for complete privacy and offline functionality. + ### `diffCommit.allowedTypes` A list of allowed commit types. If provided, this replaces the default options. You need to ensure all required commit types are included in the list. @@ -136,7 +193,7 @@ Add additional custom instructions to the commit generation prompt. Useful for p ### `diffCommit.model` -The Anthropic AI model to use for generating commit messages. +The Anthropic AI model to use for generating commit messages. This setting is only used when the provider is set to "anthropic". | **Type** | **Options** | **Default Value** | | :------- | :------------------------- | :------------------ | @@ -147,6 +204,31 @@ The Anthropic AI model to use for generating commit messages. | | "claude-opus-4-0" | | | | "claude-3-opus-latest" | | +### `diffCommit.ollamaHostname` + +The URL of the local Ollama server. This setting is only used when the provider is set to "ollama". + +| **Type** | **Description** | **Default Value** | +| :------- | :--------------------------------------------------- | :----------------------- | +| string | The hostname URL where your Ollama server is running | "http://localhost:11434" | + +> [!NOTE] +> Most users won't need to change this unless running Ollama on a different port or remote server. + +### `diffCommit.ollamaModel` + +The Ollama model to use for generating commit messages. This setting is only used when the provider is set to "ollama". + +| **Type** | **Description** | **Default Value** | +| :------- | :---------------------------------------------------------------------- | :---------------- | +| string | The name of the Ollama model (e.g., "llama3.2", "mistral", "codellama") | "" | + +> [!TIP] +> Use the "DiffCommit: Configure Ollama Model" command to easily set this up. Popular models for code tasks include: +> - **llama3.2**: General-purpose model with good reasoning +> - **mistral**: Fast and efficient for most tasks +> - **codellama**: Specialised for code-related tasks + ### `diffCommit.maxTokens` Maximum number of tokens to generate in the response. Higher values allow for longer commit messages but use more API tokens. @@ -172,24 +254,32 @@ Controls randomness in the response. Lower values (like 0.3) produce more focuse Diff Commit provides clear feedback for various scenarios to help troubleshoot issues. Here are the common messages you might encounter: -| | **Error** | **Description** | -| :----------------------- | :--------------------------------------------------------------- | :------------------------------------------------------------ | -| **Git Related** | Git extension not found in VSCode | VSCode's Git extension is not installed or enabled | -| | No Git repository found in the current workspace | The current workspace is not a Git repository | -| | No workspace folder found | No workspace is open in VSCode | -| | No changes detected | No staged changes found to generate a commit message for | -| **API Key Related** | API key is missing or not provided | No API key has been set | -| | Invalid API key format (should start with sk-ant-api) | The provided API key doesn't match the expected format | -| | Failed to access or update secure storage | Unable to store or retrieve the API key from secure storage | -| **Anthropic API Errors** | Bad Request: Review your prompt and try again | Invalid request to the Anthropic API (400) | -| | Unauthorized: Invalid API key, update your API key and try again | The API key is invalid or expired (401) | -| | Forbidden: Permission denied, update your API key and try again | The API key doesn't have permission for this operation (403) | -| | Rate Limited: Too many requests, try again later | You've exceeded your API rate limit (429) | -| | Server Error: Anthropic API server error | An error occurred on Anthropic's servers (500) | -| | Server Error: Anthropic Server Overloaded | The Anthropic API server is overloaded (529) | -| **Other Errors** | Failed to write commit message to Source Control | Unable to update the SCM input box with the generated message | -| | Failed to open commit message preview | Unable to open the preview editor | -| | No commit message was generated by the API | The API response didn't contain a valid commit message | +| | **Error** | **Description** | +| :------------------------ | :------------------------------------------------------------------------------- | :------------------------------------------------------------ | +| **Git Related** | Git extension not found in VSCode | VSCode's Git extension is not installed or enabled | +| | No Git repository found in the current workspace | The current workspace is not a Git repository | +| | No workspace folder found | No workspace is open in VSCode | +| | No changes detected | No staged changes found to generate a commit message for | +| **API Key Related** | API key is missing or not provided | No API key has been set for Anthropic provider | +| | Invalid API key format (should start with sk-ant-api) | The provided API key doesn't match the expected format | +| | Failed to access or update secure storage | Unable to store or retrieve the API key from secure storage | +| **Anthropic API Errors** | Bad Request: Review your prompt and try again | Invalid request to the Anthropic API (400) | +| | Unauthorized: Invalid API key, update your API key and try again | The API key is invalid or expired (401) | +| | Forbidden: Permission denied, update your API key and try again | The API key doesn't have permission for this operation (403) | +| | Rate Limited: Too many requests, try again later | You've exceeded your API rate limit (429) | +| | Server Error: Anthropic API server error | An error occurred on Anthropic's servers (500) | +| | Server Error: Anthropic Server Overloaded | The Anthropic API server is overloaded (529) | +| **Ollama Related Errors** | Unable to connect to Ollama server | Ollama server is not running or not accessible | +| | Ollama server not found | Invalid hostname or Ollama is not installed | +| | No Ollama model selected. Please configure an Ollama model first | No Ollama model has been configured for the extension | +| | Ollama model not specified | No Ollama model has been configured or selected | +| | Model '[model-name]' not found. Please check if the model is available in Ollama | The specified model is not available in Ollama | +| | No models found on the Ollama server | No models have been pulled to the Ollama installation | +| | Invalid hostname URL | The provided Ollama hostname is not a valid URL | +| | Ollama server error | An error occurred on the Ollama server (500) | +| **Other Errors** | Failed to write commit message to Source Control | Unable to update the SCM input box with the generated message | +| | Failed to open commit message preview | Unable to open the preview editor | +| | No commit message was generated by the API | The API response didn't contain a valid commit message | Each error includes specific guidance on how to resolve the issue. For persistent problems, please check your configuration and if appropriate, raise an issue on [GitHub](https://github.com/tsdevau/diff-commit/issues). From 2b8f6163c5b349a379aef5b63fb6d9d1bc7a62b5 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 22:49:13 +1000 Subject: [PATCH 12/15] refactor(constructor): replace arguments with typed rest parameters Refactors the CommitMessageGenerator constructor to use TypeScript's rest parameters with proper type annotations instead of accessing the arguments object directly. This improves type safety and code readability while maintaining the same functionality for both Anthropic and Ollama constructor overloads. --- src/commitMessageGenerator.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/commitMessageGenerator.ts b/src/commitMessageGenerator.ts index e44c667..2fd804d 100644 --- a/src/commitMessageGenerator.ts +++ b/src/commitMessageGenerator.ts @@ -17,27 +17,22 @@ export class CommitMessageGenerator { // Constructor overload for Ollama (hostname and model) constructor(hostname: string, ollamaModel: string) // Implementation - constructor() { + constructor(...args: [string] | [string, string]) { try { - if ( - arguments.length === 1 && - arguments[0] && - typeof arguments[0] === "string" && - arguments[0].startsWith("sk-") - ) { + if (args.length === 1 && args[0] && typeof args[0] === "string" && args[0].startsWith("sk-")) { // Anthropic constructor - this.apiKey = arguments[0] + this.apiKey = args[0] } else if ( - arguments.length === 2 && - arguments[0] && - typeof arguments[0] === "string" && - arguments[0].startsWith("http") && - arguments[1] && - typeof arguments[1] === "string" + args.length === 2 && + args[0] && + typeof args[0] === "string" && + args[0].startsWith("http") && + args[1] && + typeof args[1] === "string" ) { // Ollama constructor - this.ollamaHost = arguments[0] - this.ollamaModel = arguments[1] + this.ollamaHost = args[0] + this.ollamaModel = args[1] } else { throw new Error( "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", From e4142e992f65ae04d13dc0da30ec5bb7ebdcc688 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 22:50:06 +1000 Subject: [PATCH 13/15] fix(ollamaManager): trim trailing slashes from Ollama hostname URL --- src/ollamaManager.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ollamaManager.ts b/src/ollamaManager.ts index e3083ed..6b8fc7b 100644 --- a/src/ollamaManager.ts +++ b/src/ollamaManager.ts @@ -1,4 +1,3 @@ -import console from "console" import { Ollama } from "ollama" import { window, workspace } from "vscode" @@ -23,7 +22,7 @@ export class OllamaManager { try { const newHost = new URL(inputHostname) - hostname = newHost.toString() + hostname = newHost.toString().replace(/\/+$/, "") } catch (error) { window.showErrorMessage("Invalid hostname URL. Please enter a valid URL (eg http://localhost:11434).") return this.ollamaModelConfig(includeHostnameSelection) From cd987f8dd4db52005b4bea47b744dc038040b0d8 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 22:50:33 +1000 Subject: [PATCH 14/15] fix(config): remove trailing parenthesis in Anthropic model description --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 28cd841..d15ce23 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "claude-opus-4-0", "claude-3-opus-latest" ], - "description": "The Anthropic AI model to use for generating commit messages. This value is only used when provider is set as 'anthropic')." + "description": "The Anthropic AI model to use for generating commit messages. This value is only used when provider is set as 'anthropic'." }, "diffCommit.ollamaHostname": { "type": "string", From 3d8d3c36e441216d6059f38e360d2d2063eca8d6 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 22:54:49 +1000 Subject: [PATCH 15/15] tests(ollama): remove trailing slash from Ollama server URL in tests The commit removes the trailing slash from the Ollama server URL in test expectations to ensure consistency in how the server address is referenced. This fixes potential issues with URL handling and ensures that error messages and configuration updates use the same URL format. --- test/ollamaManager.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/ollamaManager.test.ts b/test/ollamaManager.test.ts index 12d5305..e7789fe 100644 --- a/test/ollamaManager.test.ts +++ b/test/ollamaManager.test.ts @@ -110,7 +110,7 @@ describe("OllamaManager", () => { expect(result).toBe(true) expect(mockConfigUpdate).toHaveBeenCalledWith("provider", "ollama", true) - expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaHostname", "http://localhost:11434/", true) + expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaHostname", "http://localhost:11434", true) expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaModel", "llama3.2", true) expect(mockSetStatusBarMessage).toHaveBeenCalledWith("✓ Ollama model 'llama3.2' selected successfully", 4000) }) @@ -173,7 +173,7 @@ describe("OllamaManager", () => { expect(result).toBe(false) expect(mockShowErrorMessage).toHaveBeenCalledWith( - "Unable to connect to Ollama server at http://localhost:11434/. Please ensure that the Ollama server is running and accessible.", + "Unable to connect to Ollama server at http://localhost:11434. Please ensure that the Ollama server is running and accessible.", ) }) @@ -185,7 +185,7 @@ describe("OllamaManager", () => { expect(result).toBe(false) expect(mockShowErrorMessage).toHaveBeenCalledWith( - "Unable to connect to Ollama server at http://localhost:11434/. Please ensure that the Ollama server is running and accessible.", + "Unable to connect to Ollama server at http://localhost:11434. Please ensure that the Ollama server is running and accessible.", ) }) @@ -197,7 +197,7 @@ describe("OllamaManager", () => { expect(result).toBe(false) expect(mockShowErrorMessage).toHaveBeenCalledWith( - "Ollama server not found at http://localhost:11434/. Please check the hostname and try again.", + "Ollama server not found at http://localhost:11434. Please check the hostname and try again.", ) })