Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-10-12 09:09:35 +00:00
parent e7ab7aaa8d
commit 91e8c3a6ef
49 changed files with 761 additions and 228 deletions

View File

@ -122,7 +122,7 @@ export default {
fullPath: {
default: '',
},
groupEpicsPath: {
groupPath: {
default: '',
},
hasAnyIssues: {
@ -371,16 +371,18 @@ export default {
});
}
if (this.groupEpicsPath) {
if (this.groupPath) {
tokens.push({
type: TOKEN_TYPE_EPIC,
title: TOKEN_TITLE_EPIC,
icon: 'epic',
token: EpicToken,
unique: true,
symbol: '&',
idProperty: 'id',
useIdValue: true,
fetchEpics: this.fetchEpics,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-epic_id`,
fullPath: this.groupPath,
});
}
@ -450,16 +452,6 @@ export default {
fetchEmojis(search) {
return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
},
async fetchEpics({ search }) {
const epics = await this.fetchWithCache(this.groupEpicsPath, 'epics');
if (!search) {
return epics.slice(0, MAX_LIST_SIZE);
}
const number = Number(search);
return Number.isNaN(number)
? fuzzaldrinPlus.filter(epics, search, { key: 'title' })
: epics.filter((epic) => epic.id === number);
},
fetchLabels(search) {
return this.$apollo
.query({

View File

@ -119,7 +119,7 @@ export function mountIssuesListApp() {
emptyStateSvgPath,
exportCsvPath,
fullPath,
groupEpicsPath,
groupPath,
hasAnyIssues,
hasAnyProjects,
hasBlockedIssuesFeature,
@ -152,7 +152,7 @@ export function mountIssuesListApp() {
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
fullPath,
groupEpicsPath,
groupPath,
hasAnyIssues: parseBoolean(hasAnyIssues),
hasAnyProjects: parseBoolean(hasAnyProjects),
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),

View File

@ -181,13 +181,14 @@ export default {
</template>
<template #right-action>
<gl-dropdown
v-if="!isDeleteDisabled"
:disabled="isDeleteDisabled"
icon="ellipsis_v"
:text="$options.i18n.MORE_ACTIONS_TEXT"
:text-sr-only="true"
category="tertiary"
no-caret
right
:class="{ 'gl-opacity-0 gl-pointer-events-none': isDeleteDisabled }"
data-testid="additional-actions"
data-qa-selector="more_actions_menu"
>

View File

@ -0,0 +1,15 @@
fragment EpicNode on Epic {
id
iid
group {
fullPath
}
title
state
reference
referencePath: reference(full: true)
webPath
webUrl
createdAt
closedAt
}

View File

@ -0,0 +1,16 @@
#import "./epic.fragment.graphql"
query searchEpics($fullPath: ID!, $search: String, $state: EpicState) {
group(fullPath: $fullPath) {
epics(
search: $search
state: $state
includeAncestorGroups: true
includeDescendantGroups: false
) {
nodes {
...EpicNode
}
}
}
}

View File

@ -67,6 +67,11 @@ export default {
required: false,
default: 'id',
},
searchBy: {
type: String,
required: false,
default: undefined,
},
},
data() {
return {
@ -112,16 +117,18 @@ export default {
);
},
showDefaultSuggestions() {
return this.availableDefaultSuggestions.length;
return this.availableDefaultSuggestions.length > 0;
},
showRecentSuggestions() {
return this.isRecentSuggestionsEnabled && this.recentSuggestions.length && !this.searchKey;
return (
this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey
);
},
showPreloadedSuggestions() {
return this.preloadedSuggestions.length && !this.searchKey;
return this.preloadedSuggestions.length > 0 && !this.searchKey;
},
showAvailableSuggestions() {
return this.availableSuggestions.length;
return this.availableSuggestions.length > 0;
},
showSuggestions() {
// These conditions must match the template under `#suggestions` slot
@ -134,13 +141,19 @@ export default {
this.showAvailableSuggestions
);
},
searchTerm() {
return this.searchBy && this.activeTokenValue
? this.activeTokenValue[this.searchBy]
: undefined;
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.suggestions.length) {
this.$emit('fetch-suggestions', this.value.data);
const search = this.searchTerm ? this.searchTerm : this.value.data;
this.$emit('fetch-suggestions', search);
}
},
},
@ -150,7 +163,8 @@ export default {
this.searchKey = data;
if (!this.suggestionsLoading && !this.activeTokenValue) {
this.$emit('fetch-suggestions', data);
const search = this.searchTerm ? this.searchTerm : data;
this.$emit('fetch-suggestions', search);
}
}, DEBOUNCE_DELAY),
handleTokenValueSelected(activeTokenValue) {

View File

@ -1,22 +1,19 @@
<script>
import {
GlDropdownDivider,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
import searchEpicsQuery from '../queries/search_epics.query.graphql';
import BaseToken from './base_token.vue';
export default {
separator: '::&',
prefix: '&',
separator: '::',
components: {
GlDropdownDivider,
GlFilteredSearchToken,
BaseToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
},
props: {
config: {
@ -27,11 +24,15 @@ export default {
type: Object,
required: true,
},
active: {
type: Boolean,
required: true,
},
},
data() {
return {
epics: this.config.initialEpics || [],
loading: true,
loading: false,
};
},
computed: {
@ -56,98 +57,73 @@ export default {
}
return this.defaultEpics;
},
activeEpic() {
if (this.currentValue && this.epics.length) {
// Check if current value is an epic ID.
if (typeof this.currentValue === 'number') {
return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
}
// Current value is a string.
const [groupPath, idProperty] = this.currentValue?.split(this.$options.separator);
return this.epics.find(
(epic) =>
epic.group_full_path === groupPath &&
epic[this.idProperty] === parseInt(idProperty, 10),
);
}
return null;
},
displayText() {
return `${this.activeEpic?.title}${this.$options.separator}${this.activeEpic?.iid}`;
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.epics.length) {
this.searchEpics({ data: this.currentValue });
}
},
},
},
methods: {
fetchEpicsBySearchTerm({ epicPath = '', search = '' }) {
fetchEpics(search = '') {
return this.$apollo
.query({
query: searchEpicsQuery,
variables: { fullPath: this.config.fullPath, search },
})
.then(({ data }) => data.group?.epics.nodes);
},
fetchEpicsBySearchTerm(search) {
this.loading = true;
this.config
.fetchEpics({ epicPath, search })
this.fetchEpics(search)
.then((response) => {
this.epics = Array.isArray(response) ? response : response.data;
this.epics = Array.isArray(response) ? response : response?.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => {
this.loading = false;
});
},
searchEpics: debounce(function debouncedSearch({ data }) {
let epicPath = this.activeEpic?.web_url;
// When user visits the page with token value already included in filters
// We don't have any information about selected token except for its
// group path and iid joined by separator, so we need to manually
// compose epic path from it.
if (data.includes?.(this.$options.separator)) {
const [groupPath, epicIid] = data.split(this.$options.separator);
epicPath = `/groups/${groupPath}/-/epics/${epicIid}`;
getActiveEpic(epics, data) {
if (data && epics.length) {
return epics.find((epic) => this.getValue(epic) === data);
}
this.fetchEpicsBySearchTerm({ epicPath, search: data });
}, DEBOUNCE_DELAY),
return undefined;
},
getValue(epic) {
return this.config.useIdValue
? String(epic[this.idProperty])
: `${epic.group_full_path}${this.$options.separator}${epic[this.idProperty]}`;
return this.getEpicIdProperty(epic).toString();
},
displayValue(epic) {
return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${
epic?.title
}`;
},
getEpicIdProperty(epic) {
return getIdFromGraphQLId(epic[this.idProperty]);
},
},
};
</script>
<template>
<gl-filtered-search-token
<base-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
:value="value"
:active="active"
:suggestions-loading="loading"
:suggestions="epics"
:get-active-token-value="getActiveEpic"
:default-suggestions="availableDefaultEpics"
:recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
search-by="title"
@fetch-suggestions="fetchEpicsBySearchTerm"
v-on="$listeners"
@input="searchEpics"
>
<template #view="{ inputValue }">
{{ activeEpic ? displayText : inputValue }}
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
{{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }}
</template>
<template #suggestions>
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
v-for="epic in availableDefaultEpics"
:key="epic.value"
:value="epic.value"
v-for="epic in suggestions"
:key="epic.id"
:value="getValue(epic)"
>
{{ epic.text }}
{{ epic.title }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="availableDefaultEpics.length" />
<gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion v-for="epic in epics" :key="epic.id" :value="getValue(epic)">
{{ epic.title }}
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</base-token>
</template>

View File

@ -28,7 +28,7 @@ module Mutations
def authenticate_delete_runner!(runner)
return if current_user.can_admin_all_resources?
"Runner #{runner.to_global_id} associated with more than one project" if runner.projects.count > 1
"Runner #{runner.to_global_id} associated with more than one project" if runner.runner_projects.count > 1
end
def find_object(id)

View File

@ -40,7 +40,7 @@ module Mutations
argument :description, GraphQL::Types::String,
required: false,
description: 'Description or notes for the contact.'
description: 'Description of or notes for the contact.'
authorize :admin_contact

View File

@ -0,0 +1,61 @@
# frozen_string_literal: true
module Mutations
module CustomerRelations
module Contacts
class Update < Mutations::BaseMutation
include ResolvesIds
graphql_name 'CustomerRelationsContactUpdate'
authorize :admin_contact
field :contact,
Types::CustomerRelations::ContactType,
null: true,
description: 'Contact after the mutation.'
argument :id, ::Types::GlobalIDType[::CustomerRelations::Contact],
required: true,
description: 'Global ID of the contact.'
argument :organization_id, ::Types::GlobalIDType[::CustomerRelations::Organization],
required: false,
description: 'Organization of the contact.'
argument :first_name, GraphQL::Types::String,
required: false,
description: 'First name of the contact.'
argument :last_name, GraphQL::Types::String,
required: false,
description: 'Last name of the contact.'
argument :phone, GraphQL::Types::String,
required: false,
description: 'Phone number of the contact.'
argument :email, GraphQL::Types::String,
required: false,
description: 'Email address of the contact.'
argument :description, GraphQL::Types::String,
required: false,
description: 'Description of or notes for the contact.'
def resolve(args)
contact = ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(args.delete(:id), expected_type: ::CustomerRelations::Contact))
raise_resource_not_available_error! unless contact
group = contact.group
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml)
authorize!(group)
result = ::CustomerRelations::Contacts::UpdateService.new(group: group, current_user: current_user, params: args).execute(contact)
{ contact: result.payload, errors: result.errors }
end
end
end
end
end

View File

@ -31,7 +31,7 @@ module Mutations
argument :description,
GraphQL::Types::String,
required: false,
description: 'Description or notes for the organization.'
description: 'Description of or notes for the organization.'
authorize :admin_organization

View File

@ -32,7 +32,7 @@ module Mutations
argument :description,
GraphQL::Types::String,
required: false,
description: 'Description or notes for the organization.'
description: 'Description of or notes for the organization.'
def resolve(args)
organization = ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(args.delete(:id), expected_type: ::CustomerRelations::Organization))

View File

@ -39,7 +39,7 @@ module Types
field :description,
GraphQL::Types::String,
null: true,
description: 'Description or notes for the contact.'
description: 'Description of or notes for the contact.'
field :created_at,
Types::TimeType,

View File

@ -25,7 +25,7 @@ module Types
field :description,
GraphQL::Types::String,
null: true,
description: 'Description or notes for the organization.'
description: 'Description of or notes for the organization.'
field :created_at,
Types::TimeType,

View File

@ -39,6 +39,7 @@ module Types
mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji
mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji
mount_mutation Mutations::CustomerRelations::Contacts::Create
mount_mutation Mutations::CustomerRelations::Contacts::Update
mount_mutation Mutations::CustomerRelations::Organizations::Create
mount_mutation Mutations::CustomerRelations::Organizations::Update
mount_mutation Mutations::Discussions::ToggleResolve

View File

@ -246,7 +246,7 @@ module Ci
begin
transaction do
self.projects << project
self.runner_projects << ::Ci::RunnerProject.new(project: project, runner: self)
self.save!
end
rescue ActiveRecord::RecordInvalid => e
@ -280,9 +280,7 @@ module Ci
end
def belongs_to_more_than_one_project?
::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
self.projects.limit(2).count(:all) > 1
end
runner_projects.limit(2).count(:all) > 1
end
def assigned_to_group?
@ -432,10 +430,8 @@ module Ci
end
def no_projects
::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
if projects.any?
errors.add(:runner, 'cannot have projects assigned')
end
if runner_projects.any?
errors.add(:runner, 'cannot have projects assigned')
end
end
@ -448,10 +444,8 @@ module Ci
end
def any_project
::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
unless projects.any?
errors.add(:runner, 'needs to be assigned to at least one project')
end
unless runner_projects.any?
errors.add(:runner, 'needs to be assigned to at least one project')
end
end

View File

@ -72,7 +72,7 @@ module Clusters
if cluster.group_type?
attributes[:groups] = [group]
elsif cluster.project_type?
attributes[:projects] = [project]
attributes[:runner_projects] = [::Ci::RunnerProject.new(project: project)]
end
attributes

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module CustomerRelations
module Contacts
class UpdateService < BaseService
def execute(contact)
return error_no_permissions unless allowed?
return error_updating(contact) unless contact.update(params)
ServiceResponse.success(payload: contact)
end
private
def error_no_permissions
error('You have insufficient permissions to update a contact for this group')
end
def error_updating(contact)
error(contact&.errors&.full_messages || 'Failed to update contact')
end
end
end
end

View File

@ -9,7 +9,7 @@
.row
.col-md-6
%h4= _('Restrict projects for this runner')
- if @runner.projects.any?
- if @runner.runner_projects.any?
%table.table{ data: { testid: 'assigned-projects' } }
%thead
%tr

View File

@ -39,7 +39,7 @@
- if runner.group_type?
= _('n/a')
- else
= runner.projects.count(:all)
= runner.runner_projects.count(:all)
.table-section.section-5
.table-mobile-header{ role: 'rowheader' }= _('Jobs')

View File

@ -0,0 +1,8 @@
---
name: gitaly_user_merge_branch_access_error
introduced_by_url: https://gitlab.com/gitlab-org/gitaly/-/merge_requests/3705
rollout_issue_url: https://gitlab.com/gitlab-org/gitaly/-/issues/3757
milestone: '14.3'
type: development
group: group::gitaly
default_enabled: false

View File

@ -1425,7 +1425,7 @@ Input type: `CustomerRelationsContactCreateInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationcustomerrelationscontactcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationcustomerrelationscontactcreatedescription"></a>`description` | [`String`](#string) | Description or notes for the contact. |
| <a id="mutationcustomerrelationscontactcreatedescription"></a>`description` | [`String`](#string) | Description of or notes for the contact. |
| <a id="mutationcustomerrelationscontactcreateemail"></a>`email` | [`String`](#string) | Email address of the contact. |
| <a id="mutationcustomerrelationscontactcreatefirstname"></a>`firstName` | [`String!`](#string) | First name of the contact. |
| <a id="mutationcustomerrelationscontactcreategroupid"></a>`groupId` | [`GroupID!`](#groupid) | Group for the contact. |
@ -1441,6 +1441,31 @@ Input type: `CustomerRelationsContactCreateInput`
| <a id="mutationcustomerrelationscontactcreatecontact"></a>`contact` | [`CustomerRelationsContact`](#customerrelationscontact) | Contact after the mutation. |
| <a id="mutationcustomerrelationscontactcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
### `Mutation.customerRelationsContactUpdate`
Input type: `CustomerRelationsContactUpdateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationcustomerrelationscontactupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationcustomerrelationscontactupdatedescription"></a>`description` | [`String`](#string) | Description of or notes for the contact. |
| <a id="mutationcustomerrelationscontactupdateemail"></a>`email` | [`String`](#string) | Email address of the contact. |
| <a id="mutationcustomerrelationscontactupdatefirstname"></a>`firstName` | [`String`](#string) | First name of the contact. |
| <a id="mutationcustomerrelationscontactupdateid"></a>`id` | [`CustomerRelationsContactID!`](#customerrelationscontactid) | Global ID of the contact. |
| <a id="mutationcustomerrelationscontactupdatelastname"></a>`lastName` | [`String`](#string) | Last name of the contact. |
| <a id="mutationcustomerrelationscontactupdateorganizationid"></a>`organizationId` | [`CustomerRelationsOrganizationID`](#customerrelationsorganizationid) | Organization of the contact. |
| <a id="mutationcustomerrelationscontactupdatephone"></a>`phone` | [`String`](#string) | Phone number of the contact. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationcustomerrelationscontactupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationcustomerrelationscontactupdatecontact"></a>`contact` | [`CustomerRelationsContact`](#customerrelationscontact) | Contact after the mutation. |
| <a id="mutationcustomerrelationscontactupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
### `Mutation.customerRelationsOrganizationCreate`
Input type: `CustomerRelationsOrganizationCreateInput`
@ -1451,7 +1476,7 @@ Input type: `CustomerRelationsOrganizationCreateInput`
| ---- | ---- | ----------- |
| <a id="mutationcustomerrelationsorganizationcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationcustomerrelationsorganizationcreatedefaultrate"></a>`defaultRate` | [`Float`](#float) | Standard billing rate for the organization. |
| <a id="mutationcustomerrelationsorganizationcreatedescription"></a>`description` | [`String`](#string) | Description or notes for the organization. |
| <a id="mutationcustomerrelationsorganizationcreatedescription"></a>`description` | [`String`](#string) | Description of or notes for the organization. |
| <a id="mutationcustomerrelationsorganizationcreategroupid"></a>`groupId` | [`GroupID!`](#groupid) | Group for the organization. |
| <a id="mutationcustomerrelationsorganizationcreatename"></a>`name` | [`String!`](#string) | Name of the organization. |
@ -1473,7 +1498,7 @@ Input type: `CustomerRelationsOrganizationUpdateInput`
| ---- | ---- | ----------- |
| <a id="mutationcustomerrelationsorganizationupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationcustomerrelationsorganizationupdatedefaultrate"></a>`defaultRate` | [`Float`](#float) | Standard billing rate for the organization. |
| <a id="mutationcustomerrelationsorganizationupdatedescription"></a>`description` | [`String`](#string) | Description or notes for the organization. |
| <a id="mutationcustomerrelationsorganizationupdatedescription"></a>`description` | [`String`](#string) | Description of or notes for the organization. |
| <a id="mutationcustomerrelationsorganizationupdateid"></a>`id` | [`CustomerRelationsOrganizationID!`](#customerrelationsorganizationid) | Global ID of the organization. |
| <a id="mutationcustomerrelationsorganizationupdatename"></a>`name` | [`String`](#string) | Name of the organization. |
@ -8881,7 +8906,7 @@ A custom emoji uploaded by user.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="customerrelationscontactcreatedat"></a>`createdAt` | [`Time!`](#time) | Timestamp the contact was created. |
| <a id="customerrelationscontactdescription"></a>`description` | [`String`](#string) | Description or notes for the contact. |
| <a id="customerrelationscontactdescription"></a>`description` | [`String`](#string) | Description of or notes for the contact. |
| <a id="customerrelationscontactemail"></a>`email` | [`String`](#string) | Email address of the contact. |
| <a id="customerrelationscontactfirstname"></a>`firstName` | [`String!`](#string) | First name of the contact. |
| <a id="customerrelationscontactid"></a>`id` | [`ID!`](#id) | Internal ID of the contact. |
@ -8898,7 +8923,7 @@ A custom emoji uploaded by user.
| ---- | ---- | ----------- |
| <a id="customerrelationsorganizationcreatedat"></a>`createdAt` | [`Time!`](#time) | Timestamp the organization was created. |
| <a id="customerrelationsorganizationdefaultrate"></a>`defaultRate` | [`Float`](#float) | Standard billing rate for the organization. |
| <a id="customerrelationsorganizationdescription"></a>`description` | [`String`](#string) | Description or notes for the organization. |
| <a id="customerrelationsorganizationdescription"></a>`description` | [`String`](#string) | Description of or notes for the organization. |
| <a id="customerrelationsorganizationid"></a>`id` | [`ID!`](#id) | Internal ID of the organization. |
| <a id="customerrelationsorganizationname"></a>`name` | [`String!`](#string) | Name of the organization. |
| <a id="customerrelationsorganizationupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp the organization was last updated. |
@ -16941,6 +16966,12 @@ A `CustomEmojiID` is a global ID. It is encoded as a string.
An example `CustomEmojiID` is: `"gid://gitlab/CustomEmoji/1"`.
### `CustomerRelationsContactID`
A `CustomerRelationsContactID` is a global ID. It is encoded as a string.
An example `CustomerRelationsContactID` is: `"gid://gitlab/CustomerRelations::Contact/1"`.
### `CustomerRelationsOrganizationID`
A `CustomerRelationsOrganizationID` is a global ID. It is encoded as a string.

View File

@ -467,6 +467,40 @@ Get Custom Issue Tracker integration settings for a project.
GET /projects/:id/integrations/custom-issue-tracker
```
## Discord
Send notifications about project events to a Discord channel.
### Create/Edit Discord integration
Set Discord integration for a project.
```plaintext
PUT /projects/:id/integrations/discord
```
Parameters:
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `webhook` | string | true | Discord webhook. For example, `https://discord.com/api/webhooks/…` |
### Delete Discord integration
Delete Discord integration for a project.
```plaintext
DELETE /projects/:id/integrations/discord
```
### Get Discord integration settings
Get Discord integration settings for a project.
```plaintext
GET /projects/:id/integrations/discord
```
## Drone CI
Drone is a Continuous Integration platform built on Docker, written in Go

View File

@ -1014,12 +1014,15 @@ The on-demand DAST scan runs, and the project's dashboard shows the results.
#### Schedule an on-demand scan
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/328749) in GitLab 14.3. [Deployed behind the `dast_on_demand_scans_scheduler` flag](../../../administration/feature_flags.md), disabled by default.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/328749) in GitLab 14.3. [Deployed behind the `dast_on_demand_scans_scheduler` flag](../../../administration/feature_flags.md), disabled by default.
> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/328749) in GitLab 14.4.
> - [Enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/328749) in GitLab 14.4.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available per user,
ask an administrator to [disable the `dast_on_demand_scans_scheduler` flag](../../../administration/feature_flags.md).
The feature is not ready for production use.
On self-managed GitLab, by default this feature is available. To hide the feature, ask an
administrator to [disable the feature flag](../../../administration/feature_flags.md) named
`dast_on_demand_scans_scheduler`.
On GitLab.com, this feature is available.
To schedule a scan:

View File

@ -204,7 +204,7 @@ module API
not_found!('Runner') unless runner_project
runner = runner_project.runner
forbidden!("Only one project associated with the runner. Please remove the runner instead") if runner.projects.count == 1
forbidden!("Only one project associated with the runner. Please remove the runner instead") if runner.runner_projects.count == 1
destroy_conditionally!(runner_project)
end
@ -331,7 +331,7 @@ module API
def authenticate_delete_runner!(runner)
return if current_user.admin?
forbidden!("Runner associated with more than one project") if runner.projects.count > 1
forbidden!("Runner associated with more than one project") if runner.runner_projects.count > 1
forbidden!("No access granted") unless can?(current_user, :delete_runner, runner)
end

View File

@ -340,7 +340,7 @@ module API
required: true,
name: :webhook,
type: String,
desc: 'Discord webhook. e.g. https://discordapp.com/api/webhooks/…'
desc: 'Discord webhook. For example, https://discord.com/api/webhooks/…'
}
],
'drone-ci' => [

View File

@ -45,10 +45,19 @@ module Gitlab
cte.issue_id = issue_metrics.issue_id
UPDATE_METRICS
end
mark_job_as_succeeded(start_id, end_id)
end
private
def mark_job_as_succeeded(*arguments)
Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded(
'FixFirstMentionedInCommitAt',
arguments
)
end
def scope(start_id, end_id)
TmpIssueMetrics.from_2020.where(issue_id: start_id..end_id)
end

View File

@ -162,6 +162,14 @@ module Gitlab
raise Gitlab::Git::CommitError, 'failed to apply merge to branch' unless branch_update.commit_id.present?
Gitlab::Git::OperationService::BranchUpdate.from_gitaly(branch_update)
rescue GRPC::BadStatus => e
decoded_error = decode_detailed_error(e)
raise unless decoded_error.present?
raise decoded_error
ensure
request_enum.close
end
@ -470,6 +478,31 @@ module Gitlab
rescue RangeError
raise ArgumentError, "Unknown action '#{action[:action]}'"
end
def decode_detailed_error(err)
# details could have more than one in theory, but we only have one to worry about for now.
detailed_error = err.to_rpc_status&.details&.first
return unless detailed_error.present?
prefix = %r{type\.googleapis\.com\/gitaly\.(?<error_type>.+)}
error_type = prefix.match(detailed_error.type_url)[:error_type]
detailed_error = Gitaly.const_get(error_type, false).decode(detailed_error.value)
case detailed_error.error
when :access_check
access_check_error = detailed_error.access_check
# These messages were returned from internal/allowed API calls
Gitlab::Git::PreReceiveError.new(fallback_message: access_check_error.error_message)
else
# We're handling access_check only for now, but we'll add more detailed error types
nil
end
rescue NameError, NoMethodError
# Error Class might not be known to ruby yet
nil
end
end
end
end

View File

@ -78,6 +78,22 @@ module QA
end
end
# Get group members
#
# @return [Array<QA::Resource::User>]
def members
parse_body(api_get_from("#{api_get_path}/members")).map do |member|
User.init do |resource|
resource.api_client = api_client
resource.id = member[:id]
resource.name = member[:name]
resource.username = member[:username]
resource.email = member[:email]
resource.access_level = member[:access_level]
end
end
end
# API get path
#
# @return [String]

View File

@ -7,13 +7,18 @@ module QA
attr_reader :unique_id
attr_writer :username, :password
attr_accessor :admin, :provider, :extern_uid, :expect_fabrication_success, :hard_delete_on_api_removal
attr_accessor :admin,
:provider,
:extern_uid,
:expect_fabrication_success,
:hard_delete_on_api_removal,
:access_level
attribute :id
attribute :name
attribute :first_name
attribute :last_name
attribute :email
attributes :id,
:name,
:first_name,
:last_name,
:email
def initialize
@admin = false

View File

@ -126,6 +126,33 @@ module QA
end
end
context 'with group members' do
let(:member) do
Resource::User.fabricate_via_api! do |usr|
usr.api_client = admin_api_client
usr.hard_delete_on_api_removal = true
end
end
before do
member.set_public_email
source_group.add_member(member, Resource::Members::AccessLevel::DEVELOPER)
end
after do
member.remove_via_api!
end
it 'adds members for imported group' do
expect { imported_group.import_status }.to eventually_eq('finished').within(import_wait_duration)
imported_member = imported_group.reload!.members.find { |usr| usr.username == member.username }
expect(imported_member).not_to be_nil
expect(imported_member.access_level).to eq(Resource::Members::AccessLevel::DEVELOPER)
end
end
after do
user.remove_via_api!
ensure

View File

@ -2,7 +2,14 @@
FactoryBot.define do
factory :ci_runner_project, class: 'Ci::RunnerProject' do
runner factory: [:ci_runner, :project]
project
after(:build) do |runner_project, evaluator|
unless runner_project.runner.present?
runner_project.runner = build(
:ci_runner, :project, runner_projects: [runner_project]
)
end
end
end
end

View File

@ -10,6 +10,16 @@ FactoryBot.define do
runner_type { :instance_type }
transient do
projects { [] }
end
after(:build) do |runner, evaluator|
evaluator.projects.each do |proj|
runner.runner_projects << build(:ci_runner_project, project: proj)
end
end
trait :online do
contacted_at { Time.now }
end
@ -30,7 +40,9 @@ FactoryBot.define do
runner_type { :project_type }
after(:build) do |runner, evaluator|
runner.projects << build(:project) if runner.projects.empty?
if runner.runner_projects.empty?
runner.runner_projects << build(:ci_runner_project)
end
end
end

View File

@ -520,7 +520,7 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
groupEpicsPath: '',
groupPath: '',
},
});
});
@ -536,7 +536,7 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
groupEpicsPath: '',
groupPath: '',
},
});
});
@ -564,7 +564,7 @@ describe('IssuesListApp component', () => {
provide: {
isSignedIn: true,
projectIterationsPath: 'project/iterations/path',
groupEpicsPath: 'group/epics/path',
groupPath: 'group/path',
hasIssueWeightsFeature: true,
},
});

View File

@ -283,18 +283,20 @@ describe('tags list row', () => {
});
it.each`
canDelete | digest | disabled | visible
${true} | ${null} | ${true} | ${false}
${false} | ${'foo'} | ${true} | ${false}
${false} | ${null} | ${true} | ${false}
${true} | ${'foo'} | ${true} | ${false}
${true} | ${'foo'} | ${false} | ${true}
canDelete | digest | disabled | buttonDisabled
${true} | ${null} | ${true} | ${true}
${false} | ${'foo'} | ${true} | ${true}
${false} | ${null} | ${true} | ${true}
${true} | ${'foo'} | ${true} | ${true}
${true} | ${'foo'} | ${false} | ${false}
`(
'is $visible that is visible when canDelete is $canDelete and digest is $digest and disabled is $disabled',
({ canDelete, digest, disabled, visible }) => {
({ canDelete, digest, disabled, buttonDisabled }) => {
mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled });
expect(findAdditionalActionsMenu().exists()).toBe(visible);
expect(findAdditionalActionsMenu().props('disabled')).toBe(buttonDisabled);
expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(buttonDisabled);
expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(buttonDisabled);
},
);

View File

@ -141,7 +141,62 @@ export const mockEpicToken = {
token: EpicToken,
operators: OPERATOR_IS_ONLY,
idProperty: 'iid',
fetchEpics: () => Promise.resolve({ data: mockEpics }),
fullPath: 'gitlab-org',
};
export const mockEpicNode1 = {
__typename: 'Epic',
parent: null,
id: 'gid://gitlab/Epic/40',
iid: '2',
title: 'Marketing epic',
description: 'Mock epic description',
state: 'opened',
startDate: '2017-12-25',
dueDate: '2018-02-15',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/1',
hasChildren: false,
hasParent: false,
confidential: false,
};
export const mockEpicNode2 = {
__typename: 'Epic',
parent: null,
id: 'gid://gitlab/Epic/41',
iid: '3',
title: 'Another marketing',
startDate: '2017-12-26',
dueDate: '2018-03-10',
state: 'opened',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/2',
};
export const mockGroupEpicsQueryResponse = {
data: {
group: {
id: 'gid://gitlab/Group/1',
name: 'Gitlab Org',
epics: {
edges: [
{
node: {
...mockEpicNode1,
},
__typename: 'EpicEdge',
},
{
node: {
...mockEpicNode2,
},
__typename: 'EpicEdge',
},
],
__typename: 'EpicConnection',
},
__typename: 'Group',
},
},
};
export const mockReactionEmojiToken = {

View File

@ -1,15 +1,21 @@
import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
import { GlFilteredSearchTokenSegment } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import searchEpicsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql';
import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { mockEpicToken, mockEpics } from '../mock_data';
import { mockEpicToken, mockEpics, mockGroupEpicsQueryResponse } from '../mock_data';
jest.mock('~/flash');
Vue.use(VueApollo);
const defaultStubs = {
Portal: true,
@ -21,31 +27,39 @@ const defaultStubs = {
},
};
function createComponent(options = {}) {
const {
config = mockEpicToken,
value = { data: '' },
active = false,
stubs = defaultStubs,
} = options;
return mount(EpicToken, {
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
},
stubs,
});
}
describe('EpicToken', () => {
let mock;
let wrapper;
let fakeApollo;
const findBaseToken = () => wrapper.findComponent(BaseToken);
function createComponent(
options = {},
epicsQueryHandler = jest.fn().mockResolvedValue(mockGroupEpicsQueryResponse),
) {
fakeApollo = createMockApollo([[searchEpicsQuery, epicsQueryHandler]]);
const {
config = mockEpicToken,
value = { data: '' },
active = false,
stubs = defaultStubs,
} = options;
return mount(EpicToken, {
apolloProvider: fakeApollo,
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: 'custom-class',
},
stubs,
});
}
beforeEach(() => {
mock = new MockAdapter(axios);
@ -71,23 +85,20 @@ describe('EpicToken', () => {
describe('methods', () => {
describe('fetchEpicsBySearchTerm', () => {
it('calls `config.fetchEpics` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics');
it('calls fetchEpics with provided searchTerm param', () => {
jest.spyOn(wrapper.vm, 'fetchEpics');
wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' });
findBaseToken().vm.$emit('fetch-suggestions', 'foo');
expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith({
epicPath: '',
search: 'foo',
});
expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith('foo');
});
it('sets response to `epics` when request is successful', async () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics').mockResolvedValue({
jest.spyOn(wrapper.vm, 'fetchEpics').mockResolvedValue({
data: mockEpics,
});
wrapper.vm.fetchEpicsBySearchTerm({});
findBaseToken().vm.$emit('fetch-suggestions');
await waitForPromises();
@ -95,9 +106,9 @@ describe('EpicToken', () => {
});
it('calls `createFlash` with flash error message when request fails', async () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({});
jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({});
wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' });
findBaseToken().vm.$emit('fetch-suggestions', 'foo');
await waitForPromises();
@ -107,9 +118,9 @@ describe('EpicToken', () => {
});
it('sets `loading` to false when request completes', async () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({});
jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({});
wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' });
findBaseToken().vm.$emit('fetch-suggestions', 'foo');
await waitForPromises();
@ -123,15 +134,15 @@ describe('EpicToken', () => {
beforeEach(async () => {
wrapper = createComponent({
value: { data: `${mockEpics[0].group_full_path}::&${mockEpics[0].iid}` },
value: { data: `${mockEpics[0].title}::&${mockEpics[0].iid}` },
data: { epics: mockEpics },
});
await wrapper.vm.$nextTick();
});
it('renders gl-filtered-search-token component', () => {
expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
it('renders BaseToken component', () => {
expect(findBaseToken().exists()).toBe(true);
});
it('renders token item when value is selected', () => {
@ -142,9 +153,9 @@ describe('EpicToken', () => {
});
it.each`
value | valueType | tokenValueString
${`${mockEpics[0].group_full_path}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`}
${`${mockEpics[1].group_full_path}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`}
value | valueType | tokenValueString
${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`}
${`${mockEpics[1].title}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`}
`('renders token item when selection is a $valueType', async ({ value, tokenValueString }) => {
wrapper.setProps({
value: { data: value },

View File

@ -4,8 +4,9 @@ require 'spec_helper'
RSpec.describe Mutations::CustomerRelations::Contacts::Create do
let_it_be(:user) { create(:user) }
let_it_be(:not_found_or_does_not_belong) { 'The specified organization was not found or does not belong to this group' }
let_it_be(:group) { create(:group) }
let(:not_found_or_does_not_belong) { 'The specified organization was not found or does not belong to this group' }
let(:valid_params) do
attributes_for(:contact,
group: group,
@ -22,8 +23,6 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do
end
context 'when the user does not have permission' do
let_it_be(:group) { create(:group) }
before do
group.add_reporter(user)
end
@ -35,8 +34,6 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do
end
context 'when the user has permission' do
let_it_be(:group) { create(:group) }
before_all do
group.add_developer(user)
end

View File

@ -0,0 +1,75 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::CustomerRelations::Contacts::Update do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let(:first_name) { 'Lionel' }
let(:last_name) { 'Smith' }
let(:email) { 'ls@gitlab.com' }
let(:description) { 'VIP' }
let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" }
let(:contact) { create(:contact, group: group) }
let(:attributes) do
{
id: contact.to_global_id,
first_name: first_name,
last_name: last_name,
email: email,
description: description
}
end
describe '#resolve' do
subject(:resolve_mutation) do
described_class.new(object: nil, context: { current_user: user }, field: nil).resolve(
attributes
)
end
context 'when the user does not have permission to update a contact' do
before do
group.add_reporter(user)
end
it 'raises an error' do
expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
.with_message(does_not_exist_or_no_permission)
end
end
context 'when the contact does not exist' do
it 'raises an error' do
attributes[:id] = "gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"
expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
.with_message(does_not_exist_or_no_permission)
end
end
context 'when the user has permission to update a contact' do
before_all do
group.add_developer(user)
end
it 'updates the organization with correct values' do
expect(resolve_mutation[:contact]).to have_attributes(attributes)
end
context 'when the feature is disabled' do
before do
stub_feature_flags(customer_relations: false)
end
it 'raises an error' do
expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
.with_message('Feature disabled')
end
end
end
end
specify { expect(described_class).to require_graphql_authorizations(:admin_contact) }
end

View File

@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe Mutations::CustomerRelations::Organizations::Create do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let(:valid_params) do
attributes_for(:organization,
@ -23,8 +24,6 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do
end
context 'when the user does not have permission' do
let_it_be(:group) { create(:group) }
before do
group.add_reporter(user)
end
@ -36,8 +35,6 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do
end
context 'when the user has permission' do
let_it_be(:group) { create(:group) }
before_all do
group.add_developer(user)
end

View File

@ -4,11 +4,12 @@ require 'spec_helper'
RSpec.describe Mutations::CustomerRelations::Organizations::Update do
let_it_be(:user) { create(:user) }
let_it_be(:name) { 'GitLab' }
let_it_be(:default_rate) { 1000.to_f }
let_it_be(:description) { 'VIP' }
let_it_be(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" }
let_it_be(:group) { create(:group) }
let(:name) { 'GitLab' }
let(:default_rate) { 1000.to_f }
let(:description) { 'VIP' }
let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" }
let(:organization) { create(:organization, group: group) }
let(:attributes) do
{
@ -27,8 +28,6 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do
end
context 'when the user does not have permission to update an organization' do
let_it_be(:group) { create(:group) }
before do
group.add_reporter(user)
end
@ -40,8 +39,6 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do
end
context 'when the organization does not exist' do
let_it_be(:group) { create(:group) }
it 'raises an error' do
attributes[:id] = "gid://gitlab/CustomerRelations::Organization/#{non_existing_record_id}"
@ -51,8 +48,6 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do
end
context 'when the user has permission to update an organization' do
let_it_be(:group) { create(:group) }
before_all do
group.add_developer(user)
end

View File

@ -99,6 +99,15 @@ RSpec.describe Gitlab::BackgroundMigration::FixFirstMentionedInCommitAt, :migrat
.perform(issue_metrics.minimum(:issue_id), issue_metrics.maximum(:issue_id))
end
it "marks successful slices as completed" do
min_issue_id = issue_metrics.minimum(:issue_id)
max_issue_id = issue_metrics.maximum(:issue_id)
expect(subject).to receive(:mark_job_as_succeeded).with(min_issue_id, max_issue_id)
subject.perform(min_issue_id, max_issue_id)
end
context 'when the persisted first_mentioned_in_commit_at is later than the first commit authored_date' do
it 'updates the issue_metrics record' do
record1 = issue_metrics.create!(issue_id: issue1.id, first_mentioned_in_commit_at: Time.current)

View File

@ -169,6 +169,56 @@ RSpec.describe Gitlab::GitalyClient::OperationService do
end
end
describe '#user_merge_branch' do
let(:target_branch) { 'master' }
let(:source_sha) { '5937ac0a7beb003549fc5fd26fc247adbce4a52e' }
let(:message) { 'Merge a branch' }
subject { client.user_merge_branch(user, source_sha, target_branch, message) {} }
it 'sends a user_merge_branch message' do
expect(subject).to be_a(Gitlab::Git::OperationService::BranchUpdate)
expect(subject.newrev).to be_present
expect(subject.repo_created).to be(false)
expect(subject.branch_created).to be(false)
end
context 'with an exception with the UserMergeBranchError' do
let(:permission_error) do
GRPC::PermissionDenied.new(
"GitLab: You are not allowed to push code to this project.",
{ "grpc-status-details-bin" =>
"\b\a\x129GitLab: You are not allowed to push code to this project.\x1A\xDE\x01\n/type.googleapis.com/gitaly.UserMergeBranchError\x12\xAA\x01\n\xA7\x01\n1You are not allowed to push code to this project.\x12\x03web\x1A\auser-15\"df15b32277d2c55c6c595845a87109b09c913c556 5d6e0f935ad9240655f64e883cd98fad6f9a17ee refs/heads/master\n" }
)
end
it 'raises PreRecieveError with the error message' do
expect_any_instance_of(Gitaly::OperationService::Stub)
.to receive(:user_merge_branch).with(kind_of(Enumerator), kind_of(Hash))
.and_raise(permission_error)
expect { subject }.to raise_error do |error|
expect(error).to be_a(Gitlab::Git::PreReceiveError)
expect(error.message).to eq("You are not allowed to push code to this project.")
end
end
end
context 'with an exception without the detailed error' do
let(:permission_error) do
GRPC::PermissionDenied.new
end
it 'raises PermissionDenied' do
expect_any_instance_of(Gitaly::OperationService::Stub)
.to receive(:user_merge_branch).with(kind_of(Enumerator), kind_of(Hash))
.and_raise(permission_error)
expect { subject }.to raise_error(GRPC::PermissionDenied)
end
end
end
describe '#user_ff_branch' do
let(:target_branch) { 'my-branch' }
let(:source_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' }

View File

@ -271,7 +271,7 @@ RSpec.describe Ci::Runner do
expect(subject).to be_truthy
expect(runner).to be_project_type
expect(runner.projects).to eq([project])
expect(runner.runner_projects.pluck(:project_id)).to match_array([project.id])
expect(runner.only_for?(project)).to be_truthy
end
end
@ -735,7 +735,7 @@ RSpec.describe Ci::Runner do
context 'with invalid runner' do
before do
runner.projects = []
runner.runner_projects.delete_all
end
it 'still updates redis cache and database' do

View File

@ -96,8 +96,9 @@ RSpec.describe Clusters::Applications::Runner do
it 'creates a project runner' do
subject
runner_projects = Project.where(id: runner.runner_projects.pluck(:project_id))
expect(runner).to be_project_type
expect(runner.projects).to eq [project]
expect(runner_projects).to match_array [project]
end
end

View File

@ -250,7 +250,7 @@ RSpec.describe 'Query.runner(id)' do
end
before do
project_runner2.projects.clear
project_runner2.runner_projects.clear
post_graphql(query, current_user: user)
end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe CustomerRelations::Contacts::UpdateService do
let_it_be(:user) { create(:user) }
let(:contact) { create(:contact, first_name: 'Mark', group: group) }
subject(:update) { described_class.new(group: group, current_user: user, params: params).execute(contact) }
describe '#execute' do
context 'when the user has no permission' do
let_it_be(:group) { create(:group) }
let(:params) { { first_name: 'Gary' } }
it 'returns an error' do
response = update
expect(response).to be_error
expect(response.message).to match_array(['You have insufficient permissions to update a contact for this group'])
end
end
context 'when user has permission' do
let_it_be(:group) { create(:group) }
before_all do
group.add_developer(user)
end
context 'when first_name is changed' do
let(:params) { { first_name: 'Gary' } }
it 'updates the contact' do
response = update
expect(response).to be_success
expect(response.payload.first_name).to eq('Gary')
end
end
context 'when the contact is invalid' do
let(:params) { { first_name: nil } }
it 'returns an error' do
response = update
expect(response).to be_error
expect(response.message).to match_array(["First name can't be blank"])
end
end
end
end
end

View File

@ -41,8 +41,8 @@
- "./ee/spec/services/ee/merge_requests/create_pipeline_service_spec.rb"
- "./ee/spec/services/ee/merge_requests/refresh_service_spec.rb"
- "./ee/spec/workers/scan_security_report_secrets_worker_spec.rb"
- "./spec/controllers/admin/runners_controller_spec.rb"
- "./spec/controllers/groups/settings/ci_cd_controller_spec.rb"
- "./spec/controllers/admin/runners_controller_spec.rb"
- "./spec/controllers/projects/merge_requests_controller_spec.rb"
- "./spec/controllers/projects/settings/ci_cd_controller_spec.rb"
- "./spec/features/admin/admin_runners_spec.rb"
@ -56,7 +56,6 @@
- "./spec/finders/ci/pipelines_for_merge_request_finder_spec.rb"
- "./spec/finders/ci/runners_finder_spec.rb"
- "./spec/frontend/fixtures/runner.rb"
- "./spec/graphql/mutations/ci/runner/delete_spec.rb"
- "./spec/graphql/resolvers/ci/group_runners_resolver_spec.rb"
- "./spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb"
- "./spec/lib/api/entities/package_spec.rb"
@ -71,13 +70,11 @@
- "./spec/models/ci/job_artifact_spec.rb"
- "./spec/models/ci/pipeline_spec.rb"
- "./spec/models/ci/runner_spec.rb"
- "./spec/models/clusters/applications/runner_spec.rb"
- "./spec/models/deployment_spec.rb"
- "./spec/models/environment_spec.rb"
- "./spec/models/merge_request_spec.rb"
- "./spec/models/project_spec.rb"
- "./spec/models/user_spec.rb"
- "./spec/presenters/ci/build_runner_presenter_spec.rb"
- "./spec/presenters/ci/pipeline_presenter_spec.rb"
- "./spec/presenters/packages/detail/package_presenter_spec.rb"
- "./spec/requests/api/ci/runner/runners_post_spec.rb"

View File

@ -79,8 +79,17 @@ module Database
return if cross_database_context[:transaction_depth_by_db].values.all?(&:zero?)
parsed_query = PgQuery.parse(sql)
tables = sql.downcase.include?(' for update') ? parsed_query.tables : parsed_query.dml_tables
# PgQuery might fail in some cases due to limited nesting:
# https://github.com/pganalyze/pg_query/issues/209
#
# Also, we disable GC while parsing because of https://github.com/pganalyze/pg_query/issues/226
begin
GC.disable
parsed_query = PgQuery.parse(sql)
tables = sql.downcase.include?(' for update') ? parsed_query.tables : parsed_query.dml_tables
ensure
GC.enable
end
return if tables.empty?