summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-02-10 15:12:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-02-10 15:12:42 +0000
commit74d9798736a89f07e047698e5e32964829bf8859 (patch)
treeb969a2a5e29f2f83b3f7fcddfc3804f62432bbb4
parent190128fc72e015c383e7a96c128276d1833f3beb (diff)
downloadgitlab-ce-74d9798736a89f07e047698e5e32964829bf8859.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop.yml1
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js55
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/step_nav.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue3
-rw-r--r--app/controllers/projects/issues_controller.rb1
-rw-r--r--app/experiments/application_experiment.rb2
-rw-r--r--app/experiments/combined_registration_experiment.rb2
-rw-r--r--app/experiments/empty_repo_upload_experiment.rb2
-rw-r--r--app/experiments/force_company_trial_experiment.rb2
-rw-r--r--app/experiments/in_product_guidance_environments_webide_experiment.rb2
-rw-r--r--app/experiments/new_project_sast_enabled_experiment.rb2
-rw-r--r--app/experiments/require_verification_for_namespace_creation_experiment.rb2
-rw-r--r--app/experiments/security_reports_mr_widget_prompt_experiment.rb2
-rw-r--r--app/helpers/application_helper.rb3
-rw-r--r--app/models/container_repository.rb24
-rw-r--r--app/models/customer_relations/contact.rb12
-rw-r--r--app/models/user.rb2
-rw-r--r--app/services/issues/set_crm_contacts_service.rb17
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml2
-rw-r--r--app/workers/all_queues.yml9
-rw-r--r--app/workers/container_registry/migration/guard_worker.rb47
-rw-r--r--config/feature_flags/development/contacts_autocomplete.yml8
-rw-r--r--config/initializers/1_settings.rb4
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--db/migrate/20220202115350_add_migration_indexes_to_container_repositories.rb21
-rw-r--r--db/schema_migrations/202202021153501
-rw-r--r--db/structure.sql6
-rw-r--r--doc/administration/audit_events.md6
-rw-r--r--doc/ci/variables/index.md8
-rw-r--r--doc/user/asciidoc.md5
-rw-r--r--doc/user/crm/index.md26
-rw-r--r--doc/user/markdown.md1
-rw-r--r--doc/user/project/issue_board.md2
-rw-r--r--doc/user/project/issues/confidential_issues.md2
-rw-r--r--doc/user/project/issues/issue_weight.md6
-rw-r--r--doc/user/project/issues/managing_issues.md8
-rw-r--r--doc/user/project/issues/multiple_assignees_for_issues.md2
-rw-r--r--doc/user/project/issues/related_issues.md13
-rw-r--r--doc/user/project/issues/sorting_issue_lists.md2
-rw-r--r--doc/user/project/quick_actions.md4
-rw-r--r--doc/user/shortcuts.md2
-rw-r--r--lib/container_registry/base_client.rb4
-rw-r--r--lib/container_registry/gitlab_api_client.rb15
-rw-r--r--lib/container_registry/migration.rb2
-rw-r--r--lib/gitlab/project_authorizations.rb6
-rw-r--r--lib/gitlab/quick_actions/issue_actions.rb4
-rw-r--r--locale/gitlab.pot18
-rw-r--r--spec/factories/group_members.rb12
-rw-r--r--spec/factories/project_members.rb12
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb33
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/merge_request.json71
-rw-r--r--spec/frontend/pipeline_wizard/components/step_nav_spec.js79
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js5
-rw-r--r--spec/helpers/application_helper_spec.rb2
-rw-r--r--spec/lib/container_registry/gitlab_api_client_spec.rb22
-rw-r--r--spec/lib/gitlab/project_authorizations_spec.rb72
-rw-r--r--spec/models/container_repository_spec.rb78
-rw-r--r--spec/models/customer_relations/contact_spec.rb12
-rw-r--r--spec/services/issues/set_crm_contacts_service_spec.rb95
-rw-r--r--spec/workers/container_registry/migration/guard_worker_spec.rb115
60 files changed, 855 insertions, 177 deletions
diff --git a/.rubocop.yml b/.rubocop.yml
index 5757a273926..1b2e7ea470a 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -666,6 +666,7 @@ Gitlab/NamespacedClass:
- 'ee/elastic/**/*.rb'
- 'scripts/**/*'
- 'spec/migrations/**/*.rb'
+ - 'app/experiments/**/*_experiment.rb'
Lint/HashCompareByIdentity:
Enabled: true
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 69331ff1a06..d04896bf6e5 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -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>',
diff --git a/app/assets/javascripts/pipeline_wizard/components/step_nav.vue b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue
new file mode 100644
index 00000000000..8f9198855c6
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue
@@ -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>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 603ad71adb9..cbf38984e23 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -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,
);
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 3faf43ed9ff..281e21b3ab0 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -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
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index ec6762096ad..f6af7ca15bb 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -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
diff --git a/app/experiments/combined_registration_experiment.rb b/app/experiments/combined_registration_experiment.rb
index 08c015838db..576e10815aa 100644
--- a/app/experiments/combined_registration_experiment.rb
+++ b/app/experiments/combined_registration_experiment.rb
@@ -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)
diff --git a/app/experiments/empty_repo_upload_experiment.rb b/app/experiments/empty_repo_upload_experiment.rb
index d0d79a5fb45..c8c75f32d69 100644
--- a/app/experiments/empty_repo_upload_experiment.rb
+++ b/app/experiments/empty_repo_upload_experiment.rb
@@ -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')
diff --git a/app/experiments/force_company_trial_experiment.rb b/app/experiments/force_company_trial_experiment.rb
index 00bdd5d693d..e7b98bb18ad 100644
--- a/app/experiments/force_company_trial_experiment.rb
+++ b/app/experiments/force_company_trial_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ForceCompanyTrialExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class ForceCompanyTrialExperiment < ApplicationExperiment
exclude :setup_for_personal
private
diff --git a/app/experiments/in_product_guidance_environments_webide_experiment.rb b/app/experiments/in_product_guidance_environments_webide_experiment.rb
index d77063a9834..6567ec0b3f1 100644
--- a/app/experiments/in_product_guidance_environments_webide_experiment.rb
+++ b/app/experiments/in_product_guidance_environments_webide_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment
exclude :has_environments?
def control_behavior
diff --git a/app/experiments/new_project_sast_enabled_experiment.rb b/app/experiments/new_project_sast_enabled_experiment.rb
index a779b8ec633..ee9d0dc1700 100644
--- a/app/experiments/new_project_sast_enabled_experiment.rb
+++ b/app/experiments/new_project_sast_enabled_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class NewProjectSastEnabledExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class NewProjectSastEnabledExperiment < ApplicationExperiment
def publish(_result = nil)
super
diff --git a/app/experiments/require_verification_for_namespace_creation_experiment.rb b/app/experiments/require_verification_for_namespace_creation_experiment.rb
index 78390ddd099..0c47f5d183c 100644
--- a/app/experiments/require_verification_for_namespace_creation_experiment.rb
+++ b/app/experiments/require_verification_for_namespace_creation_experiment.rb
@@ -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)
diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
index fa0ba8e24d4..bcb9d64fcb7 100644
--- a/app/experiments/security_reports_mr_widget_prompt_experiment.rb
+++ b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment
def publish(_result = nil)
super
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 7acf2220516..e675c01bcbb 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -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
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index c1fb2726d03..ebc6537637d 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -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
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index f5043e95f5c..a981351f4a0 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index 313c1726429..74832bff9ac 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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
diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb
index 947d46f0809..ea279c97c03 100644
--- a/app/services/issues/set_crm_contacts_service.rb
+++ b/app/services/issues/set_crm_contacts_service.rb
@@ -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
diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml
index c81a3683e90..5385c6a4cc6 100644
--- a/app/views/projects/notes/_more_actions_dropdown.html.haml
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -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')
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index f39aee14a69..5b1a25396af 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -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
diff --git a/app/workers/container_registry/migration/guard_worker.rb b/app/workers/container_registry/migration/guard_worker.rb
new file mode 100644
index 00000000000..1237b6058e4
--- /dev/null
+++ b/app/workers/container_registry/migration/guard_worker.rb
@@ -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
diff --git a/config/feature_flags/development/contacts_autocomplete.yml b/config/feature_flags/development/contacts_autocomplete.yml
new file mode 100644
index 00000000000..9d6960f6713
--- /dev/null
+++ b/config/feature_flags/development/contacts_autocomplete.yml
@@ -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
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 8244f570a18..69248822658 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -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'
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 9c5294b6b2b..c24a72db454 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -47,6 +47,8 @@
- 1
- - audit_events_audit_event_streaming
- 1
+- - audit_events_user_impersonation_event_create
+ - 1
- - authorized_keys
- 2
- - authorized_project_update
diff --git a/db/migrate/20220202115350_add_migration_indexes_to_container_repositories.rb b/db/migrate/20220202115350_add_migration_indexes_to_container_repositories.rb
new file mode 100644
index 00000000000..673d066e3c0
--- /dev/null
+++ b/db/migrate/20220202115350_add_migration_indexes_to_container_repositories.rb
@@ -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
diff --git a/db/schema_migrations/20220202115350 b/db/schema_migrations/20220202115350
new file mode 100644
index 00000000000..9b8148ca2d1
--- /dev/null
+++ b/db/schema_migrations/20220202115350
@@ -0,0 +1 @@
+3bcc97592e8e329e39917deffae6619e69215930a688bebad2949f69155b53f9 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 80dec806173..84810642895 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -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);
diff --git a/doc/administration/audit_events.md b/doc/administration/audit_events.md
index 02dc1489294..d4902a18cac 100644
--- a/doc/administration/audit_events.md
+++ b/doc/administration/audit_events.md
@@ -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)
diff --git a/doc/ci/variables/index.md b/doc/ci/variables/index.md
index b8dea3af441..ba8451110eb 100644
--- a/doc/ci/variables/index.md
+++ b/doc/ci/variables/index.md
@@ -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).
diff --git a/doc/user/asciidoc.md b/doc/user/asciidoc.md
index da75c008ed1..80c85358fcb 100644
--- a/doc/user/asciidoc.md
+++ b/doc/user/asciidoc.md
@@ -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[]
diff --git a/doc/user/crm/index.md b/doc/user/crm/index.md
index f0f9a907a73..305cca33dd5 100644
--- a/doc/user/crm/index.md
+++ b/doc/user/crm/index.md
@@ -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:
+```
diff --git a/doc/user/markdown.md b/doc/user/markdown.md
index 3d640185a55..a16f8fd39b1 100644
--- a/doc/user/markdown.md
+++ b/doc/user/markdown.md
@@ -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.
diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md
index 23905133d9e..71440298d85 100644
--- a/doc/user/project/issue_board.md
+++ b/doc/user/project/issue_board.md
@@ -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.
diff --git a/doc/user/project/issues/confidential_issues.md b/doc/user/project/issues/confidential_issues.md
index 54fd122d742..15130523da6 100644
--- a/doc/user/project/issues/confidential_issues.md
+++ b/doc/user/project/issues/confidential_issues.md
@@ -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)
diff --git a/doc/user/project/issues/issue_weight.md b/doc/user/project/issues/issue_weight.md
index 8f17f399cb0..756fe9699f1 100644
--- a/doc/user/project/issues/issue_weight.md
+++ b/doc/user/project/issues/issue_weight.md
@@ -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}**).
diff --git a/doc/user/project/issues/managing_issues.md b/doc/user/project/issues/managing_issues.md
index 39b59d5e7eb..155d6260a5c 100644
--- a/doc/user/project/issues/managing_issues.md
+++ b/doc/user/project/issues/managing_issues.md
@@ -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.
diff --git a/doc/user/project/issues/multiple_assignees_for_issues.md b/doc/user/project/issues/multiple_assignees_for_issues.md
index 98e940b6b51..f957d701a3b 100644
--- a/doc/user/project/issues/multiple_assignees_for_issues.md
+++ b/doc/user/project/issues/multiple_assignees_for_issues.md
@@ -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
diff --git a/doc/user/project/issues/related_issues.md b/doc/user/project/issues/related_issues.md
index f2ec58a4a69..f83ebc5e8a8 100644
--- a/doc/user/project/issues/related_issues.md
+++ b/doc/user/project/issues/related_issues.md
@@ -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).
diff --git a/doc/user/project/issues/sorting_issue_lists.md b/doc/user/project/issues/sorting_issue_lists.md
index 0340f15c25c..329f65bfacd 100644
--- a/doc/user/project/issues/sorting_issue_lists.md
+++ b/doc/user/project/issues/sorting_issue_lists.md
@@ -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
diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md
index 918b089bfd2..8070c37db78 100644
--- a/doc/user/project/quick_actions.md
+++ b/doc/user/project/quick_actions.md
@@ -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. |
diff --git a/doc/user/shortcuts.md b/doc/user/shortcuts.md
index d6cbbf352fc..19ab53ca969 100644
--- a/doc/user/shortcuts.md
+++ b/doc/user/shortcuts.md
@@ -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):
diff --git a/lib/container_registry/base_client.rb b/lib/container_registry/base_client.rb
index 5438d7a6a8c..22d4510fe71 100644
--- a/lib/container_registry/base_client.rb
+++ b/lib/container_registry/base_client.rb
@@ -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'
diff --git a/lib/container_registry/gitlab_api_client.rb b/lib/container_registry/gitlab_api_client.rb
index 38929e5e904..13c509bd809 100644
--- a/lib/container_registry/gitlab_api_client.rb
+++ b/lib/container_registry/gitlab_api_client.rb
@@ -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
diff --git a/lib/container_registry/migration.rb b/lib/container_registry/migration.rb
index 5983c3b8b93..2d321e35e6b 100644
--- a/lib/container_registry/migration.rb
+++ b/lib/container_registry/migration.rb
@@ -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
diff --git a/lib/gitlab/project_authorizations.rb b/lib/gitlab/project_authorizations.rb
index 23e380b3cf1..121626ced56 100644
--- a/lib/gitlab/project_authorizations.rb
+++ b/lib/gitlab/project_authorizations.rb
@@ -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
diff --git a/lib/gitlab/quick_actions/issue_actions.rb b/lib/gitlab/quick_actions/issue_actions.rb
index 8334d57f2a5..b44b47eca37 100644
--- a/lib/gitlab/quick_actions/issue_actions.rb
+++ b/lib/gitlab/quick_actions/issue_actions.rb
@@ -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) &&
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 63fee5760d3..2a7bc43af62 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/spec/factories/group_members.rb b/spec/factories/group_members.rb
index ab2321c81c4..4b1bf9a7d11 100644
--- a/spec/factories/group_members.rb
+++ b/spec/factories/group_members.rb
@@ -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
diff --git a/spec/factories/project_members.rb b/spec/factories/project_members.rb
index f2dedc178c7..c38257b06b6 100644
--- a/spec/factories/project_members.rb
+++ b/spec/factories/project_members.rb
@@ -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
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index 4bff8d12204..b4d1b0aeab9 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -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
diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_request.json b/spec/fixtures/api/schemas/public_api/v4/merge_request.json
index 66f894e9c5c..1ef2f9f9534 100644
--- a/spec/fixtures/api/schemas/public_api/v4/merge_request.json
+++ b/spec/fixtures/api/schemas/public_api/v4/merge_request.json
@@ -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": {
diff --git a/spec/frontend/pipeline_wizard/components/step_nav_spec.js b/spec/frontend/pipeline_wizard/components/step_nav_spec.js
new file mode 100644
index 00000000000..c6eac1386fa
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/components/step_nav_spec.js
@@ -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);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index c31374f6d56..80f94d87588 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -63,6 +63,11 @@ describe('Markdown field component', () => {
textareaValue,
lines,
},
+ provide: {
+ glFeatures: {
+ contactsAutocomplete: true,
+ },
+ },
},
);
}
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 8c2b4b16075..e6a2e3f8211 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -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
diff --git a/spec/lib/container_registry/gitlab_api_client_spec.rb b/spec/lib/container_registry/gitlab_api_client_spec.rb
index 251e15390b1..ac6b17c9aac 100644
--- a/spec/lib/container_registry/gitlab_api_client_spec.rb
+++ b/spec/lib/container_registry/gitlab_api_client_spec.rb
@@ -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
diff --git a/spec/lib/gitlab/project_authorizations_spec.rb b/spec/lib/gitlab/project_authorizations_spec.rb
index 517a2e3e335..7852470196b 100644
--- a/spec/lib/gitlab/project_authorizations_spec.rb
+++ b/spec/lib/gitlab/project_authorizations_spec.rb
@@ -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
diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb
index 5dba13f5d25..2e2650eb512 100644
--- a/spec/models/container_repository_spec.rb
+++ b/spec/models/container_repository_spec.rb
@@ -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
diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb
index ed1e94fbbdd..c7b0f1bd3d4 100644
--- a/spec/models/customer_relations/contact_spec.rb
+++ b/spec/models/customer_relations/contact_spec.rb
@@ -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) }
diff --git a/spec/services/issues/set_crm_contacts_service_spec.rb b/spec/services/issues/set_crm_contacts_service_spec.rb
index 2418f317551..2fc6bec1163 100644
--- a/spec/services/issues/set_crm_contacts_service_spec.rb
+++ b/spec/services/issues/set_crm_contacts_service_spec.rb
@@ -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_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(: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_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
+
+ context 'with contact emails' do
+ let(:params) { { remove_emails: [contacts[1].email], add_emails: ["[\"contact:#{contacts[3].email}]"] } }
- expect(response).to be_success
- expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[3]])
+ it_behaves_like 'setting contacts'
end
end
diff --git a/spec/workers/container_registry/migration/guard_worker_spec.rb b/spec/workers/container_registry/migration/guard_worker_spec.rb
new file mode 100644
index 00000000000..480e8adbd5c
--- /dev/null
+++ b/spec/workers/container_registry/migration/guard_worker_spec.rb
@@ -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