Commit fd5ae2da authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 62e4f767 27a3c00c
import { initSearchApp } from '~/search';
document.addEventListener('DOMContentLoaded', () => {
initSearchApp();
});
initSearchApp();
......@@ -31,6 +31,10 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
ciFileContent: {
type: String,
required: true,
},
ciConfigData: {
type: Object,
required: true,
......@@ -60,6 +64,7 @@ export default {
<validation-segment
:class="validationStyling"
:loading="isCiConfigDataLoading"
:ci-file-content="ciFileContent"
:ci-config="ciConfigData"
/>
</div>
......
......@@ -5,6 +5,9 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { CI_CONFIG_STATUS_VALID } from '../../constants';
export const i18n = {
empty: __(
"We'll continuously validate your pipeline configuration. The validation results will appear here.",
),
learnMore: __('Learn more'),
loading: s__('Pipelines|Validating GitLab CI configuration…'),
invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
......@@ -26,6 +29,10 @@ export default {
},
},
props: {
ciFileContent: {
type: String,
required: true,
},
ciConfig: {
type: Object,
required: false,
......@@ -38,17 +45,22 @@ export default {
},
},
computed: {
isEmpty() {
return !this.ciFileContent;
},
isValid() {
return this.ciConfig?.status === CI_CONFIG_STATUS_VALID;
},
icon() {
if (this.isValid) {
if (this.isValid || this.isEmpty) {
return 'check';
}
return 'warning-solid';
},
message() {
if (this.isValid) {
if (this.isEmpty) {
return this.$options.i18n.empty;
} else if (this.isValid) {
return this.$options.i18n.valid;
}
......@@ -74,7 +86,7 @@ export default {
<tooltip-on-truncate :title="message" class="gl-text-truncate">
<gl-icon :name="icon" /> <span data-testid="validationMsg">{{ message }}</span>
</tooltip-on-truncate>
<span class="gl-flex-shrink-0 gl-pl-2">
<span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2">
<gl-link data-testid="learnMoreLink" :href="ymlHelpPagePath">
{{ $options.i18n.learnMore }}
</gl-link>
......
<script>
import { GlSprintf } from '@gitlab/ui';
import { GlButton, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
GlButton,
GlSprintf,
},
i18n: {
......@@ -11,24 +13,44 @@ export default {
body: __(
'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.',
),
btnText: __('Create new CI/CD pipeline'),
},
mixins: [glFeatureFlagsMixin()],
inject: {
emptyStateIllustrationPath: {
default: '',
},
},
computed: {
showCTAButton() {
return this.glFeatures.pipelineEditorEmptyStateAction;
},
},
methods: {
createEmptyConfigFile() {
this.$emit('createEmptyConfigFile');
},
},
};
</script>
<template>
<div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
<img :src="emptyStateIllustrationPath" />
<h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
<p>
<p class="gl-mt-3">
<gl-sprintf :message="$options.i18n.body">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
<gl-button
v-if="showCTAButton"
variant="confirm"
class="gl-mt-3"
@click="createEmptyConfigFile"
>
{{ $options.i18n.btnText }}
</gl-button>
</div>
</template>
......@@ -36,7 +36,8 @@ export default {
// Success and failure state
failureType: null,
failureReasons: [],
hasNoCiConfigFile: false,
showStartScreen: false,
isNewConfigFile: false,
initialCiFileContent: '',
lastCommittedContent: '',
currentCiFileContent: '',
......@@ -48,6 +49,11 @@ export default {
apollo: {
initialCiFileContent: {
query: getBlobContent,
// If we are working off a new file, we don't want to fetch
// the base data as there is nothing to fetch.
skip({ isNewConfigFile }) {
return isNewConfigFile;
},
variables() {
return {
projectPath: this.projectFullPath,
......@@ -157,7 +163,7 @@ export default {
response?.status === httpStatusCodes.NOT_FOUND ||
response?.status === httpStatusCodes.BAD_REQUEST
) {
this.hasNoCiConfigFile = true;
this.showStartScreen = true;
} else {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
}
......@@ -183,6 +189,10 @@ export default {
resetContent() {
this.currentCiFileContent = this.lastCommittedContent;
},
setNewEmptyCiConfigFile() {
this.showStartScreen = false;
this.isNewConfigFile = true;
},
showErrorAlert({ type, reasons = [] }) {
this.reportFailure(type, reasons);
},
......@@ -202,7 +212,10 @@ export default {
<template>
<div class="gl-mt-4 gl-relative">
<gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" />
<pipeline-editor-empty-state v-else-if="hasNoCiConfigFile" />
<pipeline-editor-empty-state
v-else-if="showStartScreen"
@createEmptyConfigFile="setNewEmptyCiConfigFile"
/>
<div v-else>
<gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess">
{{ success.text }}
......
......@@ -45,6 +45,7 @@ export default {
<template>
<div>
<pipeline-editor-header
:ci-file-content="ciFileContent"
:ci-config-data="ciConfigData"
:is-ci-config-data-loading="isCiConfigDataLoading"
/>
......
......@@ -6,6 +6,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
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(:pipeline_status_for_pipeline_editor, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml)
end
feature_category :pipeline_authoring
......
......@@ -66,16 +66,6 @@ class BulkImports::Entity < ApplicationRecord
event :fail_op do
transition any => :failed
end
after_transition any => [:finished, :failed] do |entity|
Gitlab::Redis::Cache.with do |redis|
pattern = "bulk_import:#{entity.bulk_import.id}:entity:#{entity.id}:*"
redis.scan_each(match: pattern).each do |key|
redis.del(key)
end
end
end
end
def update_tracker_for(relation:, has_next_page:, next_page: nil)
......
......@@ -6,3 +6,4 @@ Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @m
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
= reviewers_label(@merge_request)
---
title: Add reviewers detail to merged merge request email
merge_request: 55589
author:
type: added
---
name: pipeline_editor_empty_state_action
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55414
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323229
milestone: '13.10'
type: development
group: group::pipeline authoring
default_enabled: false
......@@ -627,6 +627,35 @@ POST /projects/:id/external_approval_rules
| `external_url` | string | yes | URL of external approval resource |
| `protected_branch_ids` | array<Integer> | no | The ids of protected branches to scope the rule by |
### Delete external approval rule **(ULTIMATE)**
You can delete an external approval rule for a project using the following endpoint:
```plaintext
DELETE /projects/:id/external_approval_rules/:rule_id
```
| Attribute | Type | Required | Description |
|------------------------|----------------|----------|----------------------------------------------------|
| `rule_id` | integer | yes | The ID of an approval rule |
| `id` | integer | yes | The ID of a project |
### Update external approval rule **(ULTIMATE)**
You can update an existing external approval rule for a project using the following endpoint:
```plaintext
PATCH /projects/:id/external_approval_rules/:rule_id
```
| Attribute | Type | Required | Description |
|------------------------|----------------|----------|----------------------------------------------------|
| `id` | integer | yes | The ID of a project |
| `rule_id` | integer | yes | The ID of an external approval rule |
| `name` | string | no | Display name of approval rule |
| `external_url` | string | no | URL of external approval resource |
| `protected_branch_ids` | array<Integer> | no | The ids of protected branches to scope the rule by |
### Enable or disable External Project-level MR approvals **(ULTIMATE SELF)**
Enable or disable External Project-level MR approvals is under development and not ready for production use. It is
......
......@@ -101,7 +101,6 @@ export default {
return {
participantsArr: [],
endDateEnabled: false,
restrictToTimeEnabled: false,
};
},
methods: {
......@@ -295,15 +294,21 @@ export default {
</gl-card>
<gl-toggle
v-model="restrictToTimeEnabled"
:value="form.isRestrictedToTime"
data-testid="restricted-to-toggle"
:label="$options.i18n.fields.restrictToTime.enableToggle"
label-position="left"
class="gl-mt-5"
@change="
$emit('update-rotation-form', {
type: 'isRestrictedToTime',
value: !form.isRestrictedToTime,
})
"
/>
<gl-card
v-if="restrictToTimeEnabled"
v-if="form.isRestrictedToTime"
data-testid="restricted-to-time"
class="gl-mt-5 gl-border-gray-400 gl-bg-gray-10"
>
......@@ -317,15 +322,17 @@ export default {
<span> {{ __('From') }} </span>
<gl-dropdown
data-testid="restricted-from"
:text="format24HourTimeStringFromInt(form.restrictedTo.from)"
:text="format24HourTimeStringFromInt(form.restrictedTo.startTime)"
class="gl-px-3"
>
<gl-dropdown-item
v-for="time in $options.HOURS_IN_DAY"
:key="time"
:is-checked="form.restrictedTo.from === time"
:is-checked="form.restrictedTo.startTime === time"
is-check-item
@click="$emit('update-rotation-form', { type: 'restrictedTo.from', value: time })"
@click="
$emit('update-rotation-form', { type: 'restrictedTo.startTime', value: time })
"
>
<span class="gl-white-space-nowrap">
{{ format24HourTimeStringFromInt(time) }}</span
......@@ -335,15 +342,17 @@ export default {
<span> {{ __('To') }} </span>
<gl-dropdown
data-testid="restricted-to"
:text="format24HourTimeStringFromInt(form.restrictedTo.to)"
:text="format24HourTimeStringFromInt(form.restrictedTo.endTime)"
class="gl-px-3"
>
<gl-dropdown-item
v-for="time in $options.HOURS_IN_DAY"
:key="time"
:is-checked="form.restrictedTo.to === time"
:is-checked="form.restrictedTo.endTime === time"
is-check-item
@click="$emit('update-rotation-form', { type: 'restrictedTo.to', value: time })"
@click="
$emit('update-rotation-form', { type: 'restrictedTo.endTime', value: time })
"
>
<span class="gl-white-space-nowrap">
{{ format24HourTimeStringFromInt(time) }}</span
......
<script>
import { GlModal, GlAlert } from '@gitlab/ui';
import { set } from 'lodash';
import { cloneDeep, set } from 'lodash';
import { LENGTH_ENUM } from 'ee/oncall_schedules/constants';
import createOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mutations/create_oncall_schedule_rotation.mutation.graphql';
import updateOncallScheduleRotationMutation from 'ee/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql';
......@@ -21,9 +21,30 @@ export const i18n = {
cancel: __('Cancel'),
};
export const formEmptyState = {
name: '',
participants: [],
rotationLength: {
length: 1,
unit: LENGTH_ENUM.days,
},
startsAt: {
date: null,
time: 0,
},
endsAt: {
date: null,
time: 0,
},
isRestrictedToTime: false,
restrictedTo: {
startTime: 0,
endTime: 0,
},
};
export default {
i18n,
LENGTH_ENUM,
components: {
GlModal,
GlAlert,
......@@ -67,26 +88,7 @@ export default {
participants: [],
loading: false,
ptSearchTerm: '',
form: {
name: '',
participants: [],
rotationLength: {
length: 1,
unit: this.$options.LENGTH_ENUM.days,
},
startsAt: {
date: null,
time: 0,
},
endsAt: {
date: null,
time: 0,
},
restrictedTo: {
from: 0,
to: 0,
},
},
form: cloneDeep(formEmptyState),
error: '',
validationState: {
name: true,
......@@ -134,7 +136,7 @@ export default {
endsAt: { date: endDate, time: endTime },
} = this.form;
return {
const variables = {
projectPath: this.projectPath,
scheduleIid: this.schedule.iid,
name,
......@@ -154,6 +156,13 @@ export default {
},
participants: getParticipantsForSave(participants),
};
if (this.form.isRestrictedToTime) {
variables.activePeriod = {
startTime: format24HourTimeStringFromInt(this.form.restrictedTo.startTime),
endTime: format24HourTimeStringFromInt(this.form.restrictedTo.endTime),
};
}
return variables;
},
title() {
return this.isEditMode ? this.$options.i18n.editRotation : this.$options.i18n.addRotation;
......@@ -273,6 +282,9 @@ export default {
this.validationState.endsAt = this.isEndDateValid;
}
},
afterCloseModal() {
this.form = cloneDeep(formEmptyState);
},
},
};
</script>
......@@ -286,6 +298,7 @@ export default {
:action-cancel="actionsProps.cancel"
modal-class="rotations-modal"
@primary.prevent="isEditMode ? editRotation() : createRotation()"
@hide="afterCloseModal"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">
{{ error || $options.i18n.errorMsg }}
......
......@@ -135,6 +135,7 @@ export default {
:title="$options.i18n.editRotationLabel"
icon="pencil"
:aria-label="$options.i18n.editRotationLabel"
@click="setRotationToUpdate(rotation)"
/>
<gl-button
v-gl-modal="$options.deleteRotationModalId"
......
......@@ -7,6 +7,10 @@ fragment OnCallRotation on IncidentManagementOncallRotation {
endsAt
length
lengthUnit
activePeriod {
startTime
endTime
}
participants {
nodes {
...OnCallParticipant
......
# frozen_string_literal: true
module ExternalApprovalRules
class DestroyService < BaseContainerService
def execute(rule)
return unauthorized_error_response unless current_user.can?(:admin_project, container)
if rule.destroy
ServiceResponse.success
else
ServiceResponse.error(message: 'Failed to destroy rule',
payload: { errors: rule.errors.full_messages },
http_status: :unprocessable_entity)
end
end
private
def unauthorized_error_response
ServiceResponse.error(
message: 'Failed to destroy rule',
payload: { errors: ['Not allowed'] },
http_status: :unauthorized
)
end
end
end
# frozen_string_literal: true
module ExternalApprovalRules
class UpdateService < BaseContainerService
def execute
return unauthorized_error_response unless current_user.can?(:admin_project, container)
if rule.update(resource_params)
ServiceResponse.success(payload: { rule: rule })
else
ServiceResponse.error(message: 'Failed to update rule',
payload: { errors: rule.errors.full_messages },
http_status: :unprocessable_entity)
end
end
private
def resource_params
params.slice(:name, :external_url, :protected_branch_ids)
end
def rule
container.external_approval_rules.find(params[:rule_id])
end
def unauthorized_error_response
ServiceResponse.error(
message: 'Failed to update rule',
payload: { errors: ['Not allowed'] },
http_status: :unauthorized
)
end
end
end
......@@ -17,11 +17,10 @@ module API
end
end
resource :projects do
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
segment ':id/external_approval_rules' do
params do
requires :id, type: Integer, desc: 'The ID of the project to associate the rule with'
requires :name, type: String, desc: 'The approval rule\'s name'
requires :name, type: String, desc: 'The name of the rule'
requires :external_url, type: String, desc: 'The URL to notify when MR receives new commits'
optional :protected_branch_ids, type: Array[Integer], coerce_with: ::API::Validations::Types::CommaSeparatedToIntegerArray.coerce, desc: 'The protected branch ids for this rule'
use :pagination
......@@ -30,7 +29,6 @@ module API
success ::API::Entities::ExternalApprovalRule
detail 'This feature is gated by the :ff_compliance_approval_gates feature flag.'
end
post do
service = ::ExternalApprovalRules::CreateService.new(container: @project,
current_user: current_user,
......@@ -51,6 +49,46 @@ module API
present paginate(@project.external_approval_rules), with: ::API::Entities::ExternalApprovalRule
end
segment ':rule_id' do
desc 'Delete an external approval rule' do
detail 'This feature is gated by the :ff_compliance_approval_gates feature flag.'
end
params do
requires :rule_id, type: Integer, desc: 'The approval rule ID'
end
delete do
external_approval_rule = user_project.external_approval_rules.find(params[:rule_id])
destroy_conditionally!(external_approval_rule) do |external_approval_rule|
::ExternalApprovalRules::DestroyService.new(
container: @project,
current_user: current_user
).execute(external_approval_rule)
end
end
desc 'Update new external approval rule' do
success ::API::Entities::ExternalApprovalRule
detail 'This feature is gated by the :ff_compliance_approval_gates feature flag.'
end
params do
requires :rule_id, type: Integer, desc: 'The approval rule ID'
optional :name, type: String, desc: 'The approval rule\'s name'
optional :external_url, type: String, desc: 'The URL to notify when MR receives new commits'
optional :protected_branch_ids, type: Array[Integer], coerce_with: ::API::Validations::Types::CommaSeparatedToIntegerArray.coerce, desc: 'The protected branch ids for this rule'
end
put do
service = ::ExternalApprovalRules::UpdateService.new(container: @project,
current_user: current_user,
params: declared(params, include_missing: false)).execute
if service.success?
present service.payload[:rule], with: ::API::Entities::ExternalApprovalRule
else
render_api_error!(service.payload[:errors], service.http_status)
end
end
end
end
end
end
......
......@@ -3,7 +3,7 @@
FactoryBot.define do
factory :external_approval_rule, class: 'ApprovalRules::ExternalApprovalRule' do
project
external_url { "https://testurl.example.test" }
external_url { FFaker::Internet.http_url }
sequence :name do |i|
"rule #{i}"
......
......@@ -10,6 +10,7 @@ export const participants = [
name: 'test',
avatar: '',
avatarUrl: '',
webUrl: '',
},
{
id: '2',
......@@ -17,6 +18,7 @@ export const participants = [
name: 'hello',
avatar: '',
avatarUrl: '',
webUrl: '',
},
];
......@@ -142,6 +144,10 @@ export const createRotationResponse = {
endsAt: '2021-03-17T12:00:00Z',
length: 5,
lengthUnit: 'WEEKS',
activePeriod: {
startTime: '02:00',
endTime: '10:00',
},
participants: {
nodes: [
{
......@@ -176,6 +182,10 @@ export const createRotationResponseWithErrors = {
endsAt: '2021-03-17T12:00:00Z',
length: 5,
lengthUnit: 'WEEKS',
activePeriod: {
startTime: '02:00',
endTime: '10:00',
},
participants: {
nodes: [
{
......
......@@ -5,6 +5,10 @@
"endsAt": "2021-03-13T10:04:56.333Z",
"length": 1,
"lengthUnit": "WEEKS",
"activePeriod": {
"startTime": "02:00",
"endTime": "10:00"
},
"participants": {
"nodes": [
{
......@@ -58,6 +62,10 @@
"endsAt": "2021-03-13T10:04:56.333Z",
"length": 1,
"lengthUnit": "WEEKS",
"activePeriod": {
"startTime": "02:00",
"endTime": "10:00"
},
"participants": {
"nodes": [
{
......@@ -107,6 +115,10 @@
"endsAt": "2021-01-10T10:04:56.333Z",
"length": 1,
"lengthUnit": "WEEKS",
"activePeriod": {
"startTime": "02:00",
"endTime": "10:00"
},
"participants": {
"nodes": [
{
......@@ -156,6 +168,10 @@
"endsAt": "2021-01-11T10:04:56.333Z",
"length": 1,
"lengthUnit": "WEEKS",
"activePeriod": {
"startTime": "02:00",
"endTime": "10:00"
},
"participants": {
"nodes": [
{
......
import { GlModal, GlAlert } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlAlert, GlModal } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import AddEditRotationForm from 'ee/oncall_schedules/components/rotations/components/add_edit_rotation_form.vue';
import AddEditRotationModal, {
......@@ -18,6 +18,7 @@ import {
createRotationResponse,
createRotationResponseWithErrors,
} from '../../mocks/apollo_mock';
import mockRotation from '../../mocks/mock_rotation.json';
jest.mock('~/flash');
......@@ -102,6 +103,7 @@ describe('AddEditRotationModal', () => {
propsData: {
modalId: addRotationModalId,
schedule,
rotation: mockRotation[0],
},
apolloProvider: fakeApollo,
data() {
......
......@@ -6,25 +6,63 @@ RSpec.describe ::API::ExternalApprovalRules do
using RSpec::Parameterized::TableSyntax
let_it_be(:project) { create(:project) }
let_it_be(:rule) { create(:external_approval_rule, project: project, name: 'Rule 2', external_url: 'https://rule2.example') }
let(:collection_url) { "/projects/#{project.id}/external_approval_rules" }
let(:single_object_url) { "/projects/#{project.id}/external_approval_rules/#{rule.id}" }
describe 'DELETE projects/:id/external_approval_rules/:rule_id' do
before do
stub_licensed_features(compliance_approval_gates: true)
end
it 'deletes the specified rule' do
expect do
delete api(single_object_url, project.owner)
end.to change { ApprovalRules::ExternalApprovalRule.count }.by(-1)
end
context 'when feature is disabled, unlicensed or user has permission' do
where(:licensed, :flag, :project_owner, :status) do
false | false | false | :not_found
false | false | true | :unauthorized
false | true | true | :unauthorized
false | true | false | :not_found
true | false | false | :not_found
true | false | true | :unauthorized
true | true | false | :not_found
true | true | true | :success
end
with_them do
before do
stub_feature_flags(ff_compliance_approval_gates: flag)
stub_licensed_features(compliance_approval_gates: licensed)
end
it 'returns the correct status code' do
delete api(single_object_url, (project_owner ? project.owner : build(:user)))
expect(response).to have_gitlab_http_status(status)
end
end
end
end
describe 'GET projects/:id/external_approval_rules' do
let_it_be(:rule) { create(:external_approval_rule, project: project) }
let_it_be(:rule) { create(:external_approval_rule, project: project, name: 'Rule 1', external_url: "http://rule1.example") }
let_it_be(:protected_branches) { create_list(:protected_branch, 3, project: project) }
before_all do
create(:external_approval_rule)
create(:external_approval_rule) # Creating an orphaned rule to make sure project scoping works as expected
end
it 'responds with expected JSON' do
it 'responds with expected JSON', :aggregate_failures do
stub_licensed_features(compliance_approval_gates: true)
get api("/projects/#{project.id}/external_approval_rules", project.owner)
first_result = json_response.dig(0)
get api(collection_url, project.owner)
expect(json_response.size).to eq(1)
expect(first_result['id']).not_to be_nil
expect(first_result['name']).to eq(rule.name)
expect(first_result['external_url']).to eq(rule.external_url)
expect(json_response.size).to eq(2)
expect(json_response.map { |r| r['name'] }).to contain_exactly('Rule 1', 'Rule 2')
expect(json_response.map { |r| r['external_url'] }).to contain_exactly('http://rule1.example', 'https://rule2.example')
end
context 'when feature is disabled, unlicensed or user has permission' do
......@@ -46,7 +84,7 @@ RSpec.describe ::API::ExternalApprovalRules do
end
it 'returns the correct status code' do
get api("/projects/#{project.id}/external_approval_rules", (project_owner ? project.owner : build(:user)))
get api(collection_url, (project_owner ? project.owner : build(:user)))
expect(response).to have_gitlab_http_status(status)
end
......@@ -128,4 +166,85 @@ RSpec.describe ::API::ExternalApprovalRules do
end
end
end
describe 'PUT projects/:id/external_approval_rules/:rule_id' do
let(:params) { { external_url: 'http://newvalue.com', name: 'new name' } }
context 'successfully updating external approval rule' do
before do
stub_feature_flags(ff_compliance_approval_gates: true)
stub_licensed_features(compliance_approval_gates: true)
end
subject do
put api(single_object_url, project.owner), params: params
end
it 'updates an approval rule' do
expect { subject }.to change { rule.reload.external_url }.to eq('http://newvalue.com')
end
it 'responds with correct http status' do
subject
expect(response).to have_gitlab_http_status(:success)
end
context 'with protected branches' do
let_it_be(:protected_branch) { create(:protected_branch, project: project) }
let(:params) do
{ name: 'New rule', external_url: 'https://gitlab.com/test/example.json', protected_branch_ids: protected_branch.id }
end
subject do
put api(single_object_url, project.owner), params: params
end
it 'returns expected status code' do
subject
expect(response).to have_gitlab_http_status(:success)
end
it 'creates protected branch records' do
expect { subject }.to change { ApprovalRules::ExternalApprovalRule.last.protected_branches.count }.by(1)
end
it 'responds with expected JSON', :aggregate_failures do
subject
expect(json_response['id']).not_to be_nil
expect(json_response['name']).to eq('New rule')
expect(json_response['external_url']).to eq('https://gitlab.com/test/example.json')
expect(json_response['protected_branches'].size).to eq(1)
end
end
end
context 'when feature is disabled, unlicensed or user has permission' do
where(:licensed, :flag, :project_owner, :status) do
false | false | false | :not_found
false | false | true | :unauthorized
false | true | true | :unauthorized
false | true | false | :not_found
true | false | false | :not_found
true | false | true | :unauthorized
true | true | false | :not_found
true | true | true | :success
end
with_them do
before do
stub_feature_flags(ff_compliance_approval_gates: flag)
stub_licensed_features(compliance_approval_gates: licensed)
end
it 'returns the correct status code' do
put api(single_object_url, (project_owner ? project.owner : build(:user))), params: attributes_for(:external_approval_rule)
expect(response).to have_gitlab_http_status(status)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ExternalApprovalRules::DestroyService do
let_it_be(:project) { create(:project) }
let_it_be(:rule) { create(:external_approval_rule, project: project) }
let(:current_user) { project.owner }
subject { described_class.new(container: project, current_user: current_user).execute(rule) }
context 'when current user is project owner' do
it 'deletes an approval rule' do
expect { subject }.to change { ApprovalRules::ExternalApprovalRule.count }.by(-1)
end
it 'is successful' do
expect(subject.success?).to be true
end
end
context 'when current user is not a project owner' do
let_it_be(:current_user) { create(:user) }
it 'does not delete an approval rule' do
expect { subject }.not_to change { ApprovalRules::ExternalApprovalRule.count }
end
it 'is unsuccessful' do
expect(subject.error?).to be true
end
it 'returns an unauthorized status' do
expect(subject.http_status).to eq(:unauthorized)
end
it 'contains an appropriate message and error' do
expect(subject.message).to eq('Failed to destroy rule')
expect(subject.payload[:errors]).to contain_exactly('Not allowed')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ExternalApprovalRules::UpdateService do
let_it_be(:project) { create(:project) }
let_it_be(:rule) { create(:external_approval_rule, project: project) }
let_it_be(:protected_branch) { create(:protected_branch, project: project) }
let(:current_user) { project.owner }
let(:params) { { id: project.id, rule_id: rule.id, external_url: 'http://newvalue.com', name: 'new name', protected_branch_ids: [protected_branch.id] } }
subject { described_class.new(container: project, current_user: current_user, params: params).execute }
context 'when current user is project owner' do
it 'updates an approval rule' do
subject
rule.reload
expect(rule.external_url).to eq('http://newvalue.com')
expect(rule.name).to eq('new name')
expect(rule.protected_branches).to contain_exactly(protected_branch)
end
it 'is successful' do
expect(subject.success?).to be true
end
end
context 'when current user is not a project owner' do
let_it_be(:current_user) { create(:user) }
it 'does not change an approval rule' do
expect { subject }.not_to change { rule.name }
end
it 'is unsuccessful' do
expect(subject.error?).to be true
end
it 'returns an unauthorized status' do
expect(subject.http_status).to eq(:unauthorized)
end
it 'contains an appropriate message and error' do
expect(subject.message).to eq('Failed to update rule')
expect(subject.payload[:errors]).to contain_exactly('Not allowed')
end
end
end
# frozen_string_literal: true
module Gitlab
module RelativePositioning
class ClosedRange < RelativePositioning::Range
def initialize(lhs, rhs)
@lhs, @rhs = lhs, rhs
raise IllegalRange, 'Either lhs or rhs is missing' unless lhs && rhs
raise IllegalRange, 'lhs and rhs cannot be the same object' if lhs == rhs
end
end
end
end
# frozen_string_literal: true
module Gitlab
module RelativePositioning
class EndingAt < RelativePositioning::Range
include Gitlab::Utils::StrongMemoize
def initialize(rhs)
@rhs = rhs
raise IllegalRange, 'rhs is required' unless rhs
end
def lhs
strong_memoize(:lhs) { rhs.lhs_neighbour }
end
end
end
end
......@@ -31,39 +31,5 @@ module Gitlab
other.is_a?(RelativePositioning::Range) && lhs == other.lhs && rhs == other.rhs
end
end
class ClosedRange < RelativePositioning::Range
def initialize(lhs, rhs)
@lhs, @rhs = lhs, rhs
raise IllegalRange, 'Either lhs or rhs is missing' unless lhs && rhs
raise IllegalRange, 'lhs and rhs cannot be the same object' if lhs == rhs
end
end
class StartingFrom < RelativePositioning::Range
include Gitlab::Utils::StrongMemoize
def initialize(lhs)
@lhs = lhs
raise IllegalRange, 'lhs is required' unless lhs
end
def rhs
strong_memoize(:rhs) { lhs.rhs_neighbour }
end
end
class EndingAt < RelativePositioning::Range
include Gitlab::Utils::StrongMemoize
def initialize(rhs)
@rhs = rhs
raise IllegalRange, 'rhs is required' unless rhs
end
def lhs
strong_memoize(:lhs) { rhs.lhs_neighbour }
end
end
end
end
# frozen_string_literal: true
module Gitlab
module RelativePositioning
class StartingFrom < RelativePositioning::Range
include Gitlab::Utils::StrongMemoize
def initialize(lhs)
@lhs = lhs
raise IllegalRange, 'lhs is required' unless lhs
end
def rhs
strong_memoize(:rhs) { lhs.rhs_neighbour }
end
end
end
end
......@@ -8685,6 +8685,9 @@ msgstr ""
msgid "Create new %{name} by email"
msgstr ""
msgid "Create new CI/CD pipeline"
msgstr ""
msgid "Create new Value Stream"
msgstr ""
......@@ -33455,6 +33458,9 @@ msgstr ""
msgid "We would like to inform you that your subscription GitLab Enterprise Edition %{plan_name} is nearing its user limit. You have %{active_user_count} active users, which is almost at the user limit of %{maximum_user_count}."
msgstr ""
msgid "We'll continuously validate your pipeline configuration. The validation results will appear here."
msgstr ""
msgid "We've found no vulnerabilities"
msgstr ""
......
......@@ -3,7 +3,7 @@ import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_e
import PipelineStatus from '~/pipeline_editor/components/header/pipeline_status.vue';
import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue';
import { mockLintResponse } from '../../mock_data';
import { mockCiYml, mockLintResponse } from '../../mock_data';
describe('Pipeline editor header', () => {
let wrapper;
......@@ -19,8 +19,9 @@ describe('Pipeline editor header', () => {
...mockProvide,
...provide,
},
props: {
propsData: {
ciConfigData: mockLintResponse,
ciFileContent: mockCiYml,
isCiConfigDataLoading: false,
},
});
......
......@@ -7,9 +7,9 @@ import ValidationSegment, {
i18n,
} from '~/pipeline_editor/components/header/validation_segment.vue';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import { mockYmlHelpPagePath, mergeUnwrappedCiConfig } from '../../mock_data';
import { mockYmlHelpPagePath, mergeUnwrappedCiConfig, mockCiYml } from '../../mock_data';
describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
describe('Validation segment component', () => {
let wrapper;
const createComponent = (props = {}) => {
......@@ -20,6 +20,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
},
propsData: {
ciConfig: mergeUnwrappedCiConfig(),
ciFileContent: mockCiYml,
loading: false,
...props,
},
......@@ -42,6 +43,20 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
expect(wrapper.text()).toBe(i18n.loading);
});
describe('when config is empty', () => {
beforeEach(() => {
createComponent({ ciFileContent: '' });
});
it('has check icon', () => {
expect(findIcon().props('name')).toBe('check');
});
it('shows a message for empty state', () => {
expect(findValidationMsg().text()).toBe(i18n.empty);
});
});
describe('when config is valid', () => {
beforeEach(() => {
createComponent({});
......@@ -61,7 +76,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
});
});
describe('when config is not valid', () => {
describe('when config is invalid', () => {
beforeEach(() => {
createComponent({
ciConfig: mergeUnwrappedCiConfig({
......
import { GlSprintf } from '@gitlab/ui';
import { GlButton, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
describe('Pipeline editor empty state', () => {
let wrapper;
const defaultProvide = {
glFeatures: {
pipelineEditorEmptyStateAction: false,
},
emptyStateIllustrationPath: 'my/svg/path',
};
const createComponent = () => {
const createComponent = ({ provide } = {}) => {
wrapper = shallowMount(PipelineEditorEmptyState, {
provide: defaultProvide,
provide: { ...defaultProvide, ...provide },
});
};
const findSvgImage = () => wrapper.find('img');
const findTitle = () => wrapper.find('h1');
const findConfirmButton = () => wrapper.findComponent(GlButton);
const findDescription = () => wrapper.findComponent(GlSprintf);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders an svg image', () => {
expect(findSvgImage().exists()).toBe(true);
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders a title', () => {
expect(findTitle().exists()).toBe(true);
expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title);
it('renders an svg image', () => {
expect(findSvgImage().exists()).toBe(true);
});
it('renders a title', () => {
expect(findTitle().exists()).toBe(true);
expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title);
});
it('renders a description', () => {
expect(findDescription().exists()).toBe(true);
expect(findDescription().html()).toContain(wrapper.vm.$options.i18n.body);
});
describe('with feature flag off', () => {
it('does not renders a CTA button', () => {
expect(findConfirmButton().exists()).toBe(false);
});
});
});
it('renders a description', () => {
expect(findDescription().exists()).toBe(true);
expect(findDescription().html()).toContain(wrapper.vm.$options.i18n.body);
describe('with feature flag on', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: {
pipelineEditorEmptyStateAction: true,
},
},
});
});
it('renders a CTA button', () => {
expect(findConfirmButton().exists()).toBe(true);
expect(findConfirmButton().text()).toBe(wrapper.vm.$options.i18n.btnText);
});
it('emits an event when clicking on the CTA', async () => {
const expectedEvent = 'createEmptyConfigFile';
expect(wrapper.emitted(expectedEvent)).toBeUndefined();
await findConfirmButton().vm.$emit('click');
expect(wrapper.emitted(expectedEvent)).toHaveLength(1);
});
});
});
......@@ -7,6 +7,7 @@ import httpStatusCodes from '~/lib/utils/http_status';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants';
import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql';
......@@ -30,6 +31,9 @@ const MockEditorLite = {
const mockProvide = {
ciConfigPath: mockCiConfigPath,
defaultBranch: mockDefaultBranch,
glFeatures: {
pipelineEditorEmptyStateAction: false,
},
projectFullPath: mockProjectFullPath,
};
......@@ -40,14 +44,17 @@ describe('Pipeline editor app component', () => {
let mockBlobContentData;
let mockCiConfigData;
const createComponent = ({ blobLoading = false, options = {} } = {}) => {
const createComponent = ({ blobLoading = false, options = {}, provide = {} } = {}) => {
wrapper = shallowMount(PipelineEditorApp, {
provide: mockProvide,
provide: { ...mockProvide, ...provide },
stubs: {
GlTabs,
GlButton,
CommitForm,
PipelineEditorHome,
PipelineEditorTabs,
EditorLite: MockEditorLite,
PipelineEditorEmptyState,
},
mocks: {
$apollo: {
......@@ -65,7 +72,7 @@ describe('Pipeline editor app component', () => {
});
};
const createComponentWithApollo = ({ props = {} } = {}) => {
const createComponentWithApollo = ({ props = {}, provide = {} } = {}) => {
const handlers = [[getCiConfigData, mockCiConfigData]];
const resolvers = {
Query: {
......@@ -86,7 +93,7 @@ describe('Pipeline editor app component', () => {
apolloProvider: mockApollo,
};
createComponent({ props, options });
createComponent({ props, provide, options });
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
......@@ -94,6 +101,8 @@ describe('Pipeline editor app component', () => {
const findEditorHome = () => wrapper.findComponent(PipelineEditorHome);
const findTextEditor = () => wrapper.findComponent(TextEditor);
const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState);
const findEmptyStateButton = () =>
wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton);
beforeEach(() => {
mockBlobContentData = jest.fn();
......@@ -105,7 +114,6 @@ describe('Pipeline editor app component', () => {
mockCiConfigData.mockReset();
wrapper.destroy();
wrapper = null;
});
it('displays a loading icon if the blob query is loading', () => {
......@@ -196,6 +204,34 @@ describe('Pipeline editor app component', () => {
});
});
describe('when landing on the empty state with feature flag on', () => {
it('user can click on CTA button and see an empty editor', async () => {
mockBlobContentData.mockRejectedValueOnce({
response: {
status: httpStatusCodes.NOT_FOUND,
},
});
createComponentWithApollo({
provide: {
glFeatures: {
pipelineEditorEmptyStateAction: true,
},
},
});
await waitForPromises();
expect(findEmptyState().exists()).toBe(true);
expect(findTextEditor().exists()).toBe(false);
await findEmptyStateButton().vm.$emit('click');
expect(findEmptyState().exists()).toBe(false);
expect(findTextEditor().exists()).toBe(true);
});
});
describe('when the user commits', () => {
const updateFailureMessage = 'The GitLab CI configuration could not be updated.';
......
......@@ -6,7 +6,8 @@ require 'email_spec'
RSpec.describe Emails::MergeRequests do
include EmailSpec::Matchers
let_it_be(:recipient) { create(:user) }
include_context 'gitlab email notification'
let_it_be(:current_user) { create(:user) }
let_it_be(:assignee, reload: true) { create(:user, email: 'assignee@example.com', name: 'John Doe') }
let_it_be(:reviewer, reload: true) { create(:user, email: 'reviewer@example.com', name: 'Jane Doe') }
......@@ -20,6 +21,42 @@ RSpec.describe Emails::MergeRequests do
description: 'Awesome description')
end
let(:recipient) { assignee }
describe '#merged_merge_request_email' do
let(:merge_author) { assignee }
subject { Notify.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
it_behaves_like 'appearance header and footer not enabled'
it 'is sent as the merge author' do
sender = subject.header[:from].addrs[0]
expect(sender.display_name).to eq(merge_author.name)
expect(sender.address).to eq(gitlab_sender)
end
it 'has the correct subject and body' do
aggregate_failures do
is_expected.to have_referable_subject(merge_request, reply: true)
is_expected.to have_body_text('merged')
is_expected.to have_body_text(project_merge_request_path(project, merge_request))
is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
expect(subject.text_part).to have_content(assignee.name)
expect(subject.text_part).to have_content(reviewer.name)
end
end
end
describe "#merge_when_pipeline_succeeds_email" do
let(:title) { "Merge request #{merge_request.to_reference} was scheduled to merge after pipeline succeeds by #{current_user.name}" }
......
......@@ -495,37 +495,6 @@ RSpec.describe Notify do
end
end
describe 'that are merged' do
let(:merge_author) { create(:user) }
subject { described_class.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) }
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
end
it_behaves_like 'it should show Gmail Actions View Merge request link'
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
it_behaves_like 'appearance header and footer not enabled'
it 'is sent as the merge author' do
sender = subject.header[:from].addrs[0]
expect(sender.display_name).to eq(merge_author.name)
expect(sender.address).to eq(gitlab_sender)
end
it 'has the correct subject and body' do
aggregate_failures do
is_expected.to have_referable_subject(merge_request, reply: true)
is_expected.to have_body_text('merged')
is_expected.to have_body_text(project_merge_request_path(project, merge_request))
is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
end
end
end
describe 'that are unmergeable' do
let_it_be(:merge_request) do
create(:merge_request, :conflict,
......
......@@ -189,20 +189,4 @@ RSpec.describe BulkImports::Entity, type: :model do
expect(entity.next_page_for(:relation)).to eq('nextPage')
end
end
describe 'caching', :clean_gitlab_redis_cache do
let(:entity) { create(:bulk_import_entity, :started) }
it 'removes entity cache keys' do
cache_key = "bulk_import:#{entity.bulk_import.id}:entity:#{entity.id}:relation:1"
Gitlab::Redis::Cache.with do |redis|
redis.set(cache_key, 1)
expect(redis).to receive(:del).with(cache_key)
end
entity.finish!
end
end
end
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