Skip to content

Commit adceb63

Browse files
authored
fix(amazonq): support displaying customizations across all profiles of an Idc connection (#5591)
Follow-up to #5575. As mentioned in that PR: Before: Customizations were bound to a specific IDC instance. After: Customizations are now bound to a specific Q profile. An IDC instance can have multiple Q profiles. In other words, each Q profile has access to its own set of customizations. The product team has requested that we show all available customizations across profiles, rather than only those tied to the currently connected profile. This means that when a user selects a customization that belongs to a different Q profile, the plugin should implicitly switch to that profile. This behavior helps reduce confusion and user churn, since users might not clearly understand which profile grants access to which customizations.
1 parent c5f15d6 commit adceb63

File tree

11 files changed

+121
-75
lines changed

11 files changed

+121
-75
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type" : "feature",
3+
"description" : "Amazon Q: Support selecting customizations across all Q profiles with automatic profile switching for enterprise users"
4+
}

plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import software.amazon.awssdk.services.codewhispererruntime.model.UserIntent
3838
import software.aws.toolkits.core.utils.debug
3939
import software.aws.toolkits.core.utils.getLogger
4040
import software.aws.toolkits.jetbrains.services.amazonq.codeWhispererUserContext
41+
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
4142
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager
4243
import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization
4344
import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage
@@ -78,7 +79,7 @@ interface CodeWhispererClientAdaptor {
7879

7980
fun getCodeFixJob(request: GetCodeFixJobRequest): GetCodeFixJobResponse
8081

81-
fun listAvailableCustomizations(): List<CodeWhispererCustomization>
82+
fun listAvailableCustomizations(profile: QRegionProfile): List<CodeWhispererCustomization>
8283

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

@@ -282,9 +283,9 @@ open class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeW
282283
override fun getCodeFixJob(request: GetCodeFixJobRequest): GetCodeFixJobResponse = bearerClient().getCodeFixJob(request)
283284

284285
// DO NOT directly use this method to fetch customizations, use wrapper [CodeWhispererModelConfigurator.listCustomization()] instead
285-
override fun listAvailableCustomizations(): List<CodeWhispererCustomization> =
286-
bearerClient().listAvailableCustomizationsPaginator(
287-
ListAvailableCustomizationsRequest.builder().profileArn(QRegionProfileManager.getInstance().activeProfile(project)?.arn).build()
286+
override fun listAvailableCustomizations(profile: QRegionProfile): List<CodeWhispererCustomization> =
287+
QRegionProfileManager.getInstance().getQClient<CodeWhispererRuntimeClient>(project, profile).listAvailableCustomizationsPaginator(
288+
ListAvailableCustomizationsRequest.builder().profileArn(profile.arn).build()
288289
)
289290
.stream()
290291
.toList()
@@ -298,7 +299,8 @@ open class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeW
298299
CodeWhispererCustomization(
299300
arn = it.arn(),
300301
name = it.name(),
301-
description = it.description()
302+
description = it.description(),
303+
profile = profile
302304
)
303305
}
304306
}

plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomizationDialog.kt

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import software.amazon.awssdk.arns.Arn
2626
import software.aws.toolkits.core.utils.debug
2727
import software.aws.toolkits.core.utils.getLogger
2828
import software.aws.toolkits.core.utils.tryOrNull
29+
import software.aws.toolkits.jetbrains.services.amazonq.profile.QProfileSwitchIntent
30+
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
31+
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager
2932
import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.Q_CUSTOM_LEARN_MORE_URI
3033
import software.aws.toolkits.jetbrains.ui.AsyncComboBox
3134
import software.aws.toolkits.jetbrains.utils.notifyInfo
@@ -34,7 +37,7 @@ import javax.swing.JComponent
3437
import javax.swing.JList
3538

3639
private val NoDataToDisplay = CustomizationUiItem(
37-
CodeWhispererCustomization("", message("codewhisperer.custom.dialog.option.no_data"), ""),
40+
CodeWhispererCustomization("", message("codewhisperer.custom.dialog.option.no_data"), "", QRegionProfile("", "")),
3841
false,
3942
false
4043
)
@@ -106,6 +109,15 @@ class CodeWhispererCustomizationDialog(
106109
RadioButtonOption.Customization -> run {
107110
CodeWhispererModelConfigurator.getInstance().switchCustomization(project, modal.selectedCustomization?.customization)
108111
notifyCustomizationIsSelected(project, modal.selectedCustomization)
112+
// Switch profile if it doesn't match the customization's profile.
113+
// Customizations are profile-scoped and must be used under the correct context.
114+
if (modal.selectedCustomization?.customization?.profile?.arn != QRegionProfileManager.getInstance().activeProfile(project)?.arn) {
115+
QRegionProfileManager.getInstance().switchProfile(
116+
project,
117+
modal.selectedCustomization?.customization?.profile,
118+
QProfileSwitchIntent.Customization
119+
)
120+
}
109121
}
110122
}
111123

@@ -179,12 +191,14 @@ class CodeWhispererCustomizationDialog(
179191
proposeModelUpdate { model ->
180192
val activeCustomization = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)
181193
val unsorted = myCustomizations ?: CodeWhispererModelConfigurator.getInstance().listCustomizations(project).orEmpty()
182-
183-
val sorted = activeCustomization?.let {
184-
unsorted.putPickedUpFront(setOf(it))
185-
} ?: run {
186-
unsorted.sortedBy { it.customization.name }
187-
}
194+
val activeProfile = QRegionProfileManager.getInstance().activeProfile(project)
195+
// Group customizations by profile name (active profile first, then alphabetical), with the active customization on top
196+
val sorted = unsorted.sortedWith(
197+
compareBy<CustomizationUiItem> { it.customization.profile?.profileName != activeProfile?.profileName }
198+
.thenBy { it.customization.profile?.profileName.orEmpty() }
199+
.thenBy { it.customization.name }
200+
)
201+
.let { list -> activeCustomization?.let { list.putPickedUpFront(setOf(it)) } ?: list }
188202

189203
if (
190204
sorted.isNotEmpty() &&
@@ -259,6 +273,10 @@ private object CustomizationRenderer : ColoredListCellRenderer<CustomizationUiIt
259273
}
260274
}
261275

276+
if (it.customization.profile?.profileName?.isNotEmpty() == true) {
277+
append(" [${it.customization.profile?.profileName}]", SimpleTextAttributes.REGULAR_ATTRIBUTES)
278+
}
279+
262280
if (it.isNew) {
263281
append(" New", SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES)
264282
}

plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererModelConfigurator.kt

Lines changed: 30 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import software.aws.toolkits.core.utils.getLogger
2323
import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService
2424
import software.aws.toolkits.jetbrains.services.amazonq.calculateIfIamIdentityCenterConnection
2525
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
26+
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager
2627
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener
2728
import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor
28-
import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants
2929
import software.aws.toolkits.jetbrains.utils.notifyInfo
3030
import software.aws.toolkits.jetbrains.utils.notifyWarn
3131
import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread
@@ -108,25 +108,24 @@ class DefaultCodeWhispererModelConfigurator : CodeWhispererModelConfigurator, Pe
108108
@RequiresBackgroundThread
109109
override fun listCustomizations(project: Project, passive: Boolean): List<CustomizationUiItem>? =
110110
calculateIfIamIdentityCenterConnection(project) {
111-
// 1. invoke API and get result
112-
val listAvailableCustomizationsResult = try {
113-
CodeWhispererClientAdaptor.getInstance(project).listAvailableCustomizations()
114-
} catch (e: Exception) {
115-
val requestId = (e as? CodeWhispererRuntimeException)?.requestId()
116-
val logMessage = if (CodeWhispererConstants.Customization.noAccessToCustomizationExceptionPredicate(e)) {
117-
// TODO: not required for non GP users
118-
"ListAvailableCustomizations: connection ${it.id} is not allowlisted, requestId: ${requestId.orEmpty()}"
119-
} else {
120-
"ListAvailableCustomizations: failed due to unknown error ${e.message}, requestId: ${requestId.orEmpty()}"
121-
}
122-
123-
LOG.debug { logMessage }
124-
null
111+
// 1. fetch all profiles, invoke fetch customizations API and get result for each profile and aggregate all the results
112+
val listAvailableProfilesResult = QRegionProfileManager.getInstance().listRegionProfiles(project)
113+
?: error("Attempted to fetch profiles while there does not exist")
114+
115+
val aggregatedCustomizations = listAvailableProfilesResult.flatMap { profile ->
116+
runCatching {
117+
CodeWhispererClientAdaptor.getInstance(project).listAvailableCustomizations(profile)
118+
}.onFailure { e ->
119+
val requestId = (e as? CodeWhispererRuntimeException)?.requestId()
120+
val logMessage = "ListAvailableCustomizations: failed due to unknown error ${e.message}, " +
121+
"requestId: ${requestId.orEmpty()}, profileName: ${profile.profileName}"
122+
LOG.debug { logMessage }
123+
}.getOrDefault(emptyList())
125124
}
126125

127126
// 2. get diff
128127
val previousCustomizationsShapshot = connectionToCustomizationsShownLastTime.getOrElse(it.id) { emptyList() }
129-
val diff = listAvailableCustomizationsResult?.filterNot { customization -> previousCustomizationsShapshot.contains(customization.arn) }?.toSet()
128+
val diff = aggregatedCustomizations.filterNot { customization -> previousCustomizationsShapshot.contains(customization.arn) }.toSet()
130129

131130
// 3 if passive,
132131
// (1) update allowlisting
@@ -135,42 +134,36 @@ class DefaultCodeWhispererModelConfigurator : CodeWhispererModelConfigurator, Pe
135134
// if not passive,
136135
// (1) update the customization list snapshot (seen by users last time) if it will be displayed
137136
if (passive) {
138-
connectionIdToIsAllowlisted[it.id] = listAvailableCustomizationsResult != null
139-
if (diff?.isNotEmpty() == true && !hasShownNewCustomizationNotification.getAndSet(true)) {
137+
connectionIdToIsAllowlisted[it.id] = aggregatedCustomizations.isNotEmpty()
138+
if (diff.isNotEmpty() && !hasShownNewCustomizationNotification.getAndSet(true)) {
140139
notifyNewCustomization(project)
141140
}
142141
} else {
143-
listAvailableCustomizationsResult?.let { customizations ->
144-
connectionToCustomizationsShownLastTime[it.id] = customizations.map { customization -> customization.arn }.toMutableList()
145-
}
142+
connectionToCustomizationsShownLastTime[it.id] = aggregatedCustomizations.map { customization -> customization.arn }.toMutableList()
146143
}
147144

148145
// 4. invalidate selected customization if
149146
// (1) the API call failed
150147
// (2) the selected customization is not in the resultset of API call
148+
// (3) the existing q region profile associated with the selected customization does not match the currently active profile
151149
activeCustomization(project)?.let { activeCustom ->
152-
if (listAvailableCustomizationsResult == null) {
150+
if (aggregatedCustomizations.isEmpty()) {
153151
invalidateSelectedAndNotify(project)
154-
} else if (!listAvailableCustomizationsResult.any { latestCustom -> latestCustom.arn == activeCustom.arn }) {
152+
} else if (!aggregatedCustomizations.any { latestCustom -> latestCustom.arn == activeCustom.arn } ||
153+
(activeCustom.profile != null && activeCustom.profile != QRegionProfileManager.getInstance().activeProfile(project))
154+
) {
155155
invalidateSelectedAndNotify(project)
156156
}
157157
}
158158

159159
// 5. transform result to UI items and return
160-
val customizationUiItems = if (diff != null) {
161-
listAvailableCustomizationsResult.let { customizations ->
162-
val nameToCount = customizations.groupingBy { customization -> customization.name }.eachCount()
163-
164-
customizations.map { customization ->
165-
CustomizationUiItem(
166-
customization,
167-
isNew = diff.contains(customization),
168-
shouldPrefixAccountId = (nameToCount[customization.name] ?: 0) > 1
169-
)
170-
}
171-
}
172-
} else {
173-
null
160+
val nameToCount = aggregatedCustomizations.groupingBy { customization -> customization.name }.eachCount()
161+
val customizationUiItems = aggregatedCustomizations.map { customization ->
162+
CustomizationUiItem(
163+
customization,
164+
isNew = diff.contains(customization),
165+
shouldPrefixAccountId = (nameToCount[customization.name] ?: 0) > 1
166+
)
174167
}
175168
connectionToCustomizationUiItems[it.id] = customizationUiItems
176169

plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererClientAdaptorTest.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection
6565
import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES
6666
import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION
6767
import software.aws.toolkits.jetbrains.services.amazonq.FEATURE_EVALUATION_PRODUCT_NAME
68+
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
6869
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.metadata
6970
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonRequest
7071
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonResponseWithToken
@@ -189,13 +190,13 @@ class CodeWhispererClientAdaptorTest {
189190
on { client.listAvailableCustomizationsPaginator(any<ListAvailableCustomizationsRequest>()) } doReturn sdkIterable
190191
}
191192

192-
val actual = sut.listAvailableCustomizations()
193+
val actual = sut.listAvailableCustomizations(QRegionProfile("fake_profile", "fake arn"))
193194
assertThat(actual).hasSize(3)
194195
assertThat(actual).isEqualTo(
195196
listOf(
196-
CodeWhispererCustomization(name = "custom-1", arn = "arn-1"),
197-
CodeWhispererCustomization(name = "custom-2", arn = "arn-2"),
198-
CodeWhispererCustomization(name = "custom-3", arn = "arn-3")
197+
CodeWhispererCustomization(name = "custom-1", arn = "arn-1", profile = QRegionProfile("fake_profile", "fake arn")),
198+
CodeWhispererCustomization(name = "custom-2", arn = "arn-2", profile = QRegionProfile("fake_profile", "fake arn")),
199+
CodeWhispererCustomization(name = "custom-3", arn = "arn-3", profile = QRegionProfile("fake_profile", "fake arn"))
199200
)
200201
)
201202
}

plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererFeatureConfigServiceTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager
3535
import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection
3636
import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL
3737
import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService
38+
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile
3839
import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager
3940
import kotlin.reflect.full.memberFunctions
4041
import kotlin.test.Test
@@ -78,7 +79,7 @@ class CodeWhispererFeatureConfigServiceTest {
7879

7980
projectRule.project.replaceService(
8081
QRegionProfileManager::class.java,
81-
mock<QRegionProfileManager> { on { getQClient(any<Project>(), eq(CodeWhispererRuntimeClient::class)) } doReturn mockClient },
82+
mock<QRegionProfileManager> { on { getQClient(any<Project>(), eq(QRegionProfile()), eq(CodeWhispererRuntimeClient::class)) } doReturn mockClient },
8283
disposableRule.disposable
8384
)
8485

0 commit comments

Comments
 (0)