diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index 49608d38c6d059b164182bc940f5a8e5970e6519..29538f951c76746ef1fe2e1b4963fb0981f49d57 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -37,6 +37,7 @@ export const integrationFormSections = { GOOGLE_PLAY: 'google_play', GOOGLE_ARTIFACT_MANAGEMENT: 'google_artifact_management', GOOGLE_CLOUD_IAM: 'google_cloud_iam', + AMAZON_Q: 'amazon_q', }; export const integrationFormSectionComponents = { @@ -51,6 +52,7 @@ export const integrationFormSectionComponents = { [integrationFormSections.GOOGLE_ARTIFACT_MANAGEMENT]: 'IntegrationSectionGoogleArtifactManagement', [integrationFormSections.GOOGLE_CLOUD_IAM]: 'IntegrationSectionGoogleCloudIAM', + [integrationFormSections.AMAZON_Q]: 'IntegrationSectionAmazonQ', }; export const integrationTriggerEvents = { diff --git a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue index 79bd7e93cf927532da572ef67aa791a8f97be372..6003867809fa0ccbca852c99aa64a66632a606d9 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue @@ -51,6 +51,10 @@ export default { import( /* webpackChunkName: 'IntegrationSectionGoogleCloudIAM' */ 'ee_component/integrations/edit/components/sections/google_cloud_iam.vue' ), + IntegrationSectionAmazonQ: () => + import( + /* webpackChunkName: 'IntegrationSectionAmazonQ' */ 'ee_component/integrations/edit/components/sections/amazon_q.vue' + ), }, directives: { SafeHtml, diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 3ebad16fdf322fd181eb34cc51f0294ed4800940..ef41382ade5cf7ca2a1217c12aaabf3e2e9eb7e4 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -43,6 +43,13 @@ function parseDatasetToProps(data) { workloadIdentityFederationProjectNumber, workloadIdentityPoolId, wlifIssuer, + amazonQSubmitUrl, + amazonQDisconnectUrl, + amazonQRoleArn, + amazonQAvailability, + amazonQInstanceUid, + amazonQAwsProviderUrl, + amazonQAwsAudience, jwtClaims, redirectTo, upgradeSlackUrl, @@ -64,6 +71,7 @@ function parseDatasetToProps(data) { enableJiraVulnerabilities, shouldUpgradeSlack, customizeJiraIssueEnabled, + amazonQReady, } = parseBooleanInData(booleanAttributes); return { @@ -102,6 +110,16 @@ function parseDatasetToProps(data) { workloadIdentityFederationProjectNumber, workloadIdentityPoolId, }, + amazonQProps: { + amazonQSubmitUrl, + amazonQDisconnectUrl, + amazonQReady, + amazonQRoleArn, + amazonQAvailability, + amazonQInstanceUid, + amazonQAwsProviderUrl, + amazonQAwsAudience, + }, learnMorePath, aboutPricingUrl, triggerEvents: JSON.parse(triggerEvents), diff --git a/ee/app/assets/javascripts/integrations/edit/components/sections/amazon_q.vue b/ee/app/assets/javascripts/integrations/edit/components/sections/amazon_q.vue new file mode 100644 index 0000000000000000000000000000000000000000..9f1e10f73f7ece11b4f8e877c05137e2279fd5ba --- /dev/null +++ b/ee/app/assets/javascripts/integrations/edit/components/sections/amazon_q.vue @@ -0,0 +1,44 @@ +<script> +// eslint-disable-next-line no-restricted-imports +import { mapGetters } from 'vuex'; +import AmazonQApp from 'ee/amazon_q_settings/components/app.vue'; + +export default { + name: 'IntegrationSectionAmazonQ', + components: { + AmazonQApp, + }, + computed: { + ...mapGetters(['propsSource']), + amazonQAppProps() { + const amazonQProps = this.propsSource?.amazonQProps; + + if (!amazonQProps) { + return null; + } + + return { + submitUrl: amazonQProps.amazonQSubmitUrl, + disconnectUrl: amazonQProps.amazonQDisconnectUrl, + identityProviderPayload: { + instance_uid: amazonQProps.amazonQInstanceUid, + aws_provider_url: amazonQProps.amazonQAwsProviderUrl, + aws_audience: amazonQProps.amazonQAwsAudience, + }, + amazonQSettings: { + availability: amazonQProps.amazonQAvailability, + roleArn: amazonQProps.amazonQRoleArn, + ready: amazonQProps.amazonQReady, + }, + }; + }, + shouldRender() { + return Boolean(this.amazonQAppProps); + }, + }, +}; +</script> + +<template> + <amazon-q-app v-if="shouldRender" v-bind="amazonQAppProps" /> +</template> diff --git a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb index 8e6d706a9b8b34ed8f0ee9cac521e7cdddac87e3..12cf4be517365abecb17955403beb5fc6c23d776 100644 --- a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb +++ b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb @@ -28,7 +28,7 @@ def create end redirect_to( - admin_ai_amazon_q_settings_path, + edit_admin_application_settings_integration_path(:amazon_q), **message ) end diff --git a/ee/app/helpers/ee/integrations_helper.rb b/ee/app/helpers/ee/integrations_helper.rb index 115c54881ed52bae27be059ad0ea5009997608cf..61a2e0c52e8a268cdbdf035e936897fa8a54e979 100644 --- a/ee/app/helpers/ee/integrations_helper.rb +++ b/ee/app/helpers/ee/integrations_helper.rb @@ -52,6 +52,10 @@ def integration_form_data(integration, project: nil, group: nil) form_data[:jwt_claims] = ::Integrations::GoogleCloudPlatform::WorkloadIdentityFederation.jwt_claim_mapping_script_value end + if integration.is_a?(::Integrations::AmazonQ) + form_data[:amazon_q] = amazon_q_data + end + form_data end @@ -91,6 +95,31 @@ def zentao_issues_show_data } end + def amazon_q_data + result = ::Ai::AmazonQ::IdentityProviderPayloadFactory.new.execute + + identity_provider = + case result + in { ok: payload } + payload + in { err: err } + flash[:alert] = [ + s_('AmazonQ|Something went wrong retrieving the identity provider payload.'), + err[:message] + ].join(' ').squish + + {} + end + + { + submit_url: admin_ai_amazon_q_settings_path, + disconnect_url: disconnect_admin_ai_amazon_q_settings_path, + ready: ::Ai::Setting.instance.amazon_q_ready.to_s, + role_arn: ::Ai::Setting.instance.amazon_q_role_arn, + availability: ::Gitlab::CurrentSettings.duo_availability + }.merge(identity_provider) + end + def integrations_allow_list_data integrations = Integration.available_integration_names(include_blocked_by_settings: true).map do |integration| model = Integration.integration_name_to_model(integration) diff --git a/ee/app/models/concerns/ee/integrations/base/integration.rb b/ee/app/models/concerns/ee/integrations/base/integration.rb index 8576c4a384c1886965055cc245061f226ec80d7b..7f4b7d3a8aa33e0f163a7c8977c22490f0e9bd08 100644 --- a/ee/app/models/concerns/ee/integrations/base/integration.rb +++ b/ee/app/models/concerns/ee/integrations/base/integration.rb @@ -21,6 +21,10 @@ module Integration google_cloud_platform_artifact_registry ].freeze + EE_INSTANCE_LEVEL_ONLY_INTEGRATION_NAMES = %w[ + amazon_q + ].freeze + GOOGLE_CLOUD_PLATFORM_INTEGRATION_NAMES = %w[ google_cloud_platform_artifact_registry google_cloud_platform_workload_identity_federation @@ -51,6 +55,18 @@ def integration_names names end + override :instance_specific_integration_names + def instance_specific_integration_names + EE_INSTANCE_LEVEL_ONLY_INTEGRATION_NAMES + super + end + + override :disabled_integration_names + def disabled_integration_names + disabled = super + disabled += ['amazon_q'] unless ::Ai::AmazonQ.feature_available? + disabled + end + override :project_specific_integration_names def project_specific_integration_names names = super + EE_PROJECT_LEVEL_ONLY_INTEGRATION_NAMES diff --git a/ee/app/models/concerns/integrations/base/amazon_q.rb b/ee/app/models/concerns/integrations/base/amazon_q.rb index ddde320d19095c5c4780f9a8c5e921a22277e22c..f30807bb0125ecf5c56ba4654839742c7e967e6a 100644 --- a/ee/app/models/concerns/integrations/base/amazon_q.rb +++ b/ee/app/models/concerns/integrations/base/amazon_q.rb @@ -53,6 +53,10 @@ def editable? false end + def manual_activation? + false + end + class_methods do def title s_('AmazonQ|Amazon Q') diff --git a/ee/app/views/admin/application_settings/_amazon_q.html.haml b/ee/app/views/admin/application_settings/_amazon_q.html.haml index 7f167742e66d65ae4645e806dd221c590f7cd4e3..5ed38593ef004196fc43f0e3df993588e4b0b122 100644 --- a/ee/app/views/admin/application_settings/_amazon_q.html.haml +++ b/ee/app/views/admin/application_settings/_amazon_q.html.haml @@ -8,5 +8,5 @@ = safe_format(s_('AmazonQ|Use GitLab Duo with Amazon Q to create and review merge requests and upgrade Java. %{link_start}Learn more%{link_end}.'), tag_pair(link, :link_start, :link_end)) - c.with_body do .gl-mt-3 - = render Pajamas::ButtonComponent.new(variant: :confirm, href: admin_ai_amazon_q_settings_path) do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: edit_admin_application_settings_integration_path(:amazon_q)) do = s_('AmazonQ|View configuration setup') diff --git a/ee/config/metrics/counts_all/20250227153834_groups_amazon_q_active.yml b/ee/config/metrics/counts_all/20250227153834_groups_amazon_q_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..b1c1cb29d66f98618654f74c3d07ad920d2a4217 --- /dev/null +++ b/ee/config/metrics/counts_all/20250227153834_groups_amazon_q_active.yml @@ -0,0 +1,16 @@ +--- +key_path: counts.groups_amazon_q_active +description: Count of groups with active integrations for AmazonQ +product_group: duo_chat +product_categories: +- duo_chat +value_type: number +status: active +milestone: "17.10" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182145 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +tiers: +- ultimate diff --git a/ee/config/metrics/counts_all/20250227153834_groups_inheriting_amazon_q_active.yml b/ee/config/metrics/counts_all/20250227153834_groups_inheriting_amazon_q_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..9feec15c4d073959983381be3e932098b42173e1 --- /dev/null +++ b/ee/config/metrics/counts_all/20250227153834_groups_inheriting_amazon_q_active.yml @@ -0,0 +1,16 @@ +--- +key_path: counts.groups_inheriting_amazon_q_active +description: Count of inherited groups with active integrations for AmazonQ +product_group: duo_chat +product_categories: +- duo_chat +value_type: number +status: active +milestone: "17.10" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182145 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +tiers: +- ultimate diff --git a/ee/config/metrics/counts_all/20250227153834_instances_amazon_q_active.yml b/ee/config/metrics/counts_all/20250227153834_instances_amazon_q_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..9898a57e6f7d775152e753c41b7105ef56ed4bdb --- /dev/null +++ b/ee/config/metrics/counts_all/20250227153834_instances_amazon_q_active.yml @@ -0,0 +1,16 @@ +--- +key_path: counts.instances_amazon_q_active +description: Count of instances with active integrations for AmazonQ +product_group: duo_chat +product_categories: +- duo_chat +value_type: number +status: active +milestone: "17.10" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182145 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +tiers: +- ultimate diff --git a/ee/config/metrics/counts_all/20250227153834_projects_amazon_q_active.yml b/ee/config/metrics/counts_all/20250227153834_projects_amazon_q_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..980a79090b02ab38f05ec2d70450ae3b710e719c --- /dev/null +++ b/ee/config/metrics/counts_all/20250227153834_projects_amazon_q_active.yml @@ -0,0 +1,16 @@ +--- +key_path: counts.projects_amazon_q_active +description: Count of projects with active integrations for AmazonQ +product_group: duo_chat +product_categories: +- duo_chat +value_type: number +status: active +milestone: "17.10" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182145 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +tiers: +- ultimate diff --git a/ee/config/metrics/counts_all/20250227153834_projects_inheriting_amazon_q_active.yml b/ee/config/metrics/counts_all/20250227153834_projects_inheriting_amazon_q_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..2a9359e3f8f223e2844167b6a66f6c318714e166 --- /dev/null +++ b/ee/config/metrics/counts_all/20250227153834_projects_inheriting_amazon_q_active.yml @@ -0,0 +1,16 @@ +--- +key_path: counts.projects_inheriting_amazon_q_active +description: Count of inherited projects with active integrations for AmazonQ +product_group: duo_chat +product_categories: +- duo_chat +value_type: number +status: active +milestone: "17.10" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182145 +time_frame: all +data_source: database +data_category: optional +performance_indicator_type: [] +tiers: +- ultimate diff --git a/ee/spec/factories/integrations.rb b/ee/spec/factories/integrations.rb index a8a8c66d3a8d1f66fc27dbe20dfa9e6aae361456..54cd5bf64c6d20668bbe993fd0289ae7c67fd0c3 100644 --- a/ee/spec/factories/integrations.rb +++ b/ee/spec/factories/integrations.rb @@ -36,4 +36,11 @@ active { true } token { 'git_guardian-token' } end + + factory :amazon_q_integration, class: 'Integrations::AmazonQ' do + type { 'Integrations::AmazonQ' } + active { true } + instance { true } + role_arn { 'q' } + end end diff --git a/ee/spec/frontend/integrations/edit/components/sections/amazon_q_spec.js b/ee/spec/frontend/integrations/edit/components/sections/amazon_q_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ebdd8b41f7243404078f6f2e18cfc3b2b31c71b7 --- /dev/null +++ b/ee/spec/frontend/integrations/edit/components/sections/amazon_q_spec.js @@ -0,0 +1,72 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import IntegrationSectionAmazonQ from 'ee/integrations/edit/components/sections/amazon_q.vue'; +import AmazonQApp from 'ee/amazon_q_settings/components/app.vue'; +import { createStore } from '~/integrations/edit/store'; + +const TEST_SUBMIT_URL = '/foo/submit/url'; +const TEST_DISCONNECT_URL = '/foo/disconnect/url'; +const TEST_AMAZON_Q_VALID_ROLE_ARN = 'arn:aws:iam::123456789012:role/valid-role'; + +describe('ee/integrations/edit/components/sections/amazon_q.vue', () => { + let store; + let wrapper; + + const createWrapper = () => { + wrapper = shallowMountExtended(IntegrationSectionAmazonQ, { store }); + }; + + const findAmazonQApp = () => wrapper.findComponent(AmazonQApp); + + beforeEach(() => { + store = createStore({ + customState: { + amazonQProps: { + amazonQSubmitUrl: TEST_SUBMIT_URL, + amazonQDisconnectUrl: TEST_DISCONNECT_URL, + amazonQInstanceUid: 'instance-uid', + amazonQAwsProviderUrl: 'https://provider.url', + amazonQAwsAudience: 'audience', + amazonQReady: true, + amazonQAvailability: 'default_on', + amazonQRoleArn: TEST_AMAZON_Q_VALID_ROLE_ARN, + }, + }, + }); + }); + + describe('default', () => { + beforeEach(() => { + createWrapper(); + }); + + it('should render app.vue', () => { + expect(findAmazonQApp().exists()).toBe(true); + expect(findAmazonQApp().props()).toEqual({ + submitUrl: TEST_SUBMIT_URL, + disconnectUrl: TEST_DISCONNECT_URL, + amazonQSettings: { + availability: 'default_on', + ready: true, + roleArn: TEST_AMAZON_Q_VALID_ROLE_ARN, + }, + identityProviderPayload: { + aws_audience: 'audience', + aws_provider_url: 'https://provider.url', + instance_uid: 'instance-uid', + }, + }); + }); + }); + + describe('when store doesnt have amazonQProps', () => { + beforeEach(() => { + store = createStore({ customState: {} }); + + createWrapper(); + }); + + it('does not render app.vue', () => { + expect(findAmazonQApp().exists()).toBe(false); + }); + }); +}); diff --git a/ee/spec/helpers/ee/integrations_helper_spec.rb b/ee/spec/helpers/ee/integrations_helper_spec.rb index 30a1400fce58c0ad80927ef5d677a30c7118d373..7c648ab0a43583b527503b8e9f8af79ad39a1726 100644 --- a/ee/spec/helpers/ee/integrations_helper_spec.rb +++ b/ee/spec/helpers/ee/integrations_helper_spec.rb @@ -152,6 +152,53 @@ end end end + + context 'when integration is at the instance level' do + subject(:form_data) { helper.integration_form_data(build(:amazon_q_integration)) } + + context 'with Amazon Q integration' do + before do + ::Ai::Setting.instance.update!( + amazon_q_ready: true, + amazon_q_role_arn: 'role-arn' + ) + end + + it 'returns the data related to amazon q' do + identity_provider_params = { + instance_uid: 'instance_uid', + aws_provider_url: "https://auth.token.gitlab.com/cc/oidc/instance_uid", + aws_audience: "gitlab-cc-instance_uid" + } + + expect_next_instance_of(::Ai::AmazonQ::IdentityProviderPayloadFactory) do |instance| + expect(instance).to receive(:execute).and_return({ ok: identity_provider_params }) + end + + is_expected.to include({ + amazon_q: { + submit_url: admin_ai_amazon_q_settings_path, + disconnect_url: disconnect_admin_ai_amazon_q_settings_path, + ready: "true", + role_arn: "role-arn", + availability: :default_on + }.merge(identity_provider_params) + }) + + expect(flash[:alert]).to be_nil + end + + it 'adds an error to flash if identity provider factory fails' do + expect_next_instance_of(::Ai::AmazonQ::IdentityProviderPayloadFactory) do |instance| + expect(instance).to receive(:execute).and_return({ err: { message: 'failure' } }) + end + + amazon_q_data + + expect(flash[:alert]).to eq('Something went wrong retrieving the identity provider payload. failure') + end + end + end end describe '#integrations_allow_list_data' do diff --git a/ee/spec/models/ee/integration_spec.rb b/ee/spec/models/ee/integration_spec.rb index 1d8492487ff11fe9b5ab70704dea5c8a9f0574de..f07566e3dd6bdd4cd3d2f51c877af80799e772ef 100644 --- a/ee/spec/models/ee/integration_spec.rb +++ b/ee/spec/models/ee/integration_spec.rb @@ -182,6 +182,22 @@ expect(described_class.integration_name_to_type(Integrations::Asana.to_param)).to eq('Integrations::Asana') end end + + context 'with amazon q integration' do + it 'does not include amazon q integration' do + expect(described_class.available_integration_names).not_to include('amazon_q') + end + + context 'when it is enabled' do + before do + stub_licensed_features(amazon_q: true) + end + + it 'includes amazon q integration' do + expect(described_class.available_integration_names).to include('amazon_q') + end + end + end end describe '.available_integration_names' do @@ -355,4 +371,10 @@ integration_class.new.blocked_by_settings? end end + + describe '.instance_specific_integration_types' do + subject { described_class.instance_specific_integration_types } + + it { is_expected.to eq(['Integrations::AmazonQ', 'Integrations::BeyondIdentity']) } + end end diff --git a/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb b/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb index b2f68417b7755856687298a28d7bcf43e42dfabd..262089364dc985a55624e9a7fc300254d5ad5c1f 100644 --- a/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb +++ b/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb @@ -125,7 +125,7 @@ perform_request - expect(response).to redirect_to(admin_ai_amazon_q_settings_path) + expect(response).to redirect_to(edit_admin_application_settings_integration_path(:amazon_q)) end end @@ -138,7 +138,7 @@ perform_request expect(flash[:alert]).to be_nil - expect(response).to redirect_to(admin_ai_amazon_q_settings_path) + expect(response).to redirect_to(edit_admin_application_settings_integration_path(:amazon_q)) end end end diff --git a/ee/spec/views/admin/application_settings/_amazon_q.html.haml_spec.rb b/ee/spec/views/admin/application_settings/_amazon_q.html.haml_spec.rb index 876f4997b034484f8bd38522b9ea479e05cd1a33..9ec281fa059a653896db7abe8954ee31f2c18083 100644 --- a/ee/spec/views/admin/application_settings/_amazon_q.html.haml_spec.rb +++ b/ee/spec/views/admin/application_settings/_amazon_q.html.haml_spec.rb @@ -16,7 +16,10 @@ context 'when feature available' do it 'renders settings' do expect(rendered).to have_css('#js-amazon-q-settings') - expect(rendered).to have_link(s_('AmazonQ|View configuration setup'), href: admin_ai_amazon_q_settings_path) + expect(rendered).to have_link( + s_('AmazonQ|View configuration setup'), + href: edit_admin_application_settings_integration_path(:amazon_q) + ) end end diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index aed0e93b3854ff5ee74410e42eb98ccf7fbe8e82..295d8714aba9f969b7653a50ffc6dc8944cbbd47 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -1777,6 +1777,6 @@ def data_fields describe '.instance_specific_integration_types' do subject { described_class.instance_specific_integration_types } - it { is_expected.to eq(['Integrations::BeyondIdentity']) } + it { is_expected.to include('Integrations::BeyondIdentity') } end end