Skip to content

feat(amazonq): show all customizations across different profiles #5646

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 6, 2025
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type" : "feature",
"description" : "Amazon Q: Support selecting customizations across all Q profiles with automatic profile switching for enterprise users"
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import software.aws.toolkits.core.utils.debug
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.jetbrains.core.credentials.sono.isInternalUser
import software.aws.toolkits.jetbrains.services.amazonq.codeWhispererUserContext
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager
import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization
import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage
Expand Down Expand Up @@ -83,7 +84,7 @@ interface CodeWhispererClientAdaptor {

fun getCodeFixJob(request: GetCodeFixJobRequest): GetCodeFixJobResponse

fun listAvailableCustomizations(): List<CodeWhispererCustomization>
fun listAvailableCustomizations(profile: QRegionProfile): List<CodeWhispererCustomization>

fun startTestGeneration(uploadId: String, targetCode: List<TargetCode>, userInput: String): StartTestGenerationResponse

Expand Down Expand Up @@ -287,9 +288,9 @@ open class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeW
override fun getCodeFixJob(request: GetCodeFixJobRequest): GetCodeFixJobResponse = bearerClient().getCodeFixJob(request)

// DO NOT directly use this method to fetch customizations, use wrapper [CodeWhispererModelConfigurator.listCustomization()] instead
override fun listAvailableCustomizations(): List<CodeWhispererCustomization> =
bearerClient().listAvailableCustomizationsPaginator(
ListAvailableCustomizationsRequest.builder().profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn).build()
override fun listAvailableCustomizations(profile: QRegionProfile): List<CodeWhispererCustomization> =
QRegionProfileManager.getInstance().getQClient<CodeWhispererRuntimeClient>(project, profile).listAvailableCustomizationsPaginator(
ListAvailableCustomizationsRequest.builder().profileArn(profile.arn).build()
)
.stream()
.toList()
Expand All @@ -303,7 +304,8 @@ open class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeW
CodeWhispererCustomization(
arn = it.arn(),
name = it.name(),
description = it.description()
description = it.description(),
profile = profile
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import software.amazon.awssdk.arns.Arn
import software.aws.toolkits.core.utils.debug
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.tryOrNull
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.Q_CUSTOM_LEARN_MORE_URI
import software.aws.toolkits.jetbrains.ui.AsyncComboBox
import software.aws.toolkits.jetbrains.utils.notifyInfo
Expand All @@ -34,7 +35,7 @@ import javax.swing.JComponent
import javax.swing.JList

private val NoDataToDisplay = CustomizationUiItem(
CodeWhispererCustomization("", message("codewhisperer.custom.dialog.option.no_data"), ""),
CodeWhispererCustomization("", message("codewhisperer.custom.dialog.option.no_data"), "", QRegionProfile("", "")),
false,
false
)
Expand Down Expand Up @@ -259,6 +260,10 @@ private object CustomizationRenderer : ColoredListCellRenderer<CustomizationUiIt
}
}

if (it.customization.profile?.profileName?.isNotEmpty() == true) {
append(" [${it.customization.profile?.profileName}]", SimpleTextAttributes.REGULAR_ATTRIBUTES)
}

if (it.isNew) {
append(" New", SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ import software.aws.toolkits.core.utils.debug
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService
import software.aws.toolkits.jetbrains.services.amazonq.calculateIfIamIdentityCenterConnection
import software.aws.toolkits.jetbrains.services.amazonq.profile.QProfileSwitchIntent
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener
import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor
import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants
import software.aws.toolkits.jetbrains.utils.notifyInfo
import software.aws.toolkits.jetbrains.utils.notifyWarn
import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread
Expand Down Expand Up @@ -108,25 +109,24 @@ class DefaultCodeWhispererModelConfigurator : CodeWhispererModelConfigurator, Pe
@RequiresBackgroundThread
override fun listCustomizations(project: Project, passive: Boolean): List<CustomizationUiItem>? =
calculateIfIamIdentityCenterConnection(project) {
// 1. invoke API and get result
val listAvailableCustomizationsResult = try {
CodeWhispererClientAdaptor.getInstance(project).listAvailableCustomizations()
} catch (e: Exception) {
val requestId = (e as? CodeWhispererRuntimeException)?.requestId()
val logMessage = if (CodeWhispererConstants.Customization.noAccessToCustomizationExceptionPredicate(e)) {
// TODO: not required for non GP users
"ListAvailableCustomizations: connection ${it.id} is not allowlisted, requestId: ${requestId.orEmpty()}"
} else {
"ListAvailableCustomizations: failed due to unknown error ${e.message}, requestId: ${requestId.orEmpty()}"
}

LOG.debug { logMessage }
null
// 1. fetch all profiles, invoke fetch customizations API and get result for each profile and aggregate all the results
val profiles = QRegionProfileManager.getInstance().listRegionProfiles(project)
?: error("Attempted to fetch profiles while there does not exist")

val customizations = profiles.flatMap { profile ->
runCatching {
CodeWhispererClientAdaptor.getInstance(project).listAvailableCustomizations(profile)
}.onFailure { e ->
val requestId = (e as? CodeWhispererRuntimeException)?.requestId()
val logMessage = "ListAvailableCustomizations: failed due to unknown error ${e.message}, " +
"requestId: ${requestId.orEmpty()}, profileName: ${profile.profileName}"
LOG.debug { logMessage }
}.getOrDefault(emptyList())
}

// 2. get diff
val previousCustomizationsShapshot = connectionToCustomizationsShownLastTime.getOrElse(it.id) { emptyList() }
val diff = listAvailableCustomizationsResult?.filterNot { customization -> previousCustomizationsShapshot.contains(customization.arn) }?.toSet()
val diff = customizations.filterNot { customization -> previousCustomizationsShapshot.contains(customization.arn) }.toSet()

// 3 if passive,
// (1) update allowlisting
Expand All @@ -135,42 +135,45 @@ class DefaultCodeWhispererModelConfigurator : CodeWhispererModelConfigurator, Pe
// if not passive,
// (1) update the customization list snapshot (seen by users last time) if it will be displayed
if (passive) {
connectionIdToIsAllowlisted[it.id] = listAvailableCustomizationsResult != null
if (diff?.isNotEmpty() == true && !hasShownNewCustomizationNotification.getAndSet(true)) {
connectionIdToIsAllowlisted[it.id] = customizations.isNotEmpty()
if (diff.isNotEmpty() && !hasShownNewCustomizationNotification.getAndSet(true)) {
notifyNewCustomization(project)
}
} else {
listAvailableCustomizationsResult?.let { customizations ->
connectionToCustomizationsShownLastTime[it.id] = customizations.map { customization -> customization.arn }.toMutableList()
}
connectionToCustomizationsShownLastTime[it.id] = customizations.map { customization -> customization.arn }.toMutableList()
}

// 4. invalidate selected customization if
// (1) the API call failed
// (2) the selected customization is not in the resultset of API call
// (3) the existing q region profile associated with the selected customization does not match the currently active profile
activeCustomization(project)?.let { activeCustom ->
if (listAvailableCustomizationsResult == null) {
if (customizations.isEmpty()) {
invalidateSelectedAndNotify(project)
} else if (!listAvailableCustomizationsResult.any { latestCustom -> latestCustom.arn == activeCustom.arn }) {
} else if (customizations.none { latestCustom -> latestCustom.arn == activeCustom.arn }) {
invalidateSelectedAndNotify(project)
} else {
// for backward compatibility, previous schema didn't have profile arn, so backfill profile here if it's null
if (activeCustom.profile == null) {
customizations.find { c -> c.arn == activeCustom.arn }?.profile?.let { p ->
activeCustom.profile = p
}
}

if (activeCustom.profile != null && activeCustom.profile != QRegionProfileManager.getInstance().activeProfile(project)) {
invalidateSelectedAndNotify(project)
}
}
}

// 5. transform result to UI items and return
val customizationUiItems = if (diff != null) {
listAvailableCustomizationsResult.let { customizations ->
val nameToCount = customizations.groupingBy { customization -> customization.name }.eachCount()

customizations.map { customization ->
CustomizationUiItem(
customization,
isNew = diff.contains(customization),
shouldPrefixAccountId = (nameToCount[customization.name] ?: 0) > 1
)
}
}
} else {
null
val nameToCount = customizations.groupingBy { customization -> customization.name }.eachCount()
val customizationUiItems = customizations.map { customization ->
CustomizationUiItem(
customization,
isNew = diff.contains(customization),
shouldPrefixAccountId = (nameToCount[customization.name] ?: 0) > 1
)
}
connectionToCustomizationUiItems[it.id] = customizationUiItems

Expand Down Expand Up @@ -212,6 +215,18 @@ class DefaultCodeWhispererModelConfigurator : CodeWhispererModelConfigurator, Pe

LOG.debug { "Switch from customization $oldCus to $newCustomization" }

// Switch profile if it doesn't match the customization's profile.
// Customizations are profile-scoped and must be used under the correct context.
newCustomization?.profile?.let { p ->
if (p.arn != QRegionProfileManager.getInstance().activeProfile(project)?.arn) {
QRegionProfileManager.getInstance().switchProfile(
project,
p,
QProfileSwitchIntent.Customization
)
}
}

CodeWhispererCustomizationListener.notifyCustomUiUpdate()
}
if (isOverride) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope
import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService
import software.aws.toolkits.jetbrains.services.amazonq.calculateIfIamIdentityCenterConnection
import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager
import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization
import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator
import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager
import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isUserBuilderId
Expand Down Expand Up @@ -79,18 +78,16 @@ class CodeWhispererProjectStartupActivity : StartupActivity.DumbAware {
projectCoroutineScope(project).launch {
while (isActive) {
CodeWhispererFeatureConfigService.getInstance().fetchFeatureConfigs(project)
CodeWhispererFeatureConfigService.getInstance().getCustomizationFeature()?.let { customization ->
CodeWhispererFeatureConfigService.getInstance().getCustomizationFeature()?.let { overrideContext ->
val persistedCustomizationOverride = CodeWhispererModelConfigurator.getInstance().getPersistedCustomizationOverride()
val latestCustomizationOverride = customization.value.stringValue()
val latestCustomizationOverride = overrideContext.value.stringValue()
if (persistedCustomizationOverride == latestCustomizationOverride) return@let

// latest is different from the currently persisted, need update
CodeWhispererFeatureConfigService.getInstance().validateCustomizationOverride(project, customization)
CodeWhispererModelConfigurator.getInstance().switchCustomization(
project,
CodeWhispererCustomization(arn = customization.value.stringValue(), name = customization.variation),
isOverride = true
)
val customization = CodeWhispererFeatureConfigService.getInstance().validateCustomizationOverride(project, overrideContext)
if (customization != null) {
CodeWhispererModelConfigurator.getInstance().switchCustomization(project, customization, isOverride = true)
}
}

delay(FEATURE_CONFIG_POLL_INTERVAL_IN_MS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection
import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES
import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION
import software.aws.toolkits.jetbrains.services.amazonq.FEATURE_EVALUATION_PRODUCT_NAME
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.metadata
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonRequest
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonResponseWithToken
Expand Down Expand Up @@ -189,13 +190,13 @@ class CodeWhispererClientAdaptorTest {
on { client.listAvailableCustomizationsPaginator(any<ListAvailableCustomizationsRequest>()) } doReturn sdkIterable
}

val actual = sut.listAvailableCustomizations()
val actual = sut.listAvailableCustomizations(QRegionProfile("fake_profile", "fake arn"))
assertThat(actual).hasSize(3)
assertThat(actual).isEqualTo(
listOf(
CodeWhispererCustomization(name = "custom-1", arn = "arn-1"),
CodeWhispererCustomization(name = "custom-2", arn = "arn-2"),
CodeWhispererCustomization(name = "custom-3", arn = "arn-3")
CodeWhispererCustomization(name = "custom-1", arn = "arn-1", profile = QRegionProfile("fake_profile", "fake arn")),
CodeWhispererCustomization(name = "custom-2", arn = "arn-2", profile = QRegionProfile("fake_profile", "fake arn")),
CodeWhispererCustomization(name = "custom-3", arn = "arn-3", profile = QRegionProfile("fake_profile", "fake arn"))
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager
import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection
import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL
import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager
import kotlin.reflect.full.memberFunctions
import kotlin.test.Test
Expand Down Expand Up @@ -78,7 +79,7 @@ class CodeWhispererFeatureConfigServiceTest {

projectRule.project.replaceService(
QRegionProfileManager::class.java,
mock<QRegionProfileManager> { on { getQClient(any<Project>(), eq(CodeWhispererRuntimeClient::class)) } doReturn mockClient },
mock<QRegionProfileManager> { on { getQClient(any<Project>(), eq(QRegionProfile()), eq(CodeWhispererRuntimeClient::class)) } doReturn mockClient },
disposableRule.disposable
)

Expand Down
Loading
Loading