diff --git a/.changes/next-release/.changes/next-release/bugfix-21701bb3-5189-474e-868b-9ec46ecde6ee.json b/.changes/next-release/.changes/next-release/bugfix-21701bb3-5189-474e-868b-9ec46ecde6ee.json deleted file mode 100644 index 8d2e0694ab..0000000000 --- a/.changes/next-release/.changes/next-release/bugfix-21701bb3-5189-474e-868b-9ec46ecde6ee.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "bugfix", - "description" : "Feature Development: Fix file rejections for files outside of src/" -} diff --git a/.changes/next-release/feature-44636712-ede6-4e2a-b4d6-ca2b98da003d.json b/.changes/next-release/feature-44636712-ede6-4e2a-b4d6-ca2b98da003d.json new file mode 100644 index 0000000000..587728529e --- /dev/null +++ b/.changes/next-release/feature-44636712-ede6-4e2a-b4d6-ca2b98da003d.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "`/review` in Q chat to scan your code for vulnerabilities and quality issues, and generate fixes" +} diff --git a/.changes/next-release/feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json b/.changes/next-release/feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json new file mode 100644 index 0000000000..695a952916 --- /dev/null +++ b/.changes/next-release/feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "`/test` in Q chat to generate unit tests for java and python" +} diff --git a/.changes/next-release/feature-8fc6f4c9-6976-4a4a-88f0-bd4e6081a4b9.json b/.changes/next-release/feature-8fc6f4c9-6976-4a4a-88f0-bd4e6081a4b9.json new file mode 100644 index 0000000000..e40d4e4416 --- /dev/null +++ b/.changes/next-release/feature-8fc6f4c9-6976-4a4a-88f0-bd4e6081a4b9.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "`/doc` in Q chat to generate and update documentation for your project" +} diff --git a/.run/Run Amazon Q - Ultimate [2024.1].run.xml b/.run/Run Amazon Q - Ultimate [2024.1].run.xml index 4296908019..bae51e0465 100644 --- a/.run/Run Amazon Q - Ultimate [2024.1].run.xml +++ b/.run/Run Amazon Q - Ultimate [2024.1].run.xml @@ -22,4 +22,4 @@ false - \ No newline at end of file + diff --git a/buildSrc/src/main/kotlin/toolkit-generate-sdks.gradle.kts b/buildSrc/src/main/kotlin/toolkit-generate-sdks.gradle.kts index b051f7e913..314ff99426 100644 --- a/buildSrc/src/main/kotlin/toolkit-generate-sdks.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-generate-sdks.gradle.kts @@ -38,6 +38,8 @@ java { tasks.withType().configureEach { options.encoding = "UTF-8" + sourceCompatibility = "17" + targetCompatibility = "17" } val generateTask = tasks.register("generateSdks") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de7d410a66..49574a7802 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ mockitoKotlin = "5.4.0" mockk = "1.13.10" nimbus-jose-jwt = "9.40" node-gradle = "7.0.2" -telemetryGenerator = "1.0.278" +telemetryGenerator = "1.0.284" testLogger = "4.0.0" testRetry = "1.5.10" # test-only; platform provides slf4j transitively at runtime diff --git a/plugins/amazonq/chat/jetbrains-community/resources/META-INF/plugin-chat.xml b/plugins/amazonq/chat/jetbrains-community/resources/META-INF/plugin-chat.xml index d08356231f..6949f4ba39 100644 --- a/plugins/amazonq/chat/jetbrains-community/resources/META-INF/plugin-chat.xml +++ b/plugins/amazonq/chat/jetbrains-community/resources/META-INF/plugin-chat.xml @@ -26,6 +26,9 @@ + + + diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/clients/AmazonQCodeGenerateClient.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/clients/AmazonQCodeGenerateClient.kt new file mode 100644 index 0000000000..7741758d49 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/clients/AmazonQCodeGenerateClient.kt @@ -0,0 +1,153 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.common.clients + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import software.amazon.awssdk.services.codewhispererruntime.CodeWhispererRuntimeClient +import software.amazon.awssdk.services.codewhispererruntime.model.ArtifactType +import software.amazon.awssdk.services.codewhispererruntime.model.ContentChecksumType +import software.amazon.awssdk.services.codewhispererruntime.model.CreateTaskAssistConversationRequest +import software.amazon.awssdk.services.codewhispererruntime.model.CreateTaskAssistConversationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.CreateUploadUrlResponse +import software.amazon.awssdk.services.codewhispererruntime.model.DocGenerationEvent +import software.amazon.awssdk.services.codewhispererruntime.model.GetTaskAssistCodeGenerationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.IdeCategory +import software.amazon.awssdk.services.codewhispererruntime.model.OperatingSystem +import software.amazon.awssdk.services.codewhispererruntime.model.OptOutPreference +import software.amazon.awssdk.services.codewhispererruntime.model.SendTelemetryEventResponse +import software.amazon.awssdk.services.codewhispererruntime.model.StartTaskAssistCodeGenerationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.TaskAssistPlanningUploadContext +import software.amazon.awssdk.services.codewhispererruntime.model.UploadContext +import software.amazon.awssdk.services.codewhispererruntime.model.UploadIntent +import software.amazon.awssdk.services.codewhispererruntime.model.UserContext +import software.amazon.awssdk.services.codewhispererstreaming.model.ExportIntent +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.common.session.Intent +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.amazonq.clients.AmazonQStreamingClient +import software.aws.toolkits.jetbrains.services.amazonqDoc.FEATURE_EVALUATION_PRODUCT_NAME +import software.aws.toolkits.jetbrains.services.codemodernizer.utils.calculateTotalLatency +import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata +import software.aws.toolkits.jetbrains.settings.AwsSettings +import java.time.Instant +import software.amazon.awssdk.services.codewhispererruntime.model.ChatTriggerType as SyncChatTriggerType + +@Service(Service.Level.PROJECT) +class AmazonQCodeGenerateClient(private val project: Project) { + private fun getTelemetryOptOutPreference() = + if (AwsSettings.getInstance().isTelemetryEnabled) { + OptOutPreference.OPTIN + } else { + OptOutPreference.OPTOUT + } + + private val docGenerationUserContext = ClientMetadata.getDefault().let { + val osForFeatureDev: OperatingSystem = + when { + SystemInfo.isWindows -> OperatingSystem.WINDOWS + SystemInfo.isMac -> OperatingSystem.MAC + // For now, categorize everything else as "Linux" (Linux/FreeBSD/Solaris/etc.) + else -> OperatingSystem.LINUX + } + + UserContext.builder() + .ideCategory(IdeCategory.JETBRAINS) + .operatingSystem(osForFeatureDev) + .product(FEATURE_EVALUATION_PRODUCT_NAME) + .clientId(it.clientId) + .ideVersion(it.awsVersion) + .build() + } + + fun connection() = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) + ?: error("Attempted to use connection while one does not exist") + + fun bearerClient() = connection().getConnectionSettings().awsClient() + + private val amazonQStreamingClient + get() = AmazonQStreamingClient.getInstance(project) + + fun sendDocGenerationTelemetryEvent( + docGenerationEvent: DocGenerationEvent, + ): SendTelemetryEventResponse = + bearerClient().sendTelemetryEvent { requestBuilder -> + requestBuilder.telemetryEvent { telemetryEventBuilder -> + telemetryEventBuilder.docGenerationEvent(docGenerationEvent) + } + requestBuilder.optOutPreference(getTelemetryOptOutPreference()) + requestBuilder.userContext(docGenerationUserContext) + } + + fun createTaskAssistConversation(): CreateTaskAssistConversationResponse = bearerClient().createTaskAssistConversation( + CreateTaskAssistConversationRequest.builder().build() + ) + + fun createTaskAssistUploadUrl(conversationId: String, contentChecksumSha256: String, contentLength: Long): CreateUploadUrlResponse = + bearerClient().createUploadUrl { + it.contentChecksumType(ContentChecksumType.SHA_256) + .contentChecksum(contentChecksumSha256) + .contentLength(contentLength) + .artifactType(ArtifactType.SOURCE_CODE) + .uploadIntent(UploadIntent.TASK_ASSIST_PLANNING) + .uploadContext( + UploadContext.builder() + .taskAssistPlanningUploadContext( + TaskAssistPlanningUploadContext.builder() + .conversationId(conversationId) + .build() + ) + .build() + ) + } + + fun startTaskAssistCodeGeneration(conversationId: String, uploadId: String, userMessage: String, intent: Intent): StartTaskAssistCodeGenerationResponse = + bearerClient() + .startTaskAssistCodeGeneration { request -> + request + .conversationState { + it + .conversationId(conversationId) + .chatTriggerType(SyncChatTriggerType.MANUAL) + .currentMessage { cm -> cm.userInputMessage { um -> um.content(userMessage) } } + } + .workspaceState { + it + .programmingLanguage { pl -> pl.languageName("javascript") } + .uploadId(uploadId) + } + .intent(intent.name) + } + + fun getTaskAssistCodeGeneration(conversationId: String, codeGenerationId: String): GetTaskAssistCodeGenerationResponse = bearerClient() + .getTaskAssistCodeGeneration { + it + .conversationId(conversationId) + .codeGenerationId(codeGenerationId) + } + + suspend fun exportTaskAssistResultArchive(conversationId: String): MutableList = amazonQStreamingClient.exportResultArchive( + conversationId, + ExportIntent.TASK_ASSIST, + null, + { e -> + LOG.error(e) { "TaskAssist - ExportResultArchive stream exportId=$conversationId exportIntent=${ExportIntent.TASK_ASSIST} Failed: ${e.message} " } + }, + { startTime -> + LOG.info { "TaskAssist - ExportResultArchive latency: ${calculateTotalLatency(startTime, Instant.now())}" } + } + ) + + companion object { + private val LOG = getLogger() + + fun getInstance(project: Project) = project.service() + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/ConversationNotStartedState.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/ConversationNotStartedState.kt new file mode 100644 index 0000000000..ad300449df --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/ConversationNotStartedState.kt @@ -0,0 +1,21 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.common.session + +import software.aws.toolkits.jetbrains.services.amazonqDoc.session.SessionStateInteraction +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.SessionStateAction +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.SessionStatePhase +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.CancellationTokenSource + +class ConversationNotStartedState( + override var approach: String, + override val tabID: String, + override var token: CancellationTokenSource?, +) : SessionState { + override val phase = SessionStatePhase.INIT + + override suspend fun interact(action: SessionStateAction): SessionStateInteraction { + error("Illegal transition between states, restart the conversation") + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionState.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionState.kt new file mode 100644 index 0000000000..1585096382 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionState.kt @@ -0,0 +1,17 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.common.session + +import software.aws.toolkits.jetbrains.services.amazonqDoc.session.SessionStateInteraction +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.SessionStateAction +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.SessionStatePhase +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.CancellationTokenSource + +interface SessionState { + val tabID: String + val phase: SessionStatePhase? + var token: CancellationTokenSource? + var approach: String + suspend fun interact(action: SessionStateAction): SessionStateInteraction +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionStateTypes.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionStateTypes.kt new file mode 100644 index 0000000000..3b50f10ca9 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionStateTypes.kt @@ -0,0 +1,24 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.common.session + +import software.aws.toolkits.jetbrains.common.util.AmazonQCodeGenService +import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext + +open class SessionStateConfig( + open val conversationId: String, + open val repoContext: FeatureDevSessionContext, + open val amazonQCodeGenService: AmazonQCodeGenService, +) + +data class SessionStateConfigData( + override val conversationId: String, + override val repoContext: FeatureDevSessionContext, + override val amazonQCodeGenService: AmazonQCodeGenService, +) : SessionStateConfig(conversationId, repoContext, amazonQCodeGenService) + +enum class Intent { + DEV, + DOC, +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/util/AmazonQCodeGenService.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/util/AmazonQCodeGenService.kt new file mode 100644 index 0000000000..afe9da01b2 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/util/AmazonQCodeGenService.kt @@ -0,0 +1,214 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.common.util + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException +import software.amazon.awssdk.services.codewhispererruntime.model.CreateUploadUrlResponse +import software.amazon.awssdk.services.codewhispererruntime.model.GetTaskAssistCodeGenerationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.ServiceQuotaExceededException +import software.amazon.awssdk.services.codewhispererruntime.model.StartTaskAssistCodeGenerationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException +import software.amazon.awssdk.services.codewhispererruntime.model.ValidationException +import software.amazon.awssdk.services.codewhispererstreaming.model.CodeWhispererStreamingException +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.common.clients.AmazonQCodeGenerateClient +import software.aws.toolkits.jetbrains.common.session.Intent +import software.aws.toolkits.jetbrains.services.amazonqDoc.session.DocGenerationStreamResult +import software.aws.toolkits.jetbrains.services.amazonqDoc.session.ExportDocTaskAssistResultArchiveStreamResult +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.CodeIterationLimitException +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.ContentLengthException +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.ExportParseException +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FEATURE_NAME +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FeatureDevException +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FeatureDevOperation +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.MonthlyConversationLimitError +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.ZipFileCorruptedException +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl +import software.aws.toolkits.telemetry.AmazonqTelemetry +import software.aws.toolkits.telemetry.MetricResult + +class AmazonQCodeGenService(val proxyClient: AmazonQCodeGenerateClient, val project: Project) { + fun createConversation(): String { + val startTime = System.currentTimeMillis() + var failureReason: String? = null + var failureReasonDesc: String? = null + var result: MetricResult = MetricResult.Succeeded + var conversationId: String? = null + try { + logger.debug { "Executing createTaskAssistConversation" } + val taskAssistConversationResult = proxyClient.createTaskAssistConversation() + conversationId = taskAssistConversationResult.conversationId() + logger.debug { + "$FEATURE_NAME: Created conversation: {conversationId: $conversationId, requestId: ${ + taskAssistConversationResult.responseMetadata().requestId() + }" + } + + return conversationId + } catch (e: Exception) { + logger.warn(e) { "$FEATURE_NAME: Failed to start conversation: ${e.message}" } + result = MetricResult.Failed + failureReason = e.javaClass.simpleName + if (e is FeatureDevException) { + failureReason = e.reason() + failureReasonDesc = e.reasonDesc() + } + var errMssg = e.message + if (e is CodeWhispererRuntimeException) { + errMssg = e.awsErrorDetails().errorMessage() + logger.warn(e) { "Start conversation failed for request: ${e.requestId()}" } + + if (e is ServiceQuotaExceededException) { + throw MonthlyConversationLimitError(errMssg, operation = FeatureDevOperation.CreateConversation.toString(), desc = null, cause = e.cause) + } + } + throw FeatureDevException(errMssg, operation = FeatureDevOperation.CreateConversation.toString(), desc = null, e.cause) + } finally { + AmazonqTelemetry.startConversationInvoke( + amazonqConversationId = conversationId, + result = result, + reason = failureReason, + reasonDesc = failureReasonDesc, + duration = (System.currentTimeMillis() - startTime).toDouble(), + credentialStartUrl = getStartUrl(project = this.project), + ) + } + } + + fun createUploadUrl(conversationId: String, contentChecksumSha256: String, contentLength: Long, uploadId: String): + CreateUploadUrlResponse { + try { + logger.debug { "Executing createUploadUrl with conversationId $conversationId" } + val uploadUrlResponse = proxyClient.createTaskAssistUploadUrl( + conversationId, + contentChecksumSha256, + contentLength, + ) + logger.debug { + "$FEATURE_NAME: Created upload url: {uploadId: $uploadId, requestId: ${uploadUrlResponse.responseMetadata().requestId()}}" + } + return uploadUrlResponse + } catch (e: Exception) { + logger.warn(e) { "$FEATURE_NAME: Failed to generate presigned url: ${e.message}" } + + var errMssg = e.message + if (e is CodeWhispererRuntimeException) { + errMssg = e.awsErrorDetails().errorMessage() + logger.warn(e) { "Create UploadUrl failed for request: ${e.requestId()}" } + + if (e is ValidationException && e.message?.contains("Invalid contentLength") == true) { + throw ContentLengthException(operation = FeatureDevOperation.CreateUploadUrl.toString(), desc = null, cause = e.cause) + } + } + throw FeatureDevException(errMssg, operation = FeatureDevOperation.CreateUploadUrl.toString(), desc = null, e.cause) + } + } + + open fun startTaskAssistCodeGeneration(conversationId: String, uploadId: String, message: String, intent: Intent): + StartTaskAssistCodeGenerationResponse { + try { + logger.debug { "Executing startTaskAssistCodeGeneration with conversationId: $conversationId , uploadId: $uploadId" } + val startCodeGenerationResponse = proxyClient.startTaskAssistCodeGeneration( + conversationId, + uploadId, + message, + intent + ) + + logger.debug { "$FEATURE_NAME: Started code generation with requestId: ${startCodeGenerationResponse.responseMetadata().requestId()}" } + return startCodeGenerationResponse + } catch (e: Exception) { + logger.warn(e) { "$FEATURE_NAME: Failed to execute startTaskAssistCodeGeneration ${e.message}" } + + var errMssg = e.message + if (e is CodeWhispererRuntimeException) { + errMssg = e.awsErrorDetails().errorMessage() + logger.warn(e) { "StartTaskAssistCodeGeneration failed for request: ${e.requestId()}" } + + // API Front-end will throw Throttling if conversation limit is reached. API Front-end monitors StartCodeGeneration for throttling + if (e is software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException && + e.message?.contains("StartTaskAssistCodeGeneration reached for this month.") == true + ) { + throw MonthlyConversationLimitError(errMssg, operation = FeatureDevOperation.StartTaskAssistCodeGeneration.toString(), desc = null, e.cause) + } + + if (e is ServiceQuotaExceededException || ( + e is ThrottlingException && e.message?.contains( + "limit for number of iterations on a code generation" + ) == true + ) + ) { + throw CodeIterationLimitException(operation = FeatureDevOperation.StartTaskAssistCodeGeneration.toString(), desc = null, e.cause) + } else if (e is ValidationException && e.message?.contains("repo size is exceeding the limits") == true) { + throw ContentLengthException(operation = FeatureDevOperation.StartTaskAssistCodeGeneration.toString(), desc = null, cause = e.cause) + } else if (e is ValidationException && e.message?.contains("zipped file is corrupted") == true) { + throw ZipFileCorruptedException(operation = FeatureDevOperation.StartTaskAssistCodeGeneration.toString(), desc = null, e.cause) + } + } + throw FeatureDevException(errMssg, operation = FeatureDevOperation.StartTaskAssistCodeGeneration.toString(), desc = null, e.cause) + } + } + + fun getTaskAssistCodeGeneration(conversationId: String, codeGenerationId: String): GetTaskAssistCodeGenerationResponse { + try { + logger.debug { "Executing GetTaskAssistCodeGeneration with conversationId: $conversationId , codeGenerationId: $codeGenerationId" } + val getCodeGenerationResponse = proxyClient.getTaskAssistCodeGeneration(conversationId, codeGenerationId) + + logger.debug { + "$FEATURE_NAME: Received code generation status $getCodeGenerationResponse with requestId ${ + getCodeGenerationResponse.responseMetadata() + .requestId() + }" + } + return getCodeGenerationResponse + } catch (e: Exception) { + logger.warn(e) { "$FEATURE_NAME: Failed to execute GetTaskAssistCodeGeneration ${e.message}" } + + var errMssg = e.message + if (e is CodeWhispererRuntimeException) { + errMssg = e.awsErrorDetails().errorMessage() + logger.warn(e) { "GetTaskAssistCodeGeneration failed for request: ${e.requestId()}" } + } + throw FeatureDevException(errMssg, operation = FeatureDevOperation.GetTaskAssistCodeGeneration.toString(), desc = null, e.cause) + } + } + + suspend fun exportTaskAssistArchiveResult(conversationId: String): DocGenerationStreamResult { + val exportResponse: MutableList + try { + exportResponse = proxyClient.exportTaskAssistResultArchive(conversationId) + logger.debug { "$FEATURE_NAME: Received export task assist result archive response" } + } catch (e: Exception) { + logger.warn(e) { "$FEATURE_NAME: Failed to export archive result: ${e.message}" } + + var errMssg = e.message + if (e is CodeWhispererStreamingException) { + errMssg = e.awsErrorDetails().errorMessage() + logger.warn(e) { "ExportTaskAssistArchiveResult failed for request: ${e.requestId()}" } + } + throw FeatureDevException(errMssg, operation = FeatureDevOperation.ExportTaskAssistArchiveResult.toString(), desc = null, e.cause) + } + + val parsedResult: ExportDocTaskAssistResultArchiveStreamResult + try { + val result = exportResponse.reduce { acc, next -> acc + next } // To map the result it is needed to combine the full byte array + parsedResult = jacksonObjectMapper().readValue(result) + } catch (e: Exception) { + logger.error(e) { "Failed to parse downloaded code results" } + throw ExportParseException(operation = FeatureDevOperation.ExportTaskAssistArchiveResult.toString(), desc = null, e.cause) + } + + return parsedResult.codeGenerationResult + } + + companion object { + private val logger = getLogger() + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/util/FileUtils.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/util/FileUtils.kt new file mode 100644 index 0000000000..6cf8cc2476 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/util/FileUtils.kt @@ -0,0 +1,82 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.common.util + +import com.github.difflib.DiffUtils +import com.github.difflib.patch.DeltaType +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.CharsetToolkit +import com.intellij.openapi.vfs.VirtualFile +import java.io.File +import java.nio.charset.Charset +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.writeBytes + +fun resolveAndCreateOrUpdateFile(projectRootPath: Path, relativeFilePath: String, fileContent: String) { + val filePath = projectRootPath.resolve(relativeFilePath) + filePath.parent.createDirectories() // Create directories if needed + filePath.writeBytes(fileContent.toByteArray(Charsets.UTF_8)) +} + +fun resolveAndDeleteFile(projectRootPath: Path, relativePath: String) { + val filePath = projectRootPath.resolve(relativePath) + filePath.deleteIfExists() +} + +fun selectFolder(project: Project, openOn: VirtualFile): VirtualFile? { + val fileChooserDescriptor = FileChooserDescriptorFactory.createSingleFolderDescriptor() + return FileChooser.chooseFile(fileChooserDescriptor, project, openOn) +} + +fun readFileToString(file: File): String { + val charsetToolkit = CharsetToolkit(file.readBytes(), Charset.forName("UTF-8"), false) + val charset = charsetToolkit.guessEncoding(4096) + return file.readText(charset) +} + +/** + * Calculates the number of added characters and lines between existing content and LLM response + * + * @param existingContent The original text content before changes + * @param llmResponse The new text content from the LLM + * @return A Map containing: + * - "addedChars": Total number of new characters added + * - "addedLines": Total number of new lines added + */ +data class DiffResult(val addedChars: Int, val addedLines: Int) + +fun getDiffCharsAndLines( + existingContent: String, + llmResponse: String, +): DiffResult { + var addedChars = 0 + var addedLines = 0 + + val existingLines = existingContent.lines() + val llmLines = llmResponse.lines() + + val patch = DiffUtils.diff(existingLines, llmLines) + + for (delta in patch.deltas) { + when (delta.type) { + DeltaType.INSERT -> { + addedChars += delta.target.lines.sumOf { it.length } + addedLines += delta.target.lines.size + } + + DeltaType.CHANGE -> { + addedChars += delta.target.lines.sumOf { it.length } + addedLines += delta.target.lines.size + } + + else -> {} // Do nothing for DELETE + } + } + + return DiffResult(addedChars, addedLines) +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QOpenPanelAction.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QOpenPanelAction.kt index 95fbabd3a4..de01e99bad 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QOpenPanelAction.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QOpenPanelAction.kt @@ -9,6 +9,8 @@ import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.wm.ToolWindowManager import icons.AwsIcons import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AMAZON_Q_WINDOW_ID +import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindow +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.runScanKey import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.UiTelemetry @@ -19,5 +21,8 @@ class QOpenPanelAction : AnAction(message("action.q.openchat.text"), null, AwsIc val project = e.getRequiredData(CommonDataKeys.PROJECT) UiTelemetry.click(project, "q_openChat") ToolWindowManager.getInstance(project).getToolWindow(AMAZON_Q_WINDOW_ID)?.activate(null, true) + if (e.getData(runScanKey) == true) { + AmazonQToolWindow.openScanTab(project) + } } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt index e5673cc6da..7235a9c370 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt @@ -27,6 +27,10 @@ import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPag import software.aws.toolkits.jetbrains.services.amazonq.webview.BrowserConnector import software.aws.toolkits.jetbrains.services.amazonq.webview.FqnWebviewAdapter import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.EditorThemeAdapter +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.auth.isCodeScanAvailable +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.runCodeScanMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeTest.auth.isCodeTestAvailable +import software.aws.toolkits.jetbrains.services.amazonqDoc.auth.isDocAvailable import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.auth.isFeatureDevAvailable import software.aws.toolkits.jetbrains.services.codemodernizer.utils.isCodeTransformAvailable import javax.swing.JComponent @@ -72,6 +76,14 @@ class AmazonQToolWindow private constructor( } } + private fun sendMessageAppToUi(message: AmazonQMessage, tabType: String) { + appConnections.filter { it.app.tabTypes.contains(tabType) }.forEach { + scope.launch { + it.messagesFromAppToUi.publish(message) + } + } + } + private fun initConnections() { val apps = appSource.getApps(project) apps.forEach { app -> @@ -110,7 +122,10 @@ class AmazonQToolWindow private constructor( chatBrowser.init( isCodeTransformAvailable = isCodeTransformAvailable(project), - isFeatureDevAvailable = isFeatureDevAvailable(project) + isFeatureDevAvailable = isFeatureDevAvailable(project), + isCodeScanAvailable = isCodeScanAvailable(project), + isCodeTestAvailable = isCodeTestAvailable(project), + isDocAvailable = isDocAvailable(project) ) scope.launch { @@ -134,17 +149,25 @@ class AmazonQToolWindow private constructor( companion object { fun getInstance(project: Project): AmazonQToolWindow = project.service() + private fun showChatWindow(project: Project) = runInEdt { + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID) + toolWindow?.show() + } + fun getStarted(project: Project) { // Make sure the window is shown - runInEdt { - val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID) - toolWindow?.show() - } + showChatWindow(project) // Send the interaction message val window = getInstance(project) window.sendMessage(OnboardingPageInteraction(OnboardingPageInteractionType.CwcButtonClick), "cwc") } + + fun openScanTab(project: Project) { + showChatWindow(project) + val window = getInstance(project) + window.sendMessageAppToUi(runCodeScanMessage, tabType = "codescan") + } } override fun dispose() { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt index 9a4f30222d..9bd49041e4 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt @@ -18,7 +18,13 @@ class Browser(parent: Disposable) : Disposable { val receiveMessageQuery = JBCefJSQuery.create(jcefBrowser) - fun init(isCodeTransformAvailable: Boolean, isFeatureDevAvailable: Boolean) { + fun init( + isCodeTransformAvailable: Boolean, + isFeatureDevAvailable: Boolean, + isDocAvailable: Boolean, + isCodeScanAvailable: Boolean, + isCodeTestAvailable: Boolean, + ) { // register the scheme handler to route http://mynah/ URIs to the resources/assets directory on classpath CefApp.getInstance() .registerSchemeHandlerFactory( @@ -27,7 +33,7 @@ class Browser(parent: Disposable) : Disposable { AssetResourceHandler.AssetResourceHandlerFactory(), ) - loadWebView(isCodeTransformAvailable, isFeatureDevAvailable) + loadWebView(isCodeTransformAvailable, isFeatureDevAvailable, isDocAvailable, isCodeScanAvailable, isCodeTestAvailable) } override fun dispose() { @@ -42,19 +48,31 @@ class Browser(parent: Disposable) : Disposable { .executeJavaScript("window.postMessage(JSON.stringify($message))", jcefBrowser.cefBrowser.url, 0) // Load the chat web app into the jcefBrowser - private fun loadWebView(isCodeTransformAvailable: Boolean, isFeatureDevAvailable: Boolean) { + private fun loadWebView( + isCodeTransformAvailable: Boolean, + isFeatureDevAvailable: Boolean, + isDocAvailable: Boolean, + isCodeScanAvailable: Boolean, + isCodeTestAvailable: Boolean, + ) { // setup empty state. The message request handlers use this for storing state // that's persistent between page loads. jcefBrowser.setProperty("state", "") // load the web app - jcefBrowser.loadHTML(getWebviewHTML(isCodeTransformAvailable, isFeatureDevAvailable)) + jcefBrowser.loadHTML(getWebviewHTML(isCodeTransformAvailable, isFeatureDevAvailable, isDocAvailable, isCodeScanAvailable, isCodeTestAvailable)) } /** * Generate index.html for the web view * @return HTML source */ - private fun getWebviewHTML(isCodeTransformAvailable: Boolean, isFeatureDevAvailable: Boolean): String { + private fun getWebviewHTML( + isCodeTransformAvailable: Boolean, + isFeatureDevAvailable: Boolean, + isDocAvailable: Boolean, + isCodeScanAvailable: Boolean, + isCodeTestAvailable: Boolean, + ): String { val postMessageToJavaJsCode = receiveMessageQuery.inject("JSON.stringify(message)") val jsScripts = """ @@ -69,6 +87,9 @@ class Browser(parent: Disposable) : Disposable { }, $isFeatureDevAvailable, // whether /dev is available $isCodeTransformAvailable, // whether /transform is available + $isDocAvailable, // whether /doc is available + $isCodeScanAvailable, // whether /scan is available + $isCodeTestAvailable // whether /test is available ); } 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 b2571123b7..6fac4c8940 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 @@ -3,6 +3,7 @@ package software.aws.toolkits.jetbrains.services.amazonq.webview +import com.intellij.ide.BrowserUtil import com.intellij.ui.jcef.JBCefJSQuery.Response import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.awaitClose @@ -21,6 +22,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.util.command import software.aws.toolkits.jetbrains.services.amazonq.util.tabType import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.AmazonQTheme import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.ThemeBrowserAdapter +import software.aws.toolkits.telemetry.Telemetry import java.util.function.Function class BrowserConnector( @@ -37,9 +39,31 @@ class BrowserConnector( addMessageHook(browser) .onEach { json -> val node = serializer.toNode(json) - if (node.command == "ui-is-ready") { - uiReady.complete(true) + when (node.command) { + "ui-is-ready" -> uiReady.complete(true) + + // some weird issue preventing deserialization from working + "open-user-guide" -> { + BrowserUtil.browse(node.get("userGuideLink").asText()) + } + "send-telemetry" -> { + val source = node.get("source") + val module = node.get("module") + val trigger = node.get("trigger") + + if (source != null) { + Telemetry.ui.click.use { + it.elementId(source.asText()) + } + } else if (module != null && trigger != null) { + Telemetry.toolkit.openModule.use { + it.module(module.asText()) + it.source(trigger.asText()) + } + } + } } + val tabType = node.tabType ?: return@onEach connections.filter { connection -> connection.app.tabTypes.contains(tabType) }.forEach { connection -> launch { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatApp.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatApp.kt new file mode 100644 index 0000000000..166223d623 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatApp.kt @@ -0,0 +1,175 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQApp +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext +import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.auth.isCodeScanAvailable +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands.CodeScanActionMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands.CodeScanMessageListener +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.controller.CodeScanChatController +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.AuthenticationNeededExceptionMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.AuthenticationUpdateMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.CODE_SCAN_TAB_NAME +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.IncomingCodeScanMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.storage.ChatSessionStorage +import software.aws.toolkits.jetbrains.services.amazonqCodeTest.auth.isCodeTestAvailable +import software.aws.toolkits.jetbrains.services.amazonqDoc.auth.isDocAvailable +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.auth.isFeatureDevAvailable +import software.aws.toolkits.jetbrains.services.codemodernizer.utils.isCodeTransformAvailable +import java.util.concurrent.atomic.AtomicBoolean + +private enum class CodeScanMessageTypes(val type: String) { + ClearChat("clear"), + Help("help"), + TabCreated("new-tab-was-created"), + TabRemoved("tab-was-removed"), + Scan("scan"), + StartProjectScan("codescan_start_project_scan"), + StartFileScan("codescan_start_file_scan"), + StopFileScan("codescan_stop_file_scan"), + StopProjectScan("codescan_stop_project_scan"), + OpenIssuesPanel("codescan_open_issues"), + ResponseBodyLinkClicked("response-body-link-click"), +} + +class CodeScanChatApp(private val scope: CoroutineScope) : AmazonQApp { + private val isProcessingAuthChanged = AtomicBoolean(false) + override val tabTypes = listOf(CODE_SCAN_TAB_NAME) + override fun init(context: AmazonQAppInitContext) { + val chatSessionStorage = ChatSessionStorage() + val inboundAppMessagesHandler: InboundAppMessagesHandler = CodeScanChatController(context, chatSessionStorage) + + context.messageTypeRegistry.register( + CodeScanMessageTypes.ClearChat.type to IncomingCodeScanMessage.ClearChat::class, + CodeScanMessageTypes.Help.type to IncomingCodeScanMessage.Help::class, + CodeScanMessageTypes.TabCreated.type to IncomingCodeScanMessage.TabCreated::class, + CodeScanMessageTypes.TabRemoved.type to IncomingCodeScanMessage.TabRemoved::class, + CodeScanMessageTypes.Scan.type to IncomingCodeScanMessage.Scan::class, + CodeScanMessageTypes.StartProjectScan.type to IncomingCodeScanMessage.StartProjectScan::class, + CodeScanMessageTypes.StartFileScan.type to IncomingCodeScanMessage.StartFileScan::class, + CodeScanMessageTypes.StopProjectScan.type to IncomingCodeScanMessage.StopProjectScan::class, + CodeScanMessageTypes.StopFileScan.type to IncomingCodeScanMessage.StopFileScan::class, + CodeScanMessageTypes.ResponseBodyLinkClicked.type to IncomingCodeScanMessage.ResponseBodyLinkClicked::class, + CodeScanMessageTypes.OpenIssuesPanel.type to IncomingCodeScanMessage.OpenIssuesPanel::class + ) + + scope.launch { + merge(service().flow, context.messagesFromUiToApp.flow).collect { message -> + // Launch a new coroutine to handle each message + scope.launch { handleMessage(message, inboundAppMessagesHandler) } + } + } + + fun authChanged() { + val isAnotherThreadProcessing = !isProcessingAuthChanged.compareAndSet(false, true) + if (isAnotherThreadProcessing) return + scope.launch { + val authController = AuthController() + val credentialState = authController.getAuthNeededStates(context.project).amazonQ + if (credentialState == null) { + // Notify tabs about restoring authentication + context.messagesFromAppToUi.publish( + AuthenticationUpdateMessage( + featureDevEnabled = isFeatureDevAvailable(context.project), + codeTransformEnabled = isCodeTransformAvailable(context.project), + codeScanEnabled = isCodeScanAvailable(context.project), + codeTestEnabled = isCodeTestAvailable(context.project), + docEnabled = isDocAvailable(context.project), + authenticatingTabIDs = chatSessionStorage.getAuthenticatingSessions().map { it.tabId } + ) + ) + + chatSessionStorage.changeAuthenticationNeeded(false) + chatSessionStorage.changeAuthenticationNeededNotified(false) + } else { + chatSessionStorage.changeAuthenticationNeeded(true) + + // Ask for reauth + chatSessionStorage.getAuthenticatingSessions().filter { !it.authNeededNotified }.forEach { + context.messagesFromAppToUi.publish( + AuthenticationNeededExceptionMessage( + tabId = it.tabId, + authType = credentialState.authType, + message = credentialState.message + ) + ) + } + + // Prevent multiple calls to activeConnectionChanged + chatSessionStorage.changeAuthenticationNeededNotified(true) + } + isProcessingAuthChanged.set(false) + } + } + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun onChange(providerId: String, newScopes: List?) { + val qProvider = getQTokenProvider(context.project) + val isQ = qProvider?.id == providerId + val isAuthorized = qProvider?.state() == BearerTokenAuthState.AUTHORIZED + if (!isQ || !isAuthorized) return + authChanged() + } + } + ) + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + authChanged() + } + } + ) + } + + private fun getQTokenProvider(project: Project) = ( + ToolkitConnectionManager + .getInstance(project) + .activeConnectionForFeature(QConnection.getInstance()) as? AwsBearerTokenConnection + ) + ?.getConnectionSettings() + ?.tokenProvider + ?.delegate as? BearerTokenProvider + + private suspend fun handleMessage(message: AmazonQMessage, inboundAppMessagesHandler: InboundAppMessagesHandler) { + when (message) { + is IncomingCodeScanMessage.ClearChat -> inboundAppMessagesHandler.processClearQuickAction(message) + is IncomingCodeScanMessage.Help -> inboundAppMessagesHandler.processHelpQuickAction(message) + is IncomingCodeScanMessage.TabCreated -> inboundAppMessagesHandler.processTabCreated(message) + is IncomingCodeScanMessage.TabRemoved -> inboundAppMessagesHandler.processTabRemoved(message) + is IncomingCodeScanMessage.Scan -> inboundAppMessagesHandler.processScanQuickAction(message) + is IncomingCodeScanMessage.StartProjectScan -> inboundAppMessagesHandler.processStartProjectScan(message) + is IncomingCodeScanMessage.StartFileScan -> inboundAppMessagesHandler.processStartFileScan(message) + is CodeScanActionMessage -> inboundAppMessagesHandler.processCodeScanCommand(message) + is IncomingCodeScanMessage.StopProjectScan -> inboundAppMessagesHandler.processStopProjectScan(message) + is IncomingCodeScanMessage.StopFileScan -> inboundAppMessagesHandler.processStopFileScan(message) + is IncomingCodeScanMessage.ResponseBodyLinkClicked -> inboundAppMessagesHandler.processResponseBodyLinkClicked(message) + is IncomingCodeScanMessage.OpenIssuesPanel -> inboundAppMessagesHandler.processOpenIssuesPanel(message) + } + } + + override fun dispose() { + // nothing to do + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatAppFactory.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatAppFactory.kt new file mode 100644 index 0000000000..2ac70b38b0 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatAppFactory.kt @@ -0,0 +1,12 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan + +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppFactory + +class CodeScanChatAppFactory(private val cs: CoroutineScope) : AmazonQAppFactory { + override fun createApp(project: Project) = CodeScanChatApp(cs) +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatItems.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatItems.kt new file mode 100644 index 0000000000..63479dcc97 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanChatItems.kt @@ -0,0 +1,164 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan + +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.Button +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.CodeScanButtonId +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.CodeScanChatMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.CodeScanChatMessageContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.ProgressField +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.PromptProgressMessage +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanIssue +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.utils.IssueSeverity +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.StaticPrompt +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.StaticTextResponse +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessageType +import software.aws.toolkits.resources.message +import java.util.UUID + +private val projectScanButton = Button( + id = CodeScanButtonId.StartProjectScan.id, + text = message("codescan.chat.message.button.projectScan") +) + +private val fileScanButton = Button( + id = CodeScanButtonId.StartFileScan.id, + text = message("codescan.chat.message.button.fileScan") +) + +private val openIssuesPanelButton = Button( + id = CodeScanButtonId.OpenIssuesPanel.id, + text = message("codescan.chat.message.button.openIssues"), + keepCardAfterClick = true +) + +fun buildStartNewScanChatContent() = CodeScanChatMessageContent( + type = ChatMessageType.Answer, + message = message("codescan.chat.new_scan.input.message"), + buttons = listOf( + fileScanButton, + projectScanButton + ), + canBeVoted = false +) + +// TODO: Replace StaticPrompt and StaticTextResponse message according to Fnf +fun buildHelpChatPromptContent() = CodeScanChatMessageContent( + type = ChatMessageType.Prompt, + message = StaticPrompt.Help.message, + canBeVoted = false +) + +fun buildHelpChatAnswerContent() = CodeScanChatMessageContent( + type = ChatMessageType.Answer, + message = StaticTextResponse.Help.message, + canBeVoted = false +) + +fun buildUserSelectionProjectScanChatContent() = CodeScanChatMessageContent( + type = ChatMessageType.Prompt, + message = message("codescan.chat.message.button.projectScan"), + canBeVoted = false +) + +fun buildUserSelectionFileScanChatContent() = CodeScanChatMessageContent( + type = ChatMessageType.Prompt, + message = message("codescan.chat.message.button.fileScan"), + canBeVoted = false +) + +fun buildNotInGitRepoChatContent() = CodeScanChatMessageContent( + type = ChatMessageType.Answer, + message = message("codescan.chat.message.not_git_repo"), + canBeVoted = false +) + +fun buildScanInProgressChatContent(currentStep: Int, isProject: Boolean) = CodeScanChatMessageContent( + type = ChatMessageType.AnswerPart, + message = buildString { + appendLine(if (isProject) message("codescan.chat.message.scan_begin_project") else message("codescan.chat.message.scan_begin_file")) + appendLine("") + appendLine(message("codescan.chat.message.scan_begin_wait_time")) + appendLine("") + appendLine("${getIconForStep(0, currentStep)} " + message("codescan.chat.message.scan_step_1")) + appendLine("${getIconForStep(1, currentStep)} " + message("codescan.chat.message.scan_step_2")) + appendLine("${getIconForStep(2, currentStep)} " + message("codescan.chat.message.scan_step_3")) + } +) + +val cancellingProgressField = ProgressField( + status = "warning", + text = message("general.canceling"), + value = -1, + actions = emptyList() +) + +fun buildScanCompleteChatContent(issues: List, isProject: Boolean = false): CodeScanChatMessageContent { + val issueCountMap = IssueSeverity.entries.associate { it.displayName to 0 }.toMutableMap() + val aggregatedIssues = issues.groupBy { it.severity } + aggregatedIssues.forEach { (key, list) -> if (list.isNotEmpty()) issueCountMap[key] = list.size } + + val message = buildString { + appendLine(if (isProject) message("codewhisperer.codescan.scan_complete_project") else message("codewhisperer.codescan.scan_complete_file")) + issueCountMap.entries.forEach { (severity, count) -> + appendLine(message("codewhisperer.codescan.scan_complete_count", severity, count)) + } + } + + return CodeScanChatMessageContent( + type = ChatMessageType.Answer, + message = message, + buttons = listOf( + openIssuesPanelButton + ), + ) +} + +fun buildPromptProgressMessage(tabId: String, isProject: Boolean = false, isCanceling: Boolean = false) = PromptProgressMessage( + progressField = when { + isCanceling -> cancellingProgressField + isProject -> projectScanProgressField + else -> fileScanProgressField + }, + tabId = tabId +) + +fun buildClearPromptProgressMessage(tabId: String) = PromptProgressMessage( + tabId = tabId +) + +val runCodeScanMessage = CodeScanChatMessage(messageType = ChatMessageType.Prompt, command = "review", tabId = UUID.randomUUID().toString()) + +val cancelFileScanButton = Button( + id = CodeScanButtonId.StopFileScan.id, + text = message("general.cancel"), + icon = "cancel" +) + +val cancelProjectScanButton = cancelFileScanButton.copy( + id = CodeScanButtonId.StopProjectScan.id +) + +val fileScanProgressField = ProgressField( + status = "default", + text = message("codescan.chat.message.scan_file_in_progress"), + value = -1, + actions = listOf(cancelFileScanButton) +) + +val projectScanProgressField = fileScanProgressField.copy( + text = message("codescan.chat.message.scan_project_in_progress"), + actions = listOf(cancelProjectScanButton) +) + +fun buildProjectScanFailedChatContent(errorMessage: String?) = CodeScanChatMessageContent( + type = ChatMessageType.Answer, + message = errorMessage ?: message("codescan.chat.message.project_scan_failed") +) + +fun getIconForStep(targetStep: Int, currentStep: Int) = when { + currentStep == targetStep -> "☐" + currentStep > targetStep -> "☑" + else -> "☐" +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanConstants.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanConstants.kt new file mode 100644 index 0000000000..c27b7ef416 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/CodeScanConstants.kt @@ -0,0 +1,6 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan + +const val FEATURE_NAME = "Amazon Q Code Scan" diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/InboundAppMessagesHandler.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/InboundAppMessagesHandler.kt new file mode 100644 index 0000000000..31ca09503d --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/InboundAppMessagesHandler.kt @@ -0,0 +1,33 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan + +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands.CodeScanActionMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.IncomingCodeScanMessage + +interface InboundAppMessagesHandler { + suspend fun processScanQuickAction(message: IncomingCodeScanMessage.Scan) + + suspend fun processStartProjectScan(message: IncomingCodeScanMessage.StartProjectScan) + + suspend fun processStartFileScan(message: IncomingCodeScanMessage.StartFileScan) + + suspend fun processStopProjectScan(message: IncomingCodeScanMessage.StopProjectScan) + + suspend fun processStopFileScan(message: IncomingCodeScanMessage.StopFileScan) + + suspend fun processTabCreated(message: IncomingCodeScanMessage.TabCreated) + + suspend fun processClearQuickAction(message: IncomingCodeScanMessage.ClearChat) + + suspend fun processHelpQuickAction(message: IncomingCodeScanMessage.Help) + + suspend fun processTabRemoved(message: IncomingCodeScanMessage.TabRemoved) + + suspend fun processCodeScanCommand(message: CodeScanActionMessage) + + suspend fun processResponseBodyLinkClicked(message: IncomingCodeScanMessage.ResponseBodyLinkClicked) + + suspend fun processOpenIssuesPanel(message: IncomingCodeScanMessage.OpenIssuesPanel) +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/auth/CodeScanAuthUtils.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/auth/CodeScanAuthUtils.kt new file mode 100644 index 0000000000..2e5744b176 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/auth/CodeScanAuthUtils.kt @@ -0,0 +1,11 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan.auth + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnection +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.checkBearerConnectionValidity + +fun isCodeScanAvailable(project: Project): Boolean = checkBearerConnectionValidity(project, BearerTokenFeatureSet.Q) is ActiveConnection.ValidBearer diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/commands/CodeScanActionMessage.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/commands/CodeScanActionMessage.kt new file mode 100644 index 0000000000..01532c154f --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/commands/CodeScanActionMessage.kt @@ -0,0 +1,16 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeScanResponse +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants + +data class CodeScanActionMessage( + val command: CodeScanCommand, + val project: Project, + val scanResult: CodeScanResponse? = null, + val scope: CodeWhispererConstants.CodeAnalysisScope, +) : AmazonQMessage diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/commands/CodeScanCommand.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/commands/CodeScanCommand.kt new file mode 100644 index 0000000000..f7b16705b1 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/commands/CodeScanCommand.kt @@ -0,0 +1,8 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands + +enum class CodeScanCommand { + ScanComplete, +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/commands/CodeScanMessageListener.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/commands/CodeScanMessageListener.kt new file mode 100644 index 0000000000..832741a1fc --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/commands/CodeScanMessageListener.kt @@ -0,0 +1,21 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands + +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeScanResponse +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants + +@Service +class CodeScanMessageListener { + private val _messages by lazy { MutableSharedFlow(extraBufferCapacity = 10) } + val flow = _messages.asSharedFlow() + + fun onScanResult(result: CodeScanResponse?, scope: CodeWhispererConstants.CodeAnalysisScope, project: Project) { + _messages.tryEmit(CodeScanActionMessage(CodeScanCommand.ScanComplete, scanResult = result, scope = scope, project = project)) + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/controller/CodeScanChatController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/controller/CodeScanChatController.kt new file mode 100644 index 0000000000..59a69c7ce4 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/controller/CodeScanChatController.kt @@ -0,0 +1,197 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan.controller + +import com.intellij.ide.BrowserUtil +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext +import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.FEATURE_NAME +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.InboundAppMessagesHandler +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildHelpChatAnswerContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildHelpChatPromptContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildNotInGitRepoChatContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildProjectScanFailedChatContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildScanCompleteChatContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildScanInProgressChatContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildStartNewScanChatContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildUserSelectionFileScanChatContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildUserSelectionProjectScanChatContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands.CodeScanActionMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.commands.CodeScanCommand +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.AuthenticationNeededExceptionMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.CodeScanChatMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.IncomingCodeScanMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.storage.ChatSessionStorage +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeScanResponse +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.getAuthType +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessageType +import software.aws.toolkits.resources.message + +class CodeScanChatController( + private val context: AmazonQAppInitContext, + private val chatSessionStorage: ChatSessionStorage, + private val authController: AuthController = AuthController(), +) : InboundAppMessagesHandler { + + private val messenger = context.messagesFromAppToUi + private val codeScanManager = CodeWhispererCodeScanManager.getInstance(context.project) + private val codeScanChatHelper = CodeScanChatHelper(context.messagesFromAppToUi, chatSessionStorage) + private val scanInProgressMessageId = "scanProgressMessage" + + override suspend fun processScanQuickAction(message: IncomingCodeScanMessage.Scan) { + // TODO: telemetry + + if (!checkForAuth(message.tabId)) { + return + } + if (message.tabId != codeScanChatHelper.getActiveCodeScanTabId()) return + + codeScanChatHelper.setActiveCodeScanTabId(message.tabId) + codeScanChatHelper.addNewMessage(buildStartNewScanChatContent()) + codeScanChatHelper.sendChatInputEnabledMessage(false) + } + + override suspend fun processStartProjectScan(message: IncomingCodeScanMessage.StartProjectScan) { + if (message.tabId != codeScanChatHelper.getActiveCodeScanTabId()) return + codeScanChatHelper.addNewMessage(buildUserSelectionProjectScanChatContent()) + if (!codeScanManager.isInsideWorkTree()) { + codeScanChatHelper.addNewMessage(buildNotInGitRepoChatContent()) + } + codeScanChatHelper.addNewMessage(buildScanInProgressChatContent(currentStep = 1, isProject = true), messageIdOverride = scanInProgressMessageId) + codeScanManager.runCodeScan(CodeWhispererConstants.CodeAnalysisScope.PROJECT, initiatedByChat = true) + codeScanChatHelper.updateProgress(isProject = true, isCanceling = false) + } + + override suspend fun processStopProjectScan(message: IncomingCodeScanMessage.StopProjectScan) { + if (message.tabId != codeScanChatHelper.getActiveCodeScanTabId()) return + codeScanChatHelper.updateProgress(isProject = true, isCanceling = true) + codeScanManager.stopCodeScan(CodeWhispererConstants.CodeAnalysisScope.PROJECT) + } + + override suspend fun processStopFileScan(message: IncomingCodeScanMessage.StopFileScan) { + if (message.tabId != codeScanChatHelper.getActiveCodeScanTabId()) return + codeScanChatHelper.updateProgress(isProject = false, isCanceling = true) + codeScanManager.stopCodeScan(CodeWhispererConstants.CodeAnalysisScope.FILE) + } + + override suspend fun processStartFileScan(message: IncomingCodeScanMessage.StartFileScan) { + if (message.tabId != codeScanChatHelper.getActiveCodeScanTabId()) return + codeScanChatHelper.addNewMessage(buildUserSelectionFileScanChatContent()) + codeScanChatHelper.addNewMessage(buildScanInProgressChatContent(currentStep = 1, isProject = false), messageIdOverride = scanInProgressMessageId) + codeScanManager.runCodeScan(CodeWhispererConstants.CodeAnalysisScope.FILE, initiatedByChat = true) + codeScanChatHelper.updateProgress(isProject = false, isCanceling = false) + } + + override suspend fun processTabCreated(message: IncomingCodeScanMessage.TabCreated) { + logger.debug { "$FEATURE_NAME: New tab created: $message" } + codeScanChatHelper.setActiveCodeScanTabId(message.tabId) + CodeWhispererTelemetryService.getInstance().sendCodeScanNewTabEvent(getAuthType(context.project)) + } + + override suspend fun processClearQuickAction(message: IncomingCodeScanMessage.ClearChat) { + chatSessionStorage.deleteSession(message.tabId) + } + + override suspend fun processHelpQuickAction(message: IncomingCodeScanMessage.Help) { + if (message.tabId != codeScanChatHelper.getActiveCodeScanTabId()) return + codeScanChatHelper.addNewMessage(buildHelpChatPromptContent()) + codeScanChatHelper.addNewMessage(buildHelpChatAnswerContent()) + } + + override suspend fun processTabRemoved(message: IncomingCodeScanMessage.TabRemoved) { + chatSessionStorage.deleteSession(message.tabId) + } + + override suspend fun processResponseBodyLinkClicked(message: IncomingCodeScanMessage.ResponseBodyLinkClicked) { + BrowserUtil.browse(message.link) + } + + override suspend fun processCodeScanCommand(message: CodeScanActionMessage) { + if (message.project != context.project) return + val isProject = message.scope == CodeWhispererConstants.CodeAnalysisScope.PROJECT + when (message.command) { + CodeScanCommand.ScanComplete -> { + codeScanChatHelper.addNewMessage( + buildScanInProgressChatContent(currentStep = 2, isProject = isProject), + messageIdOverride = scanInProgressMessageId + ) + val result = message.scanResult + if (result != null) { + handleCodeScanResult(result, message.scope) + } else { + codeScanChatHelper.addNewMessage(buildProjectScanFailedChatContent("Cancelled")) + codeScanChatHelper.clearProgress() + } + } + } + } + + private suspend fun handleCodeScanResult(result: CodeScanResponse, scope: CodeWhispererConstants.CodeAnalysisScope) { + val isProject = scope == CodeWhispererConstants.CodeAnalysisScope.PROJECT + when (result) { + is CodeScanResponse.Success -> { + codeScanChatHelper.addNewMessage( + buildScanInProgressChatContent(currentStep = 3, isProject = isProject), + messageIdOverride = scanInProgressMessageId + ) + codeScanChatHelper.addNewMessage(buildScanCompleteChatContent(result.issues, isProject = isProject)) + codeScanChatHelper.clearProgress() + } + is CodeScanResponse.Failure -> { + codeScanChatHelper.addNewMessage(buildScanInProgressChatContent(3, isProject = isProject), messageIdOverride = scanInProgressMessageId) + codeScanChatHelper.addNewMessage(buildProjectScanFailedChatContent(result.failureReason.message)) + codeScanChatHelper.clearProgress() + } + } + } + + /** + * Return true if authenticated, else show authentication message and return false + * // TODO: Refactor this to avoid code duplication with other controllers + */ + private suspend fun checkForAuth(tabId: String): Boolean { + try { + val session = chatSessionStorage.getSession(tabId) + logger.debug { "$FEATURE_NAME: Session created with id: ${session.tabId}" } + + val credentialState = authController.getAuthNeededStates(context.project).amazonQ + if (credentialState != null) { + messenger.publish( + AuthenticationNeededExceptionMessage( + tabId = session.tabId, + authType = credentialState.authType, + message = credentialState.message + ) + ) + session.isAuthenticating = true + return false + } + } catch (err: Exception) { + messenger.publish( + CodeScanChatMessage( + tabId = tabId, + messageType = ChatMessageType.Answer, + message = message("codescan.chat.message.error_request") + ) + ) + return false + } + + return true + } + + override suspend fun processOpenIssuesPanel(message: IncomingCodeScanMessage.OpenIssuesPanel) { + if (message.tabId != codeScanChatHelper.getActiveCodeScanTabId()) return + codeScanManager.showCodeScanUI() + } + + companion object { + private val logger = getLogger() + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/controller/CodeScanChatHelper.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/controller/CodeScanChatHelper.kt new file mode 100644 index 0000000000..6a9acbbfc0 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/controller/CodeScanChatHelper.kt @@ -0,0 +1,81 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan.controller + +import software.aws.toolkits.jetbrains.services.amazonq.messages.MessagePublisher +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildClearPromptProgressMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.buildPromptProgressMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.ChatInputEnabledMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.CodeScanChatMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.CodeScanChatMessageContent +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages.UpdatePlaceholderMessage +import software.aws.toolkits.jetbrains.services.amazonqCodeScan.storage.ChatSessionStorage +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessageType +import java.util.UUID + +class CodeScanChatHelper( + private val messagePublisher: MessagePublisher, + private val chatSessionStorage: ChatSessionStorage, +) { + private var activeCodeScanTabId: String? = null + + fun setActiveCodeScanTabId(tabId: String) { + activeCodeScanTabId = tabId + } + + fun getActiveCodeScanTabId(): String? = activeCodeScanTabId + + private fun isInValidSession() = activeCodeScanTabId == null || chatSessionStorage.getSession(activeCodeScanTabId as String).isAuthenticating + + suspend fun addNewMessage( + content: CodeScanChatMessageContent, + messageIdOverride: String? = null, + clearPreviousItemButtons: Boolean? = false, + ) { + if (isInValidSession()) return + + messagePublisher.publish( + CodeScanChatMessage( + tabId = activeCodeScanTabId as String, + messageId = messageIdOverride ?: UUID.randomUUID().toString(), + messageType = content.type, + message = content.message, + buttons = content.buttons, + formItems = content.formItems, + followUps = content.followUps, + canBeVoted = content.canBeVoted, + isLoading = content.type == ChatMessageType.AnswerPart, + clearPreviousItemButtons = clearPreviousItemButtons as Boolean + ) + ) + } + + suspend fun updateProgress(isProject: Boolean = false, isCanceling: Boolean = false) { + if (isInValidSession()) return + messagePublisher.publish(buildPromptProgressMessage(activeCodeScanTabId as String, isProject, isCanceling)) + sendChatInputEnabledMessage(false) + } + + suspend fun clearProgress() { + if (isInValidSession()) return + messagePublisher.publish(buildClearPromptProgressMessage(activeCodeScanTabId as String)) + sendChatInputEnabledMessage(true) + } + + suspend fun sendChatInputEnabledMessage(isEnabled: Boolean) { + if (isInValidSession()) return + messagePublisher.publish(ChatInputEnabledMessage(activeCodeScanTabId as String, enabled = isEnabled)) + } + + suspend fun updatePlaceholder(newPlaceholder: String) { + if (isInValidSession()) return + + messagePublisher.publish( + UpdatePlaceholderMessage( + tabId = activeCodeScanTabId as String, + newPlaceholder = newPlaceholder + ) + ) + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/messages/CodeScanMessage.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/messages/CodeScanMessage.kt new file mode 100644 index 0000000000..062e974336 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqCodeScan/messages/CodeScanMessage.kt @@ -0,0 +1,185 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonqCodeScan.messages + +import com.fasterxml.jackson.annotation.JsonProperty +import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthFollowUpType +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessageType +import software.aws.toolkits.jetbrains.services.cwc.messages.FollowUp +import java.time.Instant +import java.util.UUID + +const val CODE_SCAN_TAB_NAME = "codescan" + +enum class CodeScanButtonId(val id: String) { + StartProjectScan("codescan_start_project_scan"), + StartFileScan("codescan_start_file_scan"), + StopProjectScan("codescan_stop_project_scan"), + StopFileScan("codescan_stop_file_scan"), + OpenIssuesPanel("codescan_open_issues"), +} + +data class Button( + val id: String, + val text: String, + val description: String? = null, + val icon: String? = null, + val keepCardAfterClick: Boolean? = false, + val disabled: Boolean? = false, + val waitMandatoryFormItems: Boolean? = false, +) +data class ProgressField( + val title: String? = null, + val value: Int? = null, + val status: String? = null, + val actions: List