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>2021-05-17 21:10:42 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-17 21:10:42 +0300
commit49bb78aac34a111c0fb13aae3a83b078be351fd3 (patch)
tree510df08e78b39ef88631f8f25bdc371a4661caa9
parent68c476dbd8a2c670aeeebffce8b63b554a3ac7f0 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/reports.gitlab-ci.yml111
-rw-r--r--.gitlab/ci/rules.gitlab-ci.yml10
-rw-r--r--app/assets/javascripts/editor/extensions/editor_lite_extension_base.js2
-rw-r--r--app/assets/javascripts/frequent_items/constants.js25
-rw-r--r--app/assets/javascripts/frequent_items/store/index.js20
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/nav/components/top_nav_app.vue59
-rw-r--r--app/assets/javascripts/nav/components/top_nav_container_view.vue74
-rw-r--r--app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue144
-rw-r--r--app/assets/javascripts/nav/components/top_nav_menu_item.vue31
-rw-r--r--app/assets/javascripts/nav/index.js12
-rw-r--r--app/assets/javascripts/nav/mount.js23
-rw-r--r--app/assets/javascripts/nav/stores/index.js4
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss88
-rw-r--r--app/assets/stylesheets/framework/header.scss36
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/themes/_dark.scss2
-rw-r--r--app/assets/stylesheets/utilities.scss18
-rw-r--r--app/controllers/boards/issues_controller.rb4
-rw-r--r--app/controllers/projects/pipelines_controller.rb6
-rw-r--r--app/helpers/boards_helper.rb6
-rw-r--r--app/helpers/issues_helper.rb16
-rw-r--r--app/helpers/version_check_helper.rb14
-rw-r--r--app/models/board.rb6
-rw-r--r--app/models/concerns/enums/ci/commit_status.rb1
-rw-r--r--app/models/concerns/relative_positioning.rb8
-rw-r--r--app/models/issue.rb12
-rw-r--r--app/models/merge_request.rb1
-rw-r--r--app/models/namespace.rb4
-rw-r--r--app/presenters/commit_status_presenter.rb1
-rw-r--r--app/services/ci/create_downstream_pipeline_service.rb19
-rw-r--r--app/services/issues/update_service.rb2
-rw-r--r--app/services/submit_usage_ping_service.rb2
-rw-r--r--app/views/groups/boards/show.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml2
-rw-r--r--app/views/layouts/nav/_combined_menu.html.haml3
-rw-r--r--app/views/layouts/nav/_top_nav.html.haml7
-rw-r--r--app/views/layouts/nav/groups_dropdown/_show.html.haml2
-rw-r--r--app/views/layouts/nav/projects_dropdown/_show.html.haml2
-rw-r--r--app/views/projects/issues/_issues.html.haml3
-rw-r--r--app/views/shared/_issues.html.haml4
-rw-r--r--app/views/shared/alerts/_positioning_disabled.html.haml2
-rw-r--r--app/views/shared/boards/_show.html.haml2
-rw-r--r--app/workers/all_queues.yml9
-rw-r--r--app/workers/ci/retry_pipeline_worker.rb19
-rw-r--r--app/workers/issue_placement_worker.rb4
-rw-r--r--app/workers/issue_rebalancing_worker.rb5
-rw-r--r--app/workers/ssh_keys/expired_notification_worker.rb4
-rw-r--r--app/workers/ssh_keys/expiring_soon_notification_worker.rb4
-rw-r--r--changelogs/unreleased/271242_memoize_merge_request_policy.yml5
-rw-r--r--changelogs/unreleased/327315-enable-ci_wildcard_file_paths.yml5
-rw-r--r--changelogs/unreleased/329664-feature-flag-enable-inmemory-remotes-for-findremoterootrefs.yml5
-rw-r--r--changelogs/unreleased/allow-migrating-sidekiq-scheduled-and-retry-jobs.yml5
-rw-r--r--changelogs/unreleased/mc-backstage-make-pipeline-retry-async.yml5
-rw-r--r--config/application.rb3
-rw-r--r--config/feature_flags/development/background_pipeline_retry_endpoint.yml8
-rw-r--r--config/feature_flags/development/ci_drop_cyclical_triggered_pipelines.yml8
-rw-r--r--config/feature_flags/development/ci_wildcard_file_paths.yml2
-rw-r--r--config/feature_flags/development/find_remote_root_refs_inmemory.yml2
-rw-r--r--config/feature_flags/development/merge_base_pipeline_for_metrics_comparison.yml8
-rw-r--r--config/feature_flags/ops/block_issue_repositioning.yml8
-rw-r--r--config/metrics/counts_28d/20210216181139_issues.yml2
-rw-r--r--config/metrics/counts_28d/20210216181508_i_quickactions_approve_monthly.yml2
-rw-r--r--config/metrics/counts_7d/20210216181506_i_quickactions_approve_weekly.yml2
-rw-r--r--config/metrics/counts_all/20210216181102_issues.yml2
-rw-r--r--config/metrics/counts_all/20210216181115_issues.yml2
-rw-r--r--config/metrics/counts_all/20210216181252_boards.yml2
-rw-r--r--config/metrics/license/20210201124933_uuid.yml2
-rw-r--r--config/metrics/license/20210204124827_hostname.yml2
-rw-r--r--config/metrics/schema.json2
-rw-r--r--doc/ci/yaml/README.md19
-rw-r--r--doc/integration/jira/issues.md43
-rw-r--r--doc/raketasks/index.md3
-rw-r--r--doc/raketasks/sidekiq_job_migration.md40
-rw-r--r--doc/user/group/epics/manage_epics.md14
-rw-r--r--doc/user/project/repository/index.md4
-rw-r--r--doc/user/project/settings/import_export.md2
-rw-r--r--doc/user/project/settings/index.md7
-rw-r--r--lib/banzai/reference_parser/merge_request_parser.rb12
-rw-r--r--lib/gitlab/ci/features.rb8
-rw-r--r--lib/gitlab/ci/status/build/failed.rb1
-rw-r--r--lib/gitlab/relative_positioning.rb1
-rw-r--r--lib/gitlab/sidekiq_migrate_jobs.rb72
-rw-r--r--lib/gitlab/usage_data_metrics.rb2
-rw-r--r--lib/tasks/gitlab/sidekiq.rake23
-rw-r--r--lib/version_check.rb6
-rw-r--r--locale/gitlab.pot21
-rw-r--r--qa/qa/page/project/web_ide/edit.rb12
-rw-r--r--qa/qa/specs/features/browser_ui/5_package/container_registry_spec.rb2
-rw-r--r--spec/controllers/admin/dev_ops_report_controller_spec.rb8
-rw-r--r--spec/controllers/boards/issues_controller_spec.rb13
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb35
-rw-r--r--spec/features/admin/admin_mode_spec.rb4
-rw-r--r--spec/frontend/nav/components/top_nav_app_spec.js68
-rw-r--r--spec/frontend/nav/components/top_nav_container_view_spec.js114
-rw-r--r--spec/frontend/nav/components/top_nav_dropdown_menu_spec.js157
-rw-r--r--spec/frontend/nav/components/top_nav_menu_item_spec.js74
-rw-r--r--spec/frontend/nav/mock_data.js35
-rw-r--r--spec/helpers/issues_helper_spec.rb61
-rw-r--r--spec/lib/banzai/reference_parser/merge_request_parser_spec.rb47
-rw-r--r--spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb215
-rw-r--r--spec/lib/gitlab/usage/metric_definition_spec.rb4
-rw-r--r--spec/lib/version_check_spec.rb11
-rw-r--r--spec/models/board_spec.rb42
-rw-r--r--spec/models/issue_spec.rb26
-rw-r--r--spec/models/merge_request_spec.rb14
-rw-r--r--spec/services/ci/create_downstream_pipeline_service_spec.rb43
-rw-r--r--spec/services/issues/update_service_spec.rb36
-rw-r--r--spec/services/submit_usage_ping_service_spec.rb2
-rw-r--r--spec/services/users/build_service_spec.rb244
-rw-r--r--spec/spec_helper.rb6
-rw-r--r--spec/tasks/gitlab/sidekiq_rake_spec.rb53
-rw-r--r--spec/views/help/index.html.haml_spec.rb9
-rw-r--r--spec/workers/ci/retry_pipeline_worker_spec.rb51
-rw-r--r--spec/workers/issue_placement_worker_spec.rb16
-rw-r--r--spec/workers/issue_rebalancing_worker_spec.rb16
116 files changed, 2261 insertions, 356 deletions
diff --git a/.gitlab/ci/reports.gitlab-ci.yml b/.gitlab/ci/reports.gitlab-ci.yml
index a05a4798880..65e4db5924b 100644
--- a/.gitlab/ci/reports.gitlab-ci.yml
+++ b/.gitlab/ci/reports.gitlab-ci.yml
@@ -1,8 +1,9 @@
include:
- template: Jobs/Code-Quality.gitlab-ci.yml
-# - template: Security/SAST.gitlab-ci.yml
-# - template: Security/Dependency-Scanning.gitlab-ci.yml
-# - template: Security/DAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
+ - template: Security/Secret-Detection.gitlab-ci.yml
+ - template: Security/Dependency-Scanning.gitlab-ci.yml
+ - template: Security/License-Scanning.gitlab-ci.yml
code_quality:
extends:
@@ -13,85 +14,55 @@ code_quality:
- gl-code-quality-report.json # GitLab-specific
rules: !reference [".reports:rules:code_quality", rules]
-# We need to duplicate this job's definition because the rules
-# defined in the extended jobs rely on local YAML anchors
-# (`*if-default-refs`)
-.sast:
+.sast-analyzer:
+ # We need to re-`extends` from `sast` as the `extends` here overrides the one from the template.
extends:
- .default-retry
- - .reports:rules:sast
- stage: test
- # `needs: []` starts the job immediately in the pipeline
- # https://docs.gitlab.com/ee/ci/yaml/README.html#needs
+ - sast
needs: []
artifacts:
paths:
- gl-sast-report.json # GitLab-specific
- reports:
- sast: gl-sast-report.json
expire_in: 1 week # GitLab-specific
variables:
- DOCKER_TLS_CERTDIR: ""
- SAST_ANALYZER_IMAGE_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers"
- SAST_ANALYZER_IMAGE_TAG: 2
SAST_BRAKEMAN_LEVEL: 2 # GitLab-specific
- SAST_EXCLUDED_PATHS: qa,spec,doc,ee/spec,config/gitlab.yml.example # GitLab-specific
+ SAST_EXCLUDED_PATHS: "qa, spec, doc, ee/spec, config/gitlab.yml.example, tmp" # GitLab-specific
SAST_DISABLE_BABEL: "true"
- script:
- - /analyzer run
brakeman-sast:
- extends: .sast
- image:
- name: "$SAST_ANALYZER_IMAGE_PREFIX/brakeman:$SAST_ANALYZER_IMAGE_TAG"
+ rules: !reference [".reports:rules:sast", rules]
eslint-sast:
- extends: .sast
- image:
- name: "$SAST_ANALYZER_IMAGE_PREFIX/eslint:$SAST_ANALYZER_IMAGE_TAG"
+ rules: !reference [".reports:rules:sast", rules]
nodejs-scan-sast:
- extends: .sast
- image:
- name: "$SAST_ANALYZER_IMAGE_PREFIX/nodejs-scan:$SAST_ANALYZER_IMAGE_TAG"
+ rules: !reference [".reports:rules:sast", rules]
-secrets-sast:
- extends: .sast
- image:
- name: "$SAST_ANALYZER_IMAGE_PREFIX/secrets:3"
+.secret-analyzer:
+ extends: .default-retry
+ needs: []
artifacts:
paths:
- gl-secret-detection-report.json # GitLab-specific
- reports:
- sast: gl-secret-detection-report.json
expire_in: 1 week # GitLab-specific
-# We need to duplicate this job's definition because the rules
-# defined in the extended jobs rely on local YAML anchors
-# (`*if-default-refs`)
-.dependency_scanning:
+secret_detection:
+ rules: !reference [".reports:rules:secret_detection", rules]
+
+.ds-analyzer:
+ # We need to re-`extends` from `dependency_scanning` as the `extends` here overrides the one from the template.
extends:
- .default-retry
- - .reports:rules:dependency_scanning
- stage: test
+ - dependency_scanning
needs: []
variables:
- DS_MAJOR_VERSION: 2
- DS_EXCLUDED_PATHS: "qa/qa/ee/fixtures/secure_premade_reports, spec, ee/spec" # GitLab-specific
- SECURE_ANALYZERS_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers"
+ DS_EXCLUDED_PATHS: "qa/qa/ee/fixtures/secure_premade_reports, spec, ee/spec, tmp" # GitLab-specific
artifacts:
paths:
- gl-dependency-scanning-report.json # GitLab-specific
- reports:
- dependency_scanning: gl-dependency-scanning-report.json
expire_in: 1 week # GitLab-specific
- script:
- - /analyzer run
-dependency_scanning gemnasium:
- extends: .dependency_scanning
- image:
- name: "$SECURE_ANALYZERS_PREFIX/gemnasium:$DS_MAJOR_VERSION"
+gemnasium-dependency_scanning:
before_script:
# git-lfs is needed for auto-remediation
- apk add git-lfs
@@ -100,26 +71,22 @@ dependency_scanning gemnasium:
- apk add jq
# Lower execa severity based on https://gitlab.com/gitlab-org/gitlab/-/issues/223859#note_452922390
- jq '(.vulnerabilities[] | select (.cve == "yarn.lock:execa:gemnasium:05cfa2e8-2d0c-42c1-8894-638e2f12ff3d")).severity = "Medium"' gl-dependency-scanning-report.json > temp.json && mv temp.json gl-dependency-scanning-report.json
+ rules: !reference [".reports:rules:dependency_scanning", rules]
-dependency_scanning bundler-audit:
- extends: .dependency_scanning
- image:
- name: "$SECURE_ANALYZERS_PREFIX/bundler-audit:$DS_MAJOR_VERSION"
+bundler-audit-dependency_scanning:
+ rules: !reference [".reports:rules:dependency_scanning", rules]
-dependency_scanning retire-js:
- extends: .dependency_scanning
- image:
- name: "$SECURE_ANALYZERS_PREFIX/retire.js:$DS_MAJOR_VERSION"
+retire-js-dependency_scanning:
+ rules: !reference [".reports:rules:dependency_scanning", rules]
-dependency_scanning gemnasium-python:
- extends: .dependency_scanning
- image:
- name: "$SECURE_ANALYZERS_PREFIX/gemnasium-python:$DS_MAJOR_VERSION"
+gemnasium-python-dependency_scanning:
+ rules: !reference [".reports:rules:dependency_scanning", rules]
# Analyze dependencies for malicious behavior
# See https://gitlab.com/gitlab-com/gl-security/security-research/package-hunter
package_hunter:
extends:
+ - .default-retry
- .reports:rules:package_hunter
stage: test
image:
@@ -133,24 +100,14 @@ package_hunter:
- DEBUG=* HTR_user=$PACKAGE_HUNTER_USER HTR_pass=$PACKAGE_HUNTER_PASS node /usr/src/app/cli.js analyze --format gitlab gitlab.tgz | tee $CI_PROJECT_DIR/gl-dependency-scanning-report.json
artifacts:
paths:
- - gl-dependency-scanning-report.json # GitLab-specific
+ - gl-dependency-scanning-report.json
reports:
dependency_scanning: gl-dependency-scanning-report.json
- expire_in: 1 week # GitLab-specific
+ expire_in: 1 week
license_scanning:
- extends:
- - .default-retry
- - .reports:rules:license_scanning
- stage: test
- image:
- name: "registry.gitlab.com/gitlab-org/security-products/analyzers/license-finder:3"
- entrypoint: [""]
+ extends: .default-retry
needs: []
- script:
- - /run.sh analyze .
artifacts:
- reports:
- license_scanning: gl-license-scanning-report.json
expire_in: 1 week # GitLab-specific
- dependencies: []
+ rules: !reference [".reports:rules:license_scanning", rules]
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml
index 2a4b7f3acb7..c3eb16e74c3 100644
--- a/.gitlab/ci/rules.gitlab-ci.yml
+++ b/.gitlab/ci/rules.gitlab-ci.yml
@@ -1001,6 +1001,16 @@
changes: *code-backstage-qa-patterns
allow_failure: true
+.reports:rules:secret_detection:
+ rules:
+ - if: '$SECRET_DETECTION_DISABLED'
+ when: never
+ - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' # The Secret-Detection template already has a `secret_detection_default_branch` job
+ when: never
+ # - <<: *if-default-branch-refs # To be done in a later iteration: https://gitlab.com/gitlab-org/gitlab/issues/31160#note_278188255
+ - changes: *code-backstage-qa-patterns
+ allow_failure: true
+
.reports:rules:dependency_scanning:
rules:
- if: '$DEPENDENCY_SCANNING_DISABLED || $GITLAB_FEATURES !~ /\bdependency_scanning\b/'
diff --git a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
index 6f6b0a04356..05a020bd958 100644
--- a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
@@ -8,8 +8,6 @@ const createAnchor = (href) => {
const fragment = new DocumentFragment();
const el = document.createElement('a');
el.classList.add('link-anchor');
- el.setAttribute('data-qa-selector', 'line_link');
- el.setAttribute('data-qa-number', href);
el.href = href;
fragment.appendChild(el);
el.addEventListener('contextmenu', (e) => {
diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js
index 5af107d9083..9e1dcf70aa5 100644
--- a/app/assets/javascripts/frequent_items/constants.js
+++ b/app/assets/javascripts/frequent_items/constants.js
@@ -37,15 +37,16 @@ export const TRANSLATION_KEYS = {
},
};
-export const FREQUENT_ITEMS_DROPDOWNS = [
- {
- namespace: 'projects',
- key: 'project',
- vuexModule: 'frequentProjects',
- },
- {
- namespace: 'groups',
- key: 'group',
- vuexModule: 'frequentGroups',
- },
-];
+export const FREQUENT_ITEMS_PROJECTS = {
+ namespace: 'projects',
+ key: 'project',
+ vuexModule: 'frequentProjects',
+};
+
+export const FREQUENT_ITEMS_GROUPS = {
+ namespace: 'groups',
+ key: 'group',
+ vuexModule: 'frequentGroups',
+};
+
+export const FREQUENT_ITEMS_DROPDOWNS = [FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS];
diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js
index 47fad112297..1faacff84e5 100644
--- a/app/assets/javascripts/frequent_items/store/index.js
+++ b/app/assets/javascripts/frequent_items/store/index.js
@@ -13,14 +13,16 @@ export const createFrequentItemsModule = (initState = {}) => ({
state: state(initState),
});
+export const createStoreOptions = () => ({
+ modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
+ (acc, { namespace, vuexModule }) =>
+ Object.assign(acc, {
+ [vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
+ }),
+ {},
+ ),
+});
+
export const createStore = () => {
- return new Vuex.Store({
- modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
- (acc, { namespace, vuexModule }) =>
- Object.assign(acc, {
- [vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
- }),
- {},
- ),
- });
+ return new Vuex.Store(createStoreOptions());
};
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 3f22bd36a4a..6200ade3595 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -35,6 +35,7 @@ import initUsagePingConsent from './usage_ping_consent';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
+import { initTopNav } from './nav';
import 'ee_else_ce/main_ee';
@@ -80,6 +81,7 @@ initRails();
function deferredInitialisation() {
const $body = $('body');
+ initTopNav();
initBreadcrumbs();
initTodoToggle();
initLogoAnimation();
diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue
new file mode 100644
index 00000000000..f8f3ba26536
--- /dev/null
+++ b/app/assets/javascripts/nav/components/top_nav_app.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlNav, GlNavItemDropdown, GlDropdownForm, GlTooltip } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TopNavDropdownMenu from './top_nav_dropdown_menu.vue';
+
+const TOOLTIP = s__('TopNav|Switch to...');
+
+export default {
+ components: {
+ GlNav,
+ GlNavItemDropdown,
+ GlDropdownForm,
+ GlTooltip,
+ TopNavDropdownMenu,
+ },
+ props: {
+ navData: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ findTooltipTarget() {
+ // ### Why use a target function instead of `v-gl-tooltip`?
+ // To get the tooltip to align correctly, we need it to target the actual
+ // toggle button which we don't directly render.
+ return this.$el.querySelector('.js-top-nav-dropdown-toggle');
+ },
+ },
+ TOOLTIP,
+};
+</script>
+
+<template>
+ <gl-nav class="navbar-sub-nav">
+ <gl-nav-item-dropdown
+ :text="navData.activeTitle"
+ icon="dot-grid"
+ menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto!"
+ toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!"
+ no-flip
+ >
+ <gl-dropdown-form>
+ <top-nav-dropdown-menu
+ :primary="navData.primary"
+ :secondary="navData.secondary"
+ :views="navData.views"
+ />
+ </gl-dropdown-form>
+ </gl-nav-item-dropdown>
+ <gl-tooltip
+ boundary="window"
+ :boundary-padding="0"
+ :target="findTooltipTarget"
+ placement="right"
+ :title="$options.TOOLTIP"
+ />
+ </gl-nav>
+</template>
diff --git a/app/assets/javascripts/nav/components/top_nav_container_view.vue b/app/assets/javascripts/nav/components/top_nav_container_view.vue
new file mode 100644
index 00000000000..21ff3ebcd7d
--- /dev/null
+++ b/app/assets/javascripts/nav/components/top_nav_container_view.vue
@@ -0,0 +1,74 @@
+<script>
+import FrequentItemsApp from '~/frequent_items/components/app.vue';
+import eventHub from '~/frequent_items/event_hub';
+import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
+import TopNavMenuItem from './top_nav_menu_item.vue';
+
+export default {
+ components: {
+ FrequentItemsApp,
+ TopNavMenuItem,
+ VuexModuleProvider,
+ },
+ props: {
+ frequentItemsVuexModule: {
+ type: String,
+ required: true,
+ },
+ frequentItemsDropdownType: {
+ type: String,
+ required: true,
+ },
+ linksPrimary: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ linksSecondary: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ linkGroups() {
+ return [
+ { key: 'primary', links: this.linksPrimary },
+ { key: 'secondary', links: this.linksSecondary },
+ ].filter((x) => x.links?.length);
+ },
+ },
+ mounted() {
+ // For historic reasons, the frequent-items-app component requires this too start up.
+ this.$nextTick(() => {
+ eventHub.$emit(`${this.frequentItemsDropdownType}-dropdownOpen`);
+ });
+ },
+};
+</script>
+
+<template>
+ <div class="top-nav-container-view gl-display-flex gl-flex-direction-column">
+ <div class="frequent-items-dropdown-container gl-w-auto">
+ <div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!">
+ <vuex-module-provider :vuex-module="frequentItemsVuexModule">
+ <frequent-items-app v-bind="$attrs" />
+ </vuex-module-provider>
+ </div>
+ </div>
+ <div
+ v-for="({ key, links }, groupIndex) in linkGroups"
+ :key="key"
+ :class="{ 'gl-mt-3': groupIndex !== 0 }"
+ class="gl-mt-auto gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100"
+ data-testid="menu-item-group"
+ >
+ <top-nav-menu-item
+ v-for="(link, linkIndex) in links"
+ :key="link.title"
+ :menu-item="link"
+ :class="{ 'gl-mt-1': linkIndex !== 0 }"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
new file mode 100644
index 00000000000..1cbd64b501d
--- /dev/null
+++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
@@ -0,0 +1,144 @@
+<script>
+import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants';
+import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
+import TopNavContainerView from './top_nav_container_view.vue';
+import TopNavMenuItem from './top_nav_menu_item.vue';
+
+const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active';
+const SECONDARY_GROUP_CLASS = 'gl-pt-3 gl-mt-3 gl-border-1 gl-border-t-solid gl-border-gray-100';
+
+export default {
+ components: {
+ KeepAliveSlots,
+ TopNavContainerView,
+ TopNavMenuItem,
+ },
+ props: {
+ primary: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ secondary: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ views: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ activeId: '',
+ };
+ },
+ computed: {
+ menuItemGroups() {
+ return [
+ { key: 'primary', items: this.primary, classes: '' },
+ {
+ key: 'secondary',
+ items: this.secondary,
+ classes: SECONDARY_GROUP_CLASS,
+ },
+ ].filter((x) => x.items?.length);
+ },
+ allMenuItems() {
+ return this.menuItemGroups.flatMap((x) => x.items);
+ },
+ activeMenuItem() {
+ return this.allMenuItems.find((x) => x.id === this.activeId);
+ },
+ activeView() {
+ return this.activeMenuItem?.view;
+ },
+ menuClass() {
+ if (!this.activeView) {
+ return 'gl-w-full';
+ }
+
+ return '';
+ },
+ },
+ created() {
+ // Initialize activeId based on initialization prop
+ this.activeId = this.allMenuItems.find((x) => x.active)?.id;
+ },
+ methods: {
+ onClick({ id, href }) {
+ // If we're a link, let's just do the default behavior so the view won't change
+ if (href) {
+ return;
+ }
+
+ this.activeId = id;
+ },
+ menuItemClasses(menuItem) {
+ if (menuItem.id === this.activeId) {
+ return ACTIVE_CLASS;
+ }
+
+ return '';
+ },
+ },
+ FREQUENT_ITEMS_PROJECTS,
+ FREQUENT_ITEMS_GROUPS,
+ // expose for unit tests
+ ACTIVE_CLASS,
+ SECONDARY_GROUP_CLASS,
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-stretch">
+ <div
+ class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10"
+ :class="menuClass"
+ data-testid="menu-sidebar"
+ >
+ <div
+ class="gl-py-3 gl-px-5 gl-h-full gl-display-flex gl-align-items-stretch gl-flex-direction-column"
+ >
+ <div
+ v-for="group in menuItemGroups"
+ :key="group.key"
+ :class="group.classes"
+ data-testid="menu-item-group"
+ >
+ <top-nav-menu-item
+ v-for="(menu, index) in group.items"
+ :key="menu.id"
+ data-testid="menu-item"
+ :class="[{ 'gl-mt-1': index !== 0 }, menuItemClasses(menu)]"
+ :menu-item="menu"
+ @click="onClick(menu)"
+ />
+ </div>
+ </div>
+ </div>
+ <keep-alive-slots
+ v-show="activeView"
+ :slot-key="activeView"
+ class="gl-w-grid-size-40 gl-overflow-hidden gl-py-3 gl-px-5"
+ data-testid="menu-subview"
+ >
+ <template #projects>
+ <top-nav-container-view
+ :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace"
+ :frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule"
+ v-bind="views.projects"
+ />
+ </template>
+ <template #groups>
+ <top-nav-container-view
+ :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace"
+ :frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule"
+ v-bind="views.groups"
+ />
+ </template>
+ </keep-alive-slots>
+ </div>
+</template>
diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
new file mode 100644
index 00000000000..a0d92811a6f
--- /dev/null
+++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ props: {
+ menuItem: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ category="tertiary"
+ :href="menuItem.href"
+ class="top-nav-menu-item gl-display-block"
+ v-on="$listeners"
+ >
+ <span class="gl-display-flex">
+ <gl-icon v-if="menuItem.icon" :name="menuItem.icon" class="gl-mr-2!" />
+ {{ menuItem.title }}
+ <gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" />
+ </span>
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/nav/index.js b/app/assets/javascripts/nav/index.js
new file mode 100644
index 00000000000..646ce3f0ecf
--- /dev/null
+++ b/app/assets/javascripts/nav/index.js
@@ -0,0 +1,12 @@
+export const initTopNav = async () => {
+ const el = document.getElementById('js-top-nav');
+
+ if (!el) {
+ return;
+ }
+
+ // With combined_menu feature flag, there's a benefit to splitting up the import
+ const { mountTopNav } = await import(/* webpackChunkName: 'top_nav' */ './mount');
+
+ mountTopNav(el);
+};
diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js
new file mode 100644
index 00000000000..0d46ff56249
--- /dev/null
+++ b/app/assets/javascripts/nav/mount.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import App from './components/top_nav_app.vue';
+import { createStore } from './stores';
+
+Vue.use(Vuex);
+
+export const mountTopNav = (el) => {
+ const viewModel = JSON.parse(el.dataset.viewModel);
+ const store = createStore();
+
+ return new Vue({
+ el,
+ store,
+ render(h) {
+ return h(App, {
+ props: {
+ navData: viewModel,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/nav/stores/index.js b/app/assets/javascripts/nav/stores/index.js
new file mode 100644
index 00000000000..527bbdd5c3f
--- /dev/null
+++ b/app/assets/javascripts/nav/stores/index.js
@@ -0,0 +1,4 @@
+import Vuex from 'vuex';
+import { createStoreOptions } from '~/frequent_items/store';
+
+export const createStore = () => new Vuex.Store(createStoreOptions());
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index a3e3cbd3e38..894eddbe1a7 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -839,8 +839,52 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.frequent-items-dropdown-container {
display: flex;
flex-direction: row;
- width: 500px;
- height: 354px;
+ height: $grid-size * 40;
+
+ &.with-deprecated-styles {
+ width: 500px;
+ height: 354px;
+
+ .section-header,
+ .frequent-items-list-container li.section-empty {
+ padding: 0 $gl-padding;
+ }
+
+ .search-input-container {
+ position: relative;
+ padding: 4px $gl-padding;
+
+ .search-icon {
+ position: absolute;
+ top: 13px;
+ right: 25px;
+ color: $gray-300;
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ flex-direction: column;
+ width: 100%;
+ height: auto;
+ flex: 1;
+
+ .frequent-items-dropdown-sidebar,
+ .frequent-items-dropdown-content {
+ width: 100%;
+ }
+
+ .frequent-items-dropdown-sidebar {
+ border-bottom: 1px solid $border-color;
+ border-right: 0;
+ }
+ }
+
+ .frequent-items-list-container {
+ width: auto;
+ height: auto;
+ padding-bottom: 0;
+ }
+ }
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
@@ -861,26 +905,8 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
width: 70%;
}
- @include media-breakpoint-down(xs) {
- flex-direction: column;
- width: 100%;
- height: auto;
- flex: 1;
-
- .frequent-items-dropdown-sidebar,
- .frequent-items-dropdown-content {
- width: 100%;
- }
-
- .frequent-items-dropdown-sidebar {
- border-bottom: 1px solid $border-color;
- border-right: 0;
- }
- }
-
.section-header,
.frequent-items-list-container li.section-empty {
- padding: 0 $gl-padding;
color: $gl-text-color-secondary;
font-size: $gl-font-size;
}
@@ -898,36 +924,16 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
}
}
- .search-input-container {
- position: relative;
- padding: 4px $gl-padding;
-
- .search-icon {
- position: absolute;
- top: 13px;
- right: 25px;
- color: $gray-300;
- }
- }
-
.section-header {
font-weight: 700;
margin-top: 8px;
}
-
- @include media-breakpoint-down(xs) {
- .frequent-items-list-container {
- width: auto;
- height: auto;
- padding-bottom: 0;
- }
- }
}
.frequent-items-list-item-container {
.frequent-items-item-avatar-container,
.frequent-items-item-metadata-container {
- float: left;
+ flex-shrink: 0;
}
.frequent-items-item-metadata-container {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 8636cdd64b7..7566a533911 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -1,3 +1,5 @@
+$top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important;
+
.navbar-gitlab {
padding: 0 16px;
z-index: $header-zindex;
@@ -254,6 +256,7 @@
}
}
+ .top-nav-toggle,
> button {
background: transparent;
border: 0;
@@ -629,3 +632,36 @@
}
}
}
+
+.top-nav-container-view {
+ .gl-new-dropdown & .gl-search-box-by-type {
+ @include gl-m-0;
+ }
+
+ .frequent-items-list-item-container > a:hover {
+ background-color: $top-nav-hover-bg;
+ }
+}
+
+.top-nav-toggle {
+ .dropdown-icon {
+ @include gl-mr-3;
+ }
+
+ .dropdown-chevron {
+ top: 0;
+ }
+}
+
+.top-nav-menu-item {
+ color: var(--indigo-900, $theme-indigo-900) !important;
+
+ &.active,
+ &:hover {
+ background-color: $top-nav-hover-bg;
+ }
+
+ .gl-icon {
+ color: inherit !important;
+ }
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 18aa0d3013d..bfb21d7112b 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -283,6 +283,8 @@ $indigo-700: #4b4ba3;
$indigo-800: #393982;
$indigo-900: #292961;
$indigo-950: #1a1a40;
+// To do this variant right for darkmode, we need to create a variable for it.
+$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$theme-blue-50: #f4f8fc;
$theme-blue-100: #e6edf5;
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index a3c6940585e..9d98fe5c739 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -70,6 +70,7 @@ $indigo-700: #a6a6de;
$indigo-800: #d1d1f0;
$indigo-900: #ebebfa;
$indigo-950: #f7f7ff;
+$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$gray-lightest: #222;
$gray-light: $gray-50;
@@ -160,6 +161,7 @@ body.gl-dark {
--indigo-800: #{$indigo-800};
--indigo-900: #{$indigo-900};
--indigo-950: #{$indigo-950};
+ --indigo-900-alpha-008: #{$indigo-900-alpha-008};
--gl-text-color: #{$gray-900};
--border-color: #{$border-color};
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index abf94c520c3..c22a1ae1187 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -189,3 +189,21 @@ $gl-line-height-42: px-to-rem(42px);
.gl-line-height-42 {
line-height: $gl-line-height-42;
}
+
+.gl-w-grid-size-30 {
+ width: $grid-size * 30;
+}
+
+.gl-w-grid-size-40 {
+ width: $grid-size * 40;
+}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209
+.gl-max-w-none\! {
+ max-width: none !important;
+}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209
+.gl-max-h-none\! {
+ max-height: none !important;
+}
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index ad5c3d28e47..003ed45adb5 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -27,7 +27,9 @@ module Boards
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
issues = issues_from(list_service)
- Issue.move_nulls_to_end(issues) if Gitlab::Database.read_write?
+ if Gitlab::Database.read_write? && !board.disabled_for?(current_user)
+ Issue.move_nulls_to_end(issues)
+ end
render_issues(issues, list_service.metadata)
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 5d7b33cfdbf..0de8dc597ae 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -176,7 +176,11 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def retry
- pipeline.retry_failed(current_user)
+ if Gitlab::Ci::Features.background_pipeline_retry_endpoint?(@project)
+ ::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker
+ else
+ pipeline.retry_failed(current_user)
+ end
respond_to do |format|
format.html do
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index ecbc4972b60..f72f8bfd151 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -10,7 +10,7 @@ module BoardsHelper
boards_endpoint: @boards_endpoint,
lists_endpoint: board_lists_path(board),
board_id: board.id,
- disabled: disabled?.to_s,
+ disabled: board.disabled_for?(current_user).to_s,
root_path: root_path,
full_path: full_path,
bulk_update_path: @bulk_issues_path,
@@ -105,10 +105,6 @@ module BoardsHelper
can?(current_user, :admin_issue, current_board_parent)
end
- def disabled?
- !can?(current_user, :create_non_backlog_issues, board)
- end
-
def board_list_data
include_descendant_groups = @group&.present?
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 6b693125f4d..1449725fb2b 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -9,6 +9,22 @@ module IssuesHelper
classes.join(' ')
end
+ def issue_manual_ordering_class
+ is_sorting_by_relative_position = @sort == 'relative_position'
+
+ if is_sorting_by_relative_position && !issue_repositioning_disabled?
+ "manual-ordering"
+ end
+ end
+
+ def issue_repositioning_disabled?
+ if @group
+ @group.root_ancestor.issue_repositioning_disabled?
+ elsif @project
+ @project.root_namespace.issue_repositioning_disabled?
+ end
+ end
+
def status_box_class(item)
if item.try(:expired?)
'status-box-expired'
diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb
index 12a812b373b..6f94c241914 100644
--- a/app/helpers/version_check_helper.rb
+++ b/app/helpers/version_check_helper.rb
@@ -11,16 +11,24 @@ module VersionCheckHelper
def link_to_version
if Gitlab.pre_release?
- commit_link = link_to(Gitlab.revision, Gitlab::COM_URL + namespace_project_commits_path('gitlab-org', source_code_project, Gitlab.revision))
+ commit_link = link_to(Gitlab.revision, source_host_url + namespace_project_commits_path(source_code_group, source_code_project, Gitlab.revision))
[Gitlab::VERSION, content_tag(:small, commit_link)].join(' ').html_safe
else
- link_to Gitlab::VERSION, Gitlab::COM_URL + namespace_project_tag_path('gitlab-org', source_code_project, "v#{Gitlab::VERSION}")
+ link_to Gitlab::VERSION, source_host_url + namespace_project_tag_path(source_code_group, source_code_project, "v#{Gitlab::VERSION}")
end
end
+ def source_host_url
+ Gitlab::COM_URL
+ end
+
+ def source_code_group
+ 'gitlab-org'
+ end
+
def source_code_project
'gitlab-foss'
end
end
-VersionCheckHelper.prepend_mod_with('VersionCheckHelper')
+VersionCheckHelper.prepend_mod
diff --git a/app/models/board.rb b/app/models/board.rb
index c13f17215df..7938819b6e4 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -45,6 +45,12 @@ class Board < ApplicationRecord
def to_type
self.class.to_type
end
+
+ def disabled_for?(current_user)
+ namespace = group_board? ? resource_parent.root_ancestor : resource_parent.root_namespace
+
+ namespace.issue_repositioning_disabled? || !Ability.allowed?(current_user, :create_non_backlog_issues, self)
+ end
end
Board.prepend_mod_with('Board')
diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb
index b4a83aef103..2e368b12cb7 100644
--- a/app/models/concerns/enums/ci/commit_status.rb
+++ b/app/models/concerns/enums/ci/commit_status.rb
@@ -23,6 +23,7 @@ module Enums
user_blocked: 14,
project_deleted: 15,
ci_quota_exceeded: 16,
+ pipeline_loop_detected: 17,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
invalid_bridge_trigger: 1_003,
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index 36c7db8456c..75dfed6d58f 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -79,6 +79,8 @@ module RelativePositioning
objects = objects.reject(&:relative_position)
return 0 if objects.empty?
+ objects.first.check_repositioning_allowed!
+
number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each
representative = RelativePositioning.mover.context(objects.first)
@@ -123,6 +125,12 @@ module RelativePositioning
::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION))
end
+ # To be overriden on child classes whenever
+ # blocking position updates is necessary.
+ def check_repositioning_allowed!
+ nil
+ end
+
def move_between(before, after)
before, after = [before, after].sort_by(&:relative_position) if before && after
diff --git a/app/models/issue.rb b/app/models/issue.rb
index c182baaf850..2077f9bfdbb 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -272,6 +272,18 @@ class Issue < ApplicationRecord
"id DESC")
end
+ # Temporary disable moving null elements because of performance problems
+ # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
+ def check_repositioning_allowed!
+ if blocked_for_repositioning?
+ raise ::Gitlab::RelativePositioning::IssuePositioningDisabled, "Issue relative position changes temporarily disabled."
+ end
+ end
+
+ def blocked_for_repositioning?
+ resource_parent.root_namespace&.issue_repositioning_disabled?
+ end
+
def hook_attrs
Gitlab::HookData::IssueBuilder.new(self).build
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 751dc4b762d..aaef56418d2 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -37,6 +37,7 @@ class MergeRequest < ApplicationRecord
SORTING_PREFERENCE_FIELD = :merge_requests_sort
ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = {
+ 'Ci::CompareMetricsReportsService' => ->(project) { ::Gitlab::Ci::Features.merge_base_pipeline_for_metrics_comparison?(project) },
'Ci::CompareCodequalityReportsService' => ->(project) { true }
}.freeze
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 8961cb082aa..8f03c6145cb 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -425,6 +425,10 @@ class Namespace < ApplicationRecord
created_at >= 90.days.ago
end
+ def issue_repositioning_disabled?
+ Feature.enabled?(:block_issue_repositioning, self, type: :ops, default_enabled: :yaml)
+ end
+
private
def expire_child_caches
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 6b783226beb..8ef6e2b7962 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -15,6 +15,7 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
scheduler_failure: 'The scheduler failed to assign job to the runner, please try again or contact system administrator',
data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator',
forward_deployment_failure: 'The deployment job is older than the previously succeeded deployment job, and therefore cannot be run',
+ pipeline_loop_detected: 'This job could not be executed because it would create infinitely looping pipelines',
invalid_bridge_trigger: 'This job could not be executed because downstream pipeline trigger definition is invalid',
downstream_bridge_project_not_found: 'This job could not be executed because downstream bridge project could not be found',
insufficient_bridge_permissions: 'This job could not be executed because of insufficient permissions to create a downstream pipeline',
diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb
index 93f0338fcba..64a99e404c6 100644
--- a/app/services/ci/create_downstream_pipeline_service.rb
+++ b/app/services/ci/create_downstream_pipeline_service.rb
@@ -85,6 +85,12 @@ module Ci
return false
end
+ if has_cyclic_dependency?
+ @bridge.drop!(:pipeline_loop_detected)
+
+ return false
+ end
+
true
end
@@ -109,11 +115,24 @@ module Ci
end
end
+ def has_cyclic_dependency?
+ return false if @bridge.triggers_child_pipeline?
+
+ if Feature.enabled?(:ci_drop_cyclical_triggered_pipelines, @bridge.project, default_enabled: :yaml)
+ checksums = @bridge.pipeline.base_and_ancestors.map { |pipeline| config_checksum(pipeline) }
+ checksums.uniq.length != checksums.length
+ end
+ end
+
def has_max_descendants_depth?
return false unless @bridge.triggers_child_pipeline?
ancestors_of_new_child = @bridge.pipeline.base_and_ancestors(same_project: true)
ancestors_of_new_child.count > MAX_DESCENDANTS_DEPTH
end
+
+ def config_checksum(pipeline)
+ [pipeline.project_id, pipeline.ref].hash
+ end
end
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 899e03d1570..af5029f8364 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -100,6 +100,8 @@ module Issues
end
def handle_move_between_ids(issue)
+ issue.check_repositioning_allowed! if params[:move_between_ids]
+
super
rebalance_if_needed(issue)
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
index 037dd1c49d5..4942dd0e913 100644
--- a/app/services/submit_usage_ping_service.rb
+++ b/app/services/submit_usage_ping_service.rb
@@ -73,3 +73,5 @@ class SubmitUsagePingService
end
end
end
+
+SubmitUsagePingService.prepend_mod
diff --git a/app/views/groups/boards/show.html.haml b/app/views/groups/boards/show.html.haml
index 92838fa4b11..dbbf78eed00 100644
--- a/app/views/groups/boards/show.html.haml
+++ b/app/views/groups/boards/show.html.haml
@@ -1 +1,3 @@
+= render 'shared/alerts/positioning_disabled'
+
= render "shared/boards/show", board: @board, group: true
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index bd0a9d5c0ed..ae333cffb84 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -20,7 +20,7 @@
= _('Next')
- if Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml)
- = render "layouts/nav/combined_menu"
+ = render "layouts/nav/top_nav"
- else
- if current_user
= render "layouts/nav/dashboard"
diff --git a/app/views/layouts/nav/_combined_menu.html.haml b/app/views/layouts/nav/_combined_menu.html.haml
deleted file mode 100644
index db5a7012e8f..00000000000
--- a/app/views/layouts/nav/_combined_menu.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-%button{ type: 'button', data: { toggle: "dropdown" } }
- = sprite_icon('ellipsis_v')
- = _('Projects')
diff --git a/app/views/layouts/nav/_top_nav.html.haml b/app/views/layouts/nav/_top_nav.html.haml
new file mode 100644
index 00000000000..50c003f8e13
--- /dev/null
+++ b/app/views/layouts/nav/_top_nav.html.haml
@@ -0,0 +1,7 @@
+- view_model = top_nav_view_model(project: @project, group: @group)
+%ul.list-unstyled.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } }
+ %li
+ %a.top-nav-toggle{ href: '#', type: 'button', data: { toggle: "dropdown" } }
+ = sprite_icon('dot-grid', css_class: "dropdown-icon")
+ = view_model[:activeTitle]
+ = sprite_icon('chevron-down')
diff --git a/app/views/layouts/nav/groups_dropdown/_show.html.haml b/app/views/layouts/nav/groups_dropdown/_show.html.haml
index 168e9035f8f..036647e2be1 100644
--- a/app/views/layouts/nav/groups_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/groups_dropdown/_show.html.haml
@@ -3,7 +3,7 @@
-# Please see [this MR][1] for more context.
-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587
- group_meta = { id: @group.id, name: @group.name, namespace: @group.full_name, web_url: group_path(@group), avatar_url: @group.avatar_url } if @group&.persisted?
-.frequent-items-dropdown-container
+.frequent-items-dropdown-container.with-deprecated-styles
.frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/groups#index') do
diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml
index 92c68cb612f..2517508ba6c 100644
--- a/app/views/layouts/nav/projects_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml
@@ -3,7 +3,7 @@
-# Please see [this MR][1] for more context.
-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587
- project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted?
-.frequent-items-dropdown-container
+.frequent-items-dropdown-container.with-deprecated-styles
.frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/projects#index') do
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index ef602da72e5..e4d072a9472 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -1,4 +1,5 @@
- is_project_overview = local_assigns.fetch(:is_project_overview, false)
+= render 'shared/alerts/positioning_disabled'
- if Feature.enabled?(:vue_issuables_list, @project) && !is_project_overview
- data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
@@ -15,7 +16,7 @@
'scoped-labels-available': scoped_labels_available?(@project).to_json } }
- else
- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
- %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') }
+ %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class }
= render partial: "projects/issues/issue", collection: @issues
- if @issues.blank?
= render empty_state_path
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
index eb12e9d463c..6eb736b0710 100644
--- a/app/views/shared/_issues.html.haml
+++ b/app/views/shared/_issues.html.haml
@@ -1,5 +1,7 @@
+= render 'shared/alerts/positioning_disabled'
+
- if @issues.to_a.any?
- %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } }
+ %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class, data: { group_full_path: @group&.full_path } }
= render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab"
- else
diff --git a/app/views/shared/alerts/_positioning_disabled.html.haml b/app/views/shared/alerts/_positioning_disabled.html.haml
new file mode 100644
index 00000000000..91c1d3463d8
--- /dev/null
+++ b/app/views/shared/alerts/_positioning_disabled.html.haml
@@ -0,0 +1,2 @@
+- if issue_repositioning_disabled?
+ = render 'shared/alert_info', body: _('Issues manual ordering is temporarily disabled for technical reasons.')
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index bf70149812a..c1a50cfe718 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -7,6 +7,8 @@
- breadcrumb_title _("Epic Boards")
- else
- breadcrumb_title _("Issue Boards")
+ = render 'shared/alerts/positioning_disabled'
+
- page_title("#{board.name}", _("Boards"))
- add_page_specific_style 'page_bundles/boards'
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 43611cef83f..e1dce5962d2 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -1515,6 +1515,15 @@
:weight: 3
:idempotent:
:tags: []
+- :name: pipeline_default:ci_retry_pipeline
+ :worker_name: Ci::RetryPipelineWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :cpu
+ :weight: 3
+ :idempotent:
+ :tags: []
- :name: pipeline_default:pipeline_metrics
:worker_name: PipelineMetricsWorker
:feature_category: :continuous_integration
diff --git a/app/workers/ci/retry_pipeline_worker.rb b/app/workers/ci/retry_pipeline_worker.rb
new file mode 100644
index 00000000000..7a1906b3ef9
--- /dev/null
+++ b/app/workers/ci/retry_pipeline_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ci
+ class RetryPipelineWorker # rubocop:disable Scalability/IdempotentWorker
+ include ::ApplicationWorker
+ include ::PipelineQueue
+
+ urgency :high
+ worker_resource_boundary :cpu
+
+ def perform(pipeline_id, user_id)
+ ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ ::User.find_by_id(user_id).try do |user|
+ pipeline.retry_failed(user)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb
index 54a483a3871..dba791c3f05 100644
--- a/app/workers/issue_placement_worker.rb
+++ b/app/workers/issue_placement_worker.rb
@@ -20,6 +20,10 @@ class IssuePlacementWorker
issue = find_issue(issue_id, project_id)
return unless issue
+ # Temporary disable moving null elements because of performance problems
+ # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
+ return if issue.blocked_for_repositioning?
+
# Move the oldest 100 unpositioned items to the end.
# This is to deal with out-of-order execution of the worker,
# while preserving creation order.
diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb
index 27cfa5cf40a..9eac451f107 100644
--- a/app/workers/issue_rebalancing_worker.rb
+++ b/app/workers/issue_rebalancing_worker.rb
@@ -14,6 +14,11 @@ class IssueRebalancingWorker
return if project_id.nil?
project = Project.find(project_id)
+
+ # Temporary disable reabalancing for performance reasons
+ # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
+ return if project.root_namespace&.issue_repositioning_disabled?
+
# All issues are equivalent as far as we are concerned
issue = project.issues.take # rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/workers/ssh_keys/expired_notification_worker.rb b/app/workers/ssh_keys/expired_notification_worker.rb
index ccae4220094..9d5143fe655 100644
--- a/app/workers/ssh_keys/expired_notification_worker.rb
+++ b/app/workers/ssh_keys/expired_notification_worker.rb
@@ -14,7 +14,8 @@ module SshKeys
def perform
return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml)
- User.with_ssh_key_expired_today.find_each do |user|
+ # rubocop:disable CodeReuse/ActiveRecord
+ User.with_ssh_key_expired_today.find_each(batch_size: 10_000) do |user|
with_context(user: user) do
Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expired ssh key(s)"
@@ -22,6 +23,7 @@ module SshKeys
Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: false }).execute
end
+ # rubocop:enable CodeReuse/ActiveRecord
end
end
end
diff --git a/app/workers/ssh_keys/expiring_soon_notification_worker.rb b/app/workers/ssh_keys/expiring_soon_notification_worker.rb
index 2765fd984bc..1ec655b5cf5 100644
--- a/app/workers/ssh_keys/expiring_soon_notification_worker.rb
+++ b/app/workers/ssh_keys/expiring_soon_notification_worker.rb
@@ -14,7 +14,8 @@ module SshKeys
def perform
return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml)
- User.with_ssh_key_expiring_soon.find_each do |user|
+ # rubocop:disable CodeReuse/ActiveRecord
+ User.with_ssh_key_expiring_soon.find_each(batch_size: 10_000) do |user|
with_context(user: user) do
Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expiring soon ssh key(s)"
@@ -23,6 +24,7 @@ module SshKeys
Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: true }).execute
end
end
+ # rubocop:enable CodeReuse/ActiveRecord
end
end
end
diff --git a/changelogs/unreleased/271242_memoize_merge_request_policy.yml b/changelogs/unreleased/271242_memoize_merge_request_policy.yml
new file mode 100644
index 00000000000..922dfedab4f
--- /dev/null
+++ b/changelogs/unreleased/271242_memoize_merge_request_policy.yml
@@ -0,0 +1,5 @@
+---
+title: Optimize merge request permission check for references
+merge_request: 61591
+author:
+type: performance
diff --git a/changelogs/unreleased/327315-enable-ci_wildcard_file_paths.yml b/changelogs/unreleased/327315-enable-ci_wildcard_file_paths.yml
new file mode 100644
index 00000000000..7fe6bf59727
--- /dev/null
+++ b/changelogs/unreleased/327315-enable-ci_wildcard_file_paths.yml
@@ -0,0 +1,5 @@
+---
+title: Implement wildcard support for pipeline include file paths
+merge_request: 61507
+author:
+type: added
diff --git a/changelogs/unreleased/329664-feature-flag-enable-inmemory-remotes-for-findremoterootrefs.yml b/changelogs/unreleased/329664-feature-flag-enable-inmemory-remotes-for-findremoterootrefs.yml
new file mode 100644
index 00000000000..722de1e9808
--- /dev/null
+++ b/changelogs/unreleased/329664-feature-flag-enable-inmemory-remotes-for-findremoterootrefs.yml
@@ -0,0 +1,5 @@
+---
+title: Make find_remote_root_refs_inmemory feature flag enabled by default
+merge_request: 61824
+author:
+type: changed
diff --git a/changelogs/unreleased/allow-migrating-sidekiq-scheduled-and-retry-jobs.yml b/changelogs/unreleased/allow-migrating-sidekiq-scheduled-and-retry-jobs.yml
new file mode 100644
index 00000000000..79686dad5bc
--- /dev/null
+++ b/changelogs/unreleased/allow-migrating-sidekiq-scheduled-and-retry-jobs.yml
@@ -0,0 +1,5 @@
+---
+title: Allow migrating scheduled and retried Sidekiq jobs to new queues
+merge_request: 60724
+author:
+type: added
diff --git a/changelogs/unreleased/mc-backstage-make-pipeline-retry-async.yml b/changelogs/unreleased/mc-backstage-make-pipeline-retry-async.yml
new file mode 100644
index 00000000000..25830373a36
--- /dev/null
+++ b/changelogs/unreleased/mc-backstage-make-pipeline-retry-async.yml
@@ -0,0 +1,5 @@
+---
+title: Make pipeline retry endpoint async.
+merge_request: 61270
+author:
+type: changed
diff --git a/config/application.rb b/config/application.rb
index 6002b668bba..dddd4ecac5e 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -56,8 +56,9 @@ module Gitlab
config.generators.templates.push("#{config.root}/generator_templates")
+ foss_eager_load_paths = config.eager_load_paths.dup.freeze
load_paths = lambda do |dir:|
- ext_paths = config.eager_load_paths.each_with_object([]) do |path, memo|
+ ext_paths = foss_eager_load_paths.each_with_object([]) do |path, memo|
ext_path = config.root.join(dir, Pathname.new(path).relative_path_from(config.root))
memo << ext_path.to_s
end
diff --git a/config/feature_flags/development/background_pipeline_retry_endpoint.yml b/config/feature_flags/development/background_pipeline_retry_endpoint.yml
new file mode 100644
index 00000000000..57f90d01e2c
--- /dev/null
+++ b/config/feature_flags/development/background_pipeline_retry_endpoint.yml
@@ -0,0 +1,8 @@
+---
+name: background_pipeline_retry_endpoint
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61270
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330915
+milestone: '13.12'
+type: development
+group: group::pipeline authoring
+default_enabled: false
diff --git a/config/feature_flags/development/ci_drop_cyclical_triggered_pipelines.yml b/config/feature_flags/development/ci_drop_cyclical_triggered_pipelines.yml
new file mode 100644
index 00000000000..6a08d4aa72c
--- /dev/null
+++ b/config/feature_flags/development/ci_drop_cyclical_triggered_pipelines.yml
@@ -0,0 +1,8 @@
+---
+name: ci_drop_cyclical_triggered_pipelines
+introduced_by_url: https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/1195
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329390
+milestone: '13.12'
+type: development
+group: group::continuous integration
+default_enabled: true
diff --git a/config/feature_flags/development/ci_wildcard_file_paths.yml b/config/feature_flags/development/ci_wildcard_file_paths.yml
index 2d21fc8fa41..43a681d171c 100644
--- a/config/feature_flags/development/ci_wildcard_file_paths.yml
+++ b/config/feature_flags/development/ci_wildcard_file_paths.yml
@@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/327315
milestone: '13.11'
type: development
group: group::pipeline authoring
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/find_remote_root_refs_inmemory.yml b/config/feature_flags/development/find_remote_root_refs_inmemory.yml
index c78eadceaad..18e2e2b366a 100644
--- a/config/feature_flags/development/find_remote_root_refs_inmemory.yml
+++ b/config/feature_flags/development/find_remote_root_refs_inmemory.yml
@@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329664
milestone: '13.12'
type: development
group: group::gitaly
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/merge_base_pipeline_for_metrics_comparison.yml b/config/feature_flags/development/merge_base_pipeline_for_metrics_comparison.yml
new file mode 100644
index 00000000000..1fdb8d5bc6d
--- /dev/null
+++ b/config/feature_flags/development/merge_base_pipeline_for_metrics_comparison.yml
@@ -0,0 +1,8 @@
+---
+name: merge_base_pipeline_for_metrics_comparison
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61282
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330809
+milestone: '13.12'
+type: development
+group: group::testing
+default_enabled: false
diff --git a/config/feature_flags/ops/block_issue_repositioning.yml b/config/feature_flags/ops/block_issue_repositioning.yml
new file mode 100644
index 00000000000..432f9063b8a
--- /dev/null
+++ b/config/feature_flags/ops/block_issue_repositioning.yml
@@ -0,0 +1,8 @@
+---
+name: block_issue_repositioning
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60141
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329663
+milestone: '13.12'
+type: ops
+group: group::project management
+default_enabled: false
diff --git a/config/metrics/counts_28d/20210216181139_issues.yml b/config/metrics/counts_28d/20210216181139_issues.yml
index 04734857bdd..c6c73e11746 100644
--- a/config/metrics/counts_28d/20210216181139_issues.yml
+++ b/config/metrics/counts_28d/20210216181139_issues.yml
@@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: 28d
data_source: database
-instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountUsersCreatingIssuesMetric'
+instrumentation_class: CountUsersCreatingIssuesMetric
distribution:
- ce
- ee
diff --git a/config/metrics/counts_28d/20210216181508_i_quickactions_approve_monthly.yml b/config/metrics/counts_28d/20210216181508_i_quickactions_approve_monthly.yml
index 9b9fa1779c7..e828cefc644 100644
--- a/config/metrics/counts_28d/20210216181508_i_quickactions_approve_monthly.yml
+++ b/config/metrics/counts_28d/20210216181508_i_quickactions_approve_monthly.yml
@@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: 28d
data_source: redis_hll
-instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountUsersUsingApproveQuickActionMetric'
+instrumentation_class: CountUsersUsingApproveQuickActionMetric
distribution:
- ce
- ee
diff --git a/config/metrics/counts_7d/20210216181506_i_quickactions_approve_weekly.yml b/config/metrics/counts_7d/20210216181506_i_quickactions_approve_weekly.yml
index 3754b20fb6e..362404036a5 100644
--- a/config/metrics/counts_7d/20210216181506_i_quickactions_approve_weekly.yml
+++ b/config/metrics/counts_7d/20210216181506_i_quickactions_approve_weekly.yml
@@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: 7d
data_source: redis_hll
-instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountUsersUsingApproveQuickActionMetric'
+instrumentation_class: CountUsersUsingApproveQuickActionMetric
distribution:
- ce
- ee
diff --git a/config/metrics/counts_all/20210216181102_issues.yml b/config/metrics/counts_all/20210216181102_issues.yml
index c4426915d02..8875b0bbc81 100644
--- a/config/metrics/counts_all/20210216181102_issues.yml
+++ b/config/metrics/counts_all/20210216181102_issues.yml
@@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: all
data_source: database
-instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountIssuesMetric'
+instrumentation_class: CountIssuesMetric
distribution:
- ce
- ee
diff --git a/config/metrics/counts_all/20210216181115_issues.yml b/config/metrics/counts_all/20210216181115_issues.yml
index d3c7fc4b79b..3843184aa10 100644
--- a/config/metrics/counts_all/20210216181115_issues.yml
+++ b/config/metrics/counts_all/20210216181115_issues.yml
@@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: all
data_source: database
-instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountUsersCreatingIssuesMetric'
+instrumentation_class: CountUsersCreatingIssuesMetric
distribution:
- ce
- ee
diff --git a/config/metrics/counts_all/20210216181252_boards.yml b/config/metrics/counts_all/20210216181252_boards.yml
index 45844a54aa8..ddf55cc6282 100644
--- a/config/metrics/counts_all/20210216181252_boards.yml
+++ b/config/metrics/counts_all/20210216181252_boards.yml
@@ -9,7 +9,7 @@ value_type: number
status: data_available
time_frame: all
data_source: database
-instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::CountBoardsMetric'
+instrumentation_class: CountBoardsMetric
distribution:
- ce
- ee
diff --git a/config/metrics/license/20210201124933_uuid.yml b/config/metrics/license/20210201124933_uuid.yml
index d2e6edec884..afad2cf540a 100644
--- a/config/metrics/license/20210201124933_uuid.yml
+++ b/config/metrics/license/20210201124933_uuid.yml
@@ -11,7 +11,7 @@ milestone: "9.1"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1521
time_frame: none
data_source: database
-instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::UuidMetric'
+instrumentation_class: UuidMetric
distribution:
- ee
- ce
diff --git a/config/metrics/license/20210204124827_hostname.yml b/config/metrics/license/20210204124827_hostname.yml
index 8b3ba10f890..953239eff7a 100644
--- a/config/metrics/license/20210204124827_hostname.yml
+++ b/config/metrics/license/20210204124827_hostname.yml
@@ -9,7 +9,7 @@ value_type: string
status: data_available
time_frame: none
data_source: system
-instrumentation_class: 'Gitlab::Usage::Metrics::Instrumentations::HostnameMetric'
+instrumentation_class: HostnameMetric
distribution:
- ce
- ee
diff --git a/config/metrics/schema.json b/config/metrics/schema.json
index fba2365845b..e9a4a16ecd3 100644
--- a/config/metrics/schema.json
+++ b/config/metrics/schema.json
@@ -56,7 +56,7 @@
},
"instrumentation_class": {
"type": "string",
- "pattern": "^(Gitlab::Usage::Metrics::Instrumentations::)(([A-Z][a-z]+)+::)*(([A-Z][a-z]+)+)$"
+ "pattern": "^(([A-Z][a-z]+)+::)*(([A-Z][a-z]+)+)$"
},
"distribution": {
"type": "array",
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index a58de3c2f48..3a8b3adb4b2 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -482,10 +482,15 @@ Use local includes instead of symbolic links.
##### `include:local` with wildcard file paths
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/25921) in GitLab 13.11.
-> - It's [deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
-> - It's disabled on GitLab.com.
-> - It's not recommended for production use.
-> - To use it in GitLab self-managed instances, ask a GitLab administrator to enable it. **(CORE ONLY)**
+> - [Deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
+> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/327315) in GitLab 13.12.
+> - Enabled on GitLab.com.
+> - Recommended for production use.
+> - For GitLab self-managed instances, GitLab administrators can opt to disable it. **(CORE ONLY)**
+
+There can be
+[risks when disabling released features](../../user/feature_flags.md#risks-when-disabling-released-features).
+Refer to this feature's version history for more details.
You can use wildcard paths (`*` and `**`) with `include:local`.
@@ -509,10 +514,10 @@ When the pipeline runs, GitLab:
include: 'configs/**/*.yml'
```
-The wildcard file paths feature is under development and not ready for production use. It is
-deployed behind a feature flag that is **disabled by default**.
+The wildcard file paths feature is under development but ready for production use.
+It is deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
-can enable it.
+can opt to disable it.
To enable it:
diff --git a/doc/integration/jira/issues.md b/doc/integration/jira/issues.md
index 4fd59c3608e..91311f85310 100644
--- a/doc/integration/jira/issues.md
+++ b/doc/integration/jira/issues.md
@@ -45,6 +45,30 @@ ENTITY_TITLE
You can [disable comments](#disable-comments-on-jira-issues) on issues.
+### Require associated Jira issue for merge requests to be merged
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/280766) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.12 behind a feature flag, disabled by default.
+> - [Deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
+> - Disabled on GitLab.com.
+> - Not recommended for production use.
+> - To use in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-the-ability-to-require-an-associated-jira-issue-on-merge-requests). **(ULTIMATE SELF)**
+
+This in-development feature might not be available for your use. There can be
+[risks when enabling features still in development](../../user/application_security/index.md#security-approvals-in-merge-requests).
+Refer to this feature's version history for more details.
+
+You can prevent merge requests from being merged if they do not refer to a Jira issue.
+To enforce this:
+
+1. Navigate to your project's **Settings > General** page.
+1. Expand the **Merge requests** section.
+1. Under **Merge checks**, select the **Require an associated issue from Jira** check box.
+1. Select **Save** for the changes to take effect.
+
+After you enable this feature, a merge request that doesn't reference an associated
+Jira issue can't be merged. The merge request displays the message
+**To merge, a Jira issue key must be mentioned in the title or description.**
+
## Close Jira issues in GitLab
If you have configured GitLab transition IDs, you can close a Jira issue directly
@@ -160,3 +184,22 @@ adding a comment to the Jira issue:
1. Refer to the [Configure GitLab](development_panel.md#configure-gitlab) instructions.
1. Clear the **Enable comments** check box.
+
+## Enable or disable the ability to require an associated Jira issue on merge requests
+
+The ability to require an associated Jira issue on merge requests is under development
+and not ready for production use. It is deployed behind a feature flag that is
+**disabled by default**.
+[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md) can enable it.
+
+To enable it:
+
+```ruby
+Feature.enable(:jira_issue_association_on_merge_request)
+```
+
+To disable it:
+
+```ruby
+Feature.disable(:jira_issue_association_on_merge_request)
+```
diff --git a/doc/raketasks/index.md b/doc/raketasks/index.md
index 7efe3115a83..799e6126a82 100644
--- a/doc/raketasks/index.md
+++ b/doc/raketasks/index.md
@@ -41,7 +41,8 @@ The following Rake tasks are available for use with GitLab:
| [Praefect Rake tasks](../administration/raketasks/praefect.md) | [Praefect](../administration/gitaly/praefect.md)-related tasks. |
| [Project import/export](../administration/raketasks/project_import_export.md) | Prepare for [project exports and imports](../user/project/settings/import_export.md). |
| [Sample Prometheus data](generate_sample_prometheus_data.md) | Generate sample Prometheus data. |
-| [SPDX license list import](spdx.md) | Import a local copy of the [SPDX license list](https://spdx.org/licenses/) for matching [License Compliance policies](../user/compliance/license_compliance/index.md). | |
+| [Sidekiq job migration](sidekiq_job_migration.md) | Migrate Sidekiq jobs scheduled for future dates to a new queue. |
+| [SPDX license list import](spdx.md) | Import a local copy of the [SPDX license list](https://spdx.org/licenses/) for matching [License Compliance policies](../user/compliance/license_compliance/index.md). |
| [Repository storage](../administration/raketasks/storage.md) | List and migrate existing projects and attachments from legacy storage to hashed storage. |
| [Uploads migrate](../administration/raketasks/uploads/migrate.md) | Migrate uploads between local storage and object storage. |
| [Uploads sanitize](../administration/raketasks/uploads/sanitize.md) | Remove EXIF data from images uploaded to earlier versions of GitLab. |
diff --git a/doc/raketasks/sidekiq_job_migration.md b/doc/raketasks/sidekiq_job_migration.md
new file mode 100644
index 00000000000..313c9c7220b
--- /dev/null
+++ b/doc/raketasks/sidekiq_job_migration.md
@@ -0,0 +1,40 @@
+---
+stage: none
+group: unassigned
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# Sidekiq job migration **(FREE SELF)**
+
+WARNING:
+This operation should be very uncommon. We do not recommend it for the vast majority of GitLab instances.
+
+Sidekiq routing rules allow administrators to re-route certain background jobs from their regular queue to an alternative queue. By default, GitLab uses one queue per background job type. GitLab has over 400 background job types, and so correspondingly it has over 400 queues.
+
+Most administrators will not need to change this setting. In some cases with particularly large background job processing workloads, Redis performance may suffer due to the number of queues that GitLab listens to.
+
+If the Sidekiq routing rules are changed, administrators need to take care with the migration to avoid losing jobs entirely. The basic migration steps are:
+
+1. Listen to both the old and new queues.
+1. Update the routing rules.
+1. Wait until there are no publishers dispatching jobs to the old queues.
+1. Run the [Rake tasks for future jobs](#future-jobs).
+1. Wait for the old queues to be empty.
+1. Stop listening to the old queues.
+
+## Future jobs
+
+Step 4 involves rewriting some Sidekiq job data for jobs that are already stored in Redis, but due to run in future. There are two sets of jobs to run in future: scheduled jobs and jobs to be retried. We provide a separate Rake task to migrate each set:
+
+- `gitlab:sidekiq:migrate_jobs:retry` for jobs to be retried.
+- `gitlab:sidekiq:migrate_jobs:scheduled` for scheduled jobs.
+
+Most of the time, running both at the same time is the correct choice. There are two separate tasks to allow for more fine-grained control where needed. To run both at once:
+
+```shell
+# omnibus-gitlab
+sudo gitlab-rake gitlab:sidekiq:migrate_jobs:retry gitlab:sidekiq:migrate_jobs:schedule
+
+# source installations
+bundle exec rake gitlab:sidekiq:migrate_jobs:retry gitlab:sidekiq:migrate_jobs:schedule RAILS_ENV=production
+```
diff --git a/doc/user/group/epics/manage_epics.md b/doc/user/group/epics/manage_epics.md
index 4a6fa1fb9fe..7bb021b4b1f 100644
--- a/doc/user/group/epics/manage_epics.md
+++ b/doc/user/group/epics/manage_epics.md
@@ -204,6 +204,13 @@ To make an epic confidential:
This section collects instructions for all the things you can do with [issues](../../project/issues/index.md)
in relation to epics.
+### View count of issues in an epic
+
+On the **Epics and Issues** tab, under each epic name, hover over the total counts.
+
+The number indicates all epics associated with the project, including issues
+you might not have permission to.
+
### Add a new issue to an epic
You can add an existing issue to an epic, or create a new issue that's
@@ -231,13 +238,6 @@ To add a new issue to an epic:
If there are multiple issues to be added, press <kbd>Space</kbd> and repeat this step.
1. Select **Add**.
-#### View count of issues in an epic
-
-On the **Epics and Issues** tab, under each epic name, hover over the total counts.
-
-The number indicates all epics associated with the project, including issues
-you might not have permission to.
-
#### Create an issue from an epic
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/5419) in GitLab 12.7.
diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md
index 70c5ef63dd4..33f439836b5 100644
--- a/doc/user/project/repository/index.md
+++ b/doc/user/project/repository/index.md
@@ -208,7 +208,7 @@ The repository graph displays the history of the repository network visually, in
Find it under your project's **Repository > Graph**.
-## Repository Languages
+## Repository languages
For the default branch of each repository, GitLab determines what programming languages
were used and displays this on the project's pages. If this information is missing, it's
@@ -268,7 +268,7 @@ All projects can be cloned into Visual Studio Code. To do that:
When VS Code has successfully cloned your project, it opens the folder.
-## Download Source Code
+## Download source code
> - Support for directory download was [introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/24704) in GitLab 11.11.
> - Support for [including Git LFS blobs](../../../topics/git/lfs#lfs-objects-in-project-archives) was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15079) in GitLab 13.5.
diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md
index 7c87630fe72..7c45fc26bf9 100644
--- a/doc/user/project/settings/import_export.md
+++ b/doc/user/project/settings/import_export.md
@@ -47,6 +47,8 @@ Note the following:
- Imported users can be mapped by their primary email on self-managed instances, if an administrative user (not an owner) does the import.
Otherwise, a supplementary comment is left to mention that the original author and
the MRs, notes, or issues are owned by the importer.
+ - For project migration imports performed over GitLab.com Groups, preserving author information is
+ possible through a [professional services engagement](https://about.gitlab.com/services/migration/).
- If an imported project contains merge requests originating from forks,
then new branches associated with such merge requests are created
within a project during the import/export. Thus, the number of branches
diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md
index a816fb4b009..d38ac78162d 100644
--- a/doc/user/project/settings/index.md
+++ b/doc/user/project/settings/index.md
@@ -40,7 +40,7 @@ The project description also partially supports [standard Markdown](../../markdo
You can create a framework label to identify that your project has certain compliance requirements or needs additional oversight.
Group owners can create, edit and delete compliance frameworks by going to **Settings** > **General** and expanding the **Compliance frameworks** section.
-Compliance frameworks created can then be assigned to any number of projects via the project settings page inside the group or subgroups.
+Compliance frameworks created can then be assigned to any number of projects via the project settings page inside the group or subgroups.
NOTE:
Attempting to create compliance frameworks on subgroups via GraphQL will cause the framework to be created on the root ancestor if the user has the correct permissions.
@@ -193,8 +193,9 @@ Set up your project's merge request settings:
- Enable [merge request approvals](../merge_requests/approvals/index.md).
- Enable [merge only if pipeline succeeds](../merge_requests/merge_when_pipeline_succeeds.md).
- Enable [merge only when all threads are resolved](../../discussions/index.md#only-allow-merge-requests-to-be-merged-if-all-threads-are-resolved).
-- Enable [`delete source branch after merge` option by default](../merge_requests/getting_started.md#deleting-the-source-branch)
-- Configure [suggested changes commit messages](../merge_requests/reviews/suggestions.md#configure-the-commit-message-for-applied-suggestions)
+- Enable [require an associated issue from Jira](../../../integration/jira/issues.md#require-associated-jira-issue-for-merge-requests-to-be-merged).
+- Enable [`delete source branch after merge` option by default](../merge_requests/getting_started.md#deleting-the-source-branch).
+- Configure [suggested changes commit messages](../merge_requests/reviews/suggestions.md#configure-the-commit-message-for-applied-suggestions).
- Configure [the default target project](../merge_requests/creating_merge_requests.md#set-the-default-target-project) for merge requests coming from forks.
### Service Desk
diff --git a/lib/banzai/reference_parser/merge_request_parser.rb b/lib/banzai/reference_parser/merge_request_parser.rb
index d7bf450465e..24bc1a24e09 100644
--- a/lib/banzai/reference_parser/merge_request_parser.rb
+++ b/lib/banzai/reference_parser/merge_request_parser.rb
@@ -3,6 +3,8 @@
module Banzai
module ReferenceParser
class MergeRequestParser < IssuableParser
+ include Gitlab::Utils::StrongMemoize
+
self.reference_type = :merge_request
def records_for_nodes(nodes)
@@ -27,6 +29,16 @@ module Banzai
self.class.data_attribute
)
end
+
+ def can_read_reference?(user, merge_request)
+ memo = strong_memoize(:can_read_reference) { {} }
+
+ project_id = merge_request.project_id
+
+ return memo[project_id] if memo.key?(project_id)
+
+ memo[project_id] = can?(user, :read_merge_request_iid, merge_request.project)
+ end
end
end
end
diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb
index f6ecbe80ceb..a9525ddb954 100644
--- a/lib/gitlab/ci/features.rb
+++ b/lib/gitlab/ci/features.rb
@@ -18,6 +18,10 @@ module Gitlab
Feature.enabled?(:ci_pipeline_status_omit_commit_sha_in_cache_key, project, default_enabled: true)
end
+ def self.merge_base_pipeline_for_metrics_comparison?(project)
+ Feature.enabled?(:merge_base_pipeline_for_metrics_comparison, project, default_enabled: :yaml)
+ end
+
# Remove in https://gitlab.com/gitlab-org/gitlab/-/issues/224199
def self.store_pipeline_messages?(project)
::Feature.enabled?(:ci_store_pipeline_messages, project, default_enabled: true)
@@ -54,6 +58,10 @@ module Gitlab
def self.gldropdown_tags_enabled?
::Feature.enabled?(:gldropdown_tags, default_enabled: :yaml)
end
+
+ def self.background_pipeline_retry_endpoint?(project)
+ ::Feature.enabled?(:background_pipeline_retry_endpoint, project)
+ end
end
end
end
diff --git a/lib/gitlab/ci/status/build/failed.rb b/lib/gitlab/ci/status/build/failed.rb
index 0656a210e4f..cbd72f54ff4 100644
--- a/lib/gitlab/ci/status/build/failed.rb
+++ b/lib/gitlab/ci/status/build/failed.rb
@@ -20,6 +20,7 @@ module Gitlab
scheduler_failure: 'scheduler failure',
data_integrity_failure: 'data integrity failure',
forward_deployment_failure: 'forward deployment failure',
+ pipeline_loop_detected: 'job would create infinitely looping pipelines',
invalid_bridge_trigger: 'downstream pipeline trigger definition is invalid',
downstream_bridge_project_not_found: 'downstream project could not be found',
insufficient_bridge_permissions: 'no permissions to trigger downstream pipeline',
diff --git a/lib/gitlab/relative_positioning.rb b/lib/gitlab/relative_positioning.rb
index ceaba2538c1..c2a73b7cfe5 100644
--- a/lib/gitlab/relative_positioning.rb
+++ b/lib/gitlab/relative_positioning.rb
@@ -15,6 +15,7 @@ module Gitlab
NoSpaceLeft = Class.new(StandardError)
InvalidPosition = Class.new(StandardError)
IllegalRange = Class.new(ArgumentError)
+ IssuePositioningDisabled = Class.new(StandardError)
def self.range(lhs, rhs)
if lhs && rhs
diff --git a/lib/gitlab/sidekiq_migrate_jobs.rb b/lib/gitlab/sidekiq_migrate_jobs.rb
new file mode 100644
index 00000000000..62d62bf82c4
--- /dev/null
+++ b/lib/gitlab/sidekiq_migrate_jobs.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module Gitlab
+ class SidekiqMigrateJobs
+ LOG_FREQUENCY = 1_000
+
+ attr_reader :sidekiq_set, :logger
+
+ def initialize(sidekiq_set, logger: nil)
+ @sidekiq_set = sidekiq_set
+ @logger = logger
+ end
+
+ # mappings is a hash of WorkerClassName => target_queue_name
+ def execute(mappings)
+ source_queues_regex = Regexp.union(mappings.keys)
+ cursor = 0
+ scanned = 0
+ migrated = 0
+
+ estimated_size = Sidekiq.redis { |c| c.zcard(sidekiq_set) }
+ logger&.info("Processing #{sidekiq_set} set. Estimated size: #{estimated_size}.")
+
+ begin
+ cursor, jobs = Sidekiq.redis { |c| c.zscan(sidekiq_set, cursor) }
+
+ jobs.each do |(job, score)|
+ if scanned > 0 && scanned % LOG_FREQUENCY == 0
+ logger&.info("In progress. Scanned records: #{scanned}. Migrated records: #{migrated}.")
+ end
+
+ scanned += 1
+
+ next unless job.match?(source_queues_regex)
+
+ job_hash = Sidekiq.load_json(job)
+ destination_queue = mappings[job_hash['class']]
+
+ next unless mappings.has_key?(job_hash['class'])
+ next if job_hash['queue'] == destination_queue
+
+ job_hash['queue'] = destination_queue
+
+ migrated += migrate_job(job, score, job_hash)
+ end
+ end while cursor.to_i != 0
+
+ logger&.info("Done. Scanned records: #{scanned}. Migrated records: #{migrated}.")
+
+ {
+ scanned: scanned,
+ migrated: migrated
+ }
+ end
+
+ private
+
+ def migrate_job(job, score, job_hash)
+ Sidekiq.redis do |connection|
+ removed = connection.zrem(sidekiq_set, job)
+
+ if removed
+ connection.zadd(sidekiq_set, score, Sidekiq.dump_json(job_hash))
+
+ 1
+ else
+ 0
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/usage_data_metrics.rb b/lib/gitlab/usage_data_metrics.rb
index 4f162c28f1c..e181da01229 100644
--- a/lib/gitlab/usage_data_metrics.rb
+++ b/lib/gitlab/usage_data_metrics.rb
@@ -9,7 +9,7 @@ module Gitlab
instrumentation_class = definition.attributes[:instrumentation_class]
if instrumentation_class.present?
- metric_value = instrumentation_class.constantize.new(time_frame: definition.attributes[:time_frame]).value
+ metric_value = "Gitlab::Usage::Metrics::Instrumentations::#{instrumentation_class}".constantize.new(time_frame: definition.attributes[:time_frame]).value
metric_payload(definition.key_path, metric_value)
else
diff --git a/lib/tasks/gitlab/sidekiq.rake b/lib/tasks/gitlab/sidekiq.rake
index 6f00db42d78..6f5c3a86dd3 100644
--- a/lib/tasks/gitlab/sidekiq.rake
+++ b/lib/tasks/gitlab/sidekiq.rake
@@ -8,6 +8,29 @@ namespace :gitlab do
File.write(path, banner + YAML.dump(object).gsub(/ *$/m, ''))
end
+ namespace :migrate_jobs do
+ def mappings
+ ::Gitlab::SidekiqConfig
+ .workers
+ .reject { |worker| worker.klass.is_a?(Gitlab::SidekiqConfig::DummyWorker) }
+ .to_h { |worker| [worker.klass.to_s, ::Gitlab::SidekiqConfig::WorkerRouter.global.route(worker.klass)] }
+ end
+
+ desc 'GitLab | Sidekiq | Migrate jobs in the scheduled set to new queue names'
+ task schedule: :environment do
+ ::Gitlab::SidekiqMigrateJobs
+ .new('schedule', logger: Logger.new($stdout))
+ .execute(mappings)
+ end
+
+ desc 'GitLab | Sidekiq | Migrate jobs in the retry set to new queue names'
+ task retry: :environment do
+ ::Gitlab::SidekiqMigrateJobs
+ .new('retry', logger: Logger.new($stdout))
+ .execute(mappings)
+ end
+ end
+
namespace :all_queues_yml do
desc 'GitLab | Sidekiq | Generate all_queues.yml based on worker definitions'
task generate: :environment do
diff --git a/lib/version_check.rb b/lib/version_check.rb
index c9f102f6b19..a8b7c7371ca 100644
--- a/lib/version_check.rb
+++ b/lib/version_check.rb
@@ -12,10 +12,12 @@ class VersionCheck
def self.url
encoded_data = Base64.urlsafe_encode64(data.to_json)
- "#{host}?gitlab_info=#{encoded_data}"
+ "#{host}/check.svg?gitlab_info=#{encoded_data}"
end
def self.host
- 'https://version.gitlab.com/check.svg'
+ 'https://version.gitlab.com'
end
end
+
+VersionCheck.prepend_mod
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 8d886689f42..42e398f1fa1 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -11342,9 +11342,6 @@ msgstr ""
msgid "DevopsAdoption|Adopted"
msgstr ""
-msgid "DevopsAdoption|Adoption"
-msgstr ""
-
msgid "DevopsAdoption|An error occurred while removing the group. Please try again."
msgstr ""
@@ -11381,6 +11378,9 @@ msgstr ""
msgid "DevopsAdoption|Deploys"
msgstr ""
+msgid "DevopsAdoption|Dev"
+msgstr ""
+
msgid "DevopsAdoption|DevOps adoption tracks the use of key features across your favorite groups. Add a group to the table to begin."
msgstr ""
@@ -11408,6 +11408,9 @@ msgstr ""
msgid "DevopsAdoption|Not adopted"
msgstr ""
+msgid "DevopsAdoption|Ops"
+msgstr ""
+
msgid "DevopsAdoption|Pipelines"
msgstr ""
@@ -11429,6 +11432,9 @@ msgstr ""
msgid "DevopsAdoption|Scanning"
msgstr ""
+msgid "DevopsAdoption|Sec"
+msgstr ""
+
msgid "DevopsAdoption|There was an error enabling the current group. Please refresh the page."
msgstr ""
@@ -11441,9 +11447,6 @@ msgstr ""
msgid "DevopsAdoption|You cannot remove the group you are currently in."
msgstr ""
-msgid "DevopsReport|Adoption"
-msgstr ""
-
msgid "DevopsReport|DevOps Score"
msgstr ""
@@ -18359,6 +18362,9 @@ msgstr ""
msgid "Issues closed"
msgstr ""
+msgid "Issues manual ordering is temporarily disabled for technical reasons."
+msgstr ""
+
msgid "Issues must match this scope to appear in this list."
msgstr ""
@@ -34130,6 +34136,9 @@ msgstr ""
msgid "Too many projects enabled. You will need to manage them via the console or the API."
msgstr ""
+msgid "TopNav|Switch to..."
+msgstr ""
+
msgid "Topics (optional)"
msgstr ""
diff --git a/qa/qa/page/project/web_ide/edit.rb b/qa/qa/page/project/web_ide/edit.rb
index 19791b07e77..78b2db7d723 100644
--- a/qa/qa/page/project/web_ide/edit.rb
+++ b/qa/qa/page/project/web_ide/edit.rb
@@ -108,10 +108,6 @@ module QA
element :file_to_commit_content
end
- view 'app/assets/javascripts/editor/extensions/editor_lite_extension_base.js' do
- element :line_link
- end
-
def has_file?(file_name)
within_element(:file_list) do
has_element?(:file_name_content, file_name: file_name)
@@ -319,11 +315,15 @@ module QA
end
def link_line(line_number)
+ previous_url = page.current_url
wait_for_animated_element(:editor_container)
within_element(:editor_container) do
- find('.line-numbers', text: line_number).hover
- find_element(:line_link, number: "#L#{line_number}")['href'].to_s
+ find('.line-numbers', text: line_number).hover.click
+ end
+ wait_until(max_duration: 5, reload: false) do
+ page.current_url != previous_url
end
+ page.current_url.to_s
end
end
end
diff --git a/qa/qa/specs/features/browser_ui/5_package/container_registry_spec.rb b/qa/qa/specs/features/browser_ui/5_package/container_registry_spec.rb
index 5de144c2ea4..7a71d1cfbaf 100644
--- a/qa/qa/specs/features/browser_ui/5_package/container_registry_spec.rb
+++ b/qa/qa/specs/features/browser_ui/5_package/container_registry_spec.rb
@@ -2,7 +2,7 @@
module QA
RSpec.describe 'Package' do
- describe 'Container Registry', only: { subdomain: %i[staging pre] } do
+ describe 'Container Registry', :reliable, only: { subdomain: %i[staging pre] } do
let(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'project-with-registry'
diff --git a/spec/controllers/admin/dev_ops_report_controller_spec.rb b/spec/controllers/admin/dev_ops_report_controller_spec.rb
index 142db175a15..49e6c0f69bd 100644
--- a/spec/controllers/admin/dev_ops_report_controller_spec.rb
+++ b/spec/controllers/admin/dev_ops_report_controller_spec.rb
@@ -9,12 +9,6 @@ RSpec.describe Admin::DevOpsReportController do
end
end
- describe 'should_track_devops_score?' do
- it 'is always true' do
- expect(controller.should_track_devops_score?).to be_truthy
- end
- end
-
describe 'GET #show' do
context 'as admin' do
let(:user) { create(:admin) }
@@ -31,6 +25,8 @@ RSpec.describe Admin::DevOpsReportController do
it_behaves_like 'tracking unique visits', :show do
let(:target_id) { 'i_analytics_dev_ops_score' }
+
+ let(:request_params) { { tab: 'devops-score' } }
end
end
end
diff --git a/spec/controllers/boards/issues_controller_spec.rb b/spec/controllers/boards/issues_controller_spec.rb
index d23f099e382..48000284264 100644
--- a/spec/controllers/boards/issues_controller_spec.rb
+++ b/spec/controllers/boards/issues_controller_spec.rb
@@ -49,6 +49,7 @@ RSpec.describe Boards::IssuesController do
create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow)
create(:labeled_issue, project: project, labels: [development], assignees: [johndoe])
issue.subscribe(johndoe, project)
+ expect(Issue).to receive(:move_nulls_to_end)
list_issues user: user, board: board, list: list2
@@ -119,6 +120,18 @@ RSpec.describe Boards::IssuesController do
expect(query_count).to eq(1)
end
+
+ context 'when block_issue_repositioning feature flag is enabled' do
+ before do
+ stub_feature_flags(block_issue_repositioning: true)
+ end
+
+ it 'does not reposition issues with null position' do
+ expect(Issue).not_to receive(:move_nulls_to_end)
+
+ list_issues(user: user, board: group_board, list: list3)
+ end
+ end
end
context 'with invalid list id' do
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index dc06389d8b4..0e6b5e84d85 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -853,10 +853,7 @@ RSpec.describe Projects::PipelinesController do
end
describe 'POST retry.json' do
- let!(:pipeline) { create(:ci_pipeline, :failed, project: project) }
- let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
-
- before do
+ subject(:post_retry) do
post :retry, params: {
namespace_id: project.namespace,
project_id: project,
@@ -865,15 +862,41 @@ RSpec.describe Projects::PipelinesController do
format: :json
end
- it 'retries a pipeline without returning any content' do
+ let!(:pipeline) { create(:ci_pipeline, :failed, project: project) }
+ let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
+
+ let(:worker_spy) { class_spy(::Ci::RetryPipelineWorker) }
+
+ before do
+ stub_const('::Ci::RetryPipelineWorker', worker_spy)
+ end
+
+ it 'retries a pipeline in the background without returning any content' do
+ post_retry
+
expect(response).to have_gitlab_http_status(:no_content)
- expect(build.reload).to be_retried
+ expect(::Ci::RetryPipelineWorker).to have_received(:perform_async).with(pipeline.id, user.id)
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(background_pipeline_retry_endpoint: false)
+ end
+
+ it 'retries the pipeline without returning any content' do
+ post_retry
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(build.reload).to be_retried
+ end
end
context 'when builds are disabled' do
let(:feature) { ProjectFeature::DISABLED }
it 'fails to retry pipeline' do
+ post_retry
+
expect(response).to have_gitlab_http_status(:not_found)
end
end
diff --git a/spec/features/admin/admin_mode_spec.rb b/spec/features/admin/admin_mode_spec.rb
index 8d4c563e7d4..4df035b13e8 100644
--- a/spec/features/admin/admin_mode_spec.rb
+++ b/spec/features/admin/admin_mode_spec.rb
@@ -20,8 +20,6 @@ RSpec.describe 'Admin mode' do
context 'when not in admin mode' do
it 'has no leave admin mode button' do
- pending_on_combined_menu_flag
-
visit new_admin_session_path
page.within('.navbar-sub-nav') do
@@ -180,8 +178,6 @@ RSpec.describe 'Admin mode' do
end
it 'shows no admin mode buttons in navbar' do
- pending_on_combined_menu_flag
-
visit admin_root_path
page.within('.navbar-sub-nav') do
diff --git a/spec/frontend/nav/components/top_nav_app_spec.js b/spec/frontend/nav/components/top_nav_app_spec.js
new file mode 100644
index 00000000000..06700ce748e
--- /dev/null
+++ b/spec/frontend/nav/components/top_nav_app_spec.js
@@ -0,0 +1,68 @@
+import { GlNavItemDropdown, GlTooltip } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import TopNavApp from '~/nav/components/top_nav_app.vue';
+import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue';
+import { TEST_NAV_DATA } from '../mock_data';
+
+describe('~/nav/components/top_nav_app.vue', () => {
+ let wrapper;
+
+ const createComponent = (mountFn = shallowMount) => {
+ wrapper = mountFn(TopNavApp, {
+ propsData: {
+ navData: TEST_NAV_DATA,
+ },
+ });
+ };
+
+ const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown);
+ const findMenu = () => wrapper.findComponent(TopNavDropdownMenu);
+ const findTooltip = () => wrapper.findComponent(GlTooltip);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders nav item dropdown', () => {
+ expect(findNavItemDropdown().attributes('href')).toBeUndefined();
+ expect(findNavItemDropdown().attributes()).toMatchObject({
+ icon: 'dot-grid',
+ text: TEST_NAV_DATA.activeTitle,
+ 'no-flip': '',
+ });
+ });
+
+ it('renders top nav dropdown menu', () => {
+ expect(findMenu().props()).toStrictEqual({
+ primary: TEST_NAV_DATA.primary,
+ secondary: TEST_NAV_DATA.secondary,
+ views: TEST_NAV_DATA.views,
+ });
+ });
+
+ it('renders tooltip', () => {
+ expect(findTooltip().attributes()).toMatchObject({
+ 'boundary-padding': '0',
+ placement: 'right',
+ title: TopNavApp.TOOLTIP,
+ });
+ });
+ });
+
+ describe('when full mounted', () => {
+ beforeEach(() => {
+ createComponent(mount);
+ });
+
+ it('has dropdown toggle as tooltip target', () => {
+ const targetFn = findTooltip().props('target');
+
+ expect(targetFn()).toBe(wrapper.find('.js-top-nav-dropdown-toggle').element);
+ });
+ });
+});
diff --git a/spec/frontend/nav/components/top_nav_container_view_spec.js b/spec/frontend/nav/components/top_nav_container_view_spec.js
new file mode 100644
index 00000000000..b08d75f36ce
--- /dev/null
+++ b/spec/frontend/nav/components/top_nav_container_view_spec.js
@@ -0,0 +1,114 @@
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import FrequentItemsApp from '~/frequent_items/components/app.vue';
+import { FREQUENT_ITEMS_PROJECTS } from '~/frequent_items/constants';
+import eventHub from '~/frequent_items/event_hub';
+import TopNavContainerView from '~/nav/components/top_nav_container_view.vue';
+import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
+import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
+import { TEST_NAV_DATA } from '../mock_data';
+
+const DEFAULT_PROPS = {
+ frequentItemsDropdownType: FREQUENT_ITEMS_PROJECTS.namespace,
+ frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule,
+ linksPrimary: TEST_NAV_DATA.primary,
+ linksSecondary: TEST_NAV_DATA.secondary,
+};
+const TEST_OTHER_PROPS = {
+ namespace: 'projects',
+ currentUserName: '',
+ currentItem: {},
+};
+
+describe('~/nav/components/top_nav_container_view.vue', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(TopNavContainerView, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...TEST_OTHER_PROPS,
+ ...props,
+ },
+ });
+ };
+
+ const findMenuItems = (parent = wrapper) => parent.findAll(TopNavMenuItem);
+ const findMenuItemsModel = (parent = wrapper) =>
+ findMenuItems(parent).wrappers.map((x) => x.props());
+ const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]');
+ const findMenuItemGroupsModel = () => findMenuItemGroups().wrappers.map(findMenuItemsModel);
+ const findFrequentItemsApp = () => {
+ const parent = wrapper.findComponent(VuexModuleProvider);
+
+ return {
+ vuexModule: parent.props('vuexModule'),
+ props: parent.findComponent(FrequentItemsApp).props(),
+ };
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each(['projects', 'groups'])(
+ 'emits frequent items event to event hub (%s)',
+ async (frequentItemsDropdownType) => {
+ const listener = jest.fn();
+ eventHub.$on(`${frequentItemsDropdownType}-dropdownOpen`, listener);
+ createComponent({ frequentItemsDropdownType });
+
+ expect(listener).not.toHaveBeenCalled();
+
+ await nextTick();
+
+ expect(listener).toHaveBeenCalled();
+ },
+ );
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders frequent items app', () => {
+ expect(findFrequentItemsApp()).toEqual({
+ vuexModule: DEFAULT_PROPS.frequentItemsVuexModule,
+ props: TEST_OTHER_PROPS,
+ });
+ });
+
+ it('renders menu item groups', () => {
+ expect(findMenuItemGroupsModel()).toEqual([
+ TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })),
+ TEST_NAV_DATA.secondary.map((menuItem) => ({ menuItem })),
+ ]);
+ });
+
+ it('only the first group does not have margin top', () => {
+ expect(findMenuItemGroups().wrappers.map((x) => x.classes('gl-mt-3'))).toEqual([false, true]);
+ });
+
+ it('only the first menu item does not have margin top', () => {
+ const actual = findMenuItems(findMenuItemGroups().at(1)).wrappers.map((x) =>
+ x.classes('gl-mt-1'),
+ );
+
+ expect(actual).toEqual([false, ...TEST_NAV_DATA.secondary.slice(1).fill(true)]);
+ });
+ });
+
+ describe('without secondary links', () => {
+ beforeEach(() => {
+ createComponent({
+ linksSecondary: [],
+ });
+ });
+
+ it('renders one menu item group', () => {
+ expect(findMenuItemGroupsModel()).toEqual([
+ TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })),
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js
new file mode 100644
index 00000000000..d9bba22238a
--- /dev/null
+++ b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js
@@ -0,0 +1,157 @@
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue';
+import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
+import { TEST_NAV_DATA } from '../mock_data';
+
+const SECONDARY_GROUP_CLASSES = TopNavDropdownMenu.SECONDARY_GROUP_CLASS.split(' ');
+
+describe('~/nav/components/top_nav_dropdown_menu.vue', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(TopNavDropdownMenu, {
+ propsData: {
+ primary: TEST_NAV_DATA.primary,
+ secondary: TEST_NAV_DATA.secondary,
+ views: TEST_NAV_DATA.views,
+ ...props,
+ },
+ });
+ };
+
+ const findMenuItems = (parent = wrapper) => parent.findAll('[data-testid="menu-item"]');
+ const findMenuItemsModel = (parent = wrapper) =>
+ findMenuItems(parent).wrappers.map((x) => ({
+ menuItem: x.props('menuItem'),
+ isActive: x.classes('active'),
+ }));
+ const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]');
+ const findMenuItemGroupsModel = () =>
+ findMenuItemGroups().wrappers.map((x) => ({
+ classes: x.classes(),
+ items: findMenuItemsModel(x),
+ }));
+ const findMenuSidebar = () => wrapper.find('[data-testid="menu-sidebar"]');
+ const findMenuSubview = () => wrapper.findComponent(KeepAliveSlots);
+ const hasFullWidthMenuSidebar = () => findMenuSidebar().classes('gl-w-full');
+
+ const createItemsGroupModelExpectation = ({
+ primary = TEST_NAV_DATA.primary,
+ secondary = TEST_NAV_DATA.secondary,
+ activeIndex = -1,
+ } = {}) => [
+ {
+ classes: [],
+ items: primary.map((menuItem, index) => ({ isActive: index === activeIndex, menuItem })),
+ },
+ {
+ classes: SECONDARY_GROUP_CLASSES,
+ items: secondary.map((menuItem) => ({ isActive: false, menuItem })),
+ },
+ ];
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders menu item groups', () => {
+ expect(findMenuItemGroupsModel()).toEqual(createItemsGroupModelExpectation());
+ });
+
+ it('has full width menu sidebar', () => {
+ expect(hasFullWidthMenuSidebar()).toBe(true);
+ });
+
+ it('renders hidden subview with no slot key', () => {
+ const subview = findMenuSubview();
+
+ expect(subview.isVisible()).toBe(false);
+ expect(subview.props()).toEqual({ slotKey: '' });
+ });
+
+ it('the first menu item in a group does not render margin top', () => {
+ const actual = findMenuItems(findMenuItemGroups().at(0)).wrappers.map((x) =>
+ x.classes('gl-mt-1'),
+ );
+
+ expect(actual).toEqual([false, ...TEST_NAV_DATA.primary.slice(1).fill(true)]);
+ });
+ });
+
+ describe('with pre-initialized active view', () => {
+ const primaryWithActive = [
+ TEST_NAV_DATA.primary[0],
+ {
+ ...TEST_NAV_DATA.primary[1],
+ active: true,
+ },
+ ...TEST_NAV_DATA.primary.slice(2),
+ ];
+
+ beforeEach(() => {
+ createComponent({
+ primary: primaryWithActive,
+ });
+ });
+
+ it('renders menu item groups', () => {
+ expect(findMenuItemGroupsModel()).toEqual(
+ createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 1 }),
+ );
+ });
+
+ it('does not have full width menu sidebar', () => {
+ expect(hasFullWidthMenuSidebar()).toBe(false);
+ });
+
+ it('renders visible subview with slot key', () => {
+ const subview = findMenuSubview();
+
+ expect(subview.isVisible()).toBe(true);
+ expect(subview.props('slotKey')).toBe(primaryWithActive[1].view);
+ });
+
+ it('does not change view if non-view menu item is clicked', async () => {
+ const secondaryLink = findMenuItems().at(primaryWithActive.length);
+
+ // Ensure this doesn't have a view
+ expect(secondaryLink.props('menuItem').view).toBeUndefined();
+
+ secondaryLink.vm.$emit('click');
+
+ await nextTick();
+
+ expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[1].view);
+ });
+
+ describe('when other view menu item is clicked', () => {
+ let primaryLink;
+
+ beforeEach(async () => {
+ primaryLink = findMenuItems().at(0);
+ primaryLink.vm.$emit('click');
+ await nextTick();
+ });
+
+ it('clicked on link with view', () => {
+ expect(primaryLink.props('menuItem').view).toBeTruthy();
+ });
+
+ it('changes active view', () => {
+ expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[0].view);
+ });
+
+ it('changes active status on menu item', () => {
+ expect(findMenuItemGroupsModel()).toStrictEqual(
+ createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 0 }),
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/nav/components/top_nav_menu_item_spec.js b/spec/frontend/nav/components/top_nav_menu_item_spec.js
new file mode 100644
index 00000000000..579af13d08a
--- /dev/null
+++ b/spec/frontend/nav/components/top_nav_menu_item_spec.js
@@ -0,0 +1,74 @@
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
+
+const TEST_MENU_ITEM = {
+ title: 'Cheeseburger',
+ icon: 'search',
+ href: '/pretty/good/burger',
+ view: 'burger-view',
+};
+
+describe('~/nav/components/top_nav_menu_item.vue', () => {
+ let listener;
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(TopNavMenuItem, {
+ propsData: {
+ menuItem: TEST_MENU_ITEM,
+ ...props,
+ },
+ listeners: {
+ click: listener,
+ },
+ });
+ };
+
+ const findButton = () => wrapper.find(GlButton);
+ const findButtonIcons = () =>
+ findButton()
+ .findAllComponents(GlIcon)
+ .wrappers.map((x) => x.props('name'));
+
+ beforeEach(() => {
+ listener = jest.fn();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders button href and text', () => {
+ const button = findButton();
+
+ expect(button.attributes('href')).toBe(TEST_MENU_ITEM.href);
+ expect(button.text()).toBe(TEST_MENU_ITEM.title);
+ });
+
+ it('passes listeners to button', () => {
+ expect(listener).not.toHaveBeenCalled();
+
+ findButton().vm.$emit('click', 'TEST');
+
+ expect(listener).toHaveBeenCalledWith('TEST');
+ });
+ });
+
+ describe.each`
+ desc | menuItem | expectedIcons
+ ${'default'} | ${TEST_MENU_ITEM} | ${[TEST_MENU_ITEM.icon, 'chevron-right']}
+ ${'with no icon'} | ${{ ...TEST_MENU_ITEM, icon: null }} | ${['chevron-right']}
+ ${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]}
+ ${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]}
+ `('$desc', ({ menuItem, expectedIcons }) => {
+ beforeEach(() => {
+ createComponent({ menuItem });
+ });
+
+ it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => {
+ expect(findButtonIcons()).toEqual(expectedIcons);
+ });
+ });
+});
diff --git a/spec/frontend/nav/mock_data.js b/spec/frontend/nav/mock_data.js
new file mode 100644
index 00000000000..2987d8deb16
--- /dev/null
+++ b/spec/frontend/nav/mock_data.js
@@ -0,0 +1,35 @@
+import { range } from 'lodash';
+
+export const TEST_NAV_DATA = {
+ activeTitle: 'Test Active Title',
+ primary: [
+ ...['projects', 'groups'].map((view) => ({
+ id: view,
+ href: null,
+ title: view,
+ view,
+ })),
+ ...range(0, 2).map((idx) => ({
+ id: `primary-link-${idx}`,
+ href: `/path/to/primary/${idx}`,
+ title: `Title ${idx}`,
+ })),
+ ],
+ secondary: range(0, 2).map((idx) => ({
+ id: `secondary-link-${idx}`,
+ href: `/path/to/secondary/${idx}`,
+ title: `SecTitle ${idx}`,
+ })),
+ views: {
+ projects: {
+ namespace: 'projects',
+ currentUserName: '',
+ currentItem: {},
+ },
+ groups: {
+ namespace: 'groups',
+ currentUserName: '',
+ currentItem: {},
+ },
+ },
+};
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index b3fda455b2f..17e6c75ca27 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -341,4 +341,65 @@ RSpec.describe IssuesHelper do
end
end
end
+
+ describe '#issue_manual_ordering_class' do
+ context 'when sorting by relative position' do
+ before do
+ assign(:sort, 'relative_position')
+ end
+
+ it 'returns manual ordering class' do
+ expect(helper.issue_manual_ordering_class).to eq("manual-ordering")
+ end
+
+ context 'when manual sorting disabled' do
+ before do
+ allow(helper).to receive(:issue_repositioning_disabled?).and_return(true)
+ end
+
+ it 'returns nil' do
+ expect(helper.issue_manual_ordering_class).to eq(nil)
+ end
+ end
+ end
+ end
+
+ describe '#issue_repositioning_disabled?' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+
+ subject { helper.issue_repositioning_disabled? }
+
+ context 'for project' do
+ before do
+ assign(:project, project)
+ end
+
+ it { is_expected.to eq(false) }
+
+ context 'when block_issue_repositioning feature flag is enabled' do
+ before do
+ stub_feature_flags(block_issue_repositioning: group)
+ end
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
+ context 'for group' do
+ before do
+ assign(:group, group)
+ end
+
+ it { is_expected.to eq(false) }
+
+ context 'when block_issue_repositioning feature flag is enabled' do
+ before do
+ stub_feature_flags(block_issue_repositioning: group)
+ end
+
+ it { is_expected.to eq(true) }
+ end
+ end
+ end
end
diff --git a/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb b/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb
index 32a9f09c3f6..1820141c898 100644
--- a/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Banzai::ReferenceParser::MergeRequestParser do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:merge_request) { create(:merge_request, source_project: project) }
- subject { described_class.new(Banzai::RenderContext.new(merge_request.target_project, user)) }
+ subject(:parser) { described_class.new(Banzai::RenderContext.new(merge_request.target_project, user)) }
let(:link) { empty_html_link }
@@ -65,4 +65,49 @@ RSpec.describe Banzai::ReferenceParser::MergeRequestParser do
it_behaves_like 'no N+1 queries'
end
+
+ describe '#can_read_reference?' do
+ subject { parser.can_read_reference?(user, merge_request) }
+
+ it { is_expected.to be_truthy }
+
+ context 'when merge request belongs to the private project' do
+ let(:project) { create(:project, :private) }
+
+ it 'prevents user from reading merge request references' do
+ is_expected.to be_falsey
+ end
+
+ context 'when user has access to the project' do
+ before do
+ project.add_developer(user)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ context 'with memoization' do
+ context 'when project is the same' do
+ it 'calls #can? only once' do
+ expect(parser).to receive(:can?).once
+
+ 2.times { parser.can_read_reference?(user, merge_request) }
+ end
+ end
+
+ context 'when merge requests belong to different projects' do
+ it 'calls #can? for each project' do
+ expect(parser).to receive(:can?).twice
+
+ another_merge_request = create(:merge_request)
+
+ 2.times do
+ parser.can_read_reference?(user, merge_request)
+ parser.can_read_reference?(user, another_merge_request)
+ end
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb b/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb
new file mode 100644
index 00000000000..b30143ed196
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do
+ def clear_queues
+ Sidekiq::Queue.new('authorized_projects').clear
+ Sidekiq::Queue.new('post_receive').clear
+ Sidekiq::RetrySet.new.clear
+ Sidekiq::ScheduledSet.new.clear
+ end
+
+ around do |example|
+ clear_queues
+ Sidekiq::Testing.disable!(&example)
+ clear_queues
+ end
+
+ describe '#execute', :aggregate_failures do
+ shared_examples 'processing a set' do
+ let(:migrator) { described_class.new(set_name) }
+
+ let(:set_after) do
+ Sidekiq.redis { |c| c.zrange(set_name, 0, -1, with_scores: true) }
+ .map { |item, score| [Sidekiq.load_json(item), score] }
+ end
+
+ context 'when the set is empty' do
+ it 'returns the number of scanned and migrated jobs' do
+ expect(migrator.execute('AuthorizedProjectsWorker' => 'new_queue')).to eq(scanned: 0, migrated: 0)
+ end
+ end
+
+ context 'when the set is not empty' do
+ it 'returns the number of scanned and migrated jobs' do
+ create_jobs
+
+ expect(migrator.execute({})).to eq(scanned: 4, migrated: 0)
+ end
+ end
+
+ context 'when there are no matching jobs' do
+ it 'does not change any queue names' do
+ create_jobs(include_post_receive: false)
+
+ expect(migrator.execute('PostReceive' => 'new_queue')).to eq(scanned: 3, migrated: 0)
+
+ expect(set_after.length).to eq(3)
+ expect(set_after.map(&:first)).to all(include('queue' => 'authorized_projects',
+ 'class' => 'AuthorizedProjectsWorker'))
+ end
+ end
+
+ context 'when there are matching jobs' do
+ it 'migrates only the workers matching the given worker from the set' do
+ freeze_time do
+ create_jobs
+
+ expect(migrator.execute('AuthorizedProjectsWorker' => 'new_queue')).to eq(scanned: 4, migrated: 3)
+
+ set_after.each.with_index do |(item, score), i|
+ if item['class'] == 'AuthorizedProjectsWorker'
+ expect(item).to include('queue' => 'new_queue', 'args' => [i])
+ else
+ expect(item).to include('queue' => 'post_receive', 'args' => [i])
+ end
+
+ expect(score).to eq(i.succ.hours.from_now.to_i)
+ end
+ end
+ end
+
+ it 'allows migrating multiple workers at once' do
+ freeze_time do
+ create_jobs
+
+ expect(migrator.execute('AuthorizedProjectsWorker' => 'new_queue', 'PostReceive' => 'another_queue'))
+ .to eq(scanned: 4, migrated: 4)
+
+ set_after.each.with_index do |(item, score), i|
+ if item['class'] == 'AuthorizedProjectsWorker'
+ expect(item).to include('queue' => 'new_queue', 'args' => [i])
+ else
+ expect(item).to include('queue' => 'another_queue', 'args' => [i])
+ end
+
+ expect(score).to eq(i.succ.hours.from_now.to_i)
+ end
+ end
+ end
+
+ it 'allows migrating multiple workers to the same queue' do
+ freeze_time do
+ create_jobs
+
+ expect(migrator.execute('AuthorizedProjectsWorker' => 'new_queue', 'PostReceive' => 'new_queue'))
+ .to eq(scanned: 4, migrated: 4)
+
+ set_after.each.with_index do |(item, score), i|
+ expect(item).to include('queue' => 'new_queue', 'args' => [i])
+ expect(score).to eq(i.succ.hours.from_now.to_i)
+ end
+ end
+ end
+
+ it 'does not try to migrate jobs that are removed from the set during the migration' do
+ freeze_time do
+ create_jobs
+
+ allow(migrator).to receive(:migrate_job).and_wrap_original do |meth, *args|
+ Sidekiq.redis { |c| c.zrem(set_name, args.first) }
+
+ meth.call(*args)
+ end
+
+ expect(migrator.execute('PostReceive' => 'new_queue')).to eq(scanned: 4, migrated: 0)
+
+ expect(set_after.length).to eq(3)
+ expect(set_after.map(&:first)).to all(include('queue' => 'authorized_projects'))
+ end
+ end
+
+ it 'does not try to migrate unmatched jobs that are added to the set during the migration' do
+ create_jobs
+
+ calls = 0
+
+ allow(migrator).to receive(:migrate_job).and_wrap_original do |meth, *args|
+ if calls == 0
+ travel_to(5.hours.from_now) { create_jobs(include_post_receive: false) }
+ end
+
+ calls += 1
+
+ meth.call(*args)
+ end
+
+ expect(migrator.execute('PostReceive' => 'new_queue')).to eq(scanned: 4, migrated: 1)
+
+ expect(set_after.group_by { |job| job.first['queue'] }.transform_values(&:count))
+ .to eq('authorized_projects' => 6, 'new_queue' => 1)
+ end
+
+ it 'iterates through the entire set of jobs' do
+ 50.times do |i|
+ travel_to(i.hours.from_now) { create_jobs }
+ end
+
+ expect(migrator.execute('NonExistentWorker' => 'new_queue')).to eq(scanned: 200, migrated: 0)
+
+ expect(set_after.length).to eq(200)
+ end
+
+ it 'logs output at the start, finish, and every LOG_FREQUENCY jobs' do
+ freeze_time do
+ create_jobs
+
+ stub_const("#{described_class}::LOG_FREQUENCY", 2)
+
+ logger = Logger.new(StringIO.new)
+ migrator = described_class.new(set_name, logger: logger)
+
+ expect(logger).to receive(:info).with(a_string_matching('Processing')).once.ordered
+ expect(logger).to receive(:info).with(a_string_matching('In progress')).once.ordered
+ expect(logger).to receive(:info).with(a_string_matching('Done')).once.ordered
+
+ expect(migrator.execute('AuthorizedProjectsWorker' => 'new_queue', 'PostReceive' => 'new_queue'))
+ .to eq(scanned: 4, migrated: 4)
+ end
+ end
+ end
+ end
+
+ context 'scheduled jobs' do
+ let(:set_name) { 'schedule' }
+
+ def create_jobs(include_post_receive: true)
+ AuthorizedProjectsWorker.perform_in(1.hour, 0)
+ AuthorizedProjectsWorker.perform_in(2.hours, 1)
+ PostReceive.perform_in(3.hours, 2) if include_post_receive
+ AuthorizedProjectsWorker.perform_in(4.hours, 3)
+ end
+
+ it_behaves_like 'processing a set'
+ end
+
+ context 'retried jobs' do
+ let(:set_name) { 'retry' }
+
+ # Try to mimic as closely as possible what Sidekiq will actually
+ # do to retry a job.
+ def retry_in(klass, time, args)
+ # In Sidekiq 6, this argument will become a JSON string
+ message = { 'class' => klass, 'args' => [args], 'retry' => true }
+
+ allow(klass).to receive(:sidekiq_retry_in_block).and_return(proc { time })
+
+ begin
+ Sidekiq::JobRetry.new.local(klass, message, klass.queue) { raise 'boom' }
+ rescue Sidekiq::JobRetry::Skip
+ # Sidekiq scheduled the retry
+ end
+ end
+
+ def create_jobs(include_post_receive: true)
+ retry_in(AuthorizedProjectsWorker, 1.hour, 0)
+ retry_in(AuthorizedProjectsWorker, 2.hours, 1)
+ retry_in(PostReceive, 3.hours, 2) if include_post_receive
+ retry_in(AuthorizedProjectsWorker, 4.hours, 3)
+ end
+
+ it_behaves_like 'processing a set'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage/metric_definition_spec.rb b/spec/lib/gitlab/usage/metric_definition_spec.rb
index c352e5bb36f..65cd4300ee6 100644
--- a/spec/lib/gitlab/usage/metric_definition_spec.rb
+++ b/spec/lib/gitlab/usage/metric_definition_spec.rb
@@ -69,8 +69,8 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
:tier | %w(test ee)
:name | 'count_<adjective_describing>_boards'
- :instrumentation_class | 'Gitlab::Usage::Metrics::Instrumentations::Metric_Class'
- :instrumentation_class | 'Gitlab::Usage::Metrics::MetricClass'
+ :instrumentation_class | 'Metric_Class'
+ :instrumentation_class | 'metricClass'
end
with_them do
diff --git a/spec/lib/version_check_spec.rb b/spec/lib/version_check_spec.rb
new file mode 100644
index 00000000000..23c381e241e
--- /dev/null
+++ b/spec/lib/version_check_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe VersionCheck do
+ describe '.url' do
+ it 'returns the correct URL' do
+ expect(described_class.url).to match(%r{\A#{Regexp.escape(described_class.host)}/check\.svg\?gitlab_info=\w+})
+ end
+ end
+end
diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb
index c8a9504d4fc..0b7c21fd0c3 100644
--- a/spec/models/board_spec.rb
+++ b/spec/models/board_spec.rb
@@ -42,4 +42,46 @@ RSpec.describe Board do
expect { project.boards.first_board.find(board_A.id) }.to raise_error(ActiveRecord::RecordNotFound)
end
end
+
+ describe '#disabled_for?' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:user) { create(:user) }
+
+ subject { board.disabled_for?(user) }
+
+ shared_examples 'board disabled_for?' do
+ context 'when current user cannot create non backlog issues' do
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when user can create backlog issues' do
+ before do
+ board.resource_parent.add_reporter(user)
+ end
+
+ it { is_expected.to eq(false) }
+
+ context 'when block_issue_repositioning is enabled' do
+ before do
+ stub_feature_flags(block_issue_repositioning: group)
+ end
+
+ it { is_expected.to eq(true) }
+ end
+ end
+ end
+
+ context 'for group board' do
+ let_it_be(:board) { create(:board, group: group) }
+
+ it_behaves_like 'board disabled_for?'
+ end
+
+ context 'for project board' do
+ let_it_be(:board) { create(:board, project: project) }
+
+ it_behaves_like 'board disabled_for?'
+ end
+ end
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 7f301d80b32..884c476932e 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -1141,11 +1141,37 @@ RSpec.describe Issue do
end
context "relative positioning" do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:issue1) { create(:issue, project: project, relative_position: nil) }
+ let_it_be(:issue2) { create(:issue, project: project, relative_position: nil) }
+
it_behaves_like "a class that supports relative positioning" do
let_it_be(:project) { reusable_project }
let(:factory) { :issue }
let(:default_params) { { project: project } }
end
+
+ it 'is not blocked for repositioning by default' do
+ expect(issue1.blocked_for_repositioning?).to eq(false)
+ end
+
+ context 'when block_issue_repositioning flag is enabled for group' do
+ before do
+ stub_feature_flags(block_issue_repositioning: group)
+ end
+
+ it 'is blocked for repositioning' do
+ expect(issue1.blocked_for_repositioning?).to eq(true)
+ end
+
+ it 'does not move issues with null position' do
+ payload = [issue1, issue2]
+
+ expect { described_class.move_nulls_to_end(payload) }.to raise_error(Gitlab::RelativePositioning::IssuePositioningDisabled)
+ expect { described_class.move_nulls_to_start(payload) }.to raise_error(Gitlab::RelativePositioning::IssuePositioningDisabled)
+ end
+ end
end
it_behaves_like 'versioned description'
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 84d4794df5e..a77ca1e9a51 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -3876,6 +3876,20 @@ RSpec.describe MergeRequest, factory_default: :keep do
subject { merge_request.use_merge_base_pipeline_for_comparison?(service_class) }
+ context 'when service class is Ci::CompareMetricsReportsService' do
+ let(:service_class) { 'Ci::CompareMetricsReportsService' }
+
+ it { is_expected.to be_truthy }
+
+ context 'with the metrics report flag disabled' do
+ before do
+ stub_feature_flags(merge_base_pipeline_for_metrics_comparison: false)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
context 'when service class is Ci::CompareCodequalityReportsService' do
let(:service_class) { 'Ci::CompareCodequalityReportsService' }
diff --git a/spec/services/ci/create_downstream_pipeline_service_spec.rb b/spec/services/ci/create_downstream_pipeline_service_spec.rb
index 6fc613ce6da..8bab7856375 100644
--- a/spec/services/ci/create_downstream_pipeline_service_spec.rb
+++ b/spec/services/ci/create_downstream_pipeline_service_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
+ include Ci::SourcePipelineHelpers
+
let_it_be(:user) { create(:user) }
let(:upstream_project) { create(:project, :repository) }
let_it_be(:downstream_project, refind: true) { create(:project, :repository) }
@@ -394,6 +396,47 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
end
end
+ context 'when relationship between pipelines is cyclical' do
+ before do
+ pipeline_a = create(:ci_pipeline, project: upstream_project)
+ pipeline_b = create(:ci_pipeline, project: downstream_project)
+ pipeline_c = create(:ci_pipeline, project: upstream_project)
+
+ create_source_pipeline(pipeline_a, pipeline_b)
+ create_source_pipeline(pipeline_b, pipeline_c)
+ create_source_pipeline(pipeline_c, upstream_pipeline)
+ end
+
+ it 'does not create a new pipeline' do
+ expect { service.execute(bridge) }
+ .not_to change { Ci::Pipeline.count }
+ end
+
+ it 'changes status of the bridge build' do
+ service.execute(bridge)
+
+ expect(bridge.reload).to be_failed
+ expect(bridge.failure_reason).to eq 'pipeline_loop_detected'
+ end
+
+ context 'when ci_drop_cyclical_triggered_pipelines is not enabled' do
+ before do
+ stub_feature_flags(ci_drop_cyclical_triggered_pipelines: false)
+ end
+
+ it 'creates a new pipeline' do
+ expect { service.execute(bridge) }
+ .to change { Ci::Pipeline.count }
+ end
+
+ it 'expect bridge build not to be failed' do
+ service.execute(bridge)
+
+ expect(bridge.reload).not_to be_failed
+ end
+ end
+ end
+
context 'when downstream pipeline creation errors out' do
let(:stub_config) { false }
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index 7490ad5b2b3..8c97dd95ced 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -165,20 +165,38 @@ RSpec.describe Issues::UpdateService, :mailer do
expect(user2.assigned_open_issues_count).to eq 1
end
- it 'sorts issues as specified by parameters' do
- issue1 = create(:issue, project: project, assignees: [user3])
- issue2 = create(:issue, project: project, assignees: [user3])
+ context 'when changing relative position' do
+ let(:issue1) { create(:issue, project: project, assignees: [user3]) }
+ let(:issue2) { create(:issue, project: project, assignees: [user3]) }
- [issue, issue1, issue2].each do |issue|
- issue.move_to_end
- issue.save!
+ before do
+ [issue, issue1, issue2].each do |issue|
+ issue.move_to_end
+ issue.save!
+ end
end
- opts[:move_between_ids] = [issue1.id, issue2.id]
+ it 'sorts issues as specified by parameters' do
+ opts[:move_between_ids] = [issue1.id, issue2.id]
- update_issue(opts)
+ update_issue(opts)
- expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
+ expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
+ end
+
+ context 'when block_issue_positioning flag is enabled' do
+ before do
+ stub_feature_flags(block_issue_repositioning: true)
+ end
+
+ it 'raises error' do
+ old_position = issue.relative_position
+ opts[:move_between_ids] = [issue1.id, issue2.id]
+
+ expect { update_issue(opts) }.to raise_error(::Gitlab::RelativePositioning::IssuePositioningDisabled)
+ expect(issue.reload.relative_position).to eq(old_position)
+ end
+ end
end
it 'does not rebalance even if needed if the flag is disabled' do
diff --git a/spec/services/submit_usage_ping_service_spec.rb b/spec/services/submit_usage_ping_service_spec.rb
index 53cc33afcff..a9f1b2c2b2d 100644
--- a/spec/services/submit_usage_ping_service_spec.rb
+++ b/spec/services/submit_usage_ping_service_spec.rb
@@ -217,7 +217,7 @@ RSpec.describe SubmitUsagePingService do
end
def stub_response(body:, status: 201)
- stub_full_request(SubmitUsagePingService::STAGING_URL, method: :post)
+ stub_full_request(subject.send(:url), method: :post)
.to_return(
headers: { 'Content-Type' => 'application/json' },
body: body.to_json,
diff --git a/spec/services/users/build_service_spec.rb b/spec/services/users/build_service_spec.rb
index bfcbc6971d4..e8786c677d1 100644
--- a/spec/services/users/build_service_spec.rb
+++ b/spec/services/users/build_service_spec.rb
@@ -6,105 +6,76 @@ RSpec.describe Users::BuildService do
using RSpec::Parameterized::TableSyntax
describe '#execute' do
- let(:params) { build_stubbed(:user).slice(:first_name, :last_name, :username, :email, :password) }
-
- context 'with an admin user' do
- let(:params) { build_stubbed(:user).slice(:name, :username, :email, :password) }
+ let_it_be(:current_user) { nil }
- let(:admin_user) { create(:admin) }
- let(:service) { described_class.new(admin_user, ActionController::Parameters.new(params).permit!) }
+ let(:params) { build_stubbed(:user).slice(:first_name, :last_name, :username, :email, :password) }
+ let(:service) { described_class.new(current_user, params) }
- it 'returns a valid user' do
- expect(service.execute).to be_valid
- end
+ shared_examples_for 'common build items' do
+ it { is_expected.to be_valid }
it 'sets the created_by_id' do
- expect(service.execute.created_by_id).to eq(admin_user.id)
+ expect(user.created_by_id).to eq(current_user&.id)
end
- context 'calls the UpdateCanonicalEmailService' do
- specify do
- expect(Users::UpdateCanonicalEmailService).to receive(:new).and_call_original
+ it 'calls UpdateCanonicalEmailService' do
+ expect(Users::UpdateCanonicalEmailService).to receive(:new).and_call_original
- service.execute
- end
+ user
end
- context 'allowed params' do
- let(:params) do
- {
- access_level: 1,
- admin: 1,
- avatar: anything,
- bio: 1,
- can_create_group: 1,
- color_scheme_id: 1,
- email: 1,
- external: 1,
- force_random_password: 1,
- hide_no_password: 1,
- hide_no_ssh_key: 1,
- linkedin: 1,
- name: 1,
- password: 1,
- password_automatically_set: 1,
- password_expires_at: 1,
- projects_limit: 1,
- remember_me: 1,
- skip_confirmation: 1,
- skype: 1,
- theme_id: 1,
- twitter: 1,
- username: 1,
- website_url: 1,
- private_profile: 1,
- organization: 1,
- location: 1,
- public_email: 1
- }
- end
+ context 'when user_type is provided' do
+ context 'when project_bot' do
+ before do
+ params.merge!({ user_type: :project_bot })
+ end
- it 'sets all allowed attributes' do
- admin_user # call first so the admin gets created before setting `expect`
+ it { expect(user.project_bot?).to be true }
+ end
- expect(User).to receive(:new).with(hash_including(params)).and_call_original
+ context 'when not a project_bot' do
+ before do
+ params.merge!({ user_type: :alert_bot })
+ end
- service.execute
+ it { expect(user).to be_human }
end
end
+ end
+ shared_examples_for 'current user not admin' do
context 'with "user_default_external" application setting' do
where(:user_default_external, :external, :email, :user_default_internal_regex, :result) do
true | nil | 'fl@example.com' | nil | true
true | true | 'fl@example.com' | nil | true
- true | false | 'fl@example.com' | nil | false
+ true | false | 'fl@example.com' | nil | true # admin difference
true | nil | 'fl@example.com' | '' | true
true | true | 'fl@example.com' | '' | true
- true | false | 'fl@example.com' | '' | false
+ true | false | 'fl@example.com' | '' | true # admin difference
true | nil | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
- true | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | true
+ true | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false # admin difference
true | false | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
true | nil | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | true
true | true | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | true
- true | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false
+ true | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | true # admin difference
false | nil | 'fl@example.com' | nil | false
- false | true | 'fl@example.com' | nil | true
+ false | true | 'fl@example.com' | nil | false # admin difference
false | false | 'fl@example.com' | nil | false
false | nil | 'fl@example.com' | '' | false
- false | true | 'fl@example.com' | '' | true
+ false | true | 'fl@example.com' | '' | false # admin difference
false | false | 'fl@example.com' | '' | false
false | nil | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
- false | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | true
+ false | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false # admin difference
false | false | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
false | nil | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false
- false | true | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | true
+ false | true | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false # admin difference
false | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false
end
@@ -116,40 +87,11 @@ RSpec.describe Users::BuildService do
params.merge!({ external: external, email: email }.compact)
end
- subject(:user) { service.execute }
-
- it 'correctly sets user.external' do
+ it 'sets the value of Gitlab::CurrentSettings.user_default_external' do
expect(user.external).to eq(result)
end
end
end
- end
-
- context 'with non admin user' do
- let(:user) { create(:user) }
- let(:service) { described_class.new(user, params) }
-
- it 'raises AccessDeniedError exception' do
- expect { service.execute }.to raise_error Gitlab::Access::AccessDeniedError
- end
-
- context 'when authorization is skipped' do
- subject(:built_user) { service.execute(skip_authorization: true) }
-
- it { is_expected.to be_valid }
-
- it 'sets the created_by_id' do
- expect(built_user.created_by_id).to eq(user.id)
- end
- end
- end
-
- context 'with nil user' do
- let(:service) { described_class.new(nil, params) }
-
- it 'returns a valid user' do
- expect(service.execute).to be_valid
- end
context 'when "send_user_confirmation_email" application setting is true' do
before do
@@ -157,7 +99,7 @@ RSpec.describe Users::BuildService do
end
it 'does not confirm the user' do
- expect(service.execute).not_to be_confirmed
+ expect(user).not_to be_confirmed
end
end
@@ -167,27 +109,103 @@ RSpec.describe Users::BuildService do
end
it 'confirms the user' do
- expect(service.execute).to be_confirmed
+ expect(user).to be_confirmed
end
end
- context 'when user_type is provided' do
- subject(:user) { service.execute }
+ context 'with allowed params' do
+ let(:params) do
+ {
+ email: 1,
+ name: 1,
+ password: 1,
+ password_automatically_set: 1,
+ username: 1,
+ user_type: 'project_bot'
+ }
+ end
- context 'when project_bot' do
- before do
- params.merge!({ user_type: :project_bot })
- end
+ it 'sets all allowed attributes' do
+ expect(User).to receive(:new).with(hash_including(params)).and_call_original
- it { expect(user.project_bot?).to be true }
+ user
end
+ end
+ end
- context 'when not a project_bot' do
- before do
- params.merge!({ user_type: :alert_bot })
- end
+ context 'with nil current_user' do
+ subject(:user) { service.execute }
- it { expect(user).to be_human }
+ it_behaves_like 'common build items'
+ it_behaves_like 'current user not admin'
+ end
+
+ context 'with non admin current_user' do
+ let_it_be(:current_user) { create(:user) }
+
+ let(:service) { described_class.new(current_user, params) }
+
+ subject(:user) { service.execute(skip_authorization: true) }
+
+ it 'raises AccessDeniedError exception when authorization is not skipped' do
+ expect { service.execute }.to raise_error Gitlab::Access::AccessDeniedError
+ end
+
+ it_behaves_like 'common build items'
+ it_behaves_like 'current user not admin'
+ end
+
+ context 'with an admin current_user' do
+ let_it_be(:current_user) { create(:admin) }
+
+ let(:params) { build_stubbed(:user).slice(:name, :username, :email, :password) }
+ let(:service) { described_class.new(current_user, ActionController::Parameters.new(params).permit!) }
+
+ subject(:user) { service.execute }
+
+ it_behaves_like 'common build items'
+
+ context 'with allowed params' do
+ let(:params) do
+ {
+ access_level: 1,
+ admin: 1,
+ avatar: anything,
+ bio: 1,
+ can_create_group: 1,
+ color_scheme_id: 1,
+ email: 1,
+ external: 1,
+ force_random_password: 1,
+ hide_no_password: 1,
+ hide_no_ssh_key: 1,
+ linkedin: 1,
+ name: 1,
+ password: 1,
+ password_automatically_set: 1,
+ password_expires_at: 1,
+ projects_limit: 1,
+ remember_me: 1,
+ skip_confirmation: 1,
+ skype: 1,
+ theme_id: 1,
+ twitter: 1,
+ username: 1,
+ website_url: 1,
+ private_profile: 1,
+ organization: 1,
+ location: 1,
+ public_email: 1,
+ user_type: 'project_bot',
+ note: 1,
+ view_diffs_file_by_file: 1
+ }
+ end
+
+ it 'sets all allowed attributes' do
+ expect(User).to receive(:new).with(hash_including(params)).and_call_original
+
+ service.execute
end
end
@@ -195,34 +213,34 @@ RSpec.describe Users::BuildService do
where(:user_default_external, :external, :email, :user_default_internal_regex, :result) do
true | nil | 'fl@example.com' | nil | true
true | true | 'fl@example.com' | nil | true
- true | false | 'fl@example.com' | nil | true
+ true | false | 'fl@example.com' | nil | false # admin difference
true | nil | 'fl@example.com' | '' | true
true | true | 'fl@example.com' | '' | true
- true | false | 'fl@example.com' | '' | true
+ true | false | 'fl@example.com' | '' | false # admin difference
true | nil | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
- true | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
+ true | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | true # admin difference
true | false | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
true | nil | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | true
true | true | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | true
- true | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | true
+ true | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false # admin difference
false | nil | 'fl@example.com' | nil | false
- false | true | 'fl@example.com' | nil | false
+ false | true | 'fl@example.com' | nil | true # admin difference
false | false | 'fl@example.com' | nil | false
false | nil | 'fl@example.com' | '' | false
- false | true | 'fl@example.com' | '' | false
+ false | true | 'fl@example.com' | '' | true # admin difference
false | false | 'fl@example.com' | '' | false
false | nil | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
- false | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
+ false | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | true # admin difference
false | false | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false
false | nil | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false
- false | true | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false
+ false | true | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | true # admin difference
false | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false
end
@@ -234,8 +252,6 @@ RSpec.describe Users::BuildService do
params.merge!({ external: external, email: email }.compact)
end
- subject(:user) { service.execute }
-
it 'sets the value of Gitlab::CurrentSettings.user_default_external' do
expect(user.external).to eq(result)
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 2cc3e515d1d..bca5614fe27 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -288,6 +288,12 @@ RSpec.configure do |config|
# Selectively disable by actor https://docs.gitlab.com/ee/development/feature_flags/#selectively-disable-by-actor
stub_feature_flags(remove_description_html_in_release_api_override: false)
+ # Disable issue respositioning to avoid heavy load on database when importing big projects.
+ # This is only turned on when app is handling heavy project imports.
+ # Can be removed when we find a better way to deal with the problem.
+ # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
+ stub_feature_flags(block_issue_repositioning: false)
+
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else
unstub_all_feature_flags
diff --git a/spec/tasks/gitlab/sidekiq_rake_spec.rb b/spec/tasks/gitlab/sidekiq_rake_spec.rb
new file mode 100644
index 00000000000..61a8aecfa61
--- /dev/null
+++ b/spec/tasks/gitlab/sidekiq_rake_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+
+RSpec.describe 'sidekiq.rake', :aggregate_failures do
+ before do
+ Rake.application.rake_require 'tasks/gitlab/sidekiq'
+
+ stub_warn_user_is_not_gitlab
+ end
+
+ shared_examples 'migration rake task' do
+ it 'runs the migrator with a mapping of workers to queues' do
+ test_routes = [
+ ['urgency=high', 'default'],
+ ['*', nil]
+ ]
+
+ test_router = ::Gitlab::SidekiqConfig::WorkerRouter.new(test_routes)
+ migrator = ::Gitlab::SidekiqMigrateJobs.new(sidekiq_set, logger: Logger.new($stdout))
+
+ allow(::Gitlab::SidekiqConfig::WorkerRouter)
+ .to receive(:global).and_return(test_router)
+
+ expect(::Gitlab::SidekiqMigrateJobs)
+ .to receive(:new).with(sidekiq_set, logger: an_instance_of(Logger)).and_return(migrator)
+
+ expect(migrator)
+ .to receive(:execute)
+ .with(a_hash_including('PostReceive' => 'default',
+ 'MergeWorker' => 'default',
+ 'DeleteDiffFilesWorker' => 'delete_diff_files'))
+ .and_call_original
+
+ run_rake_task("gitlab:sidekiq:migrate_jobs:#{sidekiq_set}")
+
+ expect($stdout.string).to include("Processing #{sidekiq_set}")
+ expect($stdout.string).to include('Done')
+ end
+ end
+
+ describe 'gitlab:sidekiq:migrate_jobs:schedule rake task' do
+ let(:sidekiq_set) { 'schedule' }
+
+ it_behaves_like 'migration rake task'
+ end
+
+ describe 'gitlab:sidekiq:migrate_jobs:retry rake task' do
+ let(:sidekiq_set) { 'retry' }
+
+ it_behaves_like 'migration rake task'
+ end
+end
diff --git a/spec/views/help/index.html.haml_spec.rb b/spec/views/help/index.html.haml_spec.rb
index c59790a346e..600e431b7ef 100644
--- a/spec/views/help/index.html.haml_spec.rb
+++ b/spec/views/help/index.html.haml_spec.rb
@@ -21,6 +21,11 @@ RSpec.describe 'help/index' do
end
context 'when logged in' do
+ def version_link_regexp(path)
+ base_url = "#{view.source_host_url}/#{view.source_code_group}"
+ %r{#{Regexp.escape(base_url)}/(gitlab|gitlab\-foss)/#{Regexp.escape(path)}}
+ end
+
before do
stub_user
end
@@ -31,7 +36,7 @@ RSpec.describe 'help/index' do
render
expect(rendered).to match '8.0.2'
- expect(rendered).to have_link('8.0.2', href: %r{https://gitlab.com/gitlab-org/(gitlab|gitlab-foss)/-/tags/v8.0.2})
+ expect(rendered).to have_link('8.0.2', href: version_link_regexp('-/tags/v8.0.2'))
end
it 'shows a link to the commit for pre-releases' do
@@ -40,7 +45,7 @@ RSpec.describe 'help/index' do
render
expect(rendered).to match '8.0.2'
- expect(rendered).to have_link('abcdefg', href: %r{https://gitlab.com/gitlab-org/(gitlab|gitlab-foss)/-/commits/abcdefg})
+ expect(rendered).to have_link('abcdefg', href: version_link_regexp('-/commits/abcdefg'))
end
end
end
diff --git a/spec/workers/ci/retry_pipeline_worker_spec.rb b/spec/workers/ci/retry_pipeline_worker_spec.rb
new file mode 100644
index 00000000000..c7600a24280
--- /dev/null
+++ b/spec/workers/ci/retry_pipeline_worker_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::RetryPipelineWorker do
+ describe '#perform' do
+ subject(:perform) { described_class.new.perform(pipeline_id, user_id) }
+
+ let(:pipeline) { create(:ci_pipeline) }
+
+ context 'when pipeline exists' do
+ let(:pipeline_id) { pipeline.id }
+
+ context 'when user exists' do
+ let(:user) { create(:user) }
+ let(:user_id) { user.id }
+
+ before do
+ pipeline.project.add_maintainer(user)
+ end
+
+ it 'retries the pipeline' do
+ expect(::Ci::Pipeline).to receive(:find_by_id).with(pipeline.id).and_return(pipeline)
+ expect(pipeline).to receive(:retry_failed).with(having_attributes(id: user_id))
+
+ perform
+ end
+ end
+
+ context 'when user does not exist' do
+ let(:user_id) { 1234 }
+
+ it 'does not retry the pipeline' do
+ expect(::Ci::Pipeline).to receive(:find_by_id).with(pipeline_id).and_return(pipeline)
+ expect(pipeline).not_to receive(:retry_failed).with(having_attributes(id: user_id))
+
+ perform
+ end
+ end
+ end
+
+ context 'when pipeline does not exist' do
+ let(:pipeline_id) { 1234 }
+ let(:user_id) { 1234 }
+
+ it 'returns nil' do
+ expect(perform).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/workers/issue_placement_worker_spec.rb b/spec/workers/issue_placement_worker_spec.rb
index 2fca7a590fd..e0c17bfadee 100644
--- a/spec/workers/issue_placement_worker_spec.rb
+++ b/spec/workers/issue_placement_worker_spec.rb
@@ -5,7 +5,8 @@ require 'spec_helper'
RSpec.describe IssuePlacementWorker do
describe '#perform' do
let_it_be(:time) { Time.now.utc }
- let_it_be(:project) { create(:project) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
let_it_be(:author) { create(:user) }
let_it_be(:common_attrs) { { author: author, project: project } }
let_it_be(:unplaced) { common_attrs.merge(relative_position: nil) }
@@ -117,6 +118,19 @@ RSpec.describe IssuePlacementWorker do
let(:worker_arguments) { { issue_id: issue_id, project_id: nil } }
it_behaves_like 'running the issue placement worker'
+
+ context 'when block_issue_repositioning is enabled' do
+ let(:issue_id) { issue.id }
+ let(:project_id) { project.id }
+
+ before do
+ stub_feature_flags(block_issue_repositioning: group)
+ end
+
+ it 'does not run repositioning tasks' do
+ expect { run_worker }.not_to change { issue.reset.relative_position }
+ end
+ end
end
context 'passing a project ID' do
diff --git a/spec/workers/issue_rebalancing_worker_spec.rb b/spec/workers/issue_rebalancing_worker_spec.rb
index 8b0fcd4bc5a..e5c6ac3f854 100644
--- a/spec/workers/issue_rebalancing_worker_spec.rb
+++ b/spec/workers/issue_rebalancing_worker_spec.rb
@@ -4,7 +4,21 @@ require 'spec_helper'
RSpec.describe IssueRebalancingWorker do
describe '#perform' do
- let_it_be(:issue) { create(:issue) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ context 'when block_issue_repositioning is enabled' do
+ before do
+ stub_feature_flags(block_issue_repositioning: group)
+ end
+
+ it 'does not run an instance of IssueRebalancingService' do
+ expect(IssueRebalancingService).not_to receive(:new)
+
+ described_class.new.perform(nil, issue.project_id)
+ end
+ end
it 'runs an instance of IssueRebalancingService' do
service = double(execute: nil)