diff --git a/.gitlab/lint/unused_methods/excluded_methods.yml b/.gitlab/lint/unused_methods/excluded_methods.yml index e28bcfc6006b45baf06a67127e55430a06cc5385..6d05448ebf537c57179780370c36405de2e7f0f9 100644 --- a/.gitlab/lint/unused_methods/excluded_methods.yml +++ b/.gitlab/lint/unused_methods/excluded_methods.yml @@ -2,6 +2,9 @@ # scripts/lint/unused_methods.rb, however subsequent research shows # them to be false positives, and should be ignored when running that script. # +x509_commit: + file: app/models/commit_signatures/x509_commit_signature.rb + reason: Accessed from lib/tasks/gitlab/x509/update.rake sort_value_stars_desc: file: app/helpers/sorting_titles_values_helper.rb reason: Used to build sorting dropdowns in app/helpers/sorting_helper.rb diff --git a/.gitlab/lint/unused_methods/potential_methods_to_remove.yml b/.gitlab/lint/unused_methods/potential_methods_to_remove.yml index dd28696a5c2855ac85656c6d862ee9db044e42a9..d0ccf4801ce39ad4e6bf6d7ebd9cd93c8731465a 100644 --- a/.gitlab/lint/unused_methods/potential_methods_to_remove.yml +++ b/.gitlab/lint/unused_methods/potential_methods_to_remove.yml @@ -237,8 +237,6 @@ updateable?: file: app/models/clusters/concerns/application_status.rb update_available?: file: app/models/clusters/concerns/application_version.rb -x509_commit: - file: app/models/commit_signatures/x509_commit_signature.rb user_authored?: file: app/models/concerns/awardable.rb degenerate!: diff --git a/doc/administration/settings/scim_setup.md b/doc/administration/settings/scim_setup.md index 9760678bdc52f704c7a6fa90125f0fb2dfb91828..01a917053ee140f9ff7f11552f2df067a92f5d72 100644 --- a/doc/administration/settings/scim_setup.md +++ b/doc/administration/settings/scim_setup.md @@ -320,6 +320,9 @@ For detailed instructions on configuring group synchronization in your identity - [Okta Groups API](https://developer.okta.com/docs/reference/api/groups/) - [Microsoft Entra ID (Azure AD) SCIM Groups](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups) + - By default, the `displayName` source attribute is used to find SAML group links with user-friendly names. + - However, if your SAML group links use an object ID for the name, + you must update the source attribute to `objectId`. {{< alert type="warning" >}} diff --git a/doc/user/application_security/vulnerability_report/_index.md b/doc/user/application_security/vulnerability_report/_index.md index e1306800e213b2745e3ddb9c31345a9f21e201f6..63b7f537557c561db6051b506817a3e3b77b06dc 100644 --- a/doc/user/application_security/vulnerability_report/_index.md +++ b/doc/user/application_security/vulnerability_report/_index.md @@ -587,6 +587,13 @@ For more information, see the history. {{< /alert >}} +{{< alert type="note" >}} + +On GitLab Self-Managed, advanced vulnerability management capabilities might be temporarily unavailable, typically for a few hours, after upgrading from versions earlier than GitLab 18.7 while the data migration completes. +Full capabilities will be available after the migration finishes. + +{{< /alert >}} + GitLab primarily uses PostgreSQL for filtering in the vulnerability report. Due to database indexing limitations and performance challenges when applying multiple filters, GitLab uses [advanced search](../../search/advanced_search.md) for specific vulnerability management features. Advanced search powers the following features: @@ -602,7 +609,7 @@ Advanced search is used only for these specific features, including when they ar ### Requirements -To use the filters in advanced vulnerability management: +To use the capabilities in advanced vulnerability management: - You must have [advanced search enabled](../../search/advanced_search.md#use-advanced-search). - On GitLab Self-Managed, after [setting up advanced search](../../../integration/advanced_search/elasticsearch.md#enable-advanced-search), ensure the **Search with advanced search** checkbox is selected. diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index d76edcae14e7f9dc85f84d78f35f69c687ff28b4..f64d535db51c4b30bab824d685539ecb1dcaa528 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -2,7 +2,7 @@ stage: Create group: Import info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments -title: Migrate GitLab data by using by using file exports +title: Migrate GitLab data by using file exports description: "Use file exports to migrate GitLab data." --- diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/constants.js b/ee/app/assets/javascripts/ai/duo_agents_platform/constants.js index f7c201bca3d7faf5abb8519c779649b1083ae4f4..5cf98a2d20c6c137aa208dca5a9d604fd3c797ff 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/constants.js +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/constants.js @@ -124,3 +124,13 @@ export const DEFAULT_AGENT_PLATFORM_PAGINATION_VARIABLES = { after: null, last: null, }; + +// Can cancel only if session is in an active state (not already finished, failed, or stopped) +export const AGENT_PLATFORM_CANCELABLE_STATUSES = [ + 'CREATED', + 'RUNNING', + 'PAUSED', + 'INPUT_REQUIRED', + 'PLAN_APPROVAL_REQUIRED', + 'TOOL_CALL_APPROVAL_REQUIRED', +]; diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/fragments/flow.fragment.graphql b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/fragments/flow.fragment.graphql index 929e43672ef4f2f6958a13ac88f6a640b0c45002..96e4e977f09a7321e9b6e0c62feb526c84fdf909 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/fragments/flow.fragment.graphql +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/fragments/flow.fragment.graphql @@ -5,6 +5,9 @@ fragment FlowFragment on DuoWorkflow { updatedAt workflowDefinition userId + userPermissions { + updateDuoWorkflow + } project { id name diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_cancelation_modal.vue b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_cancelation_modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..f2d1e4003c79523526bd819a7f4888f967a3bc14 --- /dev/null +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_cancelation_modal.vue @@ -0,0 +1,69 @@ + + diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_details.vue b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_details.vue index d6f7830a3d8d8cea8fd1be685efd09820cb818db..f1c434e257d917483dfe646e3c782d914494f551 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_details.vue +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_details.vue @@ -59,7 +59,16 @@ export default { type: String, required: true, }, + workflowId: { + type: String, + required: true, + }, + canUpdateWorkflow: { + type: Boolean, + required: true, + }, }, + emits: ['cancel-session'], AGENTS_PLATFORM_INDEX_ROUTE, }; @@ -97,6 +106,9 @@ export default { :project="project" :updated-at="updatedAt" :executor-url="executorUrl" + :workflow-id="workflowId" + :can-update-workflow="canUpdateWorkflow" + @cancel-session="$emit('cancel-session')" /> diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_info.vue b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_info.vue index 8db528d638c1bee96c50eb3ccfb61fdf8295f1ce..03c616ae6208680bb6f8e1fbdd23ace467374e18 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_info.vue +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/show/components/agent_flow_info.vue @@ -1,18 +1,23 @@ diff --git a/ee/app/assets/javascripts/ai/settings/pages/ai_group_settings.vue b/ee/app/assets/javascripts/ai/settings/pages/ai_group_settings.vue index eb442ce5dc40cd836cfe57436324d5e4a43b50f2..0dbb3634b7dd0db2d380d685017692fab523d0e4 100644 --- a/ee/app/assets/javascripts/ai/settings/pages/ai_group_settings.vue +++ b/ee/app/assets/javascripts/ai/settings/pages/ai_group_settings.vue @@ -80,10 +80,10 @@ export default { ...(foundationalAgentsStatuses && { foundational_agents_statuses: transformedFoundationalAgentsStatuses, }), - ...(this.showDuoAgentPlatformEnabledSetting && { - duo_agent_platform_enabled: duoAgentPlatformEnabled, - }), ai_settings_attributes: { + ...(this.showDuoAgentPlatformEnabledSetting && { + duo_agent_platform_enabled: duoAgentPlatformEnabled, + }), duo_workflow_mcp_enabled: this.duoWorkflowMcp, foundational_agents_default_enabled: foundationalAgentsEnabled, }, diff --git a/ee/lib/ai/duo_workflow.rb b/ee/lib/ai/duo_workflow.rb index 6b95f7e7de7778b1b45065cf6f18dd43395a1560..726e9fedd46dbb370f08cc9562d9d48598ada409 100644 --- a/ee/lib/ai/duo_workflow.rb +++ b/ee/lib/ai/duo_workflow.rb @@ -59,7 +59,7 @@ def duo_agent_platform_available?(_container = nil) return true if ::Gitlab::Saas.feature_available?(:gitlab_com_subscriptions) # For self-managed/dedicated instances, use instance-level settings - ::Ai::Setting.instance.duo_agent_platform_enabled + ai_settings.duo_agent_platform_enabled end private diff --git a/ee/lib/api/ai/duo_workflows/workflows.rb b/ee/lib/api/ai/duo_workflows/workflows.rb index 3ce7f6f6f9f49b8b829a08713b07c130bfff1eb7..75592d427b8ba44232af4e58e348714f097eaf54 100644 --- a/ee/lib/api/ai/duo_workflows/workflows.rb +++ b/ee/lib/api/ai/duo_workflows/workflows.rb @@ -434,7 +434,7 @@ def create_workflow_params end post do ::Gitlab::QueryLimiting.disable!( - 'https://gitlab.com/gitlab-org/gitlab/-/issues/566195', new_threshold: 115 + 'https://gitlab.com/gitlab-org/gitlab/-/issues/566195', new_threshold: 116 ) container = if params[:project_id] diff --git a/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_cancelation_modal_spec.js b/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_cancelation_modal_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3bb57048239032fd1184f5bc339ad17e25fd514d --- /dev/null +++ b/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_cancelation_modal_spec.js @@ -0,0 +1,100 @@ +import { GlButton, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import AgentFlowCancelationModal from 'ee/ai/duo_agents_platform/pages/show/components/agent_flow_cancelation_modal.vue'; + +describe('AgentFlowCancelationModal', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(AgentFlowCancelationModal, { + propsData: { + visible: false, + loading: false, + ...props, + }, + stubs: { + GlModal, + GlButton, + }, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + const findCancelButton = () => wrapper.find('[data-testid="cancel-session-modal-cancel"]'); + const findConfirmButton = () => wrapper.find('[data-testid="cancel-session-modal-confirm"]'); + + describe('when component has rendered', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the modal with correct props', () => { + expect(findModal().exists()).toBe(true); + expect(findModal().props('modalId')).toBe('cancel-session-confirmation-modal'); + expect(findModal().props('title')).toBe('Cancel session?'); + expect(findModal().props('size')).toBe('sm'); + }); + + it('renders the confirmation message', () => { + expect(wrapper.text()).toContain( + 'Are you sure you want to cancel this session? This action cannot be undone.', + ); + }); + + it('renders cancel button with correct text', () => { + expect(findCancelButton().exists()).toBe(true); + expect(findCancelButton().text()).toBe('Cancel'); + }); + + it('renders confirm button with correct text and variant', () => { + const confirmButton = findConfirmButton(); + + expect(confirmButton.exists()).toBe(true); + expect(confirmButton.text()).toBe('Cancel session'); + expect(confirmButton.props()).toMatchObject({ + variant: 'danger', + }); + }); + }); + + describe('visibility', () => { + it.each` + visible | expected + ${true} | ${true} + ${false} | ${false} + `('passes visible=$visible prop to modal', ({ visible, expected }) => { + createComponent({ visible }); + + expect(findModal().props('visible')).toBe(expected); + }); + }); + + describe('loading state', () => { + it.each` + loading | expected + ${true} | ${true} + ${false} | ${false} + `('passes loading=$loading to confirm button', ({ loading, expected }) => { + createComponent({ loading }); + + expect(findConfirmButton().props('loading')).toBe(expected); + }); + }); + + describe('events', () => { + beforeEach(() => { + createComponent({ visible: true }); + }); + + it.each` + action | finder | event | emittedEvent + ${'modal hidden'} | ${() => findModal()} | ${'hide'} | ${'hide'} + ${'cancel clicked'} | ${findCancelButton} | ${'click'} | ${'hide'} + ${'confirm clicked'} | ${findConfirmButton} | ${'click'} | ${'confirm'} + `('emits $emittedEvent when $action', async ({ finder, event, emittedEvent }) => { + await finder().vm.$emit(event); + + expect(wrapper.emitted(emittedEvent)).toHaveLength(1); + }); + }); +}); diff --git a/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_details_spec.js b/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_details_spec.js index 0d3776a5db566febb882511febd12fdeb485a826..62ec4a8eddba37f2323a168df49e431bf5f491ce 100644 --- a/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_details_spec.js +++ b/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_details_spec.js @@ -13,12 +13,15 @@ describe('AgentFlowDetails', () => { const defaultProps = { isLoading: false, status: 'RUNNING', + humanStatus: 'Running', agentFlowDefinition: 'software_development', duoMessages: mockDuoMessages, executorUrl: 'https://gitlab.com/gitlab-org/gitlab/-/jobs/123', createdAt: '2023-01-01T00:54:00Z', updatedAt: '2024-01-02T00:34:00Z', userId: 'gid://gitlab/User/1', + workflowId: '123', + canUpdateWorkflow: true, project: { id: 'gid://gitlab/Project/1', name: 'Test Project', @@ -40,6 +43,10 @@ describe('AgentFlowDetails', () => { isSidePanelView: false, ...provide, }, + stubs: { + GlTabs: true, + GlTab: true, + }, }); }; @@ -99,11 +106,13 @@ describe('AgentFlowDetails', () => { expect(findAgentFlowInfo().props()).toEqual({ isLoading: false, status: defaultProps.status, + humanStatus: defaultProps.humanStatus, agentFlowDefinition: defaultProps.agentFlowDefinition, executorUrl: defaultProps.executorUrl, createdAt: '2023-01-01T00:54:00Z', updatedAt: '2024-01-02T00:34:00Z', project: defaultProps.project, + canUpdateWorkflow: true, }); }); diff --git a/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_info_spec.js b/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_info_spec.js index 3f080c92bc7d2941c74d19029546dd629e7453c5..3dc16509169c10694264171f34911270f22c21fc 100644 --- a/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_info_spec.js +++ b/ee/spec/frontend/ai/duo_agents_platform/pages/show/components/agent_flow_info_spec.js @@ -36,6 +36,8 @@ describe('AgentFlowInfo', () => { executorUrl: 'https://gitlab.com/gitlab-org/gitlab/-/jobs/123', createdAt: '2023-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', + workflowId: '4545', + canUpdateWorkflow: true, project: { id: 'gid://gitlab/Project/1', name: 'Test Project', @@ -65,6 +67,7 @@ describe('AgentFlowInfo', () => { const findListItemTitles = () => wrapper.findAll('[data-testid="info-title"]'); const findListItemValues = () => wrapper.findAll('[data-testid="info-value"]'); const findSkeletonLoaders = () => wrapper.findAllComponents(GlSkeletonLoader); + const findCancelButton = () => wrapper.find('[data-testid="cancel-session-button"]'); const findLinks = () => wrapper.findAllComponents(GlLink); const findBadge = () => wrapper.findComponent(GlBadge); @@ -259,4 +262,56 @@ describe('AgentFlowInfo', () => { }); }); }); + + describe('Cancel session button', () => { + describe('when session can be cancelled', () => { + it.each` + status | description + ${'CREATED'} | ${'created status'} + ${'RUNNING'} | ${'running status'} + ${'PAUSED'} | ${'paused status'} + ${'INPUT_REQUIRED'} | ${'input_required status'} + ${'PLAN_APPROVAL_REQUIRED'} | ${'plan_approval_required status'} + ${'TOOL_CALL_APPROVAL_REQUIRED'} | ${'tool_call_approval_required status'} + `('shows cancel button for $description', ({ status }) => { + createComponent({ status }); + + expect(findCancelButton().exists()).toBe(true); + expect(findCancelButton().text()).toBe('Cancel session'); + expect(findCancelButton().attributes('variant')).toBe('danger'); + expect(findCancelButton().props().disabled).toBe(false); + }); + + it('emits cancel-session event when clicked', async () => { + createComponent({ status: 'RUNNING' }); + + await findCancelButton().vm.$emit('click'); + + expect(wrapper.emitted('cancel-session')).toHaveLength(1); + }); + }); + + describe('when session cannot be cancelled', () => { + it.each` + status | description + ${'FINISHED'} | ${'finished status'} + ${'FAILED'} | ${'failed status'} + `('does not show cancel button for $description', ({ status }) => { + createComponent({ status }); + + expect(findCancelButton().exists()).toBe(false); + }); + }); + + describe('when user lacks permission to cancel', () => { + beforeEach(() => { + createComponent({ status: 'RUNNING', canUpdateWorkflow: false }); + }); + + it('shows cancel button disabled', () => { + expect(findCancelButton().exists()).toBe(true); + expect(findCancelButton().props('disabled')).toBe(true); + }); + }); + }); }); diff --git a/ee/spec/frontend/ai/duo_agents_platform/pages/show/duo_agents_platform_show_spec.js b/ee/spec/frontend/ai/duo_agents_platform/pages/show/duo_agents_platform_show_spec.js index 27f6b8e169be1c01d32224c1748c8ae5becb68df..44616d3dd47147468bd2ac66d8735e9835772da1 100644 --- a/ee/spec/frontend/ai/duo_agents_platform/pages/show/duo_agents_platform_show_spec.js +++ b/ee/spec/frontend/ai/duo_agents_platform/pages/show/duo_agents_platform_show_spec.js @@ -1,9 +1,11 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import axios from '~/lib/utils/axios_utils'; import DuoAgentsPlatformShow from 'ee/ai/duo_agents_platform/pages/show/duo_agents_platform_show.vue'; import AgentFlowDetails from 'ee/ai/duo_agents_platform/pages/show/components/agent_flow_details.vue'; +import AgentFlowCancelationModal from 'ee/ai/duo_agents_platform/pages/show/components/agent_flow_cancelation_modal.vue'; import { DUO_AGENTS_PLATFORM_POLLING_INTERVAL } from 'ee/ai/duo_agents_platform/constants'; import { getAgentFlow } from 'ee/ai/duo_agents_platform/graphql/queries/get_agent_flow.query.graphql'; @@ -18,6 +20,7 @@ import { mockGetAgentFlowResponse, mockDuoMessages } from '../../../mocks'; Vue.use(VueApollo); jest.mock('~/alert'); +jest.mock('~/lib/utils/axios_utils'); describe('DuoAgentsPlatformShow', () => { let wrapper; @@ -49,6 +52,7 @@ describe('DuoAgentsPlatformShow', () => { }; const findAgentFlowDetails = () => wrapper.findComponent(AgentFlowDetails); + const findCancelConfirmationModal = () => wrapper.findComponent(AgentFlowCancelationModal); beforeEach(() => { getAgentFlowHandler = jest.fn().mockResolvedValue(mockGetAgentFlowResponse); @@ -78,6 +82,7 @@ describe('DuoAgentsPlatformShow', () => { agentFlowDefinition: 'Software development', duoMessages: mockDuoMessages, project: mockGetAgentFlowResponse.data.duoWorkflowWorkflows.edges[0].node.project, + canUpdateWorkflow: true, }); }); }); @@ -126,7 +131,7 @@ describe('DuoAgentsPlatformShow', () => { }); it('calls createAlert with the error message', () => { - expect(createAlert).toHaveBeenCalledWith({ message: errorMessage }); + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, captureError: true }); }); }); @@ -139,6 +144,7 @@ describe('DuoAgentsPlatformShow', () => { it('calls createAlert with default error message', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while fetching Agent Flows', + captureError: true, }); }); }); @@ -191,4 +197,122 @@ describe('DuoAgentsPlatformShow', () => { }); }); }); + + describe('Cancel session functionality', () => { + beforeEach(async () => { + await createWrapper(); + }); + + describe('confirmation modal', () => { + it('renders the cancel session confirmation modal', () => { + expect(findCancelConfirmationModal().exists()).toBe(true); + expect(findCancelConfirmationModal().props('visible')).toBe(false); + expect(findCancelConfirmationModal().props('loading')).toBe(false); + }); + + it('shows modal when cancel-session event is emitted from AgentFlowDetails', async () => { + findAgentFlowDetails().vm.$emit('cancel-session'); + await nextTick(); + + expect(findCancelConfirmationModal().props('visible')).toBe(true); + }); + + it('hides modal when hide event is emitted', async () => { + findAgentFlowDetails().vm.$emit('cancel-session'); + await nextTick(); + + expect(findCancelConfirmationModal().props('visible')).toBe(true); + + findCancelConfirmationModal().vm.$emit('hide'); + await nextTick(); + + expect(findCancelConfirmationModal().props('visible')).toBe(false); + }); + }); + + describe('session cancellation', () => { + beforeEach(async () => { + axios.patch = jest.fn(); + getAgentFlowHandler.mockClear(); + await createWrapper(); + }); + + it('calls API to cancel session when confirmed', async () => { + axios.patch.mockResolvedValue({ data: { status: 'STOPPED' } }); + + findAgentFlowDetails().vm.$emit('cancel-session'); + await nextTick(); + + expect(findCancelConfirmationModal().props('visible')).toBe(true); + + findCancelConfirmationModal().vm.$emit('confirm'); + await waitForPromises(); + + expect(axios.patch).toHaveBeenCalledWith('/api/v4/ai/duo_workflows/workflows/1', { + status_event: 'stop', + }); + expect(findCancelConfirmationModal().props('visible')).toBe(false); + }); + + it('shows success alert and refetches data on successful cancellation', async () => { + axios.patch.mockResolvedValue({ data: { status: 'STOPPED' } }); + + findAgentFlowDetails().vm.$emit('cancel-session'); + await nextTick(); + + findCancelConfirmationModal().vm.$emit('confirm'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Session has been cancelled successfully.', + variant: 'success', + }); + expect(getAgentFlowHandler).toHaveBeenCalledTimes(1); + }); + + it('shows error alert on API failure', async () => { + const errorMessage = 'Failed to cancel'; + axios.patch.mockRejectedValue({ + response: { + status: 422, + data: { message: errorMessage }, + }, + }); + + findAgentFlowDetails().vm.$emit('cancel-session'); + await nextTick(); + + findCancelConfirmationModal().vm.$emit('confirm'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: errorMessage, + captureError: true, + variant: 'danger', + }); + }); + + it('shows loading state during cancellation', async () => { + let resolveRequest; + axios.patch.mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + + findAgentFlowDetails().vm.$emit('cancel-session'); + await nextTick(); + + const cancelPromise = findCancelConfirmationModal().vm.$emit('confirm'); + await nextTick(); + + expect(findCancelConfirmationModal().props('loading')).toBe(true); + + // Resolve the promise to clean up + resolveRequest({ data: { status: 'STOPPED' } }); + await cancelPromise; + }); + }); + }); }); diff --git a/ee/spec/frontend/ai/mocks.js b/ee/spec/frontend/ai/mocks.js index 1354ef12b2752c10f268073ddbf21e3903f5ea7a..bf2a5d5622ca303aa2a154a1d4384b3f79511a6b 100644 --- a/ee/spec/frontend/ai/mocks.js +++ b/ee/spec/frontend/ai/mocks.js @@ -8,6 +8,9 @@ export const mockAgentFlowEdges = [ updatedAt: '2024-01-01T00:00:00Z', workflowDefinition: 'software_development', userId: 'gid://gitlab/User/1', + userPermissions: { + updateDuoWorkflow: true, + }, project: { id: 'gid://gitlab/Project/1', name: 'Test Project', @@ -29,6 +32,9 @@ export const mockAgentFlowEdges = [ updatedAt: '2024-01-02T00:00:00Z', workflowDefinition: 'convert_to_gitlab_ci', userId: 'gid://gitlab/User/1', + userPermissions: { + updateDuoWorkflow: true, + }, project: { id: 'gid://gitlab/Project/2', name: 'Another Project', @@ -50,6 +56,9 @@ export const mockAgentFlowEdges = [ updatedAt: '2024-01-03T00:00:00Z', workflowDefinition: 'chat', userId: 'gid://gitlab/User/2', + userPermissions: { + updateDuoWorkflow: false, + }, project: { id: 'gid://gitlab/Project/3', name: 'Chat Project', @@ -128,6 +137,9 @@ export const mockGetAgentFlowResponse = { humanStatus: 'running', workflowDefinition: 'software_development', userId: 'gid://gitlab/User/1', + userPermissions: { + updateDuoWorkflow: true, + }, project: { id: 'gid://gitlab/Project/1', name: 'Test Project', diff --git a/ee/spec/frontend/ai/settings/pages/ai_group_settings_spec.js b/ee/spec/frontend/ai/settings/pages/ai_group_settings_spec.js index 851dd908d0f04deb66131a892126f9b485bfc81e..114537f84d872657595058c89cd65d219c5ee203 100644 --- a/ee/spec/frontend/ai/settings/pages/ai_group_settings_spec.js +++ b/ee/spec/frontend/ai/settings/pages/ai_group_settings_spec.js @@ -152,8 +152,8 @@ describe('AiGroupSettings', () => { duo_sast_fp_detection_availability: false, foundational_agents_statuses: expectedFilteredAgentStatuses, enabled_foundational_flows: [], - duo_agent_platform_enabled: true, ai_settings_attributes: { + duo_agent_platform_enabled: true, duo_workflow_mcp_enabled: false, foundational_agents_default_enabled: true, }, @@ -184,7 +184,9 @@ describe('AiGroupSettings', () => { expect(updateGroupSettings).toHaveBeenCalledWith( '100', expect.not.objectContaining({ - duo_agent_platform_enabled: expect.anything(), + ai_settings_attributes: { + duo_agent_platform_enabled: expect.anything(), + }, }), ); }); diff --git a/ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb b/ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb index a347ae0d882c9840f6a6fc09cc3f11a6e64791ab..3ca4c5a1295a7fbf907be2efefbad752cfb6a749 100644 --- a/ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb +++ b/ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb @@ -678,6 +678,8 @@ let(:params) { super().merge(ai_catalog_item_version_id: ai_catalog_item_version.id) } before do + # TODO: use factory instead https://gitlab.com/gitlab-org/gitlab/-/issues/583818 + allow(::Ai::DuoWorkflow).to receive(:duo_agent_platform_available?).and_return(true) allow_next_instance_of(Ai::Catalog::ItemConsumersFinder) do |finder| allow(finder).to receive(:execute).and_return(class_double(::Ai::Catalog::ItemConsumer, exists?: true)) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 046770de086b2fac0ac538e941a2b4cc6fa3ebbd..384b7e4ece6e96b750d6025bb39a285df4afb6db 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -25430,12 +25430,21 @@ msgstr "" msgid "DuoAgentsPlatform|An error occurred while fetching users." msgstr "" +msgid "DuoAgentsPlatform|Are you sure you want to cancel this session? This action cannot be undone." +msgstr "" + msgid "DuoAgentsPlatform|Are you sure you want to delete this trigger? This action cannot be undone." msgstr "" msgid "DuoAgentsPlatform|Automate" msgstr "" +msgid "DuoAgentsPlatform|Cancel session" +msgstr "" + +msgid "DuoAgentsPlatform|Cancel session?" +msgstr "" + msgid "DuoAgentsPlatform|Concise view" msgstr "" @@ -25502,6 +25511,9 @@ msgstr "" msgid "DuoAgentsPlatform|Event types" msgstr "" +msgid "DuoAgentsPlatform|Failed to cancel session. Please try again." +msgstr "" + msgid "DuoAgentsPlatform|Failed to delete trigger." msgstr "" @@ -25607,6 +25619,9 @@ msgstr "" msgid "DuoAgentsPlatform|Service account user" msgstr "" +msgid "DuoAgentsPlatform|Session has been cancelled successfully." +msgstr "" + msgid "DuoAgentsPlatform|Sessions" msgstr "" @@ -25652,6 +25667,9 @@ msgstr "" msgid "DuoAgentsPlatform|Write file" msgstr "" +msgid "DuoAgentsPlatform|You do not have permission to cancel this session." +msgstr "" + msgid "DuoAgentsPlatform|⚠️ Create a unique service account for each project." msgstr "" diff --git a/qa/performance_test/k6_test/group_merge_requests.js b/qa/performance_test/k6_test/group_merge_requests.js index c72cb1412aaeaf3a5ae96d89559dca08bb8d36e8..fb6eab6330df33a1d8f4a3236b63cedc83dc9bdf 100644 --- a/qa/performance_test/k6_test/group_merge_requests.js +++ b/qa/performance_test/k6_test/group_merge_requests.js @@ -9,13 +9,13 @@ export const LOAD_TEST_DURATION = '50s'; export const WARMUP_TEST_VUS = 1; export const WARMUP_TEST_DURATION = '10s'; -// Global variable to store the group ID -let groupId; +let API_URL; export function setup() { const baseUrl = __ENV.GITLAB_URL || `http://gitlab.${__ENV.AI_GATEWAY_IP}.nip.io`; const token = __ENV.GITLAB_QA_ADMIN_ACCESS_TOKEN || ''; const groupName = 'Test Seed Group'; + const apiVersion = 'v4'; // Search for the group by name const searchUrl = `${baseUrl}/api/v4/groups?search=${encodeURIComponent(groupName)}`; @@ -33,14 +33,12 @@ export function setup() { const targetGroup = groups.find((group) => group.name === groupName); if (targetGroup) { - console.log(`Found group '${groupName}' with ID: ${targetGroup.id}`); - return { groupId: targetGroup.id }; + const apiUrl = `${apiVersion}/groups/${targetGroup.id}/merge_requests`; + return { apiUrl }; } - console.error(`Group '${groupName}' not found`); - return { groupId: '5' }; // Fallback to default } - console.error(`Failed to search for groups: ${res.status}`); - return { groupId: '5' }; // Fallback to default + const apiUrl = `${apiVersion}/groups/1/merge_requests`; // Fallback to default + return { apiUrl }; } export const options = { @@ -71,11 +69,10 @@ export const options = { export default function groupMergeRequestsTest(data) { const baseUrl = __ENV.GITLAB_URL || `http://gitlab.${__ENV.AI_GATEWAY_IP}.nip.io`; - const apiVersion = 'v4'; - groupId = __ENV.GROUP_ID || data.groupId; const token = __ENV.GITLAB_QA_ADMIN_ACCESS_TOKEN || ''; + API_URL = data.apiUrl; - const url = `${baseUrl}/api/${apiVersion}/groups/${groupId}/merge_requests`; + const url = `${baseUrl}/api/${API_URL}`; const params = { headers: { diff --git a/qa/performance_test/k6_test/project_merge_requests.js b/qa/performance_test/k6_test/project_merge_requests.js index c24c978061529ab739aac1f8091a9118906639da..8509707b3f806ba88a8ce7f53c78db566c261758 100644 --- a/qa/performance_test/k6_test/project_merge_requests.js +++ b/qa/performance_test/k6_test/project_merge_requests.js @@ -9,11 +9,13 @@ export const LOAD_TEST_DURATION = '50s'; export const WARMUP_TEST_VUS = 1; export const WARMUP_TEST_DURATION = '10s'; -let projectId; +let API_URL; + export function setup() { const baseUrl = __ENV.GITLAB_URL || `http://gitlab.${__ENV.AI_GATEWAY_IP}.nip.io`; const token = __ENV.GITLAB_QA_ADMIN_ACCESS_TOKEN || ''; const projectName = 'Test Seed Project'; + const apiVersion = 'v4'; // Search for the group by name const searchUrl = `${baseUrl}/api/v4/projects?search=${encodeURIComponent(projectName)}`; @@ -32,14 +34,15 @@ export function setup() { if (targetProject) { console.log(`Found project '${projectName}' with ID: ${targetProject.id}`); - return { projectId: targetProject.id }; + const apiUrl = `${apiVersion}/projects/${targetProject.id}/merge_requests`; + return { apiUrl }; } - console.error(`Project '${projectName}' not found`); - return { projectId: '5' }; // Fallback to default } - console.error(`Failed to search for groups: ${res.status}`); - return { projectId: '1' }; // Fallback to default + console.error(`Failed to search for projects: ${res.status}`); + const apiUrl = `${apiVersion}/projects/1/merge_requests`; // Fallback to default + return { apiUrl }; } + export const options = { scenarios: { warmup: { @@ -68,11 +71,11 @@ export const options = { export default function projectMergeRequestsTest(data) { const baseUrl = __ENV.GITLAB_URL || `http://gitlab.${__ENV.AI_GATEWAY_IP}.nip.io`; - const apiVersion = 'v4'; - projectId = __ENV.PROJECT_ID || data.projectId; const token = __ENV.GITLAB_QA_ADMIN_ACCESS_TOKEN || ''; - const url = `${baseUrl}/api/${apiVersion}/projects/${projectId}/merge_requests`; + API_URL = data.apiUrl; + + const url = `${baseUrl}/api/${API_URL}`; const params = { headers: {