Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-02-10 15:12:42 +00:00
parent 190128fc72
commit 74d9798736
60 changed files with 856 additions and 178 deletions

View file

@ -666,6 +666,7 @@ Gitlab/NamespacedClass:
- 'ee/elastic/**/*.rb'
- 'scripts/**/*'
- 'spec/migrations/**/*.rb'
- 'app/experiments/**/*_experiment.rb'
Lint/HashCompareByIdentity:
Enabled: true

View file

@ -86,6 +86,7 @@ export const defaultAutocompleteConfig = {
labels: true,
snippets: true,
vulnerabilities: true,
contacts: true,
};
class GfmAutoComplete {
@ -127,6 +128,7 @@ class GfmAutoComplete {
if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
if (this.enableMap.labels) this.setupLabels($input);
if (this.enableMap.snippets) this.setupSnippets($input);
if (this.enableMap.contacts) this.setupContacts($input);
$input.filter('[data-supports-quick-actions="true"]').atwho({
at: '/',
@ -174,9 +176,16 @@ class GfmAutoComplete {
let tpl = '/${name} ';
let referencePrefix = null;
if (value.params.length > 0) {
[[referencePrefix]] = value.params;
if (/^[@%~]/.test(referencePrefix)) {
const regexp = /\[[a-z]+:/;
const match = regexp.exec(value.params);
if (match) {
[referencePrefix] = match;
tpl += '<%- referencePrefix %>';
} else {
[[referencePrefix]] = value.params;
if (/^[@%~]/.test(referencePrefix)) {
tpl += '<%- referencePrefix %>';
}
}
}
return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix });
@ -619,6 +628,42 @@ class GfmAutoComplete {
});
}
setupContacts($input) {
$input.atwho({
at: '[contact:',
suffix: ']',
alias: 'contacts',
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.email != null) {
tmpl = GfmAutoComplete.Contacts.templateFunction(value);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${email}',
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(contacts) {
return $.map(contacts, (m) => {
if (m.email == null) {
return m;
}
return {
id: m.id,
email: m.email,
firstName: m.first_name,
lastName: m.last_name,
search: `${m.email}`,
};
});
},
},
});
}
getDefaultCallbacks() {
const self = this;
@ -790,6 +835,7 @@ GfmAutoComplete.atTypeMap = {
'/': 'commands',
'[vulnerability:': 'vulnerabilities',
$: 'snippets',
'[contact:': 'contacts',
};
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
@ -883,6 +929,11 @@ GfmAutoComplete.Milestones = {
return `<li>${escape(title)}</li>`;
},
};
GfmAutoComplete.Contacts = {
templateFunction({ email, firstName, lastName }) {
return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`;
},
};
GfmAutoComplete.Loading = {
template:
'<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>',

View file

@ -0,0 +1,54 @@
<script>
import { GlButton } from '@gitlab/ui';
export default {
name: 'StepNav',
components: {
GlButton,
},
props: {
showBackButton: {
type: Boolean,
required: false,
default: false,
},
showNextButton: {
type: Boolean,
required: false,
default: false,
},
nextButtonEnabled: {
type: Boolean,
required: false,
default: true,
},
},
};
</script>
<template>
<div>
<slot name="before"></slot>
<gl-button
v-if="showBackButton"
category="secondary"
data-testid="back-button"
@click="$emit('back')"
>
{{ __('Back') }}
</gl-button>
<gl-button
v-if="showNextButton"
:disabled="!nextButtonEnabled"
category="primary"
data-testid="next-button"
variant="confirm"
@click="$emit('next')"
>
{{ __('Next') }}
</gl-button>
<slot name="after"></slot>
</div>
</template>
<style scoped></style>

View file

@ -9,6 +9,7 @@ import axios from '~/lib/utils/axios_utils';
import { stripHtml } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.vue';
@ -23,6 +24,7 @@ export default {
GlIcon,
Suggestions,
},
mixins: [glFeatureFlagsMixin()],
props: {
/**
* This prop should be bound to the value of the `<textarea>` element
@ -217,6 +219,7 @@ export default {
labels: this.enableAutocomplete,
snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete,
contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete,
},
true,
);

View file

@ -45,6 +45,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:contacts_autocomplete, project&.group, default_enabled: :yaml)
end
before_action only: :show do

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/NamespacedClass
class ApplicationExperiment < Gitlab::Experiment
def publish(_result = nil)
super

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class CombinedRegistrationExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
class CombinedRegistrationExperiment < ApplicationExperiment
include Rails.application.routes.url_helpers
def key_for(source, _ = nil)

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class EmptyRepoUploadExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
class EmptyRepoUploadExperiment < ApplicationExperiment
include ProjectCommitCount
TRACKING_START_DATE = DateTime.parse('2021/4/20')

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class ForceCompanyTrialExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
class ForceCompanyTrialExperiment < ApplicationExperiment
exclude :setup_for_personal
private

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment
exclude :has_environments?
def control_behavior

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class NewProjectSastEnabledExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
class NewProjectSastEnabledExperiment < ApplicationExperiment
def publish(_result = nil)
super

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment
exclude :existing_user
EXPERIMENT_START_DATE = Date.new(2022, 1, 31)

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment
def publish(_result = nil)
super

View file

@ -396,7 +396,8 @@ module ApplicationHelper
labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
milestones: milestones_project_autocomplete_sources_path(object),
commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
snippets: snippets_project_autocomplete_sources_path(object)
snippets: snippets_project_autocomplete_sources_path(object),
contacts: contacts_project_autocomplete_sources_path(object)
}
end
end

View file

@ -10,6 +10,7 @@ class ContainerRepository < ApplicationRecord
REQUIRING_CLEANUP_STATUSES = %i[cleanup_unscheduled cleanup_scheduled].freeze
IDLE_MIGRATION_STATES = %w[default pre_import_done import_done import_aborted import_skipped].freeze
ACTIVE_MIGRATION_STATES = %w[pre_importing importing].freeze
ABORTABLE_MIGRATION_STATES = (ACTIVE_MIGRATION_STATES + ['pre_import_done']).freeze
MIGRATION_STATES = (IDLE_MIGRATION_STATES + ACTIVE_MIGRATION_STATES).freeze
belongs_to :project
@ -17,7 +18,7 @@ class ContainerRepository < ApplicationRecord
validates :name, length: { minimum: 0, allow_nil: false }
validates :name, uniqueness: { scope: :project_id }
validates :migration_state, presence: true, inclusion: { in: MIGRATION_STATES }
validates :migration_aborted_in_state, inclusion: { in: ACTIVE_MIGRATION_STATES }, allow_nil: true
validates :migration_aborted_in_state, inclusion: { in: ABORTABLE_MIGRATION_STATES }, allow_nil: true
validates :migration_retries_count, presence: true,
numericality: { greater_than_or_equal_to: 0 },
@ -43,6 +44,9 @@ class ContainerRepository < ApplicationRecord
scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
scope :waiting_for_cleanup, -> { where(expiration_policy_cleanup_status: WAITING_CLEANUP_STATUSES) }
scope :expiration_policy_started_at_nil_or_before, ->(timestamp) { where('expiration_policy_started_at < ? OR expiration_policy_started_at IS NULL', timestamp) }
scope :with_migration_import_started_at_nil_or_before, ->(timestamp) { where("COALESCE(migration_import_started_at, '01-01-1970') < ?", timestamp) }
scope :with_migration_pre_import_started_at_nil_or_before, ->(timestamp) { where("COALESCE(migration_pre_import_started_at, '01-01-1970') < ?", timestamp) }
scope :with_migration_pre_import_done_at_nil_or_before, ->(timestamp) { where("COALESCE(migration_pre_import_done_at, '01-01-1970') < ?", timestamp) }
scope :with_stale_ongoing_cleanup, ->(threshold) { cleanup_ongoing.where('expiration_policy_started_at < ?', threshold) }
state_machine :migration_state, initial: :default do
@ -96,7 +100,7 @@ class ContainerRepository < ApplicationRecord
end
event :abort_import do
transition ACTIVE_MIGRATION_STATES.map(&:to_sym) => :import_aborted
transition ABORTABLE_MIGRATION_STATES.map(&:to_sym) => :import_aborted
end
event :skip_import do
@ -181,6 +185,22 @@ class ContainerRepository < ApplicationRecord
with_enabled_policy.cleanup_unfinished
end
def self.with_stale_migration(before_timestamp)
stale_pre_importing = with_migration_states(:pre_importing)
.with_migration_pre_import_started_at_nil_or_before(before_timestamp)
stale_pre_import_done = with_migration_states(:pre_import_done)
.with_migration_pre_import_done_at_nil_or_before(before_timestamp)
stale_importing = with_migration_states(:importing)
.with_migration_import_started_at_nil_or_before(before_timestamp)
union = ::Gitlab::SQL::Union.new([
stale_pre_importing,
stale_pre_import_done,
stale_importing
])
from("(#{union.to_sql}) #{ContainerRepository.table_name}")
end
def skip_import(reason:)
self.migration_skipped_reason = reason

View file

@ -26,6 +26,18 @@ class CustomerRelations::Contact < ApplicationRecord
validate :validate_email_format
validate :unique_email_for_group_hierarchy
def self.reference_prefix
'[contact:'
end
def self.reference_prefix_quoted
'["contact:'
end
def self.reference_postfix
']'
end
def self.find_ids_by_emails(group, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK

View file

@ -149,6 +149,7 @@ class User < ApplicationRecord
has_many :members
has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, class_name: 'GroupMember'
has_many :groups, through: :group_members
has_many :groups_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :group_members, source: :group
has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group
has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group
has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group
@ -170,6 +171,7 @@ class User < ApplicationRecord
has_many :project_members, -> { where(requested_at: nil) }
has_many :projects, through: :project_members
has_many :created_projects, foreign_key: :creator_id, class_name: 'Project'
has_many :projects_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :project_members, source: :project
has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :starred_projects, through: :users_star_projects, source: :project
has_many :project_authorizations, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent

View file

@ -48,10 +48,16 @@ module Issues
end
def add_by_email
contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group, params[:add_emails])
contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group, emails(:add_emails))
add_by_id(contact_ids)
end
def emails(key)
params[key].map do |email|
extract_email_from_request_param(email)
end
end
def add_by_id(contact_ids)
contact_ids -= existing_ids
contact_ids.uniq.each do |contact_id|
@ -69,7 +75,7 @@ module Issues
end
def remove_by_email
contact_ids = ::CustomerRelations::IssueContact.find_contact_ids_by_emails(issue.id, params[:remove_emails])
contact_ids = ::CustomerRelations::IssueContact.find_contact_ids_by_emails(issue.id, emails(:remove_emails))
remove_by_id(contact_ids)
end
@ -80,6 +86,13 @@ module Issues
.delete_all
end
def extract_email_from_request_param(email_param)
email_param.delete_prefix(::CustomerRelations::Contact.reference_prefix_quoted)
.delete_prefix(::CustomerRelations::Contact.reference_prefix)
.delete_suffix(::CustomerRelations::Contact.reference_postfix)
.tr('"', '')
end
def allowed?
current_user&.can?(:set_issue_crm_contacts, issue)
end

View file

@ -13,6 +13,6 @@
= _('Report abuse to admin')
- if note_editable
%li
= link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?', qa_selector: 'delete_comment_button' }, remote: true, class: 'js-note-delete' do
= link_to note_url(note), method: :delete, data: { confirm: _('Are you sure you want to delete this comment?'), confirm_btn_variant: 'danger', qa_selector: 'delete_comment_button' }, aria: { label: _('Delete comment') }, remote: true, class: 'js-note-delete' do
%span.text-danger
= _('Delete comment')

View file

@ -273,6 +273,15 @@
:weight: 1
:idempotent:
:tags: []
- :name: cronjob:container_registry_migration_guard
:worker_name: ContainerRegistry::Migration::GuardWorker
:feature_category: :container_registry
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: cronjob:database_batched_background_migration
:worker_name: Database::BatchedBackgroundMigrationWorker
:feature_category: :database

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
module ContainerRegistry
module Migration
class GuardWorker
include ApplicationWorker
# This is a general worker with no context.
# It is not scoped to a project, user or group.
# We don't have a context.
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
data_consistency :always
feature_category :container_registry
urgency :low
worker_resource_boundary :unknown
deduplicate :until_executed
idempotent!
def perform
return unless Gitlab.com?
repositories = ::ContainerRepository.with_stale_migration(step_before_timestamp)
.limit(max_capacity)
# the #to_a is safe as the amount of entries is limited.
# In addition, we're calling #each in the next line and we don't want two different SQL queries for these two lines
log_extra_metadata_on_done(:stale_migrations_count, repositories.to_a.size)
repositories.each do |repository|
repository.abort_import
end
end
private
def step_before_timestamp
::ContainerRegistry::Migration.max_step_duration.seconds.ago
end
def max_capacity
# doubling the actual capacity to prevent issues in case the capacity
# is not properly applied
::ContainerRegistry::Migration.capacity * 2
end
end
end
end

View file

@ -0,0 +1,8 @@
---
name: contacts_autocomplete
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79639
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/352123
milestone: '14.8'
type: development
group: group::product planning
default_enabled: false

View file

@ -539,6 +539,9 @@ Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['job_class']
Settings.cron_jobs['container_expiration_policy_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['container_expiration_policy_worker']['cron'] ||= '50 * * * *'
Settings.cron_jobs['container_expiration_policy_worker']['job_class'] = 'ContainerExpirationPolicyWorker'
Settings.cron_jobs['container_registry_migration_guard_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['container_registry_migration_guard_worker']['cron'] ||= '*/10 * * * *'
Settings.cron_jobs['container_registry_migration_guard_worker']['job_class'] = 'ContainerRegistry::Migration::GuardWorker'
Settings.cron_jobs['image_ttl_group_policy_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['image_ttl_group_policy_worker']['cron'] ||= '40 0 * * *'
Settings.cron_jobs['image_ttl_group_policy_worker']['job_class'] = 'DependencyProxy::ImageTtlGroupPolicyWorker'
@ -548,7 +551,6 @@ Settings.cron_jobs['cleanup_dependency_proxy_worker']['job_class'] = 'Dependency
Settings.cron_jobs['cleanup_package_registry_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['cleanup_package_registry_worker']['cron'] ||= '20 0,12 * * *'
Settings.cron_jobs['cleanup_package_registry_worker']['job_class'] = 'Packages::CleanupPackageRegistryWorker'
Settings.cron_jobs['x509_issuer_crl_check_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['x509_issuer_crl_check_worker']['cron'] ||= '30 1 * * *'
Settings.cron_jobs['x509_issuer_crl_check_worker']['job_class'] = 'X509IssuerCrlCheckWorker'

View file

@ -47,6 +47,8 @@
- 1
- - audit_events_audit_event_streaming
- 1
- - audit_events_user_impersonation_event_create
- 1
- - authorized_keys
- 2
- - authorized_project_update

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class AddMigrationIndexesToContainerRepositories < Gitlab::Database::Migration[1.0]
PRE_IMPORTING_INDEX = 'idx_container_repos_on_pre_import_started_at_when_pre_importing'
PRE_IMPORT_DONE_INDEX = 'idx_container_repos_on_pre_import_done_at_when_pre_import_done'
IMPORTING_INDEX = 'idx_container_repos_on_import_started_at_when_importing'
disable_ddl_transaction!
def up
add_concurrent_index :container_repositories, :migration_pre_import_started_at, name: PRE_IMPORTING_INDEX, where: "migration_state = 'pre_importing'"
add_concurrent_index :container_repositories, :migration_pre_import_done_at, name: PRE_IMPORT_DONE_INDEX, where: "migration_state = 'pre_import_done'"
add_concurrent_index :container_repositories, :migration_import_started_at, name: IMPORTING_INDEX, where: "migration_state = 'importing'"
end
def down
remove_concurrent_index_by_name :container_repositories, IMPORTING_INDEX
remove_concurrent_index_by_name :container_repositories, PRE_IMPORT_DONE_INDEX
remove_concurrent_index_by_name :container_repositories, PRE_IMPORTING_INDEX
end
end

View file

@ -0,0 +1 @@
3bcc97592e8e329e39917deffae6619e69215930a688bebad2949f69155b53f9

View file

@ -25243,6 +25243,12 @@ CREATE INDEX idx_container_exp_policies_on_project_id_next_run_at_enabled ON con
CREATE INDEX idx_container_repos_on_exp_cleanup_status_project_id_start_date ON container_repositories USING btree (expiration_policy_cleanup_status, project_id, expiration_policy_started_at);
CREATE INDEX idx_container_repos_on_import_started_at_when_importing ON container_repositories USING btree (migration_import_started_at) WHERE (migration_state = 'importing'::text);
CREATE INDEX idx_container_repos_on_pre_import_done_at_when_pre_import_done ON container_repositories USING btree (migration_pre_import_done_at) WHERE (migration_state = 'pre_import_done'::text);
CREATE INDEX idx_container_repos_on_pre_import_started_at_when_pre_importing ON container_repositories USING btree (migration_pre_import_started_at) WHERE (migration_state = 'pre_importing'::text);
CREATE INDEX idx_deployment_clusters_on_cluster_id_and_kubernetes_namespace ON deployment_clusters USING btree (cluster_id, kubernetes_namespace);
CREATE INDEX idx_devops_adoption_segments_namespace_end_time ON analytics_devops_adoption_snapshots USING btree (namespace_id, end_time);

View file

@ -51,7 +51,10 @@ There are two kinds of events logged:
When a user is being [impersonated](../user/admin_area/index.md#user-impersonation), their actions are logged as audit events as usual, with two additional details:
1. Usual audit events include information about the impersonating administrator. These are visible in their respective Audit Event pages depending on their type (Group/Project/User).
1. Extra audit events are recorded for the start and stop of the administrator's impersonation session. These are visible in the instance Audit Events.
1. Extra audit events are recorded for the start and stop of the administrator's impersonation session. These are visible in
the:
- Instance audit events.
- Group audit events for all groups the user belongs to (GitLab 14.8 and later). This is limited to 20 groups for performance reasons.
![audit events](img/impersonated_audit_events_v13_8.png)
@ -103,6 +106,7 @@ From there, you can see the following actions:
- Group CI/CD variable added, removed, or protected status changed. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/30857) in GitLab 13.3.
- Compliance framework created, updated, or deleted. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/340649) in GitLab 14.5.
- Event streaming destination created, updated, or deleted. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/344664) in GitLab 14.6.
- Instance administrator started or stopped impersonation of a group member. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/300961) in GitLab 14.8.
Group events can also be accessed via the [Group Audit Events API](../api/audit_events.md#group-audit-events)

View file

@ -651,9 +651,11 @@ which variables take precedence.
The order of precedence for variables is (from highest to lowest):
1. [Trigger variables](../triggers/index.md#pass-cicd-variables-in-the-api-call),
[scheduled pipeline variables](../pipelines/schedules.md#using-variables),
and [manual pipeline run variables](#override-a-variable-when-running-a-pipeline-manually).
1. These all have the same (highest) precedence:
- [Trigger variables](../triggers/index.md#pass-cicd-variables-in-the-api-call).
- [Scheduled pipeline variables](../pipelines/schedules.md#using-variables).
- [Manual pipeline run variables](#override-a-variable-when-running-a-pipeline-manually).
- Variables added when [creating a pipeline with the API](../../api/pipelines.md#create-a-new-pipeline).
1. Project [variables](#custom-cicd-variables).
1. Group [variables](#add-a-cicd-variable-to-a-group).
1. Instance [variables](#add-a-cicd-variable-to-an-instance).

View file

@ -232,6 +232,11 @@ v1.0, 2019-01-01
#### Includes
NOTE:
[Wiki pages](project/wiki/index.md#create-a-new-wiki-page) created with the AsciiDoc
format are saved with the file extension `.asciidoc`. When working with AsciiDoc wiki
pages, change the file name from `.adoc` to `.asciidoc`.
```plaintext
include::basics.adoc[]

View file

@ -6,7 +6,11 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Customer relations management (CRM) **(FREE)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.6 [with a flag](../../administration/feature_flags.md) named `customer_relations`. Disabled by default.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.6 [with a flag](../../administration/feature_flags.md) named `customer_relations`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `customer_relations`.
On GitLab.com, this feature is not available.
With customer relations management (CRM) you can create a record of contacts
(individuals) and organizations (companies) and relate them to issues.
@ -133,7 +137,7 @@ API.
### Add contacts to an issue
To add contacts to an issue use the `/add_contacts`
To add contacts to an issue use the `/add_contacts [contact:address@example.com]`
[quick action](../project/quick_actions.md).
You can also add, remove, or replace issue contacts using the
@ -142,9 +146,25 @@ API.
### Remove contacts from an issue
To remove contacts from an issue use the `/remove_contacts`
To remove contacts from an issue use the `/remove_contacts [contact:address@example.com]`
[quick action](../project/quick_actions.md).
You can also add, remove, or replace issue contacts using the
[GraphQL](../../api/graphql/reference/index.md#mutationissuesetcrmcontacts)
API.
## Autocomplete contacts **(FREE SELF)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.8 [with a flag](../../administration/feature_flags.md) named `contacts_autocomplete`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `contacts_autocomplete`.
On GitLab.com, this feature is not available.
This feature is not ready for production use.
When you use the `/add_contacts` or `/remove_contacts` quick actions, follow them with `[contact:` and an autocomplete list appears:
```plaintext
/add_contacts [contact:
/remove_contacts [contact:
```

View file

@ -539,6 +539,7 @@ GitLab Flavored Markdown recognizes the following:
| repository file references | `[README](doc/README.md)` | | |
| repository file line references | `[README](doc/README.md#L13)` | | |
| [alert](../operations/incident_management/alerts.md) | `^alert#123` | `namespace/project^alert#123` | `project^alert#123` |
| contact | `[contact:test@example.com]` | | |
1. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/222483) in GitLab 13.7.

View file

@ -402,7 +402,7 @@ To set a WIP limit for a list:
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34723) in GitLab 12.8.
> - [View blocking issues when hovering over blocked icon](https://gitlab.com/gitlab-org/gitlab/-/issues/210452) in GitLab 13.10.
If an issue is blocked by another issue, an icon appears next to its title to indicate its blocked
If an issue is [blocked by another issue](issues/related_issues.md#blocking-issues), an icon appears next to its title to indicate its blocked
status.
When you hover over the blocked icon (**{issue-block}**), a detailed information popover is displayed.

View file

@ -51,7 +51,7 @@ the issue even if they were actively participating before the change.
## Confidential issue indicators
There are a few things that visually separate a confidential issue from a
regular one. In the issues index page view, you can see the eye-slash (**(eye-slash)**) icon
regular one. In the issues index page view, you can see the eye-slash (**{eye-slash}**) icon
next to the issues that are marked as confidential:
![Confidential issues index page](img/confidential_issues_index_page.png)

View file

@ -15,10 +15,8 @@ value, or complexity a given issue has or costs.
You can set the weight of an issue during its creation, by changing the
value in the dropdown menu. You can set it to a non-negative integer
value from 0, 1, 2, and so on. (The database stores a 4-byte value, so the
upper bound is essentially limitless.)
You can remove weight from an issue
as well.
value from 0, 1, 2, and so on.
You can remove weight from an issue as well.
This value appears on the right sidebar of an individual issue, as well as
in the issues page next to a weight icon (**{weight}**).

View file

@ -319,7 +319,7 @@ You can move all open issues from one project to another.
Prerequisites:
- You must have at least the Reporter role for the project.
- You must have access to the Rails console of the GitLab instance.
To do it:
@ -510,9 +510,9 @@ Alternatively:
## Promote an issue to an epic **(PREMIUM)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/3777) in GitLab Ultimate 11.6.
> - Moved to GitLab Premium in 12.8.
> - Promoting issues to epics via the UI [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/233974) in GitLab Premium 13.6.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/3777) in GitLab 11.6.
> - Moved from GitLab Ultimate to GitLab Premium in 12.8.
> - Promoting issues to epics via the UI [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/233974) in GitLab 13.6.
You can promote an issue to an [epic](../../group/epics/index.md) in the immediate parent group.

View file

@ -6,7 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Multiple Assignees for Issues **(PREMIUM)**
> - Moved to GitLab Premium in 13.9.
> Moved to GitLab Premium in 13.9.
In large teams, where there is shared ownership of an issue, it can be difficult
to track who is working on it, who already completed their contributions, who

View file

@ -6,7 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Linked issues **(FREE)**
> The simple "relates to" relationship [moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212329) to [GitLab Free](https://about.gitlab.com/pricing/) in 13.4.
> The simple "relates to" relationship [moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212329) from GitLab Premium to GitLab Free in 13.4.
Linked issues are a bi-directional relationship between any two issues and appear in a block below
the issue description. You can link issues in different projects.
@ -33,8 +33,8 @@ To link one issue to another:
select the add linked issue button (**{plus}**).
1. Select the relationship between the two issues. Either:
- **relates to**
- **blocks** **(PREMIUM)**
- **is blocked by** **(PREMIUM)**
- **[blocks](#blocking-issues)**
- **[is blocked by](#blocking-issues)**
1. Input the issue number or paste in the full URL of the issue.
![Adding a related issue](img/related_issues_add_v12_8.png)
@ -69,3 +69,10 @@ Due to the bi-directional relationship, the relationship no longer appears in ei
![Removing a related issue](img/related_issues_remove_v12_8.png)
Access our [permissions](../../permissions.md) page for more information.
## Blocking issues **(PREMIUM)**
When you [add a linked issue](#add-a-linked-issue), you can show that it **blocks** or
**is blocked by** another issue.
Issues that block other issues have an icon (**{issue-block}**) shown in the issue lists and [boards](../issue_board.md).

View file

@ -27,7 +27,7 @@ The available sorting options can change based on the context of the list.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34247/) in GitLab 13.7.
When you sort by **Blocking**, the issue list changes to sort descending by the
number of issues each issue is blocking.
number of issues each issue is [blocking](related_issues.md#blocking-issues).
## Sorting by created date

View file

@ -49,7 +49,7 @@ threads. Some quick actions might not be available to all subscription tiers.
| Command | Issue | Merge request | Epic | Action |
|:-------------------------------------------------------------------------------------------------|:-----------------------|:-----------------------|:-----------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `/add_contacts email1 email2` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Add one or more [CRM contacts](../crm/index.md) ([introduced in GitLab 14.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73413)). |
| `/add_contacts [contact:email1@example.com] [contact:email2@example.com]` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Add one or more [CRM contacts](../crm/index.md) ([introduced in GitLab 14.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73413)). |
| `/approve` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Approve the merge request. |
| `/assign @user1 @user2` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Assign one or more users. |
| `/assign me` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Assign yourself. |
@ -89,7 +89,7 @@ threads. Some quick actions might not be available to all subscription tiers.
| `/relabel ~label1 ~label2` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Replace current labels with those specified. |
| `/relate #issue1 #issue2` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Mark issues as related. |
| `/remove_child_epic <epic>` | **{dotted-circle}** No | **{dotted-circle}** No | **{check-circle}** Yes | Remove child epic from `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic ([introduced in GitLab 12.0](https://gitlab.com/gitlab-org/gitlab/-/issues/7330)). |
| `/remove_contacts email1 email2` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove one or more [CRM contacts](../crm/index.md) ([introduced in GitLab 14.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73413)). |
| `/remove_contacts [contact:email1@example.com] [contact:email2@example.com]` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove one or more [CRM contacts](../crm/index.md) ([introduced in GitLab 14.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73413)). |
| `/remove_due_date` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove due date. |
| `/remove_epic` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove from epic. |
| `/remove_estimate` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Remove time estimate. |

View file

@ -158,7 +158,7 @@ These shortcuts are available when using a [filtered search input](search/index.
| <kbd></kbd> (Mac) + <kbd></kbd> | Clear entire search filter. |
| <kbd></kbd> (Mac) / <kbd>Control</kbd> + <kbd></kbd> | Clear one token at a time. |
## Epics **(ULTIMATE)**
## Epics **(PREMIUM)**
These shortcuts are available when viewing [epics](group/epics/index.md):

View file

@ -57,7 +57,7 @@ module ContainerRegistry
def faraday(timeout_enabled: true)
@faraday ||= faraday_base(timeout_enabled: timeout_enabled) do |conn|
initialize_connection(conn, @options, &method(:accept_manifest))
initialize_connection(conn, @options, &method(:configure_connection))
end
end
@ -105,7 +105,7 @@ module ContainerRegistry
faraday_redirect.get(uri)
end
def accept_manifest(conn)
def configure_connection(conn)
conn.headers['Accept'] = ACCEPTED_TYPES
conn.response :json, content_type: 'application/json'

View file

@ -4,6 +4,8 @@ module ContainerRegistry
class GitlabApiClient < BaseClient
include Gitlab::Utils::StrongMemoize
JSON_TYPE = 'application/json'
IMPORT_RESPONSES = {
200 => :already_imported,
202 => :ok,
@ -46,9 +48,20 @@ module ContainerRegistry
private
def start_import_for(path, pre:)
faraday.put("/gitlab/v1/import/#{path}") do |req|
faraday.put(import_url_for(path)) do |req|
req.params['pre'] = pre.to_s
end
end
def import_url_for(path)
"/gitlab/v1/import/#{path}/"
end
# overrides the default configuration
def configure_connection(conn)
conn.headers['Accept'] = [JSON_TYPE]
conn.response :json, content_type: JSON_TYPE
end
end
end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
module ContainerRegistry
class Migration
module Migration
class << self
delegate :container_registry_import_max_tags_count, to: ::Gitlab::CurrentSettings
delegate :container_registry_import_max_retries, to: ::Gitlab::CurrentSettings

View file

@ -19,7 +19,7 @@ module Gitlab
relations = [
# The project a user has direct access to.
user.projects.select_for_project_authorization,
user.projects_with_active_memberships.select_for_project_authorization,
# The personal projects of the user.
user.personal_projects.select_as_maintainer_for_project_authorization,
@ -65,7 +65,7 @@ module Gitlab
group_group_links = GroupGroupLink.arel_table
# Namespaces the user is a member of.
cte << user.groups
cte << user.groups_with_active_memberships
.select([namespaces[:id], members[:access_level]])
.except(:order)
@ -99,6 +99,7 @@ module Gitlab
.and(members[:source_type].eq('Namespace'))
.and(members[:requested_at].eq(nil))
.and(members[:user_id].eq(user.id))
.and(members[:state].eq(::Member::STATE_ACTIVE))
.and(members[:access_level].gt(Gitlab::Access::MINIMAL_ACCESS))
Arel::Nodes::OuterJoin.new(members, Arel::Nodes::On.new(cond))
@ -120,6 +121,7 @@ module Gitlab
.and(members[:source_type].eq('Namespace'))
.and(members[:requested_at].eq(nil))
.and(members[:user_id].eq(user.id))
.and(members[:state].eq(::Member::STATE_ACTIVE))
.and(members[:access_level].gt(Gitlab::Access::MINIMAL_ACCESS))
Arel::Nodes::InnerJoin.new(members, Arel::Nodes::On.new(cond))
end

View file

@ -287,7 +287,7 @@ module Gitlab
desc _('Add customer relation contacts')
explanation _('Add customer relation contact(s).')
params 'contact@example.com person@example.org'
params '[contact:contact@example.com] [contact:person@example.org]'
types Issue
condition do
current_user.can?(:set_issue_crm_contacts, quick_action_target) &&
@ -302,7 +302,7 @@ module Gitlab
desc _('Remove customer relation contacts')
explanation _('Remove customer relation contact(s).')
params 'contact@example.com person@example.org'
params '[contact:contact@example.com] [contact:person@example.org]'
types Issue
condition do
current_user.can?(:set_issue_crm_contacts, quick_action_target) &&

View file

@ -4690,6 +4690,9 @@ msgstr ""
msgid "Are you sure you want to delete this SSH key?"
msgstr ""
msgid "Are you sure you want to delete this comment?"
msgstr ""
msgid "Are you sure you want to delete this deploy key?"
msgstr ""
@ -34916,6 +34919,21 @@ msgstr ""
msgid "Subscriptions"
msgstr ""
msgid "Subscriptions|Chat with sales"
msgstr ""
msgid "Subscriptions|Close"
msgstr ""
msgid "Subscriptions|Not ready to buy yet?"
msgstr ""
msgid "Subscriptions|Start a free trial"
msgstr ""
msgid "Subscriptions|We understand. Maybe you have some questions for our sales team, or maybe you'd like to try some of the paid features first. What would you like to do?"
msgstr ""
msgid "Subscription|Your subscription for %{strong}%{namespace_name}%{strong_close} has expired and you are now on %{pricing_link_start}the GitLab Free tier%{pricing_link_end}. Don't worry, your data is safe. Get in touch with our support team (%{support_email}). They'll gladly help with your subscription renewal."
msgstr ""

View file

@ -35,6 +35,18 @@ FactoryBot.define do
access_level { GroupMember::MINIMAL_ACCESS }
end
trait :awaiting do
after(:create) do |member|
member.update!(state: ::Member::STATE_AWAITING)
end
end
trait :active do
after(:create) do |member|
member.update!(state: ::Member::STATE_ACTIVE)
end
end
transient do
tasks_to_be_done { [] }
end

View file

@ -24,6 +24,18 @@ FactoryBot.define do
after(:build) { |project_member, _| project_member.user.block! }
end
trait :awaiting do
after(:create) do |member|
member.update!(state: ::Member::STATE_AWAITING)
end
end
trait :active do
after(:create) do |member|
member.update!(state: ::Member::STATE_ACTIVE)
end
end
transient do
tasks_to_be_done { [] }
end

View file

@ -6,7 +6,8 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') }
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user]) }
let_it_be(:label) { create(:label, project: project, title: 'special+') }
let_it_be(:label_scoped) { create(:label, project: project, title: 'scoped::label') }
@ -19,9 +20,9 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:label_xss) { create(:label, project: project, title: label_xss_title) }
before_all do
project.add_maintainer(user)
project.add_maintainer(user_xss)
project.add_maintainer(user2)
group.add_maintainer(user)
group.add_maintainer(user_xss)
group.add_maintainer(user2)
end
describe 'new issue page' do
@ -381,6 +382,30 @@ RSpec.describe 'GFM autocomplete', :js do
end
end
end
context 'contact' do
let_it_be(:contacts) { create_list(:contact, 2, group: group) }
before do
fill_in 'Comment', with: '/add_contacts [contact:'
wait_for_requests
end
it 'shows contacts list in the autocomplete menu' do
page.within(find_autocomplete_menu) do
expect(page).to have_selector('li', count: 2)
end
end
it 'shows all contacts' do
page.within(find_autocomplete_menu) do
expected_data = contacts.map { |c| "#{c.first_name} #{c.last_name} #{c.email}"}
expect(page.all('li').map(&:text)).to match_array(expected_data)
end
end
end
end
private

View file

@ -7,43 +7,10 @@
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
"merged_by": {
"type": ["object", "null"],
"properties": {
"name": { "type": "string" },
"username": { "type": "string" },
"id": { "type": "integer" },
"state": { "type": "string" },
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
"additionalProperties": false
},
"merge_user": {
"type": ["object", "null"],
"properties": {
"name": { "type": "string" },
"username": { "type": "string" },
"id": { "type": "integer" },
"state": { "type": "string" },
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
"additionalProperties": false
},
"merged_by": { "$ref": "user/basic.json" },
"merge_user": { "$ref": "user/basic.json" },
"merged_at": { "type": ["string", "null"] },
"closed_by": {
"type": ["object", "null"],
"properties": {
"name": { "type": "string" },
"username": { "type": "string" },
"id": { "type": "integer" },
"state": { "type": "string" },
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
"additionalProperties": false
},
"closed_by": { "$ref": "user/basic.json" },
"closed_at": { "type": ["string", "null"], "format": "date-time" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" },
@ -51,36 +18,20 @@
"source_branch": { "type": "string" },
"upvotes": { "type": "integer" },
"downvotes": { "type": "integer" },
"author": {
"type": "object",
"properties": {
"name": { "type": "string" },
"username": { "type": "string" },
"id": { "type": "integer" },
"state": { "type": "string" },
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
"additionalProperties": false
},
"assignee": {
"type": ["object", "null"],
"properties": {
"name": { "type": "string" },
"username": { "type": "string" },
"id": { "type": "integer" },
"state": { "type": "string" },
"avatar_url": { "type": "uri" },
"web_url": { "type": "uri" }
},
"additionalProperties": false
},
"author": { "$ref": "user/basic.json" },
"assignee": { "$ref": "user/basic.json" },
"assignees": {
"type": "array",
"items": {
"$ref": "user/basic.json"
}
},
"reviewers": {
"type": "array",
"items": {
"$ref": "user/basic.json"
}
},
"source_project_id": { "type": "integer" },
"target_project_id": { "type": "integer" },
"labels": {

View file

@ -0,0 +1,79 @@
import { mountExtended } from 'helpers/vue_test_utils_helper';
import StepNav from '~/pipeline_wizard/components/step_nav.vue';
describe('Pipeline Wizard - Step Navigation Component', () => {
const defaultProps = { showBackButton: true, showNextButton: true };
let wrapper;
let prevButton;
let nextButton;
const createComponent = (props = {}) => {
wrapper = mountExtended(StepNav, {
propsData: {
...defaultProps,
...props,
},
});
prevButton = wrapper.findByTestId('back-button');
nextButton = wrapper.findByTestId('next-button');
};
afterEach(() => {
wrapper.destroy();
});
it.each`
scenario | showBackButton | showNextButton
${'does not show prev button'} | ${false} | ${false}
${'has prev, but not next'} | ${true} | ${false}
${'has next, but not prev'} | ${false} | ${true}
${'has both next and prev'} | ${true} | ${true}
`('$scenario', async ({ showBackButton, showNextButton }) => {
createComponent({ showBackButton, showNextButton });
expect(prevButton.exists()).toBe(showBackButton);
expect(nextButton.exists()).toBe(showNextButton);
});
it('shows the expected button text', () => {
createComponent();
expect(prevButton.text()).toBe('Back');
expect(nextButton.text()).toBe('Next');
});
it('emits "back" events when clicking prev button', async () => {
createComponent();
await prevButton.trigger('click');
expect(wrapper.emitted().back.length).toBe(1);
});
it('emits "next" events when clicking next button', async () => {
createComponent();
await nextButton.trigger('click');
expect(wrapper.emitted().next.length).toBe(1);
});
it('enables the next button if nextButtonEnabled ist set to true', async () => {
createComponent({ nextButtonEnabled: true });
expect(nextButton.attributes('disabled')).not.toBe('disabled');
});
it('disables the next button if nextButtonEnabled ist set to false', async () => {
createComponent({ nextButtonEnabled: false });
expect(nextButton.attributes('disabled')).toBe('disabled');
});
it('does not emit "next" event when clicking next button while nextButtonEnabled ist set to false', async () => {
createComponent({ nextButtonEnabled: false });
await nextButton.trigger('click');
expect(wrapper.emitted().next).toBe(undefined);
});
});

View file

@ -63,6 +63,11 @@ describe('Markdown field component', () => {
textareaValue,
lines,
},
provide: {
glFeatures: {
contactsAutocomplete: true,
},
},
},
);
}

View file

@ -289,7 +289,7 @@ RSpec.describe ApplicationHelper do
it 'returns paths for autocomplete_sources_controller' do
sources = helper.autocomplete_data_sources(project, noteable_type)
expect(sources.keys).to match_array([:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets])
expect(sources.keys).to match_array([:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :contacts])
sources.keys.each do |key|
expect(sources[key]).not_to be_nil
end

View file

@ -7,6 +7,8 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
include_context 'container registry client'
let(:path) { 'namespace/path/to/repository' }
describe '#supports_gitlab_api?' do
subject { client.supports_gitlab_api? }
@ -30,9 +32,7 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
it 'returns the expected result' do
if expect_registry_to_be_pinged
expect_next_instance_of(Faraday::Connection) do |connection|
expect(connection).to receive(:run_request).and_call_original
end
expect(Faraday::Connection).to receive(:new).and_call_original
else
expect(Faraday::Connection).not_to receive(:new)
end
@ -54,9 +54,7 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
end
describe '#pre_import_repository' do
let(:path) { 'namespace/path/to/repository' }
subject { client.pre_import_repository('namespace/path/to/repository') }
subject { client.pre_import_repository(path) }
where(:status_code, :expected_result) do
200 | :already_imported
@ -80,9 +78,7 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
end
describe '#pre_import_repository' do
let(:path) { 'namespace/path/to/repository' }
subject { client.import_repository('namespace/path/to/repository') }
subject { client.import_repository(path) }
where(:status_code, :expected_result) do
200 | :already_imported
@ -129,9 +125,7 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
it 'returns the expected result' do
if expect_registry_to_be_pinged
expect_next_instance_of(Faraday::Connection) do |connection|
expect(connection).to receive(:run_request).and_call_original
end
expect(Faraday::Connection).to receive(:new).and_call_original
else
expect(Faraday::Connection).not_to receive(:new)
end
@ -166,13 +160,15 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
end
def stub_pre_import(path, status_code, pre:)
stub_request(:put, "#{registry_api_url}/gitlab/v1/import/#{path}?pre=#{pre}")
stub_request(:put, "#{registry_api_url}/gitlab/v1/import/#{path}/?pre=#{pre}")
.with(headers: { 'Accept' => described_class::JSON_TYPE })
.to_return(status: status_code, body: '')
end
def stub_registry_gitlab_api_support(supported = true)
status_code = supported ? 200 : 404
stub_request(:get, "#{registry_api_url}/gitlab/v1/")
.with(headers: { 'Accept' => described_class::JSON_TYPE })
.to_return(status: status_code, body: '')
end
end

View file

@ -345,4 +345,76 @@ RSpec.describe Gitlab::ProjectAuthorizations do
end
end
end
context 'with pending memberships' do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
subject(:mapping) { map_access_levels(authorizations) }
context 'group membership' do
let!(:group_project) { create(:project, namespace: group) }
before do
create(:group_member, :developer, :awaiting, user: user, group: group)
end
it 'does not create authorization' do
expect(mapping[group_project.id]).to be_nil
end
end
context 'inherited group membership' do
let!(:sub_group) { create(:group, parent: group) }
let!(:sub_group_project) { create(:project, namespace: sub_group) }
before do
create(:group_member, :developer, :awaiting, user: user, group: group)
end
it 'does not create authorization' do
expect(mapping[sub_group_project.id]).to be_nil
end
end
context 'project membership' do
let!(:group_project) { create(:project, namespace: group) }
before do
create(:project_member, :developer, :awaiting, user: user, project: group_project)
end
it 'does not create authorization' do
expect(mapping[group_project.id]).to be_nil
end
end
context 'shared group' do
let!(:shared_group) { create(:group) }
let!(:shared_group_project) { create(:project, namespace: shared_group) }
before do
create(:group_group_link, shared_group: shared_group, shared_with_group: group)
create(:group_member, :developer, :awaiting, user: user, group: group)
end
it 'does not create authorization' do
expect(mapping[shared_group_project.id]).to be_nil
end
end
context 'shared project' do
let!(:another_group) { create(:group) }
let!(:shared_project) { create(:project, namespace: another_group) }
before do
create(:project_group_link, group: group, project: shared_project)
create(:group_member, :developer, :awaiting, user: user, group: group)
end
it 'does not create authorization' do
expect(mapping[shared_project.id]).to be_nil
end
end
end
end

View file

@ -37,12 +37,12 @@ RSpec.describe ContainerRepository, :aggregate_failures do
it { is_expected.to validate_presence_of(:migration_retries_count) }
it { is_expected.to validate_numericality_of(:migration_retries_count).is_greater_than_or_equal_to(0) }
it { is_expected.to validate_inclusion_of(:migration_aborted_in_state).in_array(ContainerRepository::ACTIVE_MIGRATION_STATES) }
it { is_expected.to validate_inclusion_of(:migration_aborted_in_state).in_array(described_class::ABORTABLE_MIGRATION_STATES) }
it { is_expected.to allow_value(nil).for(:migration_aborted_in_state) }
context 'migration_state' do
it { is_expected.to validate_presence_of(:migration_state) }
it { is_expected.to validate_inclusion_of(:migration_state).in_array(ContainerRepository::MIGRATION_STATES) }
it { is_expected.to validate_inclusion_of(:migration_state).in_array(described_class::MIGRATION_STATES) }
describe 'pre_importing' do
it 'validates expected attributes' do
@ -161,7 +161,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
shared_examples 'transitioning from allowed states' do |allowed_states|
ContainerRepository::MIGRATION_STATES.each do |state|
described_class::MIGRATION_STATES.each do |state|
result = allowed_states.include?(state)
context "when transitioning from #{state}" do
@ -283,7 +283,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
subject { repository.abort_import }
it_behaves_like 'transitioning from allowed states', %w[pre_importing importing]
it_behaves_like 'transitioning from allowed states', %w[pre_importing pre_import_done importing]
it 'sets migration_aborted_at and migration_aborted_at and increments the retry count' do
expect { subject }.to change { repository.migration_aborted_at }
@ -634,7 +634,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
let(:path) { ContainerRegistry::Path.new(project.full_path + '/some/image') }
it 'does not throw validation errors and only creates one repository' do
expect { repository_creation_race(path) }.to change { ContainerRepository.count }.by(1)
expect { repository_creation_race(path) }.to change { described_class.count }.by(1)
end
it 'retrieves a persisted repository for all concurrent calls' do
@ -652,7 +652,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
Thread.new do
true while wait_for_it
::ContainerRepository.find_or_create_from_path(path)
described_class.find_or_create_from_path(path)
end
end
wait_for_it = false
@ -788,6 +788,36 @@ RSpec.describe ContainerRepository, :aggregate_failures do
it { is_expected.to contain_exactly(repository1, repository2, repository4) }
end
describe '.with_migration_import_started_at_nil_or_before' do
let_it_be(:repository1) { create(:container_repository, migration_import_started_at: 5.minutes.ago) }
let_it_be(:repository2) { create(:container_repository, migration_import_started_at: nil) }
let_it_be(:repository3) { create(:container_repository, migration_import_started_at: 10.minutes.ago) }
subject { described_class.with_migration_import_started_at_nil_or_before(7.minutes.ago) }
it { is_expected.to contain_exactly(repository2, repository3) }
end
describe '.with_migration_pre_import_started_at_nil_or_before' do
let_it_be(:repository1) { create(:container_repository, migration_pre_import_started_at: 5.minutes.ago) }
let_it_be(:repository2) { create(:container_repository, migration_pre_import_started_at: nil) }
let_it_be(:repository3) { create(:container_repository, migration_pre_import_started_at: 10.minutes.ago) }
subject { described_class.with_migration_pre_import_started_at_nil_or_before(7.minutes.ago) }
it { is_expected.to contain_exactly(repository2, repository3) }
end
describe '.with_migration_pre_import_done_at_nil_or_before' do
let_it_be(:repository1) { create(:container_repository, migration_pre_import_done_at: 5.minutes.ago) }
let_it_be(:repository2) { create(:container_repository, migration_pre_import_done_at: nil) }
let_it_be(:repository3) { create(:container_repository, migration_pre_import_done_at: 10.minutes.ago) }
subject { described_class.with_migration_pre_import_done_at_nil_or_before(7.minutes.ago) }
it { is_expected.to contain_exactly(repository2, repository3) }
end
describe '.with_stale_ongoing_cleanup' do
let_it_be(:repository1) { create(:container_repository, :cleanup_ongoing, expiration_policy_started_at: 1.day.ago) }
let_it_be(:repository2) { create(:container_repository, :cleanup_ongoing, expiration_policy_started_at: 25.minutes.ago) }
@ -837,7 +867,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
describe '#migration_in_active_state?' do
subject { container_repository.migration_in_active_state? }
ContainerRepository::MIGRATION_STATES.each do |state|
described_class::MIGRATION_STATES.each do |state|
context "when in #{state} migration_state" do
let(:container_repository) { create(:container_repository, state.to_sym)}
@ -849,7 +879,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
describe '#migration_importing?' do
subject { container_repository.migration_importing? }
ContainerRepository::MIGRATION_STATES.each do |state|
described_class::MIGRATION_STATES.each do |state|
context "when in #{state} migration_state" do
let(:container_repository) { create(:container_repository, state.to_sym)}
@ -861,7 +891,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
describe '#migration_pre_importing?' do
subject { container_repository.migration_pre_importing? }
ContainerRepository::MIGRATION_STATES.each do |state|
described_class::MIGRATION_STATES.each do |state|
context "when in #{state} migration_state" do
let(:container_repository) { create(:container_repository, state.to_sym)}
@ -922,4 +952,34 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
end
describe '.with_stale_migration' do
let_it_be(:repository) { create(:container_repository) }
let_it_be(:stale_pre_importing_old_timestamp) { create(:container_repository, :pre_importing, migration_pre_import_started_at: 10.minutes.ago) }
let_it_be(:stale_pre_importing_nil_timestamp) { create(:container_repository, :pre_importing).tap { |r| r.update_column(:migration_pre_import_started_at, nil) } }
let_it_be(:stale_pre_importing_recent_timestamp) { create(:container_repository, :pre_importing, migration_pre_import_started_at: 2.minutes.ago) }
let_it_be(:stale_pre_import_done_old_timestamp) { create(:container_repository, :pre_import_done, migration_pre_import_done_at: 10.minutes.ago) }
let_it_be(:stale_pre_import_done_nil_timestamp) { create(:container_repository, :pre_import_done).tap { |r| r.update_column(:migration_pre_import_done_at, nil) } }
let_it_be(:stale_pre_import_done_recent_timestamp) { create(:container_repository, :pre_import_done, migration_pre_import_done_at: 2.minutes.ago) }
let_it_be(:stale_importing_old_timestamp) { create(:container_repository, :importing, migration_import_started_at: 10.minutes.ago) }
let_it_be(:stale_importing_nil_timestamp) { create(:container_repository, :importing).tap { |r| r.update_column(:migration_import_started_at, nil) } }
let_it_be(:stale_importing_recent_timestamp) { create(:container_repository, :importing, migration_import_started_at: 2.minutes.ago) }
let(:stale_migrations) do
[
stale_pre_importing_old_timestamp,
stale_pre_importing_nil_timestamp,
stale_pre_import_done_old_timestamp,
stale_pre_import_done_nil_timestamp,
stale_importing_old_timestamp,
stale_importing_nil_timestamp
]
end
subject { described_class.with_stale_migration(5.minutes.ago) }
it { is_expected.to contain_exactly(*stale_migrations) }
end
end

View file

@ -26,6 +26,18 @@ RSpec.describe CustomerRelations::Contact, type: :model do
it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email
end
describe '.reference_prefix' do
it { expect(described_class.reference_prefix).to eq('[contact:') }
end
describe '.reference_prefix_quoted' do
it { expect(described_class.reference_prefix_quoted).to eq('["contact:') }
end
describe '.reference_postfix' do
it { expect(described_class.reference_postfix).to eq(']') }
end
describe '#unique_email_for_group_hierarchy' do
let_it_be(:parent) { create(:group) }
let_it_be(:group) { create(:group, parent: parent) }

View file

@ -7,20 +7,31 @@ RSpec.describe Issues::SetCrmContactsService do
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:contacts) { create_list(:contact, 4, group: group) }
let(:issue) { create(:issue, project: project) }
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" }
before do
create(:issue_customer_relations_contact, issue: issue, contact: contacts[0])
create(:issue_customer_relations_contact, issue: issue, contact: contacts[1])
let_it_be(:issue, reload: true) { create(:issue, project: project) }
let_it_be(:issue_contact_1) do
create(:issue_customer_relations_contact, issue: issue, contact: contacts[0]).contact
end
let_it_be(:issue_contact_2) do
create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]).contact
end
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" }
subject(:set_crm_contacts) do
described_class.new(project: project, current_user: user, params: params).execute(issue)
end
describe '#execute' do
shared_examples 'setting contacts' do
it 'updates the issue with correct contacts' do
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array(expected_contacts)
end
end
context 'when the user has no permission' do
let(:params) { { replace_ids: [contacts[1].id, contacts[2].id] } }
@ -67,56 +78,56 @@ RSpec.describe Issues::SetCrmContactsService do
context 'replace' do
let(:params) { { replace_ids: [contacts[1].id, contacts[2].id] } }
let(:expected_contacts) { [contacts[1], contacts[2]] }
it 'updates the issue with correct contacts' do
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[1], contacts[2]])
end
it_behaves_like 'setting contacts'
end
context 'add' do
let(:params) { { add_ids: [contacts[3].id] } }
let(:added_contact) { contacts[3] }
let(:params) { { add_ids: [added_contact.id] } }
let(:expected_contacts) { [issue_contact_1, issue_contact_2, added_contact] }
it 'updates the issue with correct contacts' do
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[1], contacts[3]])
end
it_behaves_like 'setting contacts'
end
context 'add by email' do
let(:params) { { add_emails: [contacts[3].email] } }
let(:added_contact) { contacts[3] }
let(:expected_contacts) { [issue_contact_1, issue_contact_2, added_contact] }
it 'updates the issue with correct contacts' do
response = set_crm_contacts
context 'with pure emails in params' do
let(:params) { { add_emails: [contacts[3].email] } }
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[1], contacts[3]])
it_behaves_like 'setting contacts'
end
context 'with autocomplete prefix emails in params' do
let(:params) { { add_emails: ["[\"contact:\"#{contacts[3].email}\"]"] } }
it_behaves_like 'setting contacts'
end
end
context 'remove' do
let(:params) { { remove_ids: [contacts[0].id] } }
let(:expected_contacts) { [contacts[1]] }
it 'updates the issue with correct contacts' do
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[1]])
end
it_behaves_like 'setting contacts'
end
context 'remove by email' do
let(:params) { { remove_emails: [contacts[0].email] } }
let(:expected_contacts) { [contacts[1]] }
it 'updates the issue with correct contacts' do
response = set_crm_contacts
context 'with pure email in params' do
let(:params) { { remove_emails: [contacts[0].email] } }
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[1]])
it_behaves_like 'setting contacts'
end
context 'with autocomplete prefix and suffix email in params' do
let(:params) { { remove_emails: ["[contact:#{contacts[0].email}]"] } }
it_behaves_like 'setting contacts'
end
end
@ -145,15 +156,19 @@ RSpec.describe Issues::SetCrmContactsService do
context 'when combining params' do
let(:error_invalid_params) { 'You cannot combine replace_ids with add_ids or remove_ids' }
let(:expected_contacts) { [contacts[0], contacts[3]] }
context 'add and remove' do
let(:params) { { remove_ids: [contacts[1].id], add_ids: [contacts[3].id] } }
context 'with contact ids' do
let(:params) { { remove_ids: [contacts[1].id], add_ids: [contacts[3].id] } }
it 'updates the issue with correct contacts' do
response = set_crm_contacts
it_behaves_like 'setting contacts'
end
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[3]])
context 'with contact emails' do
let(:params) { { remove_emails: [contacts[1].email], add_emails: ["[\"contact:#{contacts[3].email}]"] } }
it_behaves_like 'setting contacts'
end
end

View file

@ -0,0 +1,115 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
include_context 'container registry client'
let(:worker) { described_class.new }
describe '#perform' do
let(:pre_importing_migrations) { ::ContainerRepository.with_migration_states(:pre_importing) }
let(:pre_import_done_migrations) { ::ContainerRepository.with_migration_states(:pre_import_done) }
let(:importing_migrations) { ::ContainerRepository.with_migration_states(:importing) }
let(:import_aborted_migrations) { ::ContainerRepository.with_migration_states(:import_aborted) }
let(:import_done_migrations) { ::ContainerRepository.with_migration_states(:import_done) }
subject { worker.perform }
before do
stub_container_registry_config(enabled: true, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
allow(::ContainerRegistry::Migration).to receive(:max_step_duration).and_return(5.minutes)
end
context 'on gitlab.com' do
before do
allow(::Gitlab).to receive(:com?).and_return(true)
end
context 'with no stale migrations' do
it_behaves_like 'an idempotent worker'
it 'will not update any migration state' do
expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 0)
expect { subject }
.to not_change(pre_importing_migrations, :count)
.and not_change(pre_import_done_migrations, :count)
.and not_change(importing_migrations, :count)
.and not_change(import_aborted_migrations, :count)
end
end
context 'with pre_importing stale migrations' do
let(:ongoing_migration) { create(:container_repository, :pre_importing) }
let(:stale_migration) { create(:container_repository, :pre_importing, migration_pre_import_started_at: 10.minutes.ago) }
it 'will abort the migration' do
expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
expect { subject }
.to change(pre_importing_migrations, :count).by(-1)
.and not_change(pre_import_done_migrations, :count)
.and not_change(importing_migrations, :count)
.and not_change(import_done_migrations, :count)
.and change(import_aborted_migrations, :count).by(1)
.and change { stale_migration.reload.migration_state }.from('pre_importing').to('import_aborted')
.and not_change { ongoing_migration.migration_state }
end
end
context 'with pre_import_done stale migrations' do
let(:ongoing_migration) { create(:container_repository, :pre_import_done) }
let(:stale_migration) { create(:container_repository, :pre_import_done, migration_pre_import_done_at: 10.minutes.ago) }
before do
allow(::ContainerRegistry::Migration).to receive(:max_step_duration).and_return(5.minutes)
expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
end
it 'will abort the migration' do
expect { subject }
.to not_change(pre_importing_migrations, :count)
.and change(pre_import_done_migrations, :count).by(-1)
.and not_change(importing_migrations, :count)
.and not_change(import_done_migrations, :count)
.and change(import_aborted_migrations, :count).by(1)
.and change { stale_migration.reload.migration_state }.from('pre_import_done').to('import_aborted')
.and not_change { ongoing_migration.migration_state }
end
end
context 'with importing stale migrations' do
let(:ongoing_migration) { create(:container_repository, :importing) }
let(:stale_migration) { create(:container_repository, :importing, migration_import_started_at: 10.minutes.ago) }
before do
allow(::ContainerRegistry::Migration).to receive(:max_step_duration).and_return(5.minutes)
expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
end
it 'will abort the migration' do
expect { subject }
.to not_change(pre_importing_migrations, :count)
.and not_change(pre_import_done_migrations, :count)
.and change(importing_migrations, :count).by(-1)
.and not_change(import_done_migrations, :count)
.and change(import_aborted_migrations, :count).by(1)
.and change { stale_migration.reload.migration_state }.from('importing').to('import_aborted')
.and not_change { ongoing_migration.migration_state }
end
end
end
context 'not on gitlab.com' do
before do
allow(::Gitlab).to receive(:com?).and_return(false)
end
it 'is a no op' do
expect(::ContainerRepository).not_to receive(:with_stale_migration)
expect(worker).not_to receive(:log_extra_metadata_on_done)
subject
end
end
end
end