diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt index d2172d1f6f9..585d4494a4c 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt @@ -5,6 +5,7 @@ package software.aws.toolkits.jetbrains.services.amazonq.webview import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.treeToValue import com.google.gson.Gson import com.intellij.ide.BrowserUtil @@ -34,6 +35,7 @@ import org.cef.browser.CefBrowser import org.eclipse.lsp4j.TextDocumentIdentifier import org.eclipse.lsp4j.jsonrpc.ResponseErrorException import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode +import org.intellij.lang.annotations.Language import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info @@ -115,9 +117,6 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.auth.isFeature import software.aws.toolkits.jetbrains.services.codemodernizer.utils.isCodeTransformAvailable import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanIssue import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager -import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.Description -import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.Recommendation -import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.SuggestedFix import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererConfigurable import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants import software.aws.toolkits.jetbrains.settings.MeetQSettings @@ -581,32 +580,6 @@ class BrowserConnector( } } - data class FlareCodeScanIssue( - val startLine: Int, - val endLine: Int, - val comment: String?, - val title: String, - val description: Description, - val detectorId: String, - val detectorName: String, - val findingId: String, - val ruleId: String?, - val relatedVulnerabilities: List, - val severity: String, - val recommendation: Recommendation, - val suggestedFixes: List, - val scanJobId: String, - val language: String, - val autoDetected: Boolean, - val filePath: String, - val findingContext: String, - ) - - data class AggregatedCodeScanIssue( - val filePath: String, - val issues: List, - ) - private fun showResult( result: CompletableFuture, partialResultToken: String, @@ -620,14 +593,13 @@ class BrowserConnector( throw error } chatCommunicationManager.removePartialChatMessage(partialResultToken) - val decryptedMessage = Gson().fromJson(value?.let { encryptionManager?.decrypt(it) }.orEmpty(), Map::class.java) - as Map + val decryptedMessage = value?.let { encryptionManager?.decrypt(it) }.orEmpty() parseFindingsMessages(decryptedMessage) val messageToChat = ChatCommunicationManager.convertToJsonToSendToChat( SEND_CHAT_COMMAND_PROMPT, tabId, - Gson().toJson(decryptedMessage), + decryptedMessage, isPartialResult = false ) browser.postChat(messageToChat) @@ -641,26 +613,23 @@ class BrowserConnector( } } - fun parseFindingsMessages(messagesMap: Map) { + fun deserializeFindings(@Language("JSON") responsePayload: String): List { + val additionalMessages = serializer.objectMapper.readValue(responsePayload).additionalMessages + ?: return emptyList() + + return additionalMessages.filter { message -> + message.messageId.endsWith(CODE_REVIEW_FINDINGS_SUFFIX) || + message.messageId.endsWith(DISPLAY_FINDINGS_SUFFIX) + } + } + + fun parseFindingsMessages(@Language("JSON") responsePayload: String) { try { - val additionalMessages = messagesMap["additionalMessages"] as? MutableList> - val findingsMessages = additionalMessages?.filter { message -> - if (message.contains("messageId")) { - (message["messageId"] as String).endsWith(CODE_REVIEW_FINDINGS_SUFFIX) || - (message["messageId"] as String).endsWith(DISPLAY_FINDINGS_SUFFIX) - } else { - false - } - } + val findings = deserializeFindings(responsePayload) val scannedFiles = mutableListOf() - if (findingsMessages != null) { - for (findingsMessage in findingsMessages) { - additionalMessages.remove(findingsMessage) - val gson = Gson() - val jsonFindings = gson.fromJson(findingsMessage["body"] as String, List::class.java) - val mappedFindings = mutableListOf() - for (aggregatedIssueUnformatted in jsonFindings) { - val aggregatedIssue = gson.fromJson(gson.toJson(aggregatedIssueUnformatted), AggregatedCodeScanIssue::class.java) + val mappedFindings = buildList { + for (finding in findings) { + for (aggregatedIssue in finding.body) { val file = LocalFileSystem.getInstance().findFileByIoFile( Path.of(aggregatedIssue.filePath).toFile() ) @@ -677,7 +646,8 @@ class BrowserConnector( if (isIssueIgnored) { continue } - mappedFindings.add( + + add( CodeWhispererCodeScanIssue( startLine = issue.startLine, startCol = 1, @@ -705,18 +675,20 @@ class BrowserConnector( } } } - - CodeWhispererCodeScanManager.getInstance(project) - .addOnDemandIssues( - mappedFindings, - scannedFiles, - CodeWhispererConstants.CodeAnalysisScope.AGENTIC - ) - CodeWhispererCodeScanManager.getInstance(project).showCodeScanUI() } } + + if (mappedFindings.isNotEmpty()) { + CodeWhispererCodeScanManager.getInstance(project) + .addOnDemandIssues( + mappedFindings, + scannedFiles, + CodeWhispererConstants.CodeAnalysisScope.AGENTIC + ) + CodeWhispererCodeScanManager.getInstance(project).showCodeScanUI() + } } catch (e: Exception) { - LOG.error { "Failed to parse findings message $e" } + LOG.error(e) { "Failed to parse findings message" } } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/FlareAdditionalFindings.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/FlareAdditionalFindings.kt new file mode 100644 index 00000000000..66ae2e10722 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/FlareAdditionalFindings.kt @@ -0,0 +1,65 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// TODO: move to software.aws.toolkits.jetbrains.services.amazonq.lsp.model +package software.aws.toolkits.jetbrains.services.amazonq.webview + +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.Description +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.Recommendation +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.SuggestedFix + +/** + * Solely used to extract the aggregated findings from the response + */ +data class FlareAdditionalMessages( + @get:JsonDeserialize(contentUsing = FlareAggregatedFindingsDeserializer::class) + @get:JsonSetter(contentNulls = Nulls.SKIP) + val additionalMessages: List?, +) + +data class FlareAggregatedFindings( + val messageId: String, + val body: List, +) + +data class FlareCodeScanIssue( + val startLine: Int, + val endLine: Int, + val comment: String?, + val title: String, + val description: Description, + val detectorId: String, + val detectorName: String, + val findingId: String, + val ruleId: String?, + val relatedVulnerabilities: List, + val severity: String, + val recommendation: Recommendation, + val suggestedFixes: List, + val scanJobId: String, + val language: String, + val autoDetected: Boolean, + val filePath: String, + val findingContext: String, +) + +data class AggregatedCodeScanIssue( + val filePath: String, + val issues: List, +) + +class FlareAggregatedFindingsDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): FlareAggregatedFindings? = + // drop values that do not look like FlareAggregatedFindings + try { + ctxt.readValue(p, FlareAggregatedFindings::class.java) + } catch (_: Exception) { + null + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnectorTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnectorTest.kt index 2a7f87440fa..7d4f7cd81bc 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnectorTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnectorTest.kt @@ -30,6 +30,7 @@ import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.Descripti import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.Recommendation import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.SuggestedFix import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.utils.satisfiesKt class BrowserConnectorTest : AmazonQTestBase() { private lateinit var browserConnector: BrowserConnector @@ -53,54 +54,92 @@ class BrowserConnectorTest : AmazonQTestBase() { } @Test - fun `parseFindingsMessages should handle empty additionalMessages`() { - val messagesMap = mapOf() - - browserConnector.parseFindingsMessages(messagesMap) + fun `parseFindingsMessages should handle no additionalMessages`() { + browserConnector.parseFindingsMessages("""""") verify(mockCodeScanManager, never()).addOnDemandIssues(any(), any(), any()) } @Test fun `parseFindingsMessages should handle null additionalMessages`() { - val messagesMap = mapOf("additionalMessages" to null) - - browserConnector.parseFindingsMessages(messagesMap) + browserConnector.parseFindingsMessages( + """ + { + "additionalMessages": null + } + """.trimIndent() + ) verify(mockCodeScanManager, never()).addOnDemandIssues(any(), any(), any()) } @Test - fun `parseFindingsMessages should filter messages with CODE_REVIEW_FINDINGS_SUFFIX`() { - val findingsMessage = mapOf( - "messageId" to "test_codeReviewFindings", - "body" to """[{"filePath": "/test/file.kt", "issues": []}]""" - ) - val otherMessage = mapOf( - "messageId" to "other_message", - "body" to "other content" + fun `parseFindingsMessages should handle empty additionalMessages`() { + browserConnector.parseFindingsMessages( + """ + { + "additionalMessages": [] + } + """.trimIndent() ) - val additionalMessages = mutableListOf>(findingsMessage, otherMessage) - val messagesMap = mapOf("additionalMessages" to additionalMessages) - browserConnector.parseFindingsMessages(messagesMap) + verify(mockCodeScanManager, never()).addOnDemandIssues(any(), any(), any()) + } - assertThat(additionalMessages.size == 1) - assertThat(additionalMessages[0] == otherMessage) + @Test + fun `parseFindingsMessages should filter messages with CODE_REVIEW_FINDINGS_SUFFIX`() { + val findingsMessage = """ + { + "additionalMessages": [ + { + "messageId": "test_codeReviewFindings", + "body": [{"filePath": "/test/file.kt", "issues": []}] + }, + { + "messageId": "other_message", + "body": "other content" + } + ] + } + """.trimIndent() + + assertThat(browserConnector.deserializeFindings(findingsMessage)) + .singleElement() + .satisfiesKt { + assertThat(it.messageId).isEqualTo("test_codeReviewFindings") + assertThat(it.body).singleElement() + .satisfiesKt { finding -> + assertThat(finding.filePath).isEqualTo("/test/file.kt") + } + } } @Test fun `parseFindingsMessages should filter messages with DISPLAY_FINDINGS_SUFFIX`() { - val findingsMessage = mapOf( - "messageId" to "test_displayFindings", - "body" to """[{"filePath": "/test/file.kt", "issues": []}]""" - ) - val additionalMessages = mutableListOf>(findingsMessage) - val messagesMap = mapOf("additionalMessages" to additionalMessages) - - browserConnector.parseFindingsMessages(messagesMap) - - assertThat(additionalMessages).isEmpty() + val findingsMessage = """ + { + "additionalMessages": [ + { + "messageId": "test_displayFindings", + "body": [{"filePath": "/test/file.kt", "issues": []}] + }, + { + "messageId": "other_message", + "body": "other content" + } + ] + } + """.trimIndent() + + assertThat(browserConnector.deserializeFindings(findingsMessage)) + .singleElement() + .satisfiesKt { + assertThat(it.messageId).isEqualTo("test_displayFindings") + assertThat(it.body).singleElement() + .satisfiesKt { finding -> + assertThat(finding.filePath).isEqualTo("/test/file.kt") + } + } } @Test @@ -123,7 +162,7 @@ class BrowserConnectorTest : AmazonQTestBase() { whenever(mockFileDocumentManager.getDocument(mockVirtualFile)) doReturn mockDocument whenever(mockCodeScanManager.isIgnoredIssue(any(), any(), any(), any())) doReturn false - val issue = BrowserConnector.FlareCodeScanIssue( + val issue = FlareCodeScanIssue( startLine = 1, endLine = 1, comment = "Test comment", title = "Test Issue", description = Description("Test description", "Test text"), detectorId = "test-detector", detectorName = "Test Detector", findingId = "test-finding-id", ruleId = "test-rule", @@ -134,15 +173,23 @@ class BrowserConnectorTest : AmazonQTestBase() { filePath = "/test/file.kt", findingContext = "test context" ) - val aggregatedIssue = BrowserConnector.AggregatedCodeScanIssue("/test/file.kt", listOf(issue)) - val findingsMessage = mapOf( - "messageId" to "test_codeReviewFindings", - "body" to jacksonObjectMapper().writeValueAsString(listOf(aggregatedIssue)) - ) - val additionalMessages = mutableListOf>(findingsMessage) - val messagesMap = mapOf("additionalMessages" to additionalMessages) - - browserConnector.parseFindingsMessages(messagesMap) + val aggregatedIssue = AggregatedCodeScanIssue("/test/file.kt", listOf(issue)) + val findingsMessage = """ + { + "additionalMessages": [ + { + "messageId": "test_codeReviewFindings", + "body": ${jacksonObjectMapper().writeValueAsString(listOf(aggregatedIssue))} + }, + { + "messageId": "other_message", + "body": "other content" + } + ] + } + """.trimIndent() + + browserConnector.parseFindingsMessages(findingsMessage) val issuesCaptor = argumentCaptor>() verify(mockCodeScanManager).addOnDemandIssues( @@ -151,7 +198,6 @@ class BrowserConnectorTest : AmazonQTestBase() { eq(CodeWhispererConstants.CodeAnalysisScope.AGENTIC) ) - assertThat(additionalMessages).isEmpty() assertThat(issuesCaptor.firstValue.isNotEmpty()) assertThat(issuesCaptor.firstValue[0].title == "Test Issue") } @@ -166,7 +212,7 @@ class BrowserConnectorTest : AmazonQTestBase() { localFileSystemMock.`when` { LocalFileSystem.getInstance() } doReturn mockLocalFileSystem whenever(mockLocalFileSystem.findFileByIoFile(any())) doReturn mockDirectoryFile - val issue = BrowserConnector.FlareCodeScanIssue( + val issue = FlareCodeScanIssue( startLine = 1, endLine = 1, comment = null, title = "Test Issue", description = Description("Test description", "Test text"), detectorId = "test-detector", detectorName = "Test Detector", findingId = "test-finding-id", ruleId = null, @@ -176,39 +222,47 @@ class BrowserConnectorTest : AmazonQTestBase() { filePath = "/test/directory", findingContext = "test context" ) - val aggregatedIssue = BrowserConnector.AggregatedCodeScanIssue("/test/directory", listOf(issue)) - val findingsMessage = mapOf( - "messageId" to "test_displayFindings", - "body" to jacksonObjectMapper().writeValueAsString(listOf(aggregatedIssue)) - ) - val additionalMessages = mutableListOf>(findingsMessage) - val messagesMap = mapOf("additionalMessages" to additionalMessages) + val aggregatedIssue = AggregatedCodeScanIssue("/test/directory", listOf(issue)) + val findingsMessage = """ + { + "additionalMessages": [ + { + "messageId": "test_displayFindings", + "body": ${jacksonObjectMapper().writeValueAsString(listOf(aggregatedIssue))} + }, + { + "messageId": "other_message", + "body": "other content" + } + ] + } + """.trimIndent() - browserConnector.parseFindingsMessages(messagesMap) + browserConnector.parseFindingsMessages(findingsMessage) - val issuesCaptor = argumentCaptor>() - verify(mockCodeScanManager).addOnDemandIssues( - issuesCaptor.capture(), + verify(mockCodeScanManager, never()).addOnDemandIssues( + any(), any(), eq(CodeWhispererConstants.CodeAnalysisScope.AGENTIC) ) - - assertThat(issuesCaptor.firstValue.isEmpty()) - assertThat(additionalMessages).isEmpty() } } @Test fun `parseFindingsMessages should handle invalid JSON gracefully`() { - val findingsMessage = mapOf( - "messageId" to "test_codeReviewFindings", - "body" to "invalid json" - ) - val additionalMessages = mutableListOf>(findingsMessage) - val messagesMap = mapOf("additionalMessages" to additionalMessages) + val findingsMessage = """ + { + "additionalMessages": [ + { + "messageId": "test_codeReviewFindings", + "body": "invalid json" + } + ] + } + """.trimIndent() - browserConnector.parseFindingsMessages(messagesMap) + browserConnector.parseFindingsMessages(findingsMessage) - assertThat(additionalMessages).isEmpty() + verify(mockCodeScanManager, never()).addOnDemandIssues(any(), any(), any()) } }