diff --git a/plugin-idea/build.gradle.kts b/plugin-idea/build.gradle.kts index 17432de..5007fc8 100644 --- a/plugin-idea/build.gradle.kts +++ b/plugin-idea/build.gradle.kts @@ -115,6 +115,15 @@ dependencies { testImplementation(kotlin("test-junit5")) testImplementation(libs.ktor.client.mock) + testImplementation("junit:junit:4.13.2") // Required by IntelliJ Platform test framework internals + + tasks.compileTestJava { + exclude("**/samples/**") + } + tasks.compileTestKotlin { + exclude("**/samples/**") + } + } publishing { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AuthManager.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AuthManager.kt new file mode 100644 index 0000000..a05edb0 --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AuthManager.kt @@ -0,0 +1,211 @@ +package org.openprojectx.ai.plugin + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.ide.passwordSafe.PasswordSafe +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.runBlocking +import org.openprojectx.ai.plugin.llm.LlmSettings +import org.openprojectx.ai.plugin.llm.TemplateRequestConfig +import org.openprojectx.ai.plugin.llm.TemplateRequestExecutor + +@Service(Service.Level.PROJECT) +class AuthManager(private val project: Project) { + + companion object { + fun getInstance(project: Project): AuthManager = project.service() + + private const val SSO_TOKEN_KEY = "OpenProjectX.AI.SSO.Token" + private const val SSO_CREDENTIALS_KEY = "OpenProjectX.AI.SSO.Credentials" + private fun serviceTokenKey(service: String) = "OpenProjectX.AI.SSO.$service.Token" + } + + // ---- Public API ---- + + /** Get token for a service. Priority: service-specific → shared SSO → login */ + fun getToken( + service: String, + independentLogin: TemplateRequestConfig? = null, + independentUsername: String? = null, + independentPassword: String? = null + ): String { + // 1. Service-specific token from PasswordSafe + readToken(serviceTokenKey(service))?.let { return it } + + // 2. Shared SSO token from PasswordSafe + if (independentLogin == null) { + readToken(SSO_TOKEN_KEY)?.let { return it } + } + + // 3. Independent username/password configured → login with independent or shared template + if (!independentUsername.isNullOrBlank() && !independentPassword.isNullOrBlank()) { + val loginConfig = independentLogin ?: loadSharedLoginConfig() + ?: error("No login template configured. Set llm.auth.login in .ai-test.yaml") + val token = executeLogin(loginConfig, independentUsername, independentPassword) + saveToken(serviceTokenKey(service), token) + return token + } + + // 4. Saved shared credentials → silent login + val savedCreds = readCredentials(SSO_CREDENTIALS_KEY) + if (savedCreds != null) { + val loginConfig = loadSharedLoginConfig() + ?: error("No login template configured. Set llm.auth.login in .ai-test.yaml") + val token = executeLogin(loginConfig, savedCreds.first, savedCreds.second) + if (token.isNotBlank()) { + saveToken(SSO_TOKEN_KEY, token) + return token + } + } + + // 5. Prompt user + return promptAndLogin(service, independentLogin) + } + + /** Called on 401: clear token, re-login, return new token */ + fun onUnauthorized( + service: String, + independentLogin: TemplateRequestConfig? = null, + independentUsername: String? = null, + independentPassword: String? = null + ): String { + // Clear service token + clearToken(serviceTokenKey(service)) + // Also clear shared SSO token (it's the same source) + if (independentLogin == null) { + clearToken(SSO_TOKEN_KEY) + } + + // Independent credentials → retry with those + if (!independentUsername.isNullOrBlank() && !independentPassword.isNullOrBlank()) { + val loginConfig = independentLogin ?: loadSharedLoginConfig() + ?: error("No login template configured") + val token = executeLogin(loginConfig, independentUsername, independentPassword) + saveToken(serviceTokenKey(service), token) + return token + } + + // Try saved shared credentials silently + val savedCreds = readCredentials(SSO_CREDENTIALS_KEY) + if (savedCreds != null) { + val loginConfig = loadSharedLoginConfig() + ?: error("No login template configured") + val token = executeLogin(loginConfig, savedCreds.first, savedCreds.second) + if (token.isNotBlank()) { + saveToken(SSO_TOKEN_KEY, token) + return token + } + } + + // Prompt user + return promptAndLogin(service, independentLogin) + } + + // ---- Private ---- + + private var lastLoginError: String? = null + + private fun promptAndLogin(service: String, independentLogin: TemplateRequestConfig?): String { + val loginConfig = independentLogin ?: loadSharedLoginConfig() + ?: error("No login template configured. Set llm.auth.login in .ai-test.yaml") + + val credentials = promptCredentials() + val token = executeLogin(loginConfig, credentials.username, credentials.password) + if (token.isBlank()) { + val hint = lastLoginError?.let { " | last error: $it" }.orEmpty() + error("SSO login returned empty token for $service$hint. Check logs for details.") + } + + val targetKey = if (independentLogin != null) serviceTokenKey(service) else SSO_TOKEN_KEY + saveToken(targetKey, token) + if (credentials.remember) { + saveCredentials(SSO_CREDENTIALS_KEY, credentials.username, credentials.password) + } + return token + } + + private fun promptCredentials(): LoginCredentials { + val savedCreds = readCredentials(SSO_CREDENTIALS_KEY) + val prefill = if (savedCreds != null) { + Credentials(savedCreds.first, savedCreds.second) + } else null + + lateinit var result: LoginCredentials + val showDialog = { + val dialog = LlmLoginDialog( + project = project, + initialUsername = prefill?.userName.orEmpty(), + initialPassword = prefill?.getPasswordAsString().orEmpty(), + rememberByDefault = prefill != null + ) + if (!dialog.showAndGet()) error("SSO login cancelled") + result = dialog.credentials() + if (result.username.isBlank()) error("SSO login requires a username") + if (result.password.isBlank()) error("SSO login requires a password") + } + + val app = ApplicationManager.getApplication() + if (app.isDispatchThread) showDialog() + else app.invokeAndWait(showDialog, ModalityState.any()) + + return result + } + + private fun executeLogin(config: TemplateRequestConfig, username: String, password: String): String { + return try { + runBlocking { + TemplateRequestExecutor( + HttpClients.shared(disableTlsVerification = false, timeoutSeconds = 60) + ).execute( + config = config, + variables = mapOf( + "username" to username, + "password" to password, + "model" to "", + "apiKey" to "", + "prompt" to "", + "promptJson" to "\"\"" + ) + ) + }.trim().takeIf { it.isNotBlank() }.orEmpty() + } catch (e: Exception) { + lastLoginError = "${e.javaClass.simpleName}: ${e.message}" + "" + } + } + + private fun loadSharedLoginConfig(): TemplateRequestConfig? { + val settings = LlmSettingsLoader.load(project) + return settings.auth?.login + } + + // ---- PasswordSafe helpers ---- + + private fun readToken(key: String): String? { + val creds = PasswordSafe.instance.get(CredentialAttributes(key)) + return creds?.getPasswordAsString()?.takeIf { it.isNotBlank() } + } + + private fun saveToken(key: String, token: String) { + PasswordSafe.instance.set(CredentialAttributes(key), Credentials(null, token)) + } + + private fun clearToken(key: String) { + PasswordSafe.instance.set(CredentialAttributes(key), null) + } + + private fun readCredentials(key: String): Pair? { + val creds = PasswordSafe.instance.get(CredentialAttributes(key)) ?: return null + val user = creds.userName.orEmpty() + val pass = creds.getPasswordAsString().orEmpty() + return if (user.isNotBlank() && pass.isNotBlank()) Pair(user, pass) else null + } + + private fun saveCredentials(key: String, username: String, password: String) { + PasswordSafe.instance.set(CredentialAttributes(key), Credentials(username, password)) + } +} 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 index 7315a64..e1ad357 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchResolutionUtil.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/BranchResolutionUtil.kt @@ -75,7 +75,7 @@ object BranchResolutionUtil { .directory(File(repo.root.path)) .redirectErrorStream(true) .start() - val output = process.inputStream.bufferedReader().use { it.readText() } + val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } if (process.waitFor() != 0) return null val normalizedCurrent = normalizeRefName(currentBranch) 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 b4fb31f..92b0bc9 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 @@ -115,6 +115,17 @@ class ContextBoxStateService(private val project: Project) { append(ChatMessage(role = ChatMessage.Role.USER, content = content)) } + fun addAssistantMessage(content: String, typeLabel: String = "") { + append(ChatMessage(role = ChatMessage.Role.ASSISTANT, content = content, typeLabel = typeLabel)) + } + + fun removeMessage(index: Int) { + if (index in history.indices) { + history.removeAt(index) + project.messageBus.syncPublisher(TOPIC).stateUpdated(snapshot()) + } + } + 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 78f5c64..1be1efc 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 @@ -27,9 +27,12 @@ import java.awt.FlowLayout import java.awt.Font import java.awt.Component import java.awt.Dimension +import java.awt.Graphics +import java.awt.Graphics2D import java.awt.GridBagConstraints import java.awt.GridBagLayout import java.awt.Insets +import java.awt.RenderingHints import javax.swing.BorderFactory import javax.swing.Box import javax.swing.BoxLayout @@ -162,7 +165,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { testFile.writeText(code, Charsets.UTF_8) } - fun createBubble(msg: ContextBoxStateService.ChatMessage): JPanel { + fun createBubble(msg: ContextBoxStateService.ChatMessage, index: Int): JPanel { val isUser = msg.role == ContextBoxStateService.ChatMessage.Role.USER val isAssistant = msg.role == ContextBoxStateService.ChatMessage.Role.ASSISTANT val bubbleBg = when (msg.role) { @@ -191,6 +194,13 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { ContextBoxStateService.ChatMessage.Role.SYSTEM -> ThemeColors.systemTimestamp } + val originalContent = msg.content + val editBorder = BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(ThemeColors.systemAccent, 1), + BorderFactory.createEmptyBorder(3, 5, 3, 5) + ) + val readOnlyBorder = BorderFactory.createEmptyBorder(4, 6, 4, 6) + val contentArea = JTextArea().apply { text = msg.content isEditable = false @@ -202,12 +212,20 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { background = bubbleBg foreground = bubbleFg caretColor = bubbleFg - border = BorderFactory.createEmptyBorder(6, 8, 6, 8) + border = readOnlyBorder } - val inner = JPanel(BorderLayout(0, 3)).apply { + val inner = object : JPanel(BorderLayout(0, 3)) { + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = background + g2.fillRoundRect(0, 0, width - 1, height - 1, 16, 16) + g2.dispose() + } + }.apply { background = bubbleBg - isOpaque = true + isOpaque = false val header = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false add(JLabel(roleLabel).apply { @@ -222,14 +240,101 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { add(header, BorderLayout.NORTH) add(contentArea, BorderLayout.CENTER) - // Show "Create PR" button for branch analysis results + // Action bar: delete / resend / edit + val actionBar = JPanel(FlowLayout(FlowLayout.RIGHT, 2, 0)).apply { + isOpaque = false + border = BorderFactory.createEmptyBorder(2, 0, 0, 0) + + fun makeIcon(text: String, tooltip: String, hoverColor: Color, onClick: () -> Unit): JLabel { + return JLabel(text).apply { + this.toolTipText = tooltip + foreground = ThemeColors.systemTimestamp + font = chatFont.deriveFont(Font.PLAIN, 11f) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + border = BorderFactory.createEmptyBorder(0, 4, 0, 4) + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { foreground = hoverColor } + override fun mouseExited(e: MouseEvent) { foreground = ThemeColors.systemTimestamp } + override fun mouseClicked(e: MouseEvent) { onClick() } + }) + } + } + + var editIcon: JLabel? = null + fun cancelEditing() { + contentArea.isEditable = false + contentArea.text = originalContent + contentArea.background = bubbleBg + contentArea.foreground = bubbleFg + contentArea.border = readOnlyBorder + editIcon?.text = "✎" + editIcon?.toolTipText = "Edit in place" + } + fun confirmEditing() { + val edited = contentArea.text.trim() + if (edited.isBlank()) return + contentArea.isEditable = false + val snapshot = stateService.snapshot() + if (index + 1 < snapshot.history.size && + snapshot.history[index + 1].role == ContextBoxStateService.ChatMessage.Role.ASSISTANT + ) { + stateService.removeMessage(index + 1) + stateService.removeMessage(index) + } else { + stateService.removeMessage(index) + } + chatInputField.text = edited + sendButton.doClick() + } + if (isUser) { + editIcon = makeIcon("✎", "Edit in place", ThemeColors.systemAccent) { + if (contentArea.isEditable) { + confirmEditing() + } else { + contentArea.isEditable = true + contentArea.background = bubbleBg.brighter() + contentArea.border = editBorder + contentArea.requestFocusInWindow() + editIcon?.text = "✓" + editIcon?.toolTipText = "Confirm (Esc to cancel)" + // Escape key to cancel + contentArea.addKeyListener(object : java.awt.event.KeyAdapter() { + override fun keyPressed(e: java.awt.event.KeyEvent) { + if (e.keyCode == java.awt.event.KeyEvent.VK_ESCAPE) { + cancelEditing() + contentArea.removeKeyListener(this) + } + } + }) + } + } + add(editIcon!!) + } + if (isAssistant) { + add(makeIcon("↻", "Regenerate", ThemeColors.systemAccent) { + val prevUser = stateService.snapshot().history.getOrNull(index - 1) + if (prevUser?.role == ContextBoxStateService.ChatMessage.Role.USER) { + stateService.removeMessage(index) + chatInputField.text = prevUser.content + sendButton.doClick() + } + }) + } + add(makeIcon("✕", "Delete", ThemeColors.deleteRed) { + stateService.removeMessage(index) + }) + } + + // Collect any action buttons below the action bar + val extraButtons = mutableListOf() + if (msg.typeLabel == "Branch Analysis" && msg.sourceBranch != null && msg.targetBranch != null) { - val prButton = JButton("Create PR →").apply { + extraButtons.add(JButton("Create PR →").apply { font = chatFont.deriveFont(Font.BOLD, 12f) foreground = ThemeColors.systemAccent background = bubbleBg - isOpaque = true - border = BorderFactory.createEmptyBorder(4, 0, 0, 0) + isOpaque = false + border = BorderFactory.createEmptyBorder(2, 0, 0, 0) isContentAreaFilled = false isFocusPainted = false addActionListener { @@ -266,23 +371,21 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } }) } - } - 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 { + extraButtons.add(JButton("Generate Tests →").apply { font = chatFont.deriveFont(Font.BOLD, 12f) foreground = ThemeColors.systemAccent background = bubbleBg - isOpaque = true - border = BorderFactory.createEmptyBorder(4, 0, 0, 0) + isOpaque = false + border = BorderFactory.createEmptyBorder(2, 0, 0, 0) isContentAreaFilled = false isFocusPainted = false addActionListener { @@ -314,35 +417,34 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } }) } + }) + } + + if (extraButtons.isNotEmpty()) { + val southPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(actionBar) + extraButtons.forEach { add(it) } } - add(testBtn, BorderLayout.SOUTH) + add(southPanel, BorderLayout.SOUTH) + } else { + add(actionBar, BorderLayout.SOUTH) } - border = BorderFactory.createCompoundBorder( - BorderFactory.createEmptyBorder(6, 10, 6, 10), - BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder( - when { - isAssistant -> ThemeColors.assistantBubbleBorder - isUser -> ThemeColors.userBubbleBorder - else -> bubbleBg.brighter() - }, - 1 - ), - BorderFactory.createEmptyBorder(0, 0, 0, 0) - ) - ) + border = BorderFactory.createEmptyBorder(8, 12, 8, 12) } val maxBubbleWidth = bubbleColumns * chatFont.size + 40 + inner.maximumSize = Dimension(maxBubbleWidth, inner.preferredSize.height.coerceAtLeast(1)) + val outer = JPanel(BorderLayout()).apply { - background = bgColor - isOpaque = true + isOpaque = false val spacerWest = if (isUser) JPanel().apply { - isOpaque = false; minimumSize = Dimension(40, 0) + isOpaque = false; preferredSize = Dimension(40, 0) } else null val spacerEast = if (isUser) null else JPanel().apply { - isOpaque = false; minimumSize = Dimension(40, 0) + isOpaque = false; preferredSize = Dimension(40, 0) } if (spacerWest != null) add(spacerWest, BorderLayout.WEST) if (spacerEast != null) add(spacerEast, BorderLayout.EAST) @@ -351,6 +453,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { add(inner) } add(aligned, BorderLayout.CENTER) + maximumSize = Dimension(Short.MAX_VALUE.toInt(), preferredSize.height.coerceAtLeast(1)) } return outer } @@ -365,10 +468,11 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { horizontalAlignment = SwingConstants.CENTER }) } else { - snapshot.history.forEach { msg -> - messageListPanel.add(createBubble(msg)) + snapshot.history.forEachIndexed { index, msg -> + messageListPanel.add(createBubble(msg, index)) messageListPanel.add(Box.createVerticalStrut(6)) } + messageListPanel.add(Box.createVerticalGlue()) } messageListPanel.revalidate() messageListPanel.repaint() @@ -386,6 +490,10 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { if (userInput.isBlank()) return@addActionListener val snapshot = stateService.snapshot() val messages = buildFollowUpMessages(snapshot, userInput) + + // Show user bubble immediately + stateService.addUserMessage(userInput) + chatInputField.text = "" sendButton.isEnabled = false chatInputField.isEnabled = false @@ -397,15 +505,16 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { val provider = LlmProviderFactory.create(settings) runBlocking { provider.generateCode(messages) } } + // Show AI response bubble after LLM returns ApplicationManager.getApplication().invokeLater { - stateService.recordChat(userInput, response) - chatInputField.text = "" + stateService.addAssistantMessage(response) sendButton.isEnabled = true chatInputField.isEnabled = true chatInputField.requestFocusInWindow() } } catch (ex: Exception) { ApplicationManager.getApplication().invokeLater { + stateService.addAssistantMessage("Chat failed: ${ex.message ?: ex.toString()}") sendButton.isEnabled = true chatInputField.isEnabled = true Notifications.error(project, "Chat failed", ex.message ?: ex.toString()) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitCredentialHelper.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitCredentialHelper.kt index d4a47f3..ad3cc74 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitCredentialHelper.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitCredentialHelper.kt @@ -19,7 +19,8 @@ object GitCredentialHelper { } val lines = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readLines() } - process.waitFor() + val exitCode = process.waitFor() + if (exitCode != 0) return@runCatching null val values = lines.mapNotNull { line -> val idx = line.indexOf('=') if (idx <= 0) null else line.substring(0, idx) to line.substring(idx + 1) 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 d8c2088..9d72d66 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.nio.charset.Charset import java.util.concurrent.TimeUnit object GitDiffProvider { @@ -16,9 +17,11 @@ object GitDiffProvider { fun toRelativePath(absolutePath: String): String? { val f = File(absolutePath) - val canonical = if (f.isAbsolute) f.canonicalPath else File(repoRoot, absolutePath).canonicalPath - return if (canonical != null && canonical.startsWith(repoRoot)) { - canonical.removePrefix(repoRoot).removePrefix("/").removePrefix("\\") + val canonical = (if (f.isAbsolute) f.canonicalPath else File(repoRoot, absolutePath).canonicalPath) + ?.replace('\\', '/') + val normalizedRoot = repoRoot.replace('\\', '/') + return if (canonical != null && canonical.startsWith(normalizedRoot)) { + canonical.removePrefix(normalizedRoot).removePrefix("/") } else null } @@ -37,8 +40,8 @@ object GitDiffProvider { .redirectErrorStream(true) .start() - val staged = stagedProcess.inputStream.bufferedReader().use { it.readText() } - stagedProcess.waitFor() + val staged = stagedProcess.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val stagedExit = stagedProcess.waitFor() val unstagedProcess = ProcessBuilder( mutableListOf("git", "diff", "--") + uniquePaths @@ -47,14 +50,18 @@ object GitDiffProvider { .redirectErrorStream(true) .start() - val unstaged = unstagedProcess.inputStream.bufferedReader().use { it.readText() } - unstagedProcess.waitFor() + val unstaged = unstagedProcess.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val unstagedExit = unstagedProcess.waitFor() + + // Exit code 1 = differences found (normal), > 1 = error (bad ref, etc.) + val stagedOk = stagedExit <= 1 + val unstagedOk = unstagedExit <= 1 return buildString { - if (staged.isNotBlank()) { + if (stagedOk && staged.isNotBlank()) { appendLine(staged.trimEnd()) } - if (unstaged.isNotBlank()) { + if (unstagedOk && unstaged.isNotBlank()) { if (isNotEmpty()) appendLine() append(unstaged.trimEnd()) } @@ -71,21 +78,24 @@ object GitDiffProvider { .directory(repoDir) .redirectErrorStream(true) .start() - val staged = stagedProcess.inputStream.bufferedReader().use { it.readText() } - stagedProcess.waitFor() + val staged = stagedProcess.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val stagedExit = stagedProcess.waitFor() val unstagedProcess = ProcessBuilder("git", "diff") .directory(repoDir) .redirectErrorStream(true) .start() - val unstaged = unstagedProcess.inputStream.bufferedReader().use { it.readText() } - unstagedProcess.waitFor() + val unstaged = unstagedProcess.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val unstagedExit = unstagedProcess.waitFor() + + val stagedOk = stagedExit <= 1 + val unstagedOk = unstagedExit <= 1 return buildString { - if (staged.isNotBlank()) { + if (stagedOk && staged.isNotBlank()) { appendLine(staged.trimEnd()) } - if (unstaged.isNotBlank()) { + if (unstagedOk && unstaged.isNotBlank()) { if (isNotEmpty()) appendLine() append(unstaged.trimEnd()) } @@ -105,7 +115,7 @@ object GitDiffProvider { .redirectErrorStream(true) .start() - val output = process.inputStream.bufferedReader().use { it.readText() } + val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } val finished = process.waitFor(30, TimeUnit.SECONDS) if (!finished) { process.destroyForcibly() @@ -132,7 +142,7 @@ object GitDiffProvider { .redirectErrorStream(true) .start() - val output = process.inputStream.bufferedReader().use { it.readText() } + val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } val finished = process.waitFor(30, TimeUnit.SECONDS) if (!finished) { process.destroyForcibly() 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 268dc80..1ec3533 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,85 +9,31 @@ import com.intellij.openapi.components.Service 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 @Service(Service.Level.PROJECT) class LlmAuthSessionService( private val project: Project ) { - private var sessionApiKey: String? = null + private val authManager: AuthManager get() = AuthManager.getInstance(project) - fun resolve(settings: LlmSettings): LlmSettings { + private fun resolve(settings: LlmSettings): LlmSettings { installRuntimeLogSink() if (!settings.apiKey.isNullOrBlank()) { - sessionApiKey = settings.apiKey return settings } - - val auth = settings.auth ?: return settings - sessionApiKey?.let { return settings.copy(apiKey = it) } - - // Try saved credentials silently first - val saved = loadSavedCredentials(settings) - if (saved != null) { - val apiKey = runLoginTemplate( - settings, saved.userName.orEmpty(), saved.getPasswordAsString().orEmpty() - ) - if (!apiKey.isNullOrBlank()) { - sessionApiKey = apiKey - return settings.copy(apiKey = apiKey) - } - // Saved credentials failed — fall through to prompt - } - - // Prompt user for credentials - val credentials = promptCredentials(settings, prefill = saved) - val apiKey = runLoginTemplate(settings, credentials.username, credentials.password) - if (apiKey.isNullOrBlank()) { - 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 { - installRuntimeLogSink() - val auth = settings.auth ?: return settings - sessionApiKey = null - - // Try saved credentials silently - val saved = loadSavedCredentials(settings) - if (saved != null) { - val apiKey = runLoginTemplate( - settings, saved.userName.orEmpty(), saved.getPasswordAsString().orEmpty() - ) - if (!apiKey.isNullOrBlank()) { - sessionApiKey = apiKey - return settings.copy(apiKey = apiKey) - } - } - - // Prompt for new credentials - val credentials = promptCredentials(settings, prefill = saved) - val apiKey = runLoginTemplate(settings, credentials.username, credentials.password) - if (apiKey.isNullOrBlank()) { - 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) + if (settings.auth == null) return settings + val token = authManager.getToken("LLM") + return settings.copy(apiKey = token) } 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") + val token = authManager.getToken("LLM") + return token.ifBlank { error("SSO login did not produce a token") } } fun loginNowWithFeedback() { @@ -95,19 +41,19 @@ class LlmAuthSessionService( try { loginNow() ApplicationManager.getApplication().invokeLater({ - Messages.showInfoMessage(project, "LLM login succeeded.", "Code Quality Improver") + Messages.showInfoMessage(project, "SSO login succeeded.", "Code Quality Assistant") }, ModalityState.any()) } catch (e: Exception) { ApplicationManager.getApplication().invokeLater({ - Messages.showErrorDialog(project, detailedErrorMessage("LLM login failed", e), "Code Quality Improver") + Messages.showErrorDialog(project, detailedErrorMessage("SSO login failed", e), "Code Quality Assistant") }, ModalityState.any()) } } } fun loadSavedLoginCredentialsForCurrentSettings(): LoginCredentials? { - val settings = LlmSettingsLoader.load(project) - return loadSavedCredentials(settings)?.let { + val creds = PasswordSafe.instance.get(CredentialAttributes("OpenProjectX.AI.SSO.Credentials")) + return creds?.let { LoginCredentials( username = it.userName.orEmpty(), password = it.getPasswordAsString().orEmpty(), @@ -117,8 +63,15 @@ class LlmAuthSessionService( } fun promptLoginCredentialsForCurrentSettings(): LoginCredentials { - val settings = LlmSettingsLoader.load(project) - return promptCredentials(settings, prefill = loadSavedCredentials(settings)) + // This is only used by settings UI; trigger a full login and return what the user entered + val token = authManager.getToken("LLM") // triggers login dialog if needed + val creds = PasswordSafe.instance.get(CredentialAttributes("OpenProjectX.AI.SSO.Credentials")) + return LoginCredentials( + username = creds?.userName.orEmpty(), + password = creds?.getPasswordAsString().orEmpty(), + remember = true + ).takeIf { it.username.isNotBlank() && it.password.isNotBlank() } + ?: LoginCredentials(token.take(20), "", remember = false) // fallback } fun withReloginOnUnauthorized(block: (LlmSettings) -> String): String { @@ -130,41 +83,12 @@ class LlmAuthSessionService( } catch (_: LlmUnauthorizedException) { if (baseSettings.auth == null) { if (baseSettings.apiKey.isNullOrBlank()) { - throw LlmUnauthorizedException("Unauthorized LLM request and no API key or login template is configured") + throw LlmUnauthorizedException("Unauthorized LLM request and no SSO login template is configured") } 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.") } - 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 { - runBlocking { - TemplateRequestExecutor( - HttpClients.shared( - disableTlsVerification = settings.httpDisableTlsVerification, - timeoutSeconds = settings.timeoutSeconds - ) - ).execute( - config = auth.login, - variables = mapOf( - "username" to username, - "password" to password, - "model" to settings.model, - "apiKey" to "", - "prompt" to "", - "promptJson" to "\"\"" - ) - ) - }.trim().takeIf { it.isNotBlank() } - } catch (e: Exception) { - lastLoginError = "${e.javaClass.simpleName}: ${e.message}" - null + val newToken = authManager.onUnauthorized("LLM") + block(resolve(baseSettings).copy(apiKey = newToken)) } } @@ -172,70 +96,6 @@ class LlmAuthSessionService( LlmRuntimeLogger.sink = { message -> RuntimeLogStore.append(message) } } - private fun promptCredentials( - settings: LlmSettings, - prefill: Credentials? - ): LoginCredentials { - lateinit var credentials: LoginCredentials - val showDialog = { - val dialog = LlmLoginDialog( - project = project, - initialUsername = prefill?.userName.orEmpty(), - initialPassword = prefill?.getPasswordAsString().orEmpty(), - rememberByDefault = prefill != null - ) - if (!dialog.showAndGet()) { - error("LLM login cancelled") - } - - credentials = dialog.credentials() - if (credentials.username.isBlank()) { - error("LLM login requires a username") - } - if (credentials.password.isBlank()) { - error("LLM login requires a password") - } - if (credentials.remember) { - saveCredentials(settings, credentials) - } else { - clearSavedCredentials(settings) - } - } - - val app = ApplicationManager.getApplication() - if (app.isDispatchThread) { - showDialog() - } else { - app.invokeAndWait(showDialog, ModalityState.any()) - } - return credentials - } - - private fun loadSavedCredentials(settings: LlmSettings): Credentials? { - return PasswordSafe.instance.get(getCredentialAttributes(settings)) - } - - private fun saveCredentials(settings: LlmSettings, credentials: LoginCredentials) { - PasswordSafe.instance.set( - getCredentialAttributes(settings), - Credentials(credentials.username, credentials.password) - ) - } - - private fun clearSavedCredentials(settings: LlmSettings) { - PasswordSafe.instance.set(getCredentialAttributes(settings), null) - } - - private fun getCredentialAttributes(settings: LlmSettings): CredentialAttributes { - val endpointKey = settings.endpoint ?: settings.auth?.login?.url ?: "default" - val serviceName = "OpenProjectX.AI.Login.$endpointKey" - return CredentialAttributes(serviceName) - } - - companion object { - fun getInstance(project: Project): LlmAuthSessionService = project.service() - } - private fun detailedErrorMessage(prefix: String, throwable: Throwable): String { val details = generateSequence(throwable) { it.cause } .mapNotNull { it.message?.trim()?.takeIf { msg -> msg.isNotEmpty() } } @@ -243,4 +103,8 @@ class LlmAuthSessionService( .joinToString(" | caused by: ") return if (details.isBlank()) prefix else "$prefix: $details" } + + companion object { + fun getInstance(project: Project): LlmAuthSessionService = project.service() + } } 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 4d53844..22805a7 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 @@ -196,7 +196,32 @@ object LlmSettingsLoader { if (sourceText != null) { val target = findOrCreateConfigFile() + val localYaml = Yaml() + // Capture existing prompts.remoteRepo before overwriting + val savedRemoteRepo: Any? = if (target.exists()) { + try { + (localYaml.load>(target.readText(Charsets.UTF_8))["prompts"] as? Map<*, *>)?.get("remoteRepo") + } catch (_: Exception) { null } + } else null + target.writeText(sourceText, Charsets.UTF_8) + + // Merge prompts.remoteRepo back so automatic prompt sync works after import + @Suppress("UNCHECKED_CAST") + if (savedRemoteRepo != null) { + try { + val mergedMap = (localYaml.load(target.readText(Charsets.UTF_8)) as? MutableMap) + ?: mutableMapOf() + val prompts = (mergedMap["prompts"] as? MutableMap) + ?: mutableMapOf() + if (prompts["remoteRepo"] == null) { + prompts["remoteRepo"] = savedRemoteRepo + mergedMap["prompts"] = prompts + target.writeText(localYaml.dump(mergedMap), Charsets.UTF_8) + } + } catch (_: Exception) { /* best-effort */ } + } + RuntimeLogStore.append("INFO | Prompt Repo Import | Success provider=${repo.provider} path=$configPath target=${target.absolutePath}") return "${remoteRepo.repoUrl}@$configPath" } @@ -1661,7 +1686,16 @@ object LlmSettingsLoader { ?.let { listOf(BitbucketCredential("git-credential-helper", it.username, it.password)) } ?: emptyList() val credentials = configuredCredentials + gitCredentials - return bitbucketGetWithCredentials(url, config.token, credentials) + + // If no direct token, try SSO via AuthManager + val effectiveToken = config.token.ifBlank { + try { + AuthManager.getInstance(project).getToken("Bitbucket", + independentUsername = config.username.takeIf { it.isNotBlank() }, + independentPassword = config.password.takeIf { it.isNotBlank() }) + } catch (_: Exception) { "" } + } + return bitbucketGetWithCredentials(url, effectiveToken, credentials) } private fun bitbucketGetWithCredentials(url: String, token: String, credentials: List): String { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/PushAndCreatePrAction.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/PushAndCreatePrAction.kt index 563098f..22df172 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/PushAndCreatePrAction.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/PushAndCreatePrAction.kt @@ -101,7 +101,7 @@ class PushAndCreatePrAction : AnAction("Push and Create PR"), DumbAware { .directory(File(repositoryRoot)) .redirectErrorStream(true) .start() - val output = process.inputStream.bufferedReader().use { it.readText() } + val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } val code = process.waitFor() if (code != 0) { error("git push failed: $output") 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 index 9402be9..7d7e713 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt @@ -1,5 +1,6 @@ package org.openprojectx.ai.plugin +import com.intellij.openapi.project.Project import java.nio.charset.StandardCharsets import java.util.Base64 @@ -10,6 +11,22 @@ object SonarQubeAuth { password = config.resolvedPassword ) + fun authorizationHeader(project: Project, config: SonarQubeConfig): String? { + val directHeader = authorizationHeader(config) + if (directHeader != null) return directHeader + + // Fall back to SSO token via AuthManager + val ssoToken = try { + AuthManager.getInstance(project).getToken("SonarQube", + independentUsername = config.username.takeIf { it.isNotBlank() }, + independentPassword = config.resolvedPassword.takeIf { it.isNotBlank() }) + } catch (_: Exception) { "" } + if (ssoToken.isNotBlank()) { + return basic("$ssoToken:") + } + return null + } + fun authorizationHeader(token: String, username: String, password: String): String? { val normalizedToken = token.trim() if (normalizedToken.isNotBlank()) { 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 index bd49f78..bfd38c5 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ThemeColors.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ThemeColors.kt @@ -48,6 +48,8 @@ object ThemeColors { val categoryCodeGen = Color(0x22, 0xC5, 0x5E) val categoryCodeReview = Color(0xFB, 0x71, 0x85) + val deleteRed = JBColor(Color(0xE5, 0x3E, 0x3E), Color(0xFC, 0x81, 0x81)) + // --- 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 0f0e041..e4ea358 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 @@ -155,11 +155,19 @@ object GitRemoteParser { } private fun buildApiBaseUrl(uri: URI, contextPath: String): String { - val base = "${uri.scheme}://${hostWithPort(uri)}" + val base = "https://${hostWithoutPort(uri)}" return if (contextPath.isBlank()) base else "$base/$contextPath" } private fun hostWithPort(uri: URI): String { - return if (uri.port > 0) "${uri.host}:${uri.port}" else uri.host + return hostWithoutPort(uri) + } + + private fun hostWithoutPort(uri: URI): String { + val host = uri.host + val port = uri.port + if (port <= 0) return host + if (port == 22 || port == 7999 || port == 80 || port == 443) return host + return "$host:$port" } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRepositoryContextService.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRepositoryContextService.kt index 93b1ffc..3a38a7b 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRepositoryContextService.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRepositoryContextService.kt @@ -3,6 +3,8 @@ package org.openprojectx.ai.plugin.pr import com.intellij.openapi.project.Project import git4idea.repo.GitRepositoryManager import java.io.File +import java.nio.charset.Charset +import java.util.concurrent.TimeUnit data class GitRepositoryContext( val remoteUrl: String, @@ -44,8 +46,16 @@ object GitRepositoryContextService { .redirectErrorStream(true) .start() - val text = process.inputStream.bufferedReader().use { it.readText() } - process.waitFor() + val text = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val finished = process.waitFor(30, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + error("Timed out collecting branch diff for origin/$targetBranch...HEAD") + } + val exitCode = process.exitValue() + if (exitCode > 1) { + error("Failed to collect diff (exit $exitCode): ${text.take(500)}") + } return text } diff --git a/plugin-idea/src/test/java/org/openprojectx/ai/plugin/samples/CommonJavaMethodsTest.java b/plugin-idea/src/test/java/org/openprojectx/ai/plugin/samples/CommonJavaMethodsTest.java new file mode 100644 index 0000000..3e673b6 --- /dev/null +++ b/plugin-idea/src/test/java/org/openprojectx/ai/plugin/samples/CommonJavaMethodsTest.java @@ -0,0 +1,103 @@ +package org.openprojectx.ai.plugin.samples; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class CommonJavaMethodsTest { + + private CommonJavaMethods commonJavaMethods; + + @BeforeEach + void setUp() { + commonJavaMethods = new CommonJavaMethods(); + } + + @Test + void testAdd() { + assertEquals(5, commonJavaMethods.add(2, 3)); + assertEquals(-1, commonJavaMethods.add(-2, 1)); + assertEquals(0, commonJavaMethods.add(0, 0)); + assertEquals(Integer.MAX_VALUE, commonJavaMethods.add(Integer.MAX_VALUE, 0)); + } + + @Test + void testSubtract() { + assertEquals(1, commonJavaMethods.subtract(3, 2)); + assertEquals(-3, commonJavaMethods.subtract(2, 5)); + assertEquals(0, commonJavaMethods.subtract(0, 0)); + assertEquals(Integer.MIN_VALUE, commonJavaMethods.subtract(Integer.MIN_VALUE, 0)); + } + + @Test + void testMultiply() { + assertEquals(6, commonJavaMethods.multiply(2, 3)); + assertEquals(-6, commonJavaMethods.multiply(-2, 3)); + assertEquals(0, commonJavaMethods.multiply(0, 5)); + assertEquals(Integer.MAX_VALUE, commonJavaMethods.multiply(Integer.MAX_VALUE, 1)); + } + + @Test + void testDivide() { + assertEquals(2.0, commonJavaMethods.divide(6.0, 3.0), 1e-9); + assertEquals(-2.0, commonJavaMethods.divide(-6.0, 3.0), 1e-9); + assertEquals(Double.POSITIVE_INFINITY, commonJavaMethods.divide(1.0, 0.0), 1e-9); + assertThrows(IllegalArgumentException.class, () -> commonJavaMethods.divide(1.0, 0)); + } + + @Test + void testIsEven() { + assertTrue(commonJavaMethods.isEven(2)); + assertTrue(commonJavaMethods.isEven(0)); + assertFalse(commonJavaMethods.isEven(1)); + assertFalse(commonJavaMethods.isEven(-1)); + } + + @Test + void testReverse() { + assertNull(commonJavaMethods.reverse(null)); + assertEquals("", commonJavaMethods.reverse("")); + assertEquals("olleh", commonJavaMethods.reverse("hello")); + assertEquals("a", commonJavaMethods.reverse("a")); + } + + @Test + void testFactorial() { + assertEquals(1L, commonJavaMethods.factorial(0)); + assertEquals(1L, commonJavaMethods.factorial(1)); + assertEquals(120L, commonJavaMethods.factorial(5)); + assertThrows(IllegalArgumentException.class, () -> commonJavaMethods.factorial(-1)); + } + + @Test + void testIsPalindrome() { + assertFalse(commonJavaMethods.isPalindrome(null)); + assertTrue(commonJavaMethods.isPalindrome("")); + assertTrue(commonJavaMethods.isPalindrome("A man a plan a canal Panama")); + assertTrue(commonJavaMethods.isPalindrome("racecar")); + assertFalse(commonJavaMethods.isPalindrome("hello")); + } + + @Test + void testMax() { + assertEquals(5, commonJavaMethods.max(new int[]{1, 2, 3, 4, 5})); + assertEquals(-1, commonJavaMethods.max(new int[]{-5, -1, -3})); + assertEquals(0, commonJavaMethods.max(new int[]{0})); + assertThrows(IllegalArgumentException.class, () -> commonJavaMethods.max(null)); + assertThrows(IllegalArgumentException.class, () -> commonJavaMethods.max(new int[]{})); + } + + @Test + void testSafeTrim() { + assertEquals("", commonJavaMethods.safeTrim(null)); + assertEquals("", commonJavaMethods.safeTrim("")); + assertEquals("hello", commonJavaMethods.safeTrim(" hello ")); + assertEquals("hello world", commonJavaMethods.safeTrim("hello world")); + } + + public static void main(String[] args) { + org.junit.platform.console.ConsoleLauncher.main(new String[]{ + "--select-class", "org.openprojectx.ai.plugin.samples.CommonJavaMethodsTest" + }); + } +} \ No newline at end of file diff --git a/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProviderTest.kt b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProviderTest.kt index 4a4178f..2282da8 100644 --- a/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProviderTest.kt +++ b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProviderTest.kt @@ -141,6 +141,7 @@ class BitbucketPullRequestProviderTest { } assertEquals("A pull request already exists", error.message) + client.close() } private fun repository(): RepositoryRef { diff --git a/plugin-idea/src/test/resources/logback-test.xml b/plugin-idea/src/test/resources/logback-test.xml new file mode 100644 index 0000000..532234b --- /dev/null +++ b/plugin-idea/src/test/resources/logback-test.xml @@ -0,0 +1,13 @@ + + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + UTF-8 + + + + + + +