Commit 9b03a623 authored by Jiaan Louw's avatar Jiaan Louw Committed by Kushal Pandya

Add external approvals rules to project settings

parent a6785099
<script>
import { GlIcon, GlPopover } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
export default {
components: {
GlIcon,
GlPopover,
},
props: {
url: {
type: String,
required: true,
},
},
computed: {
iconId() {
return uniqueId('approval-icon-');
},
containerId() {
return uniqueId('approva-icon-container-');
},
},
i18n: {
title: __('Approval Gate'),
},
};
</script>
<template>
<div :id="containerId">
<gl-icon :id="iconId" name="api" />
<gl-popover
:target="iconId"
:container="containerId"
placement="top"
:title="$options.i18n.title"
triggers="hover focus"
:content="url"
/>
</div>
</template>
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdown,
GlDropdownItem,
},
props: {
approverTypeOptions: {
type: Array,
required: true,
},
},
data() {
return {
selected: null,
};
},
computed: {
dropdownText() {
return this.selected.text;
},
},
created() {
const [firstOption] = this.approverTypeOptions;
this.onSelect(firstOption);
},
methods: {
isSelectedType(type) {
return this.selected.type === type;
},
onSelect(option) {
this.selected = option;
this.$emit('input', option.type);
},
},
};
</script>
<template>
<gl-dropdown class="gl-w-full gl-dropdown-menu-full-width" :text="dropdownText">
<gl-dropdown-item
v-for="option in approverTypeOptions"
:key="option.type"
:is-check-item="true"
:is-checked="isSelectedType(option.type)"
@click="onSelect(option)"
>
<span>{{ option.text }}</span>
</gl-dropdown-item>
</gl-dropdown>
</template>
...@@ -3,14 +3,24 @@ import { GlSprintf } from '@gitlab/ui'; ...@@ -3,14 +3,24 @@ import { GlSprintf } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { n__, s__, __ } from '~/locale'; import { n__, s__, __ } from '~/locale';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue'; import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import { RULE_TYPE_EXTERNAL_APPROVAL } from '../constants';
const i18n = { const i18n = {
cancelButtonText: __('Cancel'), cancelButtonText: __('Cancel'),
primaryButtonText: __('Remove approvers'), regularRule: {
modalTitle: __('Remove approvers?'), primaryButtonText: __('Remove approvers'),
removeWarningText: s__( modalTitle: __('Remove approvers?'),
'ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.', removeWarningText: s__(
), 'ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.',
),
},
externalRule: {
primaryButtonText: s__('ApprovalRuleRemove|Remove approval gate'),
modalTitle: s__('ApprovalRuleRemove|Remove approval gate?'),
removeWarningText: s__(
'ApprovalRuleRemove|You are about to remove the %{name} approval gate. Approval from this service is not revoked.',
),
},
}; };
export default { export default {
...@@ -28,6 +38,9 @@ export default { ...@@ -28,6 +38,9 @@ export default {
...mapState('deleteModal', { ...mapState('deleteModal', {
rule: 'data', rule: 'data',
}), }),
isExternalApprovalRule() {
return this.rule?.ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
membersText() { membersText() {
return n__( return n__(
'ApprovalRuleRemove|%d member', 'ApprovalRuleRemove|%d member',
...@@ -42,24 +55,38 @@ export default { ...@@ -42,24 +55,38 @@ export default {
this.rule.approvers.length, this.rule.approvers.length,
); );
}, },
modalTitle() {
return this.isExternalApprovalRule
? i18n.externalRule.modalTitle
: i18n.regularRule.modalTitle;
},
modalText() { modalText() {
return `${i18n.removeWarningText} ${this.revokeWarningText}`; return this.isExternalApprovalRule
? i18n.externalRule.removeWarningText
: `${i18n.regularRule.removeWarningText} ${this.revokeWarningText}`;
},
primaryButtonProps() {
const text = this.isExternalApprovalRule
? i18n.externalRule.primaryButtonText
: i18n.regularRule.primaryButtonText;
return {
text,
attributes: [{ variant: 'danger' }],
};
}, },
}, },
methods: { methods: {
...mapActions(['deleteRule']), ...mapActions(['deleteRule', 'deleteExternalApprovalRule']),
submit() { submit() {
this.deleteRule(this.rule.id); if (this.rule.externalUrl) {
this.deleteExternalApprovalRule(this.rule.id);
} else {
this.deleteRule(this.rule.id);
}
}, },
}, },
buttonActions: { cancelButtonProps: {
primary: { text: i18n.cancelButtonText,
text: i18n.primaryButtonText,
attributes: [{ variant: 'danger' }],
},
cancel: {
text: i18n.cancelButtonText,
},
}, },
i18n, i18n,
}; };
...@@ -69,9 +96,9 @@ export default { ...@@ -69,9 +96,9 @@ export default {
<gl-modal-vuex <gl-modal-vuex
modal-module="deleteModal" modal-module="deleteModal"
:modal-id="modalId" :modal-id="modalId"
:title="$options.i18n.modalTitle" :title="modalTitle"
:action-primary="$options.buttonActions.primary" :action-primary="primaryButtonProps"
:action-cancel="$options.buttonActions.cancel" :action-cancel="$options.cancelButtonProps"
@ok.prevent="submit" @ok.prevent="submit"
> >
<p v-if="rule"> <p v-if="rule">
...@@ -82,9 +109,6 @@ export default { ...@@ -82,9 +109,6 @@ export default {
<template #nMembers> <template #nMembers>
<strong>{{ membersText }}</strong> <strong>{{ membersText }}</strong>
</template> </template>
<template #revokeWarning>
{{ revokeWarningText }}
</template>
</gl-sprintf> </gl-sprintf>
</p> </p>
</gl-modal-vuex> </gl-modal-vuex>
......
...@@ -4,8 +4,13 @@ import RuleName from 'ee/approvals/components/rule_name.vue'; ...@@ -4,8 +4,13 @@ import RuleName from 'ee/approvals/components/rule_name.vue';
import { n__, sprintf } from '~/locale'; import { n__, sprintf } from '~/locale';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { RULE_TYPE_ANY_APPROVER, RULE_TYPE_REGULAR } from '../../constants'; import {
RULE_TYPE_EXTERNAL_APPROVAL,
RULE_TYPE_ANY_APPROVER,
RULE_TYPE_REGULAR,
} from '../../constants';
import ApprovalGateIcon from '../approval_gate_icon.vue';
import EmptyRule from '../empty_rule.vue'; import EmptyRule from '../empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue'; import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue'; import RuleBranches from '../rule_branches.vue';
...@@ -15,6 +20,7 @@ import UnconfiguredSecurityRules from '../security_configuration/unconfigured_se ...@@ -15,6 +20,7 @@ import UnconfiguredSecurityRules from '../security_configuration/unconfigured_se
export default { export default {
components: { components: {
ApprovalGateIcon,
RuleControls, RuleControls,
Rules, Rules,
UserAvatarList, UserAvatarList,
...@@ -95,6 +101,9 @@ export default { ...@@ -95,6 +101,9 @@ export default {
return canEdit && (!allowMultiRule || !rule.hasSource); return canEdit && (!allowMultiRule || !rule.hasSource);
}, },
isExternalApprovalRule({ ruleType }) {
return ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
}, },
}; };
</script> </script>
...@@ -132,13 +141,14 @@ export default { ...@@ -132,13 +141,14 @@ export default {
class="js-members" class="js-members"
:class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null" :class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null"
> >
<user-avatar-list :items="rule.approvers" :img-size="24" empty-text="" /> <approval-gate-icon v-if="isExternalApprovalRule(rule)" :url="rule.externalUrl" />
<user-avatar-list v-else :items="rule.approvers" :img-size="24" empty-text="" />
</td> </td>
<td v-if="settings.allowMultiRule" class="js-branches"> <td v-if="settings.allowMultiRule" class="js-branches">
<rule-branches :rule="rule" /> <rule-branches :rule="rule" />
</td> </td>
<td class="js-approvals-required"> <td class="js-approvals-required">
<rule-input :rule="rule" /> <rule-input v-if="!isExternalApprovalRule(rule)" :rule="rule" />
</td> </td>
<td class="text-nowrap px-2 w-0 js-controls"> <td class="text-nowrap px-2 w-0 js-controls">
<rule-controls v-if="canEdit(rule)" :rule="rule" /> <rule-controls v-if="canEdit(rule)" :rule="rule" />
......
<script> <script>
import { groupBy, isNumber } from 'lodash'; import { groupBy, isNumber } from 'lodash';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale'; import { isSafeURL } from '~/lib/utils/url_utility';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants'; import { sprintf, __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
TYPE_USER,
TYPE_GROUP,
TYPE_HIDDEN_GROUPS,
RULE_TYPE_EXTERNAL_APPROVAL,
RULE_TYPE_USER_OR_GROUP_APPROVER,
} from '../constants';
import ApproverTypeSelect from './approver_type_select.vue';
import ApproversList from './approvers_list.vue'; import ApproversList from './approvers_list.vue';
import ApproversSelect from './approvers_select.vue'; import ApproversSelect from './approvers_select.vue';
import BranchesSelect from './branches_select.vue'; import BranchesSelect from './branches_select.vue';
...@@ -21,7 +30,9 @@ export default { ...@@ -21,7 +30,9 @@ export default {
ApproversList, ApproversList,
ApproversSelect, ApproversSelect,
BranchesSelect, BranchesSelect,
ApproverTypeSelect,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
initRule: { initRule: {
type: Object, type: Object,
...@@ -44,6 +55,7 @@ export default { ...@@ -44,6 +55,7 @@ export default {
name: this.defaultRuleName, name: this.defaultRuleName,
approvalsRequired: 1, approvalsRequired: 1,
minApprovalsRequired: 0, minApprovalsRequired: 0,
externalUrl: null,
approvers: [], approvers: [],
approversToAdd: [], approversToAdd: [],
branches: [], branches: [],
...@@ -52,6 +64,7 @@ export default { ...@@ -52,6 +64,7 @@ export default {
isFallback: false, isFallback: false,
containsHiddenGroups: false, containsHiddenGroups: false,
serverValidationErrors: [], serverValidationErrors: [],
ruleType: null,
...this.getInitialData(), ...this.getInitialData(),
}; };
...@@ -59,6 +72,17 @@ export default { ...@@ -59,6 +72,17 @@ export default {
}, },
computed: { computed: {
...mapState(['settings']), ...mapState(['settings']),
showApproverTypeSelect() {
return (
this.glFeatures.ffComplianceApprovalGates &&
!this.isEditing &&
!this.isMrEdit &&
!READONLY_NAMES.includes(this.name)
);
},
isExternalApprovalRule() {
return this.ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
rule() { rule() {
// If we are creating a new rule with a suggested approval name // If we are creating a new rule with a suggested approval name
return this.defaultRuleName ? null : this.initRule; return this.defaultRuleName ? null : this.initRule;
...@@ -85,16 +109,32 @@ export default { ...@@ -85,16 +109,32 @@ export default {
const invalidObject = { const invalidObject = {
name: this.invalidName, name: this.invalidName,
approvalsRequired: this.invalidApprovalsRequired,
approvers: this.invalidApprovers,
}; };
if (!this.isMrEdit) { if (!this.isMrEdit) {
invalidObject.branches = this.invalidBranches; invalidObject.branches = this.invalidBranches;
} }
if (this.isExternalApprovalRule) {
invalidObject.externalUrl = this.invalidApprovalGateUrl;
} else {
invalidObject.approvers = this.invalidApprovers;
invalidObject.approvalsRequired = this.invalidApprovalsRequired;
}
return invalidObject; return invalidObject;
}, },
invalidApprovalGateUrl() {
let error = '';
if (this.serverValidationErrors.includes('External url has already been taken')) {
error = __('External url has already been taken');
} else if (!this.externalUrl || !isSafeURL(this.externalUrl)) {
error = __('Please provide a valid URL');
}
return error;
},
invalidName() { invalidName() {
let error = ''; let error = '';
...@@ -175,9 +215,24 @@ export default { ...@@ -175,9 +215,24 @@ export default {
protectedBranchIds: this.branches, protectedBranchIds: this.branches,
}; };
}, },
isEditing() {
return Boolean(this.initRule);
},
externalRuleSubmissionData() {
const { id, name, protectedBranchIds } = this.submissionData;
return {
id,
name,
protectedBranchIds,
externalUrl: this.externalUrl,
};
},
showProtectedBranch() { showProtectedBranch() {
return !this.isMrEdit && this.settings.allowMultiRule; return !this.isMrEdit && this.settings.allowMultiRule;
}, },
approvalGateLabel() {
return this.isEditing ? this.$options.i18n.approvalGate : this.$options.i18n.addApprovalGate;
},
}, },
watch: { watch: {
approversToAdd(value) { approversToAdd(value) {
...@@ -188,7 +243,15 @@ export default { ...@@ -188,7 +243,15 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['putFallbackRule', 'postRule', 'putRule', 'deleteRule', 'postRegularRule']), ...mapActions([
'putFallbackRule',
'putExternalApprovalRule',
'postExternalApprovalRule',
'postRule',
'putRule',
'deleteRule',
'postRegularRule',
]),
addSelection() { addSelection() {
if (!this.approversToAdd.length) { if (!this.approversToAdd.length) {
return; return;
...@@ -219,9 +282,13 @@ export default { ...@@ -219,9 +282,13 @@ export default {
} }
submission.catch((failureResponse) => { submission.catch((failureResponse) => {
this.serverValidationErrors = mapServerResponseToValidationErrors( if (this.isExternalApprovalRule) {
failureResponse?.response?.data?.message || {}, this.serverValidationErrors = failureResponse?.response?.data?.message || [];
); } else {
this.serverValidationErrors = mapServerResponseToValidationErrors(
failureResponse?.response?.data?.message || {},
);
}
}); });
return submission; return submission;
...@@ -230,12 +297,14 @@ export default { ...@@ -230,12 +297,14 @@ export default {
* Submit the rule, by either put-ing or post-ing. * Submit the rule, by either put-ing or post-ing.
*/ */
submitRule() { submitRule() {
if (this.isExternalApprovalRule) {
const data = this.externalRuleSubmissionData;
return data.id ? this.putExternalApprovalRule(data) : this.postExternalApprovalRule(data);
}
const data = this.submissionData; const data = this.submissionData;
if (!this.settings.allowMultiRule && this.settings.prefix === 'mr-edit') { if (!this.settings.allowMultiRule && this.settings.prefix === 'mr-edit') {
return data.id ? this.putRule(data) : this.postRegularRule(data); return data.id ? this.putRule(data) : this.postRegularRule(data);
} }
return data.id ? this.putRule(data) : this.postRule(data); return data.id ? this.putRule(data) : this.postRule(data);
}, },
/** /**
...@@ -248,7 +317,7 @@ export default { ...@@ -248,7 +317,7 @@ export default {
* Submit as a single rule. This is determined by the settings. * Submit as a single rule. This is determined by the settings.
*/ */
submitSingleRule() { submitSingleRule() {
if (!this.approvers.length) { if (!this.approvers.length && !this.isExternalApprovalRule) {
return this.submitEmptySingleRule(); return this.submitEmptySingleRule();
} }
...@@ -280,6 +349,16 @@ export default { ...@@ -280,6 +349,16 @@ export default {
}; };
} }
if (this.initRule.ruleType === RULE_TYPE_EXTERNAL_APPROVAL) {
return {
name: this.initRule.name || '',
externalUrl: this.initRule.externalUrl,
branches: this.initRule.protectedBranches?.map((x) => x.id) || [],
ruleType: this.initRule.ruleType,
approvers: [],
};
}
const { containsHiddenGroups = false, removeHiddenGroups = false } = this.initRule; const { containsHiddenGroups = false, removeHiddenGroups = false } = this.initRule;
const users = this.initRule.users.map((x) => ({ ...x, type: TYPE_USER })); const users = this.initRule.users.map((x) => ({ ...x, type: TYPE_USER }));
...@@ -290,6 +369,7 @@ export default { ...@@ -290,6 +369,7 @@ export default {
name: this.initRule.name || '', name: this.initRule.name || '',
approvalsRequired: this.initRule.approvalsRequired || 0, approvalsRequired: this.initRule.approvalsRequired || 0,
minApprovalsRequired: this.initRule.minApprovalsRequired || 0, minApprovalsRequired: this.initRule.minApprovalsRequired || 0,
ruleType: this.initRule.ruleType,
containsHiddenGroups, containsHiddenGroups,
approvers: groups approvers: groups
.concat(users) .concat(users)
...@@ -300,6 +380,14 @@ export default { ...@@ -300,6 +380,14 @@ export default {
}; };
}, },
}, },
i18n: {
approvalGate: s__('ApprovalRule|Approvel gate'),
addApprovalGate: s__('ApprovalRule|Add approvel gate'),
},
approverTypeOptions: [
{ type: RULE_TYPE_USER_OR_GROUP_APPROVER, text: s__('ApprovalRule|Users or groups') },
{ type: RULE_TYPE_EXTERNAL_APPROVAL, text: s__('ApprovalRule|Approval service API') },
],
}; };
</script> </script>
...@@ -334,7 +422,14 @@ export default { ...@@ -334,7 +422,14 @@ export default {
{{ __('Apply this approval rule to any branch or a specific protected branch.') }} {{ __('Apply this approval rule to any branch or a specific protected branch.') }}
</small> </small>
</div> </div>
<div class="form-group gl-form-group"> <div v-if="showApproverTypeSelect" class="form-group gl-form-group">
<label class="col-form-label">{{ s__('ApprovalRule|Approver Type') }}</label>
<approver-type-select
v-model="ruleType"
:approver-type-options="$options.approverTypeOptions"
/>
</div>
<div v-if="!isExternalApprovalRule" class="form-group gl-form-group">
<label class="col-form-label">{{ s__('ApprovalRule|Approvals required') }}</label> <label class="col-form-label">{{ s__('ApprovalRule|Approvals required') }}</label>
<input <input
v-model.number="approvalsRequired" v-model.number="approvalsRequired"
...@@ -347,7 +442,7 @@ export default { ...@@ -347,7 +442,7 @@ export default {
/> />
<span class="invalid-feedback">{{ validation.approvalsRequired }}</span> <span class="invalid-feedback">{{ validation.approvalsRequired }}</span>
</div> </div>
<div class="form-group gl-form-group"> <div v-if="!isExternalApprovalRule" class="form-group gl-form-group">
<label class="col-form-label">{{ s__('ApprovalRule|Add approvers') }}</label> <label class="col-form-label">{{ s__('ApprovalRule|Add approvers') }}</label>
<approvers-select <approvers-select
v-model="approversToAdd" v-model="approversToAdd"
...@@ -359,7 +454,22 @@ export default { ...@@ -359,7 +454,22 @@ export default {
/> />
<span class="invalid-feedback">{{ validation.approvers }}</span> <span class="invalid-feedback">{{ validation.approvers }}</span>
</div> </div>
<div class="bordered-box overflow-auto h-12em"> <div v-if="isExternalApprovalRule" class="form-group gl-form-group">
<label class="col-form-label">{{ approvalGateLabel }}</label>
<input
v-model="externalUrl"
:class="{ 'is-invalid': validation.externalUrl }"
class="gl-form-input form-control"
name="approval_gate_url"
type="url"
data-qa-selector="external_url_field"
/>
<span class="invalid-feedback">{{ validation.externalUrl }}</span>
<small class="form-text text-gl-muted">
{{ s__('ApprovalRule|Invoke an external API as part of the approvals') }}
</small>
</div>
<div v-if="!isExternalApprovalRule" class="bordered-box overflow-auto h-12em">
<approvers-list v-model="approvers" /> <approvers-list v-model="approvers" />
</div> </div>
</form> </form>
......
...@@ -17,6 +17,7 @@ export const RULE_TYPE_CODE_OWNER = 'code_owner'; ...@@ -17,6 +17,7 @@ export const RULE_TYPE_CODE_OWNER = 'code_owner';
export const RULE_TYPE_ANY_APPROVER = 'any_approver'; export const RULE_TYPE_ANY_APPROVER = 'any_approver';
export const RULE_TYPE_EXTERNAL_APPROVAL = 'external_approval'; export const RULE_TYPE_EXTERNAL_APPROVAL = 'external_approval';
export const RULE_NAME_ANY_APPROVER = 'All Members'; export const RULE_NAME_ANY_APPROVER = 'All Members';
export const RULE_TYPE_USER_OR_GROUP_APPROVER = 'user_or_group';
export const VULNERABILITY_CHECK_NAME = 'Vulnerability-Check'; export const VULNERABILITY_CHECK_NAME = 'Vulnerability-Check';
export const LICENSE_CHECK_NAME = 'License-Check'; export const LICENSE_CHECK_NAME = 'License-Check';
......
...@@ -70,7 +70,7 @@ export const mapExternalApprovalRuleResponse = (res) => ({ ...@@ -70,7 +70,7 @@ export const mapExternalApprovalRuleResponse = (res) => ({
}); });
export const mapExternalApprovalResponse = (res) => ({ export const mapExternalApprovalResponse = (res) => ({
rules: withDefaultEmptyRule(res.map(mapExternalApprovalRuleResponse)), rules: res.map(mapExternalApprovalRuleResponse),
}); });
export const mapApprovalSettingsResponse = (res) => ({ export const mapApprovalSettingsResponse = (res) => ({
......
...@@ -10,6 +10,10 @@ module EE ...@@ -10,6 +10,10 @@ module EE
before_action :log_archive_audit_event, only: [:archive] before_action :log_archive_audit_event, only: [:archive]
before_action :log_unarchive_audit_event, only: [:unarchive] before_action :log_unarchive_audit_event, only: [:unarchive]
before_action only: :edit do
push_frontend_feature_flag(:ff_compliance_approval_gates, project, default_enabled: :yaml)
end
before_action only: :show do before_action only: :show do
push_frontend_feature_flag(:cve_id_request_button, project) push_frontend_feature_flag(:cve_id_request_button, project)
end end
......
...@@ -5,15 +5,16 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do ...@@ -5,15 +5,16 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
include GitlabRoutingHelper include GitlabRoutingHelper
include FeatureApprovalHelper include FeatureApprovalHelper
let(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:project) { create(:project) } let_it_be(:project) { create(:project) }
let(:group) { create(:group) } let_it_be(:group) { create(:group) }
let(:group_member) { create(:user) } let_it_be(:group_member) { create(:user) }
let(:non_member) { create(:user) } let_it_be(:non_member) { create(:user) }
let!(:config_selector) { '.js-approval-rules' } let_it_be(:config_selector) { '.js-approval-rules' }
let!(:modal_selector) { '#project-settings-approvals-create-modal' } let_it_be(:modal_selector) { '#project-settings-approvals-create-modal' }
before do before do
stub_licensed_features(compliance_approval_gates: true)
sign_in(user) sign_in(user)
project.add_maintainer(user) project.add_maintainer(user)
group.add_developer(user) group.add_developer(user)
...@@ -69,8 +70,8 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do ...@@ -69,8 +70,8 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end end
context 'with an approver group' do context 'with an approver group' do
let(:non_group_approver) { create(:user) } let_it_be(:non_group_approver) { create(:user) }
let!(:rule) { create(:approval_project_rule, project: project, groups: [group], users: [non_group_approver]) } let_it_be(:rule) { create(:approval_project_rule, project: project, groups: [group], users: [non_group_approver]) }
before do before do
project.add_developer(non_group_approver) project.add_developer(non_group_approver)
...@@ -90,6 +91,64 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do ...@@ -90,6 +91,64 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end end
end end
it 'adds an approval gate' do
visit edit_project_path(project)
open_modal(text: 'Add approval rule', expand: false)
within('.modal-content') do
find('button', text: "Users or groups").click
find('button', text: "Approval service API").click
find('[data-qa-selector="rule_name_field"]').set('My new rule')
find('[data-qa-selector="external_url_field"]').set('https://api.gitlab.com')
click_button 'Add approval rule'
end
wait_for_requests
expect(first('.js-name')).to have_content('My new rule')
end
context 'with an approval gate' do
let_it_be(:rule) { create(:external_approval_rule, project: project) }
it 'updates the approval gate' do
visit edit_project_path(project)
expect(first('.js-name')).to have_content(rule.name)
open_modal(text: 'Edit', expand: false)
within('.modal-content') do
find('[data-qa-selector="rule_name_field"]').set('Something new')
click_button 'Update approval rule'
end
wait_for_requests
expect(first('.js-name')).to have_content('Something new')
end
it 'removes the approval gate' do
visit edit_project_path(project)
expect(first('.js-name')).to have_content(rule.name)
first('.js-controls').find('[data-testid="remove-icon"]').click
within('.modal-content') do
click_button 'Remove approval gate'
end
wait_for_requests
expect(first('.js-name')).not_to have_content(rule.name)
end
end
context 'issuable default templates feature not available' do context 'issuable default templates feature not available' do
before do before do
stub_licensed_features(issuable_default_templates: false) stub_licensed_features(issuable_default_templates: false)
......
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Approvals ModalRuleRemove shows message 1`] = ` exports[`Approvals ModalRuleRemove matches the snapshot for external approval 1`] = `
<div
title="Remove approval gate?"
>
<p>
You are about to remove the
<strong>
API Gate
</strong>
approval gate. Approval from this service is not revoked.
</p>
</div>
`;
exports[`Approvals ModalRuleRemove matches the snapshot for multiple approvers 1`] = `
<div <div
title="Remove approvers?" title="Remove approvers?"
> >
...@@ -18,7 +32,7 @@ exports[`Approvals ModalRuleRemove shows message 1`] = ` ...@@ -18,7 +32,7 @@ exports[`Approvals ModalRuleRemove shows message 1`] = `
</div> </div>
`; `;
exports[`Approvals ModalRuleRemove shows singular message 1`] = ` exports[`Approvals ModalRuleRemove matches the snapshot for singular approver 1`] = `
<div <div
title="Remove approvers?" title="Remove approvers?"
> >
......
import { GlPopover, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ApprovalGateIcon from 'ee/approvals/components/approval_gate_icon.vue';
jest.mock('lodash/uniqueId', () => (id) => `${id}mock`);
describe('ApprovalGateIcon', () => {
let wrapper;
const findPopover = () => wrapper.findComponent(GlPopover);
const findIcon = () => wrapper.findComponent(GlIcon);
const createComponent = () => {
return shallowMount(ApprovalGateIcon, {
propsData: {
url: 'https://gitlab.com/',
},
});
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
wrapper = createComponent();
});
it('renders the icon', () => {
expect(findIcon().props('name')).toBe('api');
expect(findIcon().attributes('id')).toBe('approval-icon-mock');
});
it('renders the popover with the URL for the icon', () => {
expect(findPopover().exists()).toBe(true);
expect(findPopover().attributes()).toMatchObject({
content: 'https://gitlab.com/',
title: 'Approval Gate',
target: 'approval-icon-mock',
});
});
});
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import ApprovalTypeSelect from 'ee/approvals/components/approver_type_select.vue';
jest.mock('lodash/uniqueId', () => (id) => `${id}mock`);
const OPTIONS = [
{ type: 'x', text: 'foo' },
{ type: 'y', text: 'bar' },
];
describe('ApprovalTypeSelect', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const createComponent = () => {
return shallowMount(ApprovalTypeSelect, {
propsData: {
approverTypeOptions: OPTIONS,
},
});
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
wrapper = createComponent();
});
it('should select the first option by default', () => {
expect(findDropdownItems().at(0).props('isChecked')).toBe(true);
});
it('renders the dropdown with the selected text', () => {
expect(findDropdown().props('text')).toBe(OPTIONS[0].text);
});
it('renders a dropdown item for each option', () => {
OPTIONS.forEach((option, idx) => {
expect(findDropdownItems().at(idx).text()).toBe(option.text);
});
});
it('should select an item when clicked', async () => {
const item = findDropdownItems().at(1);
expect(item.props('isChecked')).toBe(false);
item.vm.$emit('click');
await nextTick();
expect(item.props('isChecked')).toBe(true);
});
});
...@@ -4,6 +4,7 @@ import Vuex from 'vuex'; ...@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue'; import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue'; import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import { createExternalRule } from '../mocks';
const MODAL_MODULE = 'deleteModal'; const MODAL_MODULE = 'deleteModal';
const TEST_MODAL_ID = 'test-delete-modal-id'; const TEST_MODAL_ID = 'test-delete-modal-id';
...@@ -14,6 +15,11 @@ const TEST_RULE = { ...@@ -14,6 +15,11 @@ const TEST_RULE = {
.fill(1) .fill(1)
.map((x, id) => ({ id })), .map((x, id) => ({ id })),
}; };
const SINGLE_APPROVER = {
...TEST_RULE,
approvers: [{ id: 1 }],
};
const EXTERNAL_RULE = createExternalRule();
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -61,6 +67,7 @@ describe('Approvals ModalRuleRemove', () => { ...@@ -61,6 +67,7 @@ describe('Approvals ModalRuleRemove', () => {
}; };
actions = { actions = {
deleteRule: jest.fn(), deleteRule: jest.fn(),
deleteExternalApprovalRule: jest.fn(),
}; };
}); });
...@@ -83,30 +90,31 @@ describe('Approvals ModalRuleRemove', () => { ...@@ -83,30 +90,31 @@ describe('Approvals ModalRuleRemove', () => {
); );
}); });
it('shows message', () => { it.each`
factory(); type | rule
${'multiple approvers'} | ${TEST_RULE}
expect(findModal().element).toMatchSnapshot(); ${'singular approver'} | ${SINGLE_APPROVER}
}); ${'external approval'} | ${EXTERNAL_RULE}
`('matches the snapshot for $type', ({ rule }) => {
it('shows singular message', () => { deleteModalState.data = rule;
deleteModalState.data = {
...TEST_RULE,
approvers: [{ id: 1 }],
};
factory(); factory();
expect(findModal().element).toMatchSnapshot(); expect(findModal().element).toMatchSnapshot();
}); });
it('deletes rule when modal is submitted', () => { it.each`
typeType | action | rule
${'regular'} | ${'deleteRule'} | ${TEST_RULE}
${'external'} | ${'deleteExternalApprovalRule'} | ${EXTERNAL_RULE}
`('calls $action when the modal is submitted for a $typeType rule', ({ action, rule }) => {
deleteModalState.data = rule;
factory(); factory();
expect(actions.deleteRule).not.toHaveBeenCalled(); expect(actions[action]).not.toHaveBeenCalled();
const modal = findModal(); const modal = findModal();
modal.vm.$emit('ok', new Event('submit')); modal.vm.$emit('ok', new Event('submit'));
expect(actions.deleteRule).toHaveBeenCalledWith(expect.anything(), TEST_RULE.id); expect(actions[action]).toHaveBeenCalledWith(expect.anything(), rule.id);
}); });
}); });
import { mount, createLocalVue } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import ApprovalGateIcon from 'ee/approvals/components/approval_gate_icon.vue';
import RuleInput from 'ee/approvals/components/mr_edit/rule_input.vue'; import RuleInput from 'ee/approvals/components/mr_edit/rule_input.vue';
import ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue'; import ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue';
import RuleName from 'ee/approvals/components/rule_name.vue'; import RuleName from 'ee/approvals/components/rule_name.vue';
...@@ -8,7 +9,7 @@ import UnconfiguredSecurityRules from 'ee/approvals/components/security_configur ...@@ -8,7 +9,7 @@ import UnconfiguredSecurityRules from 'ee/approvals/components/security_configur
import { createStoreOptions } from 'ee/approvals/stores'; import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings'; import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { createProjectRules } from '../../mocks'; import { createProjectRules, createExternalRule } from '../../mocks';
const TEST_RULES = createProjectRules(); const TEST_RULES = createProjectRules();
...@@ -149,4 +150,26 @@ describe('Approvals ProjectRules', () => { ...@@ -149,4 +150,26 @@ describe('Approvals ProjectRules', () => {
expect(wrapper.find(UnconfiguredSecurityRules).exists()).toBe(true); expect(wrapper.find(UnconfiguredSecurityRules).exists()).toBe(true);
}); });
}); });
describe('when the rule is external', () => {
const rule = createExternalRule();
beforeEach(() => {
store.modules.approvals.state.rules = [rule];
factory();
});
it('renders the approval gate component with URL', () => {
expect(wrapper.findComponent(ApprovalGateIcon).props('url')).toBe(rule.externalUrl);
});
it('does not render a user avatar component', () => {
expect(wrapper.findComponent(UserAvatarList).exists()).toBe(false);
});
it('does not render the approvals required input', () => {
expect(wrapper.findComponent(RuleInput).exists()).toBe(false);
});
});
}); });
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import ApproverTypeSelect from 'ee/approvals/components/approver_type_select.vue';
import ApproversList from 'ee/approvals/components/approvers_list.vue'; import ApproversList from 'ee/approvals/components/approvers_list.vue';
import ApproversSelect from 'ee/approvals/components/approvers_select.vue'; import ApproversSelect from 'ee/approvals/components/approvers_select.vue';
import BranchesSelect from 'ee/approvals/components/branches_select.vue'; import BranchesSelect from 'ee/approvals/components/branches_select.vue';
import RuleForm from 'ee/approvals/components/rule_form.vue'; import RuleForm from 'ee/approvals/components/rule_form.vue';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from 'ee/approvals/constants'; import {
TYPE_USER,
TYPE_GROUP,
TYPE_HIDDEN_GROUPS,
RULE_TYPE_EXTERNAL_APPROVAL,
} from 'ee/approvals/constants';
import { createStoreOptions } from 'ee/approvals/stores'; import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings'; import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import waitForPromises from 'helpers/wait_for_promises';
import { createExternalRule } from '../mocks';
const TEST_PROJECT_ID = '7'; const TEST_PROJECT_ID = '7';
const TEST_RULE = { const TEST_RULE = {
...@@ -27,6 +36,10 @@ const TEST_FALLBACK_RULE = { ...@@ -27,6 +36,10 @@ const TEST_FALLBACK_RULE = {
approvalsRequired: 1, approvalsRequired: 1,
isFallback: true, isFallback: true,
}; };
const TEST_EXTERNAL_APPROVAL_RULE = {
...createExternalRule(),
protectedBranches: TEST_PROTECTED_BRANCHES,
};
const TEST_LOCKED_RULE_NAME = 'LOCKED_RULE'; const TEST_LOCKED_RULE_NAME = 'LOCKED_RULE';
const nameTakenError = { const nameTakenError = {
response: { response: {
...@@ -37,6 +50,13 @@ const nameTakenError = { ...@@ -37,6 +50,13 @@ const nameTakenError = {
}, },
}, },
}; };
const urlTakenError = {
response: {
data: {
message: ['External url has already been taken'],
},
},
};
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -54,7 +74,11 @@ describe('EE Approvals RuleForm', () => { ...@@ -54,7 +74,11 @@ describe('EE Approvals RuleForm', () => {
store: new Vuex.Store(store), store: new Vuex.Store(store),
localVue, localVue,
provide: { provide: {
glFeatures: { scopedApprovalRules: true, ...options.provide?.glFeatures }, glFeatures: {
ffComplianceApprovalGates: true,
scopedApprovalRules: true,
...options.provide?.glFeatures,
},
}, },
}); });
}; };
...@@ -71,6 +95,9 @@ describe('EE Approvals RuleForm', () => { ...@@ -71,6 +95,9 @@ describe('EE Approvals RuleForm', () => {
const findApproversValidation = () => findValidation(findApproversSelect(), true); const findApproversValidation = () => findValidation(findApproversSelect(), true);
const findApproversList = () => wrapper.find(ApproversList); const findApproversList = () => wrapper.find(ApproversList);
const findBranchesSelect = () => wrapper.find(BranchesSelect); const findBranchesSelect = () => wrapper.find(BranchesSelect);
const findApproverTypeSelect = () => wrapper.findComponent(ApproverTypeSelect);
const findExternalUrlInput = () => wrapper.find('input[name=approval_gate_url');
const findExternalUrlValidation = () => findValidation(findExternalUrlInput(), false);
const findBranchesValidation = () => findValidation(findBranchesSelect(), true); const findBranchesValidation = () => findValidation(findBranchesSelect(), true);
const findValidations = () => [ const findValidations = () => [
findNameValidation(), findNameValidation(),
...@@ -85,12 +112,20 @@ describe('EE Approvals RuleForm', () => { ...@@ -85,12 +112,20 @@ describe('EE Approvals RuleForm', () => {
findBranchesValidation(), findBranchesValidation(),
]; ];
const findValidationForExternal = () => [
findNameValidation(),
findExternalUrlValidation(),
findBranchesValidation(),
];
beforeEach(() => { beforeEach(() => {
store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID }); store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID });
['postRule', 'putRule', 'deleteRule', 'putFallbackRule'].forEach((actionName) => { ['postRule', 'putRule', 'deleteRule', 'putFallbackRule', 'postExternalApprovalRule'].forEach(
jest.spyOn(store.modules.approvals.actions, actionName).mockImplementation(() => {}); (actionName) => {
}); jest.spyOn(store.modules.approvals.actions, actionName).mockImplementation(() => {});
},
);
({ actions } = store.modules.approvals); ({ actions } = store.modules.approvals);
}); });
...@@ -181,6 +216,119 @@ describe('EE Approvals RuleForm', () => { ...@@ -181,6 +216,119 @@ describe('EE Approvals RuleForm', () => {
}); });
}); });
describe('when the rule is an external rule', () => {
describe('with initial rule', () => {
beforeEach(() => {
createComponent({
isMrEdit: false,
initRule: TEST_EXTERNAL_APPROVAL_RULE,
});
});
it('does not render the approver type select input', () => {
expect(findApproverTypeSelect().exists()).toBe(false);
});
it('on load, it populates the external URL', () => {
expect(findExternalUrlInput().element.value).toBe(
TEST_EXTERNAL_APPROVAL_RULE.externalUrl,
);
});
});
describe('without an initial rule', () => {
beforeEach(() => {
createComponent({
isMrEdit: false,
});
findApproverTypeSelect().vm.$emit('input', RULE_TYPE_EXTERNAL_APPROVAL);
});
it('renders the approver type select input', () => {
expect(findApproverTypeSelect().exists()).toBe(true);
});
it('renders the inputs for external rules', () => {
expect(findNameInput().exists()).toBe(true);
expect(findExternalUrlInput().exists()).toBe(true);
expect(findBranchesSelect().exists()).toBe(true);
});
it('does not render the user and group input fields', () => {
expect(findApprovalsRequiredInput().exists()).toBe(false);
expect(findApproversList().exists()).toBe(false);
expect(findApproversSelect().exists()).toBe(false);
});
it('at first, shows no validation', () => {
const inputs = findValidationForExternal();
const invalidInputs = inputs.filter((x) => !x.isValid);
const feedbacks = inputs.map((x) => x.feedback);
expect(invalidInputs.length).toBe(0);
expect(feedbacks.every((str) => !str.length)).toBe(true);
});
it('on submit, does not dispatch action', () => {
wrapper.vm.submit();
expect(actions.postExternalApprovalRule).not.toHaveBeenCalled();
});
it('on submit, shows name validation', async () => {
findExternalUrlInput().setValue('');
wrapper.vm.submit();
await nextTick();
expect(findExternalUrlValidation()).toEqual({
isValid: false,
feedback: 'Please provide a valid URL',
});
});
describe('with valid data', () => {
const branches = TEST_PROTECTED_BRANCHES.map((x) => x.id);
const expected = {
id: null,
name: 'Lorem',
externalUrl: 'https://gitlab.com/',
protectedBranchIds: branches,
};
beforeEach(() => {
findNameInput().setValue(expected.name);
findExternalUrlInput().setValue(expected.externalUrl);
wrapper.vm.branches = expected.protectedBranchIds;
});
it('on submit, posts external approval rule', () => {
wrapper.vm.submit();
expect(actions.postExternalApprovalRule).toHaveBeenCalledWith(
expect.anything(),
expected,
);
});
it('when submitted with a duplicate external URL, shows the "url already taken" validation', async () => {
store.state.settings.prefix = 'project-settings';
jest.spyOn(wrapper.vm, 'postExternalApprovalRule').mockRejectedValueOnce(urlTakenError);
wrapper.vm.submit();
await waitForPromises();
expect(findExternalUrlValidation()).toEqual({
isValid: false,
feedback: 'External url has already been taken',
});
});
});
});
});
describe('without initRule', () => { describe('without initRule', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -536,16 +684,17 @@ describe('EE Approvals RuleForm', () => { ...@@ -536,16 +684,17 @@ describe('EE Approvals RuleForm', () => {
describe('with approval suggestions', () => { describe('with approval suggestions', () => {
describe.each` describe.each`
defaultRuleName | expectedDisabledAttribute defaultRuleName | expectedDisabledAttribute | approverTypeSelect
${'Vulnerability-Check'} | ${'disabled'} ${'Vulnerability-Check'} | ${'disabled'} | ${false}
${'License-Check'} | ${'disabled'} ${'License-Check'} | ${'disabled'} | ${false}
${'Foo Bar Baz'} | ${undefined} ${'Foo Bar Baz'} | ${undefined} | ${true}
`( `(
'with defaultRuleName set to $defaultRuleName', 'with defaultRuleName set to $defaultRuleName',
({ defaultRuleName, expectedDisabledAttribute }) => { ({ defaultRuleName, expectedDisabledAttribute, approverTypeSelect }) => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
initRule: null, initRule: null,
isMrEdit: false,
defaultRuleName, defaultRuleName,
}); });
}); });
...@@ -555,6 +704,12 @@ describe('EE Approvals RuleForm', () => { ...@@ -555,6 +704,12 @@ describe('EE Approvals RuleForm', () => {
} the name text field`, () => { } the name text field`, () => {
expect(findNameInput().attributes('disabled')).toBe(expectedDisabledAttribute); expect(findNameInput().attributes('disabled')).toBe(expectedDisabledAttribute);
}); });
it(`${
approverTypeSelect ? 'renders' : 'does not render'
} the approver type select`, () => {
expect(findApproverTypeSelect().exists()).toBe(approverTypeSelect);
});
}, },
); );
}); });
...@@ -727,4 +882,23 @@ describe('EE Approvals RuleForm', () => { ...@@ -727,4 +882,23 @@ describe('EE Approvals RuleForm', () => {
}); });
}); });
}); });
describe('when the approval gates feature is disabled', () => {
it('does not render the approver type select input', async () => {
createComponent(
{ isMrEdit: false },
{
provide: {
glFeatures: {
ffComplianceApprovalGates: false,
},
},
},
);
await nextTick();
expect(findApproverTypeSelect().exists()).toBe(false);
});
});
}); });
export const createExternalRule = () => ({
id: 9,
name: 'API Gate',
externalUrl: 'https://gitlab.com',
ruleType: 'external_approval',
});
export const createProjectRules = () => [ export const createProjectRules = () => [
{ {
id: 1, id: 1,
......
...@@ -3982,6 +3982,9 @@ msgstr "" ...@@ -3982,6 +3982,9 @@ msgstr ""
msgid "Applying suggestions..." msgid "Applying suggestions..."
msgstr "" msgstr ""
msgid "Approval Gate"
msgstr ""
msgid "Approval Status" msgid "Approval Status"
msgstr "" msgstr ""
...@@ -4004,6 +4007,15 @@ msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked." ...@@ -4004,6 +4007,15 @@ msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "ApprovalRuleRemove|Remove approval gate"
msgstr ""
msgid "ApprovalRuleRemove|Remove approval gate?"
msgstr ""
msgid "ApprovalRuleRemove|You are about to remove the %{name} approval gate. Approval from this service is not revoked."
msgstr ""
msgid "ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}." msgid "ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}."
msgstr "" msgstr ""
...@@ -4017,21 +4029,36 @@ msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCoun ...@@ -4017,21 +4029,36 @@ msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCoun
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "ApprovalRule|Add approvel gate"
msgstr ""
msgid "ApprovalRule|Add approvers" msgid "ApprovalRule|Add approvers"
msgstr "" msgstr ""
msgid "ApprovalRule|Approval rules" msgid "ApprovalRule|Approval rules"
msgstr "" msgstr ""
msgid "ApprovalRule|Approval service API"
msgstr ""
msgid "ApprovalRule|Approvals required" msgid "ApprovalRule|Approvals required"
msgstr "" msgstr ""
msgid "ApprovalRule|Approvel gate"
msgstr ""
msgid "ApprovalRule|Approver Type"
msgstr ""
msgid "ApprovalRule|Approvers" msgid "ApprovalRule|Approvers"
msgstr "" msgstr ""
msgid "ApprovalRule|Examples: QA, Security." msgid "ApprovalRule|Examples: QA, Security."
msgstr "" msgstr ""
msgid "ApprovalRule|Invoke an external API as part of the approvals"
msgstr ""
msgid "ApprovalRule|Name" msgid "ApprovalRule|Name"
msgstr "" msgstr ""
...@@ -4041,6 +4068,9 @@ msgstr "" ...@@ -4041,6 +4068,9 @@ msgstr ""
msgid "ApprovalRule|Target branch" msgid "ApprovalRule|Target branch"
msgstr "" msgstr ""
msgid "ApprovalRule|Users or groups"
msgstr ""
msgid "ApprovalStatusTooltip|Adheres to separation of duties" msgid "ApprovalStatusTooltip|Adheres to separation of duties"
msgstr "" msgstr ""
...@@ -13013,6 +13043,9 @@ msgstr "" ...@@ -13013,6 +13043,9 @@ msgstr ""
msgid "External storage authentication token" msgid "External storage authentication token"
msgstr "" msgstr ""
msgid "External url has already been taken"
msgstr ""
msgid "ExternalAuthorizationService|Classification label" msgid "ExternalAuthorizationService|Classification label"
msgstr "" msgstr ""
......
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