From 522de7d184cc710322f9400849a36d5f612133e7 Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Tue, 2 Jun 2026 18:33:55 +0800 Subject: [PATCH 1/5] feat: comprehensive Context Box and branch diff improvements - Fixed critical GitDiffProvider exitCode bug (git diff exits 1 on success) - Created BranchResolutionUtil with VCS_LOG_BRANCHES and VCS_LOG_COMMIT_SELECTION - Added PSI-based DependentMethodCollector for test generation quality - Implemented structured messages for LLM chat context - Added user message bubbles, Create PR button, Generate Tests button - Added ThemeColors (JBColor) for IntelliJ light/dark theme support - Added Readme tab with feature overview and jump navigation - Fixed login retry (removed premature loginAlreadyAttempted check) - Fixed response truncation (maxTokens 8192, finish_reason detection) - Added concurrency guard and HTTP/git process timeouts - Added DOCUMENTATION.md Co-Authored-By: Claude Opus 4.7 --- DOCUMENTATION.md | 257 +++++++++++++ .../ai/plugin/core/GenerationRequest.kt | 3 +- .../ai/plugin/core/PromptBuilder.kt | 15 +- .../openprojectx/ai/plugin/llm/LlmProvider.kt | 6 + .../ai/plugin/llm/LlmRuntimeLogger.kt | 4 + .../openprojectx/ai/plugin/llm/LlmSettings.kt | 2 +- .../ai/plugin/llm/OpenAiCompatibleProvider.kt | 32 +- plugin-idea/build.gradle.kts | 2 +- .../ai/plugin/BranchDiffPromptMenuAction.kt | 137 ++----- .../ai/plugin/BranchResolutionUtil.kt | 167 +++++++++ .../ai/plugin/ContextBoxStateService.kt | 29 +- .../ai/plugin/ContextBoxToolWindowFactory.kt | 342 +++++++++++++++--- .../ai/plugin/GenerateTestsService.kt | 38 +- .../openprojectx/ai/plugin/GitDiffProvider.kt | 23 +- .../org/openprojectx/ai/plugin/HttpClients.kt | 18 + .../ai/plugin/LlmAuthSessionService.kt | 20 +- .../ai/plugin/LlmProviderFactory.kt | 2 +- .../ai/plugin/SonarCubeToolWindowPanel.kt | 12 +- .../ai/plugin/SonarQubeCoverageAction.kt | 8 +- .../ai/plugin/SummarizeBranchDiffAction.kt | 135 +------ .../org/openprojectx/ai/plugin/ThemeColors.kt | 54 +++ .../ai/plugin/pr/GitRemoteParser.kt | 14 +- .../testgen/DependentMethodCollector.kt | 60 +++ .../src/main/resources/META-INF/plugin.xml | 5 +- .../samples/ApiAndJavaMixedSampleTest.java | 197 ++++++++++ 25 files changed, 1226 insertions(+), 356 deletions(-) create mode 100644 DOCUMENTATION.md create mode 100644 plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchResolutionUtil.kt create mode 100644 plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ThemeColors.kt create mode 100644 plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/DependentMethodCollector.kt create mode 100644 plugin-idea/src/test/java/org/openprojectx/ai/plugin/samples/ApiAndJavaMixedSampleTest.java diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..4949a84 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,257 @@ +# Code Quality Assistant (CQA) — 功能文档 + +## 快速开始 + +### 第一步:导入远程配置 + +1. **File → Settings → Tools → Code Quality Improver → Login 标签页** +2. 在 **Bitbucket Prompt Repo** 区域填写: + - **Repository URL**:如 `https://bitbucket.org/your-org/prompt-repo` + - **Branch**:默认 `main` + - **Token**:Bitbucket App Password 或 Personal Access Token +3. 点击 **Import Repo Config** 按钮 +4. 等待导入完成,提示 "Config imported successfully" + +这会将远程仓库中的 Prompt、Skill 模板和 UI 配置同步到本地 `~/.codeimprover/` 目录。 + +### 第二步:配置 LLM 登录 + +1. **File → Settings → Tools → Code Quality Improver → LLM 标签页** +2. **Provider**:选择 `openai-compatible`(兼容 OpenAI API 的端点,如 DeepSeek、OpenRouter 等) +3. **Model**:填入模型名,如 `deepseek-chat` +4. **Endpoint**:填入 API 端点,如 `https://api.deepseek.com/v1/chat/completions` +5. **API Key**:填入你的 API Key(或设置 **API Key Env** 环境变量名,如 `DEEPSEEK_API_KEY`) + +#### 备选:使用 Template Login 自动获取 Key + +如果 API Key 需要每次登录获取: +1. 在 **LLM → Login Template** 区域配置: + - **Method**:`POST` + - **URL**:登录端点 + - **Headers**:请求头(JSON 格式) + - **Body**:请求体模板,使用 `{{username}}` `{{password}}` `{{model}}` 变量 + - **Response Path**:JSONPath 表达式提取响应中的 API Key,如 `$.access_token` +2. 首次调用 LLM 时会弹出登录对话框,输入用户名和密码 +3. 勾选 **Remember** 保存凭证到 IntelliJ PasswordSafe + +--- + +## 功能详解 + +### 1. Test Generation(测试生成) + +**入口**: +- 在编辑器中打开 `.yaml` / `.yml`(OpenAPI 合约)或 `.java`(Java 源文件) +- 编辑器顶部出现通知栏:`[OpenAPI contract / Java source] detected` +- 点击 **"Generate Tests By AI"** 打开配置对话框 + +**对话框参数**: + +| 参数 | 说明 | +|------|------| +| **Framework** | `JUnit 5 + Rest Assured`(Java API 测试)或 `Karate`(Feature 文件) | +| **Prompt Profile** | 从 Prompt Manager 中选择预设的生成模板,影响生成风格和质量 | +| **Output Location** | 测试文件输出目录,默认自动推导(`src/main/java` → `src/test/java`) | +| **Class Name** | 生成的测试类名,默认 `<源类名>Test` | +| **Package Name** | Java package 名,Rest Assured 模式下自动从源文件路径推导 | +| **Base URL** | API 基础地址,用于 Rest Assured 的 `baseURI` | +| **Extra Notes** | 额外的生成指令,如 "包含边界测试"、"mock PaymentValidator" | + +**生成内容**: +- 自动收集被测试方法调用的外部依赖方法签名,发送给 LLM 以便正确 mock +- 生成完整的 JUnit 5 测试类,包含 `@BeforeAll` 公共设置和每个操作的测试方法 +- 结果写入项目文件系统,Context Box 中显示生成代码并支持跟进修改 + +--- + +### 2. Commit Message Generation(提交信息生成) + +**入口**: +- VCS Commit 窗口 → 工具栏 **"Generate Commit Message"** 下拉菜单 +- 选择一个 Prompt 配置文件 + +**所需上下文**: +- 自动收集当前暂存 + 未暂存的 git diff +- 如果分支名包含 JIRA Key 格式(如 `ABC-123`),自动作为前缀 + +**参数**(通过 Prompt Manager 配置): +- Prompt 模板决定生成风格(Conventional Commits、详细描述等) + +**输出**: +- 生成的 commit message 直接填充到 VCS Commit 输入框中 + +--- + +### 3. Branch Diff Analysis(分支差异分析) + +**入口**: +- 打开 **Git Log**(VCS → Log) +- 右键点击要比较的目标分支或 commit +- 菜单顶部选择 **"Analyze Branch Diff"** → 选择一个 Prompt 配置文件 + +**自动解析**: +- `sourceBranch`:当前所在分支 +- `targetBranch`:右键选中的分支(从 VCS Log 数据上下文中通过 `VCS_LOG_BRANCHES` 获取) +- 使用 `git diff target...source` 收集差异 + +**参数**(通过 Prompt Manager 配置): +- Prompt 模板决定分析重点(安全审查、性能影响、架构风险等) + +**输出**: +- Context Box 自动弹出,显示 USER 泡泡 `"Analyze changes on feature/A → main"` +- AI 回复以 "Branch Analysis" 标签显示分析摘要 +- 底部 **"Create PR →"** 按钮,可直接基于分析结果创建 PR + +--- + +### 4. Push & Create PR(推送并创建 PR) + +**入口**: +- VCS Commit 窗口 → 工具栏 **"Push and Create PR"** + +**对话框参数**: + +| 参数 | 说明 | +|------|------| +| **Create Pull Request after push** | 勾选后推送并自动创建 PR | +| **Target Branch** | PR 要合入的分支,默认 `main` | + +**自动流程**: +1. `git push origin ` +2. 收集分支差异 +3. AI 生成 PR 标题 + 描述 +4. 调用 Bitbucket 或 GitHub REST API 创建 PR +5. Context Box 记录分析摘要 +6. 通知显示 PR URL + +**前置条件**: +- Git 远程仓库已配置 +- 远程 URL 需为 Bitbucket 或 GitHub(自动识别) + +--- + +### 5. Code Generate & Review(代码生成与审查) + +**入口**: +- 在编辑器中选中代码 → 右键 → **"Code Generate & Review"** + +**对话框内容**: +- 列出 Prompt Manager 中所有 Code Generate 和 Code Review 分类下的 prompt +- 选择一个 prompt,可选填额外需求文本 + +**参数**(通过 Prompt Manager 配置): +- Prompt 模板定义生成/审查的目标和风格 +- 额外需求:用户自由填写的补充指令 + +**输出**: +- Context Box 中以对应分类标签显示 LLM 的回应 + +--- + +### 6. SonarQube Coverage(SonarQube 覆盖率) + +**入口**: +- **Tools 菜单 → SonarQube Coverage** + +**对话框参数**: + +| 参数 | 说明 | +|------|------| +| **Server URL** | SonarQube 服务器地址 | +| **Project Key** | SonarQube 项目标识 | +| **Token / Username / Password** | SonarQube 认证信息 | +| **Target Coverage %** | 目标覆盖率(用于筛选未达标的文件) | +| **Max Files** | 最多处理的文件数 | +| **Generate missing tests with AI** | 是否自动为未覆盖文件生成测试 | + +**功能**: +1. 从 SonarQube API 获取项目覆盖率数据 +2. 展示文件级覆盖率表格 +3. 本地扫描模式(不依赖 SonarQube 服务器):检测 TODO/FIXME、printStackTrace、空 catch、硬编码密钥 +4. 右键 Issue:**Go to Line**、**AI Fix**、**Mark as fixed** + +**输出**: +- Context Box 中以 "SonarQube Coverage" 标签显示报告 +- Sonar Cube 标签页中可视化展示指标卡片和 Issue 列表 + +--- + +### 7. Context Box Chat(上下文盒子聊天) + +**入口**: +- 右侧边栏 → **AI Context Box** 工具窗口 → **Context 标签页** + +**功能**: +- 像正常 LLM 聊天一样输入消息 +- 输入框文本 + Enter 发送 +- 对话历史以气泡形式展示(绿色 = 用户,深色 = AI) +- 历史自动保存 10 条上下文,支持多轮对话 +- 测试生成后可在聊天中追加 "增加边界测试" 等指令来重新生成 +- 重新生成的代码带 **"Generate Tests →"** 按钮,点击直接覆写文件 + +**参数**:无(纯文本输入) + +--- + +### 8. Prompt Manager(提示词管理器) + +**入口**: +- AI Context Box 侧边栏 → **Prompt Manager 标签页** + +**功能**: +- 管理 5 个分类下的 Prompt 模板:Test Generate、Commit Generate、Branch Compare、Code Generate、Code Review +- 创建 / 编辑 / 复制 / 删除 Prompt +- 从远程 Bitbucket/GitHub 仓库同步 Prompt +- 搜索、排序、按分类筛选 +- 全局 Prompt(蓝色地球图标)vs 本地 Prompt(灰色文件夹图标) + +**Prompt 模板语法**: +- 使用 `{{variable}}` 占位符注入上下文变量 + +--- + +### 9. Skill Manager(技能管理器) + +**入口**: +- AI Context Box 侧边栏 → **Skill Manager 标签页** + +**功能**: +- 管理 Skill 定义(YAML + Markdown 模板) +- 全局 Skill 和本地 `.md` 文件 Skill +- 创建 / 编辑 / 复制 / 删除 Skill +- 从远程仓库同步 Skill + +--- + +### 10. Settings(设置) + +**入口**: +- **File → Settings → Tools → Code Quality Improver** + +**标签页**: + +| 标签 | 配置项 | +|------|--------| +| **Login** | Bitbucket 仓库 URL、Token、登录模板配置 | +| **LLM** | Provider、Model、Endpoint、API Key、Timeout、Max Tokens、TLS 设置 | +| **Sonar Cube** | 服务器 URL、项目 Key、Token、覆盖率目标、本地/在线扫描模式 | +| **Prompts** | 各类 Prompt 默认模板、Prompt Profiles YAML 配置 | + +--- + +## 配置目录 + +所有配置存储在 `~/.codeimprover/` 下: + +``` +~/.codeimprover/ +├── .ai-test.yaml # 主配置文件 +├── usage.yaml # 使用统计 +├── prompts/ # 本地 Prompt 模板 +├── skills/ # 本地 Skill 模板 +└── projects/ # 项目级 SonarQube 配置 +``` + +## 在线文档 + +最新文档和 Prompt/Skill 模板托管在 Bitbucket 仓库中,通过 Settings → Login → Import Repo Config 同步。 diff --git a/core/src/main/kotlin/org/openprojectx/ai/plugin/core/GenerationRequest.kt b/core/src/main/kotlin/org/openprojectx/ai/plugin/core/GenerationRequest.kt index 751801a..c9fc96b 100644 --- a/core/src/main/kotlin/org/openprojectx/ai/plugin/core/GenerationRequest.kt +++ b/core/src/main/kotlin/org/openprojectx/ai/plugin/core/GenerationRequest.kt @@ -14,5 +14,6 @@ data class GenerationRequest( val location: String? = null, val packageName: String? = null, val className: String, - val outputNotes: String? + val outputNotes: String?, + val dependentMethodSignatures: String = "" ) diff --git a/core/src/main/kotlin/org/openprojectx/ai/plugin/core/PromptBuilder.kt b/core/src/main/kotlin/org/openprojectx/ai/plugin/core/PromptBuilder.kt index bb14730..db713b4 100644 --- a/core/src/main/kotlin/org/openprojectx/ai/plugin/core/PromptBuilder.kt +++ b/core/src/main/kotlin/org/openprojectx/ai/plugin/core/PromptBuilder.kt @@ -37,11 +37,16 @@ object PromptBuilder { - Focus only on methods present in the provided source; do not invent methods. - Prioritize the method referenced by user notes when provided. - For each tested method, include meaningful assertions for behavior and edge cases. - - Mock external collaborators (HTTP/DB/remote dependencies) with Mockito or test doubles; do not call real external systems. + - Mock external collaborators with Mockito or test doubles; do not call real external systems. + - Use the provided dependent method signatures to correctly mock/stub return values and verify interactions. - Keep tests deterministic and runnable. - Output ONLY Java code, no markdown. """ + const val DEFAULT_DEPENDENT_METHODS_SECTION = """ + {{dependentMethodSignatures}} + """ + const val DEFAULT_WRAPPER_TEMPLATE = """ You are a senior SDET. Generate high-quality automated API tests from the contract below. @@ -83,6 +88,12 @@ object PromptBuilder { } } + val dependentMethodsBlock = if (req.dependentMethodSignatures.isNotBlank()) { + "\n" + render(DEFAULT_DEPENDENT_METHODS_SECTION, mapOf( + "dependentMethodSignatures" to req.dependentMethodSignatures + )) + } else "" + return render(template.wrapper, mapOf( "contractType" to when (req.contractType) { ContractType.OPENAPI -> "OpenAPI/REST API" @@ -90,7 +101,7 @@ object PromptBuilder { }, "baseUrlHint" to (req.baseUrl ?: "not provided"), "outputNotes" to (req.outputNotes ?: "(none)"), - "frameworkRules" to frameworkRules, + "frameworkRules" to frameworkRules + dependentMethodsBlock, "contractText" to req.contractText )) } diff --git a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmProvider.kt b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmProvider.kt index 92a1fbf..bb6b461 100644 --- a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmProvider.kt +++ b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmProvider.kt @@ -2,4 +2,10 @@ package org.openprojectx.ai.plugin.llm interface LlmProvider { suspend fun generateCode(prompt: String): String + + suspend fun generateCode(messages: List): String { + // Default: flatten structured messages into a single prompt for non-OpenAI providers + val prompt = messages.joinToString("\n") { "[${it.role}]: ${it.content}" } + return generateCode(prompt) + } } \ No newline at end of file diff --git a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmRuntimeLogger.kt b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmRuntimeLogger.kt index 4398660..f2e7d26 100644 --- a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmRuntimeLogger.kt +++ b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmRuntimeLogger.kt @@ -8,6 +8,10 @@ object LlmRuntimeLogger { sink?.invoke("LLM | INFO | $message") } + fun warn(message: String) { + sink?.invoke("LLM | WARN | $message") + } + fun error(message: String) { sink?.invoke("LLM | ERROR | $message") } diff --git a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt index ba55265..dd91f2e 100644 --- a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt +++ b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt @@ -9,7 +9,7 @@ data class LlmSettings( val template: TemplateRequestConfig? = null, val auth: LlmAuthConfig? = null, val httpDisableTlsVerification: Boolean = false, - val maxTokens: Int = 4096 + val maxTokens: Int = 8192 ) data class TemplateRequestConfig( diff --git a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/OpenAiCompatibleProvider.kt b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/OpenAiCompatibleProvider.kt index 2ed2a68..c504e8f 100644 --- a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/OpenAiCompatibleProvider.kt +++ b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/OpenAiCompatibleProvider.kt @@ -14,6 +14,15 @@ class OpenAiCompatibleProvider( ) : LlmProvider { override suspend fun generateCode(prompt: String): String { + return generateCode( + listOf( + Message("system", "You are a helpful coding assistant. Respond concisely."), + Message("user", prompt) + ) + ) + } + + override suspend fun generateCode(messages: List): String { try { val endpoint = settings.endpoint ?: error("llm.endpoint is required for provider='${settings.provider}'") @@ -21,12 +30,14 @@ class OpenAiCompatibleProvider( ?: error("llm.apiKey or llm.apiKeyEnv is required for provider='${settings.provider}'") LlmRuntimeLogger.info("Request start | provider=${settings.provider} | endpoint=$endpoint") + // Auto-prepend system message if caller didn't include one + val hasSystem = messages.any { it.role == "system" } + val finalMessages = if (hasSystem) messages + else listOf(Message("system", "You are a helpful coding assistant. Respond concisely.")) + messages + val req = ChatCompletionsRequest( model = settings.model, - messages = listOf( - Message("system", "You generate code only."), - Message("user", prompt) - ), + messages = finalMessages, temperature = 0.1, max_tokens = settings.maxTokens.takeIf { it > 0 } ) @@ -47,10 +58,15 @@ class OpenAiCompatibleProvider( } val resp: ChatCompletionsResponse = response.body() - val result = resp.choices.firstOrNull()?.message?.content - ?: error("Empty LLM response") + val choice = resp.choices.firstOrNull() ?: error("Empty LLM response") + val result = choice.message.content + if (choice.finish_reason == "length") { + LlmRuntimeLogger.warn( + "Response truncated by token limit (finish_reason=length) | contentLength=${result.length}" + ) + } LlmRuntimeLogger.info( - "Response parsed | choices=${resp.choices.size} | contentLength=${result.length} | preview=${result.take(200)}" + "Response parsed | choices=${resp.choices.size} | contentLength=${result.length} | finish_reason=${choice.finish_reason} | preview=${result.take(200)}" ) return result @@ -76,7 +92,7 @@ class OpenAiCompatibleProvider( val choices: List ) { @Serializable - data class Choice(val message: Message) + data class Choice(val message: Message, val finish_reason: String? = null) } companion object { diff --git a/plugin-idea/build.gradle.kts b/plugin-idea/build.gradle.kts index 4e5811b..17432de 100644 --- a/plugin-idea/build.gradle.kts +++ b/plugin-idea/build.gradle.kts @@ -95,7 +95,7 @@ dependencies { create(type, version) } instrumentationTools() - bundledPlugins(listOf("org.jetbrains.plugins.yaml","Git4Idea")) + bundledPlugins(listOf("org.jetbrains.plugins.yaml","Git4Idea","com.intellij.java")) } implementation(project(":core")) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchDiffPromptMenuAction.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchDiffPromptMenuAction.kt index 15ce57e..8bb62a8 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchDiffPromptMenuAction.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchDiffPromptMenuAction.kt @@ -10,10 +10,12 @@ import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task import com.intellij.openapi.project.DumbAware import com.intellij.openapi.wm.ToolWindowManager -import com.intellij.vcs.log.VcsLogDataKeys -import git4idea.repo.GitRepositoryManager -class BranchDiffPromptMenuAction : ActionGroup("Analyze Branch Diff (Choose Prompt)", true), DumbAware { +class BranchDiffPromptMenuAction : ActionGroup("Analyze Branch Diff", true), DumbAware { + + init { + templatePresentation.icon = OpenProjectXIcons.GenerateTests + } override fun getChildren(e: AnActionEvent?): Array { val project = e?.project ?: return emptyArray() @@ -37,8 +39,8 @@ private class BranchDiffByPromptAction( override fun actionPerformed(e: AnActionEvent) { val project = e.project ?: return - val sourceBranch = resolveCurrentBranch(project) - val targetRef = resolveTargetRef(project, e, sourceBranch) + val sourceBranch = BranchResolutionUtil.resolveCurrentBranch(project) + val targetRef = BranchResolutionUtil.resolveTargetRef(project, e, sourceBranch) if (targetRef == null) { Notifications.warn( project, @@ -51,7 +53,18 @@ private class BranchDiffByPromptAction( saveDefaultBranchDiffPromptProfile(project, promptName) ButtonUsageReportService.getInstance(project).recordPromptUsage("branch.diff", promptName) - ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Summarizing Branch Diff", false) { + // Prevent concurrent branch diff analysis + val ctxBox = ContextBoxStateService.getInstance(project) + if (!ctxBox.tryStartBranchDiff()) { + Notifications.warn(project, "Summarize Branch Diff", "A branch diff analysis is already in progress.") + return + } + + // Show tool window immediately and add user bubble with branch info + ToolWindowManager.getInstance(project).getToolWindow("AI Context Box")?.show(null) + ctxBox.addUserMessage("Analyze changes on $sourceBranch → $targetRef") + + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Summarizing Branch Diff", true) { override fun run(indicator: ProgressIndicator) { try { indicator.text = "Collecting branch diff for $sourceBranch vs $targetRef..." @@ -79,11 +92,11 @@ private class BranchDiffByPromptAction( sourceBranch = sourceBranch, summary = summary ) - ToolWindowManager.getInstance(project).getToolWindow("AI Context Box")?.show(null) - Notifications.info(project, "Branch Diff Summary", "Summary updated in AI Context Box > Branch Analysis.") } } catch (ex: Exception) { Notifications.error(project, "Summarize Branch Diff failed", ex.message ?: ex.toString()) + } finally { + ContextBoxStateService.getInstance(project).finishBranchDiff() } } }) @@ -98,112 +111,4 @@ private class BranchDiffByPromptAction( ) } - private fun resolveCurrentBranch(project: com.intellij.openapi.project.Project): String { - val repo = GitRepositoryManager.getInstance(project).repositories.firstOrNull() - return repo?.currentBranchName ?: "HEAD" - } - - private fun resolveTargetRef( - project: com.intellij.openapi.project.Project, - e: AnActionEvent, - currentBranch: String - ): String? { - val rawBranches = e.getData(VcsLogDataKeys.VCS_LOG_BRANCHES) as? Collection<*> - if (rawBranches != null) { - val branches = linkedSetOf() - for (rawBranch in rawBranches) { - val name = extractBranchName(rawBranch)?.trim().orEmpty() - if (name.isNotEmpty()) { - branches.add(name) - } - } - - branches.firstOrNull { it != currentBranch }?.let { return it } - branches.firstOrNull()?.let { return it } - } - - resolveBranchFromLogUi(e, currentBranch)?.let { return it } - resolveSelectedCommitHash(e)?.let { return it } - - return resolveRepositoryBranchFallback(project, currentBranch) - } - - private fun resolveBranchFromLogUi(e: AnActionEvent, currentBranch: String): String? { - val vcsLogUi = e.getData(VcsLogDataKeys.VCS_LOG_UI) ?: return null - val filterUi = runCatching { vcsLogUi.javaClass.getMethod("getFilterUi").invoke(vcsLogUi) }.getOrNull() ?: return null - val filters = runCatching { filterUi.javaClass.getMethod("getFilters").invoke(filterUi) }.getOrNull() ?: return null - val branchFilter = runCatching { filters.javaClass.getMethod("getBranchFilter").invoke(filters) }.getOrNull() ?: return null - - val values = runCatching { - branchFilter.javaClass.getMethod("getValues").invoke(branchFilter) as? Collection<*> - }.getOrNull().orEmpty().mapNotNull { it?.toString()?.trim() } - - if (values.isNotEmpty()) { - val normalizedCurrent = currentBranch.removePrefix("refs/heads/") - values.firstOrNull { it.isNotBlank() && it != currentBranch && it != normalizedCurrent }?.let { return it } - values.firstOrNull()?.let { return it } - } - - val textPresentation = runCatching { - branchFilter.javaClass.getMethod("getTextPresentation").invoke(branchFilter) - }.getOrNull()?.toString().orEmpty() - - if (textPresentation.isBlank()) return null - val normalizedCurrent = currentBranch.removePrefix("refs/heads/") - return textPresentation - .split(",", " ", "|") - .map { it.trim() } - .firstOrNull { it.isNotBlank() && it != currentBranch && it != normalizedCurrent } - } - - private fun resolveRepositoryBranchFallback( - project: com.intellij.openapi.project.Project, - currentBranch: String - ): String? { - val repository = GitRepositoryManager.getInstance(project).repositories.firstOrNull() ?: return null - val localBranches = repository.branches.localBranches.map { it.name } - val normalizedCurrent = currentBranch.removePrefix("refs/heads/") - val candidates = localBranches.filter { it != normalizedCurrent && it != currentBranch } - - val preferred = listOf("main", "master", "develop", "dev") - preferred.firstOrNull { it in candidates }?.let { return it } - - return candidates.firstOrNull() - } - - private fun extractBranchName(value: Any?): String? { - return when (value) { - null -> null - is String -> value - else -> runCatching { - value.javaClass.getMethod("getName").invoke(value) as? String - }.getOrNull() ?: value.toString() - } - } - - private fun resolveSelectedCommitHash(e: AnActionEvent): String? { - val vcsLog = e.getData(VcsLogDataKeys.VCS_LOG) ?: return null - val selectedCommits = runCatching { - vcsLog.javaClass.getMethod("getSelectedCommits").invoke(vcsLog) as? Collection<*> - }.getOrNull() ?: return null - - return selectedCommits.firstNotNullOfOrNull { extractCommitHash(it) } - } - - private fun extractCommitHash(value: Any?): String? { - if (value == null) return null - if (value is String && value.matches(Regex("^[0-9a-fA-F]{7,40}$"))) return value - - val hashObject = runCatching { - value.javaClass.getMethod("getHash").invoke(value) - }.getOrNull() ?: return null - - return runCatching { - hashObject.javaClass.getMethod("asString").invoke(hashObject) as? String - }.getOrNull() - ?: runCatching { - hashObject.javaClass.getMethod("toShortString").invoke(hashObject) as? String - }.getOrNull() - ?: hashObject.toString() - } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchResolutionUtil.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchResolutionUtil.kt new file mode 100644 index 0000000..7315a64 --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchResolutionUtil.kt @@ -0,0 +1,167 @@ +package org.openprojectx.ai.plugin + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.vcs.log.VcsLogCommitSelection +import com.intellij.vcs.log.VcsLogDataKeys +import com.intellij.vcs.log.VcsRef +import git4idea.repo.GitRepositoryManager +import java.io.File + +object BranchResolutionUtil { + + fun resolveCurrentBranch(project: Project): String { + val repo = GitRepositoryManager.getInstance(project).repositories.firstOrNull() + return repo?.currentBranchName ?: "HEAD" + } + + fun resolveTargetRef( + project: Project, + e: AnActionEvent, + currentBranch: String + ): String? { + val normalizedCurrent = normalizeRefName(currentBranch) + + // 1. VCS_LOG_BRANCHES gives the branch refs at the right-clicked position + // in the VCS log graph. This is the most direct way to know which branch + // the user intended to compare with. + val vcsRefs = e.getData(VcsLogDataKeys.VCS_LOG_BRANCHES) + if (vcsRefs != null) { + for (ref in vcsRefs) { + val name = normalizeRefName(extractRefName(ref)) + if (name.isNotEmpty() && name != normalizedCurrent) return name + } + } + + // 2. Fall back to the selected commit: resolve its hash, then find which + // branch points to it via git for-each-ref. + val selectedHash = resolveSelectedCommitHash(e) + if (selectedHash != null) { + resolveBranchAtCommit(project, selectedHash, currentBranch)?.let { return it } + return selectedHash + } + + // 3. Try the VCS Log branch filter (user may have filtered by branch name) + resolveBranchFromLogUi(e, currentBranch)?.let { return it } + + // 4. Last resort: other local/remote branches in the repository + return resolveRepositoryBranchFallback(project, currentBranch) + } + + private fun extractRefName(value: Any?): String { + return when (value) { + is VcsRef -> value.name + is String -> value + null -> "" + else -> value.toString() + } + } + + fun normalizeRefName(name: String): String = + name.removePrefix("refs/heads/").removePrefix("refs/remotes/") + + fun resolveBranchAtCommit( + project: Project, + commitHash: String, + currentBranch: String + ): String? { + val repo = GitRepositoryManager.getInstance(project).repositories.firstOrNull() ?: return null + val process = ProcessBuilder( + "git", "for-each-ref", + "--points-at=$commitHash", + "--format=%(refname)", + "refs/heads/", "refs/remotes/" + ) + .directory(File(repo.root.path)) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + if (process.waitFor() != 0) return null + + val normalizedCurrent = normalizeRefName(currentBranch) + val localBranches = mutableListOf() + val remoteBranches = mutableListOf() + + for (line in output.lines()) { + val ref = line.trim() + when { + ref.startsWith("refs/heads/") -> { + val name = ref.removePrefix("refs/heads/") + if (name.isNotBlank() && name != normalizedCurrent) localBranches.add(name) + } + ref.startsWith("refs/remotes/") -> { + val fullName = ref.removePrefix("refs/remotes/") + val shortName = fullName.substringAfter("/") + if (fullName.isNotBlank() && fullName != normalizedCurrent && shortName != normalizedCurrent) { + remoteBranches.add(fullName) + } + } + } + } + + val preferred = listOf("main", "master", "develop", "dev") + preferred.firstOrNull { it in localBranches }?.let { return it } + localBranches.firstOrNull()?.let { return it } + preferred.firstOrNull { pref -> remoteBranches.any { it.substringAfter("/") == pref } }?.let { pref -> + remoteBranches.first { it.substringAfter("/") == pref }.also { return it } + } + return remoteBranches.firstOrNull() + } + + fun resolveBranchFromLogUi(e: AnActionEvent, currentBranch: String): String? { + val vcsLogUi = e.getData(VcsLogDataKeys.VCS_LOG_UI) ?: return null + val filterUi = runCatching { vcsLogUi.javaClass.getMethod("getFilterUi").invoke(vcsLogUi) }.getOrNull() ?: return null + val filters = runCatching { filterUi.javaClass.getMethod("getFilters").invoke(filterUi) }.getOrNull() ?: return null + val branchFilter = runCatching { filters.javaClass.getMethod("getBranchFilter").invoke(filters) }.getOrNull() ?: return null + + val values = runCatching { + branchFilter.javaClass.getMethod("getValues").invoke(branchFilter) as? Collection<*> + }.getOrNull().orEmpty().mapNotNull { it?.toString()?.trim() } + + if (values.isNotEmpty()) { + val normalizedCurrent = normalizeRefName(currentBranch) + values.firstOrNull { it.isNotBlank() && it != currentBranch && normalizeRefName(it) != normalizedCurrent }?.let { return it } + values.firstOrNull()?.let { return it } + } + + val textPresentation = runCatching { + branchFilter.javaClass.getMethod("getTextPresentation").invoke(branchFilter) + }.getOrNull()?.toString().orEmpty() + + if (textPresentation.isBlank()) return null + val normalizedCurrent = normalizeRefName(currentBranch) + return textPresentation + .split(",", " ", "|") + .map { it.trim() } + .firstOrNull { it.isNotBlank() && it != currentBranch && normalizeRefName(it) != normalizedCurrent } + } + + fun resolveRepositoryBranchFallback( + project: Project, + currentBranch: String + ): String? { + val repository = GitRepositoryManager.getInstance(project).repositories.firstOrNull() ?: return null + val normalizedCurrent = normalizeRefName(currentBranch) + val localBranches = repository.branches.localBranches.map { normalizeRefName(it.name) } + .filter { it != normalizedCurrent } + + val preferred = listOf("main", "master", "develop", "dev") + preferred.firstOrNull { it in localBranches }?.let { return it } + localBranches.firstOrNull()?.let { return it } + + val remoteBranches = repository.branches.remoteBranches.map { + normalizeRefName(it.name) + }.filter { it != normalizedCurrent && it !in localBranches } + preferred.firstOrNull { pref -> remoteBranches.any { it.substringAfter("/") == pref } }?.let { pref -> + remoteBranches.first { it.substringAfter("/") == pref }.also { return it } + } + return remoteBranches.firstOrNull() + } + + fun resolveSelectedCommitHash(e: AnActionEvent): String? { + val selection = e.getData(VcsLogDataKeys.VCS_LOG_COMMIT_SELECTION) ?: return null + val commits = selection.commits + if (commits.isEmpty()) return null + return commits.first().hash.asString() + } +} diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxStateService.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxStateService.kt index df7bfd5..b4fb31f 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxStateService.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxStateService.kt @@ -8,6 +8,7 @@ import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.util.concurrent.atomic.AtomicBoolean @Service(Service.Level.PROJECT) class ContextBoxStateService(private val project: Project) { @@ -16,7 +17,11 @@ class ContextBoxStateService(private val project: Project) { val role: Role, val content: String, val typeLabel: String = "", - val timestamp: Instant = Instant.now() + val timestamp: Instant = Instant.now(), + val sourceBranch: String? = null, + val targetBranch: String? = null, + val testTargetPath: String? = null, + val testClassName: String? = null ) { enum class Role { USER, ASSISTANT, SYSTEM } @@ -37,6 +42,10 @@ class ContextBoxStateService(private val project: Project) { } private val history = mutableListOf() + private val branchDiffInProgress = AtomicBoolean(false) + + fun tryStartBranchDiff(): Boolean = branchDiffInProgress.compareAndSet(false, true) + fun finishBranchDiff() { branchDiffInProgress.set(false) } fun snapshot(): Snapshot { val latest = history.lastOrNull() @@ -46,23 +55,27 @@ class ContextBoxStateService(private val project: Project) { fun recordGeneration(className: String, targetPath: String, diff: String) { append(ChatMessage( - role = ChatMessage.Role.SYSTEM, + role = ChatMessage.Role.ASSISTANT, typeLabel = "Generated Code", - content = "Class: $className\nTarget: $targetPath\n\n$diff" + content = diff, + testTargetPath = targetPath, + testClassName = className )) } fun recordBranchSummary(targetBranch: String, sourceBranch: String, summary: String) { append(ChatMessage( - role = ChatMessage.Role.SYSTEM, + role = ChatMessage.Role.ASSISTANT, typeLabel = "Branch Analysis", - content = "$sourceBranch → $targetBranch\n\n$summary" + content = "$sourceBranch → $targetBranch\n\n$summary", + sourceBranch = sourceBranch, + targetBranch = targetBranch )) } fun recordCodePromptResult(promptType: String, promptName: String, result: String) { append(ChatMessage( - role = ChatMessage.Role.SYSTEM, + role = ChatMessage.Role.ASSISTANT, typeLabel = promptType, content = "Prompt: $promptName\n\n${result.ifBlank { "(empty response)" }}" )) @@ -98,6 +111,10 @@ class ContextBoxStateService(private val project: Project) { append(ChatMessage(role = ChatMessage.Role.ASSISTANT, content = aiResponse.trim())) } + fun addUserMessage(content: String) { + append(ChatMessage(role = ChatMessage.Role.USER, content = content)) + } + fun clearHistory() { history.clear() project.messageBus.syncPublisher(TOPIC).stateUpdated(snapshot()) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index a31b6c4..08f837b 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -2,6 +2,7 @@ package org.openprojectx.ai.plugin import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task @@ -13,6 +14,9 @@ import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.components.JBScrollPane import com.intellij.ui.content.ContentFactory import kotlinx.coroutines.runBlocking +import org.openprojectx.ai.plugin.llm.OpenAiCompatibleProvider +import org.openprojectx.ai.plugin.pr.AiPullRequestService +import org.openprojectx.ai.plugin.pr.GitRepositoryContextService import java.io.File import java.awt.BorderLayout import java.awt.CardLayout @@ -57,19 +61,19 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { val commonFont = UIManager.getFont("Label.font") ?.deriveFont(Font.PLAIN, 13f) ?: Font("SansSerif", Font.PLAIN, 13) - val bgColor = Color(0x0B, 0x14, 0x19) - val fgColor = Color(0xE9, 0xED, 0xEF) - val borderColor = Color(0x2A, 0x37, 0x42) - - val inputColor = Color(0x1A, 0x25, 0x2F) - val userBubbleColor = Color(0xD9, 0xFD, 0xD3) - val userTextColor = Color(0x11, 0x1B, 0x21) - val assistantBubbleColor = Color(0xFF, 0xFF, 0xFF) - val assistantTextColor = Color(0x11, 0x1B, 0x21) - val systemBubbleColor = Color(0x1A, 0x25, 0x2F) - val systemAccentColor = Color(0x00, 0xA8, 0x84) - - val chatFont = Font("Segoe UI", Font.PLAIN, 14) + val bgColor = ThemeColors.mainBg + val fgColor = ThemeColors.mainFg + val borderColor = ThemeColors.borderColor + + val inputColor = ThemeColors.inputBg + val userBubbleColor = ThemeColors.userBubbleBg + val userTextColor = ThemeColors.userBubbleFg + val assistantBubbleColor = ThemeColors.assistantBubbleBg + val assistantTextColor = ThemeColors.assistantBubbleFg + val systemBubbleColor = ThemeColors.systemBubbleBg + val systemAccentColor = ThemeColors.systemAccent + + val chatFont = Font("SansSerif", Font.PLAIN, 14) val bubbleColumns = 36 val messageListPanel = JPanel().apply { @@ -95,25 +99,34 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { val sendButton = JButton("Send") val clearButton = JButton("Clear") - fun buildFollowUpPrompt(snapshot: ContextBoxStateService.Snapshot, userInput: String): String { - val recent = snapshot.history.takeLast(6) - val contextLines = recent.joinToString("\n") { msg -> - val roleTag = when (msg.role) { - ContextBoxStateService.ChatMessage.Role.USER -> "User" - ContextBoxStateService.ChatMessage.Role.ASSISTANT -> "Assistant" - ContextBoxStateService.ChatMessage.Role.SYSTEM -> "System" + fun buildFollowUpMessages(snapshot: ContextBoxStateService.Snapshot, userInput: String): List { + val recent = snapshot.history.takeLast(10) + val messages = mutableListOf() + messages.add(OpenAiCompatibleProvider.Message("system", "You are a helpful AI assistant in an IDE context box. Answer the user's questions based on the conversation history. When generating test code, output the COMPLETE class including ALL existing tests that are still valid.")) + for (msg in recent) { + val role = when (msg.role) { + ContextBoxStateService.ChatMessage.Role.USER -> "user" + ContextBoxStateService.ChatMessage.Role.ASSISTANT -> "assistant" + ContextBoxStateService.ChatMessage.Role.SYSTEM -> "assistant" } - "[$roleTag]: ${msg.content.take(500)}" + messages.add(OpenAiCompatibleProvider.Message(role, msg.content.take(2000))) } - return """ - You are a helpful AI assistant in an IDE context box. - Recent conversation: - $contextLines + // If regenerating tests, include existing test file so LLM preserves valid tests + val lastGenerated = recent.lastOrNull { it.testTargetPath != null } + val existingTests = lastGenerated?.testTargetPath?.let { path -> + try { + val testFile = File(project.basePath, path) + if (testFile.exists()) testFile.readText(Charsets.UTF_8).take(6000) else "" + } catch (_: Exception) { "" } + } ?: "" - User: $userInput + val enhancedInput = if (existingTests.isNotBlank()) { + "$userInput\n\n--- Existing test file — keep these tests, only modify/add as needed ---\n$existingTests" + } else userInput - Assistant:""".trimIndent() + messages.add(OpenAiCompatibleProvider.Message("user", enhancedInput)) + return messages } fun calculateRows(text: String, cols: Int): Int { @@ -122,6 +135,22 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { }.coerceIn(1, 24) + 1 } + fun sanitizeTestCode(raw: String): String { + val trimmed = raw.trim() + val withoutFence = trimmed + .replaceFirst(Regex("^```(?:\\w+)?\\s*\\n?"), "") + .replaceFirst(Regex("\\n?```\\s*$"), "") + return withoutFence.replace(Regex("\\.{3,}\\s*$"), "") + .lines().dropLastWhile { it.isBlank() }.joinToString("\n").trim() + } + + fun writeTestFile(project: Project, targetPath: String, code: String) { + val projectRoot = project.guessProjectDir() ?: error("Cannot resolve project root") + val testFile = File(projectRoot.path, targetPath) + testFile.parentFile.mkdirs() + testFile.writeText(code, Charsets.UTF_8) + } + fun createBubble(msg: ContextBoxStateService.ChatMessage): JPanel { val isUser = msg.role == ContextBoxStateService.ChatMessage.Role.USER val isAssistant = msg.role == ContextBoxStateService.ChatMessage.Role.ASSISTANT @@ -141,14 +170,14 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { ContextBoxStateService.ChatMessage.Role.SYSTEM -> msg.typeLabel.ifBlank { "System" } } val roleColor = when (msg.role) { - ContextBoxStateService.ChatMessage.Role.USER -> Color(0x00, 0xA8, 0x84) - ContextBoxStateService.ChatMessage.Role.ASSISTANT -> Color(0x54, 0x65, 0x6F) + ContextBoxStateService.ChatMessage.Role.USER -> ThemeColors.userRole + ContextBoxStateService.ChatMessage.Role.ASSISTANT -> ThemeColors.assistantRole ContextBoxStateService.ChatMessage.Role.SYSTEM -> systemAccentColor } val timestampColor = when (msg.role) { - ContextBoxStateService.ChatMessage.Role.USER -> Color(0x86, 0xBE, 0x9E) - ContextBoxStateService.ChatMessage.Role.ASSISTANT -> Color(0xA0, 0xA8, 0xAF) - ContextBoxStateService.ChatMessage.Role.SYSTEM -> Color(0x66, 0x77, 0x81) + ContextBoxStateService.ChatMessage.Role.USER -> ThemeColors.userTimestamp + ContextBoxStateService.ChatMessage.Role.ASSISTANT -> ThemeColors.assistantTimestamp + ContextBoxStateService.ChatMessage.Role.SYSTEM -> ThemeColors.systemTimestamp } val contentArea = JTextArea().apply { @@ -181,13 +210,110 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } add(header, BorderLayout.NORTH) add(contentArea, BorderLayout.CENTER) + + // Show "Create PR" button for branch analysis results + if (msg.typeLabel == "Branch Analysis" && msg.sourceBranch != null && msg.targetBranch != null) { + val prButton = JButton("Create PR →").apply { + font = chatFont.deriveFont(Font.BOLD, 12f) + foreground = ThemeColors.systemAccent + background = bubbleBg + isOpaque = true + border = BorderFactory.createEmptyBorder(4, 0, 0, 0) + isContentAreaFilled = false + isFocusPainted = false + addActionListener { + isEnabled = false + text = "Creating PR..." + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Creating Pull Request", false) { + override fun run(indicator: ProgressIndicator) { + try { + val src = msg.sourceBranch + val tgt = msg.targetBranch + val ctx = GitRepositoryContextService.resolve(project) + indicator.text = "Collecting diff for $src vs $tgt..." + val diff = GitDiffProvider.getDiffBetweenBranches(project, src, tgt) + indicator.text = "Creating PR..." + val result = AiPullRequestService(project).createAfterPush( + remoteUrl = ctx.remoteUrl, + sourceBranch = src, + targetBranch = tgt, + diff = diff + ) + ApplicationManager.getApplication().invokeLater { + Notifications.info(project, "PR Created", result.url) + } + } catch (ex: Exception) { + ApplicationManager.getApplication().invokeLater { + Notifications.error(project, "PR Creation Failed", ex.message ?: ex.toString()) + } + } finally { + ApplicationManager.getApplication().invokeLater { + isEnabled = true + text = "Create PR →" + } + } + } + }) + } + } + add(prButton, BorderLayout.SOUTH) + } + + // Show "Generate Tests →" button for ASSISTANT messages with test code + val hasTestCode = msg.role == ContextBoxStateService.ChatMessage.Role.ASSISTANT && ( + msg.typeLabel == "Generated Code" || + msg.testClassName != null || + (msg.content.contains("@Test") && msg.content.contains("class ")) + ) + if (hasTestCode) { + val testBtn = JButton("Generate Tests →").apply { + font = chatFont.deriveFont(Font.BOLD, 12f) + foreground = ThemeColors.systemAccent + background = bubbleBg + isOpaque = true + border = BorderFactory.createEmptyBorder(4, 0, 0, 0) + isContentAreaFilled = false + isFocusPainted = false + addActionListener { + isEnabled = false + text = "Writing..." + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Writing test file", true) { + override fun run(indicator: ProgressIndicator) { + try { + indicator.text = "Writing test file..." + indicator.isIndeterminate = true + val targetPath = msg.testTargetPath + ?: stateService.snapshot().history.lastOrNull { it.testTargetPath != null }?.testTargetPath + ?: error("Cannot find test target path") + val code = sanitizeTestCode(msg.content) + writeTestFile(project, targetPath, code) + ApplicationManager.getApplication().invokeLater { + Notifications.info(project, "Tests Updated", targetPath) + } + } catch (ex: Exception) { + ApplicationManager.getApplication().invokeLater { + Notifications.error(project, "Test Generation Failed", ex.message ?: ex.toString()) + } + } finally { + ApplicationManager.getApplication().invokeLater { + isEnabled = true + text = "Generate Tests →" + } + } + } + }) + } + } + add(testBtn, BorderLayout.SOUTH) + } + border = BorderFactory.createCompoundBorder( BorderFactory.createEmptyBorder(6, 10, 6, 10), BorderFactory.createCompoundBorder( BorderFactory.createLineBorder( when { - isAssistant -> Color(0xE0, 0xE0, 0xE0) - isUser -> Color(0xC7, 0xEB, 0xC1) + isAssistant -> ThemeColors.assistantBubbleBorder + isUser -> ThemeColors.userBubbleBorder else -> bubbleBg.brighter() }, 1 @@ -222,7 +348,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { messageListPanel.removeAll() if (snapshot.history.isEmpty()) { messageListPanel.add(JLabel("No messages yet.").apply { - foreground = Color(0x66, 0x66, 0x66) + foreground = ThemeColors.emptyText font = chatFont.deriveFont(Font.ITALIC, 13f) border = BorderFactory.createEmptyBorder(20, 20, 20, 20) horizontalAlignment = SwingConstants.CENTER @@ -248,7 +374,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { val userInput = chatInputField.text.trim() if (userInput.isBlank()) return@addActionListener val snapshot = stateService.snapshot() - val prompt = buildFollowUpPrompt(snapshot, userInput) + val messages = buildFollowUpMessages(snapshot, userInput) sendButton.isEnabled = false chatInputField.isEnabled = false @@ -258,7 +384,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { indicator.text = "Thinking..." val response = LlmAuthSessionService.getInstance(project).withReloginOnUnauthorized { settings -> val provider = LlmProviderFactory.create(settings) - runBlocking { provider.generateCode(prompt) } + runBlocking { provider.generateCode(messages) } } ApplicationManager.getApplication().invokeLater { stateService.recordChat(userInput, response) @@ -313,6 +439,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { ) val tabs = JTabbedPane().apply { + insertTab("Readme", OpenProjectXIcons.GenerateTests, createReadmePanel(bgColor, fgColor, borderColor, commonFont), "Feature overview and quick start", 0) addTab("Context", chatPanel) addTab("Prompt Manager", createPromptManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) addTab("Skill Manager", createSkillManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) @@ -380,11 +507,11 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } private fun categoryColor(category: PromptCategory): Color = when (category) { - PromptCategory.TEST -> Color(0xA7, 0x8B, 0xFA) - PromptCategory.COMMIT -> Color(0xF5, 0x9E, 0x0B) - PromptCategory.BRANCH_DIFF -> Color(0x38, 0xB2, 0xDF) - PromptCategory.CODE_GENERATE -> Color(0x22, 0xC5, 0x5E) - PromptCategory.CODE_REVIEW -> Color(0xFB, 0x71, 0x85) + PromptCategory.TEST -> ThemeColors.categoryTest + PromptCategory.COMMIT -> ThemeColors.categoryCommit + PromptCategory.BRANCH_DIFF -> ThemeColors.categoryBranchDiff + PromptCategory.CODE_GENERATE -> ThemeColors.categoryCodeGen + PromptCategory.CODE_REVIEW -> ThemeColors.categoryCodeReview } private fun scopeColor(isGlobal: Boolean): Color = @@ -398,12 +525,12 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { commonFont: Font ): Component { val usage = ButtonUsageReportService.getInstance(project) - val pageColor = Color(0x0F, 0x17, 0x2A) - val surfaceColor = Color(0x11, 0x1C, 0x2F) - val inputColor = Color(0x0B, 0x12, 0x20) - val accentColor = Color(0x3B, 0x82, 0xF6) - val mutedColor = Color(0x94, 0xA3, 0xB8) - val cardColor = Color(0x14, 0x1F, 0x34) + val pageColor = ThemeColors.pageBg + val surfaceColor = ThemeColors.surfaceBg + val inputColor = ThemeColors.inputBg + val accentColor = ThemeColors.accentBlue + val mutedColor = ThemeColors.mutedFg + val cardColor = ThemeColors.cardBg val designBorderColor = borderColor val promptFont = UIManager.getFont("EditorPane.font") ?.deriveFont(Font.PLAIN, 14f) @@ -1086,12 +1213,12 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { commonFont: Font ): Component { val usage = ButtonUsageReportService.getInstance(project) - val pageColor = Color(0x0F, 0x17, 0x2A) - val surfaceColor = Color(0x11, 0x1C, 0x2F) - val inputColor = Color(0x0B, 0x12, 0x20) - val accentColor = Color(0x3B, 0x82, 0xF6) - val mutedColor = Color(0x94, 0xA3, 0xB8) - val cardColor = Color(0x14, 0x1F, 0x34) + val pageColor = ThemeColors.pageBg + val surfaceColor = ThemeColors.surfaceBg + val inputColor = ThemeColors.inputBg + val accentColor = ThemeColors.accentBlue + val mutedColor = ThemeColors.mutedFg + val cardColor = ThemeColors.cardBg val designBorderColor = borderColor val skillFont = UIManager.getFont("EditorPane.font")?.deriveFont(Font.PLAIN, 14f) ?: Font("JetBrains Mono", Font.PLAIN, 14) val listModel = DefaultListModel() @@ -1761,6 +1888,113 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } } + private fun createReadmePanel( + bgColor: Color, fgColor: Color, borderColor: Color, commonFont: Font + ): JPanel { + val titleFont = commonFont.deriveFont(Font.BOLD, 16f) + val sectionFont = commonFont.deriveFont(Font.BOLD, 14f) + val bodyFont = commonFont.deriveFont(Font.PLAIN, 13f) + val accentColor = ThemeColors.systemAccent + + val contentPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + background = bgColor + border = BorderFactory.createEmptyBorder(16, 16, 16, 16) + } + + data class Feature(val title: String, val trigger: String, val tabName: String?) + + val features = listOf( + Feature("Test Generation", "Open an OpenAPI (.yaml/.yml) or Java source file in the editor, then click \"Generate Tests By AI\" in the banner at the top.", null), + Feature("Commit Message Generation", "In the VCS Commit dialog, use the toolbar button \"Generate Commit Message\" and choose a prompt profile.", null), + Feature("Branch Diff Analysis", "In the VCS Log (Git Log), right-click a branch or commit and choose \"Analyze Branch Diff\".", "Context"), + Feature("Push & Create PR", "In the VCS Commit dialog, use \"Push and Create PR\" to push your branch and create a pull request with an AI-generated summary.", null), + Feature("Code Generate & Review", "Select code in the editor, right-click, and choose \"Code Generate & Review\" to send it to the LLM.", null), + Feature("SonarQube Coverage", "Go to Tools → SonarQube Coverage to fetch coverage data and generate missing tests.", "Sonar Cube"), + Feature("Context Box Chat", "Use the Context tab to chat with the LLM. After a branch diff or test generation, you can ask follow-up questions, request translations, or regenerate tests with improvements.", "Context"), + Feature("Prompt Manager", "Manage prompt templates organized by category (Test, Commit, Branch Diff, Code Generate, Code Review). Create, edit, duplicate, or sync prompts from a remote repository.", "Prompt Manager"), + Feature("Skill Manager", "Manage skill definitions (YAML templates) with global and local scopes. Sync skills from a remote Bitbucket/GitHub repository.", "Skill Manager"), + Feature("Settings", "Configure LLM provider, API key, login template, prompt defaults, and SonarQube settings at File → Settings → Tools → Code Quality Improver.", null) + ) + + // Title + contentPanel.add(JLabel("Code Quality Assistant").apply { + font = titleFont + foreground = accentColor + alignmentX = Component.LEFT_ALIGNMENT + }) + contentPanel.add(Box.createVerticalStrut(4)) + contentPanel.add(JLabel("AI-powered code quality tools for IntelliJ IDEA").apply { + font = bodyFont + foreground = fgColor + alignmentX = Component.LEFT_ALIGNMENT + }) + contentPanel.add(Box.createVerticalStrut(16)) + + for (feature in features) { + val sectionPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + background = bgColor + alignmentX = Component.LEFT_ALIGNMENT + border = BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, borderColor), + BorderFactory.createEmptyBorder(10, 0, 10, 0) + ) + maximumSize = Dimension(Int.MAX_VALUE, 120) + } + + sectionPanel.add(JLabel(feature.title).apply { + font = sectionFont + foreground = accentColor + alignmentX = Component.LEFT_ALIGNMENT + }) + sectionPanel.add(Box.createVerticalStrut(4)) + sectionPanel.add(JLabel("${feature.trigger}").apply { + font = bodyFont + foreground = fgColor + alignmentX = Component.LEFT_ALIGNMENT + }) + sectionPanel.add(Box.createVerticalStrut(6)) + + if (feature.tabName != null) { + val jumpBtn = JButton("→ Open ${feature.tabName}").apply { + font = bodyFont.deriveFont(Font.BOLD) + foreground = accentColor + background = bgColor + isOpaque = true + border = BorderFactory.createEmptyBorder(4, 0, 4, 0) + isContentAreaFilled = false + isFocusPainted = false + alignmentX = Component.LEFT_ALIGNMENT + addActionListener { + val parent = javax.swing.SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, this) + if (parent is JTabbedPane) { + for (i in 0 until parent.tabCount) { + if (parent.getTitleAt(i) == feature.tabName) { + parent.selectedIndex = i + break + } + } + } + } + } + sectionPanel.add(jumpBtn) + } + + contentPanel.add(sectionPanel) + } + + return JPanel(BorderLayout()).apply { + background = bgColor + add(JBScrollPane(contentPanel).apply { + viewport.background = bgColor + background = bgColor + border = BorderFactory.createEmptyBorder() + verticalScrollBar.unitIncrement = 16 + }, BorderLayout.CENTER) + } + } + private fun isGlobalPromptName(name: String): Boolean { return name.startsWith("[ADA]") || name.startsWith("[repo]") || name.startsWith("global/") } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsService.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsService.kt index c56afa3..9f1f19c 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsService.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsService.kt @@ -14,6 +14,7 @@ import org.openprojectx.ai.plugin.core.ContractType import org.openprojectx.ai.plugin.core.Framework import org.openprojectx.ai.plugin.core.GenerationRequest import org.openprojectx.ai.plugin.core.PromptBuilder +import org.openprojectx.ai.plugin.testgen.DependentMethodCollector import org.slf4j.LoggerFactory @@ -57,6 +58,10 @@ class GenerateTestsService(private val project: Project) { JavaHeuristics.derivePackageNameForJava(file, project.basePath) } else null + val dependentMethodSignatures = if (contractType == ContractType.JAVA) { + DependentMethodCollector.collect(project, file) + } else "" + val req = GenerationRequest( contractText = contractText, framework = effectiveFramework, @@ -65,7 +70,8 @@ class GenerateTestsService(private val project: Project) { location = effectiveLocation, packageName = packageName, className = ui.className, - outputNotes = ui.notes + outputNotes = ui.notes, + dependentMethodSignatures = dependentMethodSignatures ) val generationTemplate = config.prompts.generation.copy( @@ -78,25 +84,34 @@ class GenerateTestsService(private val project: Project) { val prompt = PromptBuilder.build(req, generationTemplate) usage.recordPromptUsage("test.generate", ui.generationPromptProfileName.ifBlank { "default" }) - ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Generating tests by AI", true) { + // Show user request in Context Box + val userMessage = buildString { + appendLine("Generate tests for ${ui.className}") + appendLine("Framework: ${effectiveFramework.name}") + if (!ui.notes.isNullOrBlank()) appendLine("Notes: ${ui.notes}") + } + ContextBoxStateService.getInstance(project).addUserMessage(userMessage) + + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Generating tests for ${ui.className}", true) { override fun run(indicator: ProgressIndicator) { try { - indicator.text = "Preparing generation request..." - indicator.fraction = 0.15 + indicator.text = "Preparing request for ${ui.className}..." + indicator.fraction = 0.1 - indicator.text = "Calling LLM provider..." - indicator.fraction = 0.45 + indicator.text = "Calling LLM..." + indicator.isIndeterminate = true val code = authSession.withReloginOnUnauthorized { settings -> val provider = LlmProviderFactory.create(settings) kotlinx.coroutines.runBlocking { provider.generateCode(prompt) } } + indicator.isIndeterminate = false - indicator.text = "Post-processing generated code..." - indicator.fraction = 0.7 + indicator.text = "Processing result..." + indicator.fraction = 0.8 val sanitizedCode = sanitizeGeneratedCode(code) - indicator.text = "Writing test file to project..." - indicator.fraction = 0.9 + indicator.text = "Writing ${ui.className}.java..." + indicator.fraction = 0.95 writeGenerated( project = project, framework = effectiveFramework, @@ -106,9 +121,6 @@ class GenerateTestsService(private val project: Project) { code = sanitizedCode ) - indicator.text = "Finalizing..." - indicator.fraction = 1.0 - notificationState.setState(file.path, GenerationUiState.Done) EditorNotifications.getInstance(project).updateNotifications(file) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitDiffProvider.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitDiffProvider.kt index 56aeb21..d8c2088 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitDiffProvider.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitDiffProvider.kt @@ -5,6 +5,7 @@ import com.intellij.openapi.vcs.FilePath import com.intellij.openapi.vcs.changes.Change import git4idea.repo.GitRepositoryManager import java.io.File +import java.util.concurrent.TimeUnit object GitDiffProvider { @@ -105,10 +106,15 @@ object GitDiffProvider { .start() val output = process.inputStream.bufferedReader().use { it.readText() } - val exitCode = process.waitFor() + val finished = process.waitFor(30, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + error("Timed out collecting branch diff after 30s") + } + val exitCode = process.exitValue() - if (exitCode != 0) { - error("Failed to compare branches: $output") + if (exitCode > 1) { + error("Failed to compare branches (exit $exitCode): ${output.take(500)}") } return output @@ -127,10 +133,15 @@ object GitDiffProvider { .start() val output = process.inputStream.bufferedReader().use { it.readText() } - val exitCode = process.waitFor() + val finished = process.waitFor(30, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + error("Timed out collecting file diff after 30s") + } + val exitCode = process.exitValue() - if (exitCode != 0) { - error("Failed to collect file diff: $output") + if (exitCode > 1) { + error("Failed to collect file diff (exit $exitCode): ${output.take(500)}") } return output diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/HttpClients.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/HttpClients.kt index 56a0bb5..d491c0c 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/HttpClients.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/HttpClients.kt @@ -1,9 +1,14 @@ package org.openprojectx.ai.plugin import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import java.security.SecureRandom @@ -13,6 +18,18 @@ import javax.net.ssl.SSLContext import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager +suspend inline fun HttpClient.safeGet( + url: String, + crossinline block: HttpRequestBuilder.() -> Unit = {} +): T { + val response = get(url, block) + if (!response.status.isSuccess()) { + val errorBody = runCatching { response.bodyAsText() }.getOrDefault("(unable to read error body)") + throw RuntimeException("SonarQube API returned HTTP ${response.status.value} for $url: $errorBody") + } + return response.body() +} + object HttpClients { fun shared(disableTlsVerification: Boolean = false, timeoutSeconds: Long = 60): HttpClient { return HttpClient(OkHttp) { @@ -34,6 +51,7 @@ object HttpClients { install(HttpTimeout) { connectTimeoutMillis = TimeUnit.SECONDS.toMillis(timeoutSeconds) socketTimeoutMillis = TimeUnit.SECONDS.toMillis(timeoutSeconds) + requestTimeoutMillis = TimeUnit.SECONDS.toMillis(timeoutSeconds) } } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmAuthSessionService.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmAuthSessionService.kt index db3757f..268dc80 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmAuthSessionService.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmAuthSessionService.kt @@ -48,16 +48,16 @@ class LlmAuthSessionService( val credentials = promptCredentials(settings, prefill = saved) val apiKey = runLoginTemplate(settings, credentials.username, credentials.password) if (apiKey.isNullOrBlank()) { - error("LLM login returned an empty API key") + val hint = lastLoginError?.let { " | last error: $it" }.orEmpty() + error("LLM login returned an empty API key for ${auth.login.url}$hint. Check logs in Context Box for details.") } sessionApiKey = apiKey return settings.copy(apiKey = apiKey) } fun relogin(settings: LlmSettings): LlmSettings { - if (settings.auth == null) { - return settings - } + installRuntimeLogSink() + val auth = settings.auth ?: return settings sessionApiKey = null // Try saved credentials silently @@ -76,7 +76,8 @@ class LlmAuthSessionService( val credentials = promptCredentials(settings, prefill = saved) val apiKey = runLoginTemplate(settings, credentials.username, credentials.password) if (apiKey.isNullOrBlank()) { - error("LLM relogin returned an empty API key") + val hint = lastLoginError?.let { " | last error: $it" }.orEmpty() + error("LLM relogin returned an empty API key for ${auth.login.url}$hint. Check logs in Context Box for details.") } sessionApiKey = apiKey return settings.copy(apiKey = apiKey) @@ -123,7 +124,6 @@ class LlmAuthSessionService( fun withReloginOnUnauthorized(block: (LlmSettings) -> String): String { val baseSettings = LlmSettingsLoader.load(project) val resolved = resolve(baseSettings) - val loginAlreadyAttempted = baseSettings.auth != null && baseSettings.apiKey.isNullOrBlank() return try { block(resolved) @@ -134,14 +134,13 @@ class LlmAuthSessionService( } throw LlmUnauthorizedException("Unauthorized LLM request — your API key may be invalid or expired. Update the key in .ai-test.yaml or configure a login template for automatic renewal.") } - if (loginAlreadyAttempted) { - throw LlmUnauthorizedException("Unauthorized LLM request after login attempt; please verify login endpoint/credentials") - } val refreshed = relogin(baseSettings) block(refreshed) } } + private var lastLoginError: String? = null + private fun runLoginTemplate(settings: LlmSettings, username: String, password: String): String? { val auth = settings.auth ?: return null return try { @@ -163,7 +162,8 @@ class LlmAuthSessionService( ) ) }.trim().takeIf { it.isNotBlank() } - } catch (_: Exception) { + } catch (e: Exception) { + lastLoginError = "${e.javaClass.simpleName}: ${e.message}" null } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt index 8ae99ae..9b26eb3 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt @@ -45,7 +45,7 @@ object LlmProviderFactory { install(HttpTimeout) { connectTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) socketTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) -// requestTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) + requestTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) } install(Logging) { logger = object : Logger { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt index e0e1eb3..a3dcbbc 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt @@ -1378,22 +1378,22 @@ private class SonarCubeToolWindowClient(private val config: SonarQubeConfig) { val projectKey = encoded(config.projectKey) val measuresUrl = "$baseUrl/api/measures/component?component=$projectKey&metricKeys=coverage,line_coverage,branch_coverage,uncovered_lines,bugs,vulnerabilities,code_smells" HttpClients.logCurl("GET", measuresUrl, authHeader?.let { mapOf("Authorization" to it) } ?: emptyMap()) - val measures: SonarCubeMeasuresResponse = client.get(measuresUrl) { + val measures: SonarCubeMeasuresResponse = client.safeGet(measuresUrl) { authHeader?.let { header(HttpHeaders.Authorization, it) } - }.body() + } val fileMeasuresUrl = "$baseUrl/api/measures/component_tree?component=$projectKey&metricKeys=coverage,uncovered_lines&qualifiers=FIL&ps=500" HttpClients.logCurl("GET", fileMeasuresUrl, authHeader?.let { mapOf("Authorization" to it) } ?: emptyMap()) val fileTree = runCatching { - val resp: SonarCubeComponentTreeResponse = client.get(fileMeasuresUrl) { + val resp: SonarCubeComponentTreeResponse = client.safeGet(fileMeasuresUrl) { authHeader?.let { header(HttpHeaders.Authorization, it) } - }.body() + } resp }.getOrDefault(SonarCubeComponentTreeResponse()) val issuesUrl = "$baseUrl/api/issues/search?componentKeys=$projectKey&resolved=false&ps=100&s=SEVERITY&asc=false" HttpClients.logCurl("GET", issuesUrl, authHeader?.let { mapOf("Authorization" to it) } ?: emptyMap()) - val issues: SonarCubeIssuesResponse = client.get(issuesUrl) { + val issues: SonarCubeIssuesResponse = client.safeGet(issuesUrl) { authHeader?.let { header(HttpHeaders.Authorization, it) } - }.body() + } val now = java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) val fileCoverages = fileTree.components.map { component -> SonarQubeFileCoverage( diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeCoverageAction.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeCoverageAction.kt index 38a1f66..e6163a1 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeCoverageAction.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeCoverageAction.kt @@ -188,14 +188,14 @@ private class SonarQubeCoverageClient(private val request: SonarQubeCoverageRequ val component = encoded(request.projectKey) val projectMeasuresUrl = "$baseUrl/api/measures/component?component=$component&metricKeys=coverage,line_coverage,branch_coverage,uncovered_lines" HttpClients.logCurl("GET", projectMeasuresUrl, authHeader?.let { mapOf("Authorization" to it) } ?: emptyMap()) - val projectMeasures: SonarComponentMeasuresResponse = jsonClient.get(projectMeasuresUrl) { + val projectMeasures: SonarComponentMeasuresResponse = jsonClient.safeGet(projectMeasuresUrl) { authHeader?.let { header(HttpHeaders.Authorization, it) } - }.body() + } val fileMeasuresUrl = "$baseUrl/api/measures/component_tree?component=$component&metricKeys=coverage,uncovered_lines&qualifiers=FIL&s=metric&metricSort=uncovered_lines&asc=false&ps=${request.maxFiles.coerceIn(1, 100)}" HttpClients.logCurl("GET", fileMeasuresUrl, authHeader?.let { mapOf("Authorization" to it) } ?: emptyMap()) - val fileMeasures: SonarComponentTreeResponse = jsonClient.get(fileMeasuresUrl) { + val fileMeasures: SonarComponentTreeResponse = jsonClient.safeGet(fileMeasuresUrl) { authHeader?.let { header(HttpHeaders.Authorization, it) } - }.body() + } val project = projectMeasures.component return SonarQubeCoverageReport( diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SummarizeBranchDiffAction.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SummarizeBranchDiffAction.kt index 23bc08e..efccef9 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SummarizeBranchDiffAction.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SummarizeBranchDiffAction.kt @@ -9,8 +9,6 @@ import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.wm.ToolWindowManager -import com.intellij.vcs.log.VcsLogDataKeys -import git4idea.repo.GitRepositoryManager open class SummarizeBranchDiffAction( tooltip: String = "Summarize Branch Differences (Default)", @@ -24,8 +22,8 @@ open class SummarizeBranchDiffAction( override fun actionPerformed(e: AnActionEvent) { val project = e.project ?: return - val sourceBranch = resolveCurrentBranch(project) - val targetRef = resolveTargetRef(project, e, sourceBranch) + val sourceBranch = BranchResolutionUtil.resolveCurrentBranch(project) + val targetRef = BranchResolutionUtil.resolveTargetRef(project, e, sourceBranch) if (targetRef == null) { Notifications.warn( @@ -38,7 +36,18 @@ open class SummarizeBranchDiffAction( val selectedPrompt = LlmSettingsLoader.loadConfig(project).prompts.profiles.branchDiffSummary.selected ButtonUsageReportService.getInstance(project).recordPromptUsage("branch.diff", selectedPrompt) - ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Summarizing Branch Diff", false) { + // Prevent concurrent branch diff analysis + val ctxBox = ContextBoxStateService.getInstance(project) + if (!ctxBox.tryStartBranchDiff()) { + Notifications.warn(project, "Summarize Branch Diff", "[$sourceTag] A branch diff analysis is already in progress.") + return + } + + // Show tool window immediately and add user bubble with branch info + ToolWindowManager.getInstance(project).getToolWindow("AI Context Box")?.show(null) + ctxBox.addUserMessage("Analyze changes on $sourceBranch → $targetRef") + + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Summarizing Branch Diff", true) { override fun run(indicator: ProgressIndicator) { try { indicator.text = "Collecting branch diff for $sourceBranch vs $targetRef..." @@ -70,12 +79,6 @@ open class SummarizeBranchDiffAction( sourceBranch = sourceBranch, summary = summary ) - ToolWindowManager.getInstance(project).getToolWindow("AI Context Box")?.show(null) - Notifications.info( - project, - "Branch Diff Summary", - "Summary updated in AI Context Box > Branch Analysis." - ) } } catch (ex: Exception) { Notifications.error( @@ -83,6 +86,8 @@ open class SummarizeBranchDiffAction( "Summarize Branch Diff failed [$sourceTag]", ex.message ?: ex.toString() ) + } finally { + ContextBoxStateService.getInstance(project).finishBranchDiff() } } }) @@ -92,114 +97,6 @@ open class SummarizeBranchDiffAction( e.presentation.isEnabledAndVisible = e.project != null } - private fun resolveTargetRef( - project: com.intellij.openapi.project.Project, - e: AnActionEvent, - currentBranch: String - ): String? { - val rawBranches = e.getData(VcsLogDataKeys.VCS_LOG_BRANCHES) as? Collection<*> - if (rawBranches != null) { - val branches = linkedSetOf() - for (rawBranch in rawBranches) { - val name = extractBranchName(rawBranch)?.trim().orEmpty() - if (name.isNotEmpty()) { - branches.add(name) - } - } - - branches.firstOrNull { it != currentBranch }?.let { return it } - branches.firstOrNull()?.let { return it } - } - - resolveBranchFromLogUi(e, currentBranch)?.let { return it } - resolveSelectedCommitHash(e)?.let { return it } - - return resolveRepositoryBranchFallback(project, currentBranch) - } - - private fun resolveBranchFromLogUi(e: AnActionEvent, currentBranch: String): String? { - val vcsLogUi = e.getData(VcsLogDataKeys.VCS_LOG_UI) ?: return null - val filterUi = runCatching { vcsLogUi.javaClass.getMethod("getFilterUi").invoke(vcsLogUi) }.getOrNull() ?: return null - val filters = runCatching { filterUi.javaClass.getMethod("getFilters").invoke(filterUi) }.getOrNull() ?: return null - val branchFilter = runCatching { filters.javaClass.getMethod("getBranchFilter").invoke(filters) }.getOrNull() ?: return null - - val values = runCatching { - branchFilter.javaClass.getMethod("getValues").invoke(branchFilter) as? Collection<*> - }.getOrNull().orEmpty().mapNotNull { it?.toString()?.trim() } - - if (values.isNotEmpty()) { - val normalizedCurrent = currentBranch.removePrefix("refs/heads/") - values.firstOrNull { it.isNotBlank() && it != currentBranch && it != normalizedCurrent }?.let { return it } - values.firstOrNull()?.let { return it } - } - - val textPresentation = runCatching { - branchFilter.javaClass.getMethod("getTextPresentation").invoke(branchFilter) - }.getOrNull()?.toString().orEmpty() - - if (textPresentation.isBlank()) return null - val normalizedCurrent = currentBranch.removePrefix("refs/heads/") - return textPresentation - .split(",", " ", "|") - .map { it.trim() } - .firstOrNull { it.isNotBlank() && it != currentBranch && it != normalizedCurrent } - } - - private fun resolveRepositoryBranchFallback( - project: com.intellij.openapi.project.Project, - currentBranch: String - ): String? { - val repository = GitRepositoryManager.getInstance(project).repositories.firstOrNull() ?: return null - val localBranches = repository.branches.localBranches.map { it.name } - val normalizedCurrent = currentBranch.removePrefix("refs/heads/") - val candidates = localBranches.filter { it != normalizedCurrent && it != currentBranch } - - val preferred = listOf("main", "master", "develop", "dev") - preferred.firstOrNull { it in candidates }?.let { return it } - - return candidates.firstOrNull() - } - - private fun extractBranchName(value: Any?): String? { - return when (value) { - null -> null - is String -> value - else -> runCatching { - value.javaClass.getMethod("getName").invoke(value) as? String - }.getOrNull() ?: value.toString() - } - } - - private fun resolveCurrentBranch(project: com.intellij.openapi.project.Project): String { - val repo = GitRepositoryManager.getInstance(project).repositories.firstOrNull() - return repo?.currentBranchName ?: "HEAD" - } - - private fun resolveSelectedCommitHash(e: AnActionEvent): String? { - val vcsLog = e.getData(VcsLogDataKeys.VCS_LOG) ?: return null - val selectedCommits = runCatching { - vcsLog.javaClass.getMethod("getSelectedCommits").invoke(vcsLog) as? Collection<*> - }.getOrNull() ?: return null - - return selectedCommits.firstNotNullOfOrNull { extractCommitHash(it) } - } - - private fun extractCommitHash(value: Any?): String? { - if (value == null) return null - if (value is String && value.matches(Regex("^[0-9a-fA-F]{7,40}$"))) return value - - val hashObject = runCatching { - value.javaClass.getMethod("getHash").invoke(value) - }.getOrNull() ?: return null - - return runCatching { - hashObject.javaClass.getMethod("asString").invoke(hashObject) as? String - }.getOrNull() - ?: runCatching { - hashObject.javaClass.getMethod("toShortString").invoke(hashObject) as? String - }.getOrNull() - ?: hashObject.toString() - } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ThemeColors.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ThemeColors.kt new file mode 100644 index 0000000..bd49f78 --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ThemeColors.kt @@ -0,0 +1,54 @@ +package org.openprojectx.ai.plugin + +import com.intellij.ui.JBColor +import java.awt.Color + +object ThemeColors { + // --- Main backgrounds --- + val mainBg = JBColor(Color(0xF5, 0xF5, 0xF5), Color(0x0B, 0x14, 0x19)) + val mainFg = JBColor(Color(0x1C, 0x1C, 0x1C), Color(0xE9, 0xED, 0xEF)) + val borderColor = JBColor(Color(0xD0, 0xD0, 0xD0), Color(0x2A, 0x37, 0x42)) + + // --- Input --- + val inputBg = JBColor(Color.WHITE, Color(0x1A, 0x25, 0x2F)) + + // --- Chat bubbles (user = same in both, assistant / system = theme-aware) --- + val userBubbleBg = Color(0xD9, 0xFD, 0xD3) + val userBubbleFg = Color(0x11, 0x1B, 0x21) + val userBubbleBorder = Color(0xC7, 0xEB, 0xC1) + + val assistantBubbleBg = JBColor(Color(0xF0, 0xF0, 0xF0), Color(0x1A, 0x25, 0x2F)) + val assistantBubbleFg = JBColor(Color(0x1C, 0x1C, 0x1C), Color(0xE9, 0xED, 0xEF)) + val assistantBubbleBorder = JBColor(Color(0xCC, 0xCC, 0xCC), Color(0x3A, 0x47, 0x52)) + + val systemBubbleBg = JBColor(Color(0xE8, 0xF5, 0xE9), Color(0x1A, 0x25, 0x2F)) + val systemAccent = Color(0x00, 0xA8, 0x84) // brand, same in both + + // --- Timestamps & muted --- + val userTimestamp = Color(0x86, 0xBE, 0x9E) + val assistantTimestamp = JBColor(Color(0x99, 0x99, 0x99), Color(0xA0, 0xA8, 0xAF)) + val systemTimestamp = JBColor(Color(0x99, 0x99, 0x99), Color(0x66, 0x77, 0x81)) + val emptyText = JBColor(Color(0x99, 0x99, 0x99), Color(0x66, 0x66, 0x66)) + + // --- Role labels --- + val userRole = Color(0x00, 0xA8, 0x84) + val assistantRole = JBColor(Color(0x75, 0x75, 0x75), Color(0x54, 0x65, 0x6F)) + + // --- Page / surface / card (for Prompt Manager, Skill Manager) --- + val pageBg = JBColor(Color(0xF5, 0xF5, 0xF5), Color(0x0F, 0x17, 0x2A)) + val surfaceBg = JBColor(Color(0xFA, 0xFA, 0xFA), Color(0x11, 0x1C, 0x2F)) + val cardBg = JBColor(Color(0xF0, 0xF0, 0xF0), Color(0x14, 0x1F, 0x34)) + val accentBlue = Color(0x3B, 0x82, 0xF6) // same in both + val mutedFg = JBColor(Color(0x90, 0x90, 0x90), Color(0x94, 0xA3, 0xB8)) + + // --- Category colors (same in both themes) --- + val categoryTest = Color(0xA7, 0x8B, 0xFA) + val categoryCommit = Color(0xF5, 0x9E, 0x0B) + val categoryBranchDiff = Color(0x38, 0xB2, 0xDF) + val categoryCodeGen = Color(0x22, 0xC5, 0x5E) + val categoryCodeReview = Color(0xFB, 0x71, 0x85) + + // --- List / selection --- + val listSelectionFg = JBColor(Color.BLACK, Color.WHITE) + val designBorderColor = JBColor(Color(0xD0, 0xD0, 0xD0), Color(0x2A, 0x37, 0x42)) +} diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParser.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParser.kt index 23ec8e0..0f0e041 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParser.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParser.kt @@ -70,21 +70,23 @@ object GitRemoteParser { if (!uri.scheme.equals("ssh", ignoreCase = true)) return null val host = uri.host ?: return null val segments = uri.path.trim('/').split('/').filter { it.isNotBlank() } - val hasScmSegment = segments.firstOrNull().equals("scm", ignoreCase = true) - if (!looksLikeBitbucketHost(host) && !hasScmSegment && uri.port != 7999) return null - val normalizedSegments = if (segments.firstOrNull().equals("scm", ignoreCase = true)) { - segments.drop(1) + val scmIndex = segments.indexOfFirst { it.equals("scm", ignoreCase = true) } + if (!looksLikeBitbucketHost(host) && scmIndex < 0 && uri.port != 7999) return null + val normalizedSegments = if (scmIndex >= 0) { + segments.drop(scmIndex + 1) } else { segments } if (normalizedSegments.size < 2) return null + val contextPath = if (scmIndex > 0) segments.take(scmIndex).joinToString("/") else "" + return RepositoryRef( provider = GitHostingProviderType.BITBUCKET, - host = host, + host = hostWithPort(uri), projectKey = normalizedSegments[0], repoSlug = trimGitSuffix(normalizedSegments[1]), - apiBaseUrl = "https://$host" + apiBaseUrl = buildApiBaseUrl(uri, contextPath) ) } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/DependentMethodCollector.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/DependentMethodCollector.kt new file mode 100644 index 0000000..eda0b80 --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/DependentMethodCollector.kt @@ -0,0 +1,60 @@ +package org.openprojectx.ai.plugin.testgen + +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.JavaRecursiveElementVisitor +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiMethodCallExpression + +object DependentMethodCollector { + + fun collect(project: Project, sourceFile: VirtualFile): String { + return ReadAction.compute { + val psiFile = PsiManager.getInstance(project).findFile(sourceFile) as? PsiJavaFile + ?: return@compute "" + val psiClass = psiFile.classes.firstOrNull() ?: return@compute "" + + val qualifiedName = psiClass.qualifiedName ?: psiClass.name ?: "" + val externalSignatures = linkedSetOf() + + for (method in psiClass.methods) { + val body = method.body ?: continue + body.accept(object : JavaRecursiveElementVisitor() { + override fun visitMethodCallExpression(call: PsiMethodCallExpression) { + super.visitMethodCallExpression(call) + val target = call.resolveMethod() ?: return + val targetClass = target.containingClass ?: return + val targetQName = targetClass.qualifiedName ?: return + + // Skip methods in the same class (already in contractText) + if (targetQName == qualifiedName) return + // Skip JDK methods + if (targetQName.startsWith("java.") || targetQName.startsWith("javax.")) return + // Skip Kotlin stdlib and common libraries + if (targetQName.startsWith("kotlin.")) return + + externalSignatures.add(formatSignature(target, targetQName)) + } + }) + } + + if (externalSignatures.isEmpty()) return@compute "" + + buildString { + appendLine("## Methods called by this class (for mock/stub reference)") + externalSignatures.forEach { appendLine(it) } + } + } + } + + private fun formatSignature(method: PsiMethod, qualifiedClassName: String): String { + val returnType = method.returnType?.presentableText ?: "void" + val params = method.parameterList.parameters.joinToString(", ") { param -> + "${param.type.presentableText} ${param.name}" + } + return "$returnType $qualifiedClassName.${method.name}($params)" + } +} diff --git a/plugin-idea/src/main/resources/META-INF/plugin.xml b/plugin-idea/src/main/resources/META-INF/plugin.xml index cc5dc4b..1624a11 100644 --- a/plugin-idea/src/main/resources/META-INF/plugin.xml +++ b/plugin-idea/src/main/resources/META-INF/plugin.xml @@ -6,6 +6,7 @@ com.intellij.modules.platform + com.intellij.modules.java com.intellij.modules.lang com.intellij.modules.vcs Git4Idea @@ -49,9 +50,9 @@ - + httpResponse; // 模拟的HTTP响应 + + private ApiAndJavaMixedSample sample; // 被测试的实例 + + @BeforeEach + void setUp() { + // 初始化被测试实例,注入模拟的HTTP客户端 + sample = new ApiAndJavaMixedSample(httpClient); + } + + @Test + void fetchUserStatusCode_shouldReturnStatusCode_whenRequestSucceeds() throws Exception { + // 测试:当HTTP请求成功时,应返回状态码 + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(200); + + int statusCode = sample.fetchUserStatusCode("https://api.example.com", "user123"); + + assertEquals(200, statusCode); + verify(httpClient).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + } + + @Test + void fetchUserStatusCode_shouldThrowIllegalStateException_whenIOExceptionOccurs() throws Exception { + // 测试:当发生IO异常时,应抛出IllegalStateException + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Connection failed")); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> sample.fetchUserStatusCode("https://api.example.com", "user123")); + assertTrue(exception.getMessage().contains("Failed to fetch user status code")); + } + + @Test + void fetchUserStatusCode_shouldThrowIllegalStateException_whenInterruptedExceptionOccurs() throws Exception { + // 测试:当发生中断异常时,应抛出IllegalStateException + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new InterruptedException("Interrupted")); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> sample.fetchUserStatusCode("https://api.example.com", "user123")); + assertTrue(exception.getMessage().contains("Failed to fetch user status code")); + } + + @Test + void fetchOrderBody_shouldReturnBody_whenRequestSucceeds() throws Exception { + // 测试:当HTTP请求成功时,应返回响应体 + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(httpResponse); + when(httpResponse.body()).thenReturn("{\"orderId\": \"order456\"}"); + + String body = sample.fetchOrderBody("https://api.example.com", "order456"); + + assertEquals("{\"orderId\": \"order456\"}", body); + verify(httpClient).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + } + + @Test + void fetchOrderBody_shouldThrowIllegalStateException_whenIOExceptionOccurs() throws Exception { + // 测试:当发生IO异常时,应抛出IllegalStateException + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Connection failed")); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> sample.fetchOrderBody("https://api.example.com", "order456")); + assertTrue(exception.getMessage().contains("Failed to fetch order body")); + } + + @Test + void fetchOrderBody_shouldThrowIllegalStateException_whenInterruptedExceptionOccurs() throws Exception { + // 测试:当发生中断异常时,应抛出IllegalStateException + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new InterruptedException("Interrupted")); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> sample.fetchOrderBody("https://api.example.com", "order456")); + assertTrue(exception.getMessage().contains("Failed to fetch order body")); + } + + @Test + void sum_shouldReturnZero_whenListIsNull() { + // 测试:当列表为null时,应返回0 + assertEquals(0, sample.sum(null)); + } + + @Test + void sum_shouldReturnZero_whenListIsEmpty() { + // 测试:当列表为空时,应返回0 + assertEquals(0, sample.sum(Collections.emptyList())); + } + + @Test + void sum_shouldReturnSumOfValues_whenListContainsNonNullValues() { + // 测试:当列表包含非空值时,应返回所有值的和 + assertEquals(15, sample.sum(Arrays.asList(1, 2, 3, 4, 5))); + } + + @Test + void sum_shouldSkipNullValues_whenListContainsNulls() { + // 测试:当列表包含null值时,应跳过null值并计算非空值的和 + assertEquals(6, sample.sum(Arrays.asList(1, null, 2, null, 3))); + } + + @Test + void sum_shouldReturnZero_whenListContainsOnlyNulls() { + // 测试:当列表只包含null值时,应返回0 + assertEquals(0, sample.sum(Arrays.asList(null, null, null))); + } + + @Test + void normalizeName_shouldReturnEmptyString_whenInputIsNull() { + // 测试:当输入为null时,应返回空字符串 + assertEquals("", sample.normalizeName(null)); + } + + @Test + void normalizeName_shouldTrimAndLowercase_whenInputHasSpaces() { + // 测试:当输入包含空格时,应去除首尾空格并将中间多个空格合并为一个,然后转换为小写 + assertEquals("john doe", sample.normalizeName(" John Doe ")); + } + + @Test + void normalizeName_shouldReturnLowercase_whenInputIsAlreadyTrimmed() { + // 测试:当输入已经去除首尾空格时,应直接转换为小写 + assertEquals("alice", sample.normalizeName("Alice")); + } + + @Test + void normalizeName_shouldHandleMultipleSpaces_whenInputHasMultipleSpaces() { + // 测试:当输入包含多个连续空格时,应合并为一个空格并转换为小写 + assertEquals("a b c", sample.normalizeName("A B C")); + } + + @Test + void normalizeName_shouldReturnEmptyString_whenInputIsEmpty() { + // 测试:当输入为空字符串时,应返回空字符串 + assertEquals("", sample.normalizeName("")); + } + + @Test + void isVipLevel_shouldReturnTrue_whenScoreIs900() { + // 测试:当分数为900时,应返回true(VIP等级) + assertTrue(sample.isVipLevel(900)); + } + + @Test + void isVipLevel_shouldReturnTrue_whenScoreIsAbove900() { + // 测试:当分数大于900时,应返回true(VIP等级) + assertTrue(sample.isVipLevel(1000)); + } + + @Test + void isVipLevel_shouldReturnFalse_whenScoreIsBelow900() { + // 测试:当分数小于900时,应返回false(非VIP等级) + assertFalse(sample.isVipLevel(899)); + } + + @Test + void isVipLevel_shouldReturnFalse_whenScoreIsNegative() { + // 测试:当分数为负数时,应返回false(非VIP等级) + assertFalse(sample.isVipLevel(-100)); + } + + @Test + void isVipLevel_shouldReturnFalse_whenScoreIsZero() { + // 测试:当分数为0时,应返回false(非VIP等级) + assertFalse(sample.isVipLevel(0)); + } +} \ No newline at end of file From 204f9cf1af4e66af9c31f57dcf0db0fe38022c4f Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Tue, 2 Jun 2026 18:50:00 +0800 Subject: [PATCH 2/5] feat: redesign context box guide tab --- .../ai/plugin/ContextBoxToolWindowFactory.kt | 477 ++++++++++++++---- 1 file changed, 391 insertions(+), 86 deletions(-) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index 08f837b..7d27b72 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task import com.intellij.openapi.ui.Messages +import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.wm.ToolWindow @@ -21,6 +22,7 @@ import java.io.File import java.awt.BorderLayout import java.awt.CardLayout import java.awt.Color +import java.awt.Cursor import java.awt.FlowLayout import java.awt.Font import java.awt.Component @@ -431,15 +433,8 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { render(stateService.snapshot()) - project.messageBus.connect(toolWindow.disposable).subscribe( - ContextBoxStateService.TOPIC, - ContextBoxListener { snapshot -> - render(snapshot) - } - ) - val tabs = JTabbedPane().apply { - insertTab("Readme", OpenProjectXIcons.GenerateTests, createReadmePanel(bgColor, fgColor, borderColor, commonFont), "Feature overview and quick start", 0) + insertTab("Guide", OpenProjectXIcons.GenerateTests, createReadmePanel(project, bgColor, fgColor, borderColor, commonFont), "Feature guide and setup progress", 0) addTab("Context", chatPanel) addTab("Prompt Manager", createPromptManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) addTab("Skill Manager", createSkillManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) @@ -449,6 +444,21 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } } + project.messageBus.connect(toolWindow.disposable).subscribe( + ContextBoxStateService.TOPIC, + ContextBoxListener { snapshot -> + render(snapshot) + if (snapshot.history.isNotEmpty()) { + for (index in 0 until tabs.tabCount) { + if (tabs.getTitleAt(index) == "Context") { + tabs.selectedIndex = index + break + } + } + } + } + ) + val content = ContentFactory.getInstance().createContent(tabs, "", false) toolWindow.contentManager.addContent(content) } @@ -1889,106 +1899,401 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } private fun createReadmePanel( - bgColor: Color, fgColor: Color, borderColor: Color, commonFont: Font + project: Project, + bgColor: Color, + fgColor: Color, + borderColor: Color, + commonFont: Font ): JPanel { - val titleFont = commonFont.deriveFont(Font.BOLD, 16f) - val sectionFont = commonFont.deriveFont(Font.BOLD, 14f) + val pageColor = ThemeColors.pageBg + val surfaceColor = ThemeColors.surfaceBg + val cardColor = ThemeColors.cardBg + val mutedColor = ThemeColors.mutedFg + val accentColor = ThemeColors.accentBlue val bodyFont = commonFont.deriveFont(Font.PLAIN, 13f) - val accentColor = ThemeColors.systemAccent + val smallFont = commonFont.deriveFont(Font.PLAIN, 12f) + val sectionFont = commonFont.deriveFont(Font.BOLD, 17f) + val titleFont = commonFont.deriveFont(Font.BOLD, 22f) + + data class GuideFeature( + val icon: String, + val title: String, + val summary: String, + val category: String, + val intro: String, + val steps: List>, + val configurable: List, + val bestFor: List, + val tip: String, + val actionLabel: String, + val action: () -> Unit + ) - val contentPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - background = bgColor - border = BorderFactory.createEmptyBorder(16, 16, 16, 16) + lateinit var contentRoot: JPanel + + fun selectTab(tabName: String) { + val tabs = javax.swing.SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, contentRoot) + if (tabs is JTabbedPane) { + for (index in 0 until tabs.tabCount) { + if (tabs.getTitleAt(index) == tabName) { + tabs.selectedIndex = index + return + } + } + } } - data class Feature(val title: String, val trigger: String, val tabName: String?) + fun showUsage(title: String, message: String) { + Notifications.info(project, title, message) + } + + fun featureButton(text: String, action: () -> Unit) = JButton(text).apply { + font = bodyFont.deriveFont(Font.BOLD) + foreground = accentColor + background = cardColor + border = BorderFactory.createEmptyBorder(5, 0, 5, 0) + isContentAreaFilled = false + isFocusPainted = false + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addActionListener { action() } + } val features = listOf( - Feature("Test Generation", "Open an OpenAPI (.yaml/.yml) or Java source file in the editor, then click \"Generate Tests By AI\" in the banner at the top.", null), - Feature("Commit Message Generation", "In the VCS Commit dialog, use the toolbar button \"Generate Commit Message\" and choose a prompt profile.", null), - Feature("Branch Diff Analysis", "In the VCS Log (Git Log), right-click a branch or commit and choose \"Analyze Branch Diff\".", "Context"), - Feature("Push & Create PR", "In the VCS Commit dialog, use \"Push and Create PR\" to push your branch and create a pull request with an AI-generated summary.", null), - Feature("Code Generate & Review", "Select code in the editor, right-click, and choose \"Code Generate & Review\" to send it to the LLM.", null), - Feature("SonarQube Coverage", "Go to Tools → SonarQube Coverage to fetch coverage data and generate missing tests.", "Sonar Cube"), - Feature("Context Box Chat", "Use the Context tab to chat with the LLM. After a branch diff or test generation, you can ask follow-up questions, request translations, or regenerate tests with improvements.", "Context"), - Feature("Prompt Manager", "Manage prompt templates organized by category (Test, Commit, Branch Diff, Code Generate, Code Review). Create, edit, duplicate, or sync prompts from a remote repository.", "Prompt Manager"), - Feature("Skill Manager", "Manage skill definitions (YAML templates) with global and local scopes. Sync skills from a remote Bitbucket/GitHub repository.", "Skill Manager"), - Feature("Settings", "Configure LLM provider, API key, login template, prompt defaults, and SonarQube settings at File → Settings → Tools → Code Quality Improver.", null) + GuideFeature( + icon = "⚗", title = "Generate Tests", category = "Test Generation", + summary = "Generate unit or API tests from Java code or OpenAPI contracts.", + intro = "Generate comprehensive JUnit 5 / Rest Assured or Karate tests from Java source files and OpenAPI contracts.", + steps = listOf( + "Open a supported file" to "Open a Java source file or an OpenAPI .yaml/.yml contract in the editor.", + "Click Generate Tests By AI" to "Use the action in the editor notification bar shown above the file.", + "Configure generation options" to "Choose framework, prompt profile, output path, class name, package, base URL and extra notes.", + "Review the generated file" to "The generated test is written to the selected path and its diff is added to Context Box." + ), + configurable = listOf("Framework: JUnit 5 / Rest Assured or Karate", "Prompt profile", "Output location", "Class and package name", "Base URL and extra notes"), + bestFor = listOf("Java unit and API tests", "OpenAPI contract testing", "Boundary and edge-case coverage"), + tip = "External method signatures called by the Java target are collected to help the LLM produce more accurate mocks.", + actionLabel = "Show me how", + action = { showUsage("Generate Tests", "Open a Java source or OpenAPI .yaml/.yml file, then click 'Generate Tests By AI' in the editor notification bar.") } + ), + GuideFeature( + icon = "✎", title = "Commit Message", category = "Commit Generation", + summary = "Generate a meaningful commit message from your current changes.", + intro = "Use an AI-generated commit message based on your staged and unstaged Git diff.", + steps = listOf( + "Open the Commit window" to "Open the IDE VCS Commit tool window.", + "Choose Generate Commit Message" to "Use the commit toolbar action and select a configured prompt profile.", + "Review the generated message" to "The generated text is inserted into the Commit Message field for editing before commit." + ), + configurable = listOf("Commit prompt profile", "Prompt templates in Prompt Manager", "Optional JIRA-style branch prefix"), + bestFor = listOf("Consistent commit conventions", "Summarizing multi-file changes", "Reducing repetitive writing"), + tip = "If the branch name contains a JIRA-style key such as ABC-123, it is used as a commit-message prefix.", + actionLabel = "Open Prompt Manager", + action = { selectTab("Prompt Manager") } + ), + GuideFeature( + icon = "⑂", title = "Branch Analysis", category = "Branch Compare", + summary = "Analyze differences between branches or commits with AI insights.", + intro = "Analyze a Git branch or commit comparison with a selectable branch-diff prompt profile.", + steps = listOf( + "Open Git Log" to "Open VCS → Log in the IDE.", + "Select a target" to "Right-click the branch or commit that you want to compare with the current branch.", + "Choose Analyze Branch Diff" to "Select a configured prompt profile from the context menu.", + "Review in Context Box" to "The AI branch summary appears in the Context tab and can be used to create a PR." + ), + configurable = listOf("Branch-diff prompt profile", "Target branch or selected commit", "Follow-up questions in Context Box"), + bestFor = listOf("Pull-request preparation", "Risk review", "Understanding unfamiliar changes"), + tip = "Branch analysis uses the current branch as the source and the Git Log selection as the comparison target.", + actionLabel = "Open Context", + action = { selectTab("Context") } + ), + GuideFeature( + icon = "⇧", title = "Push & Create PR", category = "Pull Request", + summary = "Push your current branch and create a pull request with an AI summary.", + intro = "Push the checked-out Git branch and optionally create a Bitbucket or GitHub pull request with an AI-generated title and description.", + steps = listOf( + "Open the Commit window" to "Use the VCS Commit tool window after preparing your branch.", + "Choose Push and Create PR" to "Open the push dialog from the commit toolbar action.", + "Select a target branch" to "Keep or change the target branch and choose whether to create a PR after push.", + "Open the created PR" to "The IDE notification shows the pull-request URL after creation." + ), + configurable = listOf("Create PR after push", "Target branch", "PR prompt template", "Git remote credentials"), + bestFor = listOf("Fast PR creation", "Consistent PR summaries", "Bitbucket and GitHub repositories"), + tip = "A supported Bitbucket or GitHub remote and valid credentials are required for automatic pull-request creation.", + actionLabel = "Show me how", + action = { showUsage("Push & Create PR", "Open the VCS Commit window and choose 'Push and Create PR' from its toolbar.") } + ), + GuideFeature( + icon = "", title = "Code Review", category = "Code Review", + summary = "Review selected code and get AI suggestions and improvements.", + intro = "Review selected editor code using a Code Review prompt from Prompt Manager.", + steps = listOf( + "Select code" to "Highlight the code you want to review in the editor.", + "Open Code Generate & Review" to "Right-click the selection and choose the editor context-menu action.", + "Choose a Code Review prompt" to "Select a review profile and optionally add extra requirements.", + "Read the response" to "The LLM review is displayed in the Context tab." + ), + configurable = listOf("Code Review prompt profile", "Extra requirements", "Reusable prompt templates"), + bestFor = listOf("Focused code review", "Maintainability feedback", "Security and performance checks"), + tip = "Code Review and Code Generate share the same editor context-menu action; choose the appropriate prompt category in the dialog.", + actionLabel = "Open Prompt Manager", + action = { selectTab("Prompt Manager") } + ), + GuideFeature( + icon = "✣", title = "Code Generate", category = "Code Generation", + summary = "Generate code for the selected context and your requirements.", + intro = "Generate code from the selected editor context using a Code Generate prompt and optional extra requirements.", + steps = listOf( + "Select context code" to "Highlight relevant code in the editor.", + "Open Code Generate & Review" to "Right-click the selection and choose the editor context-menu action.", + "Choose a Code Generate prompt" to "Select a generation profile and describe the desired change.", + "Review the response" to "The generated suggestion is displayed in the Context tab." + ), + configurable = listOf("Code Generate prompt profile", "Extra requirements", "Reusable prompt templates"), + bestFor = listOf("Boilerplate generation", "Small focused enhancements", "Context-aware implementation ideas"), + tip = "Provide a focused selection and explicit requirements to keep generated changes relevant.", + actionLabel = "Open Prompt Manager", + action = { selectTab("Prompt Manager") } + ), + GuideFeature( + icon = "♢", title = "SonarQube Coverage", category = "Coverage Analysis", + summary = "Inspect coverage, scan issues and generate missing tests.", + intro = "Fetch SonarQube coverage or run local inspections, then optionally generate missing tests with AI.", + steps = listOf( + "Open SonarQube Coverage" to "Choose Tools → SonarQube Coverage.", + "Configure the scan" to "Enter server credentials for online mode or enable local / mock scan mode.", + "Review metrics and issues" to "Inspect coverage cards, file coverage and local inspection findings in Sonar Cube.", + "Generate missing tests" to "Optionally ask AI to generate tests for files below the target coverage." + ), + configurable = listOf("Server URL and project key", "Token or username/password", "Target coverage and max files", "Local, mock or online scan mode"), + bestFor = listOf("Coverage gap analysis", "Local code-quality inspections", "Generating missing tests"), + tip = "Local scan mode detects TODO/FIXME markers, printStackTrace calls, empty catches and hard-coded secrets without a SonarQube server.", + actionLabel = "Open Sonar Cube", + action = { selectTab("Sonar Cube") } + ), + GuideFeature( + icon = "☵", title = "AI Chat (Context Box)", category = "Context Box", + summary = "Chat with AI using recent project-task context and history.", + intro = "Ask follow-up questions in a multi-turn chat that includes recent Context Box history.", + steps = listOf( + "Open Context" to "Switch to the Context tab in AI Context Box.", + "Type a message" to "Ask a follow-up question, request a translation or refine a generated test.", + "Send with Enter" to "The recent conversation history is sent to the configured LLM.", + "Apply regenerated tests" to "When test code is returned, use Generate Tests → to overwrite the target file." + ), + configurable = listOf("LLM provider and model", "API key or login flow", "Recent conversation context"), + bestFor = listOf("Follow-up questions", "Refining generated tests", "Explaining branch analysis"), + tip = "Context Box retains the most recent messages so follow-up requests can build on earlier generated output.", + actionLabel = "Open Context", + action = { selectTab("Context") } + ) ) - // Title - contentPanel.add(JLabel("Code Quality Assistant").apply { - font = titleFont - foreground = accentColor - alignmentX = Component.LEFT_ALIGNMENT - }) - contentPanel.add(Box.createVerticalStrut(4)) - contentPanel.add(JLabel("AI-powered code quality tools for IntelliJ IDEA").apply { - font = bodyFont - foreground = fgColor + val model = LlmSettingsLoader.loadSettingsModel(project) + val repoConfigured = model.bitbucketPromptRepoEnabled && model.bitbucketPromptRepoUrl.isNotBlank() + val llmConfigured = model.llmModel.isNotBlank() && ( + (model.llmProvider == "openai-compatible" && model.llmEndpoint.isNotBlank() && + (model.llmApiKey.isNotBlank() || model.llmApiKeyEnv.isNotBlank() || model.loginEnabled)) || + (model.llmProvider == "template" && model.llmTemplateEnabled && model.llmTemplateUrl.isNotBlank()) + ) + val setupCount = listOf(repoConfigured, llmConfigured).count { it } + + fun panelBorder() = BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(borderColor), + BorderFactory.createEmptyBorder(14, 16, 14, 16) + ) + + fun label(text: String, font: Font = bodyFont, color: Color = fgColor) = JLabel(text).apply { + this.font = font + foreground = color + } + + fun html(text: String, width: Int, font: Font = bodyFont, color: Color = fgColor) = JLabel( + "${text}" + ).apply { + this.font = font + foreground = color + } + + contentRoot = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + background = pageColor + border = BorderFactory.createEmptyBorder(18, 18, 18, 18) + } + + val detailsContainer = JPanel(BorderLayout()).apply { + background = pageColor alignmentX = Component.LEFT_ALIGNMENT - }) - contentPanel.add(Box.createVerticalStrut(16)) + } - for (feature in features) { - val sectionPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - background = bgColor - alignmentX = Component.LEFT_ALIGNMENT + lateinit var showFeature: (GuideFeature) -> Unit + val cardPanels = mutableListOf() + + fun createCard(feature: GuideFeature): JPanel { + val card = JPanel(BorderLayout(0, 8)).apply { + background = cardColor border = BorderFactory.createCompoundBorder( - BorderFactory.createMatteBorder(1, 0, 0, 0, borderColor), - BorderFactory.createEmptyBorder(10, 0, 10, 0) + BorderFactory.createLineBorder(borderColor), + BorderFactory.createEmptyBorder(14, 14, 12, 14) ) - maximumSize = Dimension(Int.MAX_VALUE, 120) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } + card.add(label(feature.icon, commonFont.deriveFont(Font.BOLD, 22f), accentColor), BorderLayout.NORTH) + card.add(JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(label(feature.title, commonFont.deriveFont(Font.BOLD, 14f))) + add(Box.createVerticalStrut(6)) + add(html(feature.summary, 190, smallFont, mutedColor)) + }, BorderLayout.CENTER) + card.add(featureButton("Learn more →") { showFeature(feature) }, BorderLayout.SOUTH) + val listener = object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) = showFeature(feature) + } + card.addMouseListener(listener) + cardPanels += card + return card + } - sectionPanel.add(JLabel(feature.title).apply { - font = sectionFont - foreground = accentColor - alignmentX = Component.LEFT_ALIGNMENT - }) - sectionPanel.add(Box.createVerticalStrut(4)) - sectionPanel.add(JLabel("${feature.trigger}").apply { - font = bodyFont - foreground = fgColor - alignmentX = Component.LEFT_ALIGNMENT - }) - sectionPanel.add(Box.createVerticalStrut(6)) - - if (feature.tabName != null) { - val jumpBtn = JButton("→ Open ${feature.tabName}").apply { - font = bodyFont.deriveFont(Font.BOLD) - foreground = accentColor - background = bgColor - isOpaque = true - border = BorderFactory.createEmptyBorder(4, 0, 4, 0) - isContentAreaFilled = false + val welcomePanel = JPanel(BorderLayout(16, 0)).apply { + background = surfaceColor + border = panelBorder() + alignmentX = Component.LEFT_ALIGNMENT + maximumSize = Dimension(Int.MAX_VALUE, 138) + add(JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(label("Welcome to CQA 👋", titleFont)) + add(Box.createVerticalStrut(8)) + add(label("AI-powered tools to improve code quality and boost your productivity.", bodyFont, mutedColor)) + add(Box.createVerticalStrut(12)) + add(featureButton("Complete the setup to unlock all features →") { + ShowSettingsUtil.getInstance().showSettingsDialog(project, AiTestSettingsConfigurable::class.java) + }) + }, BorderLayout.CENTER) + add(JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(label("$setupCount / 3", commonFont.deriveFont(Font.BOLD, 18f), ThemeColors.systemAccent).apply { + horizontalAlignment = SwingConstants.CENTER + alignmentX = Component.CENTER_ALIGNMENT + }) + add(Box.createVerticalStrut(8)) + add(JButton("Setup Guide").apply { + font = bodyFont isFocusPainted = false - alignmentX = Component.LEFT_ALIGNMENT - addActionListener { - val parent = javax.swing.SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, this) - if (parent is JTabbedPane) { - for (i in 0 until parent.tabCount) { - if (parent.getTitleAt(i) == feature.tabName) { - parent.selectedIndex = i - break - } - } - } - } + alignmentX = Component.CENTER_ALIGNMENT + addActionListener { ShowSettingsUtil.getInstance().showSettingsDialog(project, AiTestSettingsConfigurable::class.java) } + }) + }, BorderLayout.EAST) + } + contentRoot.add(welcomePanel) + contentRoot.add(Box.createVerticalStrut(20)) + contentRoot.add(label("What would you like to do?", sectionFont).apply { alignmentX = Component.LEFT_ALIGNMENT }) + contentRoot.add(Box.createVerticalStrut(4)) + contentRoot.add(label("Select a feature to see how it works.", bodyFont, mutedColor).apply { alignmentX = Component.LEFT_ALIGNMENT }) + contentRoot.add(Box.createVerticalStrut(12)) + + val cardsPanel = JPanel(java.awt.GridLayout(0, 4, 12, 12)).apply { + background = pageColor + alignmentX = Component.LEFT_ALIGNMENT + maximumSize = Dimension(Int.MAX_VALUE, 410) + features.forEach { add(createCard(it)) } + } + contentRoot.add(cardsPanel) + contentRoot.add(Box.createVerticalStrut(20)) + contentRoot.add(detailsContainer) + + showFeature = { feature -> + cardPanels.forEachIndexed { index, card -> + card.border = BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(if (features[index] == feature) accentColor else borderColor), + BorderFactory.createEmptyBorder(14, 14, 12, 14) + ) + } + val stepsPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(label("How it works", commonFont.deriveFont(Font.BOLD, 15f))) + add(Box.createVerticalStrut(8)) + feature.steps.forEachIndexed { index, step -> + add(html("${index + 1}.  ${step.first}
    ${step.second}", 560, bodyFont, fgColor)) + add(Box.createVerticalStrut(10)) } - sectionPanel.add(jumpBtn) } - - contentPanel.add(sectionPanel) + val factsPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + background = surfaceColor + border = panelBorder() + add(label("You can configure", commonFont.deriveFont(Font.BOLD, 14f))) + add(Box.createVerticalStrut(6)) + feature.configurable.forEach { add(label("✓ $it", smallFont, ThemeColors.categoryCodeGen)) } + }) + add(Box.createVerticalStrut(10)) + add(JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + background = surfaceColor + border = panelBorder() + add(label("Best for", commonFont.deriveFont(Font.BOLD, 14f))) + add(Box.createVerticalStrut(6)) + feature.bestFor.forEach { add(label("• $it", smallFont, mutedColor)) } + }) + } + detailsContainer.removeAll() + detailsContainer.add(JPanel(BorderLayout(12, 12)).apply { + background = surfaceColor + border = panelBorder() + add(JPanel(BorderLayout()).apply { + isOpaque = false + add(JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply { + isOpaque = false + add(label("${feature.icon} ${feature.title}", commonFont.deriveFont(Font.BOLD, 18f))) + add(label(feature.category, smallFont, ThemeColors.categoryTest).apply { + border = BorderFactory.createEmptyBorder(3, 6, 3, 6) + isOpaque = true + background = cardColor + }) + }, BorderLayout.WEST) + add(JButton(feature.actionLabel).apply { + font = bodyFont.deriveFont(Font.BOLD) + foreground = accentColor + isFocusPainted = false + addActionListener { feature.action() } + }, BorderLayout.EAST) + add(html(feature.intro, 760, bodyFont, mutedColor), BorderLayout.SOUTH) + }, BorderLayout.NORTH) + add(JPanel(BorderLayout(20, 0)).apply { + isOpaque = false + add(stepsPanel, BorderLayout.CENTER) + add(factsPanel, BorderLayout.EAST) + }, BorderLayout.CENTER) + add(JPanel(BorderLayout()).apply { + background = cardColor + border = panelBorder() + add(html("💡 Tips
${feature.tip}", 900, smallFont, mutedColor), BorderLayout.CENTER) + }, BorderLayout.SOUTH) + }, BorderLayout.CENTER) + detailsContainer.revalidate() + detailsContainer.repaint() } + showFeature(features.first()) + + contentRoot.add(Box.createVerticalStrut(14)) + contentRoot.add(JPanel(BorderLayout()).apply { + background = surfaceColor + border = BorderFactory.createEmptyBorder(8, 10, 8, 10) + alignmentX = Component.LEFT_ALIGNMENT + add(label("⚙ Setup Progress", bodyFont, mutedColor), BorderLayout.WEST) + add(label("${if (repoConfigured) "✓" else "○"} Prompt repo configured ${if (llmConfigured) "✓" else "○"} LLM configured 3 Try a feature", bodyFont, mutedColor), BorderLayout.EAST) + }) return JPanel(BorderLayout()).apply { background = bgColor - add(JBScrollPane(contentPanel).apply { - viewport.background = bgColor - background = bgColor + add(JBScrollPane(contentRoot).apply { + viewport.background = pageColor + background = pageColor border = BorderFactory.createEmptyBorder() verticalScrollBar.unitIncrement = 16 }, BorderLayout.CENTER) From e967fed63ce8ec1008942cd61d6875b9e89536c8 Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Tue, 2 Jun 2026 19:02:04 +0800 Subject: [PATCH 3/5] fix: compact guide cards and clarify setup flow --- .../ai/plugin/ContextBoxToolWindowFactory.kt | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index 7d27b72..4eb7daa 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -2143,13 +2143,16 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { isOpaque = false add(label(feature.title, commonFont.deriveFont(Font.BOLD, 14f))) add(Box.createVerticalStrut(6)) - add(html(feature.summary, 190, smallFont, mutedColor)) + add(html(feature.summary, 176, smallFont, mutedColor)) }, BorderLayout.CENTER) card.add(featureButton("Learn more →") { showFeature(feature) }, BorderLayout.SOUTH) val listener = object : MouseAdapter() { override fun mouseClicked(event: MouseEvent) = showFeature(feature) } card.addMouseListener(listener) + card.preferredSize = Dimension(218, 168) + card.minimumSize = Dimension(218, 168) + card.maximumSize = Dimension(218, 168) cardPanels += card return card } @@ -2158,22 +2161,24 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { background = surfaceColor border = panelBorder() alignmentX = Component.LEFT_ALIGNMENT - maximumSize = Dimension(Int.MAX_VALUE, 138) + maximumSize = Dimension(Int.MAX_VALUE, 184) add(JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = false add(label("Welcome to CQA 👋", titleFont)) - add(Box.createVerticalStrut(8)) - add(label("AI-powered tools to improve code quality and boost your productivity.", bodyFont, mutedColor)) - add(Box.createVerticalStrut(12)) - add(featureButton("Complete the setup to unlock all features →") { + add(Box.createVerticalStrut(6)) + add(label("Complete this workflow before using AI features:", bodyFont, mutedColor)) + add(Box.createVerticalStrut(10)) + add(html("1.  Configure Bitbucket Prompt Repo in Settings → Login   →   2.  Click Import Repo Config   →   3.  Configure LLM and log in from Settings → LLM", 600, bodyFont, fgColor)) + add(Box.createVerticalStrut(10)) + add(featureButton("Open setup settings →") { ShowSettingsUtil.getInstance().showSettingsDialog(project, AiTestSettingsConfigurable::class.java) }) }, BorderLayout.CENTER) add(JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = false - add(label("$setupCount / 3", commonFont.deriveFont(Font.BOLD, 18f), ThemeColors.systemAccent).apply { + add(label("$setupCount / 2", commonFont.deriveFont(Font.BOLD, 18f), ThemeColors.systemAccent).apply { horizontalAlignment = SwingConstants.CENTER alignmentX = Component.CENTER_ALIGNMENT }) @@ -2190,16 +2195,29 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { contentRoot.add(Box.createVerticalStrut(20)) contentRoot.add(label("What would you like to do?", sectionFont).apply { alignmentX = Component.LEFT_ALIGNMENT }) contentRoot.add(Box.createVerticalStrut(4)) - contentRoot.add(label("Select a feature to see how it works.", bodyFont, mutedColor).apply { alignmentX = Component.LEFT_ALIGNMENT }) + contentRoot.add(label("Select a feature to see how it works. Drag the horizontal scroll bar to browse all eight features.", bodyFont, mutedColor).apply { alignmentX = Component.LEFT_ALIGNMENT }) contentRoot.add(Box.createVerticalStrut(12)) - val cardsPanel = JPanel(java.awt.GridLayout(0, 4, 12, 12)).apply { + val cardsPanel = JPanel(java.awt.GridLayout(1, 0, 12, 0)).apply { background = pageColor - alignmentX = Component.LEFT_ALIGNMENT - maximumSize = Dimension(Int.MAX_VALUE, 410) + border = BorderFactory.createEmptyBorder(0, 0, 4, 0) features.forEach { add(createCard(it)) } + preferredSize = Dimension(features.size * 218 + (features.size - 1) * 12, 176) } - contentRoot.add(cardsPanel) + contentRoot.add(JBScrollPane( + cardsPanel, + javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, + javax.swing.ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS + ).apply { + alignmentX = Component.LEFT_ALIGNMENT + preferredSize = Dimension(760, 210) + maximumSize = Dimension(Int.MAX_VALUE, 210) + border = BorderFactory.createLineBorder(borderColor) + viewport.background = pageColor + horizontalScrollBar.unitIncrement = 24 + horizontalScrollBar.blockIncrement = 220 + toolTipText = "Drag the horizontal scroll bar to browse all features" + }) contentRoot.add(Box.createVerticalStrut(20)) contentRoot.add(detailsContainer) @@ -2216,7 +2234,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { add(label("How it works", commonFont.deriveFont(Font.BOLD, 15f))) add(Box.createVerticalStrut(8)) feature.steps.forEachIndexed { index, step -> - add(html("${index + 1}.  ${step.first}
    ${step.second}", 560, bodyFont, fgColor)) + add(html("${index + 1}.  ${step.first}
    ${step.second}", 420, bodyFont, fgColor)) add(Box.createVerticalStrut(10)) } } @@ -2262,7 +2280,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { isFocusPainted = false addActionListener { feature.action() } }, BorderLayout.EAST) - add(html(feature.intro, 760, bodyFont, mutedColor), BorderLayout.SOUTH) + add(html(feature.intro, 560, bodyFont, mutedColor), BorderLayout.SOUTH) }, BorderLayout.NORTH) add(JPanel(BorderLayout(20, 0)).apply { isOpaque = false @@ -2272,7 +2290,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { add(JPanel(BorderLayout()).apply { background = cardColor border = panelBorder() - add(html("💡 Tips
${feature.tip}", 900, smallFont, mutedColor), BorderLayout.CENTER) + add(html("💡 Tips
${feature.tip}", 680, smallFont, mutedColor), BorderLayout.CENTER) }, BorderLayout.SOUTH) }, BorderLayout.CENTER) detailsContainer.revalidate() @@ -2285,8 +2303,8 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { background = surfaceColor border = BorderFactory.createEmptyBorder(8, 10, 8, 10) alignmentX = Component.LEFT_ALIGNMENT - add(label("⚙ Setup Progress", bodyFont, mutedColor), BorderLayout.WEST) - add(label("${if (repoConfigured) "✓" else "○"} Prompt repo configured ${if (llmConfigured) "✓" else "○"} LLM configured 3 Try a feature", bodyFont, mutedColor), BorderLayout.EAST) + add(label("⚙ Setup Workflow", bodyFont, mutedColor), BorderLayout.WEST) + add(label("${if (repoConfigured) "✓" else "○"} Repo details 2 Import config ${if (llmConfigured) "✓" else "○"} LLM login 4 Try a feature", bodyFont, mutedColor), BorderLayout.EAST) }) return JPanel(BorderLayout()).apply { From e56f6919e63d14ef446e73e1a67ff66c0d41b33a Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Tue, 2 Jun 2026 19:19:43 +0800 Subject: [PATCH 4/5] fix: preserve context tab routing with guide --- .../ai/plugin/ContextBoxToolWindowFactory.kt | 63 +++++++++++++------ 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index 4eb7daa..78f5c64 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -55,6 +55,15 @@ import org.yaml.snakeyaml.Yaml class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { + companion object { + private const val GUIDE_TAB = "Guide" + private const val CONTEXT_TAB = "Context" + private const val PROMPT_MANAGER_TAB = "Prompt Manager" + private const val SKILL_MANAGER_TAB = "Skill Manager" + private const val SONAR_CUBE_TAB = "Sonar Cube" + private const val LOG_TAB = "Log" + } + override fun shouldBeAvailable(project: Project): Boolean = true override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { @@ -431,31 +440,45 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { foreground = fgColor } - render(stateService.snapshot()) + val initialSnapshot = stateService.snapshot() + render(initialSnapshot) + + fun selectTab(tabs: JTabbedPane, title: String) { + for (index in 0 until tabs.tabCount) { + if (tabs.getTitleAt(index) == title) { + tabs.selectedIndex = index + return + } + } + } val tabs = JTabbedPane().apply { - insertTab("Guide", OpenProjectXIcons.GenerateTests, createReadmePanel(project, bgColor, fgColor, borderColor, commonFont), "Feature guide and setup progress", 0) - addTab("Context", chatPanel) - addTab("Prompt Manager", createPromptManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) - addTab("Skill Manager", createSkillManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) - addTab("Sonar Cube", SonarCubeToolWindowPanel.create(project, bgColor, fgColor, borderColor, commonFont)) + insertTab(GUIDE_TAB, OpenProjectXIcons.GenerateTests, createReadmePanel(project, bgColor, fgColor, borderColor, commonFont), "Feature guide and setup progress", 0) + addTab(CONTEXT_TAB, chatPanel) + addTab(PROMPT_MANAGER_TAB, createPromptManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) + addTab(SKILL_MANAGER_TAB, createSkillManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) + addTab(SONAR_CUBE_TAB, SonarCubeToolWindowPanel.create(project, bgColor, fgColor, borderColor, commonFont)) if (LlmSettingsLoader.loadSettingsModel(project).showLogTab) { - addTab("Log", createLogPanel(bgColor, fgColor, borderColor, commonFont)) + addTab(LOG_TAB, createLogPanel(bgColor, fgColor, borderColor, commonFont)) } } + // Some actions record their result before showing the tool window. In that case, + // open Context immediately instead of hiding the existing result behind Guide. + if (initialSnapshot.history.isNotEmpty()) { + selectTab(tabs, CONTEXT_TAB) + } + // Only newly appended messages should focus Context. Clearing or re-rendering + // history must not unexpectedly pull users away from Guide or manager tabs. + var previousHistorySize = initialSnapshot.history.size project.messageBus.connect(toolWindow.disposable).subscribe( ContextBoxStateService.TOPIC, ContextBoxListener { snapshot -> render(snapshot) - if (snapshot.history.isNotEmpty()) { - for (index in 0 until tabs.tabCount) { - if (tabs.getTitleAt(index) == "Context") { - tabs.selectedIndex = index - break - } - } + if (snapshot.history.size > previousHistorySize) { + selectTab(tabs, CONTEXT_TAB) } + previousHistorySize = snapshot.history.size } ) @@ -1988,7 +2011,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { bestFor = listOf("Consistent commit conventions", "Summarizing multi-file changes", "Reducing repetitive writing"), tip = "If the branch name contains a JIRA-style key such as ABC-123, it is used as a commit-message prefix.", actionLabel = "Open Prompt Manager", - action = { selectTab("Prompt Manager") } + action = { selectTab(PROMPT_MANAGER_TAB) } ), GuideFeature( icon = "⑂", title = "Branch Analysis", category = "Branch Compare", @@ -2004,7 +2027,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { bestFor = listOf("Pull-request preparation", "Risk review", "Understanding unfamiliar changes"), tip = "Branch analysis uses the current branch as the source and the Git Log selection as the comparison target.", actionLabel = "Open Context", - action = { selectTab("Context") } + action = { selectTab(CONTEXT_TAB) } ), GuideFeature( icon = "⇧", title = "Push & Create PR", category = "Pull Request", @@ -2036,7 +2059,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { bestFor = listOf("Focused code review", "Maintainability feedback", "Security and performance checks"), tip = "Code Review and Code Generate share the same editor context-menu action; choose the appropriate prompt category in the dialog.", actionLabel = "Open Prompt Manager", - action = { selectTab("Prompt Manager") } + action = { selectTab(PROMPT_MANAGER_TAB) } ), GuideFeature( icon = "✣", title = "Code Generate", category = "Code Generation", @@ -2052,7 +2075,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { bestFor = listOf("Boilerplate generation", "Small focused enhancements", "Context-aware implementation ideas"), tip = "Provide a focused selection and explicit requirements to keep generated changes relevant.", actionLabel = "Open Prompt Manager", - action = { selectTab("Prompt Manager") } + action = { selectTab(PROMPT_MANAGER_TAB) } ), GuideFeature( icon = "♢", title = "SonarQube Coverage", category = "Coverage Analysis", @@ -2068,7 +2091,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { bestFor = listOf("Coverage gap analysis", "Local code-quality inspections", "Generating missing tests"), tip = "Local scan mode detects TODO/FIXME markers, printStackTrace calls, empty catches and hard-coded secrets without a SonarQube server.", actionLabel = "Open Sonar Cube", - action = { selectTab("Sonar Cube") } + action = { selectTab(SONAR_CUBE_TAB) } ), GuideFeature( icon = "☵", title = "AI Chat (Context Box)", category = "Context Box", @@ -2084,7 +2107,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { bestFor = listOf("Follow-up questions", "Refining generated tests", "Explaining branch analysis"), tip = "Context Box retains the most recent messages so follow-up requests can build on earlier generated output.", actionLabel = "Open Context", - action = { selectTab("Context") } + action = { selectTab(CONTEXT_TAB) } ) ) From 4e3ffa3d124a6051073aba8c872d8769edf1b2dd Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Tue, 2 Jun 2026 19:19:49 +0800 Subject: [PATCH 5/5] feat: log redacted curl for API calls --- .../ai/plugin/llm/OpenAiCompatibleProvider.kt | 33 ++++++++++-- .../ai/plugin/llm/TemplateRequestExecutor.kt | 13 ++++- .../org/openprojectx/ai/plugin/HttpClients.kt | 52 +++++++++++++------ .../ai/plugin/LlmSettingsLoader.kt | 34 +++++------- .../plugin/pr/BitbucketPullRequestProvider.kt | 25 ++++++++- 5 files changed, 112 insertions(+), 45 deletions(-) diff --git a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/OpenAiCompatibleProvider.kt b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/OpenAiCompatibleProvider.kt index c504e8f..3a289b9 100644 --- a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/OpenAiCompatibleProvider.kt +++ b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/OpenAiCompatibleProvider.kt @@ -42,7 +42,7 @@ class OpenAiCompatibleProvider( max_tokens = settings.maxTokens.takeIf { it > 0 } ) - val curlCmd = buildCurlCommand(endpoint, apiKey, req) + val curlCmd = buildCurlCommand(endpoint, req) LlmRuntimeLogger.info("curl | $curlCmd") val response = http.post(endpoint) { @@ -98,10 +98,33 @@ class OpenAiCompatibleProvider( companion object { private val curlJson = Json { prettyPrint = false } - private fun buildCurlCommand(endpoint: String, apiKey: String, req: ChatCompletionsRequest): String { - val body = curlJson.encodeToString(ChatCompletionsRequest.serializer(), req) - .replace("'", "'\"'\"'") - return "curl -X POST '$endpoint' -H 'Authorization: Bearer ***' -H 'Content-Type: application/json' --data '$body'" + private fun buildCurlCommand(endpoint: String, req: ChatCompletionsRequest): String { + val rawBody = curlJson.encodeToString(ChatCompletionsRequest.serializer(), req) + val safeBody = redactSensitivePayload(rawBody) + val body = if (safeBody.length <= MAX_LOG_BODY_CHARS) safeBody + else safeBody.take(MAX_LOG_BODY_CHARS) + "..." + return "curl -X POST ${shellQuote(redactSensitiveUrl(endpoint))} -H 'Authorization: Bearer ***' -H 'Content-Type: application/json' --data ${shellQuote(body)}" } + + private fun redactSensitivePayload(text: String): String { + var result = text + listOf("password", "token", "access_token", "id_token", "refresh_token", "apiKey", "api_key", "secret").forEach { key -> + val pattern = Regex("""("${Regex.escape(key)}"\s*:\s*")[^"]*(")""", RegexOption.IGNORE_CASE) + result = result.replace(pattern) { "${it.groupValues[1]}***${it.groupValues[2]}" } + } + return result + } + + private fun redactSensitiveUrl(url: String): String { + var result = url + listOf("token", "access_token", "apiKey", "api_key", "key", "secret").forEach { key -> + result = result.replace(Regex("(?i)([?&]${Regex.escape(key)}=)[^&#]*")) { "${it.groupValues[1]}***" } + } + return result + } + + private fun shellQuote(value: String): String = "'" + value.replace("'", "'\"'\"'") + "'" + + private const val MAX_LOG_BODY_CHARS = 4_000 } } diff --git a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/TemplateRequestExecutor.kt b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/TemplateRequestExecutor.kt index 7d23b3e..90e6df0 100644 --- a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/TemplateRequestExecutor.kt +++ b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/TemplateRequestExecutor.kt @@ -32,9 +32,10 @@ class TemplateRequestExecutor( } val safeRequestHeaders = redactHeaders(effectiveRequestHeaders) val safeRequestBody = redactSensitivePayload(renderedBody) + val safeRequestUrl = redactSensitiveUrl(renderedUrl) val curlCommand = toCurlCommand(config.method.uppercase(), renderedUrl, effectiveRequestHeaders, renderedBody) LlmRuntimeLogger.info( - "Template request start | method=${config.method.uppercase()} | url=$renderedUrl | headers=$safeRequestHeaders | body=$safeRequestBody | curl=$curlCommand" + "Template request start | method=${config.method.uppercase()} | url=$safeRequestUrl | headers=$safeRequestHeaders | body=$safeRequestBody | curl=$curlCommand" ) val response = http.request { @@ -144,11 +145,19 @@ class TemplateRequestExecutor( } val redactedBody = redactSensitivePayload(body) val bodyArg = if (redactedBody.isNotBlank()) " --data " + shellQuote(redactedBody) else "" - return "curl -X $method " + shellQuote(url) + + return "curl -X $method " + shellQuote(redactSensitiveUrl(url)) + (if (headerArgs.isNotBlank()) " $headerArgs" else "") + bodyArg } + private fun redactSensitiveUrl(url: String): String { + var result = url + listOf("token", "access_token", "apiKey", "api_key", "key", "secret", "password").forEach { key -> + result = result.replace(Regex("(?i)([?&]${Regex.escape(key)}=)[^&#]*")) { "${it.groupValues[1]}***" } + } + return result + } + private fun redactHeaderValue(name: String, value: String): String { return if (name.contains("authorization", true) || name.contains("token", true) || diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/HttpClients.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/HttpClients.kt index d491c0c..607913e 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/HttpClients.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/HttpClients.kt @@ -69,30 +69,33 @@ object HttpClients { } fun logCurl(method: String, url: String, headers: Map, body: String = "") { - val safeHeaders = headers.mapValues { (name, value) -> - if (name.contains("authorization", true) || - name.contains("token", true) || - name.contains("key", true) || - name.contains("secret", true) || - name.contains("password", true) || - name.contains("cookie", true) - ) "***" else value - } - val safeBody = redactSensitivePayload(body) + val safeHeaders = headers.mapValues { (name, value) -> redactHeaderValue(name, value) } + val safeBody = truncate(redactSensitivePayload(body)) val headerArgs = safeHeaders.entries.joinToString(" ") { (name, value) -> - "-H \"$name: $value\"" + "-H ${shellQuote("$name: $value")}" + } + val bodyArg = if (safeBody.isNotBlank()) " --data ${shellQuote(safeBody)}" else "" + val command = "curl -X ${method.uppercase()} ${shellQuote(redactSensitiveUrl(url))}" + + (if (headerArgs.isNotBlank()) " $headerArgs" else "") + bodyArg + RuntimeLogStore.append("INFO | API curl | $command") + } + + private fun redactHeaderValue(name: String, value: String): String = + if (isSensitiveName(name)) "***" else value + + private fun redactSensitiveUrl(url: String): String { + var result = url + SENSITIVE_KEYS.forEach { key -> + val queryPattern = Regex("(?i)([?&]${Regex.escape(key)}=)[^&#]*") + result = result.replace(queryPattern) { "${it.groupValues[1]}***" } } - val bodyArg = if (safeBody.isNotBlank()) " --data '${safeBody.replace("'", "'\"'\"'")}'" else "" - org.openprojectx.ai.plugin.llm.LlmRuntimeLogger.info( - "curl -X $method '$url'$headerArgs$bodyArg" - ) + return result } private fun redactSensitivePayload(text: String): String { if (text.isBlank()) return text - val sensitiveKeys = listOf("password", "token", "access_token", "id_token", "refresh_token", "apiKey", "api_key", "secret") var result = text - sensitiveKeys.forEach { key -> + SENSITIVE_KEYS.forEach { key -> val quotedJsonPattern = Regex("""("${Regex.escape(key)}"\s*:\s*")[^"]*(")""", RegexOption.IGNORE_CASE) result = result.replace(quotedJsonPattern) { "${it.groupValues[1]}***${it.groupValues[2]}" } val formPattern = Regex("""(?i)(^|[&\s])(${Regex.escape(key)}=)[^&\s]+""") @@ -100,4 +103,19 @@ object HttpClients { } return result } + + private fun truncate(value: String): String = + if (value.length <= MAX_LOG_BODY_CHARS) value + else value.take(MAX_LOG_BODY_CHARS) + "..." + + private fun isSensitiveName(name: String): Boolean = SENSITIVE_KEYS.any { name.contains(it, true) } + + private fun shellQuote(value: String): String = "'" + value.replace("'", "'\"'\"'") + "'" + + private val SENSITIVE_KEYS = listOf( + "authorization", "password", "token", "access_token", "id_token", "refresh_token", + "apiKey", "api_key", "secret", "cookie" + ) + private const val MAX_LOG_BODY_CHARS = 4_000 + } \ No newline at end of file diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt index c0e0f08..4d53844 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt @@ -1632,6 +1632,14 @@ object LlmSettingsLoader { conn.setRequestProperty("Authorization", "Bearer $normalized") } conn.setRequestProperty("Accept", "application/vnd.github.raw+json") + HttpClients.logCurl( + method = "GET", + url = url, + headers = buildMap { + if (normalized.isNotBlank()) put("Authorization", "Bearer $normalized") + put("Accept", "application/vnd.github.raw+json") + } + ) val code = conn.responseCode val body = (if (code in 200..299) conn.inputStream else conn.errorStream) ?.bufferedReader(Charsets.UTF_8) @@ -1680,9 +1688,13 @@ object LlmSettingsLoader { } else { "Authorization=" } - val curlCommand = buildBitbucketCurlCommand(url, normalized, credentials) + HttpClients.logCurl( + method = "GET", + url = url, + headers = conn.getRequestProperty("Authorization")?.let { mapOf("Authorization" to it) }.orEmpty() + ) RuntimeLogStore.append( - "INFO | Bitbucket API | Request method=GET url=$url headers[$authHeaderLog] credentialSources=${credentials.joinToString(",") { it.source }.ifBlank { "" }} | curl=$curlCommand" + "INFO | Bitbucket API | Request method=GET url=$url headers[$authHeaderLog] credentialSources=${credentials.joinToString(",") { it.source }.ifBlank { "" }}" ) val code = conn.responseCode val body = (if (code in 200..299) conn.inputStream else conn.errorStream) @@ -1702,24 +1714,6 @@ object LlmSettingsLoader { } - private fun buildBitbucketCurlCommand(url: String, token: String, credentials: List): String { - val authorizationHeader = when { - token.isNotBlank() && token.contains(":") -> { - val username = token.substringBefore(':') - "Authorization: Basic ${displayHeaderValue(username)}:***" - } - token.isNotBlank() -> "Authorization: Bearer ***" - credentials.isNotEmpty() -> { - val credential = credentials.first() - "Authorization: Basic ${displayHeaderValue(credential.username)}:***" - } - else -> null - } - val authPart = authorizationHeader?.let { " -H " + shellQuote(it) }.orEmpty() - return "curl -X GET " + shellQuote(url) + authPart - } - - private fun shellQuote(value: String): String = "'" + value.replace("'", "'\"'\"'") + "'" private fun describeBasicTokenHeader(token: String): String { val separatorIndex = token.indexOf(':') val username = token.substring(0, separatorIndex) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProvider.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProvider.kt index 0967795..b6f1b8e 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProvider.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProvider.kt @@ -11,6 +11,7 @@ import io.ktor.http.contentType import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import org.openprojectx.ai.plugin.HttpClients import java.util.Base64 class BitbucketPullRequestProvider( @@ -42,6 +43,12 @@ class BitbucketPullRequestProvider( ) ) + HttpClients.logCurl( + method = "POST", + url = apiUrl, + headers = curlHeaders(), + body = json.encodeToString(CreateBitbucketPrRequest.serializer(), payload) + ) val response = http.post(apiUrl) { applyAuthorizationHeader() contentType(ContentType.Application.Json) @@ -63,10 +70,17 @@ class BitbucketPullRequestProvider( val apiUrl = "${repository.apiBaseUrl.trimEnd('/')}/rest/api/1.0/projects/${repository.projectKey}/repos/${repository.repoSlug}/pull-requests/$pullRequestId/comments" + val payload = CreateBitbucketCommentRequest(text = text) + HttpClients.logCurl( + method = "POST", + url = apiUrl, + headers = curlHeaders(), + body = json.encodeToString(CreateBitbucketCommentRequest.serializer(), payload) + ) val response = http.post(apiUrl) { applyAuthorizationHeader() contentType(ContentType.Application.Json) - setBody(CreateBitbucketCommentRequest(text = text)) + setBody(payload) } val responseText = response.bodyAsText() if (response.status.value !in 200..299) { @@ -74,6 +88,15 @@ class BitbucketPullRequestProvider( } } + private fun curlHeaders(): Map = mapOf( + HttpHeaders.Authorization to when { + !auth.token.isNullOrBlank() -> "Bearer ${auth.token}" + !auth.username.isNullOrBlank() && !auth.password.isNullOrBlank() -> "Basic ${auth.username}:${auth.password}" + else -> "" + }, + HttpHeaders.ContentType to ContentType.Application.Json.toString() + ) + private fun io.ktor.client.request.HttpRequestBuilder.applyAuthorizationHeader() { when { !auth.token.isNullOrBlank() -> {