diff --git a/app/assets/javascripts/ci/constants.js b/app/assets/javascripts/ci/constants.js index 07bf90910f6e67edced84b3431948758de9485e9..059b6c3beb6f9a653d245ebfc01e3b1713cdef9c 100644 --- a/app/assets/javascripts/ci/constants.js +++ b/app/assets/javascripts/ci/constants.js @@ -48,3 +48,6 @@ export const PIPELINE_POLL_INTERVAL_BACKOFF = 1.2; export const FOUR_MINUTES_IN_MS = 1000 * 60 * 4; export const NETWORK_STATUS_READY = 7; + +// For MR pipelines that need changes approved by a human +export const AI_REVIEW_TEXT = `code should be reviewed by a non-AI user`; diff --git a/app/assets/javascripts/ci/pipeline_details/header/components/header_badges.vue b/app/assets/javascripts/ci/pipeline_details/header/components/header_badges.vue index 0ada6bd95afc000325d66ba9215e0d0faa92ceac..348864ddb550a1a7e040e3d759f73e75075b4854 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/components/header_badges.vue +++ b/app/assets/javascripts/ci/pipeline_details/header/components/header_badges.vue @@ -21,6 +21,11 @@ export default { GlTooltip: GlTooltipDirective, }, props: { + isUnderReview: { + type: Boolean, + required: false, + default: false, + }, pipeline: { type: Object, required: true, @@ -58,26 +63,26 @@ export default { return this.pipeline.configSource === AUTO_DEVOPS_SOURCE; }, yamlErrorMessage() { - return this.pipeline?.errorMessages.nodes[0].content || ''; + return this.pipeline?.errorMessages?.nodes[0]?.content || ''; }, triggeredByPath() { return this.pipeline?.triggeredByPath; }, badges() { return { - schedule: this.isScheduledPipeline, - trigger: this.pipeline.trigger, - invalid: this.hasPipelineErrorMessages, + autoDevops: this.isAutoDevopsPipeline, + branchPipeline: this.isBranchPipeline, child: this.pipeline.child, + detached: this.isDetachedPipeline, + failed: Boolean(this.failureReason) && !this.isUnderReview, + invalid: this.hasPipelineErrorMessages && !this.isUnderReview, latest: this.pipeline.latest, - mergeTrainPipeline: this.isMergeTrainPipeline, mergedResultsPipeline: this.isMergedResultsPipeline, - branchPipeline: this.isBranchPipeline, - tagPipeline: this.isTagPipeline, - detached: this.isDetachedPipeline, - failed: Boolean(this.failureReason), - autoDevops: this.isAutoDevopsPipeline, + mergeTrainPipeline: this.isMergeTrainPipeline, + schedule: this.isScheduledPipeline, stuck: this.pipeline.stuck, + tagPipeline: this.isTagPipeline, + trigger: this.pipeline.trigger, }; }, }, diff --git a/app/assets/javascripts/ci/pipeline_details/header/components/pipeline_error_alert.vue b/app/assets/javascripts/ci/pipeline_details/header/components/pipeline_error_alert.vue new file mode 100644 index 0000000000000000000000000000000000000000..3ff94e93385fe496496583f9202ba1245acae111 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/header/components/pipeline_error_alert.vue @@ -0,0 +1,56 @@ + + + + + {{ errorMessage }} + + + + + {{ s__('Pipelines|Go to the pipeline editor') }} + + + + + diff --git a/app/assets/javascripts/ci/pipeline_details/header/components/pipeline_error_review_alert.vue b/app/assets/javascripts/ci/pipeline_details/header/components/pipeline_error_review_alert.vue new file mode 100644 index 0000000000000000000000000000000000000000..15ce802434ed4a51b1ebe83ab7c6260407eb95dc --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/header/components/pipeline_error_review_alert.vue @@ -0,0 +1,104 @@ + + + + + {{ message }} + + + + {{ content }} + + + + + + + + {{ s__('Pipelines|Approve and run pipeline') }} + + + {{ s__('Pipelines|Review commit changes') }} + + + + + diff --git a/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql index e4f52276ea836aefa0df21daa9e42436f4e25d1b..10865eb099580453b7cb3746460e5468d33f0abd 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql @@ -51,6 +51,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { name totalJobs refText + ref type triggeredByPath stuck diff --git a/app/assets/javascripts/ci/pipeline_details/header/pipeline_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_header.vue index 2928617ed02296d088d6dd44632b435f2a1c8be8..f78ea2ea0c615e06a3e7ae40be76c34b10be5b50 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/pipeline_header.vue +++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_header.vue @@ -1,5 +1,6 @@ + + {{ __('changes need approval') }} + - - + + diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index f152ebda753149e48f944ae1c7ff176bd8b974f9..c1b621e5d5700ba36c5a38ed8b4e64bb822ecca0 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -34,10 +34,13 @@ def js_pipeline_tabs_data(project, pipeline, _user) def js_pipeline_header_data(project, pipeline) { + can_view_pipeline_editor: can_view_pipeline_editor?(project).to_s, full_path: project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(pipeline), + pipeline_editor_path: project_ci_pipeline_editor_path(project), pipeline_iid: pipeline.iid, - pipelines_path: project_pipelines_path(project) + pipelines_path: project_pipelines_path(project), + settings_path: project_settings_ci_cd_path(project, anchor: 'js-general-pipeline-settings') } end diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 38568224b46647adc3a5a6df12437e5b0ef4fdd0..8f88caad9f47552476102f2f0f3ec4141de4abd1 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -12,18 +12,5 @@ .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } #js-pipeline-header-vue{ data: js_pipeline_header_data(@project, @pipeline) } - - if pipeline_has_errors - = render Pajamas::AlertComponent.new(title: s_('Pipelines|Unable to create pipeline'), - variant: :danger, - dismissible: false, - alert_options: { class: 'gl-mb-5' }) do |c| - - c.with_body do - %ul - - @pipeline.error_messages.pluck(:content).each do |error| # rubocop:disable CodeReuse/ActiveRecord -- done temporarily to drop yaml_errors - %li= error - - if can_view_pipeline_editor?(@project) - = render Pajamas::ButtonComponent.new(href: project_ci_pipeline_editor_path(@project, branch_name: @pipeline.source_ref), variant: :confirm) do - = s_("Pipelines|Go to the pipeline editor") - - - else + - unless pipeline_has_errors #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline, @current_user) } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2a40be45c24962c77624aa77758358391c2601af..079ca8480514e4aecf22a532a2955cf5cd4244ed 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -45866,6 +45866,9 @@ msgstr "" msgid "Pipelines|An error occurred while updating the trigger token. Please try again." msgstr "" +msgid "Pipelines|Approve and run pipeline" +msgstr "" + msgid "Pipelines|Are you sure you want to remove the merge request %{title} from the merge train?" msgstr "" @@ -45914,6 +45917,9 @@ msgstr "" msgid "Pipelines|Editor" msgstr "" +msgid "Pipelines|Error running pipeline" +msgstr "" + msgid "Pipelines|Expires" msgstr "" @@ -46064,6 +46070,12 @@ msgstr "" msgid "Pipelines|Retry %{jobName} Job" msgstr "" +msgid "Pipelines|Review commit changes" +msgstr "" + +msgid "Pipelines|Review required: AI-generated pipeline" +msgstr "" + msgid "Pipelines|Revoke trigger" msgstr "" @@ -46193,6 +46205,9 @@ msgstr "" msgid "Pipelines|Time" msgstr "" +msgid "Pipelines|To enable automatic pipeline execution for composite identities, visit %{linkStart}CI/CD Settings.%{linkEnd}" +msgstr "" + msgid "Pipelines|To run new jobs and pipelines in this namespace's projects, %{link_start}buy additional compute minutes%{link_end}." msgstr "" @@ -73421,6 +73436,9 @@ msgstr[1] "" msgid "changes" msgstr "" +msgid "changes need approval" +msgstr "" + msgid "check" msgid_plural "checks" msgstr[0] "" diff --git a/spec/frontend/ci/pipeline_details/header/components/header_badges_spec.js b/spec/frontend/ci/pipeline_details/header/components/header_badges_spec.js index 074c33fa31bc52d974c59e9ba30eaca3e5255d74..15f5f009fa4b5d48a5a30caeea8d7502574ec2ad 100644 --- a/spec/frontend/ci/pipeline_details/header/components/header_badges_spec.js +++ b/spec/frontend/ci/pipeline_details/header/components/header_badges_spec.js @@ -15,10 +15,14 @@ describe('Header badges', () => { return sprintf.exists() && sprintf.attributes('message').includes('Child pipeline'); }); - const createComponent = (mockPipeline = pipelineHeaderSuccess.data.project.pipeline) => { + const createComponent = ( + mockPipeline = pipelineHeaderSuccess.data.project.pipeline, + isUnderReview = false, + ) => { wrapper = shallowMountExtended(HeaderBadges, { propsData: { pipeline: mockPipeline, + isUnderReview, }, }); }; @@ -101,4 +105,44 @@ describe('Header badges', () => { ); }); }); + + describe('in a failed pipeline', () => { + const failedPipeline = { + ...pipelineHeaderSuccess.data.project.pipeline, + failureReason: 'some reason', + }; + + it('displays error badge', () => { + createComponent(failedPipeline); + + expect(wrapper.findByText('error').exists()).toBe(true); + expect(wrapper.findByTestId('badges-failed').attributes('title')).toBe('some reason'); + }); + + it('does not display error badge if changes need review', () => { + createComponent(failedPipeline, true); + + expect(wrapper.findByText('error').exists()).toBe(false); + }); + }); + + describe('in an invalid pipeline', () => { + const invalidPipeline = { + ...pipelineHeaderSuccess.data.project.pipeline, + errorMessages: { nodes: [{ content: 'detailed error' }] }, + }; + + it('displays error badge', () => { + createComponent(invalidPipeline); + + expect(wrapper.findByText('yaml invalid').exists()).toBe(true); + expect(wrapper.findByTestId('badges-invalid').attributes('title')).toBe('detailed error'); + }); + + it('does not display error badge if changes need review', () => { + createComponent(invalidPipeline, true); + + expect(wrapper.findByText('yaml invalid').exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_details/header/components/pipeline_error_alert_spec.js b/spec/frontend/ci/pipeline_details/header/components/pipeline_error_alert_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..751430c37dae3acc77477e1806e524051cc58675 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/header/components/pipeline_error_alert_spec.js @@ -0,0 +1,100 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlButton } from '@gitlab/ui'; +import PipelineErrorAlert from '~/ci/pipeline_details/header/components/pipeline_error_alert.vue'; + +describe('PipelineErrorAlert', () => { + let wrapper; + + const pipelineEditorPath = '/project/-/ci/editor'; + + const defaultProps = { + errorMessage: 'Pipeline creation failed', + }; + + const defaultProvide = { + canViewPipelineEditor: true, + paths: { + pipelineEditorPath, + }, + }; + + const createComponent = ({ props = {}, provide = {} } = {}) => { + wrapper = shallowMount(PipelineErrorAlert, { + propsData: { ...defaultProps, ...props }, + provide: { ...defaultProvide, ...provide }, + stubs: { GlAlert }, + }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findButton = () => wrapper.findComponent(GlButton); + + describe('template rendering', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the alert with correct props', () => { + expect(findAlert().props()).toMatchObject({ + title: 'Unable to create pipeline', + variant: 'danger', + dismissible: false, + }); + }); + + it('displays the error message', () => { + expect(findAlert().text()).toContain(defaultProps.errorMessage); + }); + }); + + describe('pipeline editor button', () => { + describe('when canViewPipelineEditor is true', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the pipeline editor button', () => { + expect(findButton().props()).toMatchObject({ + href: defaultProvide.paths.pipelineEditorPath, + variant: 'confirm', + target: '_blank', + }); + expect(findButton().text()).toBe('Go to the pipeline editor'); + }); + }); + + describe('when canViewPipelineEditor is false', () => { + beforeEach(() => { + createComponent({ provide: { canViewPipelineEditor: false } }); + }); + + it('does not render the pipeline editor button', () => { + expect(findButton().exists()).toBe(false); + }); + }); + + describe('when pipelineRef is not provided', () => { + beforeEach(() => { + createComponent(); + }); + + it('contains editor path', () => { + expect(findButton().props('href')).toBe(pipelineEditorPath); + }); + }); + + describe('when pipelineRef is provided', () => { + const pipelineRef = 'feature-branch'; + + beforeEach(() => { + createComponent({ props: { pipelineRef } }); + }); + + it('contains branch parameter', () => { + const expectedPath = `${pipelineEditorPath}?branch_name=${encodeURIComponent(pipelineRef)}`; + + expect(findButton().props('href')).toBe(expectedPath); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/header/components/pipeline_error_review_alert_spec.js b/spec/frontend/ci/pipeline_details/header/components/pipeline_error_review_alert_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..26475e726f4c7e8115e84441f1e5cd1aa32b2670 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/header/components/pipeline_error_review_alert_spec.js @@ -0,0 +1,189 @@ +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { reportToSentry } from '~/ci/utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import createPipelineMutation from '~/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql'; +import PipelineApprovalAlert from '~/ci/pipeline_details/header/components/pipeline_error_review_alert.vue'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); +jest.mock('~/ci/utils'); +jest.mock('~/lib/utils/url_utility'); + +describe('PipelineApprovalAlert', () => { + let wrapper; + let apolloProvider; + + const pipelinePath = '/project/pipelines/123'; + + const defaultProps = { + commitPath: '/project/commit/abc123', + message: 'This pipeline was generated by AI and requires review.', + pipelineRef: 'main', + }; + + const defaultProvide = { + paths: { + settingsPath: '/project/settings/ci_cd', + fullProject: 'group/project', + }, + }; + + const successHandler = jest.fn().mockResolvedValue({ + data: { + pipelineCreate: { + id: '1', + clientMutationId: '1', + errors: [], + pipeline: { + id: '123', + path: pipelinePath, + warningMessages: { nodes: [] }, + errorMessages: { nodes: [] }, + }, + }, + }, + }); + + const errorHandler = jest.fn().mockResolvedValue({ + data: { + pipelineCreate: { + id: '1', + clientMutationId: '1', + errors: [], + pipeline: { + id: '123', + path: '', + warningMessages: { nodes: [] }, + errorMessages: { nodes: [{ id: '1', content: 'error creating pipeline' }] }, + }, + }, + }, + }); + + const mutationError = jest.fn().mockRejectedValue(new Error('mutation error')); + + const defaultHandlers = [[createPipelineMutation, successHandler]]; + + const createMockApolloProvider = (handlers) => { + return createMockApollo(handlers); + }; + + const createComponent = (handlers = defaultHandlers) => { + apolloProvider = createMockApolloProvider(handlers); + + wrapper = shallowMountExtended(PipelineApprovalAlert, { + apolloProvider, + propsData: defaultProps, + provide: defaultProvide, + stubs: { GlAlert, GlSprintf }, + }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findApproveButton = () => wrapper.findByTestId('approve-button'); + const findReviewButton = () => wrapper.findByTestId('review-button'); + const findSettingsLink = () => wrapper.findComponent(GlLink); + + describe('template rendering', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the alert with correct props', () => { + expect(findAlert().props()).toMatchObject({ + title: 'Review required: AI-generated pipeline', + variant: 'warning', + dismissible: false, + }); + }); + + it('displays the message', () => { + expect(findAlert().text()).toContain(defaultProps.message); + }); + + it('renders the settings link with correct href', () => { + expect(findSettingsLink().attributes('href')).toBe(defaultProvide.paths.settingsPath); + }); + + it('renders the approve button with correct text', () => { + expect(findApproveButton().props('variant')).toBe('confirm'); + expect(findApproveButton().text()).toBe('Approve and run pipeline'); + }); + + it('renders the review button with correct props', () => { + expect(findReviewButton().attributes('href')).toBe(defaultProps.commitPath); + expect(findReviewButton().text()).toBe('Review commit changes'); + }); + }); + + describe('approve button', () => { + it('calls pipeline creation mutation when approve button is clicked', async () => { + createComponent(); + await findApproveButton().vm.$emit('click'); + + expect(successHandler).toHaveBeenCalledWith({ + input: { + projectPath: defaultProvide.paths.fullProject, + ref: defaultProps.pipelineRef, + }, + }); + }); + + describe('on success', () => { + it('redirects to the new pipeline page', async () => { + createComponent(); + await findApproveButton().vm.$emit('click'); + await waitForPromises(); + + expect(visitUrl).toHaveBeenCalledWith(pipelinePath); + }); + }); + + describe('on pipeline error', () => { + const handlers = [[createPipelineMutation, errorHandler]]; + beforeEach(async () => { + createComponent(handlers); + await findApproveButton().vm.$emit('click'); + await waitForPromises(); + }); + + it('shows an alert', () => { + expect(createAlert).toHaveBeenCalledWith({ message: 'error creating pipeline' }); + }); + + it('does not redirect to the new pipeline page on error', () => { + expect(visitUrl).not.toHaveBeenCalled(); + }); + }); + + describe('on mutation error', () => { + beforeEach(async () => { + createComponent([[createPipelineMutation, mutationError]]); + await findApproveButton().vm.$emit('click'); + await waitForPromises(); + }); + + it('shows an alert', () => { + expect(createAlert).toHaveBeenCalledWith({ message: 'Error running pipeline' }); + }); + + it('reports an error to sentry', () => { + expect(reportToSentry).toHaveBeenCalledWith( + 'PipelineApprovalAlert', + new Error('mutation error'), + ); + }); + + it('does not redirect to the new pipeline page on error', () => { + expect(visitUrl).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/header/pipeline_header_spec.js b/spec/frontend/ci/pipeline_details/header/pipeline_header_spec.js index e017bf7fd8ef10441133c13bdda1e508237a3624..d9185000f77411e5d2d389fd10fb0d2a33c58d1d 100644 --- a/spec/frontend/ci/pipeline_details/header/pipeline_header_spec.js +++ b/spec/frontend/ci/pipeline_details/header/pipeline_header_spec.js @@ -12,6 +12,8 @@ import deletePipelineMutation from '~/ci/pipeline_details/graphql/mutations/dele import retryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; import HeaderActions from '~/ci/pipeline_details/header/components/header_actions.vue'; import HeaderBadges from '~/ci/pipeline_details/header/components/header_badges.vue'; +import PipelineErrorReviewAlert from '~/ci/pipeline_details/header/components/pipeline_error_review_alert.vue'; +import PipelineErrorAlert from '~/ci/pipeline_details/header/components/pipeline_error_alert.vue'; import getPipelineDetailsQuery from '~/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql'; import pipelineCiStatusUpdatedSubscription from '~/graphql_shared/subscriptions/pipeline_ci_status_updated.subscription.graphql'; @@ -20,6 +22,8 @@ import { pipelineHeaderRunning, pipelineHeaderRunningWithDuration, pipelineHeaderFailed, + pipelineHeaderUnderAiReview, + pipelineHeaderWithErrorMessages, pipelineRetryMutationResponseSuccess, pipelineCancelMutationResponseSuccess, pipelineDeleteMutationResponseSuccess, @@ -40,6 +44,8 @@ describe('Pipeline header', () => { const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning); const runningHandlerWithDuration = jest.fn().mockResolvedValue(pipelineHeaderRunningWithDuration); const failedHandler = jest.fn().mockResolvedValue(pipelineHeaderFailed); + const aiReviewHandler = jest.fn().mockResolvedValue(pipelineHeaderUnderAiReview); + const errorMessageHandler = jest.fn().mockResolvedValue(pipelineHeaderWithErrorMessages); const subscriptionHandler = jest.fn().mockResolvedValue(mockPipelineStatusUpdatedResponse); const subscriptionNullHandler = jest.fn().mockResolvedValue(mockPipelineStatusNullResponse); @@ -67,6 +73,8 @@ describe('Pipeline header', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findBadges = () => wrapper.findComponent(HeaderBadges); const findHeaderActions = () => wrapper.findComponent(HeaderActions); + const findPipelineErrorReviewAlert = () => wrapper.findComponent(PipelineErrorReviewAlert); + const findPipelineErrorAlert = () => wrapper.findComponent(PipelineErrorAlert); const findCreatedTimeAgo = () => wrapper.findByTestId('pipeline-created-time-ago'); const findFinishedTimeAgo = () => wrapper.findByTestId('pipeline-finished-time-ago'); const findFinishedCreatedTimeAgo = () => @@ -98,6 +106,7 @@ describe('Pipeline header', () => { paths: { pipelinesPath: '/namespace/my-project/-/pipelines', fullProject: '/namespace/my-project', + settingsPath: '/path/to/settings', }, }; @@ -274,6 +283,45 @@ describe('Pipeline header', () => { }); }); + describe('pipeline with error messages', () => { + beforeEach(async () => { + await createComponent([ + [getPipelineDetailsQuery, errorMessageHandler], + [pipelineCiStatusUpdatedSubscription, subscriptionHandler], + ]); + }); + + it('displays the pipeline error alert', () => { + expect(findPipelineErrorAlert().exists()).toBe(true); + }); + }); + + describe('ai review', () => { + beforeEach(() => { + return createComponent([ + [getPipelineDetailsQuery, aiReviewHandler], + [pipelineCiStatusUpdatedSubscription, subscriptionHandler], + ]); + }); + + it('displays the pipeline error review alert', () => { + expect(findPipelineErrorReviewAlert().exists()).toBe(true); + }); + + it('does not display error alert', () => { + expect(findPipelineErrorAlert().exists()).toBe(false); + }); + + it('sends correct props', () => { + expect(findPipelineErrorReviewAlert().props()).toEqual({ + commitPath: pipelineHeaderUnderAiReview.data.project.pipeline.commit.webPath, + message: + 'This pipeline did not run because the code should be reviewed by a non-AI user first. Verify that all changes in this merge request are safe before running a new pipeline.', + pipelineRef: pipelineHeaderUnderAiReview.data.project.pipeline.ref, + }); + }); + }); + describe('actions', () => { it('passes correct props to the header actions component', async () => { await createComponent([ diff --git a/spec/frontend/ci/pipeline_details/mock_data.js b/spec/frontend/ci/pipeline_details/mock_data.js index 3db5d47c8f22b1ff42785bc13e12c6fefe5b5933..0ff5cdbde932197064cd6c7802ad0a28a690abf5 100644 --- a/spec/frontend/ci/pipeline_details/mock_data.js +++ b/spec/frontend/ci/pipeline_details/mock_data.js @@ -4,6 +4,7 @@ import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_head import pipelineHeaderRunningNoPermissions from 'test_fixtures/graphql/pipelines/pipeline_header_running_no_permissions.json'; import pipelineHeaderRunningWithDuration from 'test_fixtures/graphql/pipelines/pipeline_header_running_with_duration.json'; import pipelineHeaderFailed from 'test_fixtures/graphql/pipelines/pipeline_header_failed.json'; +import pipelineHeaderUnderAiReview from 'test_fixtures/graphql/pipelines/pipeline_header_under_ai_review.json'; const PIPELINE_RUNNING = 'RUNNING'; @@ -16,6 +17,7 @@ export { pipelineHeaderRunningNoPermissions, pipelineHeaderRunningWithDuration, pipelineHeaderFailed, + pipelineHeaderUnderAiReview, }; export const pipelineHeaderTrigger = { @@ -30,6 +32,21 @@ export const pipelineHeaderTrigger = { }, }; +export const pipelineHeaderWithErrorMessages = { + data: { + project: { + id: 'gid://gitlab/Project/1', + pipeline: { + ...pipelineHeaderFailed.data.project.pipeline, + errorMessages: { + nodes: [{ id: '1', content: 'Pipeline failed due to configuration error' }], + __typename: 'PipelineMessageConnection', + }, + }, + }, + }, +}; + export const pipelineRetryMutationResponseSuccess = { data: { pipelineRetry: { errors: [] } }, }; diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js index ff3975ea4414f11ecba72f923ee531485d05ee44..d166c7376362329e38db6c0f1ca6b3fc4ba82de8 100644 --- a/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js +++ b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js @@ -24,6 +24,7 @@ describe('Pipeline label component', () => { const findForkTag = () => wrapper.findByTestId('pipeline-url-fork'); const findTrainTag = () => wrapper.findByTestId('pipeline-url-train'); const findApiTag = () => wrapper.findByTestId('pipeline-api-badge'); + const findNeedsReviewTag = () => wrapper.findByTestId('pipeline-needs-review'); const defaultProps = mockPipeline(projectPath); @@ -52,6 +53,7 @@ describe('Pipeline label component', () => { expect(findMergedResultsTag().exists()).toBe(false); expect(findBranchTag().exists()).toBe(false); expect(findTagTag().exists()).toBe(false); + expect(findNeedsReviewTag().exists()).toBe(false); }); it('should render the stuck tag when flag is provided', () => { @@ -179,6 +181,26 @@ describe('Pipeline label component', () => { }); }); + describe('needs review badge', () => { + it('when pipeline does not have a failure reason does not render the badge', () => { + createComponent(); + + expect(findNeedsReviewTag().exists()).toBe(false); + }); + + it('when pipeline has a failure reason renders the badge', () => { + const failedPipeline = defaultProps.pipeline; + failedPipeline.flags.failure_reason = true; + failedPipeline.failure_reason = 'code should be reviewed by a non-AI user'; + + createComponent({ + ...failedPipeline, + }); + expect(findNeedsReviewTag().exists()).toBe(true); + expect(findNeedsReviewTag().text()).toBe('changes need approval'); + }); + }); + it('should render the merged results badge when the pipeline is a merged results pipeline', () => { const mergedResultsPipeline = defaultProps.pipeline; mergedResultsPipeline.flags.merged_result_pipeline = true; diff --git a/spec/frontend/fixtures/pipeline_header.rb b/spec/frontend/fixtures/pipeline_header.rb index 77d626100ad8585daaf35dbcf079e6e749a65696..6dd029120bbda4946f60023b4f926f58447a1ba1 100644 --- a/spec/frontend/fixtures/pipeline_header.rb +++ b/spec/frontend/fixtures/pipeline_header.rb @@ -148,4 +148,28 @@ expect_graphql_errors_to_be_empty end end + + context 'with pipeline under AI review' do + let_it_be(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + user: user, + status: :failed, + failure_reason: :composite_identity_forbidden + ) + end + + let_it_be(:build) { create(:ci_build, :failed, pipeline: pipeline, ref: 'master') } + + it "graphql/pipelines/pipeline_header_under_ai_review.json" do + query = get_graphql_query_as_string(query_path) + + post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid }) + + expect_graphql_errors_to_be_empty + end + end end diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb index b8599144eac8042b66b8a4795a489382a1858349..c671d2465a9f1c94edb5e316f22360b388e42ec2 100644 --- a/spec/views/projects/pipelines/show.html.haml_spec.rb +++ b/spec/views/projects/pipelines/show.html.haml_spec.rb @@ -21,43 +21,14 @@ create(:ci_pipeline_message, pipeline: pipeline, content: 'some errors', severity: :error) end - it 'shows errors' do - render - - expect(rendered).to have_content('Unable to create pipeline') - expect(rendered).to have_content('some errors') - end - it 'does not render the pipeline tabs' do render expect(rendered).not_to have_selector('#js-pipeline-tabs') end - - it 'renders the pipeline editor button with correct link for users who can view' do - project.add_developer(user) - - render - - expect(rendered).to have_link s_('Go to the pipeline editor'), - href: project_ci_pipeline_editor_path(project, branch_name: pipeline.source_ref) - end - - it 'renders the pipeline editor button with correct link for users who can not view' do - render - - expect(rendered).not_to have_link s_('Go to the pipeline editor'), - href: project_ci_pipeline_editor_path(project, branch_name: pipeline.source_ref) - end end context 'when pipeline does not have errors' do - it 'does not show errors' do - render - - expect(rendered).not_to have_content('Unable to create pipeline') - end - it 'renders the pipeline tabs' do render
{{ errorMessage }}
{{ message }}