From 7d6a7561c7da271f20181d0659a04f89c2cd8f5b Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Mon, 18 May 2026 16:45:40 +0800 Subject: [PATCH 1/7] feat: add Sonar Cube tool window tab --- README.md | 15 + .../ai/plugin/llm/TemplateRequestExecutor.kt | 102 ++++-- .../ai/plugin/AiPromptDefaults.kt | 25 ++ .../openprojectx/ai/plugin/AiTestConfig.kt | 15 +- .../ai/plugin/ContextBoxStateService.kt | 17 + .../ai/plugin/ContextBoxToolWindowFactory.kt | 1 + .../ai/plugin/LlmAuthSessionService.kt | 7 + .../ai/plugin/LlmSettingsLoader.kt | 36 +- .../ai/plugin/SonarCubeToolWindowPanel.kt | 314 ++++++++++++++++++ .../ai/plugin/SonarQubeCoverageAction.kt | 276 +++++++++++++++ .../src/main/resources/META-INF/plugin.xml | 8 + 11 files changed, 792 insertions(+), 24 deletions(-) create mode 100644 plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt create mode 100644 plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeCoverageAction.kt diff --git a/README.md b/README.md index 7b9650f..e185211 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,21 @@ generation: - `OPENAI_API_KEY` - Your OpenAI API key (or any OpenAI-compatible provider) - Use `${ENV_VAR}` syntax in config for secrets +### SonarQube Coverage Assistant + +Configure SonarQube in `.ai-test.yaml` to let the plugin fetch current project coverage, list files with missing coverage, and optionally ask the configured LLM to generate tests for the uncovered code. + +```yaml +sonarQube: + serverUrl: "https://sonarqube.example.com" + projectKey: "my-service" + tokenEnv: "SONAR_TOKEN" + targetCoverage: 80 + maxFiles: 5 +``` + +Use **Tools → SonarQube Coverage** in IntelliJ IDEA. The action shows a coverage summary in the AI Context Box, highlights files with uncovered lines, and can generate missing test code in one click. + ## Prompt Types & Prompt File Samples ### Current Prompt Types 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 8e0988d..4dc1f88 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 @@ -8,7 +8,6 @@ import io.ktor.client.request.header import io.ktor.client.request.request import io.ktor.client.request.setBody import io.ktor.client.request.url -import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpMethod @@ -25,7 +24,16 @@ class TemplateRequestExecutor( val renderedUrl = render(config.url, variables) val renderedHeaders = config.headers.mapValues { (_, value) -> render(value, variables) } val renderedBody = render(config.body, variables) - LlmRuntimeLogger.info("Template request start | method=${config.method.uppercase()} | url=$renderedUrl") + val effectiveRequestHeaders = if (renderedHeaders.keys.none { it.equals("Content-Type", ignoreCase = true) }) { + renderedHeaders + ("Content-Type" to ContentType.Application.Json.toString()) + } else { + renderedHeaders + } + val safeRequestHeaders = redactHeaders(effectiveRequestHeaders) + val safeRequestBody = redactSensitivePayload(renderedBody) + LlmRuntimeLogger.info( + "Template request start | method=${config.method.uppercase()} | url=$renderedUrl | headers=$safeRequestHeaders | body=$safeRequestBody" + ) val response = http.request { url(renderedUrl) @@ -41,38 +49,94 @@ class TemplateRequestExecutor( setBody(renderedBody) } - LlmRuntimeLogger.info("Template response received | url=$renderedUrl | status=${response.status.value}") - - val responseText = readBodyOrThrow(response, config) + val responseText = response.bodyAsText() + val safeResponseHeaders = redactHeaders(response.headers.names().associateWith { name -> + response.headers.getAll(name).orEmpty().joinToString(",") + }) + val safeResponseBody = redactSensitivePayload(responseText) LlmRuntimeLogger.info( - "Template response body received | url=$renderedUrl | length=${responseText.length} | preview=${responseText.take(200)}" + "Template response received | url=$renderedUrl | status=${response.status.value} | headers=$safeResponseHeaders | body=$safeResponseBody" ) + val responseSummary = responseSummary( + status = response.status.value, + headers = safeResponseHeaders, + body = safeResponseBody + ) + if (response.status == HttpStatusCode.Unauthorized) { + LlmRuntimeLogger.error("Template unauthorized | url=$renderedUrl | $responseSummary") + throw LlmUnauthorizedException("Unauthorized request for template '$renderedUrl'. Received response: $responseSummary") + } - val extracted = JsonPath.read(responseText, config.responsePath) - ?: error("Template responsePath '${config.responsePath}' returned null") + val extracted = try { + JsonPath.read(responseText, config.responsePath) + ?: error("Template responsePath '${config.responsePath}' returned null") + } catch (_: Throwable) { + throw IllegalStateException( + "Template responsePath '${config.responsePath}' was not found in the received response. " + + "Received response: $responseSummary" + ) + } LlmRuntimeLogger.info( - "Template response extracted | path=${config.responsePath} | length=${extracted.length} | preview=${extracted.take(200)}" + "Template response extracted | path=${config.responsePath} | length=${extracted.length} | preview=${maskSecret(extracted).take(200)}" ) return extracted } catch (t: Throwable) { LlmRuntimeLogger.error( - "Template request failed | method=${config.method.uppercase()} | url=${config.url} | error=${t.message ?: t::class.java.simpleName}" + "Template request failed | method=${config.method.uppercase()} | url=${config.url} | responsePath=${config.responsePath} | error=${t.message ?: t::class.java.simpleName}" ) throw t } } - private suspend fun readBodyOrThrow(response: HttpResponse, config: TemplateRequestConfig): String { - val responseText = response.bodyAsText() - if (response.status == HttpStatusCode.Unauthorized) { - LlmRuntimeLogger.error("Template unauthorized | url=${config.url} | status=${response.status.value}") - throw LlmUnauthorizedException("Unauthorized request for template '${config.url}'") - } - return responseText - } - private fun render(templateText: String, variables: Map): String { val template: Template = handlebars.compileInline(templateText) return template.apply(variables) } + + private fun redactHeaders(headers: Map): Map { + return headers.mapValues { (name, value) -> + if (name.contains("authorization", ignoreCase = true) || + name.contains("token", ignoreCase = true) || + name.contains("key", ignoreCase = true) || + name.contains("secret", ignoreCase = true) || + name.contains("password", ignoreCase = true) || + name.contains("cookie", ignoreCase = true) + ) { + maskSecret(value) + } else { + value + } + } + } + + private fun responseSummary(status: Int, headers: Map, body: String): String { + return "status=$status | headers=$headers | body=$body" + } + + private fun redactSensitivePayload(text: String): String { + val sensitiveKeys = listOf("password", "token", "access_token", "id_token", "refresh_token", "apiKey", "api_key", "secret") + var result = text + sensitiveKeys.forEach { key -> + val quotedJsonPattern = Regex("""("${Regex.escape(key)}"\s*:\s*")[^"]*(")""", RegexOption.IGNORE_CASE) + result = result.replace(quotedJsonPattern) { match -> + match.groupValues[1] + "***" + match.groupValues[2] + } + val formPattern = Regex("""(?i)(^|[&\s])(${Regex.escape(key)}=)[^&\s]+""") + result = result.replace(formPattern) { match -> + match.groupValues[1] + match.groupValues[2] + "***" + } + } + return result.take(MAX_LOG_BODY_CHARS).let { truncated -> + if (result.length > MAX_LOG_BODY_CHARS) "$truncated..." else truncated + } + } + + private fun maskSecret(value: String): String { + if (value.isBlank()) return "" + return "***" + } + + private companion object { + const val MAX_LOG_BODY_CHARS = 4_000 + } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiPromptDefaults.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiPromptDefaults.kt index 89ceed2..78d6e27 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiPromptDefaults.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiPromptDefaults.kt @@ -98,6 +98,31 @@ object AiPromptDefaults { {{extraRequirements}} """ + + const val SONARQUBE_MISSING_TESTS = """ + You are a senior test engineer. + + Generate missing tests to improve SonarQube coverage for the project below. + + Requirements: + - Prioritize files with uncovered lines and coverage below target + - Generate concrete test code, including imports and realistic assertions + - Infer the most likely test framework and style from the source snippets + - If a dependency must be mocked, include the mock setup + - Group output by target source file and proposed test file path + - Do not claim tests were executed + - Do not include unrelated commentary + + Project key: {{projectKey}} + Target coverage: {{targetCoverage}} + + Coverage summary: + {{coverageSummary}} + + Source context: + {{fileContexts}} + """ + const val CODE_REVIEW = """ You are a senior code reviewer. diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestConfig.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestConfig.kt index 5443946..e2d6364 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestConfig.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestConfig.kt @@ -7,9 +7,22 @@ import org.openprojectx.ai.plugin.llm.LlmSettings data class AiTestConfig( val llm: LlmSettings, val generation: GenerationConfig = GenerationConfig(), - val prompts: PromptOverrides = PromptOverrides() + val prompts: PromptOverrides = PromptOverrides(), + val sonarQube: SonarQubeConfig = SonarQubeConfig() ) +data class SonarQubeConfig( + val serverUrl: String = "", + val projectKey: String = "", + val token: String = "", + val tokenEnv: String = "", + val targetCoverage: Double = 80.0, + val maxFiles: Int = 5 +) { + val resolvedToken: String + get() = token.ifBlank { tokenEnv.takeIf { it.isNotBlank() }?.let { System.getenv(it).orEmpty() }.orEmpty() } +} + data class PromptOverrides( val generation: GenerationPromptTemplate = GenerationPromptTemplate(), val commitMessage: String = AiPromptDefaults.COMMIT_MESSAGE, 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 f965087..f58df3c 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 @@ -67,6 +67,23 @@ class ContextBoxStateService(private val project: Project) { }.trimEnd()) } + fun recordSonarQubeCoverage(projectKey: String, coverageSummary: String, generation: String) { + val now = LocalDateTime.now().format(formatter) + appendResult(buildString { + appendLine("Type: SonarQube Coverage") + appendLine("Time: $now") + appendLine("Project Key: $projectKey") + appendLine() + appendLine("Coverage Summary:") + appendLine(coverageSummary.trim().ifBlank { "(empty coverage summary)" }) + if (generation.isNotBlank()) { + appendLine() + appendLine("Generated Missing Tests:") + append(generation.trim()) + } + }.trimEnd()) + } + fun recordFollowUp(extraRequirement: String, result: String) { val now = LocalDateTime.now().format(formatter) appendResult(buildString { 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 3a14752..7afcada 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 @@ -170,6 +170,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { val tabs = JTabbedPane().apply { addTab("Context", panel) addTab("Prompt Manager", createPromptManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) + addTab("Sonar Cube", SonarCubeToolWindowPanel.create(project, bgColor, fgColor, borderColor, commonFont)) if (LlmSettingsLoader.loadSettingsModel(project).showLogTab) { addTab("Log", createLogPanel(bgColor, fgColor, borderColor, commonFont)) } 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 e7600a9..4400aba 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 @@ -9,6 +9,7 @@ import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages import kotlinx.coroutines.runBlocking +import org.openprojectx.ai.plugin.llm.LlmRuntimeLogger import org.openprojectx.ai.plugin.llm.LlmSettings import org.openprojectx.ai.plugin.llm.LlmUnauthorizedException import org.openprojectx.ai.plugin.llm.TemplateRequestExecutor @@ -20,6 +21,7 @@ class LlmAuthSessionService( private var sessionApiKey: String? = null fun resolve(settings: LlmSettings): LlmSettings { + installRuntimeLogSink() if (!settings.apiKey.isNullOrBlank()) { sessionApiKey = settings.apiKey return settings @@ -65,6 +67,7 @@ class LlmAuthSessionService( } fun loginNow(): String { + installRuntimeLogSink() val settings = LlmSettingsLoader.load(project) val resolved = if (settings.auth != null) relogin(settings) else resolve(settings) return resolved.apiKey ?: error("LLM login did not produce an API key") @@ -114,6 +117,10 @@ class LlmAuthSessionService( } } + private fun installRuntimeLogSink() { + LlmRuntimeLogger.sink = { message -> RuntimeLogStore.append(message) } + } + private fun promptCredentials(settings: LlmSettings): LoginCredentials { lateinit var credentials: LoginCredentials val saved = loadSavedCredentials(settings) 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 f75af88..5eef421 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 @@ -198,7 +198,7 @@ object LlmSettingsLoader { fun checkBitbucketPromptUpdates(project: Project): PromptUpdateStatus { val model = loadSettingsModel(project) - if (model.bitbucketPromptRepoUrl.isBlank()) { + if (!model.bitbucketPromptRepoEnabled || model.bitbucketPromptRepoUrl.isBlank()) { return PromptUpdateStatus( configured = false, remoteCount = 0, @@ -271,7 +271,7 @@ object LlmSettingsLoader { val root = readRootMap(project) val prompts = root["prompts"] as? Map<*, *> ?: emptyMap() val remoteConfig = parseBitbucketPromptRepoConfig(prompts.map("remoteRepo")) - if (remoteConfig.repoUrl.isBlank()) { + if (!remoteConfig.enabled || remoteConfig.repoUrl.isBlank()) { return PromptUpdateStatus( configured = false, remoteCount = 0, @@ -474,6 +474,8 @@ object LlmSettingsLoader { writeRootMap(project, root) } + fun loadSonarQubeConfig(project: Project): SonarQubeConfig = parseSonarQubeConfig(readRootMap(project)) + fun loadConfig(project: Project): AiTestConfig { val configFile = findConfigFile() ?: error("AI TestGen config not found. Expected one of: ${configNames.joinToString()} under ${configHomeDir().absolutePath}.") @@ -486,11 +488,13 @@ object LlmSettingsLoader { val llm = parseLlmSettings(root) val generation = parseGenerationConfig(root) val prompts = parsePromptOverrides(project, root) + val sonarQube = parseSonarQubeConfig(root) return AiTestConfig( llm = llm, generation = generation, - prompts = prompts + prompts = prompts, + sonarQube = sonarQube ) } @@ -554,6 +558,18 @@ object LlmSettingsLoader { ) } + private fun parseSonarQubeConfig(root: Map<*, *>): SonarQubeConfig { + val sonar = root["sonarQube"] as? Map<*, *> ?: return SonarQubeConfig() + return SonarQubeConfig( + serverUrl = sonar.string("serverUrl"), + projectKey = sonar.string("projectKey"), + token = sonar.string("token"), + tokenEnv = sonar.string("tokenEnv"), + targetCoverage = sonar.double("targetCoverage") ?: 80.0, + maxFiles = sonar.int("maxFiles") ?: 5 + ) + } + private fun parseLlmSettings(root: Map<*, *>): LlmSettings { val llm = (root["llm"] as? Map<*, *>) ?: error("Invalid YAML: missing top-level 'llm' object") @@ -1122,7 +1138,7 @@ object LlmSettingsLoader { } private fun fetchBitbucketGlobalPromptEntries(project: Project, config: BitbucketPromptRepoConfig, strict: Boolean = false): List { - if (config.repoUrl.isBlank()) return emptyList() + if (!config.enabled || config.repoUrl.isBlank()) return emptyList() val result = runCatching { val directBitbucketRawBaseUrl = parseDirectBitbucketRawBaseUrl(config.repoUrl) val repo = directBitbucketRawBaseUrl?.let { @@ -1441,6 +1457,18 @@ object LlmSettingsLoader { private fun Map<*, *>?.string(key: String): String = this?.get(key)?.toString()?.trim().orEmpty() + private fun Map<*, *>?.double(key: String): Double? = when (val value = this?.get(key)) { + is Number -> value.toDouble() + is String -> value.trim().toDoubleOrNull() + else -> null + } + + private fun Map<*, *>?.int(key: String): Int? = when (val value = this?.get(key)) { + is Number -> value.toInt() + is String -> value.trim().toIntOrNull() + else -> null + } + private fun Map<*, *>?.map(key: String): Map<*, *> = this?.get(key) as? Map<*, *> ?: emptyMap() 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 new file mode 100644 index 0000000..a358a12 --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt @@ -0,0 +1,314 @@ +package org.openprojectx.ai.plugin + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Dimension +import java.awt.FlowLayout +import java.awt.Font +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.Locale +import javax.swing.BorderFactory +import javax.swing.DefaultListCellRenderer +import javax.swing.DefaultListModel +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.JTextArea +import javax.swing.ListSelectionModel +import javax.swing.UIManager + +object SonarCubeToolWindowPanel { + fun create(project: Project, bgColor: Color, fgColor: Color, borderColor: Color, commonFont: Font): JPanel { + val issueListModel = DefaultListModel() + val summaryArea = JTextArea().apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + font = commonFont + background = bgColor + foreground = fgColor + caretColor = fgColor + border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + text = "Click Refresh to load configured SonarQube results." + } + val issueList = JList(issueListModel).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + background = Color(0x11, 0x1C, 0x2F) + foreground = fgColor + fixedCellHeight = 54 + cellRenderer = SonarCubeIssueRenderer() + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount >= 2) { + selectedValue?.let { openIssue(project, it) } + } + } + }) + } + val refreshButton = JButton("Refresh") + val openButton = JButton("Open Selected") + + fun load() { + refreshButton.isEnabled = false + openButton.isEnabled = false + issueListModel.clear() + summaryArea.text = "Loading SonarQube results..." + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Load SonarQube Results", false) { + override fun run(indicator: ProgressIndicator) { + try { + indicator.text = "Reading SonarQube configuration..." + val config = LlmSettingsLoader.loadSonarQubeConfig(project) + if (config.serverUrl.isBlank() || config.projectKey.isBlank()) { + ApplicationManager.getApplication().invokeLater { + summaryArea.text = "SonarQube is not configured. Add sonarQube.serverUrl and sonarQube.projectKey to .ai-test.yaml." + refreshButton.isEnabled = true + openButton.isEnabled = true + } + return + } + + indicator.text = "Fetching SonarQube issues..." + val result = runBlocking { SonarCubeToolWindowClient(config).load() } + ApplicationManager.getApplication().invokeLater { + summaryArea.text = SonarCubeResultRenderer.render(result) + result.issues.forEach(issueListModel::addElement) + refreshButton.isEnabled = true + openButton.isEnabled = true + } + } catch (ex: Exception) { + ApplicationManager.getApplication().invokeLater { + summaryArea.text = "Failed to load SonarQube results: ${ex.message ?: ex.toString()}" + refreshButton.isEnabled = true + openButton.isEnabled = true + Notifications.error(project, "SonarQube Results failed", ex.message ?: ex.toString()) + } + } + } + }) + } + + refreshButton.addActionListener { load() } + openButton.addActionListener { + issueList.selectedValue?.let { openIssue(project, it) } + } + + val root = JPanel(BorderLayout(8, 8)).apply { + background = bgColor + foreground = fgColor + border = BorderFactory.createEmptyBorder(8, 8, 8, 8) + add(JPanel(BorderLayout()).apply { + isOpaque = false + add(JLabel("Sonar Cube").apply { foreground = fgColor }, BorderLayout.WEST) + add(JPanel(FlowLayout(FlowLayout.RIGHT, 8, 0)).apply { + isOpaque = false + add(openButton) + add(refreshButton) + }, BorderLayout.EAST) + }, BorderLayout.NORTH) + add(com.intellij.ui.components.JBScrollPane(summaryArea).apply { + preferredSize = Dimension(320, 150) + viewport.background = bgColor + border = BorderFactory.createLineBorder(borderColor) + }, BorderLayout.CENTER) + add(com.intellij.ui.components.JBScrollPane(issueList).apply { + preferredSize = Dimension(320, 360) + viewport.background = Color(0x11, 0x1C, 0x2F) + border = BorderFactory.createLineBorder(borderColor) + }, BorderLayout.SOUTH) + } + load() + return root + } + + private fun openIssue(project: Project, issue: SonarCubeIssue) { + val basePath = project.basePath + if (basePath.isNullOrBlank()) { + Notifications.warn(project, "Sonar Cube", "Cannot open file because project base path is unknown.") + return + } + val file = java.io.File(basePath, issue.path) + val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + if (virtualFile == null) { + Notifications.warn(project, "Sonar Cube", "Cannot find local file: ${issue.path}") + return + } + val lineIndex = issue.line?.minus(1)?.coerceAtLeast(0) ?: 0 + OpenFileDescriptor(project, virtualFile, lineIndex, 0).navigate(true) + } +} + +private class SonarCubeToolWindowClient(private val config: SonarQubeConfig) { + private val authHeader = config.resolvedToken.takeIf { it.isNotBlank() }?.let { + "Basic " + Base64.getEncoder().encodeToString("$it:".toByteArray(StandardCharsets.UTF_8)) + } + + suspend fun load(): SonarCubeResult { + val client = HttpClients.shared(timeoutSeconds = 60) + try { + val baseUrl = config.serverUrl.trimEnd('/') + val projectKey = encoded(config.projectKey) + val measures: SonarCubeMeasuresResponse = client.get( + "$baseUrl/api/measures/component?component=$projectKey&metricKeys=coverage,line_coverage,branch_coverage,uncovered_lines,bugs,vulnerabilities,code_smells" + ) { + authHeader?.let { header(HttpHeaders.Authorization, it) } + }.body() + val issues: SonarCubeIssuesResponse = client.get( + "$baseUrl/api/issues/search?componentKeys=$projectKey&resolved=false&ps=100&s=SEVERITY&asc=false" + ) { + authHeader?.let { header(HttpHeaders.Authorization, it) } + }.body() + return SonarCubeResult( + projectKey = config.projectKey, + serverUrl = config.serverUrl, + coverage = measures.component.measureValue("coverage")?.toDoubleOrNull(), + lineCoverage = measures.component.measureValue("line_coverage")?.toDoubleOrNull(), + branchCoverage = measures.component.measureValue("branch_coverage")?.toDoubleOrNull(), + uncoveredLines = measures.component.measureValue("uncovered_lines")?.toIntOrNull(), + bugs = measures.component.measureValue("bugs")?.toIntOrNull(), + vulnerabilities = measures.component.measureValue("vulnerabilities")?.toIntOrNull(), + codeSmells = measures.component.measureValue("code_smells")?.toIntOrNull(), + issues = issues.issues.map { it.toDomain(config.projectKey) } + ) + } finally { + client.close() + } + } + + private fun encoded(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.name()) +} + +private data class SonarCubeResult( + val projectKey: String, + val serverUrl: String, + val coverage: Double?, + val lineCoverage: Double?, + val branchCoverage: Double?, + val uncoveredLines: Int?, + val bugs: Int?, + val vulnerabilities: Int?, + val codeSmells: Int?, + val issues: List +) + +private data class SonarCubeIssue( + val key: String, + val path: String, + val line: Int?, + val severity: String, + val type: String, + val rule: String, + val message: String +) { + val locationText: String + get() = if (line != null) "$path:$line" else path +} + +@Serializable +private data class SonarCubeMeasuresResponse(val component: SonarCubeComponent) + +@Serializable +private data class SonarCubeComponent( + val key: String, + val name: String = key, + val measures: List = emptyList() +) { + fun measureValue(metric: String): String? = measures.firstOrNull { it.metric == metric }?.value +} + +@Serializable +private data class SonarCubeMeasure(val metric: String, val value: String? = null) + +@Serializable +private data class SonarCubeIssuesResponse(val issues: List = emptyList()) + +@Serializable +private data class SonarCubeIssueDto( + val key: String, + val component: String, + val line: Int? = null, + val textRange: SonarCubeTextRange? = null, + val severity: String = "UNKNOWN", + val type: String = "ISSUE", + val rule: String = "", + val message: String = "" +) { + fun toDomain(projectKey: String): SonarCubeIssue { + val prefix = "$projectKey:" + return SonarCubeIssue( + key = key, + path = component.removePrefix(prefix), + line = line ?: textRange?.startLine, + severity = severity, + type = type, + rule = rule, + message = message + ) + } +} + +@Serializable +private data class SonarCubeTextRange(val startLine: Int? = null) + +private object SonarCubeResultRenderer { + fun render(result: SonarCubeResult): String = buildString { + appendLine("Project: ${result.projectKey}") + appendLine("Server: ${result.serverUrl}") + appendLine("Coverage: ${result.coverage?.formatPercent() ?: "n/a"}") + appendLine("Line coverage: ${result.lineCoverage?.formatPercent() ?: "n/a"}") + appendLine("Branch coverage: ${result.branchCoverage?.formatPercent() ?: "n/a"}") + appendLine("Uncovered lines: ${result.uncoveredLines ?: 0}") + appendLine("Bugs: ${result.bugs ?: 0}") + appendLine("Vulnerabilities: ${result.vulnerabilities ?: 0}") + appendLine("Code smells: ${result.codeSmells ?: 0}") + appendLine("Open issues: ${result.issues.size}") + appendLine() + appendLine("Double-click an issue below, or select it and click Open Selected, to jump to the local source line.") + }.trimEnd() +} + +private class SonarCubeIssueRenderer : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + val label = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) as JLabel + val issue = value as? SonarCubeIssue + if (issue != null) { + label.text = "${issue.severity} [${issue.type}] ${issue.locationText}
${escapeHtml(issue.message)}
${issue.rule}" + } + label.border = BorderFactory.createEmptyBorder(6, 8, 6, 8) + label.font = UIManager.getFont("Label.font") ?: label.font + return label + } + + private fun escapeHtml(value: String): String = value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") +} + +private fun Double.formatPercent(): String = String.format(Locale.US, "%.2f%%", this) 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 new file mode 100644 index 0000000..7a6bd80 --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeCoverageAction.kt @@ -0,0 +1,276 @@ +package org.openprojectx.ai.plugin + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.wm.ToolWindowManager +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.io.File +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.Locale +import javax.swing.BoxLayout +import javax.swing.JCheckBox +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JTextField + +class SonarQubeCoverageAction : AnAction("SonarQube Coverage"), DumbAware { + + init { + templatePresentation.icon = OpenProjectXIcons.GenerateTests + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = e.project != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val config = runCatching { LlmSettingsLoader.loadConfig(project).sonarQube } + .getOrElse { SonarQubeConfig() } + val dialog = SonarQubeCoverageDialog(project, config) + if (!dialog.showAndGet()) return + + val request = dialog.request() + if (request.serverUrl.isBlank() || request.projectKey.isBlank()) { + Notifications.warn(project, "SonarQube Coverage", "Please provide SonarQube server URL and project key.") + return + } + + ButtonUsageReportService.getInstance(project).record("sonarqube.coverage") + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "SonarQube Coverage", false) { + override fun run(indicator: ProgressIndicator) { + try { + indicator.text = "Loading SonarQube coverage..." + val report = runBlocking { SonarQubeCoverageClient(request).loadCoverage() } + val coverageSummary = SonarQubeCoverageRenderer.render(report, request.targetCoverage) + + val generation = if (request.generateMissingTests) { + indicator.text = "Generating tests for uncovered code..." + val prompt = SonarQubeCoveragePromptBuilder.build(project, report, request.targetCoverage) + val provider = LlmProviderFactory.create(LlmSettingsLoader.load(project)) + runBlocking { provider.generateCode(prompt) } + } else { + "" + } + + ApplicationManager.getApplication().invokeLater { + ContextBoxStateService.getInstance(project).recordSonarQubeCoverage( + projectKey = request.projectKey, + coverageSummary = coverageSummary, + generation = generation + ) + ToolWindowManager.getInstance(project).getToolWindow("AI Context Box")?.show(null) + } + } catch (ex: Exception) { + Notifications.error(project, "SonarQube Coverage failed", ex.message ?: ex.toString()) + } + } + }) + } +} + +private data class SonarQubeCoverageRequest( + val serverUrl: String, + val projectKey: String, + val token: String, + val targetCoverage: Double, + val maxFiles: Int, + val generateMissingTests: Boolean +) + +private class SonarQubeCoverageDialog( + project: Project, + config: SonarQubeConfig +) : DialogWrapper(project) { + private val serverUrlField = JTextField(config.serverUrl, 40) + private val projectKeyField = JTextField(config.projectKey, 40) + private val tokenField = JTextField(config.resolvedToken, 40) + private val targetCoverageField = JTextField(config.targetCoverage.toString(), 8) + private val maxFilesField = JTextField(config.maxFiles.toString(), 8) + private val generateMissingTestsBox = JCheckBox("Generate missing tests with AI", true) + + init { + title = "SonarQube Coverage" + init() + } + + override fun createCenterPanel(): JComponent = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(JLabel("SonarQube server URL")) + add(serverUrlField) + add(JLabel("Project key")) + add(projectKeyField) + add(JLabel("Token (optional; tokenEnv can be configured in .ai-test.yaml)")) + add(tokenField) + add(JLabel("Target coverage %")) + add(targetCoverageField) + add(JLabel("Max uncovered files to inspect")) + add(maxFilesField) + add(generateMissingTestsBox) + } + + fun request(): SonarQubeCoverageRequest = SonarQubeCoverageRequest( + serverUrl = serverUrlField.text.trim(), + projectKey = projectKeyField.text.trim(), + token = tokenField.text.trim(), + targetCoverage = targetCoverageField.text.trim().toDoubleOrNull() ?: 80.0, + maxFiles = maxFilesField.text.trim().toIntOrNull()?.coerceIn(1, 20) ?: 5, + generateMissingTests = generateMissingTestsBox.isSelected + ) +} + +private data class SonarQubeCoverageReport( + val projectKey: String, + val projectCoverage: Double?, + val projectLineCoverage: Double?, + val projectBranchCoverage: Double?, + val uncoveredLines: Int?, + val files: List +) + +private data class SonarQubeFileCoverage( + val key: String, + val path: String, + val name: String, + val coverage: Double?, + val uncoveredLines: Int? +) + +private class SonarQubeCoverageClient(private val request: SonarQubeCoverageRequest) { + private val authHeader = request.token.takeIf { it.isNotBlank() }?.let { + "Basic " + Base64.getEncoder().encodeToString("$it:".toByteArray(StandardCharsets.UTF_8)) + } + + suspend fun loadCoverage(): SonarQubeCoverageReport { + val jsonClient = HttpClients.shared(timeoutSeconds = 60) + try { + val baseUrl = request.serverUrl.trimEnd('/') + val component = encoded(request.projectKey) + val projectMeasures: SonarComponentMeasuresResponse = jsonClient.get( + "$baseUrl/api/measures/component?component=$component&metricKeys=coverage,line_coverage,branch_coverage,uncovered_lines" + ) { + authHeader?.let { header(HttpHeaders.Authorization, it) } + }.body() + val fileMeasures: SonarComponentTreeResponse = jsonClient.get( + "$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)}" + ) { + authHeader?.let { header(HttpHeaders.Authorization, it) } + }.body() + + val project = projectMeasures.component + return SonarQubeCoverageReport( + projectKey = project.key, + projectCoverage = project.measureValue("coverage")?.toDoubleOrNull(), + projectLineCoverage = project.measureValue("line_coverage")?.toDoubleOrNull(), + projectBranchCoverage = project.measureValue("branch_coverage")?.toDoubleOrNull(), + uncoveredLines = project.measureValue("uncovered_lines")?.toIntOrNull(), + files = fileMeasures.components.map { component -> + SonarQubeFileCoverage( + key = component.key, + path = component.path ?: component.key.substringAfter(':'), + name = component.name, + coverage = component.measureValue("coverage")?.toDoubleOrNull(), + uncoveredLines = component.measureValue("uncovered_lines")?.toIntOrNull() + ) + }.filter { (it.uncoveredLines ?: 0) > 0 || (it.coverage ?: 100.0) < request.targetCoverage } + ) + } finally { + jsonClient.close() + } + } + + private fun encoded(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.name()) +} + +@Serializable +private data class SonarComponentMeasuresResponse(val component: SonarComponent) + +@Serializable +private data class SonarComponentTreeResponse(val components: List = emptyList()) + +@Serializable +private data class SonarComponent( + val key: String, + val name: String = key, + val path: String? = null, + val measures: List = emptyList() +) { + fun measureValue(metric: String): String? = measures.firstOrNull { it.metric == metric }?.value +} + +@Serializable +private data class SonarMeasure( + val metric: String, + val value: String? = null, + @SerialName("bestValue") val bestValue: Boolean? = null +) + +private object SonarQubeCoverageRenderer { + fun render(report: SonarQubeCoverageReport, targetCoverage: Double): String = buildString { + appendLine("Project: ${report.projectKey}") + appendLine("Coverage: ${report.projectCoverage?.formatPercent() ?: "n/a"} (target ${targetCoverage.formatPercent()})") + appendLine("Line coverage: ${report.projectLineCoverage?.formatPercent() ?: "n/a"}") + appendLine("Branch coverage: ${report.projectBranchCoverage?.formatPercent() ?: "n/a"}") + appendLine("Uncovered lines: ${report.uncoveredLines ?: 0}") + appendLine() + if (report.files.isEmpty()) { + appendLine("No uncovered files found by SonarQube for the selected filters.") + } else { + appendLine("Files that need coverage:") + report.files.forEachIndexed { index, file -> + appendLine("${index + 1}. ${file.path} — coverage ${file.coverage?.formatPercent() ?: "n/a"}, uncovered lines ${file.uncoveredLines ?: 0}") + } + } + }.trimEnd() +} + +private object SonarQubeCoveragePromptBuilder { + fun build(project: Project, report: SonarQubeCoverageReport, targetCoverage: Double): String { + val fileContexts = report.files.joinToString("\n\n") { file -> + val code = readProjectFile(project, file.path) + """ + File: ${file.path} + Current coverage: ${file.coverage?.formatPercent() ?: "n/a"} + Uncovered lines: ${file.uncoveredLines ?: 0} + Code: + ``` + ${code.ifBlank { "// Local source file not found. Use the path and SonarQube metadata to propose tests." }} + ``` + """.trimIndent() + } + return AiPromptDefaults.render( + AiPromptDefaults.SONARQUBE_MISSING_TESTS, + mapOf( + "projectKey" to report.projectKey, + "targetCoverage" to targetCoverage.formatPercent(), + "coverageSummary" to SonarQubeCoverageRenderer.render(report, targetCoverage), + "fileContexts" to fileContexts + ) + ) + } + + private fun readProjectFile(project: Project, path: String): String { + val root = project.basePath ?: return "" + val file = File(root, path) + if (!file.exists() || !file.isFile) return "" + return file.readText(Charsets.UTF_8).take(12_000) + } +} + +private fun Double.formatPercent(): String = String.format(Locale.US, "%.2f%%", this) diff --git a/plugin-idea/src/main/resources/META-INF/plugin.xml b/plugin-idea/src/main/resources/META-INF/plugin.xml index 54de858..1a9f10e 100644 --- a/plugin-idea/src/main/resources/META-INF/plugin.xml +++ b/plugin-idea/src/main/resources/META-INF/plugin.xml @@ -58,6 +58,14 @@ description="Summarize [Probe Vcs.Log.Toolbar.Internal]"> + + + + Date: Mon, 18 May 2026 17:18:59 +0800 Subject: [PATCH 2/7] fix: load Sonar Cube tab off EDT --- .../ai/plugin/SonarCubeToolWindowPanel.kt | 61 +++++++++---------- 1 file changed, 30 insertions(+), 31 deletions(-) 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 a358a12..23cc7a9 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 @@ -2,9 +2,6 @@ package org.openprojectx.ai.plugin import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileEditor.OpenFileDescriptor -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.LocalFileSystem import io.ktor.client.call.body @@ -25,6 +22,7 @@ import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.util.Base64 import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean import javax.swing.BorderFactory import javax.swing.DefaultListCellRenderer import javax.swing.DefaultListModel @@ -66,44 +64,45 @@ object SonarCubeToolWindowPanel { } val refreshButton = JButton("Refresh") val openButton = JButton("Open Selected") + val loading = AtomicBoolean(false) + + fun finishLoading() { + loading.set(false) + refreshButton.isEnabled = true + openButton.isEnabled = true + } fun load() { + if (!loading.compareAndSet(false, true)) return refreshButton.isEnabled = false openButton.isEnabled = false issueListModel.clear() summaryArea.text = "Loading SonarQube results..." - ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Load SonarQube Results", false) { - override fun run(indicator: ProgressIndicator) { - try { - indicator.text = "Reading SonarQube configuration..." - val config = LlmSettingsLoader.loadSonarQubeConfig(project) - if (config.serverUrl.isBlank() || config.projectKey.isBlank()) { - ApplicationManager.getApplication().invokeLater { - summaryArea.text = "SonarQube is not configured. Add sonarQube.serverUrl and sonarQube.projectKey to .ai-test.yaml." - refreshButton.isEnabled = true - openButton.isEnabled = true - } - return - } - - indicator.text = "Fetching SonarQube issues..." - val result = runBlocking { SonarCubeToolWindowClient(config).load() } - ApplicationManager.getApplication().invokeLater { - summaryArea.text = SonarCubeResultRenderer.render(result) - result.issues.forEach(issueListModel::addElement) - refreshButton.isEnabled = true - openButton.isEnabled = true - } - } catch (ex: Exception) { + ApplicationManager.getApplication().executeOnPooledThread { + try { + val config = LlmSettingsLoader.loadSonarQubeConfig(project) + if (config.serverUrl.isBlank() || config.projectKey.isBlank()) { ApplicationManager.getApplication().invokeLater { - summaryArea.text = "Failed to load SonarQube results: ${ex.message ?: ex.toString()}" - refreshButton.isEnabled = true - openButton.isEnabled = true - Notifications.error(project, "SonarQube Results failed", ex.message ?: ex.toString()) + summaryArea.text = "SonarQube is not configured. Add sonarQube.serverUrl and sonarQube.projectKey to .ai-test.yaml." + finishLoading() } + return@executeOnPooledThread + } + + val result = runBlocking { SonarCubeToolWindowClient(config).load() } + ApplicationManager.getApplication().invokeLater { + summaryArea.text = SonarCubeResultRenderer.render(result) + result.issues.forEach(issueListModel::addElement) + finishLoading() + } + } catch (ex: Exception) { + ApplicationManager.getApplication().invokeLater { + summaryArea.text = "Failed to load SonarQube results: ${ex.message ?: ex.toString()}" + finishLoading() + Notifications.error(project, "SonarQube Results failed", ex.message ?: ex.toString()) } } - }) + } } refreshButton.addActionListener { load() } From 87ce31411c5a0f39411c67aecd6434cdf9cd220d Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Mon, 18 May 2026 17:19:06 +0800 Subject: [PATCH 3/7] fix: run settings network actions off EDT --- .../ai/plugin/AiTestSettingsConfigurable.kt | 121 +++++++++++------- .../ai/plugin/LlmAuthSessionService.kt | 16 ++- 2 files changed, 89 insertions(+), 48 deletions(-) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt index 0733ace..b61541f 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt @@ -1,6 +1,7 @@ package org.openprojectx.ai.plugin import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages @@ -234,22 +235,26 @@ class AiTestSettingsConfigurable( add(JButton("Import Repo Config").apply { addActionListener { usage.record("settings.toolbar.import_repo_config") - runCatching { - LlmSettingsLoader.importConfigFromRepo(project, collectState()) - }.onSuccess { sourcePath -> - reset() - Messages.showInfoMessage( - project, - "Imported config from: $sourcePath", - "AI Test Generator" - ) - }.onFailure { ex -> - Messages.showErrorDialog( - project, - detailedErrorMessage("Import repo config failed", ex), - "AI Test Generator" - ) - } + val state = collectState() + runOffEdt( + label = "Import repo config", + block = { LlmSettingsLoader.importConfigFromRepo(project, state) }, + onSuccess = { sourcePath -> + reset() + Messages.showInfoMessage( + project, + "Imported config from: $sourcePath", + "AI Test Generator" + ) + }, + onFailure = { ex -> + Messages.showErrorDialog( + project, + detailedErrorMessage("Import repo config failed", ex), + "AI Test Generator" + ) + } + ) } }) }, BorderLayout.EAST) @@ -301,6 +306,25 @@ class AiTestSettingsConfigurable( pathLabel = null } + + private fun runOffEdt( + label: String, + block: () -> T, + onSuccess: (T) -> Unit, + onFailure: (Throwable) -> Unit + ) { + RuntimeLogStore.append("INFO | Settings | Started background task: $label") + ApplicationManager.getApplication().executeOnPooledThread { + runCatching(block) + .onSuccess { value -> + ApplicationManager.getApplication().invokeLater { onSuccess(value) } + } + .onFailure { ex -> + ApplicationManager.getApplication().invokeLater { onFailure(ex) } + } + } + } + private fun llmTab(): JComponent = scrollableTab(JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) border = BorderFactory.createEmptyBorder(12, 12, 12, 12) @@ -331,40 +355,51 @@ class AiTestSettingsConfigurable( addActionListener { usage.record("settings.bitbucket_prompt_repo.update") if (!saveCurrentState()) return@addActionListener - try { - val status = LlmSettingsLoader.pullBitbucketPromptUpdates(project) - if (!status.configured) { - Notifications.warn(project, "Bitbucket Prompt Repo", status.message) - return@addActionListener - } - if (status.error) { - Notifications.error(project, "Bitbucket Prompt Repo", status.message) - return@addActionListener + runOffEdt( + label = "Update Bitbucket prompts", + block = { + val status = LlmSettingsLoader.pullBitbucketPromptUpdates(project) + val latest = if (status.configured && !status.error) LlmSettingsLoader.loadSettingsModel(project) else null + status to latest + }, + onSuccess = { (status, latest) -> + when { + !status.configured -> Notifications.warn(project, "Bitbucket Prompt Repo", status.message) + status.error -> Notifications.error(project, "Bitbucket Prompt Repo", status.message) + else -> { + if (latest != null) { + applyState(latest) + initialState = latest + } + Notifications.info( + project, + "Bitbucket Prompt Repo", + "${status.message} Remote=${status.remoteCount}, LocalCache=${status.cachedCount}." + ) + } + } + }, + onFailure = { ex -> + Notifications.error(project, "Bitbucket Prompt Repo", ex.message ?: ex.toString()) } - val latest = LlmSettingsLoader.loadSettingsModel(project) - applyState(latest) - initialState = latest - Notifications.info( - project, - "Bitbucket Prompt Repo", - "${status.message} Remote=${status.remoteCount}, LocalCache=${status.cachedCount}." - ) - } catch (ex: Exception) { - Notifications.error(project, "Bitbucket Prompt Repo", ex.message ?: ex.toString()) - } + ) } } val checkHardcodedPathButton = JButton("Check Hardcoded Path").apply { addActionListener { usage.record("settings.bitbucket_prompt_repo.check_hardcoded_path") if (!saveCurrentState()) return@addActionListener - runCatching { - LlmSettingsLoader.checkBitbucketHardcodedPath(project, bitbucketHardcodedPathField.text) - }.onSuccess { - Notifications.info(project, "Bitbucket Prompt Repo", "Hardcoded path check succeeded.") - }.onFailure { ex -> - Notifications.error(project, "Bitbucket Prompt Repo", ex.message ?: ex.toString()) - } + val hardcodedPath = bitbucketHardcodedPathField.text + runOffEdt( + label = "Check Bitbucket hardcoded path", + block = { LlmSettingsLoader.checkBitbucketHardcodedPath(project, hardcodedPath) }, + onSuccess = { + Notifications.info(project, "Bitbucket Prompt Repo", "Hardcoded path check succeeded.") + }, + onFailure = { ex -> + Notifications.error(project, "Bitbucket Prompt Repo", ex.message ?: ex.toString()) + } + ) } } add(formSection("Bitbucket Prompt Repo (Global Prompts)", listOf( 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 4400aba..77d3d1f 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 @@ -74,11 +74,17 @@ class LlmAuthSessionService( } fun loginNowWithFeedback() { - try { - loginNow() - Messages.showInfoMessage(project, "LLM login succeeded.", "AI Test Generator") - } catch (e: Exception) { - Messages.showErrorDialog(project, detailedErrorMessage("LLM login failed", e), "AI Test Generator") + ApplicationManager.getApplication().executeOnPooledThread { + try { + loginNow() + ApplicationManager.getApplication().invokeLater { + Messages.showInfoMessage(project, "LLM login succeeded.", "AI Test Generator") + } + } catch (e: Exception) { + ApplicationManager.getApplication().invokeLater { + Messages.showErrorDialog(project, detailedErrorMessage("LLM login failed", e), "AI Test Generator") + } + } } } From 2555981445d6737025d93c2ecab9f3d47e727c25 Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Mon, 18 May 2026 17:19:12 +0800 Subject: [PATCH 4/7] feat: add Sonar Cube settings tab --- .../ai/plugin/AiTestSettingsConfigurable.kt | 72 ++++++++++++++++--- .../ai/plugin/AiTestSettingsModel.kt | 6 ++ .../ai/plugin/LlmAuthSessionService.kt | 4 +- .../ai/plugin/LlmSettingsLoader.kt | 17 +++++ 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt index b61541f..85418d3 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt @@ -92,6 +92,12 @@ class AiTestSettingsConfigurable( private lateinit var bitbucketPromptRepoPasswordField: JPasswordField private lateinit var bitbucketPromptRepoTokenField: JPasswordField private lateinit var bitbucketHardcodedPathField: JTextField + private lateinit var sonarQubeServerUrlField: JTextField + private lateinit var sonarQubeProjectKeyField: JTextField + private lateinit var sonarQubeTokenField: JPasswordField + private lateinit var sonarQubeTokenEnvField: JTextField + private lateinit var sonarQubeTargetCoverageField: JTextField + private lateinit var sonarQubeMaxFilesField: JTextField private lateinit var promptTypeField: JComboBox private lateinit var promptNameField: JTextField private lateinit var promptListPanel: JPanel @@ -128,7 +134,7 @@ class AiTestSettingsConfigurable( override fun getId(): String = "org.openprojectx.ai.plugin.settings" - override fun getDisplayName(): String = "AI Test Generator" + override fun getDisplayName(): String = "Code Quality Improver" override fun createComponent(): JComponent { val usage = ButtonUsageReportService.getInstance(project) @@ -193,6 +199,12 @@ class AiTestSettingsConfigurable( bitbucketPromptRepoPasswordField = JPasswordField() bitbucketPromptRepoTokenField = JPasswordField() bitbucketHardcodedPathField = JTextField() + sonarQubeServerUrlField = JTextField() + sonarQubeProjectKeyField = JTextField() + sonarQubeTokenField = JPasswordField() + sonarQubeTokenEnvField = JTextField() + sonarQubeTargetCoverageField = JTextField("80") + sonarQubeMaxFilesField = JTextField("5") promptTypeField = JComboBox(PromptCategory.entries.toTypedArray()) promptNameField = JTextField() promptContentField = textArea(8) @@ -203,6 +215,7 @@ class AiTestSettingsConfigurable( val tabs = JTabbedPane().apply { addTab("Login", loginTab()) addTab("LLM", llmTab()) + addTab("Sonar Cube", sonarCubeTab()) addTab("Prompts", promptsTab()) } @@ -244,14 +257,14 @@ class AiTestSettingsConfigurable( Messages.showInfoMessage( project, "Imported config from: $sourcePath", - "AI Test Generator" + "Code Quality Improver" ) }, onFailure = { ex -> Messages.showErrorDialog( project, detailedErrorMessage("Import repo config failed", ex), - "AI Test Generator" + "Code Quality Improver" ) } ) @@ -419,6 +432,25 @@ class AiTestSettingsConfigurable( add(sectionWithToggle(loginEnabled, loginPanel).also { loginCardPanel = it }) }) + + private fun sonarCubeTab(): JComponent = scrollableTab(JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = BorderFactory.createEmptyBorder(12, 12, 12, 12) + add(infoBanner("Configure SonarQube/SonarCloud access used by the Sonar Cube side tab and Tools → SonarQube Coverage action. Authentication currently uses a token/PAT or token environment variable.")) + add(formSection("Connection", listOf( + "Server URL" to sonarQubeServerUrlField, + "Project Key" to sonarQubeProjectKeyField + ))) + add(formSection("Authentication", listOf( + "Token / PAT" to sonarQubeTokenField, + "Token env var" to sonarQubeTokenEnvField + ))) + add(formSection("Coverage", listOf( + "Target coverage %" to sonarQubeTargetCoverageField, + "Max files to inspect" to sonarQubeMaxFilesField + ))) + }) + private fun promptsTab(): JComponent = scrollableTab(JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) border = BorderFactory.createEmptyBorder(12, 12, 12, 12) @@ -490,7 +522,7 @@ class AiTestSettingsConfigurable( val name = promptNameField.text.trim() val content = promptContentField.text.trim() if (name.isBlank() || content.isBlank()) { - Messages.showErrorDialog(project, "Prompt name and content are required.", "AI Test Generator") + Messages.showErrorDialog(project, "Prompt name and content are required.", "Code Quality Improver") return } @@ -498,7 +530,7 @@ class AiTestSettingsConfigurable( val currentSelection = selectedPromptSelection if (currentSelection != null) { if (currentSelection.isGlobal && (currentSelection.category != category || currentSelection.name != name)) { - Messages.showErrorDialog(project, "Global prompt name/category cannot be changed.", "AI Test Generator") + Messages.showErrorDialog(project, "Global prompt name/category cannot be changed.", "Code Quality Improver") return } maps[currentSelection.category]?.remove(currentSelection.name) @@ -510,7 +542,7 @@ class AiTestSettingsConfigurable( val targetMap = maps.getValue(category) if (targetMap.containsKey(name) && (currentSelection == null || currentSelection.name != name || currentSelection.category != category)) { - Messages.showErrorDialog(project, "Prompt name already exists in selected category.", "AI Test Generator") + Messages.showErrorDialog(project, "Prompt name already exists in selected category.", "Code Quality Improver") return } targetMap[name] = content @@ -522,7 +554,7 @@ class AiTestSettingsConfigurable( private fun deletePromptProfile() { val selection = selectedPromptSelection if (selection == null) { - Messages.showErrorDialog(project, "Select a prompt before deleting.", "AI Test Generator") + Messages.showErrorDialog(project, "Select a prompt before deleting.", "Code Quality Improver") return } val maps = mutableMapsByCategory() @@ -821,6 +853,12 @@ class AiTestSettingsConfigurable( bitbucketPromptRepoUsername = bitbucketPromptRepoUsernameField.text.trim(), bitbucketPromptRepoPassword = String(bitbucketPromptRepoPasswordField.password).trim(), bitbucketConfigImportPath = bitbucketHardcodedPathField.text.trim(), + sonarQubeServerUrl = sonarQubeServerUrlField.text.trim(), + sonarQubeProjectKey = sonarQubeProjectKeyField.text.trim(), + sonarQubeToken = String(sonarQubeTokenField.password).trim(), + sonarQubeTokenEnv = sonarQubeTokenEnvField.text.trim(), + sonarQubeTargetCoverage = sonarQubeTargetCoverageField.text.trim(), + sonarQubeMaxFiles = sonarQubeMaxFilesField.text.trim(), suppressedGlobalPrompts = suppressedGlobalPrompts.sorted() ) @@ -869,6 +907,12 @@ class AiTestSettingsConfigurable( bitbucketPromptRepoUsernameField.text = state.bitbucketPromptRepoUsername bitbucketPromptRepoPasswordField.text = state.bitbucketPromptRepoPassword bitbucketHardcodedPathField.text = state.bitbucketConfigImportPath + sonarQubeServerUrlField.text = state.sonarQubeServerUrl + sonarQubeProjectKeyField.text = state.sonarQubeProjectKey + sonarQubeTokenField.text = state.sonarQubeToken + sonarQubeTokenEnvField.text = state.sonarQubeTokenEnv + sonarQubeTargetCoverageField.text = state.sonarQubeTargetCoverage + sonarQubeMaxFilesField.text = state.sonarQubeMaxFiles suppressedGlobalPrompts = state.suppressedGlobalPrompts.toSet() refreshPromptManager() @@ -887,6 +931,7 @@ class AiTestSettingsConfigurable( runCatching { GitRemoteParser.parse(state.bitbucketPromptRepoUrl).provider.name } .getOrElse { ex -> throw IllegalArgumentException("Prompt repo URL is invalid: ${ex.message ?: ex}") } } + validateSonarQubeSettings(state) } private fun validateLlmSettings(state: AiTestSettingsModel) { @@ -904,6 +949,13 @@ class AiTestSettingsConfigurable( } } + private fun validateSonarQubeSettings(state: AiTestSettingsModel) { + state.sonarQubeTargetCoverage.takeIf { it.isNotBlank() }?.toDoubleOrNull() + ?: throw IllegalArgumentException("Sonar Cube target coverage must be a number") + state.sonarQubeMaxFiles.takeIf { it.isNotBlank() }?.toIntOrNull() + ?: throw IllegalArgumentException("Sonar Cube max files must be an integer") + } + private fun requireTemplate(label: String, url: String, body: String, responsePath: String) { if (url.isBlank() || body.isBlank() || responsePath.isBlank()) { throw IllegalArgumentException("$label requires URL, body, and response JSONPath") @@ -954,7 +1006,7 @@ class AiTestSettingsConfigurable( val ioFile = File(configPath) val vFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(ioFile) if (vFile == null) { - Messages.showErrorDialog(project, "Cannot find config file: $configPath", "AI Test Generator") + Messages.showErrorDialog(project, "Cannot find config file: $configPath", "Code Quality Improver") return } FileEditorManager.getInstance(project).openFile(vFile, true) @@ -965,7 +1017,7 @@ class AiTestSettingsConfigurable( apply() true } catch (e: Exception) { - Messages.showErrorDialog(project, e.message ?: e.toString(), "AI Test Generator") + Messages.showErrorDialog(project, e.message ?: e.toString(), "Code Quality Improver") false } } @@ -979,7 +1031,7 @@ class AiTestSettingsConfigurable( updatePathLabel() true } catch (e: Exception) { - Messages.showErrorDialog(project, e.message ?: e.toString(), "AI Test Generator") + Messages.showErrorDialog(project, e.message ?: e.toString(), "Code Quality Improver") false } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt index 0ffcecf..6cab276 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt @@ -44,5 +44,11 @@ data class AiTestSettingsModel( val bitbucketPromptRepoUsername: String = "", val bitbucketPromptRepoPassword: String = "", val bitbucketConfigImportPath: String = "", + val sonarQubeServerUrl: String = "", + val sonarQubeProjectKey: String = "", + val sonarQubeToken: String = "", + val sonarQubeTokenEnv: String = "", + val sonarQubeTargetCoverage: String = "80", + val sonarQubeMaxFiles: String = "5", val suppressedGlobalPrompts: List = emptyList() ) 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 77d3d1f..731482f 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 @@ -78,11 +78,11 @@ class LlmAuthSessionService( try { loginNow() ApplicationManager.getApplication().invokeLater { - Messages.showInfoMessage(project, "LLM login succeeded.", "AI Test Generator") + Messages.showInfoMessage(project, "LLM login succeeded.", "Code Quality Improver") } } catch (e: Exception) { ApplicationManager.getApplication().invokeLater { - Messages.showErrorDialog(project, detailedErrorMessage("LLM login failed", e), "AI Test Generator") + Messages.showErrorDialog(project, detailedErrorMessage("LLM login failed", e), "Code Quality Improver") } } } 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 5eef421..02b13dd 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 @@ -346,6 +346,7 @@ object LlmSettingsLoader { val http = llm["http"] as? Map<*, *> val ui = root["ui"] as? Map<*, *> ?: emptyMap() val prompts = root["prompts"] as? Map<*, *> ?: emptyMap() + val sonarQube = root["sonarQube"] as? Map<*, *> ?: emptyMap() val remoteRepo = prompts.map("remoteRepo") val suppressedGlobalPrompts = parseSuppressedGlobalPrompts(prompts) val promptGeneration = prompts["generation"] as? Map<*, *> ?: emptyMap() @@ -454,6 +455,12 @@ object LlmSettingsLoader { bitbucketPromptRepoUsername = remoteRepo.string("username"), bitbucketPromptRepoPassword = remoteRepo.string("password"), bitbucketConfigImportPath = remoteRepo.string("configImportPath"), + sonarQubeServerUrl = sonarQube.string("serverUrl"), + sonarQubeProjectKey = sonarQube.string("projectKey"), + sonarQubeToken = sonarQube.string("token"), + sonarQubeTokenEnv = sonarQube.string("tokenEnv"), + sonarQubeTargetCoverage = sonarQube.string("targetCoverage").ifBlank { "80" }, + sonarQubeMaxFiles = sonarQube.string("maxFiles").ifBlank { "5" }, suppressedGlobalPrompts = suppressedGlobalPrompts ) } @@ -462,6 +469,7 @@ object LlmSettingsLoader { val root = readRootMap(project).toMutableLinkedMap() root["llm"] = buildLlmMap(root["llm"] as? Map<*, *>, model) root["ui"] = buildUiMap(root["ui"] as? Map<*, *>, model) + root["sonarQube"] = buildSonarQubeMap(model) root.remove("generation") root["prompts"] = buildPromptsMap(project, root["prompts"] as? Map<*, *>, model) writeRootMap(project, root) @@ -743,6 +751,15 @@ object LlmSettingsLoader { return llm } + private fun buildSonarQubeMap(model: AiTestSettingsModel): MutableMap = linkedMapOf().apply { + put("serverUrl", model.sonarQubeServerUrl) + put("projectKey", model.sonarQubeProjectKey) + put("token", model.sonarQubeToken) + put("tokenEnv", model.sonarQubeTokenEnv) + put("targetCoverage", model.sonarQubeTargetCoverage.toDoubleOrNull() ?: 80.0) + put("maxFiles", model.sonarQubeMaxFiles.toIntOrNull() ?: 5) + } + private fun buildPromptsMap(project: Project, existing: Map<*, *>?, model: AiTestSettingsModel): MutableMap { val prompts = existing.toMutableLinkedMap() val remoteRepoConfig = BitbucketPromptRepoConfig( From 5cbd8f6bb99cbcd4a8b48f8efe585263a602790b Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Mon, 18 May 2026 17:25:42 +0800 Subject: [PATCH 5/7] Support SonarQube password authentication --- .../openprojectx/ai/plugin/AiTestConfig.kt | 6 ++++ .../ai/plugin/AiTestSettingsConfigurable.kt | 19 ++++++++++-- .../ai/plugin/AiTestSettingsModel.kt | 3 ++ .../ai/plugin/LlmSettingsLoader.kt | 9 ++++++ .../ai/plugin/SonarCubeToolWindowPanel.kt | 6 +--- .../openprojectx/ai/plugin/SonarQubeAuth.kt | 30 +++++++++++++++++++ .../ai/plugin/SonarQubeCoverageAction.kt | 21 +++++++++---- 7 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestConfig.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestConfig.kt index e2d6364..15c771d 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestConfig.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestConfig.kt @@ -16,11 +16,17 @@ data class SonarQubeConfig( val projectKey: String = "", val token: String = "", val tokenEnv: String = "", + val username: String = "", + val password: String = "", + val passwordEnv: String = "", val targetCoverage: Double = 80.0, val maxFiles: Int = 5 ) { val resolvedToken: String get() = token.ifBlank { tokenEnv.takeIf { it.isNotBlank() }?.let { System.getenv(it).orEmpty() }.orEmpty() } + + val resolvedPassword: String + get() = password.ifBlank { passwordEnv.takeIf { it.isNotBlank() }?.let { System.getenv(it).orEmpty() }.orEmpty() } } data class PromptOverrides( diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt index 85418d3..eea7aa2 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt @@ -96,6 +96,9 @@ class AiTestSettingsConfigurable( private lateinit var sonarQubeProjectKeyField: JTextField private lateinit var sonarQubeTokenField: JPasswordField private lateinit var sonarQubeTokenEnvField: JTextField + private lateinit var sonarQubeUsernameField: JTextField + private lateinit var sonarQubePasswordField: JPasswordField + private lateinit var sonarQubePasswordEnvField: JTextField private lateinit var sonarQubeTargetCoverageField: JTextField private lateinit var sonarQubeMaxFilesField: JTextField private lateinit var promptTypeField: JComboBox @@ -203,6 +206,9 @@ class AiTestSettingsConfigurable( sonarQubeProjectKeyField = JTextField() sonarQubeTokenField = JPasswordField() sonarQubeTokenEnvField = JTextField() + sonarQubeUsernameField = JTextField() + sonarQubePasswordField = JPasswordField() + sonarQubePasswordEnvField = JTextField() sonarQubeTargetCoverageField = JTextField("80") sonarQubeMaxFilesField = JTextField("5") promptTypeField = JComboBox(PromptCategory.entries.toTypedArray()) @@ -436,14 +442,17 @@ class AiTestSettingsConfigurable( private fun sonarCubeTab(): JComponent = scrollableTab(JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) border = BorderFactory.createEmptyBorder(12, 12, 12, 12) - add(infoBanner("Configure SonarQube/SonarCloud access used by the Sonar Cube side tab and Tools → SonarQube Coverage action. Authentication currently uses a token/PAT or token environment variable.")) + add(infoBanner("Configure SonarQube/SonarCloud access used by the Sonar Cube side tab and Tools → SonarQube Coverage action. Prefer Token/PAT auth; username/password basic auth is also supported for SonarQube instances that allow it.")) add(formSection("Connection", listOf( "Server URL" to sonarQubeServerUrlField, "Project Key" to sonarQubeProjectKeyField ))) add(formSection("Authentication", listOf( "Token / PAT" to sonarQubeTokenField, - "Token env var" to sonarQubeTokenEnvField + "Token env var" to sonarQubeTokenEnvField, + "Username" to sonarQubeUsernameField, + "Password" to sonarQubePasswordField, + "Password env var" to sonarQubePasswordEnvField ))) add(formSection("Coverage", listOf( "Target coverage %" to sonarQubeTargetCoverageField, @@ -857,6 +866,9 @@ class AiTestSettingsConfigurable( sonarQubeProjectKey = sonarQubeProjectKeyField.text.trim(), sonarQubeToken = String(sonarQubeTokenField.password).trim(), sonarQubeTokenEnv = sonarQubeTokenEnvField.text.trim(), + sonarQubeUsername = sonarQubeUsernameField.text.trim(), + sonarQubePassword = String(sonarQubePasswordField.password).trim(), + sonarQubePasswordEnv = sonarQubePasswordEnvField.text.trim(), sonarQubeTargetCoverage = sonarQubeTargetCoverageField.text.trim(), sonarQubeMaxFiles = sonarQubeMaxFilesField.text.trim(), suppressedGlobalPrompts = suppressedGlobalPrompts.sorted() @@ -911,6 +923,9 @@ class AiTestSettingsConfigurable( sonarQubeProjectKeyField.text = state.sonarQubeProjectKey sonarQubeTokenField.text = state.sonarQubeToken sonarQubeTokenEnvField.text = state.sonarQubeTokenEnv + sonarQubeUsernameField.text = state.sonarQubeUsername + sonarQubePasswordField.text = state.sonarQubePassword + sonarQubePasswordEnvField.text = state.sonarQubePasswordEnv sonarQubeTargetCoverageField.text = state.sonarQubeTargetCoverage sonarQubeMaxFilesField.text = state.sonarQubeMaxFiles suppressedGlobalPrompts = state.suppressedGlobalPrompts.toSet() diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt index 6cab276..4434b2e 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt @@ -48,6 +48,9 @@ data class AiTestSettingsModel( val sonarQubeProjectKey: String = "", val sonarQubeToken: String = "", val sonarQubeTokenEnv: String = "", + val sonarQubeUsername: String = "", + val sonarQubePassword: String = "", + val sonarQubePasswordEnv: String = "", val sonarQubeTargetCoverage: String = "80", val sonarQubeMaxFiles: String = "5", val suppressedGlobalPrompts: List = emptyList() 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 02b13dd..bd2487c 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 @@ -459,6 +459,9 @@ object LlmSettingsLoader { sonarQubeProjectKey = sonarQube.string("projectKey"), sonarQubeToken = sonarQube.string("token"), sonarQubeTokenEnv = sonarQube.string("tokenEnv"), + sonarQubeUsername = sonarQube.string("username"), + sonarQubePassword = sonarQube.string("password"), + sonarQubePasswordEnv = sonarQube.string("passwordEnv"), sonarQubeTargetCoverage = sonarQube.string("targetCoverage").ifBlank { "80" }, sonarQubeMaxFiles = sonarQube.string("maxFiles").ifBlank { "5" }, suppressedGlobalPrompts = suppressedGlobalPrompts @@ -573,6 +576,9 @@ object LlmSettingsLoader { projectKey = sonar.string("projectKey"), token = sonar.string("token"), tokenEnv = sonar.string("tokenEnv"), + username = sonar.string("username"), + password = sonar.string("password"), + passwordEnv = sonar.string("passwordEnv"), targetCoverage = sonar.double("targetCoverage") ?: 80.0, maxFiles = sonar.int("maxFiles") ?: 5 ) @@ -756,6 +762,9 @@ object LlmSettingsLoader { put("projectKey", model.sonarQubeProjectKey) put("token", model.sonarQubeToken) put("tokenEnv", model.sonarQubeTokenEnv) + put("username", model.sonarQubeUsername) + put("password", model.sonarQubePassword) + put("passwordEnv", model.sonarQubePasswordEnv) put("targetCoverage", model.sonarQubeTargetCoverage.toDoubleOrNull() ?: 80.0) put("maxFiles", model.sonarQubeMaxFiles.toIntOrNull() ?: 5) } 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 23cc7a9..d961c5a 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 @@ -19,8 +19,6 @@ import java.awt.Font import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import java.util.Base64 import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import javax.swing.BorderFactory @@ -156,9 +154,7 @@ object SonarCubeToolWindowPanel { } private class SonarCubeToolWindowClient(private val config: SonarQubeConfig) { - private val authHeader = config.resolvedToken.takeIf { it.isNotBlank() }?.let { - "Basic " + Base64.getEncoder().encodeToString("$it:".toByteArray(StandardCharsets.UTF_8)) - } + private val authHeader = SonarQubeAuth.authorizationHeader(config) suspend fun load(): SonarCubeResult { val client = HttpClients.shared(timeoutSeconds = 60) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt new file mode 100644 index 0000000..9402be9 --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt @@ -0,0 +1,30 @@ +package org.openprojectx.ai.plugin + +import java.nio.charset.StandardCharsets +import java.util.Base64 + +object SonarQubeAuth { + fun authorizationHeader(config: SonarQubeConfig): String? = authorizationHeader( + token = config.resolvedToken, + username = config.username, + password = config.resolvedPassword + ) + + fun authorizationHeader(token: String, username: String, password: String): String? { + val normalizedToken = token.trim() + if (normalizedToken.isNotBlank()) { + return basic("$normalizedToken:") + } + + val normalizedUsername = username.trim() + val normalizedPassword = password.trim() + if (normalizedUsername.isNotBlank() && normalizedPassword.isNotBlank()) { + return basic("$normalizedUsername:$normalizedPassword") + } + + return null + } + + private fun basic(raw: String): String = + "Basic " + Base64.getEncoder().encodeToString(raw.toByteArray(StandardCharsets.UTF_8)) +} 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 7a6bd80..3295de5 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 @@ -19,14 +19,13 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.io.File import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import java.util.Base64 import java.util.Locale import javax.swing.BoxLayout import javax.swing.JCheckBox import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel +import javax.swing.JPasswordField import javax.swing.JTextField class SonarQubeCoverageAction : AnAction("SonarQube Coverage"), DumbAware { @@ -89,6 +88,8 @@ private data class SonarQubeCoverageRequest( val serverUrl: String, val projectKey: String, val token: String, + val username: String, + val password: String, val targetCoverage: Double, val maxFiles: Int, val generateMissingTests: Boolean @@ -101,6 +102,8 @@ private class SonarQubeCoverageDialog( private val serverUrlField = JTextField(config.serverUrl, 40) private val projectKeyField = JTextField(config.projectKey, 40) private val tokenField = JTextField(config.resolvedToken, 40) + private val usernameField = JTextField(config.username, 40) + private val passwordField = JPasswordField(config.resolvedPassword, 40) private val targetCoverageField = JTextField(config.targetCoverage.toString(), 8) private val maxFilesField = JTextField(config.maxFiles.toString(), 8) private val generateMissingTestsBox = JCheckBox("Generate missing tests with AI", true) @@ -118,6 +121,10 @@ private class SonarQubeCoverageDialog( add(projectKeyField) add(JLabel("Token (optional; tokenEnv can be configured in .ai-test.yaml)")) add(tokenField) + add(JLabel("Username (optional; used only when token is blank)")) + add(usernameField) + add(JLabel("Password (optional; passwordEnv can be configured in .ai-test.yaml)")) + add(passwordField) add(JLabel("Target coverage %")) add(targetCoverageField) add(JLabel("Max uncovered files to inspect")) @@ -129,6 +136,8 @@ private class SonarQubeCoverageDialog( serverUrl = serverUrlField.text.trim(), projectKey = projectKeyField.text.trim(), token = tokenField.text.trim(), + username = usernameField.text.trim(), + password = String(passwordField.password).trim(), targetCoverage = targetCoverageField.text.trim().toDoubleOrNull() ?: 80.0, maxFiles = maxFilesField.text.trim().toIntOrNull()?.coerceIn(1, 20) ?: 5, generateMissingTests = generateMissingTestsBox.isSelected @@ -153,9 +162,11 @@ private data class SonarQubeFileCoverage( ) private class SonarQubeCoverageClient(private val request: SonarQubeCoverageRequest) { - private val authHeader = request.token.takeIf { it.isNotBlank() }?.let { - "Basic " + Base64.getEncoder().encodeToString("$it:".toByteArray(StandardCharsets.UTF_8)) - } + private val authHeader = SonarQubeAuth.authorizationHeader( + token = request.token, + username = request.username, + password = request.password + ) suspend fun loadCoverage(): SonarQubeCoverageReport { val jsonClient = HttpClients.shared(timeoutSeconds = 60) From d5aa8f527bb1451abe3df1acaf4cf9d1811a5284 Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Mon, 18 May 2026 17:30:49 +0800 Subject: [PATCH 6/7] Fix SonarQube URL encoding imports --- .../org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt | 1 + .../kotlin/org/openprojectx/ai/plugin/SonarQubeCoverageAction.kt | 1 + 2 files changed, 2 insertions(+) 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 d961c5a..9820a34 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 @@ -19,6 +19,7 @@ import java.awt.Font import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.net.URLEncoder +import java.nio.charset.StandardCharsets import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import javax.swing.BorderFactory 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 3295de5..d618e52 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 @@ -19,6 +19,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.io.File import java.net.URLEncoder +import java.nio.charset.StandardCharsets import java.util.Locale import javax.swing.BoxLayout import javax.swing.JCheckBox From 416a574cc14197fb9763a8b2d3c170154f094807 Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Mon, 18 May 2026 17:38:01 +0800 Subject: [PATCH 7/7] Store button usage beside AI config --- .../openprojectx/ai/plugin/ButtonUsageReportService.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ButtonUsageReportService.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ButtonUsageReportService.kt index 27c46ba..49a5e8a 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ButtonUsageReportService.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ButtonUsageReportService.kt @@ -68,16 +68,16 @@ class ButtonUsageReportService(private val project: Project) { isPrettyFlow = true } runCatching { + val path = reportPath() + path.parent?.let { Files.createDirectories(it) } val text = Yaml(options).dump(root) - Files.writeString(reportPath(), text) + Files.writeString(path, text) } } private fun reportPath(): Path { - val userHome = System.getProperty("user.home")?.takeIf { it.isNotBlank() } - ?: System.getenv("HOME")?.takeIf { it.isNotBlank() } - ?: "." - return Path.of(userHome).resolve(".ai-test-button-usage-report.yaml") + val configPath = Path.of(LlmSettingsLoader.configFilePath(project)) + return (configPath.parent ?: Path.of(".")).resolve("usage.yaml") } private fun projectKey(): String {