From 49bb78aac34a111c0fb13aae3a83b078be351fd3 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Mon, 17 May 2021 18:10:42 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .gitlab/ci/reports.gitlab-ci.yml | 111 ++----- .gitlab/ci/rules.gitlab-ci.yml | 10 + .../extensions/editor_lite_extension_base.js | 2 - .../javascripts/frequent_items/constants.js | 25 +- .../javascripts/frequent_items/store/index.js | 20 +- app/assets/javascripts/main.js | 2 + .../nav/components/top_nav_app.vue | 59 ++++ .../nav/components/top_nav_container_view.vue | 74 +++++ .../nav/components/top_nav_dropdown_menu.vue | 144 ++++++++ .../nav/components/top_nav_menu_item.vue | 31 ++ app/assets/javascripts/nav/index.js | 12 + app/assets/javascripts/nav/mount.js | 23 ++ app/assets/javascripts/nav/stores/index.js | 4 + .../stylesheets/framework/dropdowns.scss | 88 ++--- app/assets/stylesheets/framework/header.scss | 36 ++ .../stylesheets/framework/variables.scss | 2 + app/assets/stylesheets/themes/_dark.scss | 2 + app/assets/stylesheets/utilities.scss | 18 + app/controllers/boards/issues_controller.rb | 4 +- .../projects/pipelines_controller.rb | 6 +- app/helpers/boards_helper.rb | 6 +- app/helpers/issues_helper.rb | 16 + app/helpers/version_check_helper.rb | 14 +- app/models/board.rb | 6 + app/models/concerns/enums/ci/commit_status.rb | 1 + app/models/concerns/relative_positioning.rb | 8 + app/models/issue.rb | 12 + app/models/merge_request.rb | 1 + app/models/namespace.rb | 4 + app/presenters/commit_status_presenter.rb | 1 + .../ci/create_downstream_pipeline_service.rb | 19 ++ app/services/issues/update_service.rb | 2 + app/services/submit_usage_ping_service.rb | 2 + app/views/groups/boards/show.html.haml | 2 + app/views/layouts/header/_default.html.haml | 2 +- .../layouts/nav/_combined_menu.html.haml | 3 - app/views/layouts/nav/_top_nav.html.haml | 7 + .../nav/groups_dropdown/_show.html.haml | 2 +- .../nav/projects_dropdown/_show.html.haml | 2 +- app/views/projects/issues/_issues.html.haml | 3 +- app/views/shared/_issues.html.haml | 4 +- .../alerts/_positioning_disabled.html.haml | 2 + app/views/shared/boards/_show.html.haml | 2 + app/workers/all_queues.yml | 9 + app/workers/ci/retry_pipeline_worker.rb | 19 ++ app/workers/issue_placement_worker.rb | 4 + app/workers/issue_rebalancing_worker.rb | 5 + .../ssh_keys/expired_notification_worker.rb | 4 +- .../expiring_soon_notification_worker.rb | 4 +- .../271242_memoize_merge_request_policy.yml | 5 + .../327315-enable-ci_wildcard_file_paths.yml | 5 + ...nmemory-remotes-for-findremoterootrefs.yml | 5 + ...ating-sidekiq-scheduled-and-retry-jobs.yml | 5 + ...mc-backstage-make-pipeline-retry-async.yml | 5 + config/application.rb | 3 +- .../background_pipeline_retry_endpoint.yml | 8 + .../ci_drop_cyclical_triggered_pipelines.yml | 8 + .../development/ci_wildcard_file_paths.yml | 2 +- .../find_remote_root_refs_inmemory.yml | 2 +- ...e_base_pipeline_for_metrics_comparison.yml | 8 + .../ops/block_issue_repositioning.yml | 8 + .../counts_28d/20210216181139_issues.yml | 2 +- ...6181508_i_quickactions_approve_monthly.yml | 2 +- ...16181506_i_quickactions_approve_weekly.yml | 2 +- .../counts_all/20210216181102_issues.yml | 2 +- .../counts_all/20210216181115_issues.yml | 2 +- .../counts_all/20210216181252_boards.yml | 2 +- .../metrics/license/20210201124933_uuid.yml | 2 +- .../license/20210204124827_hostname.yml | 2 +- config/metrics/schema.json | 2 +- doc/ci/yaml/README.md | 19 +- doc/integration/jira/issues.md | 43 +++ doc/raketasks/index.md | 3 +- doc/raketasks/sidekiq_job_migration.md | 40 +++ doc/user/group/epics/manage_epics.md | 14 +- doc/user/project/repository/index.md | 4 +- doc/user/project/settings/import_export.md | 2 + doc/user/project/settings/index.md | 7 +- .../reference_parser/merge_request_parser.rb | 12 + lib/gitlab/ci/features.rb | 8 + lib/gitlab/ci/status/build/failed.rb | 1 + lib/gitlab/relative_positioning.rb | 1 + lib/gitlab/sidekiq_migrate_jobs.rb | 72 ++++ lib/gitlab/usage_data_metrics.rb | 2 +- lib/tasks/gitlab/sidekiq.rake | 23 ++ lib/version_check.rb | 6 +- locale/gitlab.pot | 21 +- qa/qa/page/project/web_ide/edit.rb | 12 +- .../5_package/container_registry_spec.rb | 2 +- .../admin/dev_ops_report_controller_spec.rb | 8 +- .../boards/issues_controller_spec.rb | 13 + .../projects/pipelines_controller_spec.rb | 35 +- spec/features/admin/admin_mode_spec.rb | 4 - .../nav/components/top_nav_app_spec.js | 68 ++++ .../components/top_nav_container_view_spec.js | 114 +++++++ .../components/top_nav_dropdown_menu_spec.js | 157 +++++++++ .../nav/components/top_nav_menu_item_spec.js | 74 +++++ spec/frontend/nav/mock_data.js | 35 ++ spec/helpers/issues_helper_spec.rb | 61 ++++ .../merge_request_parser_spec.rb | 47 ++- spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb | 215 ++++++++++++ .../gitlab/usage/metric_definition_spec.rb | 4 +- spec/lib/version_check_spec.rb | 11 + spec/models/board_spec.rb | 42 +++ spec/models/issue_spec.rb | 26 ++ spec/models/merge_request_spec.rb | 14 + ...create_downstream_pipeline_service_spec.rb | 43 +++ spec/services/issues/update_service_spec.rb | 36 +- .../submit_usage_ping_service_spec.rb | 2 +- spec/services/users/build_service_spec.rb | 308 +++++++++--------- spec/spec_helper.rb | 6 + spec/tasks/gitlab/sidekiq_rake_spec.rb | 53 +++ spec/views/help/index.html.haml_spec.rb | 9 +- spec/workers/ci/retry_pipeline_worker_spec.rb | 51 +++ spec/workers/issue_placement_worker_spec.rb | 16 +- spec/workers/issue_rebalancing_worker_spec.rb | 16 +- 116 files changed, 2293 insertions(+), 388 deletions(-) create mode 100644 app/assets/javascripts/nav/components/top_nav_app.vue create mode 100644 app/assets/javascripts/nav/components/top_nav_container_view.vue create mode 100644 app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue create mode 100644 app/assets/javascripts/nav/components/top_nav_menu_item.vue create mode 100644 app/assets/javascripts/nav/index.js create mode 100644 app/assets/javascripts/nav/mount.js create mode 100644 app/assets/javascripts/nav/stores/index.js delete mode 100644 app/views/layouts/nav/_combined_menu.html.haml create mode 100644 app/views/layouts/nav/_top_nav.html.haml create mode 100644 app/views/shared/alerts/_positioning_disabled.html.haml create mode 100644 app/workers/ci/retry_pipeline_worker.rb create mode 100644 changelogs/unreleased/271242_memoize_merge_request_policy.yml create mode 100644 changelogs/unreleased/327315-enable-ci_wildcard_file_paths.yml create mode 100644 changelogs/unreleased/329664-feature-flag-enable-inmemory-remotes-for-findremoterootrefs.yml create mode 100644 changelogs/unreleased/allow-migrating-sidekiq-scheduled-and-retry-jobs.yml create mode 100644 changelogs/unreleased/mc-backstage-make-pipeline-retry-async.yml create mode 100644 config/feature_flags/development/background_pipeline_retry_endpoint.yml create mode 100644 config/feature_flags/development/ci_drop_cyclical_triggered_pipelines.yml create mode 100644 config/feature_flags/development/merge_base_pipeline_for_metrics_comparison.yml create mode 100644 config/feature_flags/ops/block_issue_repositioning.yml create mode 100644 doc/raketasks/sidekiq_job_migration.md create mode 100644 lib/gitlab/sidekiq_migrate_jobs.rb create mode 100644 spec/frontend/nav/components/top_nav_app_spec.js create mode 100644 spec/frontend/nav/components/top_nav_container_view_spec.js create mode 100644 spec/frontend/nav/components/top_nav_dropdown_menu_spec.js create mode 100644 spec/frontend/nav/components/top_nav_menu_item_spec.js create mode 100644 spec/frontend/nav/mock_data.js create mode 100644 spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb create mode 100644 spec/lib/version_check_spec.rb create mode 100644 spec/tasks/gitlab/sidekiq_rake_spec.rb create mode 100644 spec/workers/ci/retry_pipeline_worker_spec.rb 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 Space 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__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,31 +6,166 @@ RSpec.describe Users::BuildService do using RSpec::Parameterized::TableSyntax describe '#execute' do + let_it_be(:current_user) { nil } + let(:params) { build_stubbed(:user).slice(:first_name, :last_name, :username, :email, :password) } + let(:service) { described_class.new(current_user, params) } - context 'with an admin user' do - let(:params) { build_stubbed(:user).slice(:name, :username, :email, :password) } - - let(:admin_user) { create(:admin) } - let(:service) { described_class.new(admin_user, ActionController::Parameters.new(params).permit!) } - - 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 + user + end + + context 'when user_type is provided' do + context 'when project_bot' do + before do + params.merge!({ user_type: :project_bot }) + end + + it { expect(user.project_bot?).to be true } + end + + context 'when not a project_bot' do + before do + params.merge!({ user_type: :alert_bot }) + end + + 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 | true # admin difference + + true | nil | 'fl@example.com' | '' | true + true | true | 'fl@example.com' | '' | true + true | false | 'fl@example.com' | '' | true # admin difference + + true | nil | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false + 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?' | true # admin difference + + false | nil | 'fl@example.com' | nil | false + 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' | '' | false # admin difference + false | false | 'fl@example.com' | '' | false + + false | nil | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false + 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?' | false # admin difference + false | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false + end + + with_them do + before do + stub_application_setting(user_default_external: user_default_external) + stub_application_setting(user_default_internal_regex: user_default_internal_regex) + + params.merge!({ external: external, email: email }.compact) + end + + it 'sets the value of Gitlab::CurrentSettings.user_default_external' do + expect(user.external).to eq(result) + end end end - context 'allowed params' do + context 'when "send_user_confirmation_email" application setting is true' do + before do + stub_application_setting(send_user_confirmation_email: true, signup_enabled?: true) + end + + it 'does not confirm the user' do + expect(user).not_to be_confirmed + end + end + + context 'when "send_user_confirmation_email" application setting is false' do + before do + stub_application_setting(send_user_confirmation_email: false, signup_enabled?: true) + end + + it 'confirms the user' do + expect(user).to be_confirmed + end + end + + 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 + + it 'sets all allowed attributes' do + expect(User).to receive(:new).with(hash_including(params)).and_call_original + + user + end + end + end + + context 'with nil current_user' do + subject(:user) { service.execute } + + 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, @@ -60,13 +195,14 @@ RSpec.describe Users::BuildService do private_profile: 1, organization: 1, location: 1, - public_email: 1 + public_email: 1, + user_type: 'project_bot', + note: 1, + view_diffs_file_by_file: 1 } end it 'sets all allowed attributes' do - admin_user # call first so the admin gets created before setting `expect` - expect(User).to receive(:new).with(hash_including(params)).and_call_original service.execute @@ -77,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 | false + 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' | '' | false + true | false | 'fl@example.com' | '' | false # 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?' | 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?' | false + true | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false # admin difference false | nil | 'fl@example.com' | nil | false - false | true | 'fl@example.com' | nil | true + 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' | '' | true + 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?' | true + 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?' | true + false | true | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | true # admin difference false | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false end @@ -116,126 +252,6 @@ RSpec.describe Users::BuildService do params.merge!({ external: external, email: email }.compact) end - subject(:user) { service.execute } - - it 'correctly sets user.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 - stub_application_setting(send_user_confirmation_email: true, signup_enabled?: true) - end - - it 'does not confirm the user' do - expect(service.execute).not_to be_confirmed - end - end - - context 'when "send_user_confirmation_email" application setting is false' do - before do - stub_application_setting(send_user_confirmation_email: false, signup_enabled?: true) - end - - it 'confirms the user' do - expect(service.execute).to be_confirmed - end - end - - context 'when user_type is provided' do - subject(:user) { service.execute } - - context 'when project_bot' do - before do - params.merge!({ user_type: :project_bot }) - end - - it { expect(user.project_bot?).to be true } - end - - context 'when not a project_bot' do - before do - params.merge!({ user_type: :alert_bot }) - end - - it { expect(user).to be_human } - end - end - - 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 | true - - true | nil | 'fl@example.com' | '' | true - true | true | 'fl@example.com' | '' | true - true | false | 'fl@example.com' | '' | true - - true | nil | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false - true | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false - 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 - - false | nil | 'fl@example.com' | nil | false - false | true | 'fl@example.com' | nil | false - false | false | 'fl@example.com' | nil | false - - false | nil | 'fl@example.com' | '' | false - false | true | 'fl@example.com' | '' | false - false | false | 'fl@example.com' | '' | false - - false | nil | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false - false | true | 'fl@example.com' | '^(?:(?!\.ext@).)*$\r?' | false - 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 | false | 'tester.ext@domain.com' | '^(?:(?!\.ext@).)*$\r?' | false - end - - with_them do - before do - stub_application_setting(user_default_external: user_default_external) - stub_application_setting(user_default_internal_regex: user_default_internal_regex) - - 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)