Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-09-04 12:08:38 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-09-04 12:08:38 +0300
commit477c2c26047bc2d2da32b31eb8b26a6397675931 (patch)
treeac863e97c714d08c93267650ba60af613f5777ae
parent4be2167e71cf1b19a049fdced9356f311a364c7f (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--CHANGELOG.md20
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js133
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js31
-rw-r--r--app/controllers/admin/users_controller.rb7
-rw-r--r--app/controllers/profiles/notifications_controller.rb6
-rw-r--r--app/finders/user_group_notification_settings_finder.rb44
-rw-r--r--app/graphql/resolvers/concerns/looks_ahead.rb2
-rw-r--r--app/graphql/resolvers/issues_resolver.rb3
-rw-r--r--app/graphql/types/issue_type.rb3
-rw-r--r--app/helpers/emails_helper.rb26
-rw-r--r--app/mailers/devise_mailer.rb4
-rw-r--r--app/models/concerns/admin_changed_password_notifier.rb60
-rw-r--r--app/models/issue.rb3
-rw-r--r--app/models/user.rb7
-rw-r--r--app/services/auto_merge/base_service.rb15
-rw-r--r--app/services/merge_requests/create_pipeline_service.rb12
-rw-r--r--app/views/clusters/clusters/_banner.html.haml4
-rw-r--r--app/views/devise/mailer/password_change_by_admin.html.haml6
-rw-r--r--app/views/devise/mailer/password_change_by_admin.text.erb5
-rw-r--r--changelogs/unreleased/229266-mlunoe-analytics-filter-module-list-values-follow-up.yml6
-rw-r--r--changelogs/unreleased/235889-jira-importer-user-mapping-shows-50-users-max.yml5
-rw-r--r--changelogs/unreleased/243760-fix-link-reference-definitions.yml5
-rw-r--r--changelogs/unreleased/27284-indicate-that-password-reset-was-initiated-by-an-admin-when-an-admi.yml5
-rw-r--r--changelogs/unreleased/bump-ado-image-to-v1-0-2.yml5
-rw-r--r--changelogs/unreleased/cat-time-precision-2fa-ldap.yml5
-rw-r--r--changelogs/unreleased/fix-run-pipeline-in-target-project.yml6
-rw-r--r--changelogs/unreleased/fix_concurrent_backup.yml5
-rw-r--r--changelogs/unreleased/id-remove-memoize-on-processing-ref-changes.yml5
-rw-r--r--changelogs/unreleased/sh-fix-backup-restore-race.yml5
-rw-r--r--config/feature_flags/development/ci_disallow_to_create_merge_request_pipelines_in_target_project.yml7
-rw-r--r--config/locales/devise.en.yml2
-rw-r--r--doc/administration/index.md2
-rw-r--r--doc/administration/operations/puma.md2
-rw-r--r--doc/administration/raketasks/uploads/migrate.md19
-rw-r--r--doc/administration/uploads.md10
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql41
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json135
-rw-r--r--doc/api/graphql/reference/index.md9
-rw-r--r--doc/api/issues.md8
-rw-r--r--doc/api/repository_submodules.md4
-rw-r--r--doc/ci/quick_start/README.md17
-rw-r--r--doc/integration/elasticsearch.md322
-rw-r--r--doc/raketasks/backup_restore.md2
-rw-r--r--doc/security/README.md2
-rw-r--r--doc/security/reset_user_password.md (renamed from doc/security/reset_root_password.md)35
-rw-r--r--doc/user/profile/notifications.md3
-rw-r--r--doc/user/search/index.md9
-rw-r--r--lib/api/users.rb10
-rw-r--r--lib/backup/repository.rb28
-rw-r--r--lib/gitlab/ci/features.rb7
-rw-r--r--locale/gitlab.pot12
-rw-r--r--spec/controllers/admin/users_controller_spec.rb85
-rw-r--r--spec/controllers/profiles/notifications_controller_spec.rb7
-rw-r--r--spec/factories/issues.rb4
-rw-r--r--spec/features/issues/incident_issue_spec.rb11
-rw-r--r--spec/features/merge_request/user_sees_pipelines_spec.rb4
-rw-r--r--spec/finders/user_group_notification_settings_finder_spec.rb95
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js184
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js30
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js48
-rw-r--r--spec/helpers/emails_helper_spec.rb35
-rw-r--r--spec/lib/backup/repository_spec.rb30
-rw-r--r--spec/mailers/devise_mailer_spec.rb29
-rw-r--r--spec/models/issue_spec.rb5
-rw-r--r--spec/models/user_spec.rb90
-rw-r--r--spec/requests/api/graphql/project/issues_spec.rb61
-rw-r--r--spec/requests/api/users_spec.rb53
-rw-r--r--spec/requests/profiles/notifications_controller_spec.rb5
-rw-r--r--spec/services/merge_requests/create_pipeline_service_spec.rb25
-rw-r--r--spec/services/merge_requests/create_service_spec.rb1
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb4
73 files changed, 1636 insertions, 309 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5248609b586..eeff84d47ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,18 +2,6 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
-## 13.3.5 (2020-09-04)
-
-### Fixed (6 changes)
-
-- Coerce string object storage options to booleans. !39901
-- Fix Jira importer user mapping limit. !40310
-- Fix auto-deploy-image external chart dependencies. !40730
-- Fix ActiveRecord::IrreversibleOrderError during restore from backup. !40789
-- Fix wrong caching logic in ProcessRefChangesService. !40821
-- Update the 2FA user update check to account for rounding errors. !41327
-
-
## 13.3.4 (2020-09-02)
### Security (1 change)
@@ -601,14 +589,6 @@ entry.
- Replace fa-pencil icon with GitLab SVG. !39648
-## 13.2.9 (2020-09-04)
-
-### Fixed (2 changes)
-
-- Fix ActiveRecord::IrreversibleOrderError during restore from backup. !40789
-- Update the 2FA user update check to account for rounding errors. !41327
-
-
## 13.2.8 (2020-09-02)
### Security (1 change)
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 92517203972..a75646db162 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -237,7 +237,7 @@ export default class Clusters {
}
addBannerCloseHandler(el, status) {
- el.querySelector('.js-close-banner').addEventListener('click', () => {
+ el.querySelector('.js-close').addEventListener('click', () => {
el.classList.add('hidden');
this.setBannerDismissedState(status, true);
});
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
index a981c67e7be..e7d7b7d9f1b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
@@ -1,3 +1,6 @@
+import { isEmpty } from 'lodash';
+import { queryToObject } from '~/lib/utils/url_utility';
+
/**
* Strips enclosing quotations from a string if it has one.
*
@@ -29,3 +32,133 @@ export const uniqueTokens = tokens => {
return uniques;
}, []);
};
+
+/**
+ * Creates a token from a type and a filter. Example returned object
+ * { type: 'myType', value: { data: 'myData', operator: '= '} }
+ * @param {String} type the name of the filter
+ * @param {Object}
+ * @param {Object.value} filter value to be returned as token data
+ * @param {Object.operator} filter operator to be retuned as token operator
+ * @return {Object}
+ * @return {Object.type} token type
+ * @return {Object.value} token value
+ */
+function createToken(type, filter) {
+ return { type, value: { data: filter.value, operator: filter.operator } };
+}
+
+/**
+ * This function takes a filter object and translates it into a token array
+ * @param {Object} filters
+ * @param {Object.myFilterName} a single filter value or an array of filters
+ * @return {Array} tokens an array of tokens created from filter values
+ */
+export function prepareTokens(filters = {}) {
+ return Object.keys(filters).reduce((memo, key) => {
+ const value = filters[key];
+ if (!value) {
+ return memo;
+ }
+ if (Array.isArray(value)) {
+ return [...memo, ...value.map(filterValue => createToken(key, filterValue))];
+ }
+
+ return [...memo, createToken(key, value)];
+ }, []);
+}
+
+export function processFilters(filters) {
+ return filters.reduce((acc, token) => {
+ const { type, value } = token;
+ const { operator } = value;
+ const tokenValue = value.data;
+
+ if (!acc[type]) {
+ acc[type] = [];
+ }
+
+ acc[type].push({ value: tokenValue, operator });
+ return acc;
+ }, {});
+}
+
+/**
+ * This function takes a filter object and maps it into a query object. Example filter:
+ * { myFilterName: { value: 'foo', operator: '=' } }
+ * gets translated into:
+ * { myFilterName: 'foo', 'not[myFilterName]': null }
+ * @param {Object} filters
+ * @param {Object.myFilterName} a single filter value or an array of filters
+ * @return {Object} query object with both filter name and not-name with values
+ */
+export function filterToQueryObject(filters = {}) {
+ return Object.keys(filters).reduce((memo, key) => {
+ const filter = filters[key];
+
+ let selected;
+ let unselected;
+ if (Array.isArray(filter)) {
+ selected = filter.filter(item => item.operator === '=').map(item => item.value);
+ unselected = filter.filter(item => item.operator === '!=').map(item => item.value);
+ } else {
+ selected = filter?.operator === '=' ? filter.value : null;
+ unselected = filter?.operator === '!=' ? filter.value : null;
+ }
+
+ if (isEmpty(selected)) {
+ selected = null;
+ }
+ if (isEmpty(unselected)) {
+ unselected = null;
+ }
+
+ return { ...memo, [key]: selected, [`not[${key}]`]: unselected };
+ }, {});
+}
+
+/**
+ * Extracts filter name from url name, e.g. `not[my_filter]` => `my_filter`
+ * and returns the operator with it depending on the filter name
+ * @param {String} filterName from url
+ * @return {Object}
+ * @return {Object.filterName} extracted filtern ame
+ * @return {Object.operator} `=` or `!=`
+ */
+function extractNameAndOperator(filterName) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (filterName.startsWith('not[') && filterName.endsWith(']')) {
+ return { filterName: filterName.slice(4, -1), operator: '!=' };
+ }
+
+ return { filterName, operator: '=' };
+}
+
+/**
+ * This function takes a URL query string and maps it into a filter object. Example query string:
+ * '?myFilterName=foo'
+ * gets translated into:
+ * { myFilterName: { value: 'foo', operator: '=' } }
+ * @param {String} query URL quert string, e.g. from `window.location.search`
+ * @return {Object} filter object with filter names and their values
+ */
+export function urlQueryToFilter(query = '') {
+ const filters = queryToObject(query, { gatherArrays: true });
+ return Object.keys(filters).reduce((memo, key) => {
+ const value = filters[key];
+ if (!value) {
+ return memo;
+ }
+ const { filterName, operator } = extractNameAndOperator(key);
+ let previousValues = [];
+ if (Array.isArray(memo[filterName])) {
+ previousValues = memo[filterName];
+ }
+ if (Array.isArray(value)) {
+ const newAdditions = value.filter(Boolean).map(item => ({ value: item, operator }));
+ return { ...memo, [filterName]: [...previousValues, ...newAdditions] };
+ }
+
+ return { ...memo, [filterName]: { value, operator } };
+ }, {});
+}
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
index 74c7a3853bf..a214151bae9 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
@@ -29,6 +29,7 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
const emphasisNode = 'EM, I';
const strongNode = 'STRONG, B';
const headingNode = 'H1, H2, H3, H4, H5, H6';
+ const preCodeNode = 'PRE CODE';
return {
TEXT_NODE(node) {
@@ -91,6 +92,13 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
return attributeDefinition ? `${result.trimRight()}\n${attributeDefinition}\n\n` : result;
},
+ [preCodeNode](node, subContent) {
+ const isReferenceDefinition = Boolean(node.dataset.sseReferenceDefinition);
+
+ return isReferenceDefinition
+ ? `\n\n${node.innerText}\n`
+ : baseRenderer.convert(node, subContent);
+ },
};
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
index 4ec45ecd3a7..3f9c6291d1b 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
@@ -1,5 +1,3 @@
-import { renderUneditableBranch as render } from './render_utils';
-
const identifierRegex = /(^\[.+\]: .+)/;
const isIdentifier = text => {
@@ -10,4 +8,33 @@ const canRender = (node, context) => {
return isIdentifier(context.getChildrenText(node));
};
+const getReferenceDefinitions = (node, definitions = '') => {
+ if (!node) {
+ return definitions;
+ }
+
+ const definition = node.type === 'text' ? node.literal : '\n';
+
+ return getReferenceDefinitions(node.next, `${definitions}${definition}`);
+};
+
+const render = (node, { skipChildren }) => {
+ const content = getReferenceDefinitions(node.firstChild);
+
+ skipChildren();
+
+ return [
+ {
+ type: 'openTag',
+ tagName: 'pre',
+ classNames: ['code-block', 'language-markdown'],
+ attributes: { 'data-sse-reference-definition': true },
+ },
+ { type: 'openTag', tagName: 'code' },
+ { type: 'text', content },
+ { type: 'closeTag', tagName: 'code' },
+ { type: 'closeTag', tagName: 'pre' },
+ ];
+};
+
export default { canRender, render };
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index c3ea7f28530..050f83edacb 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -149,7 +149,7 @@ class Admin::UsersController < Admin::ApplicationController
password_confirmation: params[:user][:password_confirmation]
}
- password_params[:password_expires_at] = Time.current unless changing_own_password?
+ password_params[:password_expires_at] = Time.current if admin_making_changes_for_another_user?
user_params_with_pass.merge!(password_params)
end
@@ -157,6 +157,7 @@ class Admin::UsersController < Admin::ApplicationController
respond_to do |format|
result = Users::UpdateService.new(current_user, user_params_with_pass.merge(user: user)).execute do |user|
user.skip_reconfirmation!
+ user.send_only_admin_changed_your_password_notification! if admin_making_changes_for_another_user?
end
if result[:status] == :success
@@ -197,8 +198,8 @@ class Admin::UsersController < Admin::ApplicationController
protected
- def changing_own_password?
- user == current_user
+ def admin_making_changes_for_another_user?
+ user != current_user
end
def user
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index 5a55e0e9e4e..bc51830c119 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -1,13 +1,11 @@
# frozen_string_literal: true
class Profiles::NotificationsController < Profiles::ApplicationController
- NOTIFICATIONS_PER_PAGE = 10
-
# rubocop: disable CodeReuse/ActiveRecord
def show
@user = current_user
@user_groups = user_groups
- @group_notifications = user_groups.map { |group| current_user.notification_settings_for(group, inherit: true) }
+ @group_notifications = UserGroupNotificationSettingsFinder.new(current_user, user_groups).execute
@project_notifications = current_user.notification_settings.for_projects.order(:id)
.preload_source_route
@@ -35,6 +33,6 @@ class Profiles::NotificationsController < Profiles::ApplicationController
private
def user_groups
- GroupsFinder.new(current_user, all_available: false).execute.order_name_asc.page(params[:page]).per(NOTIFICATIONS_PER_PAGE)
+ GroupsFinder.new(current_user, all_available: false).execute.order_name_asc.page(params[:page])
end
end
diff --git a/app/finders/user_group_notification_settings_finder.rb b/app/finders/user_group_notification_settings_finder.rb
new file mode 100644
index 00000000000..a29cf409692
--- /dev/null
+++ b/app/finders/user_group_notification_settings_finder.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class UserGroupNotificationSettingsFinder
+ def initialize(user, groups)
+ @user = user
+ @groups = groups
+ end
+
+ def execute
+ groups_with_ancestors = Gitlab::ObjectHierarchy.new(groups).base_and_ancestors
+
+ @loaded_groups_with_ancestors = groups_with_ancestors.index_by(&:id)
+ @loaded_notification_settings = user.notification_settings_for_groups(groups_with_ancestors).preload_source_route.index_by(&:source_id)
+
+ groups.map do |group|
+ find_notification_setting_for(group)
+ end
+ end
+
+ private
+
+ attr_reader :user, :groups, :loaded_groups_with_ancestors, :loaded_notification_settings
+
+ def find_notification_setting_for(group)
+ return loaded_notification_settings[group.id] if loaded_notification_settings[group.id]
+ return user.notification_settings.build(source: group) if group.parent_id.nil?
+
+ parent_setting = loaded_notification_settings[group.parent_id]
+
+ if should_copy?(parent_setting)
+ user.notification_settings.build(source: group) do |ns|
+ ns.assign_attributes(parent_setting.slice(*NotificationSetting.allowed_fields))
+ end
+ else
+ find_notification_setting_for(loaded_groups_with_ancestors[group.parent_id])
+ end
+ end
+
+ def should_copy?(parent_setting)
+ return false unless parent_setting
+
+ parent_setting.level != NotificationSetting.levels[:global] || parent_setting.notification_email.present?
+ end
+end
diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb
index becc6debd33..e7230287e13 100644
--- a/app/graphql/resolvers/concerns/looks_ahead.rb
+++ b/app/graphql/resolvers/concerns/looks_ahead.rb
@@ -46,7 +46,7 @@ module LooksAhead
if lookahead.selects?(:nodes)
lookahead.selection(:nodes)
elsif lookahead.selects?(:edges)
- lookahead.selection(:edges).selection(:nodes)
+ lookahead.selection(:edges).selection(:node)
end
end
end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 923d22e1be5..54542cdace6 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -34,7 +34,8 @@ module Resolvers
def preloads
{
- alert_management_alert: [:alert_management_alert]
+ alert_management_alert: [:alert_management_alert],
+ labels: [:labels]
}
end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 6df21056367..e49cb3b3932 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -42,8 +42,7 @@ module Types
field :assignees, Types::UserType.connection_type, null: true, complexity: 5,
description: 'Assignees of the issue'
- # Remove complexity when BatchLoader is used
- field :labels, Types::LabelType.connection_type, null: true, complexity: 5,
+ field :labels, Types::LabelType.connection_type, null: true,
description: 'Labels of the issue'
field :milestone, Types::MilestoneType, null: true,
description: 'Milestone of the issue',
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index 9a44b66002a..d5c22927991 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -181,6 +181,10 @@ module EmailsHelper
_('Hi %{username}!') % { username: sanitize_name(user.name) }
end
+ def say_hello(user)
+ _('Hello, %{username}!') % { username: sanitize_name(user.name) }
+ end
+
def two_factor_authentication_disabled_text
_('Two-factor authentication has been disabled for your GitLab account.')
end
@@ -190,7 +194,7 @@ module EmailsHelper
case format
when :html
- settings_link_to = link_to(_('two-factor authentication settings'), url, target: :_blank, rel: 'noopener noreferrer').html_safe
+ settings_link_to = generate_link(_('two-factor authentication settings'), url).html_safe
_("If you want to re-enable two-factor authentication, visit the %{settings_link_to} page.").html_safe % { settings_link_to: settings_link_to }
else
_('If you want to re-enable two-factor authentication, visit %{two_factor_link}') %
@@ -198,8 +202,28 @@ module EmailsHelper
end
end
+ def admin_changed_password_text(format: nil)
+ url = Gitlab.config.gitlab.url
+
+ case format
+ when :html
+ link_to = generate_link(url, url).html_safe
+ _('An administrator changed the password for your GitLab account on %{link_to}.').html_safe % { link_to: link_to }
+ else
+ _('An administrator changed the password for your GitLab account on %{link_to}.') % { link_to: url }
+ end
+ end
+
+ def contact_your_administrator_text
+ _('Please contact your administrator with any questions.')
+ end
+
private
+ def generate_link(text, url)
+ link_to(text, url, target: :_blank, rel: 'noopener noreferrer')
+ end
+
def show_footer?
email_header_and_footer_enabled? && current_appearance&.show_footer?
end
diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb
index cbaf53fced1..a02670aed90 100644
--- a/app/mailers/devise_mailer.rb
+++ b/app/mailers/devise_mailer.rb
@@ -9,6 +9,10 @@ class DeviseMailer < Devise::Mailer
helper EmailsHelper
helper ApplicationHelper
+ def password_change_by_admin(record, opts = {})
+ devise_mail(record, :password_change_by_admin, opts)
+ end
+
protected
def subject_for(key)
diff --git a/app/models/concerns/admin_changed_password_notifier.rb b/app/models/concerns/admin_changed_password_notifier.rb
new file mode 100644
index 00000000000..f6c2abc7e0f
--- /dev/null
+++ b/app/models/concerns/admin_changed_password_notifier.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module AdminChangedPasswordNotifier
+ # This module is responsible for triggering the `Password changed by administrator` emails
+ # when a GitLab administrator changes the password of another user.
+
+ # Usage
+ # These emails are disabled by default and are never trigerred after updating the password, unless
+ # explicitly specified.
+
+ # To explicitly trigger this email, the `send_only_admin_changed_your_password_notification!`
+ # method should be called, so like:
+
+ # user = User.find_by(email: 'hello@example.com')
+ # user.send_only_admin_changed_your_password_notification!
+ # user.password = user.password_confirmation = 'new_password'
+ # user.save!
+
+ # The `send_only_admin_changed_your_password_notification` has 2 responsibilities.
+ # It prevents triggering Devise's default `Password changed` email.
+ # It trigggers the `Password changed by administrator` email.
+
+ # It is important to skip sending the default Devise email when sending out `Password changed by administrator`
+ # email because we should not be sending 2 emails for the same event,
+ # hence the only public API made available from this module is `send_only_admin_changed_your_password_notification!`
+
+ # There is no public API made available to send the `Password changed by administrator` email,
+ # *without* skipping the default `Password changed` email, to prevent the problem mentioned above.
+
+ extend ActiveSupport::Concern
+
+ included do
+ after_update :send_admin_changed_your_password_notification, if: :send_admin_changed_your_password_notification?
+ end
+
+ def initialize(*args, &block)
+ @allow_admin_changed_your_password_notification = false # These emails are off by default
+ super
+ end
+
+ def send_only_admin_changed_your_password_notification!
+ skip_password_change_notification! # skip sending the default Devise 'password changed' notification
+ allow_admin_changed_your_password_notification!
+ end
+
+ private
+
+ def send_admin_changed_your_password_notification
+ send_devise_notification(:password_change_by_admin)
+ end
+
+ def allow_admin_changed_your_password_notification!
+ @allow_admin_changed_your_password_notification = true # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def send_admin_changed_your_password_notification?
+ self.class.send_password_change_notification && saved_change_to_encrypted_password? &&
+ @allow_admin_changed_your_password_notification # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index f4e1e5a6612..93fc3325b38 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -73,7 +73,8 @@ class Issue < ApplicationRecord
enum issue_type: {
issue: 0,
- incident: 1
+ incident: 1,
+ test_case: 2 ## EE-only
}
alias_attribute :parent_ids, :project_id
diff --git a/app/models/user.rb b/app/models/user.rb
index 7574f0865a0..061d958ea72 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -58,6 +58,8 @@ class User < ApplicationRecord
devise :lockable, :recoverable, :rememberable, :trackable,
:validatable, :omniauthable, :confirmable, :registerable
+ include AdminChangedPasswordNotifier
+
# This module adds async behaviour to Devise emails
# and should be added after Devise modules are initialized.
include AsyncDeviseEmail
@@ -1461,6 +1463,11 @@ class User < ApplicationRecord
end
end
+ def notification_settings_for_groups(groups)
+ ids = groups.is_a?(ActiveRecord::Relation) ? groups.select(:id) : groups.map(&:id)
+ notification_settings.for_groups.where(source_id: ids)
+ end
+
# Lazy load global notification setting
# Initializes User setting with Participating level if setting not persisted
def global_notification_setting
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
index 5c63dc34cb1..41236286d23 100644
--- a/app/services/auto_merge/base_service.rb
+++ b/app/services/auto_merge/base_service.rb
@@ -60,6 +60,21 @@ module AutoMerge
end
end
+ ##
+ # NOTE: This method is to be removed when `disallow_to_create_merge_request_pipelines_in_target_project`
+ # feature flag is removed.
+ def self.can_add_to_merge_train?(merge_request)
+ if Gitlab::Ci::Features.disallow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project)
+ merge_request.for_same_project?
+ else
+ true
+ end
+ end
+
+ def can_add_to_merge_train?(merge_request)
+ self.class.can_add_to_merge_train?(merge_request)
+ end
+
private
# Overridden in child classes
diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb
index f9352f10fea..46c4c102091 100644
--- a/app/services/merge_requests/create_pipeline_service.rb
+++ b/app/services/merge_requests/create_pipeline_service.rb
@@ -48,12 +48,18 @@ module MergeRequests
end
def can_create_pipeline_in_target_project?(merge_request)
- if Gitlab::Ci::Features.allow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project)
- can?(current_user, :create_pipeline, merge_request.target_project)
- else
+ if Gitlab::Ci::Features.disallow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project)
merge_request.for_same_project?
+ else
+ can?(current_user, :create_pipeline, merge_request.target_project) &&
+ can_update_source_branch_in_target_project?(merge_request)
end
end
+
+ def can_update_source_branch_in_target_project?(merge_request)
+ ::Gitlab::UserAccess.new(current_user, container: merge_request.target_project)
+ .can_update_branch?(merge_request.source_branch_ref)
+ end
end
end
diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml
index 3461831eda2..4a84745cf98 100644
--- a/app/views/clusters/clusters/_banner.html.haml
+++ b/app/views/clusters/clusters/_banner.html.haml
@@ -8,14 +8,14 @@
.hidden.row.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' }
= sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
= sprite_icon('close', css_class: 'gl-icon')
.gl-alert-body
= s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.')
.hidden.js-cluster-authentication-failure.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' }
= sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
= sprite_icon('close', css_class: 'gl-icon')
.gl-alert-body
= s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.')
diff --git a/app/views/devise/mailer/password_change_by_admin.html.haml b/app/views/devise/mailer/password_change_by_admin.html.haml
new file mode 100644
index 00000000000..d72f36838c5
--- /dev/null
+++ b/app/views/devise/mailer/password_change_by_admin.html.haml
@@ -0,0 +1,6 @@
+= email_default_heading(say_hello(@resource))
+
+%p
+ = admin_changed_password_text(format: :html)
+%p
+ = contact_your_administrator_text
diff --git a/app/views/devise/mailer/password_change_by_admin.text.erb b/app/views/devise/mailer/password_change_by_admin.text.erb
new file mode 100644
index 00000000000..d0e7ab98641
--- /dev/null
+++ b/app/views/devise/mailer/password_change_by_admin.text.erb
@@ -0,0 +1,5 @@
+<%= say_hello(@resource) %>
+
+<%= admin_changed_password_text %>
+
+<%= contact_your_administrator_text %>
diff --git a/changelogs/unreleased/229266-mlunoe-analytics-filter-module-list-values-follow-up.yml b/changelogs/unreleased/229266-mlunoe-analytics-filter-module-list-values-follow-up.yml
new file mode 100644
index 00000000000..312bcc5fea3
--- /dev/null
+++ b/changelogs/unreleased/229266-mlunoe-analytics-filter-module-list-values-follow-up.yml
@@ -0,0 +1,6 @@
+---
+title: Fixed an issue where not all URL query parameters would apply to the filter
+ bar on initial load in the Value Stream Analytics page
+merge_request: 40975
+author:
+type: fixed
diff --git a/changelogs/unreleased/235889-jira-importer-user-mapping-shows-50-users-max.yml b/changelogs/unreleased/235889-jira-importer-user-mapping-shows-50-users-max.yml
new file mode 100644
index 00000000000..1fcf6c768e7
--- /dev/null
+++ b/changelogs/unreleased/235889-jira-importer-user-mapping-shows-50-users-max.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Jira importer user mapping limit
+merge_request: 40310
+author:
+type: fixed
diff --git a/changelogs/unreleased/243760-fix-link-reference-definitions.yml b/changelogs/unreleased/243760-fix-link-reference-definitions.yml
new file mode 100644
index 00000000000..b20d2f10f07
--- /dev/null
+++ b/changelogs/unreleased/243760-fix-link-reference-definitions.yml
@@ -0,0 +1,5 @@
+---
+title: Render reference definitions as code blocks
+merge_request: 41186
+author:
+type: fixed
diff --git a/changelogs/unreleased/27284-indicate-that-password-reset-was-initiated-by-an-admin-when-an-admi.yml b/changelogs/unreleased/27284-indicate-that-password-reset-was-initiated-by-an-admin-when-an-admi.yml
new file mode 100644
index 00000000000..99bbf8c8427
--- /dev/null
+++ b/changelogs/unreleased/27284-indicate-that-password-reset-was-initiated-by-an-admin-when-an-admi.yml
@@ -0,0 +1,5 @@
+---
+title: Password changed emails must specify that password was changed by admin
+merge_request: 40342
+author:
+type: added
diff --git a/changelogs/unreleased/bump-ado-image-to-v1-0-2.yml b/changelogs/unreleased/bump-ado-image-to-v1-0-2.yml
new file mode 100644
index 00000000000..d24e9a1e6c3
--- /dev/null
+++ b/changelogs/unreleased/bump-ado-image-to-v1-0-2.yml
@@ -0,0 +1,5 @@
+---
+title: Fix auto-deploy-image external chart dependencies
+merge_request: 40730
+author:
+type: fixed
diff --git a/changelogs/unreleased/cat-time-precision-2fa-ldap.yml b/changelogs/unreleased/cat-time-precision-2fa-ldap.yml
new file mode 100644
index 00000000000..dc2cdaa8632
--- /dev/null
+++ b/changelogs/unreleased/cat-time-precision-2fa-ldap.yml
@@ -0,0 +1,5 @@
+---
+title: Update the 2FA user update check to account for rounding errors
+merge_request: 41327
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-run-pipeline-in-target-project.yml b/changelogs/unreleased/fix-run-pipeline-in-target-project.yml
new file mode 100644
index 00000000000..6d402b8050b
--- /dev/null
+++ b/changelogs/unreleased/fix-run-pipeline-in-target-project.yml
@@ -0,0 +1,6 @@
+---
+title: Fix fork users cannot create pipelines in a fork project when parent project
+ protects all branches
+merge_request: 40724
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix_concurrent_backup.yml b/changelogs/unreleased/fix_concurrent_backup.yml
new file mode 100644
index 00000000000..b71219d6919
--- /dev/null
+++ b/changelogs/unreleased/fix_concurrent_backup.yml
@@ -0,0 +1,5 @@
+---
+title: Fix deadlock in backup repositories rake task
+merge_request: 41042
+author:
+type: fixed
diff --git a/changelogs/unreleased/id-remove-memoize-on-processing-ref-changes.yml b/changelogs/unreleased/id-remove-memoize-on-processing-ref-changes.yml
new file mode 100644
index 00000000000..2c52d5f3f61
--- /dev/null
+++ b/changelogs/unreleased/id-remove-memoize-on-processing-ref-changes.yml
@@ -0,0 +1,5 @@
+---
+title: Fix wrong caching logic in ProcessRefChangesService
+merge_request: 40821
+author:
+type: fixed
diff --git a/changelogs/unreleased/sh-fix-backup-restore-race.yml b/changelogs/unreleased/sh-fix-backup-restore-race.yml
new file mode 100644
index 00000000000..ab5d4d8fcb0
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-backup-restore-race.yml
@@ -0,0 +1,5 @@
+---
+title: Fix ActiveRecord::IrreversibleOrderError during restore from backup
+merge_request: 40789
+author:
+type: fixed
diff --git a/config/feature_flags/development/ci_disallow_to_create_merge_request_pipelines_in_target_project.yml b/config/feature_flags/development/ci_disallow_to_create_merge_request_pipelines_in_target_project.yml
new file mode 100644
index 00000000000..81a0d014b12
--- /dev/null
+++ b/config/feature_flags/development/ci_disallow_to_create_merge_request_pipelines_in_target_project.yml
@@ -0,0 +1,7 @@
+---
+name: ci_disallow_to_create_merge_request_pipelines_in_target_project
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40724
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/235119
+group: group::progressive delivery
+type: development
+default_enabled: false
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml
index e8110e21766..e4a46be9bf3 100644
--- a/config/locales/devise.en.yml
+++ b/config/locales/devise.en.yml
@@ -25,6 +25,8 @@ en:
subject: "Unlock instructions"
password_change:
subject: "Password Changed"
+ password_change_by_admin:
+ subject: "Password changed by administrator"
omniauth_callbacks:
failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
success: "Successfully authenticated from %{kind} account."
diff --git a/doc/administration/index.md b/doc/administration/index.md
index e60be2a2c3b..c46564506f5 100644
--- a/doc/administration/index.md
+++ b/doc/administration/index.md
@@ -231,6 +231,6 @@ who are aware of the risks.
- [GitLab Developer Docs](../development/README.md)
- [Repairing and recovering broken Git repositories](https://git.seveas.net/repairing-and-recovering-broken-git-repositories.html)
- [Testing with OpenSSL](https://www.feistyduck.com/library/openssl-cookbook/online/ch-testing-with-openssl.html)
- - [`Strace` zine](https://wizardzines.com/zines/strace/)
+ - [`strace` zine](https://wizardzines.com/zines/strace/)
- GitLab.com-specific resources:
- [Group SAML/SCIM setup](troubleshooting/group_saml_scim.md)
diff --git a/doc/administration/operations/puma.md b/doc/administration/operations/puma.md
index e7b4bb88faf..782b3cd6758 100644
--- a/doc/administration/operations/puma.md
+++ b/doc/administration/operations/puma.md
@@ -1,6 +1,6 @@
# Switching to Puma
-As of GitLab 12.9, [Puma](https://github.com/puma/puma) has replaced [Unicorn](https://yhbt.net/unicorn/).
+As of GitLab 12.9, [Puma](https://github.com/puma/puma) has replaced [Unicorn](https://yhbt.net/unicorn/)
as the default web server. From GitLab 13.0, the following run Puma instead of Unicorn unless
explicitly configured not to:
diff --git a/doc/administration/raketasks/uploads/migrate.md b/doc/administration/raketasks/uploads/migrate.md
index 8c020e91a15..8427575ea4b 100644
--- a/doc/administration/raketasks/uploads/migrate.md
+++ b/doc/administration/raketasks/uploads/migrate.md
@@ -1,6 +1,9 @@
# Uploads migrate Rake tasks **(CORE ONLY)**
-`gitlab:uploads:migrate` migrates uploads between different storage types.
+There is a Rake task for migrating uploads between different storage types.
+
+- Migrate all uploads with [`gitlab:uploads:migrate:all`](#all-in-one-rake-task) or
+- To only migrate specific upload types, use [`gitlab:uploads:migrate`](#individual-rake-tasks).
## Migrate to object storage
@@ -166,3 +169,17 @@ To migrate uploads from object storage to local storage:
After running the Rake task, you can disable object storage by undoing the changes described
in the instructions to [configure object storage](../../uploads.md#using-object-storage-core-only).
+
+## Troubleshooting
+
+### undefined method constantize
+
+You will see the following error if you run `gitlab:uploads:migrate` without parameters.
+
+```plaintext
+rake aborted!
+NoMethodError: undefined method `constantize' for nil:NilClass
+```
+
+- If you intend to migrate all uploads, use the all-in-one Rake task [`gitlab:uploads:migrate:all`](#all-in-one-rake-task).
+- To migrate specific uploads, use [`gitlab:uploads:migrate`](#individual-rake-tasks) and supply the necessary parameters.
diff --git a/doc/administration/uploads.md b/doc/administration/uploads.md
index d9902208e93..71a41719003 100644
--- a/doc/administration/uploads.md
+++ b/doc/administration/uploads.md
@@ -1,4 +1,4 @@
-# Uploads administration
+# Uploads administration **(CORE ONLY)**
Uploads represent all user data that may be sent to GitLab as a single file. As an example, avatars and notes' attachments are uploads. Uploads are integral to GitLab functionality, and therefore cannot be disabled.
@@ -108,7 +108,7 @@ _The uploads are stored by default in
```
1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
-1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate` Rake task](raketasks/uploads/migrate.md).
+1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md).
**In installations from source:**
@@ -131,7 +131,7 @@ _The uploads are stored by default in
```
1. Save the file and [restart GitLab](restart_gitlab.md#installations-from-source) for the changes to take effect.
-1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate` Rake task](raketasks/uploads/migrate.md).
+1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md).
### OpenStack example
@@ -157,7 +157,7 @@ _The uploads are stored by default in
```
1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
-1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate` Rake task](raketasks/uploads/migrate.md).
+1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md).
---
@@ -188,4 +188,4 @@ _The uploads are stored by default in
```
1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
-1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate` Rake task](raketasks/uploads/migrate.md).
+1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md).
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index c1bc231a1e7..d8072955820 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -3062,6 +3062,41 @@ type DastScannerProfileCreatePayload {
}
"""
+Autogenerated input type of DastScannerProfileDelete
+"""
+input DastScannerProfileDeleteInput {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Full path for the project the scanner profile belongs to.
+ """
+ fullPath: ID!
+
+ """
+ ID of the scanner profile to be deleted.
+ """
+ id: DastScannerProfileID!
+}
+
+"""
+Autogenerated return type of DastScannerProfileDelete
+"""
+type DastScannerProfileDeletePayload {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Errors encountered during execution of the mutation.
+ """
+ errors: [String!]!
+}
+
+"""
An edge in a connection.
"""
type DastScannerProfileEdge {
@@ -8122,6 +8157,11 @@ enum IssueType {
Issue issue type
"""
ISSUE
+
+ """
+ Test Case issue type
+ """
+ TEST_CASE
}
"""
@@ -10081,6 +10121,7 @@ type Mutation {
createSnippet(input: CreateSnippetInput!): CreateSnippetPayload
dastOnDemandScanCreate(input: DastOnDemandScanCreateInput!): DastOnDemandScanCreatePayload
dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload
+ dastScannerProfileDelete(input: DastScannerProfileDeleteInput!): DastScannerProfileDeletePayload
dastScannerProfileUpdate(input: DastScannerProfileUpdateInput!): DastScannerProfileUpdatePayload
dastSiteProfileCreate(input: DastSiteProfileCreateInput!): DastSiteProfileCreatePayload
dastSiteProfileDelete(input: DastSiteProfileDeleteInput!): DastSiteProfileDeletePayload
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index eb8cad5c44c..10dfa4773d2 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -8307,6 +8307,108 @@
"possibleTypes": null
},
{
+ "kind": "INPUT_OBJECT",
+ "name": "DastScannerProfileDeleteInput",
+ "description": "Autogenerated input type of DastScannerProfileDelete",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "fullPath",
+ "description": "Full path for the project the scanner profile belongs to.",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "id",
+ "description": "ID of the scanner profile to be deleted.",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "DastScannerProfileID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "DastScannerProfileDeletePayload",
+ "description": "Autogenerated return type of DastScannerProfileDelete",
+ "fields": [
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "errors",
+ "description": "Errors encountered during execution of the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "OBJECT",
"name": "DastScannerProfileEdge",
"description": "An edge in a connection.",
@@ -22433,6 +22535,12 @@
"description": "Incident issue type",
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "TEST_CASE",
+ "description": "Test Case issue type",
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"possibleTypes": null
@@ -28874,6 +28982,33 @@
"deprecationReason": null
},
{
+ "name": "dastScannerProfileDelete",
+ "description": null,
+ "args": [
+ {
+ "name": "input",
+ "description": null,
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "DastScannerProfileDeleteInput",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "DastScannerProfileDeletePayload",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "dastScannerProfileUpdate",
"description": null,
"args": [
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index bceaf4bde4f..02357a69537 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -532,6 +532,15 @@ Autogenerated return type of DastScannerProfileCreate
| `globalId` | DastScannerProfileID | ID of the scanner profile. |
| `id` **{warning-solid}** | ID | **Deprecated:** Use `global_id`. Deprecated in 13.4 |
+## DastScannerProfileDeletePayload
+
+Autogenerated return type of DastScannerProfileDelete
+
+| Name | Type | Description |
+| --- | ---- | ---------- |
+| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
+| `errors` | String! => Array | Errors encountered during execution of the mutation. |
+
## DastScannerProfileUpdatePayload
Autogenerated return type of DastScannerProfileUpdate
diff --git a/doc/api/issues.md b/doc/api/issues.md
index b762698bd5b..2a0d66a8b3e 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -2079,10 +2079,10 @@ The preferred way to do this, is by using [personal access tokens](../user/profi
GET /projects/:id/issues/:issue_iid/closed_by
```
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | integer | yes | The ID of a project |
-| `issue_iid` | integer | yes | The internal ID of a project issue |
+| Attribute | Type | Required | Description |
+| ----------- | ---------------| -------- | ---------------------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](./README.md#namespaced-path-encoding) owned by the authenticated user |
+| `issue_iid` | integer | yes | The internal ID of a project issue |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/issues/11/closed_by"
diff --git a/doc/api/repository_submodules.md b/doc/api/repository_submodules.md
index 9a5dcacbc2f..77f64b178f0 100644
--- a/doc/api/repository_submodules.md
+++ b/doc/api/repository_submodules.md
@@ -23,13 +23,13 @@ PUT /projects/:id/repository/submodules/:submodule
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
-| `submodule` | string | yes | URL encoded full path to the submodule. For example, `lib%2Fclass%2Erb` |
+| `submodule` | string | yes | URL-encoded full path to the submodule. For example, `lib%2Fclass%2Erb` |
| `branch` | string | yes | Name of the branch to commit into |
| `commit_sha` | string | yes | Full commit SHA to update the submodule to |
| `commit_message` | string | no | Commit message. If no message is provided, a default one will be set |
```shell
-curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/repository/submodules/lib%2Fmodules%2Fexample"
+curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/repository/submodules/lib%2Fmodules%2Fexample" \
--data "branch=master&commit_sha=3ddec28ea23acc5caa5d8331a6ecb2a65fc03e88&commit_message=Update submodule reference"
```
diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md
index 25421b067dc..2a33f09c725 100644
--- a/doc/ci/quick_start/README.md
+++ b/doc/ci/quick_start/README.md
@@ -80,14 +80,15 @@ so you have to pay extra attention to indentation. Always use spaces, not tabs.
Below is an example for a Ruby on Rails project:
```yaml
-image: "ruby:2.5"
-
-before_script:
- - sudo apt-get update -qq && sudo apt-get install -y -qq sqlite3 libsqlite3-dev nodejs
- - ruby -v
- - which ruby
- - gem install bundler --no-document
- - bundle install --jobs $(nproc) "${FLAGS[@]}"
+default:
+ image: ruby:2.5
+ before_script:
+ - apt-get update
+ - apt-get install -y sqlite3 libsqlite3-dev nodejs
+ - ruby -v
+ - which ruby
+ - gem install bundler --no-document
+ - bundle install --jobs $(nproc) "${FLAGS[@]}"
rspec:
script:
diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md
index 1d245f50dc4..f788c2ca5cc 100644
--- a/doc/integration/elasticsearch.md
+++ b/doc/integration/elasticsearch.md
@@ -160,7 +160,7 @@ After installation, be sure to [enable Elasticsearch](#enabling-elasticsearch).
## Enabling Elasticsearch
NOTE: **Note:**
-For large GitLab instances you can follow the instructions for [Indexing large
+For GitLab instances with more than 50GB repository data you can follow the instructions for [Indexing large
instances](#indexing-large-instances) below.
To enable Elasticsearch, you need to have admin access to GitLab:
@@ -270,164 +270,6 @@ To disable the Elasticsearch integration:
bundle exec rake gitlab:elastic:delete_index RAILS_ENV=production
```
-### Indexing large instances
-
-CAUTION: **Warning:**
-Indexing a large instance will generate a lot of Sidekiq jobs.
-Make sure to prepare for this task by having a [Scalable and Highly Available
-Setup](../administration/reference_architectures/index.md) or creating [extra
-Sidekiq processes](../administration/operations/extra_sidekiq_processes.md)
-
-1. [Configure your Elasticsearch host and port](#enabling-elasticsearch).
-1. Create empty indexes:
-
- ```shell
- # Omnibus installations
- sudo gitlab-rake gitlab:elastic:create_empty_index
-
- # Installations from source
- bundle exec rake gitlab:elastic:create_empty_index RAILS_ENV=production
- ```
-
-1. If this is a re-index of your GitLab instance, clear the index status:
-
- ```shell
- # Omnibus installations
- sudo gitlab-rake gitlab:elastic:clear_index_status
-
- # Installations from source
- bundle exec rake gitlab:elastic:clear_index_status RAILS_ENV=production
- ```
-
-1. [Enable **Elasticsearch indexing**](#enabling-elasticsearch).
-1. Indexing large Git repositories can take a while. To speed up the process, you can [tune for indexing speed](https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-indexing-speed.html#tune-for-indexing-speed):
-
- - You can temporarily disable [`refresh`](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html), the operation responsible for making changes to an index available to search.
-
- - You can set the number of replicas to 0. This setting controls the number of copies each primary shard of an index will have. Thus, having 0 replicas effectively disables the replication of shards across nodes, which should increase the indexing performance. This is an important trade-off in terms of reliability and query performance. It is important to remember to set the replicas to a considered value after the initial indexing is complete.
-
- In our experience, you can expect a 20% decrease in indexing time. After completing indexing in a later step, you can return `refresh` and `number_of_replicas` to their desired settings.
-
- NOTE: **Note:**
- This step is optional but may help significantly speed up large indexing operations.
-
- ```shell
- curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
- "index" : {
- "refresh_interval" : "-1",
- "number_of_replicas" : 0
- } }'
- ```
-
-1. Index projects and their associated data:
-
- ```shell
- # Omnibus installations
- sudo gitlab-rake gitlab:elastic:index_projects
-
- # Installations from source
- bundle exec rake gitlab:elastic:index_projects RAILS_ENV=production
- ```
-
- This enqueues a Sidekiq job for each project that needs to be indexed.
- You can view the jobs in **Admin Area > Monitoring > Background Jobs > Queues Tab**
- and click `elastic_indexer`, or you can query indexing status using a Rake task:
-
- ```shell
- # Omnibus installations
- sudo gitlab-rake gitlab:elastic:index_projects_status
-
- # Installations from source
- bundle exec rake gitlab:elastic:index_projects_status RAILS_ENV=production
-
- Indexing is 65.55% complete (6555/10000 projects)
- ```
-
- If you want to limit the index to a range of projects you can provide the
- `ID_FROM` and `ID_TO` parameters:
-
- ```shell
- # Omnibus installations
- sudo gitlab-rake gitlab:elastic:index_projects ID_FROM=1001 ID_TO=2000
-
- # Installations from source
- bundle exec rake gitlab:elastic:index_projects ID_FROM=1001 ID_TO=2000 RAILS_ENV=production
- ```
-
- Where `ID_FROM` and `ID_TO` are project IDs. Both parameters are optional.
- The above example will index all projects from ID `1001` up to (and including) ID `2000`.
-
- TIP: **Troubleshooting:**
- Sometimes the project indexing jobs queued by `gitlab:elastic:index_projects`
- can get interrupted. This may happen for many reasons, but it's always safe
- to run the indexing task again. It will skip repositories that have
- already been indexed.
-
- As the indexer stores the last commit SHA of every indexed repository in the
- database, you can run the indexer with the special parameter `UPDATE_INDEX` and
- it will check every project repository again to make sure that every commit in
- a repository is indexed, which can be useful in case if your index is outdated:
-
- ```shell
- # Omnibus installations
- sudo gitlab-rake gitlab:elastic:index_projects UPDATE_INDEX=true ID_TO=1000
-
- # Installations from source
- bundle exec rake gitlab:elastic:index_projects UPDATE_INDEX=true ID_TO=1000 RAILS_ENV=production
- ```
-
- You can also use the `gitlab:elastic:clear_index_status` Rake task to force the
- indexer to "forget" all progress, so it will retry the indexing process from the
- start.
-
-1. Personal snippets are not associated with a project and need to be indexed separately:
-
- ```shell
- # Omnibus installations
- sudo gitlab-rake gitlab:elastic:index_snippets
-
- # Installations from source
- bundle exec rake gitlab:elastic:index_snippets RAILS_ENV=production
- ```
-
-1. Enable replication and refreshing again after indexing (only if you previously disabled it):
-
- ```shell
- curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
- "index" : {
- "number_of_replicas" : 1,
- "refresh_interval" : "1s"
- } }'
- ```
-
- A force merge should be called after enabling the refreshing above.
-
- For Elasticsearch 6.x, the index should be in read-only mode before proceeding with the force merge:
-
- ```shell
- curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
- "settings": {
- "index.blocks.write": true
- } }'
- ```
-
- Then, initiate the force merge:
-
- ```shell
- curl --request POST 'localhost:9200/gitlab-production/_forcemerge?max_num_segments=5'
- ```
-
- After this, if your index is in read-only mode, switch back to read-write:
-
- ```shell
- curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
- "settings": {
- "index.blocks.write": false
- } }'
- ```
-
-1. After the indexing has completed, enable [**Search with Elasticsearch**](#enabling-elasticsearch).
-
## Zero downtime reindexing
The idea behind this reindexing method is to leverage Elasticsearch index alias
@@ -661,6 +503,168 @@ For basic guidance on choosing a cluster configuration you may refer to [Elastic
- The `Number of Elasticsearch shards` setting usually corresponds with the number of CPUs available in your cluster. For example, if you have a 3-node cluster with 4 cores each, this means you will benefit from having at least 3*4=12 shards in the cluster. Please note, it's only possible to change the shards number by using [Split index API](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-split-index.html) or by reindexing to a different index with a changed number of shards.
- The `Number of Elasticsearch replicas` setting should most of the time be equal to `1` (each shard will have 1 replica). Using `0` is not recommended, because losing one node will corrupt the index.
+### Indexing large instances
+
+This section may be helpful in the event that the other
+[basic instructions](#enabling-elasticsearch) cause problems
+due to large volumes of data being indexed.
+
+CAUTION: **Warning:**
+Indexing a large instance will generate a lot of Sidekiq jobs.
+Make sure to prepare for this task by having a [Scalable and Highly Available
+Setup](../administration/reference_architectures/index.md) or creating [extra
+Sidekiq processes](../administration/operations/extra_sidekiq_processes.md).
+
+1. [Configure your Elasticsearch host and port](#enabling-elasticsearch).
+1. Create empty indexes:
+
+ ```shell
+ # Omnibus installations
+ sudo gitlab-rake gitlab:elastic:create_empty_index
+
+ # Installations from source
+ bundle exec rake gitlab:elastic:create_empty_index RAILS_ENV=production
+ ```
+
+1. If this is a re-index of your GitLab instance, clear the index status:
+
+ ```shell
+ # Omnibus installations
+ sudo gitlab-rake gitlab:elastic:clear_index_status
+
+ # Installations from source
+ bundle exec rake gitlab:elastic:clear_index_status RAILS_ENV=production
+ ```
+
+1. [Enable **Elasticsearch indexing**](#enabling-elasticsearch).
+1. Indexing large Git repositories can take a while. To speed up the process, you can [tune for indexing speed](https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-indexing-speed.html#tune-for-indexing-speed):
+
+ - You can temporarily disable [`refresh`](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html), the operation responsible for making changes to an index available to search.
+
+ - You can set the number of replicas to 0. This setting controls the number of copies each primary shard of an index will have. Thus, having 0 replicas effectively disables the replication of shards across nodes, which should increase the indexing performance. This is an important trade-off in terms of reliability and query performance. It is important to remember to set the replicas to a considered value after the initial indexing is complete.
+
+ In our experience, you can expect a 20% decrease in indexing time. After completing indexing in a later step, you can return `refresh` and `number_of_replicas` to their desired settings.
+
+ NOTE: **Note:**
+ This step is optional but may help significantly speed up large indexing operations.
+
+ ```shell
+ curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
+ "index" : {
+ "refresh_interval" : "-1",
+ "number_of_replicas" : 0
+ } }'
+ ```
+
+1. Index projects and their associated data:
+
+ ```shell
+ # Omnibus installations
+ sudo gitlab-rake gitlab:elastic:index_projects
+
+ # Installations from source
+ bundle exec rake gitlab:elastic:index_projects RAILS_ENV=production
+ ```
+
+ This enqueues a Sidekiq job for each project that needs to be indexed.
+ You can view the jobs in **Admin Area > Monitoring > Background Jobs > Queues Tab**
+ and click `elastic_indexer`, or you can query indexing status using a Rake task:
+
+ ```shell
+ # Omnibus installations
+ sudo gitlab-rake gitlab:elastic:index_projects_status
+
+ # Installations from source
+ bundle exec rake gitlab:elastic:index_projects_status RAILS_ENV=production
+
+ Indexing is 65.55% complete (6555/10000 projects)
+ ```
+
+ If you want to limit the index to a range of projects you can provide the
+ `ID_FROM` and `ID_TO` parameters:
+
+ ```shell
+ # Omnibus installations
+ sudo gitlab-rake gitlab:elastic:index_projects ID_FROM=1001 ID_TO=2000
+
+ # Installations from source
+ bundle exec rake gitlab:elastic:index_projects ID_FROM=1001 ID_TO=2000 RAILS_ENV=production
+ ```
+
+ Where `ID_FROM` and `ID_TO` are project IDs. Both parameters are optional.
+ The above example will index all projects from ID `1001` up to (and including) ID `2000`.
+
+ TIP: **Troubleshooting:**
+ Sometimes the project indexing jobs queued by `gitlab:elastic:index_projects`
+ can get interrupted. This may happen for many reasons, but it's always safe
+ to run the indexing task again. It will skip repositories that have
+ already been indexed.
+
+ As the indexer stores the last commit SHA of every indexed repository in the
+ database, you can run the indexer with the special parameter `UPDATE_INDEX` and
+ it will check every project repository again to make sure that every commit in
+ a repository is indexed, which can be useful in case if your index is outdated:
+
+ ```shell
+ # Omnibus installations
+ sudo gitlab-rake gitlab:elastic:index_projects UPDATE_INDEX=true ID_TO=1000
+
+ # Installations from source
+ bundle exec rake gitlab:elastic:index_projects UPDATE_INDEX=true ID_TO=1000 RAILS_ENV=production
+ ```
+
+ You can also use the `gitlab:elastic:clear_index_status` Rake task to force the
+ indexer to "forget" all progress, so it will retry the indexing process from the
+ start.
+
+1. Personal snippets are not associated with a project and need to be indexed separately:
+
+ ```shell
+ # Omnibus installations
+ sudo gitlab-rake gitlab:elastic:index_snippets
+
+ # Installations from source
+ bundle exec rake gitlab:elastic:index_snippets RAILS_ENV=production
+ ```
+
+1. Enable replication and refreshing again after indexing (only if you previously disabled it):
+
+ ```shell
+ curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
+ "index" : {
+ "number_of_replicas" : 1,
+ "refresh_interval" : "1s"
+ } }'
+ ```
+
+ A force merge should be called after enabling the refreshing above.
+
+ For Elasticsearch 6.x, the index should be in read-only mode before proceeding with the force merge:
+
+ ```shell
+ curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
+ "settings": {
+ "index.blocks.write": true
+ } }'
+ ```
+
+ Then, initiate the force merge:
+
+ ```shell
+ curl --request POST 'localhost:9200/gitlab-production/_forcemerge?max_num_segments=5'
+ ```
+
+ After this, if your index is in read-only mode, switch back to read-write:
+
+ ```shell
+ curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
+ "settings": {
+ "index.blocks.write": false
+ } }'
+ ```
+
+1. After the indexing has completed, enable [**Search with Elasticsearch**](#enabling-elasticsearch).
+
### Deleted documents
Whenever a change or deletion is made to an indexed GitLab object (a merge request description is changed, a file is deleted from the master branch in a repository, a project is deleted, etc), a document in the index is deleted. However, since these are "soft" deletes, the overall number of "deleted documents", and therefore wasted space, increases. Elasticsearch does intelligent merging of segments in order to remove these deleted documents. However, depending on the amount and type of activity in your GitLab installation, it's possible to see as much as 50% wasted space in the index.
diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md
index adbc6e151f3..7865bd180fb 100644
--- a/doc/raketasks/backup_restore.md
+++ b/doc/raketasks/backup_restore.md
@@ -16,7 +16,7 @@ remember to enable backups with your object storage provider if desired.
## Requirements
-In order to be able to backup and restore, you need two essential tools
+In order to be able to backup and restore, you need one essential tool
installed on your system.
- **Rsync**: If you installed GitLab:
diff --git a/doc/security/README.md b/doc/security/README.md
index bbc7db54b14..f8b9e423c04 100644
--- a/doc/security/README.md
+++ b/doc/security/README.md
@@ -12,7 +12,7 @@ type: index
- [Rate limits](rate_limits.md)
- [Webhooks and insecure internal web services](webhooks.md)
- [Information exclusivity](information_exclusivity.md)
-- [Reset your root password](reset_root_password.md)
+- [Reset user password](reset_user_password.md)
- [Unlock a locked user](unlock_user.md)
- [User File Uploads](user_file_uploads.md)
- [How we manage the CRIME vulnerability](crime_vulnerability.md)
diff --git a/doc/security/reset_root_password.md b/doc/security/reset_user_password.md
index cd2144698f6..bc8de882afe 100644
--- a/doc/security/reset_root_password.md
+++ b/doc/security/reset_user_password.md
@@ -2,9 +2,9 @@
type: howto
---
-# How to reset your root password
+# How to reset user password
-To reset your root password, first log into your server with root privileges.
+To reset the password of a user, first log into your server with root privileges.
Start a Ruby on Rails console with this command:
@@ -14,18 +14,22 @@ gitlab-rails console -e production
Wait until the console has loaded.
-There are multiple ways to find your user. You can search for email or username.
+## Find the user
+
+There are multiple ways to find your user. You can search by email or user ID number.
```shell
-user = User.where(id: 1).first
+user = User.where(id: 7).first
```
or
```shell
-user = User.find_by(email: 'admin@example.com')
+user = User.find_by(email: 'user@example.com')
```
+## Reset the password
+
Now you can change your password:
```shell
@@ -35,6 +39,14 @@ user.password_confirmation = 'secret_pass'
It's important that you change both password and password_confirmation to make it work.
+When using this method instead of the [Users API](../api/users.md#user-modification), GitLab sends an email to the user stating that the user changed their password.
+
+If the password was changed by an administrator, execute the following command to notify the user by email:
+
+```shell
+user.send_only_admin_changed_your_password_notification!
+```
+
Don't forget to save the changes.
```shell
@@ -43,6 +55,19 @@ user.save!
Exit the console and try to login with your new password.
+NOTE: **Note:**
+Passwords can also be reset via the [Users API](../api/users.md#user-modification)
+
+### Reset your root password
+
+The steps described above can also be used to reset the root password. But first, identify the root user, with an `id` of `1`. To do so, run the following command:
+
+```shell
+user = User.where(id: 1).first
+```
+
+After finding the user, follow the steps mentioned in the [Reset the password](#reset-the-password) section to reset the password of the root user.
+
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues
diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md
index dfa57bfb25f..6d29374596c 100644
--- a/doc/user/profile/notifications.md
+++ b/doc/user/profile/notifications.md
@@ -143,7 +143,8 @@ Users will be notified of the following events:
| New SSH key added | User | Security email, always sent. |
| New email added | User | Security email, always sent. |
| Email changed | User | Security email, always sent. |
-| Password changed | User | Security email, always sent. |
+| Password changed | User | Security email, always sent when user changes their own password |
+| Password changed by administrator | User | Security email, always sent when an adminstrator changes the password of another user |
| Two-factor authentication disabled | User | Security email, always sent. |
| New user created | User | Sent on user creation, except for OmniAuth (LDAP)|
| User added to project | User | Sent when user is added to project |
diff --git a/doc/user/search/index.md b/doc/user/search/index.md
index 56ac2bfbc17..12b7b0adba9 100644
--- a/doc/user/search/index.md
+++ b/doc/user/search/index.md
@@ -162,6 +162,15 @@ quickly access issues and merge requests created or assigned to you within that
![search per project - shortcut](img/project_search.png)
+### Autocomplete suggestions
+
+You can also type in this search bar to see autocomplete suggestions for:
+
+- Projects and groups
+- Various help pages (try and type **API help**)
+- Project feature pages (try and type **milestones**)
+- Various settings pages (try and type **user settings**)
+
## To-Do List
Your [To-Do List](../todos.md#gitlab-to-do-list) can be searched by "to do" and "done".
diff --git a/lib/api/users.rb b/lib/api/users.rb
index aa3ecf4019d..b630ca057db 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -218,9 +218,15 @@ module API
.where.not(id: user.id).exists?
user_params = declared_params(include_missing: false)
+ admin_making_changes_for_another_user = (current_user != user)
- user_params[:password_expires_at] = Time.current if user_params[:password].present?
- result = ::Users::UpdateService.new(current_user, user_params.merge(user: user)).execute
+ if user_params[:password].present?
+ user_params[:password_expires_at] = Time.current if admin_making_changes_for_another_user
+ end
+
+ result = ::Users::UpdateService.new(current_user, user_params.merge(user: user)).execute do |user|
+ user.send_only_admin_changed_your_password_notification! if admin_making_changes_for_another_user
+ end
if result[:status] == :success
present user, with: Entities::UserWithAdmin, current_user: current_user
diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb
index 51fac9e8706..1e237d3c423 100644
--- a/lib/backup/repository.rb
+++ b/lib/backup/repository.rb
@@ -148,20 +148,22 @@ module Backup
private
def dump_consecutive
- Project.find_each(batch_size: 1000) do |project|
+ Project.includes(:route).find_each(batch_size: 1000) do |project|
dump_project(project)
end
end
def dump_storage(storage, semaphore, max_storage_concurrency:)
errors = Queue.new
- queue = SizedQueue.new(1)
+ queue = InterlockSizedQueue.new(1)
threads = Array.new(max_storage_concurrency) do
Thread.new do
Rails.application.executor.wrap do
while project = queue.pop
- semaphore.acquire
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
+ semaphore.acquire
+ end
begin
dump_project(project)
@@ -176,7 +178,7 @@ module Backup
end
end
- Project.for_repository_storage(storage).find_each(batch_size: 100) do |project|
+ Project.for_repository_storage(storage).includes(:route).find_each(batch_size: 100) do |project|
break unless errors.empty?
queue.push(project)
@@ -241,5 +243,23 @@ module Backup
pool.schedule
end
end
+
+ class InterlockSizedQueue < SizedQueue
+ extend ::Gitlab::Utils::Override
+
+ override :pop
+ def pop(*)
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
+ super
+ end
+ end
+
+ override :push
+ def push(*)
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
+ super
+ end
+ end
+ end
end
end
diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb
index 206ba029750..ab70ba5e17e 100644
--- a/lib/gitlab/ci/features.rb
+++ b/lib/gitlab/ci/features.rb
@@ -56,8 +56,11 @@ module Gitlab
::Feature.enabled?(:ci_if_parenthesis_enabled, default_enabled: true)
end
- def self.allow_to_create_merge_request_pipelines_in_target_project?(target_project)
- ::Feature.enabled?(:ci_allow_to_create_merge_request_pipelines_in_target_project, target_project, default_enabled: true)
+ # NOTE: The feature flag `disallow_to_create_merge_request_pipelines_in_target_project`
+ # is a safe switch to disable the feature for a parituclar project when something went wrong,
+ # therefore it's not supposed to be enabled by default.
+ def self.disallow_to_create_merge_request_pipelines_in_target_project?(target_project)
+ ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, target_project)
end
def self.ci_plan_needs_size_limit?(project)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index da902413079..14a50c8d5ef 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2540,6 +2540,9 @@ msgstr ""
msgid "An %{link_start}alert%{link_end} with the same fingerprint is already open. To change the status of this alert, resolve the linked alert."
msgstr ""
+msgid "An administrator changed the password for your GitLab account on %{link_to}."
+msgstr ""
+
msgid "An alert has been triggered in %{project_path}."
msgstr ""
@@ -12565,6 +12568,9 @@ msgstr ""
msgid "Hello there"
msgstr ""
+msgid "Hello, %{username}!"
+msgstr ""
+
msgid "Help"
msgstr ""
@@ -18304,6 +18310,9 @@ msgstr ""
msgid "Please complete your profile with email address"
msgstr ""
+msgid "Please contact your administrator with any questions."
+msgstr ""
+
msgid "Please contact your administrator."
msgstr ""
@@ -24329,6 +24338,9 @@ msgstr ""
msgid "Test Cases"
msgstr ""
+msgid "Test cases are not available for this project"
+msgstr ""
+
msgid "Test coverage parsing"
msgstr ""
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 30fa991190a..e4cdcda756b 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -286,82 +286,111 @@ RSpec.describe Admin::UsersController do
describe 'POST update' do
context 'when the password has changed' do
- def update_password(user, password, password_confirmation = nil)
+ def update_password(user, password = User.random_password, password_confirmation = password)
params = {
id: user.to_param,
user: {
password: password,
- password_confirmation: password_confirmation || password
+ password_confirmation: password_confirmation
}
}
post :update, params: params
end
- context 'when the admin changes their own password' do
- it 'updates the password' do
- expect { update_password(admin, 'AValidPassword1') }
- .to change { admin.reload.encrypted_password }
- end
+ context 'when admin changes their own password' do
+ context 'when password is valid' do
+ it 'updates the password' do
+ expect { update_password(admin) }
+ .to change { admin.reload.encrypted_password }
+ end
+
+ it 'does not set the new password to expire immediately' do
+ expect { update_password(admin) }
+ .not_to change { admin.reload.password_expired? }
+ end
+
+ it 'does not enqueue the `admin changed your password` email' do
+ expect { update_password(admin) }
+ .not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
- it 'does not set the new password to expire immediately' do
- expect { update_password(admin, 'AValidPassword1') }
- .not_to change { admin.reload.password_expires_at }
+ it 'enqueues the `password changed` email' do
+ expect { update_password(admin) }
+ .to have_enqueued_mail(DeviseMailer, :password_change)
+ end
end
end
- context 'when the new password is valid' do
- it 'redirects to the user' do
- update_password(user, 'AValidPassword1')
+ context 'when admin changes the password of another user' do
+ context 'when the new password is valid' do
+ it 'redirects to the user' do
+ update_password(user)
- expect(response).to redirect_to(admin_user_path(user))
- end
+ expect(response).to redirect_to(admin_user_path(user))
+ end
- it 'updates the password' do
- expect { update_password(user, 'AValidPassword1') }
- .to change { user.reload.encrypted_password }
- end
+ it 'updates the password' do
+ expect { update_password(user) }
+ .to change { user.reload.encrypted_password }
+ end
- it 'sets the new password to expire immediately' do
- expect { update_password(user, 'AValidPassword1') }
- .to change { user.reload.password_expires_at }.to be_within(2.seconds).of(Time.current)
+ it 'sets the new password to expire immediately' do
+ expect { update_password(user) }
+ .to change { user.reload.password_expired? }.from(false).to(true)
+ end
+
+ it 'enqueues the `admin changed your password` email' do
+ expect { update_password(user) }
+ .to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+
+ it 'does not enqueue the `password changed` email' do
+ expect { update_password(user) }
+ .not_to have_enqueued_mail(DeviseMailer, :password_change)
+ end
end
end
context 'when the new password is invalid' do
+ let(:password) { 'invalid' }
+
it 'shows the edit page again' do
- update_password(user, 'invalid')
+ update_password(user, password)
expect(response).to render_template(:edit)
end
it 'returns the error message' do
- update_password(user, 'invalid')
+ update_password(user, password)
expect(assigns[:user].errors).to contain_exactly(a_string_matching(/too short/))
end
it 'does not update the password' do
- expect { update_password(user, 'invalid') }
+ expect { update_password(user, password) }
.not_to change { user.reload.encrypted_password }
end
end
context 'when the new password does not match the password confirmation' do
+ let(:password) { 'some_password' }
+ let(:password_confirmation) { 'not_same_as_password' }
+
it 'shows the edit page again' do
- update_password(user, 'AValidPassword1', 'AValidPassword2')
+ update_password(user, password, password_confirmation)
expect(response).to render_template(:edit)
end
it 'returns the error message' do
- update_password(user, 'AValidPassword1', 'AValidPassword2')
+ update_password(user, password, password_confirmation)
expect(assigns[:user].errors).to contain_exactly(a_string_matching(/doesn't match/))
end
it 'does not update the password' do
- expect { update_password(user, 'AValidPassword1', 'AValidPassword2') }
+ expect { update_password(user, password, password_confirmation) }
.not_to change { user.reload.encrypted_password }
end
end
diff --git a/spec/controllers/profiles/notifications_controller_spec.rb b/spec/controllers/profiles/notifications_controller_spec.rb
index cd6eb929b19..90df7cc0991 100644
--- a/spec/controllers/profiles/notifications_controller_spec.rb
+++ b/spec/controllers/profiles/notifications_controller_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe Profiles::NotificationsController do
expect(assigns(:group_notifications).map(&:source_id)).to include(subgroup.id)
end
- it 'has an N+1 (but should not)' do
+ it 'does not have an N+1' do
sign_in(user)
control = ActiveRecord::QueryRecorder.new do
@@ -46,10 +46,9 @@ RSpec.describe Profiles::NotificationsController do
create_list(:group, 2, parent: group)
- # We currently have an N + 1, switch to `not_to` once fixed
expect do
get :show
- end.to exceed_query_limit(control)
+ end.not_to exceed_query_limit(control)
end
end
@@ -62,7 +61,7 @@ RSpec.describe Profiles::NotificationsController do
before do
group.add_developer(user)
sign_in(user)
- stub_const('Profiles::NotificationsController::NOTIFICATIONS_PER_PAGE', notifications_per_page)
+ allow(Kaminari.config).to receive(:default_per_page).and_return(notifications_per_page)
end
it 'paginates the groups' do
diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb
index 5c62de4d08d..90e43b9e22c 100644
--- a/spec/factories/issues.rb
+++ b/spec/factories/issues.rb
@@ -52,5 +52,9 @@ FactoryBot.define do
factory :incident do
issue_type { :incident }
end
+
+ factory :quality_test_case do
+ issue_type { :test_case }
+ end
end
end
diff --git a/spec/features/issues/incident_issue_spec.rb b/spec/features/issues/incident_issue_spec.rb
index 57dfb370bf4..d004ee85dd8 100644
--- a/spec/features/issues/incident_issue_spec.rb
+++ b/spec/features/issues/incident_issue_spec.rb
@@ -3,17 +3,14 @@
require 'spec_helper'
RSpec.describe 'Incident Detail', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
- let(:incident) { create(:issue, project: project, author: user, issue_type: 'incident', description: 'hello') }
-
context 'when user displays the incident' do
- before do
+ it 'shows the incident tabs' do
+ project = create(:project, :public)
+ incident = create(:incident, project: project, description: 'hello')
+
visit project_issue_path(project, incident)
wait_for_requests
- end
- it 'shows the incident tabs' do
page.within('.issuable-details') do
incident_tabs = find('[data-testid="incident-tabs"]')
diff --git a/spec/features/merge_request/user_sees_pipelines_spec.rb b/spec/features/merge_request/user_sees_pipelines_spec.rb
index 5d41e49c478..8e15ba6cf8d 100644
--- a/spec/features/merge_request/user_sees_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_pipelines_spec.rb
@@ -123,6 +123,10 @@ RSpec.describe 'Merge request > User sees pipelines', :js do
context 'when actor is a developer in parent project' do
let(:actor) { developer_in_parent }
+ before do
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false)
+ end
+
it 'creates a pipeline in the parent project when user proceeds with the warning' do
visit project_merge_request_path(parent_project, merge_request)
diff --git a/spec/finders/user_group_notification_settings_finder_spec.rb b/spec/finders/user_group_notification_settings_finder_spec.rb
new file mode 100644
index 00000000000..6f391621999
--- /dev/null
+++ b/spec/finders/user_group_notification_settings_finder_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe UserGroupNotificationSettingsFinder do
+ let_it_be(:user) { create(:user) }
+
+ subject { described_class.new(user, Group.where(id: groups.map(&:id))).execute }
+
+ def attributes(&proc)
+ subject.map(&proc).uniq
+ end
+
+ context 'when the groups have no existing notification settings' do
+ context 'when the groups have no ancestors' do
+ let_it_be(:groups) { create_list(:group, 3) }
+
+ it 'will be a default Global notification setting', :aggregate_failures do
+ expect(subject.count).to eq(3)
+ expect(attributes(&:notification_email)).to eq([nil])
+ expect(attributes(&:level)).to eq(['global'])
+ end
+ end
+
+ context 'when the groups have ancestors' do
+ context 'when an ancestor has a level other than Global' do
+ let_it_be(:ancestor_a) { create(:group) }
+ let_it_be(:group_a) { create(:group, parent: ancestor_a) }
+ let_it_be(:ancestor_b) { create(:group) }
+ let_it_be(:group_b) { create(:group, parent: ancestor_b) }
+ let_it_be(:email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) }
+
+ let_it_be(:groups) { [group_a, group_b] }
+
+ before do
+ create(:notification_setting, user: user, source: ancestor_a, level: 'participating', notification_email: email.email)
+ create(:notification_setting, user: user, source: ancestor_b, level: 'participating', notification_email: email.email)
+ end
+
+ it 'has the same level set' do
+ expect(attributes(&:level)).to eq(['participating'])
+ end
+
+ it 'has the same email set' do
+ expect(attributes(&:notification_email)).to eq(['ancestor@example.com'])
+ end
+
+ it 'only returns the two queried groups' do
+ expect(subject.count).to eq(2)
+ end
+ end
+
+ context 'when an ancestor has a Global level but has an email set' do
+ let_it_be(:grand_ancestor) { create(:group) }
+ let_it_be(:ancestor) { create(:group, parent: grand_ancestor) }
+ let_it_be(:group) { create(:group, parent: ancestor) }
+ let_it_be(:ancestor_email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) }
+ let_it_be(:grand_email) { create(:email, :confirmed, email: 'grand@example.com', user: user) }
+
+ let_it_be(:groups) { [group] }
+
+ before do
+ create(:notification_setting, user: user, source: grand_ancestor, level: 'participating', notification_email: grand_email.email)
+ create(:notification_setting, user: user, source: ancestor, level: 'global', notification_email: ancestor_email.email)
+ end
+
+ it 'has the same email and level set', :aggregate_failures do
+ expect(subject.count).to eq(1)
+ expect(attributes(&:level)).to eq(['global'])
+ expect(attributes(&:notification_email)).to eq(['ancestor@example.com'])
+ end
+ end
+
+ it 'does not cause an N+1', :aggregate_failures do
+ parent = create(:group)
+ child = create(:group, parent: parent)
+
+ control = ActiveRecord::QueryRecorder.new do
+ described_class.new(user, Group.where(id: child.id)).execute
+ end
+
+ other_parent = create(:group)
+ other_children = create_list(:group, 2, parent: other_parent)
+
+ result = nil
+
+ expect do
+ result = described_class.new(user, Group.where(id: other_children.append(child).map(&:id))).execute
+ end.not_to exceed_query_limit(control)
+
+ expect(result.count).to eq(3)
+ end
+ end
+ end
+end
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
index 14ffd7b2d85..4869e75a2f3 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
@@ -1,4 +1,11 @@
-import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import {
+ stripQuotes,
+ uniqueTokens,
+ prepareTokens,
+ processFilters,
+ filterToQueryObject,
+ urlQueryToFilter,
+} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
tokenValueAuthor,
@@ -20,7 +27,7 @@ describe('Filtered Search Utils', () => {
`(
'returns string $outputValue when called with string $inputValue',
({ inputValue, outputValue }) => {
- expect(filteredSearchUtils.stripQuotes(inputValue)).toBe(outputValue);
+ expect(stripQuotes(inputValue)).toBe(outputValue);
},
);
});
@@ -28,7 +35,7 @@ describe('Filtered Search Utils', () => {
describe('uniqueTokens', () => {
it('returns tokens array with duplicates removed', () => {
expect(
- filteredSearchUtils.uniqueTokens([
+ uniqueTokens([
tokenValueAuthor,
tokenValueLabel,
tokenValueMilestone,
@@ -40,13 +47,172 @@ describe('Filtered Search Utils', () => {
it('returns tokens array as it is if it does not have duplicates', () => {
expect(
- filteredSearchUtils.uniqueTokens([
- tokenValueAuthor,
- tokenValueLabel,
- tokenValueMilestone,
- tokenValuePlain,
- ]),
+ uniqueTokens([tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValuePlain]),
).toHaveLength(4);
});
});
});
+
+describe('prepareTokens', () => {
+ describe('with empty data', () => {
+ it('returns an empty array', () => {
+ expect(prepareTokens()).toEqual([]);
+ expect(prepareTokens({})).toEqual([]);
+ expect(prepareTokens({ milestone: null, author: null, assignees: [], labels: [] })).toEqual(
+ [],
+ );
+ });
+ });
+
+ it.each([
+ [
+ 'milestone',
+ { value: 'v1.0', operator: '=' },
+ [{ type: 'milestone', value: { data: 'v1.0', operator: '=' } }],
+ ],
+ [
+ 'author',
+ { value: 'mr.popo', operator: '!=' },
+ [{ type: 'author', value: { data: 'mr.popo', operator: '!=' } }],
+ ],
+ [
+ 'labels',
+ [{ value: 'z-fighters', operator: '=' }],
+ [{ type: 'labels', value: { data: 'z-fighters', operator: '=' } }],
+ ],
+ [
+ 'assignees',
+ [{ value: 'krillin', operator: '=' }, { value: 'piccolo', operator: '!=' }],
+ [
+ { type: 'assignees', value: { data: 'krillin', operator: '=' } },
+ { type: 'assignees', value: { data: 'piccolo', operator: '!=' } },
+ ],
+ ],
+ [
+ 'foo',
+ [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }],
+ [
+ { type: 'foo', value: { data: 'bar', operator: '!=' } },
+ { type: 'foo', value: { data: 'baz', operator: '!=' } },
+ ],
+ ],
+ ])('gathers %s=%j into result=%j', (token, value, result) => {
+ const res = prepareTokens({ [token]: value });
+ expect(res).toEqual(result);
+ });
+});
+
+describe('processFilters', () => {
+ it('processes multiple filter values', () => {
+ const result = processFilters([
+ { type: 'foo', value: { data: 'foo', operator: '=' } },
+ { type: 'bar', value: { data: 'bar1', operator: '=' } },
+ { type: 'bar', value: { data: 'bar2', operator: '!=' } },
+ ]);
+
+ expect(result).toStrictEqual({
+ foo: [{ value: 'foo', operator: '=' }],
+ bar: [{ value: 'bar1', operator: '=' }, { value: 'bar2', operator: '!=' }],
+ });
+ });
+
+ it('does not remove wrapping double quotes from the data', () => {
+ const result = processFilters([
+ { type: 'foo', value: { data: '"value with spaces"', operator: '=' } },
+ ]);
+
+ expect(result).toStrictEqual({
+ foo: [{ value: '"value with spaces"', operator: '=' }],
+ });
+ });
+});
+
+describe('filterToQueryObject', () => {
+ describe('with empty data', () => {
+ it('returns an empty object', () => {
+ expect(filterToQueryObject()).toEqual({});
+ expect(filterToQueryObject({})).toEqual({});
+ expect(filterToQueryObject({ author_username: null, label_name: [] })).toEqual({
+ author_username: null,
+ label_name: null,
+ 'not[author_username]': null,
+ 'not[label_name]': null,
+ });
+ });
+ });
+
+ it.each([
+ [
+ 'author_username',
+ { value: 'v1.0', operator: '=' },
+ { author_username: 'v1.0', 'not[author_username]': null },
+ ],
+ [
+ 'author_username',
+ { value: 'v1.0', operator: '!=' },
+ { author_username: null, 'not[author_username]': 'v1.0' },
+ ],
+ [
+ 'label_name',
+ [{ value: 'z-fighters', operator: '=' }],
+ { label_name: ['z-fighters'], 'not[label_name]': null },
+ ],
+ [
+ 'label_name',
+ [{ value: 'z-fighters', operator: '!=' }],
+ { label_name: null, 'not[label_name]': ['z-fighters'] },
+ ],
+ [
+ 'foo',
+ [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }],
+ { foo: ['bar', 'baz'], 'not[foo]': null },
+ ],
+ [
+ 'foo',
+ [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }],
+ { foo: null, 'not[foo]': ['bar', 'baz'] },
+ ],
+ [
+ 'foo',
+ [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '=' }],
+ { foo: ['baz'], 'not[foo]': ['bar'] },
+ ],
+ ])('gathers filter values %s=%j into query object=%j', (token, value, result) => {
+ const res = filterToQueryObject({ [token]: value });
+ expect(res).toEqual(result);
+ });
+});
+
+describe('urlQueryToFilter', () => {
+ describe('with empty data', () => {
+ it('returns an empty object', () => {
+ expect(urlQueryToFilter()).toEqual({});
+ expect(urlQueryToFilter('')).toEqual({});
+ expect(urlQueryToFilter('author_username=&milestone_title=&')).toEqual({});
+ });
+ });
+
+ it.each([
+ ['author_username=v1.0', { author_username: { value: 'v1.0', operator: '=' } }],
+ ['not[author_username]=v1.0', { author_username: { value: 'v1.0', operator: '!=' } }],
+ ['foo=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }],
+ ['foo=bar&foo[]=baz', { foo: [{ value: 'baz', operator: '=' }] }],
+ ['not[foo]=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }],
+ [
+ 'foo[]=bar&foo[]=baz&not[foo]=',
+ { foo: [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }] },
+ ],
+ [
+ 'foo[]=&not[foo][]=bar&not[foo][]=baz',
+ { foo: [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }] },
+ ],
+ [
+ 'foo[]=baz&not[foo][]=bar',
+ { foo: [{ value: 'baz', operator: '=' }, { value: 'bar', operator: '!=' }] },
+ ],
+ ['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }],
+ ])('gathers filter values %s into query object=%j', (query, result) => {
+ const res = urlQueryToFilter(query);
+ expect(res).toEqual(result);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
index 812aa2184ec..48653bce0fd 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
@@ -17,6 +17,10 @@ describe('rich_content_editor/services/html_to_markdown_renderer', () => {
fakeNode = { nodeValue: 'mock_node', dataset: {} };
});
+ afterEach(() => {
+ htmlToMarkdownRenderer = null;
+ });
+
describe('TEXT_NODE visitor', () => {
it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => {
htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
@@ -157,4 +161,30 @@ describe('rich_content_editor/services/html_to_markdown_renderer', () => {
expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result);
});
});
+
+ describe('PRE CODE', () => {
+ let node;
+ const subContent = 'sub content';
+ const originalConverterResult = 'base result';
+
+ beforeEach(() => {
+ node = document.createElement('PRE');
+
+ node.innerText = 'reference definition content';
+ node.dataset.sseReferenceDefinition = true;
+
+ baseRenderer.convert.mockReturnValueOnce(originalConverterResult);
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ });
+
+ it('returns raw text when pre node has sse-reference-definitions class', () => {
+ expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(`\n\n${node.innerText}\n`);
+ });
+
+ it('returns base result when pre node does not have sse-reference-definitions class', () => {
+ delete node.dataset.sseReferenceDefinition;
+
+ expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
index f4a06b91a10..b3d9576f38b 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
@@ -1,5 +1,4 @@
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph';
-import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils';
import { buildMockTextNode } from './mock_data';
@@ -17,7 +16,7 @@ const identifierParagraphNode = buildMockParagraphNode(
`[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`,
);
-describe('Render Identifier Paragraph renderer', () => {
+describe('rich_content_editor/renderers_render_identifier_paragraph', () => {
describe('canRender', () => {
it.each`
node | paragraph | target
@@ -37,8 +36,49 @@ describe('Render Identifier Paragraph renderer', () => {
});
describe('render', () => {
- it('should delegate rendering to the renderUneditableBranch util', () => {
- expect(renderer.render).toBe(renderUneditableBranch);
+ let context;
+ let result;
+
+ beforeEach(() => {
+ const node = {
+ firstChild: {
+ type: 'text',
+ literal: '[Some text]: https://link.com',
+ next: {
+ type: 'linebreak',
+ next: {
+ type: 'text',
+ literal: '[identifier]: http://example1.com "title"',
+ },
+ },
+ },
+ };
+ context = { skipChildren: jest.fn() };
+ result = renderer.render(node, context);
+ });
+
+ it('renders the reference definitions as a code block', () => {
+ expect(result).toEqual([
+ {
+ type: 'openTag',
+ tagName: 'pre',
+ classNames: ['code-block', 'language-markdown'],
+ attributes: {
+ 'data-sse-reference-definition': true,
+ },
+ },
+ { type: 'openTag', tagName: 'code' },
+ {
+ type: 'text',
+ content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"',
+ },
+ { type: 'closeTag', tagName: 'code' },
+ { type: 'closeTag', tagName: 'pre' },
+ ]);
+ });
+
+ it('skips the reference definition node children from rendering', () => {
+ expect(context.skipChildren).toHaveBeenCalled();
});
});
});
diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb
index 4af81cf83ac..96ac4015c77 100644
--- a/spec/helpers/emails_helper_spec.rb
+++ b/spec/helpers/emails_helper_spec.rb
@@ -118,6 +118,14 @@ RSpec.describe EmailsHelper do
end
end
+ describe '#say_hello' do
+ let(:user) { build(:user, name: 'John') }
+
+ it 'returns the greeting message for the given user' do
+ expect(say_hello(user)).to eq('Hello, John!')
+ end
+ end
+
describe '#two_factor_authentication_disabled_text' do
it 'returns the message that 2FA is disabled' do
expect(two_factor_authentication_disabled_text).to eq(
@@ -145,6 +153,33 @@ RSpec.describe EmailsHelper do
end
end
+ describe '#admin_changed_password_text' do
+ context 'format is html' do
+ it 'returns HTML' do
+ expect(admin_changed_password_text(format: :html)).to eq(
+ "An administrator changed the password for your GitLab account on " \
+ "#{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url, target: :_blank, rel: 'noopener noreferrer')}."
+ )
+ end
+ end
+
+ context 'format is not specified' do
+ it 'returns text' do
+ expect(admin_changed_password_text).to eq(
+ "An administrator changed the password for your GitLab account on #{Gitlab.config.gitlab.url}."
+ )
+ end
+ end
+ end
+
+ describe '#contact_your_administrator_text' do
+ it 'returns the message to contact the administrator' do
+ expect(contact_your_administrator_text).to eq(
+ _('Please contact your administrator with any questions.')
+ )
+ end
+ end
+
describe 'password_reset_token_valid_time' do
def validate_time_string(time_limit, expected_string)
Devise.reset_password_within = time_limit
diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb
index c4ad239f9d7..b8b0d66d8d8 100644
--- a/spec/lib/backup/repository_spec.rb
+++ b/spec/lib/backup/repository_spec.rb
@@ -47,15 +47,27 @@ RSpec.describe Backup::Repository do
end
it 'project query raises an error' do
- allow(Project).to receive(:find_each).and_raise(ActiveRecord::StatementTimeout)
+ allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(ActiveRecord::StatementTimeout)
end
end
+
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
+ end.count
+
+ create_list(:project, 2, :wiki_repo)
+
+ expect do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
+ end.not_to exceed_query_limit(control_count)
+ end
end
[4, 10].each do |max_storage_concurrency|
- context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do
+ context "max_storage_concurrency #{max_storage_concurrency}" do
it 'creates the expected number of threads' do
expect(Thread).to receive(:new)
.exactly(storage_keys.length * (max_storage_concurrency + 1)).times
@@ -89,7 +101,7 @@ RSpec.describe Backup::Repository do
end
it 'project query raises an error' do
- allow(Project).to receive_message_chain('for_repository_storage.find_each').and_raise(ActiveRecord::StatementTimeout)
+ allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(ActiveRecord::StatementTimeout)
end
@@ -102,6 +114,18 @@ RSpec.describe Backup::Repository do
end
end
end
+
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
+ end.count
+
+ create_list(:project, 2, :wiki_repo)
+
+ expect do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
+ end.not_to exceed_query_limit(control_count)
+ end
end
end
end
diff --git a/spec/mailers/devise_mailer_spec.rb b/spec/mailers/devise_mailer_spec.rb
index 4637df9c8a3..2ee15308400 100644
--- a/spec/mailers/devise_mailer_spec.rb
+++ b/spec/mailers/devise_mailer_spec.rb
@@ -4,6 +4,9 @@ require 'spec_helper'
require 'email_spec'
RSpec.describe DeviseMailer do
+ include EmailSpec::Matchers
+ include_context 'gitlab email notification'
+
describe "#confirmation_instructions" do
subject { described_class.confirmation_instructions(user, 'faketoken', {}) }
@@ -35,4 +38,30 @@ RSpec.describe DeviseMailer do
end
end
end
+
+ describe '#password_change_by_admin' do
+ subject { described_class.password_change_by_admin(user) }
+
+ let_it_be(:user) { create(:user) }
+
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
+
+ it 'is sent to the user' do
+ is_expected.to deliver_to user.email
+ end
+
+ it 'has the correct subject' do
+ is_expected.to have_subject /^Password changed by administrator$/i
+ end
+
+ it 'includes the correct content' do
+ is_expected.to have_body_text /An administrator changed the password for your GitLab account/
+ end
+
+ it 'includes a link to GitLab' do
+ is_expected.to have_body_text /#{Gitlab.config.gitlab.url}/
+ end
+ end
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 0b9c06d9737..f869b586fbe 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -133,6 +133,7 @@ RSpec.describe Issue do
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:test_case) { create(:quality_test_case, project: project) }
it 'gives issues with the given issue type' do
expect(described_class.with_issue_type('issue'))
@@ -140,8 +141,8 @@ RSpec.describe Issue do
end
it 'gives issues with the given issue type' do
- expect(described_class.with_issue_type(%w(issue incident)))
- .to contain_exactly(issue, incident)
+ expect(described_class.with_issue_type(%w(issue incident test_case)))
+ .to contain_exactly(issue, incident, test_case)
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 74a36daa727..3733e47334a 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -180,6 +180,58 @@ RSpec.describe User do
end.to have_enqueued_job.on_queue('mailers').exactly(:twice)
end
end
+
+ context 'emails sent on changing password' do
+ context 'when password is updated' do
+ context 'default behaviour' do
+ it 'enqueues the `password changed` email' do
+ user.password = User.random_password
+
+ expect { user.save! }.to have_enqueued_mail(DeviseMailer, :password_change)
+ end
+
+ it 'does not enqueue the `admin changed your password` email' do
+ user.password = User.random_password
+
+ expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+ end
+
+ context '`admin changed your password` email' do
+ it 'is enqueued only when explicitly allowed' do
+ user.password = User.random_password
+ user.send_only_admin_changed_your_password_notification!
+
+ expect { user.save! }.to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+
+ it '`password changed` email is not enqueued if it is explicitly allowed' do
+ user.password = User.random_password
+ user.send_only_admin_changed_your_password_notification!
+
+ expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_changed)
+ end
+
+ it 'is not enqueued if sending notifications on password updates is turned off as per Devise config' do
+ user.password = User.random_password
+ user.send_only_admin_changed_your_password_notification!
+
+ allow(Devise).to receive(:send_password_change_notification).and_return(false)
+
+ expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+ end
+ end
+
+ context 'when password is not updated' do
+ it 'does not enqueue the `admin changed your password` email even if explicitly allowed' do
+ user.name = 'John'
+ user.send_only_admin_changed_your_password_notification!
+
+ expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+ end
+ end
end
describe 'validations' do
@@ -4491,6 +4543,44 @@ RSpec.describe User do
end
end
+ describe '#notification_settings_for_groups' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:groups) { create_list(:group, 2) }
+
+ subject { user.notification_settings_for_groups(arg) }
+
+ before do
+ groups.each do |group|
+ group.add_maintainer(user)
+ end
+ end
+
+ shared_examples_for 'notification_settings_for_groups method' do
+ it 'returns NotificationSetting objects for provided groups', :aggregate_failures do
+ expect(subject.count).to eq(groups.count)
+ expect(subject.map(&:source_id)).to match_array(groups.map(&:id))
+ end
+ end
+
+ context 'when given an ActiveRecord relationship' do
+ let_it_be(:arg) { Group.where(id: groups.map(&:id)) }
+
+ it_behaves_like 'notification_settings_for_groups method'
+
+ it 'uses #select to maintain lazy querying behavior' do
+ expect(arg).to receive(:select).and_call_original
+
+ subject
+ end
+ end
+
+ context 'when given an Array of Groups' do
+ let_it_be(:arg) { groups }
+
+ it_behaves_like 'notification_settings_for_groups method'
+ end
+ end
+
describe '#notification_email_for' do
let(:user) { create(:user) }
let(:group) { create(:group) }
diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb
index ceb32476f7d..89b3ecdabb9 100644
--- a/spec/requests/api/graphql/project/issues_spec.rb
+++ b/spec/requests/api/graphql/project/issues_spec.rb
@@ -292,4 +292,65 @@ RSpec.describe 'getting an issue list for a project' do
expect(alert_titles).to contain_exactly(*expected_titles)
end
end
+
+ context 'fetching labels' do
+ let(:fields) do
+ <<~QUERY
+ edges {
+ node {
+ id
+ labels {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => project.full_path },
+ query_graphql_field('issues', {}, fields)
+ )
+ end
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:label1) { create(:label, project: project) }
+ let_it_be(:label2) { create(:label, project: project) }
+ let_it_be(:issue1) { create(:issue, project: project, labels: [label1]) }
+ let_it_be(:issue2) { create(:issue, project: project, labels: [label2]) }
+ let_it_be(:issues) { [issue1, issue2] }
+
+ before do
+ stub_feature_flags(graphql_lookahead_support: true)
+ end
+
+ def response_label_ids(response_data)
+ response_data.map do |edge|
+ edge['node']['labels']['nodes'].map { |u| u['id'] }
+ end.flatten
+ end
+
+ def labels_as_global_ids(issues)
+ issues.map(&:labels).flatten.map(&:to_global_id).map(&:to_s)
+ end
+
+ it 'avoids N+1 queries', :aggregate_failures do
+ control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
+ expect(issues_data.count).to eq(2)
+ expect(response_label_ids(issues_data)).to match_array(labels_as_global_ids(issues))
+
+ issues.append create(:issue, project: project, labels: [create(:label, project: project)])
+
+ expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control)
+ # graphql_data is memoized (see spec/support/helpers/graphql_helpers.rb:271)
+ # so we have to parse the body ourselves the second time
+ response_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['edges']
+ expect(response_data.count).to eq(3)
+ expect(response_label_ids(response_data)).to match_array(labels_as_global_ids(issues))
+ end
+ end
end
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 39cff2ad74e..806b586ef49 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -914,6 +914,50 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
expect(response).to have_gitlab_http_status(:ok)
end
+ context 'updating password' do
+ def update_password(user, admin, password = User.random_password)
+ put api("/users/#{user.id}", admin), params: { password: password }
+ end
+
+ context 'admin updates their own password' do
+ it 'does not force reset on next login' do
+ update_password(admin, admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(user.reload.password_expired?).to eq(false)
+ end
+
+ it 'does not enqueue the `admin changed your password` email' do
+ expect { update_password(admin, admin) }
+ .not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+
+ it 'enqueues the `password changed` email' do
+ expect { update_password(admin, admin) }
+ .to have_enqueued_mail(DeviseMailer, :password_change)
+ end
+ end
+
+ context 'admin updates the password of another user' do
+ it 'forces reset on next login' do
+ update_password(user, admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(user.reload.password_expired?).to eq(true)
+ end
+
+ it 'enqueues the `admin changed your password` email' do
+ expect { update_password(user, admin) }
+ .to have_enqueued_mail(DeviseMailer, :password_change_by_admin)
+ end
+
+ it 'does not enqueue the `password changed` email' do
+ expect { update_password(user, admin) }
+ .not_to have_enqueued_mail(DeviseMailer, :password_change)
+ end
+ end
+ end
+
it "updates user with new bio" do
put api("/users/#{user.id}", admin), params: { bio: 'new test bio' }
@@ -940,13 +984,6 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
expect(user.reload.bio).to eq('')
end
- it "updates user with new password and forces reset on next login" do
- put api("/users/#{user.id}", admin), params: { password: '12345678' }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(user.reload.password_expires_at).to be <= Time.now
- end
-
it "updates user with organization" do
put api("/users/#{user.id}", admin), params: { organization: 'GitLab' }
@@ -1397,7 +1434,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
end
end
- describe 'POST /users/:id/keys' do
+ describe 'POST /users/:id/gpg_keys' do
it 'does not create invalid GPG key' do
post api("/users/#{user.id}/gpg_keys", admin)
diff --git a/spec/requests/profiles/notifications_controller_spec.rb b/spec/requests/profiles/notifications_controller_spec.rb
index 633375de6aa..d162f5b220d 100644
--- a/spec/requests/profiles/notifications_controller_spec.rb
+++ b/spec/requests/profiles/notifications_controller_spec.rb
@@ -25,8 +25,7 @@ RSpec.describe 'view user notifications' do
end
describe 'GET /profile/notifications' do
- # To be fixed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40457
- it 'has an N+1 due to an additional groups (with no parent group) - but should not' do
+ it 'does not have an N+1 due to an additional groups (with no parent group)' do
get_profile_notifications
control = ActiveRecord::QueryRecorder.new do
@@ -37,7 +36,7 @@ RSpec.describe 'view user notifications' do
expect do
get_profile_notifications
- end.to exceed_query_limit(control)
+ end.not_to exceed_query_limit(control)
end
end
end
diff --git a/spec/services/merge_requests/create_pipeline_service_spec.rb b/spec/services/merge_requests/create_pipeline_service_spec.rb
index db46bd37eea..4dd70627977 100644
--- a/spec/services/merge_requests/create_pipeline_service_spec.rb
+++ b/spec/services/merge_requests/create_pipeline_service_spec.rb
@@ -5,13 +5,14 @@ require 'spec_helper'
RSpec.describe MergeRequests::CreatePipelineService do
include ProjectForksHelper
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:project, reload: true) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let(:service) { described_class.new(project, actor, params) }
let(:actor) { user }
let(:params) { {} }
before do
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false)
project.add_developer(user)
end
@@ -58,9 +59,27 @@ RSpec.describe MergeRequests::CreatePipelineService do
expect(subject.project).to eq(project)
end
- context 'when ci_allow_to_create_merge_request_pipelines_in_target_project feature flag is disabled' do
+ context 'when source branch is protected' do
+ context 'when actor does not have permission to update the protected branch in target project' do
+ let!(:protected_branch) { create(:protected_branch, name: '*', project: project) }
+
+ it 'creates a pipeline in the source project' do
+ expect(subject.project).to eq(source_project)
+ end
+ end
+
+ context 'when actor has permission to update the protected branch in target project' do
+ let!(:protected_branch) { create(:protected_branch, :developers_can_merge, name: '*', project: project) }
+
+ it 'creates a pipeline in the target project' do
+ expect(subject.project).to eq(project)
+ end
+ end
+ end
+
+ context 'when ci_disallow_to_create_merge_request_pipelines_in_target_project feature flag is enabled' do
before do
- stub_feature_flags(ci_allow_to_create_merge_request_pipelines_in_target_project: false)
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: true)
end
it 'creates a pipeline in the source project' do
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index bb62e594e7a..5ccefe3d129 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -212,6 +212,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do
end
before do
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false)
target_project.add_developer(assignee)
target_project.add_maintainer(user)
end
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 0696e8a247f..46031330750 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -225,6 +225,10 @@ RSpec.describe MergeRequests::RefreshService do
context 'when service runs on forked project' do
let(:project) { @fork_project }
+ before do
+ stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false)
+ end
+
it 'creates detached merge request pipeline for fork merge request', :sidekiq_inline do
expect { subject }
.to change { @fork_merge_request.pipelines_for_merge_request.count }.by(1)