diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 77859b2..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" - }, - "plugins": ["@typescript-eslint"], - "rules": { - "@typescript-eslint/naming-convention": "off", - "@typescript-eslint/semi": "warn", - "curly": "warn", - "eqeqeq": "warn", - "no-throw-literal": "warn", - "semi": "off" - }, - "ignorePatterns": ["out", "dist", "**/*.d.ts"] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6903c3b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['18.x', '20.x'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm install --no-audit --no-fund + - name: Lint + run: npm run lint + - name: Type-check (strict) + run: npm run typecheck + - name: Unit tests + run: npm run test:unit + - name: Build extension bundle + run: npm run build diff --git a/.vscodeignore b/.vscodeignore index 3899967..4eecbe8 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,10 +1,17 @@ .vscode/** .vscode-test/** +.github/** src/** +test/** +coverage/** +out/** .gitignore +.npmrc +.prettierrc .yarnrc vsc-extension-quickstart.md **/tsconfig.json -**/.eslintrc.json +**/eslint.config.mjs +**/vitest.config.ts **/*.map **/*.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..923bb85 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to **AI Commit** are documented here. + +## Unreleased + +### Added + +- **Pluggable Provider architecture** — `LLMProvider` interface, `AbstractLLMProvider` base class, and factory dispatch. Adding a new provider is now a single new file plus one factory entry. +- **Ollama Provider** — first-class local model support via `http://localhost:11434/v1`. Configurable model, temperature, and base URL. +- **OpenAI-compatible presets** — new command `AI Commit: Use OpenAI-Compatible Preset` for DeepSeek, Zhipu GLM, Qwen (DashScope), Groq, and OpenRouter (one-click `OPENAI_BASE_URL` + recommended model + API key prompt). +- **Streaming output** — generation streams into the SCM input box character-by-character. Toggle with `STREAMING_ENABLED` (default on). +- **Real cancellation** — the progress notification's Cancel button now aborts the in-flight API request via `AbortController` bridged to `CancellationToken`. +- **SecretStorage migration** — API keys are stored in VS Code's OS-backed SecretStorage. One-time migration of existing settings runs on activation. +- **New command `AI Commit: Set API Key`** — interactive picker + password input. +- **Diff processor** — built-in exclusion of lock files (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `Cargo.lock`, `Pipfile.lock`, `poetry.lock`, `composer.lock`, `Gemfile.lock`, `go.sum`, `bun.lockb`), `dist/`, `build/`, `out/`, `.next/`, `node_modules/`, and `*.min.{js,css,map}`. Token-budgeted truncation prevents context overruns. Customizable via `DIFF_MAX_TOKENS`, `DIFF_EXCLUDE_PATTERNS`, `DIFF_INCLUDE_DEFAULT_EXCLUDES`. +- **Unit test suite** — `vitest` with 36 tests covering providers, diff processor, presets, redaction, and the `` cleaner. +- **CI workflow** — GitHub Actions matrix (Node 18.x, 20.x) running lint, typecheck, unit tests, and build on push/PR. +- **TypeScript strict mode** — `strict: true`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, `forceConsistentCasingInFileNames`. +- **Logger secret redaction** — common API key prefixes (`sk-`, `sk-ant-`, `AIza`, `gsk_`, `xai-`) are masked in Output channel logs. + +### Fixed + +- **Gemini system prompt loss** — previously the system message was flattened into `sendMessage` and silently dropped. Now passed as `systemInstruction` per the official Google SDK contract. Regression test added. +- **Bogus null return value in `git-utils`** — `error: null` (which conflicted with the `error?: string` signature) is removed; success path now omits `error` entirely. +- **Conflicting `resolutions["@types/node"]: 16.x`** — removed; the project's `devDependencies` `@types/node: 25.x` now takes effect. +- **Empty OpenAI response handling** — non-null assertion replaced with an explicit empty-response guard. +- **Retry loop** — `Failed` modal now caps Retry attempts at 3 per command, then prompts the user to set the API key or open settings. +- **Activation event** — switched from the never-triggered `onCommand:ai-commit` to `onStartupFinished` so the SCM button is active when the user opens a repo. + +### Changed + +- `tsconfig` target & lib bumped to `ES2022` (required for `Error({ cause })`). +- ESLint migrated from `.eslintrc.json` to flat `eslint.config.mjs` (ESLint v10 compatibility). +- `engines.node` raised to `>= 18`. +- `Error` instances thrown from provider failures now attach the original error as `cause`. +- The deprecated settings-based API key fields (`OPENAI_API_KEY`, `CLAUDE_API_KEY`, `GEMINI_API_KEY`) are kept for back-compat but marked deprecated in descriptions. + +## 0.1.2 + +Last published version under the original architecture. See git history. diff --git a/README.md b/README.md index 7431fee..5ff42e1 100644 --- a/README.md +++ b/README.md @@ -27,58 +27,82 @@ Use OpenAI / Azure OpenAI / DeepSeek / Grok / Gemini / Claude (Anthropic) API to ## ✨ Features -- 🤯 Support generating commit messages based on git diffs using OpenAI / Azure OpenAI / DeepSeek / Grok / Gemini / Claude (Anthropic) API. -- 🧠 Support OpenAI Responses API with configurable reasoning effort and output verbosity. -- 🗺️ Support multi-language commit messages. -- 😜 Support adding Gitmoji. -- 🛠️ Support custom system prompt. -- 📝 Support Conventional Commits specification. +- 🔌 **Pluggable providers** — OpenAI (Chat Completions & Responses API), Anthropic Claude, Google Gemini, and **local Ollama** out of the box. +- 🚀 **OpenAI-compatible presets** — one click to switch to DeepSeek / Zhipu GLM / Qwen (DashScope) / Groq / OpenRouter via `AI Commit: Use OpenAI-Compatible Preset`. +- 📡 **Streaming output** — commit message is written into the SCM input box character-by-character as it generates. +- ✋ **Real cancellation** — pressing the progress notification's Cancel button truly aborts the in-flight API request (`AbortController`). +- 🔐 **SecretStorage** — API keys live in VS Code's OS-backed secret store, not your `settings.json`. Existing settings are migrated automatically. +- 🧹 **Smart diff** — lock files, `dist/`, `build/`, `.min.*`, and other generated files are excluded by default; oversized diffs are auto-truncated to a configurable token budget. +- 🧠 **Responses API** — configurable reasoning effort and output verbosity. +- 🌐 **19 languages** for commit message output. +- 😜 **Gitmoji** + Conventional Commits, plus custom system prompts. ## 📦 Installation -1. Search for "AI Commit" in VSCode and click the "Install" button. -2. Install it directly from the [Visual Studio Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Sitoi.ai-commit). +1. Search for "AI Commit" in VSCode and click "Install". +2. Or install from the [Visual Studio Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Sitoi.ai-commit). -> **Note**\ -> Make sure your node version >= 16 +> **Note**: Requires Node.js >= 18 and VS Code >= 1.77. ## 🤯 Usage -1. Ensure that you have installed and enabled the "AI Commit" extension. -2. In VSCode settings, locate the "ai-commit" configuration options and configure them as needed. -3. Make changes in your project and add the changes to the staging area (git add). -4. (Optional) If you want to provide additional context for the commit message, type it in the Source Control panel's message input box before clicking the AI Commit button. -5. Next to the commit message input box in the "Source Control" panel, click the "AI Commit" icon button. After clicking, the extension will generate a commit message (considering any additional context if provided) and populate it in the input box. -6. Review the generated commit message, and if you are satisfied, proceed to commit your changes. +1. Run `AI Commit: Set API Key` from the Command Palette and pick a provider. The key is stored in SecretStorage. +2. (Optional) For non-OpenAI vendors that share the OpenAI protocol, run `AI Commit: Use OpenAI-Compatible Preset` to pre-fill the base URL and a recommended model. +3. Stage some changes (`git add ...`). +4. Click the **AI Commit** icon in the Source Control panel header. +5. Watch the commit message stream into the input box. Review, edit if needed, commit. -> **Note**\ -> If the code exceeds the maximum token length, consider adding it to the staging area in batches. +## 🛠️ Commands + +| Command | Purpose | +| --- | --- | +| `AI Commit` (SCM title button) | Generate a commit message from staged changes | +| `AI Commit: Set API Key` | Store an OpenAI/Claude/Gemini key in SecretStorage | +| `AI Commit: Use OpenAI-Compatible Preset` | Pre-configure DeepSeek/Zhipu/Qwen/Groq/OpenRouter | +| `AI Commit: Show Available OpenAI Models` | Pick from `/v1/models` | + +## 🧪 Diff handling + +| Built-in exclude | Pattern | +| --- | --- | +| Lock files | `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `Cargo.lock`, `Pipfile.lock`, `poetry.lock`, `composer.lock`, `Gemfile.lock`, `go.sum`, `bun.lockb` | +| Generated output | `dist/`, `build/`, `out/`, `.next/`, `node_modules/` | +| Minified | `*.min.{js,css,map}` | + +Customize via `ai-commit.DIFF_EXCLUDE_PATTERNS` (additional regexes) and `ai-commit.DIFF_INCLUDE_DEFAULT_EXCLUDES` (toggle defaults). Token budget is controlled by `ai-commit.DIFF_MAX_TOKENS` (default `8000`). ### ⚙️ Configuration -> **Note** Version >= 0.0.5 Don't need to configure `EMOJI_ENABLED` and `FULL_GITMOJI_SPEC`, Default Prompt is [prompt/with_gitmoji.md](./prompt/with_gitmoji.md), If don't need to use `Gitmoji`. Please set `SYSTEM_PROMPT` to your custom prompt, please refer to [prompt/without_gitmoji.md](./prompt/without_gitmoji.md). +> The Gitmoji-enabled prompt is built in; to disable Gitmoji or customize the output format, paste a custom prompt into `AI_COMMIT_SYSTEM_PROMPT` (see [prompt/with_gitmoji.md](./prompt/with_gitmoji.md) and [prompt/without_gitmoji.md](./prompt/without_gitmoji.md) for reference templates). In the VSCode settings, locate the "ai-commit" configuration options and configure them as needed: -| Configuration | Type | Default | Required | Notes | -| :---------------------- | :----: | :------------------------: | :------: | :--------------------------------------------------------------------------------------------------------------------------------: | -| AI_PROVIDER | string | openai | Yes | AI Provider to use: `openai`, `gemini`, or `claude` | -| OPENAI_API_KEY | string | None | Yes | Required when `AI_PROVIDER` is `openai`. [OpenAI token](https://platform.openai.com/account/api-keys) | -| OPENAI_BASE_URL | string | None | No | If using Azure, use: `https://{resource}.openai.azure.com/openai/deployments/{model}` | -| OPENAI_MODEL | string | gpt-4o | Yes | OpenAI model. Run the `Show Available OpenAI Models` command to pick from available models | -| AZURE_API_VERSION | string | None | No | Azure API version string | -| OPENAI_TEMPERATURE | number | 0.7 | No | Controls randomness. Range: 0–2. Lower = more focused, Higher = more creative. (Chat Completions only) | -| OPENAI_API_TYPE | string | completion | No | Choose API: `completion` (Chat Completions) or `response` (Responses API) | -| OPENAI_REASONING_EFFORT | string | medium | No | Reasoning effort for Responses API: `minimal`, `low`, `medium`, `high`. Only applies when `OPENAI_API_TYPE` is `response` | -| OPENAI_TEXT_VERBOSITY | string | medium | No | Output verbosity for Responses API: `low` (~1000 tokens), `medium` (~4000 tokens), `high` (~16000 tokens) | -| GEMINI_API_KEY | string | None | Yes | Required when `AI_PROVIDER` is `gemini`. [Gemini API key](https://makersuite.google.com/app/apikey) | -| GEMINI_MODEL | string | gemini-2.0-flash-001 | Yes | Gemini model to use | -| GEMINI_TEMPERATURE | number | 0.7 | No | Controls randomness. Range: 0–2. Lower = more focused, Higher = more creative | -| CLAUDE_API_KEY | string | None | No | Anthropic API key. Leave empty to use Claude CLI (authenticated via `claude setup-token`). Required when `AI_PROVIDER` is `claude` | -| CLAUDE_MODEL | string | claude-sonnet-4-5-20250929 | No | Claude model to use | -| CLAUDE_TEMPERATURE | number | 0.7 | No | Controls randomness. Range: 0–1 | -| AI_COMMIT_LANGUAGE | string | English | Yes | Supports 19 languages | -| SYSTEM_PROMPT | string | None | No | Custom system prompt | +| Configuration | Type | Default | Notes | +| ----------------------------------- | --------- | ----------------------------- | ----- | +| `AI_PROVIDER` | string | `openai` | `openai` / `gemini` / `claude` / `ollama` | +| `OPENAI_API_KEY` | string | `""` | Deprecated — prefer `AI Commit: Set API Key` (SecretStorage) | +| `OPENAI_BASE_URL` | string | `""` | Azure or OpenAI-compatible vendor (DeepSeek/Zhipu/Qwen/Groq/OpenRouter). Use the preset command for one-click setup | +| `OPENAI_MODEL` | string | `gpt-4o` | Run `AI Commit: Show Available OpenAI Models` to discover | +| `AZURE_API_VERSION` | string | `""` | Azure API version | +| `OPENAI_TEMPERATURE` | number | `0.7` | 0–2; Chat Completions only | +| `OPENAI_API_TYPE` | enum | `completion` | `completion` or `response` (Responses API) | +| `OPENAI_REASONING_EFFORT` | enum | `medium` | `minimal`/`low`/`medium`/`high` (Responses API) | +| `OPENAI_TEXT_VERBOSITY` | enum | `medium` | Maps to max output tokens (Responses API) | +| `GEMINI_API_KEY` | string | `""` | Deprecated — use `AI Commit: Set API Key` | +| `GEMINI_MODEL` | string | `gemini-2.0-flash-001` | | +| `GEMINI_TEMPERATURE` | number | `0.7` | 0–2 | +| `CLAUDE_API_KEY` | string | `""` | Deprecated — use `AI Commit: Set API Key` | +| `CLAUDE_MODEL` | string | `claude-sonnet-4-5-20250929` | | +| `CLAUDE_TEMPERATURE` | number | `0.7` | 0–1 | +| `OLLAMA_BASE_URL` | string | `http://localhost:11434/v1` | Local Ollama OpenAI-compatible endpoint | +| `OLLAMA_MODEL` | string | `llama3.2` | Any pulled local model | +| `OLLAMA_TEMPERATURE` | number | `0.7` | 0–2 | +| `STREAMING_ENABLED` | boolean | `true` | Stream output into the SCM input box | +| `DIFF_MAX_TOKENS` | number | `8000` | Approx token budget for the diff | +| `DIFF_EXCLUDE_PATTERNS` | string[] | `[]` | Extra regex patterns to exclude (added to defaults) | +| `DIFF_INCLUDE_DEFAULT_EXCLUDES` | boolean | `true` | Toggle the built-in exclude list | +| `AI_COMMIT_LANGUAGE` | enum | `English` | 19 supported languages | +| `AI_COMMIT_SYSTEM_PROMPT` | string | `""` | Custom system prompt that overrides the default | ## ⌨️ Local Development @@ -92,9 +116,21 @@ Alternatively, you can clone the repository and run the following commands for l $ git clone https://github.com/sitoi/ai-commit.git $ cd ai-commit $ npm install +$ npm run verify # typecheck + unit tests +$ npm run build # webpack production bundle ``` -Open the project folder in VSCode. Press F5 to run the project. This will open a new Extension Development Host window and launch the plugin within it. +Open the project folder in VSCode. Press F5 to run the project. This opens a new Extension Development Host window with the plugin loaded. + +### Running tests + +```bash +$ npm run test:unit # vitest run +$ npm run test:unit:watch # watch mode +$ npm run test:coverage # with v8 coverage report +$ npm run lint # eslint flat config +$ npm run typecheck # strict tsc +``` ## 🤝 Contributing diff --git a/README.zh_CN.md b/README.zh_CN.md index 9a76cf8..ee0e611 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -27,58 +27,80 @@ ## ✨ 特性 -- 🤯 支持使用 OpenAI / Azure OpenAI / DeepSeek / Grok / Gemini / Claude (Anthropic) API 根据 git diffs 自动生成提交信息 -- 🧠 支持 OpenAI Responses API,可配置推理强度(reasoning effort)和输出详细程度 -- 🗺️ 支持多语言提交信息 -- 😜 支持添加 Gitmoji -- 🛠️ 支持自定义系统提示词 -- 📝 支持 Conventional Commits 规范 +- 🔌 **可插拔的 Provider 架构** —— 内置 OpenAI(Chat Completions 与 Responses API)、Anthropic Claude、Google Gemini、本地 **Ollama** +- 🚀 **OpenAI 兼容预设** —— 一键切换到 DeepSeek / 智谱 GLM / 通义 (DashScope) / Groq / OpenRouter,命令 `AI Commit: Use OpenAI-Compatible Preset` +- 📡 **流式输出** —— 提交信息以"打字机"形式逐字写入 SCM 输入框 +- ✋ **真正的取消** —— 点击进度通知上的取消按钮会通过 `AbortController` 立即中止 API 请求 +- 🔐 **SecretStorage** —— API key 存入 VSCode 系统密钥库,不再写在 `settings.json`,旧设置启动时自动迁移 +- 🧹 **智能 diff** —— 默认排除 lock 文件、`dist/`、`build/`、`.min.*` 等噪声;超大 diff 自动按 token 预算截断 +- 🧠 **Responses API** —— 可配置 reasoning effort 与输出 verbosity +- 🌐 **19 种语言**的提交信息 +- 😜 **Gitmoji** + Conventional Commits + 自定义系统提示词 ## 📦 安装 -1. 在 VSCode 中搜索 "AI Commit" 并点击 "Install" 按钮。 -2. 从 [Visual Studio Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Sitoi.ai-commit) 直接安装。 +1. 在 VSCode 中搜索 "AI Commit" 并点击 "Install"。 +2. 或从 [Visual Studio Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Sitoi.ai-commit) 安装。 -> **Note**\ -> 请确保 Node.js 版本 >= 16 +> **Note**:需要 Node.js >= 18,VS Code >= 1.77。 ## 🤯 使用 -1. 确保您已经安装并启用了 `AI Commit` 扩展。 -2. 在 `VSCode` 设置中,找到 "ai-commit" 配置项,并根据需要进行配置: -3. 在项目中进行更改并将更改添加到暂存区 (git add)。 -4. (可选) 如果您想为提交消息提供额外的上下文,请在点击 AI Commit 按钮之前,在源代码管理面板的消息输入框中输入上下文。 -5. 在 `Source Control` 面板的提交消息输入框旁边,单击 `AI Commit` 图标按钮。点击后,扩展将生成 Commit 信息(如果提供了额外上下文,将会考虑在内)并填充到输入框中。 -6. 审核生成的 Commit 信息,如果满意,请提交更改。 +1. 在命令面板运行 `AI Commit: Set API Key` 选择 Provider 输入密钥,密钥会存入 SecretStorage。 +2. (可选)对于使用 OpenAI 协议的国产 / 第三方模型,运行 `AI Commit: Use OpenAI-Compatible Preset` 一键配置 baseURL + 推荐模型。 +3. 暂存改动 (`git add ...`)。 +4. 点击源代码管理面板标题栏的 **AI Commit** 图标按钮。 +5. 观察提交信息流式写入输入框,按需修改后提交。 -> **Note**\ -> 如果超过最大 token 长度请分批将代码添加到暂存区。 +## 🛠️ 命令 + +| 命令 | 用途 | +| --- | --- | +| `AI Commit`(SCM 标题按钮) | 根据暂存改动生成提交信息 | +| `AI Commit: Set API Key` | 把 OpenAI / Claude / Gemini 的密钥写入 SecretStorage | +| `AI Commit: Use OpenAI-Compatible Preset` | 一键配置 DeepSeek / 智谱 / 通义 / Groq / OpenRouter | +| `AI Commit: Show Available OpenAI Models` | 从 `/v1/models` 拉取并选择模型 | + +## 🧪 Diff 处理 + +| 内置排除 | 模式 | +| --- | --- | +| Lock 文件 | `package-lock.json`、`pnpm-lock.yaml`、`yarn.lock`、`Cargo.lock`、`Pipfile.lock`、`poetry.lock`、`composer.lock`、`Gemfile.lock`、`go.sum`、`bun.lockb` | +| 生成产物 | `dist/`、`build/`、`out/`、`.next/`、`node_modules/` | +| 压缩文件 | `*.min.{js,css,map}` | + +可以通过 `ai-commit.DIFF_EXCLUDE_PATTERNS`(追加正则)和 `ai-commit.DIFF_INCLUDE_DEFAULT_EXCLUDES`(关掉默认)来定制。Token 预算由 `ai-commit.DIFF_MAX_TOKENS` 控制(默认 `8000`)。 ### ⚙️ 配置 -> **Note** Version >= 0.0.5 不需要配置 `EMOJI_ENABLED` 和 `FULL_GITMOJI_SPEC`,默认提示词为 [prompt/without_gitmoji.md](./prompt/with_gitmoji.md),如果不需要使用 `Gitmoji`,请将 `SYSTEM_PROMPT` 设置为您的自定义提示词, 请参考 [prompt/without_gitmoji.md](./prompt/without_gitmoji.md)。 - -在 `VSCode` 设置中,找到 "ai-commit" 配置项,并根据需要进行配置 - -| 配置 | 类型 | 默认 | 必要 | 备注 | -| :---------------------- | :----: | :------------------------: | :--: | :-------------------------------------------------------------------------------------------------------------: | -| AI_PROVIDER | string | openai | Yes | 选择 AI 提供商:`openai`、`gemini` 或 `claude` | -| OPENAI_API_KEY | string | None | 是 | [OpenAI 令牌](https://platform.openai.com/account/api-keys) | -| OPENAI_BASE_URL | string | None | 否 | 如果使用 Azure,填入:`https://{resource}.openai.azure.com/openai/deployments/{model}` | -| OPENAI_MODEL | string | gpt-4o | 是 | OpenAI 模型,可通过运行 `Show Available OpenAI Models` 命令从列表中选择 | -| AZURE_API_VERSION | string | None | 否 | Azure API 版本号 | -| OPENAI_TEMPERATURE | number | 0.7 | 否 | 控制输出随机性。范围:0–2。较低:更集中,较高:更有创造性(仅 Chat Completions) | -| OPENAI_API_TYPE | string | completion | 否 | 选择 API 类型:`completion`(Chat Completions)或 `response`(Responses API) | -| OPENAI_REASONING_EFFORT | string | medium | 否 | Responses API 推理强度:`minimal`、`low`、`medium`、`high`。仅在 `OPENAI_API_TYPE` 为 `response` 时生效 | -| OPENAI_TEXT_VERBOSITY | string | medium | 否 | Responses API 输出详细程度:`low`(~1000 tokens)、`medium`(~4000 tokens)、`high`(~16000 tokens) | -| GEMINI_API_KEY | string | None | 是 | `AI_PROVIDER` 为 `gemini` 时必填。[Gemini API key](https://makersuite.google.com/app/apikey) | -| GEMINI_MODEL | string | gemini-2.0-flash-001 | 是 | Gemini 使用的模型 | -| GEMINI_TEMPERATURE | number | 0.7 | 否 | 控制输出随机性。范围:0–2。较低:更集中,较高:更有创造性 | -| CLAUDE_API_KEY | string | None | 否 | Anthropic API 密钥。留空可使用 Claude CLI(通过 `claude setup-token` 认证)。`AI_PROVIDER` 为 `claude` 时需配置 | -| CLAUDE_MODEL | string | claude-sonnet-4-5-20250929 | 否 | Claude 使用的模型 | -| CLAUDE_TEMPERATURE | number | 0.7 | 否 | 控制输出随机性。范围:0–1 | -| AI_COMMIT_LANGUAGE | string | English | 是 | 支持 19 种语言 | -| SYSTEM_PROMPT | string | None | 否 | 自定义系统提示词 | +> 默认提示词已内置 Gitmoji;若要关闭 Gitmoji 或自定义输出格式,把自定义内容贴到 `AI_COMMIT_SYSTEM_PROMPT`(参考模板见 [prompt/with_gitmoji.md](./prompt/with_gitmoji.md) 和 [prompt/without_gitmoji.md](./prompt/without_gitmoji.md))。 + +| 配置项 | 类型 | 默认值 | 备注 | +| ----------------------------------- | --------- | ----------------------------- | ---- | +| `AI_PROVIDER` | string | `openai` | `openai` / `gemini` / `claude` / `ollama` | +| `OPENAI_API_KEY` | string | `""` | 已废弃 —— 请用 `AI Commit: Set API Key`(SecretStorage) | +| `OPENAI_BASE_URL` | string | `""` | Azure 或 OpenAI 兼容厂商(DeepSeek / 智谱 / 通义 / Groq / OpenRouter)。建议用预设命令一键设置 | +| `OPENAI_MODEL` | string | `gpt-4o` | 运行 `AI Commit: Show Available OpenAI Models` 可从列表选择 | +| `AZURE_API_VERSION` | string | `""` | Azure API 版本 | +| `OPENAI_TEMPERATURE` | number | `0.7` | 0–2;仅 Chat Completions | +| `OPENAI_API_TYPE` | enum | `completion` | `completion` 或 `response`(Responses API) | +| `OPENAI_REASONING_EFFORT` | enum | `medium` | `minimal`/`low`/`medium`/`high`(仅 Responses API) | +| `OPENAI_TEXT_VERBOSITY` | enum | `medium` | 映射到最大输出 tokens(仅 Responses API) | +| `GEMINI_API_KEY` | string | `""` | 已废弃 —— 请用 `AI Commit: Set API Key` | +| `GEMINI_MODEL` | string | `gemini-2.0-flash-001` | | +| `GEMINI_TEMPERATURE` | number | `0.7` | 0–2 | +| `CLAUDE_API_KEY` | string | `""` | 已废弃 —— 请用 `AI Commit: Set API Key` | +| `CLAUDE_MODEL` | string | `claude-sonnet-4-5-20250929` | | +| `CLAUDE_TEMPERATURE` | number | `0.7` | 0–1 | +| `OLLAMA_BASE_URL` | string | `http://localhost:11434/v1` | 本地 Ollama 的 OpenAI 兼容端点 | +| `OLLAMA_MODEL` | string | `llama3.2` | 任何已拉取的本地模型 | +| `OLLAMA_TEMPERATURE` | number | `0.7` | 0–2 | +| `STREAMING_ENABLED` | boolean | `true` | 流式写入 SCM 输入框 | +| `DIFF_MAX_TOKENS` | number | `8000` | 送给模型的 diff 的最大约略 token 数 | +| `DIFF_EXCLUDE_PATTERNS` | string[] | `[]` | 在内置规则之上额外排除的正则 | +| `DIFF_INCLUDE_DEFAULT_EXCLUDES` | boolean | `true` | 启用内置默认排除列表 | +| `AI_COMMIT_LANGUAGE` | enum | `English` | 支持 19 种语言 | +| `AI_COMMIT_SYSTEM_PROMPT` | string | `""` | 覆盖默认系统提示词的自定义内容 | ## ⌨️ 本地开发 @@ -92,10 +114,22 @@ $ git clone https://github.com/sitoi/ai-commit.git $ cd ai-commit $ npm install +$ npm run verify # 类型检查 + 单元测试 +$ npm run build # webpack 生产构建 ``` 在 VSCode 中打开项目文件夹。按 F5 键运行项目。会弹出一个新的 Extension Development Host 窗口,并在其中启动插件。 +### 跑测试 + +```bash +$ npm run test:unit # vitest 单测 +$ npm run test:unit:watch # watch 模式 +$ npm run test:coverage # v8 覆盖率报告 +$ npm run lint # eslint 扁平配置 +$ npm run typecheck # 严格 tsc +``` + ## 🤝 参与贡献 我们非常欢迎各种形式的贡献。如果你对贡献代码感兴趣,可以查看我们的 GitHub [Issues][github-issues-link],大展身手,向我们展示你的奇思妙想。 diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..37a165a --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,42 @@ +import js from '@eslint/js'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; + +export default [ + js.configs.recommended, + { + files: ['src/**/*.ts', 'test/**/*.ts'], + languageOptions: { + parser: tsParser, + ecmaVersion: 2022, + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + fetch: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + URL: 'readonly' + } + }, + plugins: { + '@typescript-eslint': tsPlugin + }, + rules: { + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } + ], + '@typescript-eslint/no-explicit-any': 'warn', + 'no-throw-literal': 'error', + 'eqeqeq': ['error', 'smart'], + 'curly': ['error', 'multi-line'], + 'no-undef': 'off' + } + }, + { + ignores: ['out/**', 'dist/**', '**/*.d.ts', 'node_modules/**'] + } +]; diff --git a/package.json b/package.json index 0314328..d5cfdc2 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "ai-commit", - "displayName": "AI Commit", - "description": "Use OpenAI/Gemini/Claude/Azure API to review Git changes, generate conventional commit messages that meet the conventions, simplify the commit process, and keep the commit conventions consistent.", + "displayName": "%extension.displayName%", + "description": "%extension.description%", "version": "0.1.2", "engines": { - "node": ">=16", + "node": ">=18", "vscode": "^1.77.0" }, "categories": [ @@ -30,7 +30,7 @@ "repository": "https://github.com/sitoi/ai-commit", "icon": "images/logo.png", "activationEvents": [ - "onCommand:ai-commit" + "onStartupFinished" ], "contributes": { "commands": [ @@ -40,11 +40,19 @@ "dark": "images/icon.svg", "light": "images/icon.svg" }, - "title": "AI Commit" + "title": "%command.aiCommit%" }, { "command": "ai-commit.showAvailableModels", - "title": "Show Available OpenAI Models" + "title": "%command.showAvailableModels%" + }, + { + "command": "ai-commit.setApiKey", + "title": "%command.setApiKey%" + }, + { + "command": "ai-commit.useOpenAIPreset", + "title": "%command.useOpenAIPreset%" } ], "configuration": { @@ -103,7 +111,7 @@ }, "ai-commit.OPENAI_API_KEY": { "default": "", - "description": "OpenAI API Key", + "description": "OpenAI API Key (deprecated — prefer the 'AI Commit: Set API Key' command which stores in SecretStorage)", "type": "string" }, "ai-commit.OPENAI_BASE_URL": { @@ -132,7 +140,10 @@ "type": "string", "default": "completion", "description": "Choose OpenAI API: Chat Completions API or Responses API", - "enum": ["completion", "response"], + "enum": [ + "completion", + "response" + ], "enumDescriptions": [ "Chat Completions API (default)", "Responses API (supports reasoning models)" @@ -142,7 +153,12 @@ "type": "string", "default": "medium", "description": "Reasoning effort for the Responses API (only applies when OPENAI_API_TYPE is 'response')", - "enum": ["minimal", "low", "medium", "high"], + "enum": [ + "minimal", + "low", + "medium", + "high" + ], "enumDescriptions": [ "Minimal reasoning effort", "Low reasoning effort", @@ -154,7 +170,11 @@ "type": "string", "default": "medium", "description": "Output verbosity for the Responses API — controls max output tokens: low (~1000), medium (~4000), high (~16000)", - "enum": ["low", "medium", "high"], + "enum": [ + "low", + "medium", + "high" + ], "enumDescriptions": [ "Low verbosity (~1000 tokens)", "Medium verbosity (~4000 tokens, default)", @@ -164,17 +184,24 @@ "ai-commit.AI_PROVIDER": { "type": "string", "default": "openai", - "description": "AI Provider to use (OpenAI, Gemini, or Claude)", + "description": "AI Provider to use", "enum": [ "openai", "gemini", - "claude" + "claude", + "ollama" + ], + "enumDescriptions": [ + "OpenAI (also for Azure / DeepSeek / Zhipu / Qwen / Groq / OpenRouter via OPENAI_BASE_URL)", + "Google Gemini", + "Anthropic Claude", + "Local Ollama runtime (http://localhost:11434/v1 by default)" ] }, "ai-commit.GEMINI_API_KEY": { "type": "string", "default": "", - "description": "Gemini API Key" + "description": "Gemini API Key (deprecated — prefer 'AI Commit: Set API Key' which stores in SecretStorage)" }, "ai-commit.GEMINI_MODEL": { "type": "string", @@ -191,7 +218,7 @@ "ai-commit.CLAUDE_API_KEY": { "type": "string", "default": "", - "description": "Claude API Key (optional). Leave empty to use Claude CLI (authenticated via 'claude setup-token'), or provide an Anthropic API key for direct API access." + "description": "Claude API Key (deprecated — prefer 'AI Commit: Set API Key' which stores in SecretStorage)" }, "ai-commit.CLAUDE_MODEL": { "type": "string", @@ -204,6 +231,47 @@ "minimum": 0, "maximum": 1, "description": "Claude temperature setting (0-1). Controls randomness." + }, + "ai-commit.OLLAMA_BASE_URL": { + "type": "string", + "default": "http://localhost:11434/v1", + "description": "Base URL for the local Ollama OpenAI-compatible API" + }, + "ai-commit.OLLAMA_MODEL": { + "type": "string", + "default": "llama3.2", + "description": "Ollama model name (e.g. llama3.2, qwen2.5, mistral)" + }, + "ai-commit.OLLAMA_TEMPERATURE": { + "type": "number", + "default": 0.7, + "minimum": 0, + "maximum": 2, + "description": "Ollama temperature setting (0-2)" + }, + "ai-commit.DIFF_MAX_TOKENS": { + "type": "number", + "default": 8000, + "minimum": 500, + "description": "Maximum approximate tokens of diff to send to the model. Larger diffs are truncated." + }, + "ai-commit.DIFF_EXCLUDE_PATTERNS": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "Additional regex patterns for file paths to exclude from the diff (applied on top of built-in defaults)." + }, + "ai-commit.DIFF_INCLUDE_DEFAULT_EXCLUDES": { + "type": "boolean", + "default": true, + "description": "Whether to use built-in exclude patterns for lock files, dist/, build/, .min files, etc." + }, + "ai-commit.STREAMING_ENABLED": { + "type": "boolean", + "default": true, + "description": "Stream the generated commit message into the SCM input box character-by-character." } }, "title": "AI Commit" @@ -221,42 +289,36 @@ "scripts": { "build": "webpack --mode production --devtool hidden-source-map", "compile": "webpack", - "compile-tests": "tsc -p . --outDir out", - "lint": "eslint src --ext ts", + "lint": "eslint src test --ext ts", "package": "vsce package --no-dependencies", - "pretest": "npm run compile-tests && npm run compile && npm run lint", "publish": "vsce publish --no-dependencies", - "test": "node ./out/test/runTest.js", + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit", + "verify": "npm run lint && npm run typecheck && npm run test:unit", "vscode:prepublish": "npm run build", - "watch": "webpack --watch", - "watch-tests": "tsc -p . -w --outDir out" + "watch": "webpack --watch" }, "devDependencies": { - "@types/fs-extra": "^11.0.4", - "@types/glob": "^9.0.0", - "@types/mocha": "^10.0.10", + "@eslint/js": "^10.0.1", "@types/node": "25.x", "@types/vscode": "^1.77.0", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.2", - "@vscode/test-electron": "^2.5.2", + "@vitest/coverage-v8": "^4.1.7", "eslint": "^10.3.0", - "glob": "^13.0.6", - "mocha": "^11.7.5", "ts-loader": "^9.5.7", "typescript": "^6.0.3", + "vitest": "^4.1.7", "webpack": "^5.106.2", "webpack-cli": "^7.0.2" }, "dependencies": { "@anthropic-ai/sdk": "^0.93.0", "@google/generative-ai": "^0.24.1", - "fs-extra": "^11.3.4", "openai": "^6.36.0", "simple-git": "^3.36.0" }, - "resolutions": { - "@types/node": "16.x" - }, "license": "MIT" } diff --git a/package.nls.json b/package.nls.json new file mode 100644 index 0000000..ea1b100 --- /dev/null +++ b/package.nls.json @@ -0,0 +1,13 @@ +{ + "extension.displayName": "AI Commit", + "extension.description": "Generate conventional Git commit messages from staged diffs using OpenAI / Claude / Gemini / Ollama and OpenAI-compatible providers (DeepSeek, Zhipu, Qwen, Groq, OpenRouter).", + "command.aiCommit": "AI Commit", + "command.showAvailableModels": "AI Commit: Show Available OpenAI Models", + "command.setApiKey": "AI Commit: Set API Key (stored in SecretStorage)", + "command.useOpenAIPreset": "AI Commit: Use OpenAI-Compatible Preset (DeepSeek/Zhipu/Qwen/Groq/OpenRouter)", + "config.aiProvider.description": "AI Provider to use.", + "config.streamingEnabled.description": "Stream the generated commit message into the SCM input box character-by-character.", + "config.diffMaxTokens.description": "Maximum approximate tokens of diff to send to the model. Larger diffs are truncated.", + "config.diffExcludePatterns.description": "Additional regex patterns for file paths to exclude from the diff (applied on top of built-in defaults).", + "config.diffIncludeDefaultExcludes.description": "Use built-in exclude patterns for lock files, dist/, build/, .min files, etc." +} diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json new file mode 100644 index 0000000..c855ecb --- /dev/null +++ b/package.nls.zh-cn.json @@ -0,0 +1,13 @@ +{ + "extension.displayName": "AI Commit", + "extension.description": "基于 git staged diff,使用 OpenAI / Claude / Gemini / 本地 Ollama 以及 OpenAI 兼容厂商(DeepSeek、智谱、通义、Groq、OpenRouter)生成符合 Conventional Commits 规范的提交信息。", + "command.aiCommit": "AI Commit", + "command.showAvailableModels": "AI Commit: 显示可用 OpenAI 模型", + "command.setApiKey": "AI Commit: 设置 API Key(存入 SecretStorage)", + "command.useOpenAIPreset": "AI Commit: 使用 OpenAI 兼容预设(DeepSeek/智谱/通义/Groq/OpenRouter)", + "config.aiProvider.description": "要使用的 AI Provider。", + "config.streamingEnabled.description": "把生成的提交信息流式写入 SCM 输入框(逐字符显示)。", + "config.diffMaxTokens.description": "送给模型的 diff 的最大约略 token 数。超出会按 token 预算自动截断。", + "config.diffExcludePatterns.description": "在内置默认规则之外,额外排除匹配的文件路径(正则)。", + "config.diffIncludeDefaultExcludes.description": "启用内置默认排除规则(lock 文件、dist/、build/、.min 等)。" +} diff --git a/src/claude-utils.ts b/src/claude-utils.ts deleted file mode 100644 index e0acf2a..0000000 --- a/src/claude-utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import { ConfigKeys, ConfigurationManager } from './config'; -import { Logger } from './logger'; - -/** - * Sends a chat completion request to Claude using the Anthropic API. - * @param {Array} messages - The messages to send to Claude. - * @returns {Promise} - A promise that resolves to the API response. - */ -export async function ClaudeAPI(messages: any[]): Promise { - try { - const configManager = ConfigurationManager.getInstance(); - const apiKey = configManager.getConfig(ConfigKeys.CLAUDE_API_KEY, ''); - - if (!apiKey || apiKey.trim() === '') { - throw new Error('Claude API Key not configured'); - } - - const model = configManager.getConfig( - ConfigKeys.CLAUDE_MODEL, - 'claude-sonnet-4-5' - ); - const temperature = configManager.getConfig( - ConfigKeys.CLAUDE_TEMPERATURE, - 0.7 - ); - - const anthropic = new Anthropic({ apiKey }); - - const systemMessage = messages.find((msg) => msg.role === 'system'); - const conversationMessages = messages - .filter((msg) => msg.role !== 'system') - .map((msg) => ({ - role: msg.role as 'user' | 'assistant', - content: msg.content - })); - - const response = await anthropic.messages.create({ - model, - max_tokens: 1024, - temperature, - system: systemMessage?.content, - messages: conversationMessages - }); - - const textContent = response.content.find((block) => block.type === 'text'); - if (textContent && textContent.type === 'text') { - return textContent.text; - } - return ''; - } catch (error: any) { - Logger.error('Claude API call failed:', error); - throw error; - } -} diff --git a/src/commands.ts b/src/commands.ts index c84a00e..ac44c48 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,81 +1,155 @@ import * as vscode from 'vscode'; -import { generateCommitMsg } from './generate-commit-msg'; import { ConfigurationManager } from './config'; +import { generateCommitMsg } from './generate-commit-msg'; import { Logger } from './logger'; +import { OPENAI_COMPATIBLE_PRESETS } from './providers/presets'; +import type { SecretProvider } from './secrets'; +import { SecretsManager } from './secrets'; + +const RETRY_LIMIT = 3; + +const PROVIDER_OPTIONS: { id: SecretProvider; label: string }[] = [ + { id: 'openai', label: 'OpenAI' }, + { id: 'claude', label: 'Claude (Anthropic)' }, + { id: 'gemini', label: 'Gemini (Google)' } +]; -/** - * Manages the registration and disposal of commands. - */ export class CommandManager { private disposables: vscode.Disposable[] = []; + private retryCounts = new Map(); - constructor(private context: vscode.ExtensionContext) {} + constructor( + private context: vscode.ExtensionContext, + private secrets: SecretsManager + ) {} registerCommands() { this.registerCommand('extension.ai-commit', generateCommitMsg); - this.registerCommand('extension.configure-ai-commit', () => - vscode.commands.executeCommand('workbench.action.openSettings', 'ai-commit') - ); + this.registerCommand('extension.configure-ai-commit', async () => { + await vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'ai-commit' + ); + }); - // Show available OpenAI models this.registerCommand('ai-commit.showAvailableModels', async () => { const configManager = ConfigurationManager.getInstance(); const models = await configManager.getAvailableOpenAIModels(); const selected = await vscode.window.showQuickPick(models, { placeHolder: 'Please select a model' }); - if (selected) { const config = vscode.workspace.getConfiguration('ai-commit'); - await config.update('OPENAI_MODEL', selected, vscode.ConfigurationTarget.Global); + await config.update( + 'OPENAI_MODEL', + selected, + vscode.ConfigurationTarget.Global + ); } }); - /** - * @deprecated - * This function is deprecated because Gemini API does not currently support listing models via API. - * - * Show available Gemini models - */ - /* - this.registerCommand('ai-commit.showAvailableGeminiModels', async () => { - const configManager = ConfigurationManager.getInstance(); - const models = await configManager.getAvailableGeminiModels(); // Use the updated function - const selected = await vscode.window.showQuickPick(models, { - placeHolder: 'Please select a Gemini model' + this.registerCommand('ai-commit.setApiKey', async (...args) => { + let providerId: SecretProvider | undefined = + typeof args[0] === 'string' + ? (args[0] as SecretProvider) + : undefined; + if (!providerId) { + const picked = await vscode.window.showQuickPick( + PROVIDER_OPTIONS.map((p) => ({ label: p.label, id: p.id })), + { placeHolder: 'Select provider to configure' } + ); + if (!picked) return; + providerId = picked.id; + } + const value = await vscode.window.showInputBox({ + password: true, + ignoreFocusOut: true, + placeHolder: `Enter ${providerId} API key`, + prompt: `The key is stored in VS Code SecretStorage, not settings.json.` }); + if (!value) return; + await this.secrets.setApiKey(providerId, value.trim()); + void vscode.window.showInformationMessage(`${providerId} API key saved securely.`); + }); - if (selected) { - const config = vscode.workspace.getConfiguration('ai-commit'); - await config.update('GEMINI_MODEL', selected, vscode.ConfigurationTarget.Global); + this.registerCommand('ai-commit.useOpenAIPreset', async () => { + const picked = await vscode.window.showQuickPick( + OPENAI_COMPATIBLE_PRESETS.map((p) => ({ + label: p.displayName, + detail: p.baseURL, + description: p.recommendedModels[0] ?? '', + presetId: p.id + })), + { placeHolder: 'Select an OpenAI-compatible provider preset' } + ); + if (!picked) return; + const preset = OPENAI_COMPATIBLE_PRESETS.find((p) => p.id === picked.presetId); + if (!preset) return; + + const cfg = vscode.workspace.getConfiguration('ai-commit'); + await cfg.update('AI_PROVIDER', 'openai', vscode.ConfigurationTarget.Global); + await cfg.update( + 'OPENAI_BASE_URL', + preset.baseURL, + vscode.ConfigurationTarget.Global + ); + if (preset.recommendedModels[0]) { + await cfg.update( + 'OPENAI_MODEL', + preset.recommendedModels[0], + vscode.ConfigurationTarget.Global + ); } + + void vscode.window.showInformationMessage( + `Configured ${preset.displayName}. Now setting API key...` + ); + await vscode.commands.executeCommand('ai-commit.setApiKey', 'openai'); }); - */ } - private registerCommand(command: string, handler: (...args: any[]) => any) { - const disposable = vscode.commands.registerCommand(command, async (...args) => { - try { - Logger.info(`Executing command: ${command}`); - await handler(...args); - } catch (error) { - Logger.error(`Command '${command}' failed:`, error); - const result = await vscode.window.showErrorMessage( - `Failed: ${error.message}`, - 'Retry', - 'Configure' - ); - - if (result === 'Retry') { + private registerCommand( + command: string, + handler: (...args: unknown[]) => unknown | Promise + ) { + const disposable = vscode.commands.registerCommand( + command, + async (...args: unknown[]) => { + try { + Logger.info(`Executing command: ${command}`); await handler(...args); - } else if (result === 'Configure') { - await vscode.commands.executeCommand( - 'workbench.action.openSettings', - 'ai-commit' + this.retryCounts.delete(command); + } catch (error) { + Logger.error(`Command '${command}' failed:`, error); + const message = + error instanceof Error ? error.message : String(error); + + const tries = this.retryCounts.get(command) ?? 0; + const actions: string[] = []; + if (tries < RETRY_LIMIT) actions.push('Retry'); + actions.push('Set API Key', 'Open Settings'); + + const result = await vscode.window.showErrorMessage( + `Failed: ${message}`, + ...actions ); + + if (result === 'Retry') { + this.retryCounts.set(command, tries + 1); + await vscode.commands.executeCommand(command, ...args); + } else if (result === 'Set API Key') { + this.retryCounts.delete(command); + await vscode.commands.executeCommand('ai-commit.setApiKey'); + } else if (result === 'Open Settings') { + this.retryCounts.delete(command); + await vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'ai-commit' + ); + } } } - }); + ); this.disposables.push(disposable); this.context.subscriptions.push(disposable); diff --git a/src/config.ts b/src/config.ts index 97eb8f7..594ae77 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,19 +1,8 @@ import * as vscode from 'vscode'; -import { createOpenAIApi } from './openai-utils'; -import { createGeminiAPIClient } from './gemini-utils'; import { Logger } from './logger'; +import { createOpenAIClient } from './providers/openai'; +import type { SecretsManager } from './secrets'; -/** - * Configuration keys used in the AI commit extension. - * @constant {Object} - * @property {string} OPENAI_API_KEY - The key for OpenAI API. - * @property {string} OPENAI_BASE_URL - The base URL for OpenAI API. - * @property {string} OPENAI_MODEL - The model used for OpenAI. - * @property {string} AZURE_API_VERSION - The version of Azure API. - * @property {string} AI_COMMIT_LANGUAGE - The language for AI commit messages. - * @property {string} SYSTEM_PROMPT - The system prompt for generating commit messages. - * @property {string} OPENAI_TEMPERATURE - The temperature setting for OpenAI API. - */ export enum ConfigKeys { OPENAI_API_KEY = 'OPENAI_API_KEY', OPENAI_BASE_URL = 'OPENAI_BASE_URL', @@ -34,7 +23,17 @@ export enum ConfigKeys { OPENAI_API_TYPE = 'OPENAI_API_TYPE', OPENAI_REASONING_EFFORT = 'OPENAI_REASONING_EFFORT', - OPENAI_TEXT_VERBOSITY = 'OPENAI_TEXT_VERBOSITY' + OPENAI_TEXT_VERBOSITY = 'OPENAI_TEXT_VERBOSITY', + + OLLAMA_BASE_URL = 'OLLAMA_BASE_URL', + OLLAMA_MODEL = 'OLLAMA_MODEL', + OLLAMA_TEMPERATURE = 'OLLAMA_TEMPERATURE', + + DIFF_MAX_TOKENS = 'DIFF_MAX_TOKENS', + DIFF_EXCLUDE_PATTERNS = 'DIFF_EXCLUDE_PATTERNS', + DIFF_INCLUDE_DEFAULT_EXCLUDES = 'DIFF_INCLUDE_DEFAULT_EXCLUDES', + + STREAMING_ENABLED = 'STREAMING_ENABLED' } /** @@ -42,9 +41,10 @@ export enum ConfigKeys { */ export class ConfigurationManager { private static instance: ConfigurationManager; - private configCache: Map = new Map(); + private configCache: Map = new Map(); private disposable: vscode.Disposable; private context: vscode.ExtensionContext; + private secrets?: SecretsManager; private constructor(context: vscode.ExtensionContext) { this.context = context; @@ -52,11 +52,8 @@ export class ConfigurationManager { if (event.affectsConfiguration('ai-commit')) { this.configCache.clear(); - if ( - event.affectsConfiguration('ai-commit.OPENAI_BASE_URL') || - event.affectsConfiguration('ai-commit.OPENAI_API_KEY') - ) { - this.updateOpenAIModelList(); + if (event.affectsConfiguration('ai-commit.OPENAI_BASE_URL')) { + void this.updateOpenAIModelList(); } } }); @@ -69,102 +66,52 @@ export class ConfigurationManager { return this.instance; } + setSecrets(secrets: SecretsManager) { + this.secrets = secrets; + } + getConfig(key: string, defaultValue?: T): T { if (!this.configCache.has(key)) { const config = vscode.workspace.getConfiguration('ai-commit'); - this.configCache.set(key, config.get(key, defaultValue)); + this.configCache.set(key, config.get(key, defaultValue as T)); } - return this.configCache.get(key); + return this.configCache.get(key) as T; } dispose() { this.disposable.dispose(); } - /** - * Updates the list of available OpenAI models. - */ private async updateOpenAIModelList() { + if (!this.secrets) return; try { - const openai = createOpenAIApi(); + const openai = await createOpenAIClient({ + config: this, + secrets: this.secrets, + logger: Logger + }); const models = await openai.models.list(); + const availableModels = models.data.map((m) => m.id); + await this.context.globalState.update('availableOpenAIModels', availableModels); - // Save available models to extension state - await this.context.globalState.update( - 'availableOpenAIModels', - models.data.map((model) => model.id) - ); - - // Get the current selected model const config = vscode.workspace.getConfiguration('ai-commit'); const currentModel = config.get('OPENAI_MODEL'); - - // If the current selected model is not in the available list, set it to the default value - const availableModels = models.data.map((model) => model.id); - if (!availableModels.includes(currentModel)) { - await config.update('OPENAI_MODEL', 'gpt-4', vscode.ConfigurationTarget.Global); + if (currentModel && !availableModels.includes(currentModel)) { + await config.update( + 'OPENAI_MODEL', + availableModels[0] ?? 'gpt-4o', + vscode.ConfigurationTarget.Global + ); } } catch (error) { Logger.error('Failed to fetch OpenAI models:', error); } } - /** - * Retrieves the list of available OpenAI models. - * @returns {Promise} The list of available OpenAI models. - */ public async getAvailableOpenAIModels(): Promise { if (!this.context.globalState.get('availableOpenAIModels')) { await this.updateOpenAIModelList(); } return this.context.globalState.get('availableOpenAIModels', []); } - - /** - * @deprecated - * This function is deprecated because Gemini API does not currently support listing models via API. - * We have to wait for this feature to be updated to the gemini library at some point, or find another way. - * - * Updates the list of available Gemini models. - */ - /* - private async updateGeminiModelList() { - try { - const geminiAPI = createGeminiAPIClient(); - const modelListResponse = await geminiAPI.listModels(); // Gemini API does not currently have a function to get a list of models - const availableModels = modelListResponse.models.map(model => model.name); - - // Save available Gemini models to extension global state - await this.context.globalState.update('availableGeminiModels', availableModels); - - // Get the currently selected Gemini model - const config = vscode.workspace.getConfiguration('ai-commit'); - const currentModel = config.get('GEMINI_MODEL'); - - // If the current selected Gemini model is not in the available list, set it to a default value - if (currentModel && !availableModels.includes(currentModel)) { - await config.update('GEMINI_MODEL', 'gemini-2.0-flash-001', vscode.ConfigurationTarget.Global); - } - - } catch (error) { - console.error('Failed to fetch Gemini models:', error); - } - } - */ - - /** - * @deprecated - * This function is deprecated because Gemini API does not currently support listing models via API. - * - * Retrieves the list of available Gemini models. - * @returns {Promise} The list of available Gemini models. - */ - /* - public async getAvailableGeminiModels(): Promise { - if (!this.context.globalState.get('availableGeminiModels')) { - await this.updateGeminiModelList(); - } - return this.context.globalState.get('availableGeminiModels', []); - } - */ } diff --git a/src/diff-processor.ts b/src/diff-processor.ts new file mode 100644 index 0000000..1fa3613 --- /dev/null +++ b/src/diff-processor.ts @@ -0,0 +1,114 @@ +export interface DiffProcessOptions { + maxTokens: number; + excludePatterns: RegExp[]; +} + +export interface DiffProcessResult { + diff: string; + truncated: boolean; + excludedFiles: string[]; +} + +interface FileDiff { + filePath: string; + text: string; +} + +export const DEFAULT_EXCLUDE_PATTERNS: RegExp[] = [ + /(^|\/)package-lock\.json$/, + /(^|\/)pnpm-lock\.yaml$/, + /(^|\/)yarn\.lock$/, + /(^|\/)bun\.lock(b)?$/, + /(^|\/)Cargo\.lock$/, + /(^|\/)Pipfile\.lock$/, + /(^|\/)poetry\.lock$/, + /(^|\/)composer\.lock$/, + /(^|\/)Gemfile\.lock$/, + /(^|\/)go\.sum$/, + /\.min\.(js|css|map)$/, + /^dist\//, + /^build\//, + /^out\//, + /^\.next\//, + /^node_modules\//, + /\.lock$/ +]; + +const ESTIMATE_CHARS_PER_TOKEN = 4; + +function estimateTokens(text: string): number { + return Math.ceil(text.length / ESTIMATE_CHARS_PER_TOKEN); +} + +function splitDiffByFile(rawDiff: string): FileDiff[] { + const lines = rawDiff.split('\n'); + const files: FileDiff[] = []; + let current: FileDiff | undefined; + + for (const line of lines) { + if (line.startsWith('diff --git ')) { + if (current) files.push(current); + const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + const filePath = match?.[2] ?? match?.[1] ?? 'unknown'; + current = { filePath, text: line + '\n' }; + } else if (current) { + current.text += line + '\n'; + } + } + if (current) files.push(current); + return files; +} + +export function processDiff( + rawDiff: string, + opts: DiffProcessOptions +): DiffProcessResult { + if (!rawDiff) { + return { diff: '', truncated: false, excludedFiles: [] }; + } + + const fileDiffs = splitDiffByFile(rawDiff); + if (fileDiffs.length === 0) { + return { diff: rawDiff, truncated: false, excludedFiles: [] }; + } + + const excludedFiles: string[] = []; + const keptDiffs: FileDiff[] = []; + + for (const fd of fileDiffs) { + const isExcluded = opts.excludePatterns.some((rx) => rx.test(fd.filePath)); + if (isExcluded) { + excludedFiles.push(fd.filePath); + } else { + keptDiffs.push(fd); + } + } + + let total = 0; + const chunks: string[] = []; + let truncated = false; + for (const fd of keptDiffs) { + const tokens = estimateTokens(fd.text); + if (total + tokens > opts.maxTokens) { + truncated = true; + const remainingTokens = opts.maxTokens - total; + if (remainingTokens > 200) { + const sliceChars = remainingTokens * ESTIMATE_CHARS_PER_TOKEN; + chunks.push(fd.text.slice(0, sliceChars)); + } + break; + } + chunks.push(fd.text); + total += tokens; + } + + if (truncated) { + chunks.push('\n[... diff truncated due to size limit ...]\n'); + } + + return { + diff: chunks.join('').trim(), + truncated, + excludedFiles + }; +} diff --git a/src/extension.ts b/src/extension.ts index b0dfa78..f7d2aea 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,21 +1,54 @@ import * as vscode from 'vscode'; import { CommandManager } from './commands'; -import { ConfigKeys, ConfigurationManager } from './config'; +import { ConfigurationManager } from './config'; +import { setSecretsManager } from './generate-commit-msg'; import { Logger } from './logger'; +import { createProvider } from './providers/factory'; +import type { LLMProvider } from './providers/types'; +import { SecretsManager } from './secrets'; + +async function ensureProviderConfigured( + provider: LLMProvider, + configManager: ConfigurationManager, + secrets: SecretsManager +): Promise { + if (!provider.requiresApiKey) return; + + const fromSecrets = await secrets.getApiKey(provider.id as 'openai' | 'claude' | 'gemini'); + if (fromSecrets) return; + + const fromSettings = configManager.getConfig(provider.apiKeyConfigKey); + if (fromSettings && fromSettings.trim() !== '') return; + + const result = await vscode.window.showWarningMessage( + `${provider.displayName} API Key not configured. Configure now?`, + 'Set API Key', + 'Open Settings', + 'No' + ); + if (result === 'Set API Key') { + await vscode.commands.executeCommand('ai-commit.setApiKey', provider.id); + } else if (result === 'Open Settings') { + await vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'ai-commit' + ); + } +} -/** - * Activates the extension and registers commands. - * - * @param {vscode.ExtensionContext} context - The context for the extension. - */ export async function activate(context: vscode.ExtensionContext) { try { Logger.initialize(); Logger.info('Activating AI Commit extension...'); const configManager = ConfigurationManager.getInstance(context); + const secrets = new SecretsManager(context); + configManager.setSecrets(secrets); + setSecretsManager(secrets); - const commandManager = new CommandManager(context); + await secrets.migrate(); + + const commandManager = new CommandManager(context, secrets); commandManager.registerCommands(); context.subscriptions.push({ @@ -26,70 +59,16 @@ export async function activate(context: vscode.ExtensionContext) { } }); - // Check API key based on configured AI provider - const aiProvider = configManager.getConfig( - ConfigKeys.AI_PROVIDER, - 'openai' - ); - - if (aiProvider === 'gemini') { - const geminiApiKey = configManager.getConfig(ConfigKeys.GEMINI_API_KEY); - if (!geminiApiKey) { - const result = await vscode.window.showWarningMessage( - 'Gemini API Key not configured. Would you like to configure it now?', - 'Yes', - 'No' - ); - - if (result === 'Yes') { - await vscode.commands.executeCommand( - 'workbench.action.openSettings', - 'ai-commit.GEMINI_API_KEY' - ); - } - } - } else if (aiProvider === 'claude') { - const claudeApiKey = configManager.getConfig(ConfigKeys.CLAUDE_API_KEY); - if (!claudeApiKey) { - const result = await vscode.window.showWarningMessage( - 'Claude API Key not configured. Would you like to configure it now?', - 'Yes', - 'No' - ); - - if (result === 'Yes') { - await vscode.commands.executeCommand( - 'workbench.action.openSettings', - 'ai-commit.CLAUDE_API_KEY' - ); - } - } - } else { - // Default to OpenAI provider - const openaiApiKey = configManager.getConfig(ConfigKeys.OPENAI_API_KEY); - if (!openaiApiKey) { - const result = await vscode.window.showWarningMessage( - 'OpenAI API Key not configured. Would you like to configure it now?', - 'Yes', - 'No' - ); - - if (result === 'Yes') { - await vscode.commands.executeCommand( - 'workbench.action.openSettings', - 'ai-commit.OPENAI_API_KEY' - ); - } - } - } + const provider = createProvider({ + config: configManager, + secrets, + logger: Logger + }); + await ensureProviderConfigured(provider, configManager, secrets); } catch (error) { Logger.error('Failed to activate extension:', error); throw error; } } -/** - * Deactivates the extension. - * This function is called when the extension is deactivated. - */ export function deactivate() {} diff --git a/src/gemini-utils.ts b/src/gemini-utils.ts deleted file mode 100644 index 7d3ee67..0000000 --- a/src/gemini-utils.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { GoogleGenerativeAI } from "@google/generative-ai"; -import { ConfigKeys, ConfigurationManager } from './config'; -import { Logger } from './logger'; - -/** - * Creates and returns a Gemini API configuration object. - * @returns {Object} - The Gemini API configuration object. - * @throws {Error} - Throws an error if the API key is missing or empty. - */ -function getGeminiConfig() { - const configManager = ConfigurationManager.getInstance(); - const apiKey = configManager.getConfig(ConfigKeys.GEMINI_API_KEY); - - if (!apiKey) { - throw new Error('The GEMINI_API_KEY environment variable is missing or empty.'); - } - - const config: { - apiKey: string; - } = { - apiKey - }; - - return config; -} - -/** - * Creates and returns a Gemini API instance. - * @returns {GoogleGenerativeAI} - The Gemini API instance. - */ -export function createGeminiAPIClient() { - const config = getGeminiConfig(); - return new GoogleGenerativeAI(config.apiKey); -} - -/** - * Sends a chat completion request to the Gemini API. - * @param {any[]} messages - The messages to send to the API. - * @returns {Promise} - A promise that resolves to the API response. - */ -export async function GeminiAPI(messages: any[]) { - try { - const gemini = createGeminiAPIClient(); - const configManager = ConfigurationManager.getInstance(); - const modelName = configManager.getConfig(ConfigKeys.GEMINI_MODEL); - const temperature = configManager.getConfig(ConfigKeys.GEMINI_TEMPERATURE, 0.7); - - const model = gemini.getGenerativeModel({ model: modelName }); - const chat = model.startChat({ - generationConfig: { - temperature: temperature, - }, - }); - - const result = await chat.sendMessage(messages.map(msg => msg.content)); - const response = result.response; - const text = response.text(); - - return text; - - } catch (error) { - Logger.error('Gemini API call failed:', error); - throw error; - } -} \ No newline at end of file diff --git a/src/generate-commit-msg.ts b/src/generate-commit-msg.ts index 1d3b964..1978450 100644 --- a/src/generate-commit-msg.ts +++ b/src/generate-commit-msg.ts @@ -1,197 +1,210 @@ -import * as fs from 'fs-extra'; -import { ChatCompletionMessageParam } from 'openai/resources'; +import * as fs from 'node:fs'; import * as vscode from 'vscode'; import { ConfigKeys, ConfigurationManager } from './config'; +import { + DEFAULT_EXCLUDE_PATTERNS, + processDiff, + type DiffProcessResult +} from './diff-processor'; import { getDiffStaged } from './git-utils'; -import { ChatGPTAPI, ResponsesAPI } from './openai-utils'; -import { getMainCommitPrompt } from './prompts'; -import { ProgressHandler } from './utils'; -import { GeminiAPI } from './gemini-utils'; -import { ClaudeAPI } from './claude-utils'; import { Logger } from './logger'; +import { AbstractLLMProvider } from './providers/base'; +import { createProvider } from './providers/factory'; +import type { LLMProvider } from './providers/types'; +import { getMainCommitPrompt } from './prompts'; +import type { SecretsManager } from './secrets'; +import type { ChatMessage } from './types/messages'; +import { createAbortBridge, ProgressHandler } from './utils'; -/** - * Generates a chat completion prompt for the commit message based on the provided diff. - * - * @param {string} diff - The diff string representing changes to be committed. - * @param {string} additionalContext - Additional context for the changes. - * @returns {Promise>} - A promise that resolves to an array of messages for the chat completion. - */ -const generateCommitMessageChatCompletionPrompt = async ( +let secretsManagerRef: SecretsManager | undefined; +export function setSecretsManager(secrets: SecretsManager) { + secretsManagerRef = secrets; +} + +async function buildMessages( diff: string, additionalContext?: string -) => { - const INIT_MESSAGES_PROMPT = await getMainCommitPrompt(); - const chatContextAsCompletionRequest = [...INIT_MESSAGES_PROMPT]; +): Promise { + const base = await getMainCommitPrompt(); + const messages: ChatMessage[] = [...base]; if (additionalContext) { - chatContextAsCompletionRequest.push({ + messages.push({ role: 'user', content: `Additional context for the changes:\n${additionalContext}` }); } - chatContextAsCompletionRequest.push({ - role: 'user', - content: diff - }); - return chatContextAsCompletionRequest; -}; - -/** - * Retrieves the repository associated with the provided argument. - * - * @param {any} arg - The input argument containing the root URI of the repository. - * @returns {Promise} - A promise that resolves to the repository object. - */ -export async function getRepo(arg) { + messages.push({ role: 'user', content: diff }); + return messages; +} + +export async function getRepo(arg: unknown) { const gitApi = vscode.extensions.getExtension('vscode.git')?.exports.getAPI(1); if (!gitApi) { throw new Error('Git extension not found'); } - if (typeof arg === 'object' && arg.rootUri) { - const resourceUri = arg.rootUri; - const realResourcePath: string = fs.realpathSync(resourceUri!.fsPath); - for (let i = 0; i < gitApi.repositories.length; i++) { - const repo = gitApi.repositories[i]; - if (realResourcePath.startsWith(repo.rootUri.fsPath)) { - return repo; + if ( + arg && + typeof arg === 'object' && + 'rootUri' in arg && + (arg as { rootUri?: vscode.Uri }).rootUri + ) { + const resourceUri = (arg as { rootUri: vscode.Uri }).rootUri; + try { + const realResourcePath = fs.realpathSync(resourceUri.fsPath); + for (const repo of gitApi.repositories) { + if (realResourcePath.startsWith(repo.rootUri.fsPath)) { + return repo; + } } + } catch (err) { + Logger.warn( + `Failed to resolve real path for ${resourceUri.fsPath}; falling back to first repository`, + err + ); } } return gitApi.repositories[0]; } -/** - * Generates a commit message based on the changes staged in the repository. - * - * @param {any} arg - The input argument containing the root URI of the repository. - * @returns {Promise} - A promise that resolves when the commit message has been generated and set in the SCM input box. - */ -export async function generateCommitMsg(arg) { - return ProgressHandler.withProgress('', async (progress) => { +function buildExcludePatterns(configManager: ConfigurationManager): RegExp[] { + const includeDefaults = configManager.getConfig( + ConfigKeys.DIFF_INCLUDE_DEFAULT_EXCLUDES, + true + ); + const userPatterns = + configManager.getConfig(ConfigKeys.DIFF_EXCLUDE_PATTERNS, []) ?? []; + + const compiled: RegExp[] = []; + for (const raw of userPatterns) { try { - const configManager = ConfigurationManager.getInstance(); - const repo = await getRepo(arg); + compiled.push(new RegExp(raw)); + } catch (err) { + Logger.warn(`Ignoring invalid DIFF_EXCLUDE_PATTERNS entry: ${raw}`, err); + } + } + return includeDefaults ? [...DEFAULT_EXCLUDE_PATTERNS, ...compiled] : compiled; +} - const aiProvider = configManager.getConfig( - ConfigKeys.AI_PROVIDER, - 'openai' - ); - Logger.info(`Using AI provider: ${aiProvider}`); +async function runStreamingGeneration( + provider: LLMProvider, + messages: ChatMessage[], + scmInputBox: { value: string }, + signal: AbortSignal +): Promise { + if (!provider.generateStream) { + return provider.generate(messages, { signal }); + } + scmInputBox.value = ''; + let buffer = ''; + for await (const chunk of provider.generateStream(messages, { signal })) { + buffer += chunk; + scmInputBox.value = AbstractLLMProvider.cleanThinkTags(buffer); + } + return AbstractLLMProvider.cleanThinkTags(buffer); +} - progress.report({ message: 'Getting staged changes...' }); - const { diff, error } = await getDiffStaged(repo); +function reportDiffStats( + progress: vscode.Progress<{ message?: string }>, + result: DiffProcessResult +) { + if (result.excludedFiles.length > 0) { + Logger.info(`Excluded ${result.excludedFiles.length} file(s) from diff:`); + for (const file of result.excludedFiles) Logger.info(` - ${file}`); + } + if (result.truncated) { + progress.report({ message: 'Diff was large; truncated to fit token budget.' }); + Logger.warn('Diff truncated due to DIFF_MAX_TOKENS limit'); + } +} - if (error) { - throw new Error(`Failed to get staged changes: ${error}`); - } +export async function generateCommitMsg(arg: unknown) { + return ProgressHandler.withProgress('', async (progress, token) => { + const configManager = ConfigurationManager.getInstance(); + const secrets = secretsManagerRef; + if (!secrets) { + throw new Error('SecretsManager not initialized'); + } - if (!diff || diff === 'No changes staged.') { - throw new Error('No changes staged for commit'); - } + const repo = await getRepo(arg); + const provider = createProvider({ + config: configManager, + secrets, + logger: Logger + }); + Logger.info(`Using AI provider: ${provider.id}`); - const scmInputBox = repo.inputBox; - if (!scmInputBox) { - throw new Error('Unable to find the SCM input box'); - } + progress.report({ message: 'Getting staged changes...' }); + const { diff: rawDiff, error } = await getDiffStaged(repo); - const additionalContext = scmInputBox.value.trim(); + if (error) { + throw new Error(`Failed to get staged changes: ${error}`); + } + if (!rawDiff || rawDiff === 'No changes staged.') { + throw new Error('No changes staged for commit'); + } - progress.report({ - message: additionalContext - ? 'Analyzing changes with additional context...' - : 'Analyzing changes...' - }); - const messages = await generateCommitMessageChatCompletionPrompt( - diff, - additionalContext + const maxTokens = configManager.getConfig( + ConfigKeys.DIFF_MAX_TOKENS, + 8000 + ); + const excludePatterns = buildExcludePatterns(configManager); + const processed = processDiff(rawDiff, { maxTokens, excludePatterns }); + reportDiffStats(progress, processed); + + if (!processed.diff) { + throw new Error( + 'After filtering, no diff content remained. All changed files matched exclude patterns.' ); + } - progress.report({ - message: additionalContext - ? 'Generating commit message with additional context...' - : 'Generating commit message...' - }); - try { - let commitMessage: string | undefined; - - if (aiProvider === 'gemini') { - const geminiApiKey = configManager.getConfig( - ConfigKeys.GEMINI_API_KEY - ); - if (!geminiApiKey) { - throw new Error('Gemini API Key not configured'); - } - commitMessage = await GeminiAPI(messages); - } else if (aiProvider === 'claude') { - const claudeApiKey = configManager.getConfig( - ConfigKeys.CLAUDE_API_KEY - ); - if (!claudeApiKey) { - throw new Error('Claude API Key not configured'); - } - commitMessage = await ClaudeAPI(messages); - } else { - const openaiApiKey = configManager.getConfig( - ConfigKeys.OPENAI_API_KEY - ); - if (!openaiApiKey) { - throw new Error('OpenAI API Key not configured'); - } - const apiType = configManager.getConfig( - ConfigKeys.OPENAI_API_TYPE, - 'completion' - ); - if (apiType === 'response') { - commitMessage = await ResponsesAPI( - messages as ChatCompletionMessageParam[] - ); - } else { - commitMessage = await ChatGPTAPI(messages as ChatCompletionMessageParam[]); - } - } + const scmInputBox = repo.inputBox; + if (!scmInputBox) { + throw new Error('Unable to find the SCM input box'); + } - if (commitMessage) { - // 清理 think 标签内容 - commitMessage = commitMessage.replace(/.*?<\/think>/gs, '').trim(); - Logger.info('Commit message generated successfully'); - scmInputBox.value = commitMessage; - } else { - throw new Error('Failed to generate commit message'); - } - } catch (err: any) { - Logger.error(`${aiProvider} API call failed:`, err); - let errorMessage = - (err instanceof Error && err.message) || - (typeof err === 'string' ? err : 'An unexpected error occurred'); - - if (aiProvider === 'openai' && err?.response?.status) { - switch (err.response.status) { - case 401: - errorMessage = 'Invalid OpenAI API key or unauthorized access'; - break; - case 429: - errorMessage = 'Rate limit exceeded. Please try again later'; - break; - case 500: - errorMessage = 'OpenAI server error. Please try again later'; - break; - case 503: - errorMessage = 'OpenAI service is temporarily unavailable'; - break; - } - } else if (aiProvider === 'gemini') { - errorMessage = `Gemini API error: ${err.message}`; - } else if (aiProvider === 'claude') { - errorMessage = `Claude API error: ${err.message}`; - } + const additionalContext = scmInputBox.value.trim(); + progress.report({ + message: additionalContext + ? 'Analyzing changes with additional context...' + : 'Analyzing changes...' + }); + + const messages = await buildMessages(processed.diff, additionalContext); + const streamingEnabled = configManager.getConfig( + ConfigKeys.STREAMING_ENABLED, + true + ); - throw new Error(errorMessage); + progress.report({ + message: streamingEnabled + ? 'Streaming commit message...' + : 'Generating commit message...' + }); + + const bridge = createAbortBridge(token); + try { + await provider.validate(); + const commitMessage = streamingEnabled + ? await runStreamingGeneration(provider, messages, scmInputBox, bridge.signal) + : await provider.generate(messages, { signal: bridge.signal }); + + if (!commitMessage) { + throw new Error('Failed to generate commit message'); + } + scmInputBox.value = commitMessage; + Logger.info('Commit message generated successfully'); + } catch (err) { + if (token.isCancellationRequested) { + Logger.info('Generation cancelled by user'); + return; } - } catch (error) { - throw error; + Logger.error(`${provider.id} API call failed:`, err); + throw err instanceof Error ? err : new Error(String(err)); + } finally { + bridge.dispose(); } }); } diff --git a/src/git-utils.ts b/src/git-utils.ts index 4946cfa..b00e38b 100644 --- a/src/git-utils.ts +++ b/src/git-utils.ts @@ -2,15 +2,19 @@ import simpleGit from 'simple-git'; import * as vscode from 'vscode'; import { Logger } from './logger'; +interface GitRepoLike { + rootUri?: { fsPath: string }; +} + /** * Retrieves the staged changes from the Git repository. */ export async function getDiffStaged( - repo: any + repo: GitRepoLike | undefined ): Promise<{ diff: string; error?: string }> { try { const rootPath = - repo?.rootUri?.fsPath || vscode.workspace.workspaceFolders?.[0].uri.fsPath; + repo?.rootUri?.fsPath ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!rootPath) { throw new Error('No workspace folder found'); @@ -19,12 +23,10 @@ export async function getDiffStaged( const git = simpleGit(rootPath); const diff = await git.diff(['--staged']); - return { - diff: diff || 'No changes staged.', - error: null - }; - } catch (error) { - Logger.error('Error reading Git diff:', error); - return { diff: '', error: error.message }; + return { diff: diff || 'No changes staged.' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + Logger.error('Error reading Git diff:', err); + return { diff: '', error: `Failed to read git diff: ${message}` }; } } diff --git a/src/logger.ts b/src/logger.ts index 6d85278..833fb6d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,46 +1,65 @@ import * as vscode from 'vscode'; +const API_KEY_PATTERNS: RegExp[] = [ + /sk-ant-[A-Za-z0-9_-]{20,}/g, + /sk-[A-Za-z0-9_-]{20,}/g, + /AIza[A-Za-z0-9_-]{30,}/g, + /gsk_[A-Za-z0-9]{20,}/g, + /xai-[A-Za-z0-9]{20,}/g +]; + +export function redactSecrets(text: string): string { + let result = text; + for (const pattern of API_KEY_PATTERNS) { + result = result.replace(pattern, (match) => { + const tail = match.slice(-4); + return `***${tail}`; + }); + } + return result; +} + export class Logger { - private static outputChannel: vscode.OutputChannel; + private static outputChannel: vscode.OutputChannel | undefined; static initialize() { this.outputChannel = vscode.window.createOutputChannel('AI Commit'); } - static info(message: string, ...args: any[]) { + static info(message: string, ...args: unknown[]) { this.log('INFO', message, ...args); } - static warn(message: string, ...args: any[]) { + static warn(message: string, ...args: unknown[]) { this.log('WARN', message, ...args); } - static error(message: string, ...args: any[]) { + static error(message: string, ...args: unknown[]) { this.log('ERROR', message, ...args); } - private static log(level: string, message: string, ...args: any[]) { - if (!this.outputChannel) { - return; - } + private static log(level: string, message: string, ...args: unknown[]) { + if (!this.outputChannel) return; const timestamp = new Date().toISOString(); - const formattedMessage = `[${timestamp}] [${level}] ${message}`; - - if (args.length > 0) { - this.outputChannel.appendLine( - `${formattedMessage} ${args.map((a) => this.formatArg(a)).join(' ')}` - ); - } else { - this.outputChannel.appendLine(formattedMessage); - } + const head = `[${timestamp}] [${level}] ${message}`; + const body = + args.length > 0 + ? `${head} ${args.map((a) => this.formatArg(a)).join(' ')}` + : head; + this.outputChannel.appendLine(redactSecrets(body)); } - private static formatArg(a: any): string { + private static formatArg(a: unknown): string { if (a instanceof Error) { - return `${a.message}\n${a.stack || ''}`; + const stackLines = (a.stack ?? '').split('\n').slice(0, 6).join('\n'); + return `${a.message}\n${stackLines}`; } - if (typeof a === 'object') { - return JSON.stringify(a, null, 2); + if (typeof a === 'object' && a !== null) { + try { + return JSON.stringify(a, null, 2); + } catch { + return String(a); + } } return String(a); } diff --git a/src/openai-utils.ts b/src/openai-utils.ts deleted file mode 100644 index a3e25ac..0000000 --- a/src/openai-utils.ts +++ /dev/null @@ -1,123 +0,0 @@ -import OpenAI from 'openai'; -import { ChatCompletionMessageParam } from 'openai/resources'; -import { ReasoningEffort } from 'openai/resources/shared'; -import { ConfigKeys, ConfigurationManager } from './config'; - -/** - * Creates and returns an OpenAI configuration object. - * @returns {Object} - The OpenAI configuration object. - * @throws {Error} - Throws an error if the API key is missing or empty. - */ -function getOpenAIConfig() { - const configManager = ConfigurationManager.getInstance(); - const apiKey = configManager.getConfig(ConfigKeys.OPENAI_API_KEY); - const baseURL = configManager.getConfig(ConfigKeys.OPENAI_BASE_URL); - const apiVersion = configManager.getConfig(ConfigKeys.AZURE_API_VERSION); - - if (!apiKey) { - throw new Error('The OPENAI_API_KEY environment variable is missing or empty.'); - } - - const config: { - apiKey: string; - baseURL?: string; - defaultQuery?: { 'api-version': string }; - defaultHeaders?: { 'api-key': string }; - } = { - apiKey - }; - - if (baseURL) { - config.baseURL = baseURL; - if (apiVersion) { - config.defaultQuery = { 'api-version': apiVersion }; - config.defaultHeaders = { 'api-key': apiKey }; - } - } - - return config; -} - -/** - * Creates and returns an OpenAI API instance. - * @returns {OpenAI} - The OpenAI API instance. - */ -export function createOpenAIApi() { - const config = getOpenAIConfig(); - return new OpenAI(config); -} - -/** - * Sends a chat completion request to the OpenAI API. - * @param {Array} messages - The messages to send to the API. - * @returns {Promise} - A promise that resolves to the API response. - */ -export async function ChatGPTAPI(messages: ChatCompletionMessageParam[]) { - const openai = createOpenAIApi(); - const configManager = ConfigurationManager.getInstance(); - const model = configManager.getConfig(ConfigKeys.OPENAI_MODEL); - const temperature = configManager.getConfig( - ConfigKeys.OPENAI_TEMPERATURE, - 0.7 - ); - - const completion = await openai.chat.completions.create({ - model, - messages: messages as ChatCompletionMessageParam[], - temperature - }); - - return completion.choices[0]!.message?.content; -} - -/** - * Sends a request to the OpenAI Responses API. - * Supports reasoning effort and output verbosity configuration. - * @param {Array} messages - The messages to send (same format as Chat Completions). - * @returns {Promise} - A promise that resolves to the API response text. - */ -export async function ResponsesAPI(messages: ChatCompletionMessageParam[]) { - const openai = createOpenAIApi(); - const configManager = ConfigurationManager.getInstance(); - const model = configManager.getConfig(ConfigKeys.OPENAI_MODEL); - const reasoningEffort = configManager.getConfig( - ConfigKeys.OPENAI_REASONING_EFFORT, - 'medium' - ); - const textVerbosity = configManager.getConfig( - ConfigKeys.OPENAI_TEXT_VERBOSITY, - 'medium' - ); - - const verbosityTokenMap: Record = { - low: 1000, - medium: 4000, - high: 16000 - }; - const maxOutputTokens = verbosityTokenMap[textVerbosity] ?? 4000; - - // Extract system message as instructions; pass the rest as input - const systemMsg = messages.find((m) => m.role === 'system'); - const instructions = systemMsg - ? typeof systemMsg.content === 'string' - ? systemMsg.content - : undefined - : undefined; - - const inputMessages = messages - .filter((m) => m.role !== 'system') - .map((m) => ({ - role: m.role as 'user' | 'assistant', - content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) - })); - - const response = await openai.responses.create({ - model, - ...(instructions ? { instructions } : {}), - input: inputMessages, - reasoning: { effort: reasoningEffort as ReasoningEffort }, - max_output_tokens: maxOutputTokens - }); - - return response.output_text; -} diff --git a/src/prompts.ts b/src/prompts.ts index 59f80e3..c0db88b 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -1,12 +1,10 @@ import { ConfigKeys, ConfigurationManager } from './config'; +import type { ChatMessage } from './types/messages'; /** * Initializes the main prompt for generating commit messages. - * - * @param {string} language - The language to be used in the prompt. - * @returns {Object} - The main prompt object containing role and content. */ -const INIT_MAIN_PROMPT = (language: string) => ({ +const INIT_MAIN_PROMPT = (language: string): ChatMessage => ({ role: 'system', content: ConfigurationManager.getInstance().getConfig(ConfigKeys.SYSTEM_PROMPT) || @@ -92,9 +90,9 @@ diff --git a/src/server.ts b/src/server.ts\n index ad4db42..f3b18a9 100644\n --- \n -const port = 7799; \n +const PORT = 7799; \n \n app.use(express.json()); -\n \n @@ -34,6 +34,6 @@\n app.use((\_, res, next) => {\n // ROUTES\n app.use(PROTECTED_ROUTER_URL, protectedRouter); -\n \n -app.listen(port, () => {\n - console.log(\`Server listening on port \$\{port\}\`); -\n +app.listen(process.env.PORT || PORT, () => {\n + console.log(\`Server listening on port \$\{PORT\}\`); +\n \n @@ -34,6 +34,6 @@\n app.use((_, res, next) => {\n // ROUTES\n app.use(PROTECTED_ROUTER_URL, protectedRouter); +\n \n -app.listen(port, () => {\n - console.log(\`Server listening on port \${port}\`); +\n +app.listen(process.env.PORT || PORT, () => {\n + console.log(\`Server listening on port \${PORT}\`); \n }); OUTPUT: @@ -107,12 +105,7 @@ OUTPUT: Remember: All output MUST be in ${language} language. You are to act as a pure commit message generator. Your response should contain NOTHING but the commit message itself.` }); -/** - * Retrieves the main commit prompt. - * - * @returns {Promise>} - A promise that resolves to an array of prompts. - */ -export const getMainCommitPrompt = async () => { +export const getMainCommitPrompt = async (): Promise => { const language = ConfigurationManager.getInstance().getConfig( ConfigKeys.AI_COMMIT_LANGUAGE ); diff --git a/src/providers/base.ts b/src/providers/base.ts new file mode 100644 index 0000000..620fdbf --- /dev/null +++ b/src/providers/base.ts @@ -0,0 +1,43 @@ +import type { SecretProvider } from '../secrets'; +import type { ChatMessage, GenerateOptions } from '../types/messages'; +import type { LLMProvider, ProviderContext, ProviderId } from './types'; + +export abstract class AbstractLLMProvider implements LLMProvider { + abstract readonly id: ProviderId; + abstract readonly displayName: string; + abstract readonly apiKeyConfigKey: string; + readonly requiresApiKey: boolean = true; + + constructor(protected readonly ctx: ProviderContext) {} + + async validate(): Promise { + if (!this.requiresApiKey) return; + await this.resolveApiKey(); + } + + async generate(messages: ChatMessage[], opts: GenerateOptions = {}): Promise { + const raw = await this.doGenerate(messages, opts); + return AbstractLLMProvider.cleanThinkTags(raw); + } + + protected abstract doGenerate( + messages: ChatMessage[], + opts: GenerateOptions + ): Promise; + + protected async resolveApiKey(): Promise { + if (!this.requiresApiKey) return ''; + const fromSecrets = await this.ctx.secrets.getApiKey(this.id as SecretProvider); + if (fromSecrets) return fromSecrets; + const fromSettings = this.ctx.config.getConfig(this.apiKeyConfigKey); + if (fromSettings && fromSettings.trim() !== '') { + return fromSettings.trim(); + } + throw new Error(`${this.displayName} API Key not configured`); + } + + static cleanThinkTags(text: string): string { + if (!text) return text; + return text.replace(/[\s\S]*?<\/think>/g, '').trim(); + } +} diff --git a/src/providers/claude.ts b/src/providers/claude.ts new file mode 100644 index 0000000..005a934 --- /dev/null +++ b/src/providers/claude.ts @@ -0,0 +1,104 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { ConfigKeys } from '../config'; +import type { ChatMessage, GenerateOptions } from '../types/messages'; +import { AbstractLLMProvider } from './base'; +import type { ProviderId } from './types'; + +export class ClaudeProvider extends AbstractLLMProvider { + readonly id: ProviderId = 'claude'; + readonly displayName = 'Claude'; + readonly apiKeyConfigKey = ConfigKeys.CLAUDE_API_KEY; + + private async createClient(): Promise { + const apiKey = await this.resolveApiKey(); + return new Anthropic({ apiKey }); + } + + private splitMessages(messages: ChatMessage[]) { + const systemMessage = messages.find((m) => m.role === 'system'); + const conversation = messages + .filter((m) => m.role !== 'system') + .map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content + })); + return { system: systemMessage?.content, conversation }; + } + + protected async doGenerate( + messages: ChatMessage[], + opts: GenerateOptions + ): Promise { + const anthropic = await this.createClient(); + const model = this.ctx.config.getConfig( + ConfigKeys.CLAUDE_MODEL, + 'claude-sonnet-4-5' + ); + const temperature = this.ctx.config.getConfig( + ConfigKeys.CLAUDE_TEMPERATURE, + 0.7 + ); + + const { system, conversation } = this.splitMessages(messages); + + try { + const response = await anthropic.messages.create( + { + model, + max_tokens: 1024, + temperature, + system, + messages: conversation + }, + opts.signal ? { signal: opts.signal } : undefined + ); + + const textBlock = response.content.find((b) => b.type === 'text'); + if (textBlock && textBlock.type === 'text') { + return textBlock.text; + } + throw new Error('Claude returned no text content'); + } catch (err) { + if (err instanceof Error) { + throw new Error(`Claude API error: ${err.message}`, { cause: err }); + } + throw new Error('Claude API error: unknown failure', { cause: err }); + } + } + + async *generateStream( + messages: ChatMessage[], + opts: GenerateOptions = {} + ): AsyncIterable { + const anthropic = await this.createClient(); + const model = this.ctx.config.getConfig( + ConfigKeys.CLAUDE_MODEL, + 'claude-sonnet-4-5' + ); + const temperature = this.ctx.config.getConfig( + ConfigKeys.CLAUDE_TEMPERATURE, + 0.7 + ); + const { system, conversation } = this.splitMessages(messages); + + const stream = anthropic.messages.stream( + { + model, + max_tokens: 1024, + temperature, + system, + messages: conversation + }, + opts.signal ? { signal: opts.signal } : undefined + ); + + for await (const event of stream) { + if ( + event.type === 'content_block_delta' && + event.delta.type === 'text_delta' + ) { + yield event.delta.text; + } + } + } +} diff --git a/src/providers/factory.ts b/src/providers/factory.ts new file mode 100644 index 0000000..a975de2 --- /dev/null +++ b/src/providers/factory.ts @@ -0,0 +1,21 @@ +import { ConfigKeys } from '../config'; +import { ClaudeProvider } from './claude'; +import { GeminiProvider } from './gemini'; +import { OllamaProvider } from './ollama'; +import { OpenAIProvider } from './openai'; +import type { LLMProvider, ProviderContext, ProviderId } from './types'; + +export function createProvider(ctx: ProviderContext): LLMProvider { + const id = ctx.config.getConfig(ConfigKeys.AI_PROVIDER, 'openai'); + switch (id) { + case 'gemini': + return new GeminiProvider(ctx); + case 'claude': + return new ClaudeProvider(ctx); + case 'ollama': + return new OllamaProvider(ctx); + case 'openai': + default: + return new OpenAIProvider(ctx); + } +} diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts new file mode 100644 index 0000000..2ece642 --- /dev/null +++ b/src/providers/gemini.ts @@ -0,0 +1,105 @@ +import { GoogleGenerativeAI } from '@google/generative-ai'; +import type { GenerativeModel } from '@google/generative-ai'; +import { ConfigKeys } from '../config'; +import type { ChatMessage, GenerateOptions } from '../types/messages'; +import { AbstractLLMProvider } from './base'; +import type { ProviderId } from './types'; + +export class GeminiProvider extends AbstractLLMProvider { + readonly id: ProviderId = 'gemini'; + readonly displayName = 'Gemini'; + readonly apiKeyConfigKey = ConfigKeys.GEMINI_API_KEY; + + private async createModel(messages: ChatMessage[]): Promise { + const apiKey = await this.resolveApiKey(); + const modelName = this.ctx.config.getConfig(ConfigKeys.GEMINI_MODEL); + const gemini = new GoogleGenerativeAI(apiKey); + + const systemMessage = messages.find((m) => m.role === 'system'); + return gemini.getGenerativeModel({ + model: modelName, + ...(systemMessage + ? { + systemInstruction: { + role: 'system', + parts: [{ text: systemMessage.content }] + } + } + : {}) + }); + } + + /** + * @google/generative-ai does not honor AbortSignal, so we race the call + * against an abort promise. This still leaks the underlying request, but + * surfaces the abort to the caller immediately. + */ + private withAbort(p: Promise, signal?: AbortSignal): Promise { + if (!signal) return p; + if (signal.aborted) return Promise.reject(new Error('Aborted')); + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error('Aborted')); + signal.addEventListener('abort', onAbort, { once: true }); + p.then( + (v) => { + signal.removeEventListener('abort', onAbort); + resolve(v); + }, + (e) => { + signal.removeEventListener('abort', onAbort); + reject(e); + } + ); + }); + } + + protected async doGenerate( + messages: ChatMessage[], + opts: GenerateOptions + ): Promise { + const temperature = this.ctx.config.getConfig( + ConfigKeys.GEMINI_TEMPERATURE, + 0.7 + ); + + const model = await this.createModel(messages); + const userAssistantMessages = messages.filter((m) => m.role !== 'system'); + + try { + const chat = model.startChat({ generationConfig: { temperature } }); + const userContents = userAssistantMessages.map((m) => m.content); + const result = await this.withAbort(chat.sendMessage(userContents), opts.signal); + return result.response.text(); + } catch (err) { + if (err instanceof Error) { + throw new Error(`Gemini API error: ${err.message}`, { cause: err }); + } + throw new Error('Gemini API error: unknown failure', { cause: err }); + } + } + + async *generateStream( + messages: ChatMessage[], + opts: GenerateOptions = {} + ): AsyncIterable { + const temperature = this.ctx.config.getConfig( + ConfigKeys.GEMINI_TEMPERATURE, + 0.7 + ); + const model = await this.createModel(messages); + const userAssistantMessages = messages.filter((m) => m.role !== 'system'); + const userContents = userAssistantMessages.map((m) => m.content); + + const chat = model.startChat({ generationConfig: { temperature } }); + const streamResult = await this.withAbort( + chat.sendMessageStream(userContents), + opts.signal + ); + + for await (const chunk of streamResult.stream) { + if (opts.signal?.aborted) throw new Error('Aborted'); + const text = chunk.text(); + if (text) yield text; + } + } +} diff --git a/src/providers/ollama.ts b/src/providers/ollama.ts new file mode 100644 index 0000000..e66c171 --- /dev/null +++ b/src/providers/ollama.ts @@ -0,0 +1,112 @@ +import OpenAI from 'openai'; +import type { ChatCompletionMessageParam } from 'openai/resources'; +import { ConfigKeys } from '../config'; +import type { ChatMessage, GenerateOptions } from '../types/messages'; +import { AbstractLLMProvider } from './base'; +import type { ProviderContext, ProviderId } from './types'; + +export class OllamaProvider extends AbstractLLMProvider { + readonly id: ProviderId = 'ollama'; + readonly displayName = 'Ollama (local)'; + readonly apiKeyConfigKey = ConfigKeys.OLLAMA_BASE_URL; + readonly requiresApiKey = false; + + constructor(ctx: ProviderContext) { + super(ctx); + } + + private get baseURL(): string { + return ( + this.ctx.config.getConfig( + ConfigKeys.OLLAMA_BASE_URL, + 'http://localhost:11434/v1' + ) || 'http://localhost:11434/v1' + ); + } + + private createClient(): OpenAI { + return new OpenAI({ apiKey: 'ollama', baseURL: this.baseURL }); + } + + override async validate(): Promise { + try { + const res = await fetch(`${this.baseURL.replace(/\/v1$/, '')}/api/tags`, { + method: 'GET' + }); + if (!res.ok) { + throw new Error(`Ollama responded with ${res.status}`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Cannot reach Ollama at ${this.baseURL}. Make sure 'ollama serve' is running. (${msg})`, + { cause: err } + ); + } + } + + protected async doGenerate( + messages: ChatMessage[], + opts: GenerateOptions + ): Promise { + const client = this.createClient(); + const model = this.ctx.config.getConfig( + ConfigKeys.OLLAMA_MODEL, + 'llama3.2' + ); + const temperature = this.ctx.config.getConfig( + ConfigKeys.OLLAMA_TEMPERATURE, + 0.7 + ); + + const completion = await client.chat.completions.create( + { + model, + messages: messages as ChatCompletionMessageParam[], + temperature + }, + opts.signal ? { signal: opts.signal } : undefined + ); + const text = completion.choices[0]?.message?.content; + if (!text) throw new Error('Ollama returned an empty response'); + return text; + } + + async *generateStream( + messages: ChatMessage[], + opts: GenerateOptions = {} + ): AsyncIterable { + const client = this.createClient(); + const model = this.ctx.config.getConfig( + ConfigKeys.OLLAMA_MODEL, + 'llama3.2' + ); + const temperature = this.ctx.config.getConfig( + ConfigKeys.OLLAMA_TEMPERATURE, + 0.7 + ); + + const stream = await client.chat.completions.create( + { + model, + messages: messages as ChatCompletionMessageParam[], + temperature, + stream: true + }, + opts.signal ? { signal: opts.signal } : undefined + ); + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta?.content; + if (delta) yield delta; + } + } + + async listModels(): Promise { + const res = await fetch(`${this.baseURL.replace(/\/v1$/, '')}/api/tags`); + if (!res.ok) { + throw new Error(`Failed to list Ollama models: ${res.status}`); + } + const data = (await res.json()) as { models?: Array<{ name: string }> }; + return (data.models ?? []).map((m) => m.name); + } +} diff --git a/src/providers/openai.ts b/src/providers/openai.ts new file mode 100644 index 0000000..157df2d --- /dev/null +++ b/src/providers/openai.ts @@ -0,0 +1,230 @@ +import OpenAI from 'openai'; +import type { ChatCompletionMessageParam } from 'openai/resources'; +import type { ReasoningEffort } from 'openai/resources/shared'; +import { ConfigKeys } from '../config'; +import type { ChatMessage, GenerateOptions } from '../types/messages'; +import { AbstractLLMProvider } from './base'; +import type { ProviderContext, ProviderId } from './types'; + +interface OpenAIClientConfig { + apiKey: string; + baseURL?: string; + defaultQuery?: { 'api-version': string }; + defaultHeaders?: { 'api-key': string }; +} + +function buildClientConfig(ctx: ProviderContext, apiKey: string): OpenAIClientConfig { + const baseURL = ctx.config.getConfig(ConfigKeys.OPENAI_BASE_URL); + const apiVersion = ctx.config.getConfig(ConfigKeys.AZURE_API_VERSION); + + const cfg: OpenAIClientConfig = { apiKey }; + if (baseURL) { + cfg.baseURL = baseURL; + if (apiVersion) { + cfg.defaultQuery = { 'api-version': apiVersion }; + cfg.defaultHeaders = { 'api-key': apiKey }; + } + } + return cfg; +} + +/** + * Helper used by ConfigurationManager.updateOpenAIModelList(). Reads + * the API key via SecretsManager first, falling back to settings. + */ +export async function createOpenAIClient(ctx: ProviderContext): Promise { + const fromSecrets = await ctx.secrets.getApiKey('openai'); + const fromSettings = ctx.config.getConfig(ConfigKeys.OPENAI_API_KEY); + const apiKey = (fromSecrets || fromSettings || '').trim(); + if (!apiKey) { + throw new Error('OPENAI_API_KEY is missing. Run "AI Commit: Set API Key".'); + } + return new OpenAI(buildClientConfig(ctx, apiKey)); +} + +export class OpenAIProvider extends AbstractLLMProvider { + readonly id: ProviderId = 'openai'; + readonly displayName = 'OpenAI'; + readonly apiKeyConfigKey = ConfigKeys.OPENAI_API_KEY; + + private async createClient(): Promise { + const apiKey = await this.resolveApiKey(); + return new OpenAI(buildClientConfig(this.ctx, apiKey)); + } + + protected async doGenerate( + messages: ChatMessage[], + opts: GenerateOptions + ): Promise { + const apiType = this.ctx.config.getConfig( + ConfigKeys.OPENAI_API_TYPE, + 'completion' + ); + return apiType === 'response' + ? this.callResponsesAPI(messages, opts) + : this.callChatCompletions(messages, opts); + } + + private async callChatCompletions( + messages: ChatMessage[], + opts: GenerateOptions + ): Promise { + const openai = await this.createClient(); + const model = this.ctx.config.getConfig(ConfigKeys.OPENAI_MODEL); + const temperature = this.ctx.config.getConfig( + ConfigKeys.OPENAI_TEMPERATURE, + 0.7 + ); + + try { + const completion = await openai.chat.completions.create( + { + model, + messages: messages as ChatCompletionMessageParam[], + temperature + }, + opts.signal ? { signal: opts.signal } : undefined + ); + const text = completion.choices[0]?.message?.content; + if (!text) throw new Error('OpenAI returned an empty response'); + return text; + } catch (err) { + throw this.wrapError(err); + } + } + + private async callResponsesAPI( + messages: ChatMessage[], + opts: GenerateOptions + ): Promise { + const openai = await this.createClient(); + const model = this.ctx.config.getConfig(ConfigKeys.OPENAI_MODEL); + const reasoningEffort = this.ctx.config.getConfig( + ConfigKeys.OPENAI_REASONING_EFFORT, + 'medium' + ); + const textVerbosity = this.ctx.config.getConfig( + ConfigKeys.OPENAI_TEXT_VERBOSITY, + 'medium' + ); + + const verbosityTokenMap: Record = { + low: 1000, + medium: 4000, + high: 16000 + }; + const maxOutputTokens = verbosityTokenMap[textVerbosity] ?? 4000; + + const systemMsg = messages.find((m) => m.role === 'system'); + const instructions = systemMsg?.content; + + const inputMessages = messages + .filter((m) => m.role !== 'system') + .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })); + + try { + const response = await openai.responses.create( + { + model, + ...(instructions ? { instructions } : {}), + input: inputMessages, + reasoning: { effort: reasoningEffort as ReasoningEffort }, + max_output_tokens: maxOutputTokens + }, + opts.signal ? { signal: opts.signal } : undefined + ); + const text = response.output_text; + if (!text) throw new Error('OpenAI Responses API returned an empty response'); + return text; + } catch (err) { + throw this.wrapError(err); + } + } + + async *generateStream( + messages: ChatMessage[], + opts: GenerateOptions = {} + ): AsyncIterable { + const apiType = this.ctx.config.getConfig( + ConfigKeys.OPENAI_API_TYPE, + 'completion' + ); + + const openai = await this.createClient(); + const model = this.ctx.config.getConfig(ConfigKeys.OPENAI_MODEL); + + if (apiType === 'response') { + // Responses API: stream via openai.responses.stream + const reasoningEffort = this.ctx.config.getConfig( + ConfigKeys.OPENAI_REASONING_EFFORT, + 'medium' + ); + const systemMsg = messages.find((m) => m.role === 'system'); + const inputMessages = messages + .filter((m) => m.role !== 'system') + .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })); + + const stream = await openai.responses.create( + { + model, + ...(systemMsg ? { instructions: systemMsg.content } : {}), + input: inputMessages, + reasoning: { effort: reasoningEffort as ReasoningEffort }, + stream: true + }, + opts.signal ? { signal: opts.signal } : undefined + ); + for await (const event of stream) { + const anyEvent = event as { type: string; delta?: string }; + if (anyEvent.type === 'response.output_text.delta' && anyEvent.delta) { + yield anyEvent.delta; + } + } + return; + } + + const temperature = this.ctx.config.getConfig( + ConfigKeys.OPENAI_TEMPERATURE, + 0.7 + ); + const stream = await openai.chat.completions.create( + { + model, + messages: messages as ChatCompletionMessageParam[], + temperature, + stream: true + }, + opts.signal ? { signal: opts.signal } : undefined + ); + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta?.content; + if (delta) yield delta; + } + } + + private wrapError(err: unknown): Error { + const status = + (err as { response?: { status?: number }; status?: number })?.response?.status ?? + (err as { status?: number })?.status; + if (status) { + switch (status) { + case 401: + return new Error('Invalid OpenAI API key or unauthorized access'); + case 429: + return new Error('Rate limit exceeded. Please try again later'); + case 500: + return new Error('OpenAI server error. Please try again later'); + case 503: + return new Error('OpenAI service is temporarily unavailable'); + } + } + if (err instanceof Error) return err; + return new Error(typeof err === 'string' ? err : 'OpenAI request failed'); + } + + async listModels(): Promise { + const openai = await this.createClient(); + const models = await openai.models.list(); + return models.data.map((m) => m.id); + } +} diff --git a/src/providers/presets.ts b/src/providers/presets.ts new file mode 100644 index 0000000..ed8690b --- /dev/null +++ b/src/providers/presets.ts @@ -0,0 +1,49 @@ +export interface OpenAICompatiblePreset { + id: string; + displayName: string; + baseURL: string; + recommendedModels: string[]; + homepage: string; +} + +export const OPENAI_COMPATIBLE_PRESETS: OpenAICompatiblePreset[] = [ + { + id: 'deepseek', + displayName: 'DeepSeek', + baseURL: 'https://api.deepseek.com/v1', + recommendedModels: ['deepseek-chat', 'deepseek-reasoner'], + homepage: 'https://platform.deepseek.com' + }, + { + id: 'zhipu', + displayName: 'Zhipu GLM', + baseURL: 'https://open.bigmodel.cn/api/paas/v4', + recommendedModels: ['glm-4-flash', 'glm-4-plus'], + homepage: 'https://bigmodel.cn' + }, + { + id: 'qwen', + displayName: 'Qwen (DashScope)', + baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + recommendedModels: ['qwen-plus', 'qwen-max'], + homepage: 'https://dashscope.console.aliyun.com' + }, + { + id: 'groq', + displayName: 'Groq', + baseURL: 'https://api.groq.com/openai/v1', + recommendedModels: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'], + homepage: 'https://console.groq.com' + }, + { + id: 'openrouter', + displayName: 'OpenRouter', + baseURL: 'https://openrouter.ai/api/v1', + recommendedModels: [ + 'anthropic/claude-sonnet-4.5', + 'openai/gpt-5', + 'google/gemini-2.5-pro' + ], + homepage: 'https://openrouter.ai' + } +]; diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..1391e2c --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,27 @@ +import type { ConfigurationManager } from '../config'; +import type { Logger } from '../logger'; +import type { SecretsManager } from '../secrets'; +import type { ChatMessage, GenerateOptions } from '../types/messages'; + +export type ProviderId = 'openai' | 'claude' | 'gemini' | 'ollama'; + +export interface ProviderContext { + config: ConfigurationManager; + secrets: SecretsManager; + logger: typeof Logger; +} + +export interface LLMProvider { + readonly id: ProviderId; + readonly displayName: string; + readonly apiKeyConfigKey: string; + readonly requiresApiKey: boolean; + + validate(): Promise; + generate(messages: ChatMessage[], opts?: GenerateOptions): Promise; + generateStream?( + messages: ChatMessage[], + opts?: GenerateOptions + ): AsyncIterable; + listModels?(): Promise; +} diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 0000000..4d5fd45 --- /dev/null +++ b/src/secrets.ts @@ -0,0 +1,54 @@ +import * as vscode from 'vscode'; +import { Logger } from './logger'; + +export type SecretProvider = 'openai' | 'claude' | 'gemini' | 'ollama'; + +const MIGRATED_FLAG = 'ai-commit.secretsMigrated'; +const KNOWN_PROVIDERS: SecretProvider[] = ['openai', 'claude', 'gemini']; + +export class SecretsManager { + constructor(private context: vscode.ExtensionContext) {} + + private secretKey(provider: SecretProvider): string { + return `ai-commit.${provider.toUpperCase()}_API_KEY`; + } + + async getApiKey(provider: SecretProvider): Promise { + if (provider === 'ollama') return ''; + return (await this.context.secrets.get(this.secretKey(provider))) ?? ''; + } + + async setApiKey(provider: SecretProvider, value: string): Promise { + if (provider === 'ollama') return; + await this.context.secrets.store(this.secretKey(provider), value); + } + + async deleteApiKey(provider: SecretProvider): Promise { + if (provider === 'ollama') return; + await this.context.secrets.delete(this.secretKey(provider)); + } + + /** + * One-time migration of API keys from settings.json into SecretStorage. + * Runs idempotently — guarded by globalState flag. + */ + async migrate(): Promise { + if (this.context.globalState.get(MIGRATED_FLAG)) return; + + const cfg = vscode.workspace.getConfiguration('ai-commit'); + for (const provider of KNOWN_PROVIDERS) { + const settingKey = `${provider.toUpperCase()}_API_KEY`; + const value = cfg.get(settingKey); + if (value && value.trim() !== '') { + try { + await this.setApiKey(provider, value.trim()); + await cfg.update(settingKey, '', vscode.ConfigurationTarget.Global); + Logger.info(`Migrated ${provider} API key from settings to SecretStorage`); + } catch (err) { + Logger.warn(`Migration failed for ${provider}; leaving setting in place`, err); + } + } + } + await this.context.globalState.update(MIGRATED_FLAG, true); + } +} diff --git a/src/types/messages.ts b/src/types/messages.ts new file mode 100644 index 0000000..01d96a7 --- /dev/null +++ b/src/types/messages.ts @@ -0,0 +1,11 @@ +export type ChatRole = 'system' | 'user' | 'assistant'; + +export interface ChatMessage { + role: ChatRole; + content: string; +} + +export interface GenerateOptions { + signal?: AbortSignal; + onToken?: (chunk: string) => void; +} diff --git a/src/utils.ts b/src/utils.ts index 2680017..e0550fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,13 +1,11 @@ import * as vscode from 'vscode'; -/** - * Adds progress handling functionality. - */ export class ProgressHandler { static async withProgress( title: string, task: ( - progress: vscode.Progress<{ message?: string; increment?: number }> + progress: vscode.Progress<{ message?: string; increment?: number }>, + token: vscode.CancellationToken ) => Promise ): Promise { return vscode.window.withProgress( @@ -20,3 +18,18 @@ export class ProgressHandler { ); } } + +/** + * Bridge a VS Code CancellationToken to an AbortController so SDK calls can + * react to the user pressing Cancel on the progress notification. + */ +export function createAbortBridge( + token: vscode.CancellationToken +): { signal: AbortSignal; dispose: () => void } { + const controller = new AbortController(); + const sub = token.onCancellationRequested(() => controller.abort()); + return { + signal: controller.signal, + dispose: () => sub.dispose() + }; +} diff --git a/test/__mocks__/vscode.ts b/test/__mocks__/vscode.ts new file mode 100644 index 0000000..11d0b48 --- /dev/null +++ b/test/__mocks__/vscode.ts @@ -0,0 +1,79 @@ +/** + * Minimal stub of the `vscode` module so unit tests can import production code + * without spinning up an Extension Host. + */ + +const configStore = new Map(); + +export const ConfigurationTarget = { + Global: 1 as const, + Workspace: 2 as const, + WorkspaceFolder: 3 as const +}; + +export const ProgressLocation = { + SourceControl: 1 as const, + Window: 10 as const, + Notification: 15 as const +}; + +class FakeConfig { + get(key: string, defaultValue?: T): T { + return (configStore.get(`ai-commit.${key}`) as T) ?? (defaultValue as T); + } + async update(key: string, value: unknown) { + configStore.set(`ai-commit.${key}`, value); + } +} + +export const workspace = { + getConfiguration(_section?: string) { + return new FakeConfig(); + }, + onDidChangeConfiguration(_listener: unknown) { + return { dispose() {} }; + }, + workspaceFolders: undefined as undefined | Array<{ uri: { fsPath: string } }> +}; + +export const window = { + withProgress: async (_opts: unknown, task: (p: unknown, t: unknown) => Promise) => + task({ report: () => undefined }, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }), + showInformationMessage: async () => undefined, + showWarningMessage: async () => undefined, + showErrorMessage: async () => undefined, + showInputBox: async () => undefined, + showQuickPick: async () => undefined, + createOutputChannel: () => ({ + appendLine: () => undefined, + show: () => undefined, + dispose: () => undefined + }) +}; + +export const commands = { + registerCommand: () => ({ dispose() {} }), + executeCommand: async () => undefined +}; + +export const extensions = { + getExtension: () => undefined +}; + +class FakeUri { + constructor(public fsPath: string) {} +} +export const Uri = { + file: (p: string) => new FakeUri(p) +}; + +export type Disposable = { dispose(): void }; + +export const __testHelpers = { + setConfig(key: string, value: unknown) { + configStore.set(`ai-commit.${key}`, value); + }, + clearConfig() { + configStore.clear(); + } +}; diff --git a/test/diff-processor.test.ts b/test/diff-processor.test.ts new file mode 100644 index 0000000..5d5a7be --- /dev/null +++ b/test/diff-processor.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_EXCLUDE_PATTERNS, + processDiff +} from '../src/diff-processor'; + +const sampleDiff = (path: string, body = 'line1\nline2') => + `diff --git a/${path} b/${path}\nindex 1234567..abcdefg 100644\n--- a/${path}\n+++ b/${path}\n@@ -1,2 +1,2 @@\n${body}\n`; + +describe('processDiff', () => { + it('returns empty result for empty diff', () => { + const result = processDiff('', { + maxTokens: 1000, + excludePatterns: [] + }); + expect(result.diff).toBe(''); + expect(result.truncated).toBe(false); + expect(result.excludedFiles).toEqual([]); + }); + + it('excludes lock files by default', () => { + const raw = sampleDiff('pnpm-lock.yaml') + sampleDiff('src/app.ts'); + const result = processDiff(raw, { + maxTokens: 10000, + excludePatterns: DEFAULT_EXCLUDE_PATTERNS + }); + expect(result.excludedFiles).toContain('pnpm-lock.yaml'); + expect(result.diff).toContain('src/app.ts'); + expect(result.diff).not.toContain('pnpm-lock.yaml'); + }); + + it('excludes generated output directories', () => { + const raw = sampleDiff('dist/bundle.js') + sampleDiff('src/index.ts'); + const result = processDiff(raw, { + maxTokens: 10000, + excludePatterns: DEFAULT_EXCLUDE_PATTERNS + }); + expect(result.excludedFiles).toContain('dist/bundle.js'); + expect(result.diff).toContain('src/index.ts'); + }); + + it('excludes .min files', () => { + const raw = + sampleDiff('public/jquery.min.js') + sampleDiff('public/styles.min.css'); + const result = processDiff(raw, { + maxTokens: 10000, + excludePatterns: DEFAULT_EXCLUDE_PATTERNS + }); + expect(result.excludedFiles).toEqual( + expect.arrayContaining(['public/jquery.min.js', 'public/styles.min.css']) + ); + expect(result.diff.trim()).toBe(''); + }); + + it('respects custom exclude patterns', () => { + const raw = sampleDiff('src/secrets.ts') + sampleDiff('src/main.ts'); + const result = processDiff(raw, { + maxTokens: 10000, + excludePatterns: [/secrets\.ts$/] + }); + expect(result.excludedFiles).toContain('src/secrets.ts'); + expect(result.diff).toContain('src/main.ts'); + }); + + it('truncates when total token estimate exceeds maxTokens', () => { + const bigBody = 'x'.repeat(40000); + const raw = sampleDiff('a.ts', bigBody) + sampleDiff('b.ts', 'small body'); + const result = processDiff(raw, { + maxTokens: 2000, + excludePatterns: [] + }); + expect(result.truncated).toBe(true); + expect(result.diff).toContain('truncated due to size limit'); + }); + + it('keeps all chunks when under token budget', () => { + const raw = sampleDiff('a.ts') + sampleDiff('b.ts'); + const result = processDiff(raw, { + maxTokens: 10000, + excludePatterns: [] + }); + expect(result.truncated).toBe(false); + expect(result.diff).toContain('a.ts'); + expect(result.diff).toContain('b.ts'); + }); +}); diff --git a/test/git-utils.test.ts b/test/git-utils.test.ts new file mode 100644 index 0000000..2b1c4fb --- /dev/null +++ b/test/git-utils.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const diffMock = vi.fn(); + +vi.mock('simple-git', () => ({ + default: () => ({ diff: diffMock }) +})); + +const { getDiffStaged } = await import('../src/git-utils'); + +describe('getDiffStaged', () => { + beforeEach(() => diffMock.mockReset()); + + it('passes --staged to git diff and returns the diff string', async () => { + diffMock.mockResolvedValue('diff --git a/x b/x\n+hello'); + const result = await getDiffStaged({ rootUri: { fsPath: '/tmp/repo' } }); + expect(diffMock).toHaveBeenCalledWith(['--staged']); + expect(result.diff).toContain('hello'); + expect(result.error).toBeUndefined(); + }); + + it('returns the sentinel string when diff is empty', async () => { + diffMock.mockResolvedValue(''); + const result = await getDiffStaged({ rootUri: { fsPath: '/tmp/repo' } }); + expect(result.diff).toBe('No changes staged.'); + }); + + it('wraps git failures into structured error', async () => { + diffMock.mockImplementationOnce(() => + Promise.reject(new Error('not a git repo')) + ); + const result = await getDiffStaged({ rootUri: { fsPath: '/tmp/repo' } }); + expect(result.diff).toBe(''); + expect(result.error).toMatch(/Failed to read git diff/); + expect(result.error).toContain('not a git repo'); + }); + + it('throws when no repo and no workspace folder is available', async () => { + const result = await getDiffStaged(undefined); + expect(result.diff).toBe(''); + expect(result.error).toMatch(/No workspace folder found/); + }); +}); diff --git a/test/logger-format.test.ts b/test/logger-format.test.ts new file mode 100644 index 0000000..a055033 --- /dev/null +++ b/test/logger-format.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; + +const appendLine = vi.fn(); + +vi.mock('vscode', async () => { + const actual = await vi.importActual>('./__mocks__/vscode'); + return { + ...actual, + window: { + ...((actual as { window: Record }).window), + createOutputChannel: () => ({ + appendLine, + show: () => undefined, + dispose: () => undefined + }) + } + }; +}); + +const { Logger } = await import('../src/logger'); + +describe('Logger format & redact pipeline', () => { + it('uninitialized logger silently swallows messages', () => { + appendLine.mockReset(); + Logger.info('test message'); + expect(appendLine).not.toHaveBeenCalled(); + }); + + it('initialize() enables Output channel writes', () => { + appendLine.mockReset(); + Logger.initialize(); + Logger.info('hello world'); + expect(appendLine).toHaveBeenCalledTimes(1); + expect(appendLine.mock.calls[0]?.[0]).toContain('hello world'); + }); + + it('formats Error objects with truncated stack', () => { + appendLine.mockReset(); + Logger.initialize(); + const err = new Error('boom'); + err.stack = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7', 'L8'].join('\n'); + Logger.error('failed', err); + const out = appendLine.mock.calls[0]?.[0] as string; + expect(out).toContain('failed'); + expect(out).toContain('boom'); + // Only 6 stack lines should remain + expect((out.match(/L\d/g) ?? []).length).toBeLessThanOrEqual(6); + }); + + it('JSON-stringifies object arguments', () => { + appendLine.mockReset(); + Logger.initialize(); + Logger.info('payload:', { provider: 'openai', model: 'gpt-4o' }); + const out = appendLine.mock.calls[0]?.[0] as string; + expect(out).toContain('"provider": "openai"'); + }); + + it('redacts API keys embedded in messages', () => { + appendLine.mockReset(); + Logger.initialize(); + Logger.info('using sk-abcdefghijklmnopqrstuvwxyz12'); + const out = appendLine.mock.calls[0]?.[0] as string; + expect(out).not.toContain('sk-abcdefghijklmnopqrstuvwxyz12'); + expect(out).toContain('***'); + }); + + it('warn and error are separate levels', () => { + appendLine.mockReset(); + Logger.initialize(); + Logger.warn('a warning'); + Logger.error('an error'); + expect(appendLine.mock.calls[0]?.[0]).toContain('[WARN]'); + expect(appendLine.mock.calls[1]?.[0]).toContain('[ERROR]'); + }); +}); diff --git a/test/logger.test.ts b/test/logger.test.ts new file mode 100644 index 0000000..a5e96ae --- /dev/null +++ b/test/logger.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { redactSecrets } from '../src/logger'; + +describe('redactSecrets', () => { + it('redacts OpenAI sk- keys', () => { + const text = 'API call with key sk-abcd1234567890abcdefghij'; + const out = redactSecrets(text); + expect(out).not.toContain('abcd1234567890abcd'); + expect(out).toContain('***'); + }); + + it('redacts Anthropic sk-ant- keys', () => { + const text = 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz'; + const out = redactSecrets(text); + expect(out).not.toMatch(/sk-ant-api03-abcdefghijklmnopqrstuvwxyz/); + expect(out).toContain('***'); + }); + + it('redacts Google AIza keys', () => { + const text = 'AIzaSyDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + const out = redactSecrets(text); + expect(out).not.toContain('AIzaSyDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'); + }); + + it('redacts Groq gsk_ keys', () => { + const text = 'gsk_abc123def456ghi789jkl000'; + const out = redactSecrets(text); + expect(out).toContain('***'); + }); + + it('preserves trailing characters for traceability', () => { + const text = 'sk-abcdefghijklmnopqrstuvxyz9876'; + const out = redactSecrets(text); + expect(out).toContain('9876'); + }); + + it('passes through unmatched text unchanged', () => { + const text = 'no secret here, just text'; + expect(redactSecrets(text)).toBe(text); + }); +}); diff --git a/test/presets.test.ts b/test/presets.test.ts new file mode 100644 index 0000000..3206f33 --- /dev/null +++ b/test/presets.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { OPENAI_COMPATIBLE_PRESETS } from '../src/providers/presets'; + +describe('OPENAI_COMPATIBLE_PRESETS', () => { + it('includes all 5 documented presets', () => { + const ids = OPENAI_COMPATIBLE_PRESETS.map((p) => p.id); + expect(ids).toEqual( + expect.arrayContaining(['deepseek', 'zhipu', 'qwen', 'groq', 'openrouter']) + ); + }); + + it('each preset has a https baseURL', () => { + for (const p of OPENAI_COMPATIBLE_PRESETS) { + expect(p.baseURL).toMatch(/^https:\/\//); + } + }); + + it('each preset suggests at least one model', () => { + for (const p of OPENAI_COMPATIBLE_PRESETS) { + expect(p.recommendedModels.length).toBeGreaterThan(0); + } + }); +}); diff --git a/test/prompts.test.ts b/test/prompts.test.ts new file mode 100644 index 0000000..54ed730 --- /dev/null +++ b/test/prompts.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { __testHelpers } from './__mocks__/vscode'; + +const { ConfigurationManager } = await import('../src/config'); +const { getMainCommitPrompt } = await import('../src/prompts'); + +describe('getMainCommitPrompt', () => { + beforeEach(() => { + __testHelpers.clearConfig(); + // Force a fresh singleton each test by clearing private static via cast + (ConfigurationManager as unknown as { instance: unknown }).instance = undefined; + ConfigurationManager.getInstance({ + // minimum context for ConfigurationManager constructor + secrets: { get: async () => undefined, store: async () => undefined, delete: async () => undefined }, + globalState: { get: () => undefined, update: async () => undefined }, + subscriptions: [] + } as never); + }); + + it('returns exactly one system message by default', async () => { + __testHelpers.setConfig('AI_COMMIT_LANGUAGE', 'English'); + const messages = await getMainCommitPrompt(); + expect(messages).toHaveLength(1); + expect(messages[0]?.role).toBe('system'); + }); + + it('interpolates the configured language into the prompt body', async () => { + __testHelpers.setConfig('AI_COMMIT_LANGUAGE', 'Simplified Chinese'); + const messages = await getMainCommitPrompt(); + expect(messages[0]?.content).toContain('Simplified Chinese'); + }); + + it('uses custom system prompt when AI_COMMIT_SYSTEM_PROMPT is set', async () => { + __testHelpers.setConfig('AI_COMMIT_LANGUAGE', 'English'); + __testHelpers.setConfig('AI_COMMIT_SYSTEM_PROMPT', 'CUSTOM_PROMPT_BODY_XYZ'); + const messages = await getMainCommitPrompt(); + expect(messages[0]?.content).toBe('CUSTOM_PROMPT_BODY_XYZ'); + }); + + it('falls back to the built-in template when custom prompt is empty', async () => { + __testHelpers.setConfig('AI_COMMIT_LANGUAGE', 'English'); + __testHelpers.setConfig('AI_COMMIT_SYSTEM_PROMPT', ''); + const messages = await getMainCommitPrompt(); + expect(messages[0]?.content).toContain('Git Commit Message Guide'); + expect(messages[0]?.content).toContain('feat'); + }); +}); diff --git a/test/providers/base.test.ts b/test/providers/base.test.ts new file mode 100644 index 0000000..82f7a06 --- /dev/null +++ b/test/providers/base.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { AbstractLLMProvider } from '../../src/providers/base'; + +describe('AbstractLLMProvider.cleanThinkTags', () => { + it('strips ... blocks', () => { + const input = 'internal reasoningfeat: add feature'; + expect(AbstractLLMProvider.cleanThinkTags(input)).toBe('feat: add feature'); + }); + + it('handles multi-line think blocks', () => { + const input = '\nline1\nline2\n\nfeat: do thing'; + expect(AbstractLLMProvider.cleanThinkTags(input)).toBe('feat: do thing'); + }); + + it('strips multiple think blocks', () => { + const input = 'afeat:b work'; + expect(AbstractLLMProvider.cleanThinkTags(input)).toBe('feat: work'); + }); + + it('leaves text without think tags untouched', () => { + const input = 'feat(scope): clean commit'; + expect(AbstractLLMProvider.cleanThinkTags(input)).toBe(input); + }); + + it('returns the original value for empty input', () => { + expect(AbstractLLMProvider.cleanThinkTags('')).toBe(''); + }); +}); diff --git a/test/providers/claude.test.ts b/test/providers/claude.test.ts new file mode 100644 index 0000000..d2bd29f --- /dev/null +++ b/test/providers/claude.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const createMock = vi.fn(); + +vi.mock('@anthropic-ai/sdk', () => ({ + default: class { + public messages = { create: createMock }; + constructor(public opts: { apiKey: string }) {} + } +})); + +const { ClaudeProvider } = await import('../../src/providers/claude'); +const { ConfigKeys } = await import('../../src/config'); + +function makeCtx() { + return { + config: { + getConfig: (key: string, defaultValue?: T): T => { + if (key === ConfigKeys.CLAUDE_MODEL) return 'claude-sonnet-4-5' as unknown as T; + if (key === ConfigKeys.CLAUDE_TEMPERATURE) return 0.5 as unknown as T; + return defaultValue as T; + } + } as never, + secrets: { + getApiKey: async () => 'sk-ant-test-key-aaaaaaaaaaaaaaaaaaaaaaaa', + setApiKey: async () => undefined, + deleteApiKey: async () => undefined, + migrate: async () => undefined + } as never, + logger: {} as never + }; +} + +describe('ClaudeProvider', () => { + beforeEach(() => createMock.mockReset()); + + it('passes system message as the dedicated `system` parameter', async () => { + createMock.mockResolvedValue({ + content: [{ type: 'text', text: 'feat: add login' }] + }); + + const provider = new ClaudeProvider(makeCtx()); + await provider.generate([ + { role: 'system', content: 'YOU_ARE_COMMIT_BOT' }, + { role: 'user', content: 'diff text' } + ]); + + expect(createMock).toHaveBeenCalledTimes(1); + const args = createMock.mock.calls[0]?.[0] as { + system?: string; + messages: Array<{ role: string; content: string }>; + }; + expect(args.system).toBe('YOU_ARE_COMMIT_BOT'); + expect(args.messages).toEqual([{ role: 'user', content: 'diff text' }]); + }); + + it('uses configured model and temperature', async () => { + createMock.mockResolvedValue({ + content: [{ type: 'text', text: 'ok' }] + }); + const provider = new ClaudeProvider(makeCtx()); + await provider.generate([{ role: 'user', content: 'x' }]); + const args = createMock.mock.calls[0]?.[0] as { + model: string; + temperature: number; + }; + expect(args.model).toBe('claude-sonnet-4-5'); + expect(args.temperature).toBe(0.5); + }); +}); diff --git a/test/providers/factory.test.ts b/test/providers/factory.test.ts new file mode 100644 index 0000000..4e4b719 --- /dev/null +++ b/test/providers/factory.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { ClaudeProvider } from '../../src/providers/claude'; +import { createProvider } from '../../src/providers/factory'; +import { GeminiProvider } from '../../src/providers/gemini'; +import { OllamaProvider } from '../../src/providers/ollama'; +import { OpenAIProvider } from '../../src/providers/openai'; +import type { ProviderContext, ProviderId } from '../../src/providers/types'; + +function makeContext(providerId: ProviderId): ProviderContext { + return { + config: { + getConfig: (key: string, defaultValue?: T): T => + (key === 'AI_PROVIDER' ? (providerId as unknown as T) : (defaultValue as T)) + } as ProviderContext['config'], + secrets: { + getApiKey: async () => '', + setApiKey: async () => undefined, + deleteApiKey: async () => undefined, + migrate: async () => undefined + } as unknown as ProviderContext['secrets'], + logger: {} as ProviderContext['logger'] + }; +} + +describe('createProvider', () => { + it('returns OpenAIProvider for "openai"', () => { + expect(createProvider(makeContext('openai'))).toBeInstanceOf(OpenAIProvider); + }); + + it('returns ClaudeProvider for "claude"', () => { + expect(createProvider(makeContext('claude'))).toBeInstanceOf(ClaudeProvider); + }); + + it('returns GeminiProvider for "gemini"', () => { + expect(createProvider(makeContext('gemini'))).toBeInstanceOf(GeminiProvider); + }); + + it('returns OllamaProvider for "ollama"', () => { + expect(createProvider(makeContext('ollama'))).toBeInstanceOf(OllamaProvider); + }); + + it('defaults to OpenAIProvider for unknown ids', () => { + expect(createProvider(makeContext('unknown' as ProviderId))).toBeInstanceOf( + OpenAIProvider + ); + }); + + it('marks Ollama as not requiring an API key', () => { + const provider = createProvider(makeContext('ollama')); + expect(provider.requiresApiKey).toBe(false); + }); +}); diff --git a/test/providers/gemini.test.ts b/test/providers/gemini.test.ts new file mode 100644 index 0000000..471f926 --- /dev/null +++ b/test/providers/gemini.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Capture what gets passed into getGenerativeModel — this is the key +// regression test: system messages MUST be passed as systemInstruction, +// not flattened into sendMessage(). Previously the bug discarded them. + +const getGenerativeModelMock = vi.fn(); +const startChatMock = vi.fn(); +const sendMessageMock = vi.fn(); + +vi.mock('@google/generative-ai', () => ({ + GoogleGenerativeAI: class { + constructor(public apiKey: string) {} + getGenerativeModel(opts: unknown) { + getGenerativeModelMock(opts); + return { + startChat: (chatOpts: unknown) => { + startChatMock(chatOpts); + return { + sendMessage: async (msg: unknown) => { + sendMessageMock(msg); + return { response: { text: () => 'feat: hello' } }; + } + }; + } + }; + } + } +})); + +const { GeminiProvider } = await import('../../src/providers/gemini'); +const { ConfigKeys } = await import('../../src/config'); + +function makeCtx(secretKey = 'AIza-test-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx') { + return { + config: { + getConfig: (key: string, defaultValue?: T): T => { + if (key === ConfigKeys.GEMINI_MODEL) return 'gemini-2.0-flash-001' as unknown as T; + if (key === ConfigKeys.GEMINI_TEMPERATURE) return 0.7 as unknown as T; + return defaultValue as T; + } + } as never, + secrets: { + getApiKey: async () => secretKey, + setApiKey: async () => undefined, + deleteApiKey: async () => undefined, + migrate: async () => undefined + } as never, + logger: {} as never + }; +} + +describe('GeminiProvider', () => { + beforeEach(() => { + getGenerativeModelMock.mockClear(); + startChatMock.mockClear(); + sendMessageMock.mockClear(); + }); + + it('passes system message as systemInstruction (regression for old bug)', async () => { + const provider = new GeminiProvider(makeCtx()); + await provider.generate([ + { role: 'system', content: 'You are a commit message generator.' }, + { role: 'user', content: 'diff --git ...' } + ]); + + expect(getGenerativeModelMock).toHaveBeenCalledTimes(1); + const callArg = getGenerativeModelMock.mock.calls[0]?.[0] as { + systemInstruction?: { parts: Array<{ text: string }> }; + }; + expect(callArg.systemInstruction).toBeDefined(); + expect(callArg.systemInstruction?.parts?.[0]?.text).toBe( + 'You are a commit message generator.' + ); + }); + + it('does not include system content in sendMessage payload', async () => { + const provider = new GeminiProvider(makeCtx()); + await provider.generate([ + { role: 'system', content: 'SYSTEM_PROMPT' }, + { role: 'user', content: 'USER_DIFF' } + ]); + const payload = sendMessageMock.mock.calls[0]?.[0] as string[]; + expect(payload).not.toContain('SYSTEM_PROMPT'); + expect(payload).toContain('USER_DIFF'); + }); + + it('omits systemInstruction when no system message is present', async () => { + const provider = new GeminiProvider(makeCtx()); + await provider.generate([{ role: 'user', content: 'just user' }]); + const callArg = getGenerativeModelMock.mock.calls[0]?.[0] as { + systemInstruction?: unknown; + }; + expect(callArg.systemInstruction).toBeUndefined(); + }); + + it('wraps Gemini errors with a "Gemini API error" prefix', async () => { + sendMessageMock.mockImplementationOnce(() => { + throw new Error('quota exceeded'); + }); + const provider = new GeminiProvider(makeCtx()); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/Gemini API error: quota exceeded/); + }); + + it('aborts immediately when the signal is already aborted', async () => { + const ctrl = new AbortController(); + ctrl.abort(); + const provider = new GeminiProvider(makeCtx()); + await expect( + provider.generate([{ role: 'user', content: 'x' }], { signal: ctrl.signal }) + ).rejects.toThrow(/Gemini API error: Aborted/); + }); +}); diff --git a/test/providers/ollama.test.ts b/test/providers/ollama.test.ts new file mode 100644 index 0000000..75554a9 --- /dev/null +++ b/test/providers/ollama.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const chatCreate = vi.fn(); + +vi.mock('openai', () => ({ + default: class { + public chat = { completions: { create: chatCreate } }; + constructor(public opts: { apiKey: string; baseURL: string }) {} + } +})); + +const { OllamaProvider } = await import('../../src/providers/ollama'); +const { ConfigKeys } = await import('../../src/config'); + +function makeCtx(overrides: Record = {}) { + const values: Record = { + [ConfigKeys.OLLAMA_MODEL]: 'llama3.2', + [ConfigKeys.OLLAMA_TEMPERATURE]: 0.7, + [ConfigKeys.OLLAMA_BASE_URL]: 'http://localhost:11434/v1', + ...overrides + }; + return { + config: { + getConfig: (key: string, defaultValue?: T): T => + (values[key] as T) ?? (defaultValue as T) + } as never, + secrets: { + getApiKey: async () => '', + setApiKey: async () => undefined, + deleteApiKey: async () => undefined, + migrate: async () => undefined + } as never, + logger: {} as never + }; +} + +describe('OllamaProvider', () => { + beforeEach(() => chatCreate.mockReset()); + + it('does not require an API key', () => { + const provider = new OllamaProvider(makeCtx()); + expect(provider.requiresApiKey).toBe(false); + }); + + it('uses configured model name', async () => { + chatCreate.mockResolvedValue({ + choices: [{ message: { content: 'feat: add login' } }] + }); + const provider = new OllamaProvider( + makeCtx({ [ConfigKeys.OLLAMA_MODEL]: 'qwen2.5' }) + ); + await provider.generate([{ role: 'user', content: 'diff' }]); + const args = chatCreate.mock.calls[0]?.[0] as { model: string }; + expect(args.model).toBe('qwen2.5'); + }); + + it('forwards AbortSignal in request options', async () => { + chatCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok' } }] + }); + const controller = new AbortController(); + const provider = new OllamaProvider(makeCtx()); + await provider.generate([{ role: 'user', content: 'x' }], { + signal: controller.signal + }); + const opts = chatCreate.mock.calls[0]?.[1] as { signal?: AbortSignal }; + expect(opts?.signal).toBe(controller.signal); + }); + + it('throws when Ollama returns an empty response', async () => { + chatCreate.mockResolvedValue({ choices: [{ message: { content: '' } }] }); + const provider = new OllamaProvider(makeCtx()); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/empty response/); + }); +}); + +describe('OllamaProvider.validate', () => { + beforeEach(() => { + chatCreate.mockReset(); + vi.stubGlobal('fetch', vi.fn()); + }); + + it('rejects when Ollama is unreachable', async () => { + (globalThis.fetch as ReturnType).mockRejectedValue( + new Error('ECONNREFUSED') + ); + const provider = new OllamaProvider(makeCtx()); + await expect(provider.validate()).rejects.toThrow(/Cannot reach Ollama/); + }); + + it('accepts when /api/tags returns OK', async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200 + }); + const provider = new OllamaProvider(makeCtx()); + await expect(provider.validate()).resolves.toBeUndefined(); + }); + + it('listModels returns the names from /api/tags', async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + models: [{ name: 'llama3.2' }, { name: 'qwen2.5' }] + }) + }); + const provider = new OllamaProvider(makeCtx()); + const models = await provider.listModels(); + expect(models).toEqual(['llama3.2', 'qwen2.5']); + }); +}); diff --git a/test/providers/openai.test.ts b/test/providers/openai.test.ts new file mode 100644 index 0000000..0b2d780 --- /dev/null +++ b/test/providers/openai.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const chatCreate = vi.fn(); +const responsesCreate = vi.fn(); + +vi.mock('openai', () => ({ + default: class { + public chat = { completions: { create: chatCreate } }; + public responses = { create: responsesCreate }; + public models = { list: async () => ({ data: [{ id: 'gpt-4o' }] }) }; + constructor(public opts: unknown) {} + } +})); + +const { OpenAIProvider } = await import('../../src/providers/openai'); +const { ConfigKeys } = await import('../../src/config'); + +function makeCtx(overrides: Record = {}) { + const values: Record = { + [ConfigKeys.OPENAI_MODEL]: 'gpt-4o', + [ConfigKeys.OPENAI_TEMPERATURE]: 0.3, + [ConfigKeys.OPENAI_API_TYPE]: 'completion', + ...overrides + }; + return { + config: { + getConfig: (key: string, defaultValue?: T): T => + (values[key] as T) ?? (defaultValue as T) + } as never, + secrets: { + getApiKey: async () => 'sk-test-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + setApiKey: async () => undefined, + deleteApiKey: async () => undefined, + migrate: async () => undefined + } as never, + logger: {} as never + }; +} + +describe('OpenAIProvider', () => { + beforeEach(() => { + chatCreate.mockReset(); + responsesCreate.mockReset(); + }); + + it('calls chat.completions.create by default', async () => { + chatCreate.mockResolvedValue({ + choices: [{ message: { content: 'feat: hello' } }] + }); + const provider = new OpenAIProvider(makeCtx()); + const text = await provider.generate([ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'diff' } + ]); + expect(text).toBe('feat: hello'); + expect(chatCreate).toHaveBeenCalledTimes(1); + expect(responsesCreate).not.toHaveBeenCalled(); + }); + + it('switches to responses.create when OPENAI_API_TYPE=response', async () => { + responsesCreate.mockResolvedValue({ output_text: 'fix: bug' }); + const provider = new OpenAIProvider( + makeCtx({ [ConfigKeys.OPENAI_API_TYPE]: 'response' }) + ); + const text = await provider.generate([ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'diff' } + ]); + expect(text).toBe('fix: bug'); + expect(responsesCreate).toHaveBeenCalledTimes(1); + expect(chatCreate).not.toHaveBeenCalled(); + }); + + it('forwards AbortSignal in request options', async () => { + chatCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok' } }] + }); + const controller = new AbortController(); + const provider = new OpenAIProvider(makeCtx()); + await provider.generate([{ role: 'user', content: 'x' }], { + signal: controller.signal + }); + const opts = chatCreate.mock.calls[0]?.[1] as { signal?: AbortSignal }; + expect(opts?.signal).toBe(controller.signal); + }); + + it('maps HTTP 401 to a friendly auth error', async () => { + chatCreate.mockRejectedValue({ status: 401, message: 'Unauthorized' }); + const provider = new OpenAIProvider(makeCtx()); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/Invalid OpenAI API key/); + }); + + it('maps HTTP 429 to a rate-limit error', async () => { + chatCreate.mockRejectedValue({ status: 429 }); + const provider = new OpenAIProvider(makeCtx()); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/Rate limit exceeded/); + }); + + it('maps HTTP 500 to a server-error message', async () => { + chatCreate.mockRejectedValue({ status: 500 }); + const provider = new OpenAIProvider(makeCtx()); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/OpenAI server error/); + }); + + it('maps HTTP 503 to service-unavailable', async () => { + chatCreate.mockRejectedValue({ status: 503 }); + const provider = new OpenAIProvider(makeCtx()); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/temporarily unavailable/); + }); + + it('extracts status from err.response.status (legacy shape)', async () => { + chatCreate.mockRejectedValue({ response: { status: 401 } }); + const provider = new OpenAIProvider(makeCtx()); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/Invalid OpenAI API key/); + }); + + it('rejects on empty response', async () => { + chatCreate.mockResolvedValue({ choices: [{ message: { content: '' } }] }); + const provider = new OpenAIProvider(makeCtx()); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/empty response/); + }); + + it('listModels returns model ids from the API', async () => { + const provider = new OpenAIProvider(makeCtx()); + const models = await provider.listModels(); + expect(models).toEqual(['gpt-4o']); + }); + + it('Responses API path also propagates 401 errors', async () => { + responsesCreate.mockRejectedValue({ status: 401 }); + const provider = new OpenAIProvider( + makeCtx({ [ConfigKeys.OPENAI_API_TYPE]: 'response' }) + ); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/Invalid OpenAI API key/); + }); + + it('Responses API rejects on empty output_text', async () => { + responsesCreate.mockResolvedValue({ output_text: '' }); + const provider = new OpenAIProvider( + makeCtx({ [ConfigKeys.OPENAI_API_TYPE]: 'response' }) + ); + await expect( + provider.generate([{ role: 'user', content: 'x' }]) + ).rejects.toThrow(/empty response/); + }); +}); diff --git a/test/providers/streaming.test.ts b/test/providers/streaming.test.ts new file mode 100644 index 0000000..48d193b --- /dev/null +++ b/test/providers/streaming.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const chatCreate = vi.fn(); +const responsesCreate = vi.fn(); +const messagesStreamMock = vi.fn(); + +vi.mock('openai', () => ({ + default: class { + public chat = { completions: { create: chatCreate } }; + public responses = { create: responsesCreate }; + public models = { list: async () => ({ data: [{ id: 'gpt-4o' }] }) }; + constructor(public opts: unknown) {} + } +})); + +vi.mock('@anthropic-ai/sdk', () => ({ + default: class { + public messages = { stream: messagesStreamMock }; + constructor(public opts: { apiKey: string }) {} + } +})); + +const { OpenAIProvider } = await import('../../src/providers/openai'); +const { ClaudeProvider } = await import('../../src/providers/claude'); +const { ConfigKeys } = await import('../../src/config'); + +function openaiCtx(overrides: Record = {}) { + const values: Record = { + [ConfigKeys.OPENAI_MODEL]: 'gpt-4o', + [ConfigKeys.OPENAI_TEMPERATURE]: 0.7, + [ConfigKeys.OPENAI_API_TYPE]: 'completion', + ...overrides + }; + return { + config: { + getConfig: (key: string, defaultValue?: T): T => + (values[key] as T) ?? (defaultValue as T) + } as never, + secrets: { + getApiKey: async () => 'sk-test-aaaaaaaaaaaaaaaaaaaaaaaaaaaa', + setApiKey: async () => undefined, + deleteApiKey: async () => undefined, + migrate: async () => undefined + } as never, + logger: {} as never + }; +} + +function claudeCtx() { + return { + config: { + getConfig: (key: string, defaultValue?: T): T => { + if (key === ConfigKeys.CLAUDE_MODEL) { + return 'claude-sonnet-4-5' as unknown as T; + } + if (key === ConfigKeys.CLAUDE_TEMPERATURE) { + return 0.5 as unknown as T; + } + return defaultValue as T; + } + } as never, + secrets: { + getApiKey: async () => 'sk-ant-test', + setApiKey: async () => undefined, + deleteApiKey: async () => undefined, + migrate: async () => undefined + } as never, + logger: {} as never + }; +} + +async function collect(stream: AsyncIterable): Promise { + let out = ''; + for await (const chunk of stream) out += chunk; + return out; +} + +describe('OpenAIProvider.generateStream (chat completions)', () => { + beforeEach(() => { + chatCreate.mockReset(); + responsesCreate.mockReset(); + }); + + it('yields delta content from each chunk', async () => { + const chunks = [ + { choices: [{ delta: { content: 'feat: ' } }] }, + { choices: [{ delta: { content: 'add ' } }] }, + { choices: [{ delta: { content: 'login' } }] } + ]; + chatCreate.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (const c of chunks) yield c; + } + }); + + const provider = new OpenAIProvider(openaiCtx()); + const text = await collect( + provider.generateStream([{ role: 'user', content: 'diff' }]) + ); + expect(text).toBe('feat: add login'); + const opts = chatCreate.mock.calls[0]?.[0] as { stream: boolean }; + expect(opts.stream).toBe(true); + }); +}); + +describe('OpenAIProvider.generateStream (responses API)', () => { + beforeEach(() => { + chatCreate.mockReset(); + responsesCreate.mockReset(); + }); + + it('yields response.output_text.delta events', async () => { + const events = [ + { type: 'response.output_text.delta', delta: 'fix: ' }, + { type: 'response.output_text.delta', delta: 'bug' }, + { type: 'response.completed' } + ]; + responsesCreate.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (const e of events) yield e; + } + }); + + const provider = new OpenAIProvider( + openaiCtx({ [ConfigKeys.OPENAI_API_TYPE]: 'response' }) + ); + const text = await collect( + provider.generateStream([{ role: 'user', content: 'diff' }]) + ); + expect(text).toBe('fix: bug'); + }); +}); + +describe('ClaudeProvider.generateStream', () => { + beforeEach(() => messagesStreamMock.mockReset()); + + it('yields content_block_delta text events', async () => { + const events = [ + { type: 'message_start' }, + { type: 'content_block_delta', delta: { type: 'text_delta', text: 'feat: ' } }, + { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hello' } }, + { type: 'message_stop' } + ]; + messagesStreamMock.mockReturnValue({ + async *[Symbol.asyncIterator]() { + for (const e of events) yield e; + } + }); + + const provider = new ClaudeProvider(claudeCtx()); + const text = await collect( + provider.generateStream([ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'diff' } + ]) + ); + expect(text).toBe('feat: hello'); + }); +}); diff --git a/test/secrets.test.ts b/test/secrets.test.ts new file mode 100644 index 0000000..b07744a --- /dev/null +++ b/test/secrets.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SecretsManager } from '../src/secrets'; + +type FakeStorage = Map; +type FakeState = Map; + +interface FakeContext { + secrets: { + get: (key: string) => Promise; + store: (key: string, value: string) => Promise; + delete: (key: string) => Promise; + }; + globalState: { + get: (key: string) => T | undefined; + update: (key: string, value: unknown) => Promise; + }; +} + +function makeContext(): { ctx: FakeContext; storage: FakeStorage; state: FakeState } { + const storage: FakeStorage = new Map(); + const state: FakeState = new Map(); + const ctx: FakeContext = { + secrets: { + get: async (k) => storage.get(k), + store: async (k, v) => { + storage.set(k, v); + }, + delete: async (k) => { + storage.delete(k); + } + }, + globalState: { + get: (k: string) => state.get(k) as T | undefined, + update: async (k, v) => { + state.set(k, v); + } + } + }; + return { ctx, storage, state }; +} + +describe('SecretsManager', () => { + let mgr: SecretsManager; + let storage: FakeStorage; + let state: FakeState; + + beforeEach(() => { + const made = makeContext(); + storage = made.storage; + state = made.state; + mgr = new SecretsManager(made.ctx as never); + }); + + it('round-trips an OpenAI key', async () => { + await mgr.setApiKey('openai', 'sk-secret'); + expect(await mgr.getApiKey('openai')).toBe('sk-secret'); + }); + + it('returns empty string when no key is stored', async () => { + expect(await mgr.getApiKey('claude')).toBe(''); + }); + + it('deletes a key', async () => { + await mgr.setApiKey('gemini', 'AIza-test'); + await mgr.deleteApiKey('gemini'); + expect(await mgr.getApiKey('gemini')).toBe(''); + }); + + it('treats Ollama as keyless: setApiKey is a no-op', async () => { + await mgr.setApiKey('ollama', 'irrelevant'); + expect(await mgr.getApiKey('ollama')).toBe(''); + expect(storage.size).toBe(0); + }); + + it('migration is idempotent — second migrate() is a no-op', async () => { + state.set('ai-commit.secretsMigrated', true); + const storeSpy = vi.spyOn(mgr, 'setApiKey'); + await mgr.migrate(); + expect(storeSpy).not.toHaveBeenCalled(); + }); + + it('marks migration complete after running', async () => { + expect(state.get('ai-commit.secretsMigrated')).toBeUndefined(); + await mgr.migrate(); + expect(state.get('ai-commit.secretsMigrated')).toBe(true); + }); +}); diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..3b0b04c --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,6 @@ +import { beforeEach } from 'vitest'; +import { __testHelpers } from './__mocks__/vscode'; + +beforeEach(() => { + __testHelpers.clearConfig(); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 0000000..1c76016 --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createAbortBridge } from '../src/utils'; + +function makeToken(): { + isCancellationRequested: boolean; + onCancellationRequested: (cb: () => void) => { dispose: () => void }; + trigger: () => void; +} { + let callback: (() => void) | undefined; + return { + isCancellationRequested: false, + onCancellationRequested(cb) { + callback = cb; + return { dispose: () => (callback = undefined) }; + }, + trigger() { + callback?.(); + } + }; +} + +describe('createAbortBridge', () => { + it('provides an AbortSignal that starts unaborted', () => { + const token = makeToken(); + const { signal } = createAbortBridge(token as never); + expect(signal.aborted).toBe(false); + }); + + it('aborts the signal when the VS Code token fires cancellation', () => { + const token = makeToken(); + const { signal } = createAbortBridge(token as never); + token.trigger(); + expect(signal.aborted).toBe(true); + }); + + it('returns a dispose() that detaches the subscription', () => { + const token = makeToken(); + const { dispose } = createAbortBridge(token as never); + const spy = vi.fn(dispose); + spy(); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 311f84f..8618c35 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,19 +1,18 @@ { "compilerOptions": { "module": "commonjs", - "target": "ES2020", - "lib": ["ES2020", "DOM"], + "target": "ES2022", + "lib": ["ES2022", "DOM"], "sourceMap": true, "rootDir": "src", "outDir": "dist", - // "esModuleInterop": true, "skipLibCheck": true, - "strict": false /* enable all strict type-checking options */, - /* Additional Checks */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, "esModuleInterop": true, - "resolveJsonModule": true - } + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": ["node_modules", "dist", "out", "test", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9e70ba8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['test/**/*.test.ts'], + setupFiles: ['test/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['src/extension.ts', 'src/commands.ts'], + thresholds: { + statements: 50, + branches: 50, + functions: 50, + lines: 50 + } + } + }, + resolve: { + alias: { + vscode: new URL('./test/__mocks__/vscode.ts', import.meta.url).pathname + } + } +});