Skip to content
Snippets Groups Projects
Commit 06355b17 authored by Peter Hegman's avatar Peter Hegman :red_circle:
Browse files

Merge branch 'afontaine/deployment-approval' into 'master'

Add deployment approval UI MVC

See merge request !80759
parents 0ee0a889 d39dff98
No related branches found
No related tags found
1 merge request!80759Add deployment approval UI MVC
Pipeline #489511451 passed with warnings
Pipeline: GitLab

#489527594

    Showing
    with 562 additions and 11 deletions
    ......@@ -102,6 +102,9 @@ export default {
    refPath() {
    return this.ref?.refPath;
    },
    needsApproval() {
    return this.deployment.pendingApprovalCount > 0;
    },
    },
    methods: {
    toggleCollapse() {
    ......@@ -116,6 +119,7 @@ export default {
    showDetails: __('Show details'),
    hideDetails: __('Hide details'),
    triggerer: s__('Deployment|Triggerer'),
    needsApproval: s__('Deployment|Needs Approval'),
    job: __('Job'),
    api: __('API'),
    branch: __('Branch'),
    ......@@ -153,6 +157,9 @@ export default {
    <div :class="$options.headerDetailsClasses">
    <div :class="$options.deploymentStatusClasses">
    <deployment-status-badge v-if="status" :status="status" />
    <gl-badge v-if="needsApproval" variant="warning">
    {{ $options.i18n.needsApproval }}
    </gl-badge>
    <gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge>
    </div>
    <div class="gl-display-flex gl-align-items-center gl-gap-x-5">
    ......@@ -199,6 +206,7 @@ export default {
    </gl-button>
    </div>
    <commit v-if="commit" :commit="commit" class="gl-mt-3" />
    <div class="gl-mt-3"><slot name="approval"></slot></div>
    <gl-collapse :visible="visible">
    <div
    class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0"
    ......
    ......@@ -41,6 +41,8 @@ export default {
    TimeAgoTooltip,
    Delete,
    EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'),
    EnvironmentApproval: () =>
    import('ee_component/environments/components/environment_approval.vue'),
    },
    directives: {
    GlTooltip,
    ......@@ -305,7 +307,11 @@ export default {
    :deployment="upcomingDeployment"
    :class="{ 'gl-ml-7': inFolder }"
    class="gl-pl-4"
    />
    >
    <template #approval>
    <environment-approval :environment="environment" @change="$emit('change')" />
    </template>
    </deployment>
    </div>
    </template>
    <div v-else :class="$options.deploymentClasses">
    ......
    ......@@ -175,11 +175,10 @@ export default {
    },
    resetPolling() {
    this.$apollo.queries.environmentApp.stopPolling();
    this.$apollo.queries.environmentApp.refetch();
    this.$nextTick(() => {
    if (this.interval) {
    this.$apollo.queries.environmentApp.startPolling(this.interval);
    } else {
    this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page });
    }
    });
    },
    ......@@ -233,6 +232,7 @@ export default {
    :key="environment.name"
    class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid"
    :environment="environment.latest"
    @change="resetPolling"
    />
    <gl-pagination
    align="center"
    ......
    ......@@ -22,6 +22,7 @@ export default (el) => {
    apolloProvider,
    provide: {
    projectPath: el.dataset.projectPath,
    projectId: el.dataset.projectId,
    defaultBranchName: el.dataset.defaultBranchName,
    },
    data() {
    ......
    ......@@ -15,6 +15,7 @@ export default (el) => {
    helpPagePath,
    projectPath,
    defaultBranchName,
    projectId,
    } = el.dataset;
    return new Vue({
    ......@@ -26,6 +27,7 @@ export default (el) => {
    endpoint,
    newEnvironmentPath,
    helpPagePath,
    projectId,
    canCreateEnvironment: parseBoolean(canCreateEnvironment),
    },
    render(h) {
    ......
    ......@@ -8,6 +8,7 @@
    "new-environment-path" => new_project_environment_path(@project),
    "help-page-path" => help_page_path("ci/environments/index.md"),
    "project-path" => @project.full_path,
    "project-id" => @project.id,
    "default-branch-name" => @project.default_branch_or_main } }
    - else
    #environments-list-view{ data: { environments_data: environments_list_data,
    ......@@ -16,4 +17,5 @@
    "new-environment-path" => new_project_environment_path(@project),
    "help-page-path" => help_page_path("ci/environments/index.md"),
    "project-path" => @project.full_path,
    "project-id" => @project.id,
    "default-branch-name" => @project.default_branch_or_main } }
    ......@@ -84,7 +84,12 @@ This functionality is currently only available through the API. UI is planned fo
    A blocked deployment is enqueued as soon as it receives the required number of approvals. A single rejection causes the deployment to fail. The creator of a deployment cannot approve it, even if they have permission to deploy.
    Using the [Deployments API](../../api/deployments.md#approve-or-reject-a-blocked-deployment), users who are allowed to deploy to the protected environment can approve or reject a blocked deployment.
    There are two ways to approve or reject a deployment to a protected environment:
    1. Using the [UI](index.md#view-environments-and-deployments):
    1. Select **Approval options** (**{thumb-up}**)
    1. Select **Approve** or **Reject**
    1. Using the [Deployments API](../../api/deployments.md#approve-or-reject-a-blocked-deployment), users who are allowed to deploy to the protected environment can approve or reject a blocked deployment.
    Example:
    ......
    ......@@ -43,6 +43,7 @@ export default {
    issueMetricImagesPath: '/api/:version/projects/:id/issues/:issue_iid/metric_images',
    issueMetricSingleImagePath:
    '/api/:version/projects/:id/issues/:issue_iid/metric_images/:image_id',
    environmentApprovalPath: '/api/:version/projects/:id/deployments/:deployment_id/approval',
    userSubscription(namespaceId) {
    const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
    ......@@ -387,4 +388,19 @@ export default {
    return data;
    });
    },
    deploymentApproval(id, deploymentId, approve) {
    const url = Api.buildUrl(this.environmentApprovalPath)
    .replace(':id', encodeURIComponent(id))
    .replace(':deployment_id', encodeURIComponent(deploymentId));
    return axios.post(url, { status: approve ? 'approved' : 'rejected' });
    },
    approveDeployment(id, deploymentId) {
    return this.deploymentApproval(id, deploymentId, true);
    },
    rejectDeployment(id, deploymentId) {
    return this.deploymentApproval(id, deploymentId, false);
    },
    };
    <script>
    import { GlButton, GlButtonGroup, GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
    import { uniqueId } from 'lodash';
    import Api from 'ee/api';
    import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
    import { createAlert } from '~/flash';
    import { __, s__, sprintf } from '~/locale';
    export default {
    components: {
    GlButton,
    GlButtonGroup,
    GlLink,
    GlPopover,
    GlSprintf,
    TimeAgoTooltip,
    },
    inject: ['projectId'],
    props: {
    environment: {
    required: true,
    type: Object,
    },
    },
    data() {
    return {
    id: uniqueId('environment-approval'),
    loading: false,
    show: false,
    };
    },
    computed: {
    title() {
    return sprintf(this.$options.i18n.title, {
    deploymentIid: this.deploymentIid,
    });
    },
    upcomingDeployment() {
    return this.environment?.upcomingDeployment;
    },
    needsApproval() {
    return this.upcomingDeployment.pendingApprovalCount > 0;
    },
    deploymentIid() {
    return this.upcomingDeployment.iid;
    },
    totalApprovals() {
    return this.environment.requiredApprovalCount;
    },
    currentApprovals() {
    return this.totalApprovals - this.upcomingDeployment.pendingApprovalCount;
    },
    currentUserHasApproved() {
    return this.upcomingDeployment?.approvals.find(
    ({ user }) => user.username === gon.current_username,
    );
    },
    canApproveDeployment() {
    return this.upcomingDeployment.canApproveDeployment && !this.currentUserHasApproved;
    },
    deployableName() {
    return this.upcomingDeployment.deployable?.name;
    },
    },
    methods: {
    showPopover() {
    this.show = true;
    },
    approve() {
    return this.actOnDeployment(Api.approveDeployment.bind(Api));
    },
    reject() {
    return this.actOnDeployment(Api.rejectDeployment.bind(Api));
    },
    actOnDeployment(action) {
    this.loading = true;
    this.show = false;
    action(this.projectId, this.upcomingDeployment.id)
    .catch((err) => {
    if (err.response) {
    createAlert({ message: err.response.data.message });
    }
    })
    .finally(() => {
    this.loading = false;
    this.$emit('change');
    });
    },
    approvalText({ user }) {
    if (user.username === gon.current_username) {
    return this.$options.i18n.approvalByMe;
    }
    return this.$options.i18n.approval;
    },
    },
    i18n: {
    button: s__('DeploymentApproval|Approval options'),
    title: s__('DeploymentApproval|Approve or reject deployment #%{deploymentIid}'),
    message: s__(
    'DeploymentApproval|Approving will run the manual job from deployment #%{deploymentIid}. Rejecting will fail the manual job.',
    ),
    environment: s__('DeploymentApproval|Environment: %{environment}'),
    tier: s__('DeploymentApproval|Deployment tier: %{tier}'),
    job: s__('DeploymentApproval|Manual job: %{jobName}'),
    current: s__('DeploymentApproval| Current approvals: %{current}'),
    approval: s__('DeploymentApproval|Approved by %{user} %{time}'),
    approvalByMe: s__('DeploymentApproval|Approved by you %{time}'),
    approve: __('Approve'),
    reject: __('Reject'),
    },
    };
    </script>
    <template>
    <gl-button-group v-if="needsApproval">
    <gl-button :id="id" ref="button" :loading="loading" icon="thumb-up" @click="showPopover">
    {{ $options.i18n.button }}
    </gl-button>
    <gl-popover :target="id" triggers="click blur" placement="top" :title="title" :show="show">
    <p>
    <gl-sprintf :message="$options.i18n.message">
    <template #deploymentIid>{{ deploymentIid }}</template>
    </gl-sprintf>
    </p>
    <div>
    <gl-sprintf :message="$options.i18n.environment">
    <template #environment>
    <span class="gl-font-weight-bold">{{ environment.name }}</span>
    </template>
    </gl-sprintf>
    </div>
    <div v-if="environment.tier">
    <gl-sprintf :message="$options.i18n.tier">
    <template #tier>
    <span class="gl-font-weight-bold">{{ environment.tier }}</span>
    </template>
    </gl-sprintf>
    </div>
    <div>
    <gl-sprintf v-if="deployableName" :message="$options.i18n.job">
    <template #jobName>
    <span class="gl-font-weight-bold">
    {{ deployableName }}
    </span>
    </template>
    </gl-sprintf>
    </div>
    <div class="gl-mt-4 gl-pt-4">
    <gl-sprintf :message="$options.i18n.current">
    <template #current>
    <span class="gl-font-weight-bold"> {{ currentApprovals }}/{{ totalApprovals }}</span>
    </template>
    </gl-sprintf>
    </div>
    <p v-for="(approval, index) in upcomingDeployment.approvals" :key="index">
    <gl-sprintf :message="approvalText(approval)">
    <template #user>
    <gl-link :href="approval.user.webUrl">@{{ approval.user.username }}</gl-link>
    </template>
    <template #time><time-ago-tooltip :time="approval.createdAt" /></template>
    </gl-sprintf>
    </p>
    <div v-if="canApproveDeployment" class="gl-mt-4 gl-pt-4">
    <gl-button ref="approve" :loading="loading" variant="confirm" @click="approve">
    {{ $options.i18n.approve }}
    </gl-button>
    <gl-button ref="reject" :loading="loading" @click="reject">
    {{ $options.i18n.reject }}
    </gl-button>
    </div>
    </gl-popover>
    </gl-button-group>
    </template>
    ......@@ -7,6 +7,10 @@ module DeploymentEntity
    prepended do
    expose :pending_approval_count
    expose :approvals, using: ::API::Entities::Deployments::Approval
    expose :can_approve_deployment do |deployment|
    can?(request.current_user, :update_deployment, deployment)
    end
    end
    end
    end
    ......@@ -749,4 +749,28 @@ describe('Api', () => {
    });
    });
    });
    describe('deployment approvals', () => {
    const projectId = 1;
    const deploymentId = 2;
    const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/deployments/${deploymentId}/approval`;
    it('sends an approval when approve is true', async () => {
    mock.onPost(expectedUrl, { status: 'approved' }).replyOnce(httpStatus.OK);
    await Api.deploymentApproval(projectId, deploymentId, true);
    expect(mock.history.post.length).toBe(1);
    expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'approved' }));
    });
    it('sends a rejection when approve is false', async () => {
    mock.onPost(expectedUrl, { status: 'rejected' }).replyOnce(httpStatus.OK);
    await Api.deploymentApproval(projectId, deploymentId, false);
    expect(mock.history.post.length).toBe(1);
    expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'rejected' }));
    });
    });
    });
    import { GlButton, GlPopover } from '@gitlab/ui';
    import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
    import { trimText } from 'helpers/text_helper';
    import waitForPromises from 'helpers/wait_for_promises';
    import EnvironmentApproval from 'ee/environments/components/environment_approval.vue';
    import Api from 'ee/api';
    import { __, s__, sprintf } from '~/locale';
    import { createAlert } from '~/flash';
    import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
    import { environment as mockEnvironment } from './mock_data';
    jest.mock('ee/api.js');
    jest.mock('~/flash');
    describe('ee/environments/components/environment_approval.vue', () => {
    let wrapper;
    const environment = convertObjectPropsToCamelCase(mockEnvironment, { deep: true });
    const createWrapper = ({ propsData = {} } = {}) =>
    mountExtended(EnvironmentApproval, {
    propsData: { environment, ...propsData },
    provide: { projectId: '5' },
    });
    afterEach(() => {
    wrapper.destroy();
    });
    const findPopover = () => extendedWrapper(wrapper.findComponent(GlPopover));
    const findButton = () => extendedWrapper(wrapper.findComponent(GlButton));
    it('should link the popover to the button', () => {
    wrapper = createWrapper();
    const popover = findPopover();
    const button = findButton();
    expect(popover.props('target')).toBe(button.attributes('id'));
    });
    describe('popover', () => {
    let popover;
    beforeEach(async () => {
    wrapper = createWrapper();
    await findButton().trigger('click');
    popover = findPopover();
    });
    it('should set the popover title', () => {
    expect(popover.props('title')).toBe(
    sprintf(s__('DeploymentApproval|Approve or reject deployment #%{deploymentIid}'), {
    deploymentIid: environment.upcomingDeployment.iid,
    }),
    );
    });
    it('should show the popover after clicking the button', () => {
    expect(popover.attributes('show')).toBe('true');
    });
    it('should show which deployment this is approving', () => {
    const main = sprintf(
    s__(
    'DeploymentApproval|Approving will run the manual job from deployment #%{deploymentIid}. Rejecting will fail the manual job.',
    ),
    {
    deploymentIid: environment.upcomingDeployment.iid,
    },
    );
    expect(popover.findByText(main).exists()).toBe(true);
    });
    describe('showing details about the environment', () => {
    it.each`
    detail | text
    ${'environment name'} | ${sprintf(s__('DeploymentApproval|Environment: %{environment}'), { environment: environment.name })}
    ${'environment tier'} | ${sprintf(s__('DeploymentApproval|Deployment tier: %{tier}'), { tier: environment.tier })}
    ${'job name'} | ${sprintf(s__('DeploymentApproval|Manual job: %{jobName}'), { jobName: environment.upcomingDeployment.deployable.name })}
    `('should show information on $detail', ({ text }) => {
    expect(trimText(popover.text())).toContain(text);
    });
    it('shows the number of current approvals as well as the number of total approvals needed', () => {
    expect(trimText(popover.text())).toContain(
    sprintf(s__('DeploymentApproval| Current approvals: %{current}'), {
    current: '5/10',
    }),
    );
    });
    });
    describe('permissions', () => {
    beforeAll(() => {
    gon.current_username = 'root';
    });
    it.each`
    scenario | username | approvals | canApproveDeployment | visible
    ${'user can approve, no approvals'} | ${'root'} | ${[]} | ${true} | ${true}
    ${'user cannot approve, no approvals'} | ${'root'} | ${[]} | ${false} | ${false}
    ${'user can approve, has approved'} | ${'root'} | ${[{ user: { username: 'root' }, createdAt: Date.now() }]} | ${true} | ${false}
    ${'user can approve, someone else approved'} | ${'root'} | ${[{ user: { username: 'foo' }, createdAt: Date.now() }]} | ${true} | ${true}
    ${'user cannot approve, has already approved'} | ${'root'} | ${[{ user: { username: 'root' }, createdAt: Date.now() }]} | ${false} | ${false}
    `(
    'should have buttons visible when $scenario: $visible',
    ({ approvals, canApproveDeployment, visible }) => {
    wrapper = createWrapper({
    propsData: {
    environment: {
    ...environment,
    upcomingDeployment: {
    ...environment.upcomingDeployment,
    approvals,
    canApproveDeployment,
    },
    },
    },
    });
    expect(wrapper.findComponent({ ref: 'approve' }).exists()).toBe(visible);
    expect(wrapper.findComponent({ ref: 'reject' }).exists()).toBe(visible);
    },
    );
    });
    describe.each`
    ref | api | text
    ${'approve'} | ${Api.approveDeployment} | ${__('Approve')}
    ${'reject'} | ${Api.rejectDeployment} | ${__('Reject')}
    `('$ref', ({ ref, api, text }) => {
    let button;
    beforeEach(() => {
    button = wrapper.findComponent({ ref });
    });
    it('should show the correct text', () => {
    expect(button.text()).toBe(text);
    });
    it('should approve the deployment when Approve is clicked', async () => {
    api.mockResolvedValue();
    await button.trigger('click');
    expect(api).toHaveBeenCalledWith('5', environment.upcomingDeployment.id);
    await waitForPromises();
    expect(wrapper.emitted('change')).toEqual([[]]);
    });
    it('should show an error on failure', async () => {
    api.mockRejectedValue({ response: { data: { message: 'oops' } } });
    await button.trigger('click');
    expect(createAlert).toHaveBeenCalledWith({ message: 'oops' });
    });
    it('should set loading to true after click', async () => {
    await button.trigger('click');
    expect(button.props('loading')).toBe(true);
    });
    it('should stop showing the popover once resolved', async () => {
    api.mockResolvedValue();
    await button.trigger('click');
    expect(popover.attributes('show')).toBeUndefined();
    });
    });
    });
    });
    ......@@ -58,6 +58,69 @@ export const environment = {
    ],
    deployed_at: '2016-11-29T18:11:58.430Z',
    },
    upcoming_deployment: {
    id: 66,
    iid: 6,
    sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
    ref: {
    name: 'main',
    ref_url: 'root/ci-folders/tree/main',
    },
    tag: true,
    'last?': true,
    user: {
    name: 'Administrator',
    username: 'root',
    id: 1,
    state: 'active',
    avatar_url:
    'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
    web_url: 'http://localhost:3000/root',
    },
    commit: {
    id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
    short_id: '500aabcb',
    title: 'Update .gitlab-ci.yml',
    author_name: 'Administrator',
    author_email: 'admin@example.com',
    created_at: '2016-11-07T18:28:13.000+00:00',
    message: 'Update .gitlab-ci.yml',
    author: {
    name: 'Administrator',
    username: 'root',
    id: 1,
    state: 'active',
    avatar_url:
    'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
    web_url: 'http://localhost:3000/root',
    },
    commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
    },
    deployable: {
    id: 1279,
    name: 'deploy',
    build_path: '/root/ci-folders/builds/1279',
    retry_path: '/root/ci-folders/builds/1279/retry',
    created_at: '2016-11-29T18:11:58.430Z',
    updated_at: '2016-11-29T18:11:58.430Z',
    status: {
    text: 'success',
    icon: 'status_success',
    },
    },
    manual_actions: [
    {
    name: 'action',
    play_path: '/play',
    },
    ],
    approvals: [],
    can_approve_deployment: true,
    deployed_at: '2016-11-29T18:11:58.430Z',
    pending_approval_count: 5,
    },
    required_approval_count: 10,
    tier: 'production',
    has_stop_action: true,
    environment_path: 'root/ci-folders/environments/31',
    log_path: 'root/ci-folders/environments/31/logs',
    ......
    ......@@ -5,6 +5,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
    import { stubTransition } from 'helpers/stub_transition';
    import EnvironmentItem from '~/environments/components/new_environment_item.vue';
    import EnvironmentAlert from 'ee/environments/components/environment_alert.vue';
    import EnvironmentApproval from 'ee/environments/components/environment_approval.vue';
    import alertQuery from 'ee/environments/graphql/queries/environment.query.graphql';
    import { resolvedEnvironment } from 'jest/environments/graphql/mock_data';
    ......@@ -13,6 +14,7 @@ Vue.use(VueApollo);
    describe('~/environments/components/new_environment_item.vue', () => {
    let wrapper;
    let alert;
    let approval;
    const createApolloProvider = () => {
    return createMockApollo([
    ......@@ -43,14 +45,16 @@ describe('~/environments/components/new_environment_item.vue', () => {
    wrapper = mountExtended(EnvironmentItem, {
    apolloProvider,
    propsData: { environment: resolvedEnvironment, ...propsData },
    provide: { helpPagePath: '/help' },
    provide: { helpPagePath: '/help', projectId: '1' },
    stubs: { transition: stubTransition() },
    });
    await nextTick();
    alert = wrapper.findComponent(EnvironmentAlert);
    approval = wrapper.findComponent(EnvironmentApproval);
    };
    it('shows an alert if one is opened', async () => {
    const environment = { ...resolvedEnvironment, hasOpenedAlert: true };
    await createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() });
    ......@@ -62,7 +66,16 @@ describe('~/environments/components/new_environment_item.vue', () => {
    it('does not show an alert if one is opened', async () => {
    await createWrapper({ apolloProvider: createApolloProvider() });
    alert = wrapper.findComponent(EnvironmentAlert);
    expect(alert.exists()).toBe(false);
    });
    it('emits a change if approval changes', async () => {
    const upcomingDeployment = resolvedEnvironment.lastDeployment;
    const environment = { ...resolvedEnvironment, lastDeployment: null, upcomingDeployment };
    await createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() });
    approval.vm.$emit('change');
    expect(wrapper.emitted('change')).toEqual([[]]);
    });
    });
    ......@@ -4,15 +4,17 @@
    RSpec.describe DeploymentEntity do
    let_it_be(:project) { create(:project, :repository) }
    let_it_be(:environment) { create(:environment, project: project) }
    let_it_be(:deployment) { create(:deployment, :blocked, project: project, environment: environment) }
    let_it_be(:request) { EntityRequest.new(project: project, current_user: create(:user)) }
    let_it_be(:current_user) { create(:user) }
    let_it_be(:request) { EntityRequest.new(project: project, current_user: current_user) }
    let(:deployment) { create(:deployment, :blocked, project: project, environment: environment) }
    let(:environment) { create(:environment, project: project) }
    let!(:protected_environment) { create(:protected_environment, name: environment.name, project: project, required_approval_count: 3) }
    subject { described_class.new(deployment, request: request).as_json }
    before do
    stub_licensed_features(protected_environments: true)
    create(:protected_environment, name: environment.name, project: project, required_approval_count: 3)
    create(:deployment_approval, deployment: deployment)
    end
    ......@@ -27,4 +29,23 @@
    expect(subject[:approvals].length).to eq(1)
    end
    end
    describe '#can_approve_deployment' do
    context 'when user has permission to update deployment' do
    before do
    project.add_maintainer(current_user)
    create(:protected_environment_deploy_access_level, protected_environment: protected_environment, user: current_user)
    end
    it 'returns true' do
    expect(subject[:can_approve_deployment]).to be(true)
    end
    end
    context 'when user does not have permission to update deployment' do
    it 'returns false' do
    expect(subject[:can_approve_deployment]).to be(false)
    end
    end
    end
    end
    ......@@ -12233,6 +12233,33 @@ msgstr ""
    msgid "Deployment frequency"
    msgstr ""
     
    msgid "DeploymentApproval| Current approvals: %{current}"
    msgstr ""
    msgid "DeploymentApproval|Approval options"
    msgstr ""
    msgid "DeploymentApproval|Approve or reject deployment #%{deploymentIid}"
    msgstr ""
    msgid "DeploymentApproval|Approved by %{user} %{time}"
    msgstr ""
    msgid "DeploymentApproval|Approved by you %{time}"
    msgstr ""
    msgid "DeploymentApproval|Approving will run the manual job from deployment #%{deploymentIid}. Rejecting will fail the manual job."
    msgstr ""
    msgid "DeploymentApproval|Deployment tier: %{tier}"
    msgstr ""
    msgid "DeploymentApproval|Environment: %{environment}"
    msgstr ""
    msgid "DeploymentApproval|Manual job: %{jobName}"
    msgstr ""
    msgid "DeploymentTarget|GitLab Pages"
    msgstr ""
     
    ......@@ -12295,6 +12322,9 @@ msgstr ""
    msgid "Deployment|Latest Deployed"
    msgstr ""
     
    msgid "Deployment|Needs Approval"
    msgstr ""
    msgid "Deployment|Running"
    msgstr ""
     
    ......@@ -30343,6 +30373,9 @@ msgstr ""
    msgid "Reindexing Status: %{status} (Slice multiplier: %{multiplier}, Maximum running slices: %{max_slices})"
    msgstr ""
     
    msgid "Reject"
    msgstr ""
    msgid "Rejected (closed)"
    msgstr ""
     
    ......
    ......@@ -24,7 +24,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
    mountExtended(EnvironmentItem, {
    apolloProvider,
    propsData: { environment: resolvedEnvironment, ...propsData },
    provide: { helpPagePath: '/help' },
    provide: { helpPagePath: '/help', projectId: '1' },
    stubs: { transition: stubTransition() },
    });
    ......
    ......@@ -48,6 +48,7 @@ describe('~/environments/components/new_environments_app.vue', () => {
    canCreateEnvironment: true,
    defaultBranchName: 'main',
    helpPagePath: '/help',
    projectId: '1',
    ...provide,
    },
    apolloProvider,
    ......
    0% Loading or .
    You are about to add 0 people to the discussion. Proceed with caution.
    Finish editing this message first!
    Please register or to comment