Commit 88b4bdbe authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'pipeline-status-for-pipeline-editor' into 'master'

Show status of pipelines triggered by commits in the pipeline editor [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!53797
parents a752c885 6c5b6423
<script> <script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PipelineStatus from './pipeline_status.vue';
import ValidationSegment from './validation_segment.vue'; import ValidationSegment from './validation_segment.vue';
const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100'];
const pipelineStatusClasses = [
...baseClasses,
'gl-border-1',
'gl-border-b-0!',
'gl-rounded-top-base',
];
const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base'];
const validationSegmentWithPipelineStatusClasses = [
...baseClasses,
'gl-border-1',
'gl-rounded-bottom-left-base',
'gl-rounded-bottom-right-base',
];
export default { export default {
validationSegmentClasses: pipelineStatusClasses,
'gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base', validationSegmentClasses,
validationSegmentWithPipelineStatusClasses,
components: { components: {
PipelineStatus,
ValidationSegment, ValidationSegment,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
ciConfigData: { ciConfigData: {
type: Object, type: Object,
...@@ -17,12 +40,25 @@ export default { ...@@ -17,12 +40,25 @@ export default {
required: true, required: true,
}, },
}, },
computed: {
showPipelineStatus() {
return this.glFeatures.pipelineStatusForPipelineEditor;
},
// make sure corners are rounded correctly depending on if
// pipeline status is rendered
validationStyling() {
return this.showPipelineStatus
? this.$options.validationSegmentWithPipelineStatusClasses
: this.$options.validationSegmentClasses;
},
},
}; };
</script> </script>
<template> <template>
<div class="gl-mb-5"> <div class="gl-mb-5">
<pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" />
<validation-segment <validation-segment
:class="$options.validationSegmentClasses" :class="validationStyling"
:loading="isCiConfigDataLoading" :loading="isCiConfigDataLoading"
:ci-config="ciConfigData" :ci-config="ciConfigData"
/> />
......
<script>
import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql';
import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
const POLL_INTERVAL = 10000;
export const i18n = {
fetchError: s__('Pipeline|We are currently unable to fetch pipeline data'),
fetchLoading: s__('Pipeline|Checking pipeline status'),
pipelineInfo: s__(
`Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`,
),
};
export default {
i18n,
components: {
CiIcon,
GlIcon,
GlLink,
GlLoadingIcon,
GlSprintf,
},
inject: ['projectFullPath'],
apollo: {
commitSha: {
query: getCommitSha,
},
pipeline: {
query: getPipelineQuery,
variables() {
return {
fullPath: this.projectFullPath,
sha: this.commitSha,
};
},
update: (data) => {
const { id, commitPath = '', shortSha = '', detailedStatus = {} } =
data.project?.pipeline || {};
return {
id,
commitPath,
shortSha,
detailedStatus,
};
},
error() {
this.hasError = true;
},
pollInterval: POLL_INTERVAL,
},
},
data() {
return {
hasError: false,
};
},
computed: {
hasPipelineData() {
return Boolean(this.$apollo.queries.pipeline?.id);
},
isQueryLoading() {
return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
},
status() {
return this.pipeline.detailedStatus;
},
pipelineId() {
return getIdFromGraphQLId(this.pipeline.id);
},
},
};
</script>
<template>
<div class="gl-white-space-nowrap gl-max-w-full">
<template v-if="isQueryLoading">
<gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
<span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span>
</template>
<template v-else-if="hasError">
<gl-icon class="gl-mr-auto" name="warning-solid" />
<span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span>
</template>
<template v-else>
<a :href="status.detailsPath" class="gl-mr-auto">
<ci-icon :status="status" :size="18" />
</a>
<span class="gl-font-weight-bold">
<gl-sprintf :message="$options.i18n.pipelineInfo">
<template #id="{ content }">
<gl-link
:href="status.detailsPath"
class="pipeline-id gl-font-weight-normal pipeline-number"
target="_blank"
data-testid="pipeline-id"
>
{{ content }}{{ pipelineId }}</gl-link
>
</template>
<template #status>{{ status.text }}</template>
<template #commit>
<gl-link
:href="pipeline.commitPath"
class="commit-sha gl-font-weight-normal"
target="_blank"
data-testid="pipeline-commit"
>
{{ pipeline.shortSha }}
</gl-link>
</template>
</gl-sprintf>
</span>
</template>
</div>
</template>
query getPipeline($fullPath: ID!, $sha: String!) {
project(fullPath: $fullPath) @client {
pipeline(sha: $sha) {
commitPath
id
iid
shortSha
status
detailedStatus {
detailsPath
icon
group
text
}
}
}
}
...@@ -11,6 +11,29 @@ export const resolvers = { ...@@ -11,6 +11,29 @@ export const resolvers = {
}), }),
}; };
}, },
/* eslint-disable @gitlab/require-i18n-strings */
project() {
return {
__typename: 'Project',
pipeline: {
__typename: 'Pipeline',
commitPath: `/-/commit/aabbccdd`,
id: 'gid://gitlab/Ci::Pipeline/118',
iid: '28',
shortSha: 'aabbccdd',
status: 'SUCCESS',
detailedStatus: {
__typename: 'DetailedStatus',
detailsPath: '/root/sample-ci-project/-/pipelines/118"',
group: 'success',
icon: 'status_success',
text: 'passed',
},
},
};
},
/* eslint-enable @gitlab/require-i18n-strings */
}, },
Mutation: { Mutation: {
lintCI: (_, { endpoint, content, dry_run }) => { lintCI: (_, { endpoint, content, dry_run }) => {
......
...@@ -5,6 +5,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController ...@@ -5,6 +5,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: :yaml) push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml) push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_status_for_pipeline_editor, @project, default_enabled: :yaml)
end end
feature_category :pipeline_authoring feature_category :pipeline_authoring
......
---
name: pipeline_status_for_pipeline_editor
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53797
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/321518
milestone: '13.10'
type: development
group: group::pipeline authoring
default_enabled: false
...@@ -21992,6 +21992,9 @@ msgstr "" ...@@ -21992,6 +21992,9 @@ msgstr ""
msgid "Pipeline|Canceled" msgid "Pipeline|Canceled"
msgstr "" msgstr ""
msgid "Pipeline|Checking pipeline status"
msgstr ""
msgid "Pipeline|Checking pipeline status." msgid "Pipeline|Checking pipeline status."
msgstr "" msgstr ""
...@@ -22043,6 +22046,9 @@ msgstr "" ...@@ -22043,6 +22046,9 @@ msgstr ""
msgid "Pipeline|Pipeline" msgid "Pipeline|Pipeline"
msgstr "" msgstr ""
msgid "Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}"
msgstr ""
msgid "Pipeline|Pipelines" msgid "Pipeline|Pipelines"
msgstr "" msgstr ""
...@@ -22100,6 +22106,9 @@ msgstr "" ...@@ -22100,6 +22106,9 @@ msgstr ""
msgid "Pipeline|Variables" msgid "Pipeline|Variables"
msgstr "" msgstr ""
msgid "Pipeline|We are currently unable to fetch pipeline data"
msgstr ""
msgid "Pipeline|You’re about to stop pipeline %{pipelineId}." msgid "Pipeline|You’re about to stop pipeline %{pipelineId}."
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue';
import PipelineStatus from '~/pipeline_editor/components/header/pipeline_status.vue';
import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue'; import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue';
import { mockLintResponse } from '../../mock_data'; import { mockLintResponse } from '../../mock_data';
describe('Pipeline editor header', () => { describe('Pipeline editor header', () => {
let wrapper; let wrapper;
const mockProvide = {
glFeatures: {
pipelineStatusForPipelineEditor: true,
},
};
const createComponent = () => { const createComponent = ({ provide = {} } = {}) => {
wrapper = shallowMount(PipelineEditorHeader, { wrapper = shallowMount(PipelineEditorHeader, {
provide: {
...mockProvide,
...provide,
},
props: { props: {
ciConfigData: mockLintResponse, ciConfigData: mockLintResponse,
isCiConfigDataLoading: false, isCiConfigDataLoading: false,
...@@ -16,6 +26,7 @@ describe('Pipeline editor header', () => { ...@@ -16,6 +26,7 @@ describe('Pipeline editor header', () => {
}); });
}; };
const findPipelineStatus = () => wrapper.findComponent(PipelineStatus);
const findValidationSegment = () => wrapper.findComponent(ValidationSegment); const findValidationSegment = () => wrapper.findComponent(ValidationSegment);
afterEach(() => { afterEach(() => {
...@@ -27,8 +38,27 @@ describe('Pipeline editor header', () => { ...@@ -27,8 +38,27 @@ describe('Pipeline editor header', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
}); });
it('renders the pipeline status', () => {
expect(findPipelineStatus().exists()).toBe(true);
});
it('renders the validation segment', () => { it('renders the validation segment', () => {
expect(findValidationSegment().exists()).toBe(true); expect(findValidationSegment().exists()).toBe(true);
}); });
}); });
describe('with pipeline status feature flag off', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: { pipelineStatusForPipelineEditor: false },
},
});
});
it('does not render the pipeline status', () => {
expect(findPipelineStatus().exists()).toBe(false);
});
});
}); });
import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
const mockProvide = {
projectFullPath: mockProjectFullPath,
};
describe('Pipeline Status', () => {
let wrapper;
let mockApollo;
let mockPipelineQuery;
const createComponent = ({ hasPipeline = true, isQueryLoading = false }) => {
const pipeline = hasPipeline
? { loading: isQueryLoading, ...mockProjectPipeline.pipeline }
: { loading: isQueryLoading };
wrapper = shallowMount(PipelineStatus, {
provide: mockProvide,
stubs: { GlLink, GlSprintf },
data: () => (hasPipeline ? { pipeline } : {}),
mocks: {
$apollo: {
queries: {
pipeline,
},
},
},
});
};
const createComponentWithApollo = () => {
const resolvers = {
Query: {
project: mockPipelineQuery,
},
};
mockApollo = createMockApollo([], resolvers);
wrapper = shallowMount(PipelineStatus, {
localVue,
apolloProvider: mockApollo,
provide: mockProvide,
stubs: { GlLink, GlSprintf },
data() {
return {
commitSha: mockCommitSha,
};
},
});
};
const findIcon = () => wrapper.findComponent(GlIcon);
const findCiIcon = () => wrapper.findComponent(CiIcon);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]');
const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]');
const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]');
const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]');
beforeEach(() => {
mockPipelineQuery = jest.fn();
});
afterEach(() => {
mockPipelineQuery.mockReset();
wrapper.destroy();
wrapper = null;
});
describe('while querying', () => {
it('renders loading icon', () => {
createComponent({ isQueryLoading: true, hasPipeline: false });
expect(findLoadingIcon().exists()).toBe(true);
expect(findPipelineLoadingMsg().text()).toBe(i18n.fetchLoading);
});
it('does not render loading icon if pipeline data is already set', () => {
createComponent({ isQueryLoading: true });
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('when querying data', () => {
describe('when data is set', () => {
beforeEach(async () => {
mockPipelineQuery.mockResolvedValue(mockProjectPipeline);
createComponentWithApollo();
await waitForPromises();
});
it('query is called with correct variables', async () => {
expect(mockPipelineQuery).toHaveBeenCalledTimes(1);
expect(mockPipelineQuery).toHaveBeenCalledWith(
expect.anything(),
{
fullPath: mockProjectFullPath,
},
expect.anything(),
expect.anything(),
);
});
it('does not render error', () => {
expect(findIcon().exists()).toBe(false);
});
it('renders pipeline data', () => {
const { id } = mockProjectPipeline.pipeline;
expect(findCiIcon().exists()).toBe(true);
expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`);
expect(findPipelineCommit().text()).toBe(mockCommitSha);
});
});
describe('when data cannot be fetched', () => {
beforeEach(async () => {
mockPipelineQuery.mockRejectedValue(new Error());
createComponentWithApollo();
await waitForPromises();
});
it('renders error', () => {
expect(findIcon().attributes('name')).toBe('warning-solid');
expect(findPipelineErrorMsg().text()).toBe(i18n.fetchError);
});
it('does not render pipeline data', () => {
expect(findCiIcon().exists()).toBe(false);
expect(findPipelineId().exists()).toBe(false);
expect(findPipelineCommit().exists()).toBe(false);
});
});
});
});
...@@ -46,6 +46,24 @@ describe('~/pipeline_editor/graphql/resolvers', () => { ...@@ -46,6 +46,24 @@ describe('~/pipeline_editor/graphql/resolvers', () => {
await expect(result.rawData).resolves.toBe(mockCiYml); await expect(result.rawData).resolves.toBe(mockCiYml);
}); });
}); });
describe('pipeline', () => {
it('resolves pipeline data with type names', async () => {
const result = await resolvers.Query.project(null);
// eslint-disable-next-line no-underscore-dangle
expect(result.__typename).toBe('Project');
});
it('resolves pipeline data with necessary data', async () => {
const result = await resolvers.Query.project(null);
const pipelineKeys = Object.keys(result.pipeline);
const statusKeys = Object.keys(result.pipeline.detailedStatus);
expect(pipelineKeys).toContain('id', 'commitPath', 'detailedStatus', 'shortSha');
expect(statusKeys).toContain('detailsPath', 'text');
});
});
}); });
describe('Mutation', () => { describe('Mutation', () => {
......
...@@ -138,6 +138,22 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { ...@@ -138,6 +138,22 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => {
}; };
}; };
export const mockProjectPipeline = {
pipeline: {
commitPath: '/-/commit/aabbccdd',
id: 'gid://gitlab/Ci::Pipeline/118',
iid: '28',
shortSha: mockCommitSha,
status: 'SUCCESS',
detailedStatus: {
detailsPath: '/root/sample-ci-project/-/pipelines/118"',
group: 'success',
icon: 'status_success',
text: 'passed',
},
},
};
export const mockLintResponse = { export const mockLintResponse = {
valid: true, valid: true,
mergedYaml: mockCiYml, mergedYaml: mockCiYml,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment