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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-06-08 15:08:46 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-06-08 15:08:46 +0300
commitcdda3d117c99cadf295f26abc92cb2456033b762 (patch)
tree30315b1ea79ee4639f44a407978ed719c62a1653
parentf4ea1f8998fd64bcd02280514b91f103f830d5ce (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab-ci.yml5
-rw-r--r--.gitlab/ci/build-images.gitlab-ci.yml10
-rw-r--r--.gitlab/ci/qa.gitlab-ci.yml4
-rw-r--r--.gitlab/ci/review-apps/qa.gitlab-ci.yml6
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue18
-rw-r--r--app/assets/javascripts/editor/schema/ci.json27
-rw-r--r--app/assets/javascripts/groups/settings/api/access_dropdown_api.js16
-rw-r--r--app/assets/javascripts/groups/settings/components/access_dropdown.vue194
-rw-r--r--app/assets/javascripts/groups/settings/constants.js3
-rw-r--r--app/assets/javascripts/groups/settings/init_access_dropdown.js36
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue13
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue16
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue33
-rw-r--r--app/assets/javascripts/invite_members/constants.js12
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue1
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss16
-rw-r--r--app/assets/stylesheets/framework/header.scss2
-rw-r--r--app/assets/stylesheets/framework/typography.scss8
-rw-r--r--app/assets/stylesheets/framework/variables.scss4
-rw-r--r--app/assets/stylesheets/pages/detail_page.scss17
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss21
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss42
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss37
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss11
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb5
-rw-r--r--app/finders/issuable_finder/params.rb4
-rw-r--r--app/finders/issuables/label_filter.rb6
-rw-r--r--app/finders/issues_finder.rb14
-rw-r--r--app/finders/work_items/work_items_finder.rb19
-rw-r--r--app/models/ci/build.rb2
-rw-r--r--app/models/concerns/awardable.rb16
-rw-r--r--app/models/concerns/issuable.rb18
-rw-r--r--app/models/concerns/limitable.rb26
-rw-r--r--app/models/environment.rb2
-rw-r--r--app/models/work_item.rb4
-rw-r--r--app/policies/project_policy.rb1
-rw-r--r--app/policies/work_item_policy.rb5
-rw-r--r--app/services/bulk_imports/lfs_objects_export_service.rb2
-rw-r--r--app/services/resource_events/base_change_timebox_service.rb2
-rw-r--r--app/views/admin/users/_access_levels.html.haml19
-rw-r--r--app/views/admin/users/_admin_notes.html.haml5
-rw-r--r--app/views/admin/users/_form.html.haml56
-rw-r--r--app/views/admin/users/edit.html.haml3
-rw-r--r--app/views/admin/users/new.html.haml3
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml3
-rw-r--r--app/views/layouts/_page.html.haml2
-rw-r--r--app/views/shared/_sidebar_toggle_button.html.haml2
-rw-r--r--babel.config.js4
-rw-r--r--config/feature_flags/development/ci_docker_image_pull_policy.yml8
-rw-r--r--config/feature_flags/development/seat_count_alerts.yml (renamed from config/feature_flags/development/env_stopped_on_stop_success.yml)10
-rw-r--r--db/migrate/20220516201245_add_security_policy_scan_execution_schedules_to_plan_limits.rb11
-rw-r--r--db/schema_migrations/202205162012451
-rw-r--r--db/structure.sql3
-rw-r--r--doc/administration/geo/setup/index.md2
-rw-r--r--doc/administration/instance_limits.md20
-rw-r--r--doc/ci/yaml/index.md46
-rw-r--r--doc/development/adding_service_component.md2
-rw-r--r--doc/development/code_review.md1
-rw-r--r--doc/integration/saml.md4
-rw-r--r--doc/user/gitlab_com/index.md1
-rw-r--r--lib/api/entities/ci/job_request/image.rb2
-rw-r--r--lib/api/entities/ci/job_request/service.rb5
-rw-r--r--lib/gitlab/ci/build/image.rb3
-rw-r--r--lib/gitlab/ci/config/entry/image.rb31
-rw-r--r--lib/gitlab/ci/config/entry/pull_policy.rb34
-rw-r--r--lib/gitlab/config/entry/node.rb4
-rw-r--r--lib/gitlab/import_export/lfs_saver.rb6
-rw-r--r--lib/tasks/gitlab/db/validate_config.rake73
-rw-r--r--locale/gitlab.pot36
-rw-r--r--qa/Dockerfile5
-rw-r--r--spec/finders/issues_finder_spec.rb1448
-rw-r--r--spec/finders/work_items/work_items_finder_spec.rb10
-rw-r--r--spec/frontend/invite_members/components/invite_modal_base_spec.js24
-rw-r--r--spec/frontend/invite_members/components/user_limit_notification_spec.js34
-rw-r--r--spec/frontend/pipelines/graph/linked_pipeline_spec.js16
-rw-r--r--spec/lib/api/entities/ci/job_request/image_spec.rb16
-rw-r--r--spec/lib/gitlab/ci/build/image_spec.rb9
-rw-r--r--spec/lib/gitlab/ci/config/entry/image_spec.rb66
-rw-r--r--spec/lib/gitlab/ci/config/entry/pull_policy_spec.rb87
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb47
-rw-r--r--spec/lib/gitlab/import_export/lfs_saver_spec.rb12
-rw-r--r--spec/models/concerns/limitable_spec.rb4
-rw-r--r--spec/models/environment_spec.rb18
-rw-r--r--spec/models/plan_limits_spec.rb1
-rw-r--r--spec/policies/project_policy_spec.rb11
-rw-r--r--spec/policies/work_item_policy_spec.rb6
-rw-r--r--spec/requests/api/ci/runner/jobs_request_post_spec.rb41
-rw-r--r--spec/services/bulk_imports/lfs_objects_export_service_spec.rb12
-rw-r--r--spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb26
-rw-r--r--spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb79
-rw-r--r--spec/support/shared_examples/finders/issues_finder_shared_examples.rb1471
-rw-r--r--spec/support/shared_examples/models/concerns/limitable_shared_examples.rb24
-rw-r--r--spec/tasks/gitlab/db/validate_config_rake_spec.rb24
-rw-r--r--spec/workers/build_success_worker_spec.rb12
96 files changed, 2852 insertions, 1735 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index db0888bbd97..f6910a067dd 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -59,7 +59,7 @@ workflow:
variables:
PG_VERSION: "12"
- DEFAULT_CI_IMAGE: "${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-${DEBIAN_VERSION}-ruby-2.7.patched-golang-1.17-node-16.14-postgresql-${PG_VERSION}:git-2.36-lfs-2.9-chrome-101-yarn-1.22-graphicsmagick-1.3.36"
+ DEFAULT_CI_IMAGE: "${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-${DEBIAN_VERSION}-ruby-2.7.patched-golang-1.17-node-16.14-postgresql-${PG_VERSION}:git-2.36-lfs-2.9-chrome-${CHROME_VERSION}-yarn-1.22-graphicsmagick-1.3.36"
RAILS_ENV: "test"
NODE_ENV: "test"
BUNDLE_WITHOUT: "production:development"
@@ -73,6 +73,8 @@ variables:
GIT_SUBMODULE_STRATEGY: "none"
GET_SOURCES_ATTEMPTS: "3"
DEBIAN_VERSION: "bullseye"
+ CHROME_VERSION: "101"
+ DOCKER_VERSION: "20.10.14"
TMP_TEST_FOLDER: "${CI_PROJECT_DIR}/tmp/tests"
GITLAB_WORKHORSE_FOLDER: "gitlab-workhorse"
@@ -89,7 +91,6 @@ variables:
ES_JAVA_OPTS: "-Xms256m -Xmx256m"
ELASTIC_URL: "http://elastic:changeme@elasticsearch:9200"
- DOCKER_VERSION: "20.10.1"
CACHE_CLASSES: "true"
CHECK_PRECOMPILED_ASSETS: "true"
FF_USE_FASTZIP: "true"
diff --git a/.gitlab/ci/build-images.gitlab-ci.yml b/.gitlab/ci/build-images.gitlab-ci.yml
index 6a222d8937f..46d0bb2fb8f 100644
--- a/.gitlab/ci/build-images.gitlab-ci.yml
+++ b/.gitlab/ci/build-images.gitlab-ci.yml
@@ -29,7 +29,15 @@ build-qa-image:
- !reference [.base-image-build, script]
- echo $QA_IMAGE
- echo $QA_IMAGE_BRANCH
- - /kaniko/executor --context=${CI_PROJECT_DIR} --dockerfile=${CI_PROJECT_DIR}/qa/Dockerfile --destination=${QA_IMAGE} --destination=${QA_IMAGE_BRANCH} --cache=true
+ - |
+ /kaniko/executor \
+ --context=${CI_PROJECT_DIR} \
+ --dockerfile=${CI_PROJECT_DIR}/qa/Dockerfile \
+ --destination=${QA_IMAGE} \
+ --destination=${QA_IMAGE_BRANCH} \
+ --build-arg=CHROME_VERSION=${CHROME_VERSION} \
+ --build-arg=DOCKER_VERSION=${DOCKER_VERSION} \
+ --cache=true
# This image is used by:
# - The `CNG` pipelines (via the `review-build-cng` job): https://gitlab.com/gitlab-org/build/CNG/-/blob/cfc67136d711e1c8c409bf8e57427a644393da2f/.gitlab-ci.yml#L335
diff --git a/.gitlab/ci/qa.gitlab-ci.yml b/.gitlab/ci/qa.gitlab-ci.yml
index 1ebc408e0d4..463d110b274 100644
--- a/.gitlab/ci/qa.gitlab-ci.yml
+++ b/.gitlab/ci/qa.gitlab-ci.yml
@@ -1,5 +1,5 @@
.qa-job-base:
- image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-bullseye-ruby-2.7:bundler-2.3-git-2.33-chrome-99
+ image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-bullseye-ruby-2.7:bundler-2.3-git-2.33-chrome-${CHROME_VERSION}
extends:
- .default-retry
- .qa-cache
@@ -12,7 +12,7 @@
before_script:
- !reference [.default-before_script, before_script]
- cd qa/
- - bundle_install_script
+ - bundle install
qa:internal:
extends:
diff --git a/.gitlab/ci/review-apps/qa.gitlab-ci.yml b/.gitlab/ci/review-apps/qa.gitlab-ci.yml
index 58ceb744480..bc75bf51e03 100644
--- a/.gitlab/ci/review-apps/qa.gitlab-ci.yml
+++ b/.gitlab/ci/review-apps/qa.gitlab-ci.yml
@@ -1,6 +1,6 @@
include:
- project: gitlab-org/quality/pipeline-common
- ref: 0.6.0
+ ref: 0.13.0
file:
- /ci/allure-report.yml
- /ci/knapsack-report.yml
@@ -28,7 +28,7 @@ include:
- .qa-cache
- .test_variables
- .bundler_variables
- image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-bullseye-ruby-2.7:bundler-2.3-git-2.33-lfs-2.9-chrome-99-docker-20.10.14-gcloud-383-kubectl-1.23
+ image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-bullseye-ruby-2.7:bundler-2.3-git-2.33-lfs-2.9-chrome-${CHROME_VERSION}-docker-${DOCKER_VERSION}-gcloud-383-kubectl-1.23
stage: qa
needs:
- review-deploy
@@ -81,7 +81,7 @@ include:
# Store knapsack report as artifact so the same report is reused across all jobs
download-knapsack-report:
- image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-bullseye-ruby-2.7:bundler-2.3-git-2.33-chrome-99
+ image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-bullseye-ruby-2.7:bundler-2.3-git-2.33-chrome-${CHROME_VERSION}
extends:
- .qa-cache
- .bundler_variables
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
index f4cc0678c38..3860831169e 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -42,6 +42,20 @@ const bodyTrClass =
export default {
i18n,
typeSet,
+ modal: {
+ actionPrimary: {
+ text: i18n.deleteIntegration,
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
components: {
GlButtonGroup,
GlButton,
@@ -204,8 +218,8 @@ export default {
<gl-modal
modal-id="deleteIntegration"
:title="$options.i18n.deleteIntegration"
- :ok-title="$options.i18n.deleteIntegration"
- ok-variant="danger"
+ :action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
@ok="deleteIntegration"
>
<gl-sprintf
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 8262dae2b89..c8015f884b7 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -374,6 +374,33 @@
"type": "array",
"description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.",
"minItems": 1
+ },
+ "pull_policy": {
+ "markdownDescription": "Specifies how to pull the image in Runner. It can be one of `always`, `never` or `if-not-present`. The default value is `always`. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#imagepull_policy).",
+ "default": "always",
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "always",
+ "never",
+ "if-not-present"
+ ]
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "always",
+ "never",
+ "if-not-present"
+ ]
+ },
+ "minItems": 1,
+ "uniqueItems": true
+ }
+ ]
}
},
"required": ["name"]
diff --git a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
new file mode 100644
index 00000000000..5560d10d179
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
@@ -0,0 +1,16 @@
+import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+
+const GROUP_SUBGROUPS_PATH = '/-/autocomplete/group_subgroups.json';
+
+const buildUrl = (urlRoot, url) => {
+ return joinPaths(urlRoot, url);
+};
+
+export const getSubGroups = () => {
+ return axios.get(buildUrl(gon.relative_url_root || '', GROUP_SUBGROUPS_PATH), {
+ params: {
+ group_id: gon.current_group_id,
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
new file mode 100644
index 00000000000..b8a269de98a
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
@@ -0,0 +1,194 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
+import createFlash from '~/flash';
+import { __, s__, n__ } from '~/locale';
+import { getSubGroups } from '../api/access_dropdown_api';
+import { LEVEL_TYPES } from '../constants';
+
+export const i18n = {
+ selectUsers: s__('ProtectedEnvironment|Select groups'),
+ groupsSectionHeader: s__('AccessDropdown|Groups'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ },
+ props: {
+ hasLicense: {
+ required: false,
+ type: Boolean,
+ default: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: i18n.selectUsers,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ preselectedItems: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ initialLoading: false,
+ query: '',
+ groups: [],
+ selected: {
+ [LEVEL_TYPES.GROUP]: [],
+ },
+ };
+ },
+ computed: {
+ preselected() {
+ return groupBy(this.preselectedItems, 'type');
+ },
+ toggleLabel() {
+ const counts = Object.fromEntries(
+ Object.entries(this.selected).map(([key, value]) => [key, value.length]),
+ );
+
+ const labelPieces = [];
+
+ if (counts[LEVEL_TYPES.GROUP] > 0) {
+ labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
+ }
+
+ return labelPieces.join(', ') || this.label;
+ },
+ toggleClass() {
+ return this.toggleLabel === this.label ? 'gl-text-gray-500!' : '';
+ },
+ selection() {
+ return [...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id')];
+ },
+ },
+ watch: {
+ query: debounce(function debouncedSearch() {
+ return this.getData();
+ }, 500),
+ },
+ created() {
+ this.getData({ initial: true });
+ },
+ methods: {
+ focusInput() {
+ this.$refs.search.focusInput();
+ },
+ getData({ initial = false } = {}) {
+ this.initialLoading = initial;
+ this.loading = true;
+
+ if (this.hasLicense) {
+ Promise.all([this.groups.length ? Promise.resolve({ data: this.groups }) : getSubGroups()])
+ .then(([groupsResponse]) => {
+ this.consolidateData(groupsResponse.data);
+ this.setSelected({ initial });
+ })
+ .catch(() => createFlash({ message: __('Failed to load groups.') }))
+ .finally(() => {
+ this.initialLoading = false;
+ this.loading = false;
+ });
+ }
+ },
+ consolidateData(groupsResponse = []) {
+ if (this.hasLicense) {
+ this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP }));
+ }
+ },
+ setSelected({ initial } = {}) {
+ if (initial) {
+ const selectedGroups = intersectionWith(
+ this.groups,
+ this.preselectedItems,
+ (group, selected) => {
+ return selected.type === LEVEL_TYPES.GROUP && group.id === selected.group_id;
+ },
+ );
+ this.selected[LEVEL_TYPES.GROUP] = selectedGroups;
+ }
+ },
+ getDataForSave(accessType, key) {
+ const selected = this.selected[accessType].map(({ id }) => ({ [key]: id }));
+ const preselected = this.preselected[accessType];
+ const added = differenceBy(selected, preselected, key);
+ const preserved = intersectionBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
+ id,
+ [key]: keyId,
+ }));
+ const removed = differenceBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
+ id,
+ [key]: keyId,
+ _destroy: true,
+ }));
+ return [...added, ...removed, ...preserved];
+ },
+ onItemClick(item) {
+ this.toggleSelection(this.selected[item.type], item);
+ this.emitUpdate();
+ },
+ toggleSelection(arr, item) {
+ const itemIndex = arr.findIndex(({ id }) => id === item.id);
+ if (itemIndex > -1) {
+ arr.splice(itemIndex, 1);
+ } else arr.push(item);
+ },
+ isSelected(item) {
+ return this.selected[item.type].some((selected) => selected.id === item.id);
+ },
+ emitUpdate() {
+ this.$emit('select', this.selection);
+ },
+ onHide() {
+ this.$emit('hidden', this.selection);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :disabled="disabled || initialLoading"
+ :text="toggleLabel"
+ class="gl-min-w-20"
+ :toggle-class="toggleClass"
+ aria-labelledby="allowed-users-label"
+ @shown="focusInput"
+ @hidden="onHide"
+ >
+ <template #header>
+ <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" />
+ </template>
+ <template v-if="groups.length">
+ <gl-dropdown-section-header>{{
+ $options.i18n.groupsSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="group in groups"
+ :key="`${group.id}${group.name}`"
+ fingerprint
+ data-testid="group-dropdown-item"
+ :avatar-url="group.avatar_url"
+ is-check-item
+ :is-checked="isSelected(group)"
+ @click.native.capture.stop="onItemClick(group)"
+ >
+ {{ group.name }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js
new file mode 100644
index 00000000000..c91c2a20529
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/constants.js
@@ -0,0 +1,3 @@
+export const LEVEL_TYPES = {
+ GROUP: 'group',
+};
diff --git a/app/assets/javascripts/groups/settings/init_access_dropdown.js b/app/assets/javascripts/groups/settings/init_access_dropdown.js
new file mode 100644
index 00000000000..24419280fc0
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/init_access_dropdown.js
@@ -0,0 +1,36 @@
+import * as Sentry from '@sentry/browser';
+import Vue from 'vue';
+import AccessDropdown from './components/access_dropdown.vue';
+
+export const initAccessDropdown = (el) => {
+ if (!el) {
+ return false;
+ }
+
+ const { label, disabled, preselectedItems } = el.dataset;
+ let preselected = [];
+ try {
+ preselected = JSON.parse(preselectedItems);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+
+ return new Vue({
+ el,
+ render(createElement) {
+ const vm = this;
+ return createElement(AccessDropdown, {
+ props: {
+ preselectedItems: preselected,
+ label,
+ disabled,
+ },
+ on: {
+ select(selected) {
+ vm.$emit('select', selected);
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index 7857b9d86d2..d597c7e53bb 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -14,6 +14,7 @@ import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
import {
+ CLOSE_TO_LIMIT_COUNT,
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
MEMBER_MODAL_LABELS,
@@ -151,6 +152,16 @@ export default {
isOnLearnGitlab() {
return this.source === LEARN_GITLAB;
},
+ closeToLimit() {
+ if (this.usersLimitDataset.freeUsersLimit && this.usersLimitDataset.membersCount) {
+ return (
+ this.usersLimitDataset.membersCount >=
+ this.usersLimitDataset.freeUsersLimit - CLOSE_TO_LIMIT_COUNT
+ );
+ }
+
+ return false;
+ },
reachedLimit() {
if (this.usersLimitDataset.freeUsersLimit && this.usersLimitDataset.membersCount) {
return this.usersLimitDataset.membersCount >= this.usersLimitDataset.freeUsersLimit;
@@ -297,6 +308,7 @@ export default {
:is-loading="isLoading"
:new-users-to-invite="newUsersToInvite"
:root-group-id="rootId"
+ :close-to-limit="closeToLimit"
:reached-limit="reachedLimit"
:users-limit-dataset="usersLimitDataset"
@reset="resetFields"
@@ -314,6 +326,7 @@ export default {
<template #user-limit-notification>
<user-limit-notification
+ :close-to-limit="closeToLimit"
:reached-limit="reachedLimit"
:users-limit-dataset="usersLimitDataset"
/>
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index 33d37b809c2..90d266c3155 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -131,6 +131,11 @@ export default {
required: false,
default: false,
},
+ closeToLimit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
reachedLimit: {
type: Boolean,
required: false,
@@ -183,6 +188,17 @@ export default {
actionCancel() {
if (this.reachedLimit && this.usersLimitDataset.userNamespace) return undefined;
+ if (this.closeToLimit && this.usersLimitDataset.userNamespace) {
+ return {
+ text: INVITE_BUTTON_TEXT_DISABLED,
+ attributes: {
+ href: this.usersLimitDataset.membersPath,
+ category: 'secondary',
+ variant: 'confirm',
+ },
+ };
+ }
+
return {
text: this.reachedLimit ? CANCEL_BUTTON_TEXT_DISABLED : this.cancelButtonText,
...(this.reachedLimit && { attributes: { href: this.usersLimitDataset.purchasePath } }),
diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
index ea5f4317d86..ae5c3c11386 100644
--- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -8,15 +8,20 @@ import {
REACHED_LIMIT_MESSAGE,
REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
CLOSE_TO_LIMIT_MESSAGE,
+ CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
+ DANGER_ALERT_TITLE_PERSONAL_NAMESPACE,
+ WARNING_ALERT_TITLE_PERSONAL_NAMESPACE,
} from '../constants';
-const CLOSE_TO_LIMIT_COUNT = 2;
-
export default {
name: 'UserLimitNotification',
components: { GlAlert, GlSprintf, GlLink },
inject: ['name'],
props: {
+ closeToLimit: {
+ type: Boolean,
+ required: true,
+ },
reachedLimit: {
type: Boolean,
required: true,
@@ -40,14 +45,14 @@ export default {
purchasePath() {
return this.usersLimitDataset.purchasePath;
},
- closeToLimit() {
- if (this.freeUsersLimit && this.membersCount) {
- return this.membersCount >= this.freeUsersLimit - CLOSE_TO_LIMIT_COUNT;
+ warningAlertTitle() {
+ if (this.usersLimitDataset.userNamespace) {
+ return sprintf(WARNING_ALERT_TITLE_PERSONAL_NAMESPACE, {
+ count: this.freeUsersLimit - this.membersCount,
+ members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
+ });
}
- return false;
- },
- warningAlertTitle() {
return sprintf(WARNING_ALERT_TITLE, {
count: this.freeUsersLimit - this.membersCount,
members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
@@ -55,6 +60,13 @@ export default {
});
},
dangerAlertTitle() {
+ if (this.usersLimitDataset.userNamespace) {
+ return sprintf(DANGER_ALERT_TITLE_PERSONAL_NAMESPACE, {
+ count: this.freeUsersLimit,
+ members: this.pluralMembers(this.freeUsersLimit),
+ });
+ }
+
return sprintf(DANGER_ALERT_TITLE, {
count: this.freeUsersLimit,
members: this.pluralMembers(this.freeUsersLimit),
@@ -79,6 +91,10 @@ export default {
return this.reachedLimitMessage;
}
+ if (this.usersLimitDataset.userNamespace) {
+ return this.$options.i18n.closeToLimitMessagePersonalNamespace;
+ }
+
return this.$options.i18n.closeToLimitMessage;
},
},
@@ -91,6 +107,7 @@ export default {
reachedLimitMessage: REACHED_LIMIT_MESSAGE,
reachedLimitUpgradeSuggestionMessage: REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE,
+ closeToLimitMessagePersonalNamespace: CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
},
};
</script>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 552359f2463..beb8f5b5aab 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,7 +1,7 @@
import { s__ } from '~/locale';
+export const CLOSE_TO_LIMIT_COUNT = 2;
export const SEARCH_DELAY = 200;
-
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
name: 'invite_members_for_task',
@@ -132,10 +132,17 @@ export const ON_SUBMIT_TRACK_LABEL = 'manage_members_clicked';
export const WARNING_ALERT_TITLE = s__(
'InviteMembersModal|You only have space for %{count} more %{members} in %{name}',
);
+export const WARNING_ALERT_TITLE_PERSONAL_NAMESPACE = s__(
+ 'InviteMembersModal|You only have space for %{count} more %{members} in your personal projects',
+);
export const DANGER_ALERT_TITLE = s__(
"InviteMembersModal|You've reached your %{count} %{members} limit for %{name}",
);
+export const DANGER_ALERT_TITLE_PERSONAL_NAMESPACE = s__(
+ "InviteMembersModal|You've reached your %{count} %{members} limit for your personal projects",
+);
+
export const REACHED_LIMIT_MESSAGE = s__(
'InviteMembersModal|You cannot add more members, but you can remove members who no longer need access.',
);
@@ -149,3 +156,6 @@ export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.co
export const CLOSE_TO_LIMIT_MESSAGE = s__(
'InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
);
+export const CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE = s__(
+ 'InviteMembersModal|To make more space, you can remove members who no longer need access.',
+);
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 100ffc0664b..31e80107fd8 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -10,7 +10,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-new-user-signups-cap-reached',
'.js-eoa-bronze-plan-banner',
'.js-security-newsletter-callout',
- '.js-approaching-seats-count-threshold',
+ '.js-approaching-seat-count-threshold',
'.js-storage-enforcement-banner',
'.js-user-over-limit-free-plan-alert',
'.js-minute-limit-banner',
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index 35c0032a9b4..7c9e2485056 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -103,9 +103,9 @@ export default {
},
expandedIcon() {
if (this.isUpstream) {
- return this.expanded ? 'angle-right' : 'angle-left';
+ return this.expanded ? 'chevron-lg-right' : 'chevron-lg-left';
}
- return this.expanded ? 'angle-left' : 'angle-right';
+ return this.expanded ? 'chevron-lg-left' : 'chevron-lg-right';
},
expandBtnText() {
return this.expanded ? __('Collapse jobs') : __('Expand jobs');
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index e0e19094c40..5bd7745d704 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -126,6 +126,7 @@ export default {
}) => {
toast(__('Marked as ready. Merging is now allowed.'));
$('.merge-request .detail-page-description .title').text(title);
+ eventHub.$emit('MRWidgetUpdateRequested');
},
)
.catch(() =>
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index e06c71dccf0..32439c13a9d 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -27,7 +27,8 @@
}
.toggle-sidebar-button {
- width: $contextual-sidebar-collapsed-width;
+ width: #{$contextual-sidebar-collapsed-width - 1px};
+ padding: 0 21px;
.collapse-text {
display: none;
@@ -81,7 +82,7 @@
@include gl-px-0;
@include gl-pb-2;
@include gl-pt-0;
- background-color: $gray-10;
+ @include gl-bg-gray-10;
box-shadow: 0 $gl-spacing-scale-2 $gl-spacing-scale-5 $t-gray-a-24, 0 0 $gl-spacing-scale-1 $t-gray-a-24;
border-style: none;
border-radius: $border-radius-default;
@@ -128,7 +129,7 @@
@include gl-p-2;
@include gl-mb-2;
- @include gl-mt-0;
+ @include gl-mt-1;
.avatar-container {
@include gl-font-weight-normal;
@@ -246,7 +247,8 @@
z-index: 600;
width: $contextual-sidebar-width;
top: $header-height;
- background-color: $gray-50;
+ @include gl-bg-gray-10;
+ border-right: 1px solid $gray-50;
transform: translate3d(0, 0, 0);
&.sidebar-collapsed-desktop {
@@ -352,7 +354,6 @@
}
.sidebar-top-level-items {
- @include gl-mt-2;
margin-bottom: 60px;
.context-header a {
@@ -410,11 +411,10 @@
.toggle-sidebar-button,
.close-nav-button {
@include side-panel-toggle;
- background-color: $gray-50;
- border-top: 1px solid $border-color;
+ @include gl-bg-gray-10;
position: fixed;
bottom: 0;
- width: $contextual-sidebar-width;
+ width: #{$contextual-sidebar-width - 1px};
.collapse-text,
.icon-chevron-double-lg-left,
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 940932b67d2..ced62926218 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -52,7 +52,7 @@
display: flex;
align-items: center;
padding: 2px 8px;
- margin: 4px 2px 4px -12px;
+ margin: 4px 2px 4px -8px;
border-radius: $border-radius-default;
&:active,
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index fc5b07b6420..25fa4379064 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -593,6 +593,14 @@
}
/**
+ * Links
+ *
+ */
+a:focus-visible {
+ @include gl-focus($outline: true, $outline-offset: $outline-width);
+}
+
+/**
* Headers
*
*/
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index f719c17c708..72e7722cd6d 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -9,7 +9,7 @@ $sidebar-transition-duration: 0.3s;
$sidebar-breakpoint: 1024px;
$default-transition-duration: 0.15s;
$contextual-sidebar-width: 256px;
-$contextual-sidebar-collapsed-width: 48px;
+$contextual-sidebar-collapsed-width: 56px;
$toggle-sidebar-height: 48px;
/**
@@ -580,7 +580,7 @@ $sidebar-toggle-height: 60px;
$sidebar-toggle-width: 40px;
$sidebar-milestone-toggle-bottom-margin: 10px;
$sidebar-avatar-size: 32px;
-$sidebar-top-item-lr-margin: 4px;
+$sidebar-top-item-lr-margin: 8px;
$sidebar-top-item-tb-margin: 1px;
/*
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss
index 6109aa47788..092c676c397 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/pages/detail_page.scss
@@ -5,14 +5,6 @@
line-height: 34px;
display: flex;
- a {
- color: $gl-text-color;
-
- &.link {
- color: $blue-600;
- }
- }
-
.author-link {
white-space: nowrap;
}
@@ -22,6 +14,15 @@
}
}
+.detail-page-header a {
+ color: $gl-text-color;
+}
+
+.detail-page-header a.link,
+.detail-page-header .title a {
+ color: $blue-600;
+}
+
.detail-page-header-body {
position: relative;
display: flex;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index fbfb1398889..c1416e20aea 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -214,8 +214,7 @@ $tabs-holder-z-index: 250;
}
}
-.merge-request-tabs-holder,
-.epic-tabs-holder {
+.merge-request-tabs-holder {
top: $header-height;
z-index: $tabs-holder-z-index;
background-color: $body-bg;
@@ -248,14 +247,12 @@ $tabs-holder-z-index: 250;
}
.with-performance-bar {
- .merge-request-tabs-holder,
- .epic-tabs-holder {
+ .merge-request-tabs-holder {
top: calc(#{$header-height} + #{$performance-bar-height});
}
}
-.merge-request-tabs,
-.epic-tabs {
+.merge-request-tabs {
display: flex;
flex-wrap: nowrap;
margin-bottom: 0;
@@ -263,8 +260,7 @@ $tabs-holder-z-index: 250;
}
.limit-container-width {
- .merge-request-tabs-container,
- .epic-tabs-container {
+ .merge-request-tabs-container {
max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
@@ -277,8 +273,7 @@ $tabs-holder-z-index: 250;
}
}
-.merge-request-tabs-container,
-.epic-tabs-container {
+.merge-request-tabs-container {
display: flex;
justify-content: space-between;
@@ -308,16 +303,14 @@ $tabs-holder-z-index: 250;
// Wrap MR tabs/buttons so you don't have to scroll on desktop
@include media-breakpoint-down(md) {
- .merge-request-tabs-container,
- .epic-tabs-container {
+ .merge-request-tabs-container {
flex-direction: column-reverse;
}
}
@include media-breakpoint-down(lg) {
.right-sidebar-expanded {
- .merge-request-tabs-container,
- .epic-tabs-container {
+ .merge-request-tabs-container {
flex-direction: column-reverse;
align-items: flex-start;
}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 0121fbc3cf9..7792959968f 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -780,7 +780,7 @@ input {
display: flex;
align-items: center;
padding: 2px 8px;
- margin: 4px 2px 4px -12px;
+ margin: 4px 2px 4px -8px;
border-radius: 4px;
}
.navbar-gitlab .header-content .title a:active {
@@ -1014,7 +1014,7 @@ input {
}
@media (min-width: 768px) {
.page-with-contextual-sidebar {
- padding-left: 48px;
+ padding-left: 56px;
}
}
@media (min-width: 1200px) {
@@ -1024,7 +1024,7 @@ input {
}
@media (min-width: 768px) {
.page-with-icon-sidebar {
- padding-left: 48px;
+ padding-left: 56px;
}
}
.nav-sidebar {
@@ -1034,11 +1034,12 @@ input {
z-index: 600;
width: 256px;
top: var(--header-height, 48px);
- background-color: #303030;
+ background-color: #1f1f1f;
+ border-right: 1px solid #303030;
transform: translate3d(0, 0, 0);
}
.nav-sidebar.sidebar-collapsed-desktop {
- width: 48px;
+ width: 56px;
}
.nav-sidebar.sidebar-collapsed-desktop .nav-sidebar-inner-scroll {
overflow-x: hidden;
@@ -1091,7 +1092,7 @@ input {
border-radius: 0.25rem;
width: auto;
line-height: 1rem;
- margin: 1px 4px;
+ margin: 1px 8px;
}
.nav-sidebar li.active > a {
font-weight: 600;
@@ -1227,7 +1228,7 @@ input {
}
@media (min-width: 768px) and (max-width: 1199px) {
.nav-sidebar:not(.sidebar-expanded-mobile) {
- width: 48px;
+ width: 56px;
}
.nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll {
overflow-x: hidden;
@@ -1262,7 +1263,7 @@ input {
}
.nav-sidebar:not(.sidebar-expanded-mobile) .context-header {
height: 60px;
- width: 48px;
+ width: 56px;
}
.nav-sidebar:not(.sidebar-expanded-mobile) .context-header a {
padding: 10px 4px;
@@ -1294,7 +1295,8 @@ input {
margin-right: 0;
}
.nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button {
- width: 48px;
+ width: 55px;
+ padding: 0 21px;
}
.nav-sidebar:not(.sidebar-expanded-mobile)
.toggle-sidebar-button
@@ -1327,10 +1329,10 @@ input {
border-radius: 0.25rem;
width: auto;
line-height: 1rem;
- margin: 1px 4px;
+ margin: 1px 8px;
padding: 0.25rem;
margin-bottom: 0.25rem;
- margin-top: 0;
+ margin-top: 0.125rem;
}
.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
@@ -1349,13 +1351,12 @@ input {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items {
- margin-top: 0.25rem;
margin-bottom: 60px;
}
.sidebar-top-level-items .context-header a {
padding: 0.25rem;
margin-bottom: 0.25rem;
- margin-top: 0;
+ margin-top: 0.125rem;
}
.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
@@ -1401,11 +1402,10 @@ input {
color: #999;
display: flex;
align-items: center;
- background-color: #303030;
- border-top: 1px solid #404040;
+ background-color: #1f1f1f;
position: fixed;
bottom: 0;
- width: 256px;
+ width: 255px;
}
.toggle-sidebar-button .collapse-text,
.toggle-sidebar-button .icon-chevron-double-lg-left,
@@ -1419,7 +1419,7 @@ input {
}
.sidebar-collapsed-desktop .context-header {
height: 60px;
- width: 48px;
+ width: 56px;
}
.sidebar-collapsed-desktop .context-header a {
padding: 10px 4px;
@@ -1451,7 +1451,8 @@ input {
margin-right: 0;
}
.sidebar-collapsed-desktop .toggle-sidebar-button {
- width: 48px;
+ width: 55px;
+ padding: 0 21px;
}
.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text {
display: none;
@@ -1787,6 +1788,11 @@ body.gl-dark {
--svg-status-bg: #333;
--nav-active-bg: rgba(255, 255, 255, 0.08);
}
+.nav-sidebar,
+.toggle-sidebar-button,
+.close-nav-button {
+ background-color: #262626;
+}
.nav-sidebar li a {
color: var(--gray-600);
}
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 6fd5148fabf..3e32de56e90 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -765,7 +765,7 @@ input {
display: flex;
align-items: center;
padding: 2px 8px;
- margin: 4px 2px 4px -12px;
+ margin: 4px 2px 4px -8px;
border-radius: 4px;
}
.navbar-gitlab .header-content .title a:active {
@@ -999,7 +999,7 @@ input {
}
@media (min-width: 768px) {
.page-with-contextual-sidebar {
- padding-left: 48px;
+ padding-left: 56px;
}
}
@media (min-width: 1200px) {
@@ -1009,7 +1009,7 @@ input {
}
@media (min-width: 768px) {
.page-with-icon-sidebar {
- padding-left: 48px;
+ padding-left: 56px;
}
}
.nav-sidebar {
@@ -1019,11 +1019,12 @@ input {
z-index: 600;
width: 256px;
top: var(--header-height, 48px);
- background-color: #f0f0f0;
+ background-color: #fafafa;
+ border-right: 1px solid #f0f0f0;
transform: translate3d(0, 0, 0);
}
.nav-sidebar.sidebar-collapsed-desktop {
- width: 48px;
+ width: 56px;
}
.nav-sidebar.sidebar-collapsed-desktop .nav-sidebar-inner-scroll {
overflow-x: hidden;
@@ -1076,7 +1077,7 @@ input {
border-radius: 0.25rem;
width: auto;
line-height: 1rem;
- margin: 1px 4px;
+ margin: 1px 8px;
}
.nav-sidebar li.active > a {
font-weight: 600;
@@ -1212,7 +1213,7 @@ input {
}
@media (min-width: 768px) and (max-width: 1199px) {
.nav-sidebar:not(.sidebar-expanded-mobile) {
- width: 48px;
+ width: 56px;
}
.nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll {
overflow-x: hidden;
@@ -1247,7 +1248,7 @@ input {
}
.nav-sidebar:not(.sidebar-expanded-mobile) .context-header {
height: 60px;
- width: 48px;
+ width: 56px;
}
.nav-sidebar:not(.sidebar-expanded-mobile) .context-header a {
padding: 10px 4px;
@@ -1279,7 +1280,8 @@ input {
margin-right: 0;
}
.nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button {
- width: 48px;
+ width: 55px;
+ padding: 0 21px;
}
.nav-sidebar:not(.sidebar-expanded-mobile)
.toggle-sidebar-button
@@ -1312,10 +1314,10 @@ input {
border-radius: 0.25rem;
width: auto;
line-height: 1rem;
- margin: 1px 4px;
+ margin: 1px 8px;
padding: 0.25rem;
margin-bottom: 0.25rem;
- margin-top: 0;
+ margin-top: 0.125rem;
}
.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
@@ -1334,13 +1336,12 @@ input {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items {
- margin-top: 0.25rem;
margin-bottom: 60px;
}
.sidebar-top-level-items .context-header a {
padding: 0.25rem;
margin-bottom: 0.25rem;
- margin-top: 0;
+ margin-top: 0.125rem;
}
.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
@@ -1386,11 +1387,10 @@ input {
color: #666;
display: flex;
align-items: center;
- background-color: #f0f0f0;
- border-top: 1px solid #dbdbdb;
+ background-color: #fafafa;
position: fixed;
bottom: 0;
- width: 256px;
+ width: 255px;
}
.toggle-sidebar-button .collapse-text,
.toggle-sidebar-button .icon-chevron-double-lg-left,
@@ -1404,7 +1404,7 @@ input {
}
.sidebar-collapsed-desktop .context-header {
height: 60px;
- width: 48px;
+ width: 56px;
}
.sidebar-collapsed-desktop .context-header a {
padding: 10px 4px;
@@ -1436,7 +1436,8 @@ input {
margin-right: 0;
}
.sidebar-collapsed-desktop .toggle-sidebar-button {
- width: 48px;
+ width: 55px;
+ padding: 0 21px;
}
.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text {
display: none;
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index dbb961fe71f..fb001504eed 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -41,6 +41,12 @@
border-color: $gray-800;
}
+.nav-sidebar,
+.toggle-sidebar-button,
+.close-nav-button {
+ background-color: darken($gray-50, 4%);
+}
+
.nav-sidebar {
li {
a {
@@ -68,6 +74,11 @@
}
}
+aside.right-sidebar:not(.right-sidebar-merge-requests) {
+ background-color: $gray-10;
+ border-left-color: $gray-50;
+}
+
body.gl-dark {
@include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $gray-900, $white);
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 4b75cec19f7..b1afac1f1c7 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -9,6 +9,7 @@ module Groups
before_action :authorize_update_max_artifacts_size!, only: [:update]
before_action :define_variables, only: [:show]
before_action :push_licensed_features, only: [:show]
+ before_action :assign_variables_to_gon, only: [:show]
feature_category :continuous_integration
urgency :low
@@ -81,6 +82,10 @@ module Groups
# Overridden in EE
def push_licensed_features
end
+
+ # Overridden in EE
+ def assign_variables_to_gon
+ end
end
end
end
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index b0b5301f021..32d50802537 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -142,7 +142,7 @@ class IssuableFinder
projects_public_or_visible_to_user
end
- projects.with_feature_available_for_user(klass, current_user).reorder(nil) # rubocop: disable CodeReuse/ActiveRecord
+ projects.with_feature_available_for_user(klass.base_class, current_user).reorder(nil) # rubocop: disable CodeReuse/ActiveRecord
end
end
@@ -215,7 +215,7 @@ class IssuableFinder
end
def min_access_level
- ProjectFeature.required_minimum_access_level(klass)
+ ProjectFeature.required_minimum_access_level(klass.base_class)
end
def method_missing(method_name, *args, &block)
diff --git a/app/finders/issuables/label_filter.rb b/app/finders/issuables/label_filter.rb
index 9a6ca107b19..4e9c964e51c 100644
--- a/app/finders/issuables/label_filter.rb
+++ b/app/finders/issuables/label_filter.rb
@@ -27,7 +27,7 @@ module Issuables
def by_label(issuables)
return issuables unless label_names_from_params.present?
- target_model = issuables.model
+ target_model = issuables.base_class
if filter_by_no_label?
issuables.where(label_link_query(target_model).arel.exists.not)
@@ -55,7 +55,7 @@ module Issuables
# rubocop: disable CodeReuse/ActiveRecord
def issuables_with_selected_labels(issuables, label_names)
- target_model = issuables.model
+ target_model = issuables.base_class
if root_namespace
all_label_ids = find_label_ids(label_names)
@@ -77,7 +77,7 @@ module Issuables
# rubocop: disable CodeReuse/ActiveRecord
def issuables_without_selected_labels(issuables, label_names)
- target_model = issuables.model
+ target_model = issuables.base_class
if root_namespace
label_ids = find_label_ids(label_names).flatten(1)
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 7929c36906d..663dda73a6a 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -37,7 +37,7 @@ class IssuesFinder < IssuableFinder
# rubocop: disable CodeReuse/ActiveRecord
def klass
- Issue.includes(:author)
+ model_class.includes(:author)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -47,10 +47,10 @@ class IssuesFinder < IssuableFinder
# rubocop: disable CodeReuse/ActiveRecord
def with_confidentiality_access_check
- return Issue.all if params.user_can_see_all_issues?
+ return model_class.all if params.user_can_see_all_issues?
# Only admins can see hidden issues, so for non-admins, we filter out any hidden issues
- issues = Issue.without_hidden
+ issues = model_class.without_hidden
return issues.all if params.user_can_see_all_confidential_issues?
@@ -77,7 +77,7 @@ class IssuesFinder < IssuableFinder
def init_collection
if params.public_only?
- Issue.public_only
+ model_class.public_only
else
with_confidentiality_access_check
end
@@ -129,7 +129,7 @@ class IssuesFinder < IssuableFinder
def by_issue_types(items)
issue_type_params = Array(params[:issue_types]).map(&:to_s)
return items if issue_type_params.blank?
- return Issue.none unless (WorkItems::Type.base_types.keys & issue_type_params).sort == issue_type_params.sort
+ return model_class.none unless (WorkItems::Type.base_types.keys & issue_type_params).sort == issue_type_params.sort
items.with_issue_type(params[:issue_types])
end
@@ -140,6 +140,10 @@ class IssuesFinder < IssuableFinder
items.without_issue_type(issue_type_params)
end
+
+ def model_class
+ Issue
+ end
end
IssuesFinder.prepend_mod_with('IssuesFinder')
diff --git a/app/finders/work_items/work_items_finder.rb b/app/finders/work_items/work_items_finder.rb
new file mode 100644
index 00000000000..960272fe47e
--- /dev/null
+++ b/app/finders/work_items/work_items_finder.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# WorkItem model inherits from Issue model. It's planned to be its extension
+# with widgets support. Because WorkItems are internally Issues, WorkItemsFinder
+# can be almost identical to IssuesFinder, except it should return instances of
+# WorkItems instead of Issues
+module WorkItems
+ class WorkItemsFinder < IssuesFinder
+ def params_class
+ ::IssuesFinder::Params
+ end
+
+ private
+
+ def model_class
+ WorkItem
+ end
+ end
+end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 0d9285f138e..2dd258b27e6 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -569,7 +569,7 @@ module Ci
end
def stop_action_successful?
- Feature.disabled?(:env_stopped_on_stop_success, project) || success?
+ success?
end
##
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index 896f0916d8c..1d0ce594f63 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -18,7 +18,7 @@ module Awardable
inner_query = award_emoji_table
.project('true')
.where(award_emoji_table[:user_id].eq(user.id))
- .where(award_emoji_table[:awardable_type].eq(self.name))
+ .where(award_emoji_table[:awardable_type].eq(base_class.name))
.where(award_emoji_table[:awardable_id].eq(self.arel_table[:id]))
inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present?
@@ -31,7 +31,7 @@ module Awardable
inner_query = award_emoji_table
.project('true')
.where(award_emoji_table[:user_id].eq(user.id))
- .where(award_emoji_table[:awardable_type].eq(self.name))
+ .where(award_emoji_table[:awardable_type].eq(base_class.name))
.where(award_emoji_table[:awardable_id].eq(self.arel_table[:id]))
inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present?
@@ -56,13 +56,11 @@ module Awardable
awardable_table = self.arel_table
awards_table = AwardEmoji.arel_table
- join_clause = awardable_table.join(awards_table, Arel::Nodes::OuterJoin).on(
- awards_table[:awardable_id].eq(awardable_table[:id]).and(
- awards_table[:awardable_type].eq(self.name).and(
- awards_table[:name].eq(emoji_name)
- )
- )
- ).join_sources
+ join_clause = awardable_table
+ .join(awards_table, Arel::Nodes::OuterJoin)
+ .on(awards_table[:awardable_id].eq(awardable_table[:id])
+ .and(awards_table[:awardable_type].eq(base_class.name).and(awards_table[:name].eq(emoji_name))))
+ .join_sources
joins(join_clause).group(awardable_table[:id]).reorder(
Arel.sql("COUNT(award_emoji.id) #{direction}")
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 81650e3d84a..4dca07132ef 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -106,23 +106,23 @@ module Issuable
scope :closed, -> { with_state(:closed) }
# rubocop:disable GitlabSecurity/SqlInjection
- # The `to_ability_name` method is not an user input.
+ # The `assignee_association_name` method is not an user input.
scope :assigned, -> do
- where("EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)")
+ where("EXISTS (SELECT TRUE FROM #{assignee_association_name}_assignees WHERE #{assignee_association_name}_id = #{assignee_association_name}s.id)")
end
scope :unassigned, -> do
- where("NOT EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)")
+ where("NOT EXISTS (SELECT TRUE FROM #{assignee_association_name}_assignees WHERE #{assignee_association_name}_id = #{assignee_association_name}s.id)")
end
scope :assigned_to, ->(users) do
- assignees_class = self.reflect_on_association("#{to_ability_name}_assignees").klass
+ assignees_class = self.reflect_on_association("#{assignee_association_name}_assignees").klass
- condition = assignees_class.where(user_id: users).where(Arel.sql("#{to_ability_name}_id = #{to_ability_name}s.id"))
+ condition = assignees_class.where(user_id: users).where(Arel.sql("#{assignee_association_name}_id = #{assignee_association_name}s.id"))
where(condition.arel.exists)
end
scope :not_assigned_to, ->(users) do
- assignees_class = self.reflect_on_association("#{to_ability_name}_assignees").klass
+ assignees_class = self.reflect_on_association("#{assignee_association_name}_assignees").klass
- condition = assignees_class.where(user_id: users).where(Arel.sql("#{to_ability_name}_id = #{to_ability_name}s.id"))
+ condition = assignees_class.where(user_id: users).where(Arel.sql("#{assignee_association_name}_id = #{assignee_association_name}s.id"))
where(condition.arel.exists.not)
end
# rubocop:enable GitlabSecurity/SqlInjection
@@ -412,6 +412,10 @@ module Issuable
def parent_class
::Project
end
+
+ def assignee_association_name
+ to_ability_name
+ end
end
def state
diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb
index 6ff540b7866..0cccb7b51a8 100644
--- a/app/models/concerns/limitable.rb
+++ b/app/models/concerns/limitable.rb
@@ -15,17 +15,29 @@ module Limitable
validate :validate_plan_limit_not_exceeded, on: :create
end
+ def exceeds_limits?
+ limits, relation = fetch_plan_limit_data
+
+ limits&.exceeded?(limit_name, relation)
+ end
+
private
def validate_plan_limit_not_exceeded
+ limits, relation = fetch_plan_limit_data
+
+ check_plan_limit_not_exceeded(limits, relation)
+ end
+
+ def fetch_plan_limit_data
if GLOBAL_SCOPE == limit_scope
- validate_global_plan_limit_not_exceeded
+ global_plan_limits
else
- validate_scoped_plan_limit_not_exceeded
+ scoped_plan_limits
end
end
- def validate_scoped_plan_limit_not_exceeded
+ def scoped_plan_limits
scope_relation = self.public_send(limit_scope) # rubocop:disable GitlabSecurity/PublicSend
return unless scope_relation
return if limit_feature_flag && ::Feature.disabled?(limit_feature_flag, scope_relation)
@@ -34,18 +46,18 @@ module Limitable
relation = limit_relation ? self.public_send(limit_relation) : self.class.where(limit_scope => scope_relation) # rubocop:disable GitlabSecurity/PublicSend
limits = scope_relation.actual_limits
- check_plan_limit_not_exceeded(limits, relation)
+ [limits, relation]
end
- def validate_global_plan_limit_not_exceeded
+ def global_plan_limits
relation = self.class.all
limits = Plan.default.actual_limits
- check_plan_limit_not_exceeded(limits, relation)
+ [limits, relation]
end
def check_plan_limit_not_exceeded(limits, relation)
- return unless limits.exceeded?(limit_name, relation)
+ return unless limits&.exceeded?(limit_name, relation)
errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") %
{ name: limit_name.humanize(capitalize: false), count: limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/models/environment.rb b/app/models/environment.rb
index f3453bc5844..da6ab5ed077 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -300,7 +300,7 @@ class Environment < ApplicationRecord
end
def wait_for_stop?
- stop_actions.present? && Feature.enabled?(:env_stopped_on_stop_success, project)
+ stop_actions.present?
end
def stop_with_actions!(current_user)
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 83fa16c814a..bdd9aae90a4 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -13,6 +13,10 @@ class WorkItem < Issue
scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) }
+ def self.assignee_association_name
+ 'issue'
+ end
+
def noteable_target_type_name
'issue'
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index fb391485ada..dd97915010f 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -669,6 +669,7 @@ class ProjectPolicy < BasePolicy
enable :read_design
enable :read_design_activity
enable :read_issue_link
+ enable :read_work_item
end
rule { can?(:developer_access) }.policy do
diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb
index e191e8d26ca..ea7559592e1 100644
--- a/app/policies/work_item_policy.rb
+++ b/app/policies/work_item_policy.rb
@@ -8,4 +8,9 @@ class WorkItemPolicy < IssuePolicy
rule { can?(:update_issue) }.enable :update_work_item
rule { can?(:read_issue) }.enable :read_work_item
+ # because IssuePolicy delegates to ProjectPolicy and
+ # :read_work_item is enabled in ProjectPolicy too, we
+ # need to make sure we also prevent this rule if read_issue
+ # is prevented
+ rule { ~can?(:read_issue) }.prevent :read_work_item
end
diff --git a/app/services/bulk_imports/lfs_objects_export_service.rb b/app/services/bulk_imports/lfs_objects_export_service.rb
index fa606e4e5a3..1f745201c8a 100644
--- a/app/services/bulk_imports/lfs_objects_export_service.rb
+++ b/app/services/bulk_imports/lfs_objects_export_service.rb
@@ -32,6 +32,8 @@ module BulkImports
destination_filepath = File.join(export_path, lfs_object.oid)
if lfs_object.local_store?
+ return unless File.exist?(lfs_object.file.path)
+
copy_files(lfs_object.file.path, destination_filepath)
else
download(lfs_object.file.url, destination_filepath)
diff --git a/app/services/resource_events/base_change_timebox_service.rb b/app/services/resource_events/base_change_timebox_service.rb
index d802bbee107..372f1c9d816 100644
--- a/app/services/resource_events/base_change_timebox_service.rb
+++ b/app/services/resource_events/base_change_timebox_service.rb
@@ -22,7 +22,7 @@ module ResourceEvents
end
def build_resource_args
- key = resource.class.name.foreign_key
+ key = resource.class.base_class.name.foreign_key
{
user_id: user.id,
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index 51e6af56377..cf951ae0265 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -1,22 +1,19 @@
%fieldset
- %legend
+ %legend.gl-border-bottom-0
= s_('AdminUsers|Access')
.form-group.row
- .col-sm-2.col-form-label
+ .col-12
= f.label :projects_limit
- .col-sm-10
= f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input'
.form-group.row
- .col-sm-2.col-form-label.gl-pt-0
+ .col-12.gl-pt-0
= f.label :can_create_group
- .col-sm-10
= f.gitlab_ui_checkbox_component :can_create_group, ''
.form-group.row
- .col-sm-2.col-form-label.gl-pt-0
+ .col-12.gl-pt-0
= f.label :access_level
- .col-sm-10
- editing_current_user = (current_user == @user)
= f.gitlab_ui_radio_component :access_level, :regular,
@@ -35,10 +32,10 @@
.form-group.row
- .col-sm-2.col-form-label.gl-pt-0
+ .col-12.gl-pt-0
= f.label :external
.hidden{ data: user_internal_regex_data }
- .col-sm-10.gl-display-flex.gl-align-items-baseline
+ .col-12.gl-display-flex.gl-align-items-baseline
= f.gitlab_ui_checkbox_component :external, s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.')
%row.hidden#warning_external_automatically_set
= gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning
@@ -46,9 +43,9 @@
.form-group.row
- @user.credit_card_validation || @user.build_credit_card_validation
= f.fields_for :credit_card_validation do |ff|
- .col-sm-2.col-form-label.gl-pt-0
+ .col-12.gl-pt-0
= ff.label s_('AdminUsers|Validate user account')
- .col-sm-10.gl-display-flex.gl-align-items-baseline
+ .col-12.gl-display-flex.gl-align-items-baseline
= ff.gitlab_ui_checkbox_component :credit_card_validated_at,
s_('AdminUsers|User is validated and can use free CI minutes on shared runners.'),
help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user.'),
diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml
index 7c3220e2cee..10f654e0f71 100644
--- a/app/views/admin/users/_admin_notes.html.haml
+++ b/app/views/admin/users/_admin_notes.html.haml
@@ -1,7 +1,6 @@
%fieldset
- %legend= _('Admin notes')
+ %legend.gl-border-bottom-0= _('Admin notes')
.form-group.row
- .col-sm-2.col-form-label
+ .col-12
= f.label :note, s_('Admin|Note')
- .col-sm-10
= f.text_area :note, class: 'form-control gl-form-input gl-form-textarea'
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 3869a2b6dcd..c1d0984c556 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -3,47 +3,39 @@
= form_errors(@user)
%fieldset
- %legend= _('Account')
+ %legend.gl-border-bottom-0= _('Account')
.form-group.row
- .col-sm-2.col-form-label
- = f.label :name
- .col-sm-10
+ .col-12
+ = f.label "#{:name} (required)"
= f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input'
- %span.help-inline * #{_('required')}
.form-group.row
- .col-sm-2.col-form-label
- = f.label :username
- .col-sm-10
+ .col-12
+ = f.label "#{:username} (required)"
= f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input'
- %span.help-inline * #{_('required')}
.form-group.row
- .col-sm-2.col-form-label
- = f.label :email
- .col-sm-10
+ .col-12
+ = f.label "#{:email} (required)"
= f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input'
- %span.help-inline * #{_('required')}
- if @user.new_record?
%fieldset
- %legend= _('Password')
+ %legend.gl-border-bottom-0= _('Password')
.form-group.row
- .col-sm-2.col-form-label
- = f.label :password
- .col-sm-10
+ .col-12
%strong
= _('Reset link will be generated and sent to the user. %{break} User will be forced to set the password on first sign in.').html_safe % { break: '<br />'.html_safe }
- else
%fieldset
- %legend= _('Password')
+ %legend.gl-border-bottom-0= _('Password')
.form-group.row
- .col-sm-2.col-form-label
+ .col-12
= f.label :password
- .col-sm-10
+ .col-12
= f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input'
.form-group.row
- .col-sm-2.col-form-label
+ .col-12
= f.label :password_confirmation
- .col-sm-10
+ .col-12
= f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input'
= render partial: 'access_levels', locals: { f: f }
@@ -53,37 +45,33 @@
= render_if_exists 'admin/users/limits', f: f
%fieldset
- %legend= _('Profile')
+ %legend.gl-border-bottom-0= _('Profile')
.form-group.row
- .col-sm-2.col-form-label
+ .col-12
= f.label :avatar
- .col-sm-10
+ .col-12
= f.file_field :avatar
.form-group.row
- .col-sm-2.col-form-label
+ .col-12
= f.label :skype
- .col-sm-10
= f.text_field :skype, class: 'form-control gl-form-input'
.form-group.row
- .col-sm-2.col-form-label
+ .col-12
= f.label :linkedin
- .col-sm-10
= f.text_field :linkedin, class: 'form-control gl-form-input'
.form-group.row
- .col-sm-2.col-form-label
+ .col-12
= f.label :twitter
- .col-sm-10
= f.text_field :twitter, class: 'form-control gl-form-input'
.form-group.row
- .col-sm-2.col-form-label
+ .col-12
= f.label :website_url
- .col-sm-10
= f.text_field :website_url, class: 'form-control gl-form-input'
= render 'admin/users/admin_notes', f: f
- .form-actions
+ %div
- if @user.new_record?
= f.submit _('Create user'), class: "btn gl-button btn-confirm"
= link_to _('Cancel'), admin_users_path, class: "gl-button btn btn-default btn-cancel"
diff --git a/app/views/admin/users/edit.html.haml b/app/views/admin/users/edit.html.haml
index d6c65741422..598c0aec851 100644
--- a/app/views/admin/users/edit.html.haml
+++ b/app/views/admin/users/edit.html.haml
@@ -1,5 +1,4 @@
- page_title _("Edit"), @user.name, _("Users")
-%h1.page-title
+%h1.page-title.gl-font-size-h-display.gl-mb-6
= _("Edit user: %{user_name}") % { user_name: @user.name }
-%hr
= render 'form'
diff --git a/app/views/admin/users/new.html.haml b/app/views/admin/users/new.html.haml
index ad11f797b2c..650aba858c6 100644
--- a/app/views/admin/users/new.html.haml
+++ b/app/views/admin/users/new.html.haml
@@ -1,5 +1,4 @@
- page_title _("New User")
-%h1.page-title
+%h1.page-title.gl-font-size-h-display.gl-mb-6
= s_('AdminUsers|New user')
-%hr
= render 'form'
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 331cb31c626..3e113b1f35f 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -51,3 +51,6 @@
.settings-content
= render 'groups/settings/ci_cd/auto_devops_form', group: @group
+
+- if ::Feature.enabled?(:group_level_protected_environment, @group)
+ = render_if_exists 'groups/settings/ci_cd/protected_environments', expanded: expanded
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 185ed228d92..b7cf7b7468f 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -16,7 +16,7 @@
= yield :flash_message
= dispensable_render "shared/service_ping_consent"
= dispensable_render_if_exists "layouts/header/ee_subscribable_banner"
- = dispensable_render_if_exists "layouts/header/seats_count_alert"
+ = dispensable_render_if_exists "layouts/header/seat_count_alert"
= dispensable_render_if_exists "shared/namespace_storage_limit_alert"
= dispensable_render_if_exists "shared/namespace_user_cap_reached_alert"
= dispensable_render_if_exists "shared/new_user_signups_cap_reached_alert"
diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml
index b3d6c4c327b..0a74e47fa4c 100644
--- a/app/views/shared/_sidebar_toggle_button.html.haml
+++ b/app/views/shared/_sidebar_toggle_button.html.haml
@@ -1,5 +1,5 @@
%a.toggle-sidebar-button.js-toggle-sidebar.qa-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
- = sprite_icon('chevron-double-lg-left', css_class: 'icon-chevron-double-lg-left')
+ = sprite_icon('chevron-double-lg-left', size: 12, css_class: 'icon-chevron-double-lg-left')
%span.collapse-text.gl-ml-3= _("Collapse sidebar")
= button_tag class: 'close-nav-button', type: 'button' do
diff --git a/babel.config.js b/babel.config.js
index 39bb7858087..a545d8e6062 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,3 +1,5 @@
+const coreJSVersion = require('./node_modules/core-js/package.json').version;
+
const BABEL_ENV = process.env.BABEL_ENV || process.env.NODE_ENV || null;
let presets = [
@@ -5,7 +7,7 @@ let presets = [
'@babel/preset-env',
{
useBuiltIns: 'usage',
- corejs: { version: 3, proposals: true },
+ corejs: { version: coreJSVersion, proposals: true },
modules: false,
},
],
diff --git a/config/feature_flags/development/ci_docker_image_pull_policy.yml b/config/feature_flags/development/ci_docker_image_pull_policy.yml
new file mode 100644
index 00000000000..09e01fb5232
--- /dev/null
+++ b/config/feature_flags/development/ci_docker_image_pull_policy.yml
@@ -0,0 +1,8 @@
+---
+name: ci_docker_image_pull_policy
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85588
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/363186
+milestone: '15.1'
+type: development
+group: group::pipeline authoring
+default_enabled: false
diff --git a/config/feature_flags/development/env_stopped_on_stop_success.yml b/config/feature_flags/development/seat_count_alerts.yml
index 4936f548c39..9b2f3a2ef55 100644
--- a/config/feature_flags/development/env_stopped_on_stop_success.yml
+++ b/config/feature_flags/development/seat_count_alerts.yml
@@ -1,8 +1,8 @@
---
-name: env_stopped_on_stop_success
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/86478
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/361473
-milestone: '15.0'
+name: seat_count_alerts
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89204
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/362041
+milestone: '15.1'
type: development
-group: group::release
+group: group::purchase
default_enabled: false
diff --git a/db/migrate/20220516201245_add_security_policy_scan_execution_schedules_to_plan_limits.rb b/db/migrate/20220516201245_add_security_policy_scan_execution_schedules_to_plan_limits.rb
new file mode 100644
index 00000000000..733ac971b98
--- /dev/null
+++ b/db/migrate/20220516201245_add_security_policy_scan_execution_schedules_to_plan_limits.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class AddSecurityPolicyScanExecutionSchedulesToPlanLimits < Gitlab::Database::Migration[2.0]
+ def up
+ add_column(:plan_limits, :security_policy_scan_execution_schedules, :integer, default: 0, null: false)
+ end
+
+ def down
+ remove_column(:plan_limits, :security_policy_scan_execution_schedules)
+ end
+end
diff --git a/db/schema_migrations/20220516201245 b/db/schema_migrations/20220516201245
new file mode 100644
index 00000000000..eabfba67df8
--- /dev/null
+++ b/db/schema_migrations/20220516201245
@@ -0,0 +1 @@
+4b979c4ae290efdbc7c4bfe7105f0b30d00e532ac11c579db7417a317fd35db8 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 68bcd3075e5..5fe209df915 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -18773,7 +18773,8 @@ CREATE TABLE plan_limits (
dotenv_size integer DEFAULT 5120 NOT NULL,
pipeline_triggers integer DEFAULT 25000 NOT NULL,
project_ci_secure_files integer DEFAULT 100 NOT NULL,
- repository_size bigint DEFAULT 0 NOT NULL
+ repository_size bigint DEFAULT 0 NOT NULL,
+ security_policy_scan_execution_schedules integer DEFAULT 0 NOT NULL
);
CREATE SEQUENCE plan_limits_id_seq
diff --git a/doc/administration/geo/setup/index.md b/doc/administration/geo/setup/index.md
index bf6d994793a..59748499a7e 100644
--- a/doc/administration/geo/setup/index.md
+++ b/doc/administration/geo/setup/index.md
@@ -19,7 +19,7 @@ The steps below should be followed in the order they appear. **Make sure the Git
If you installed GitLab using the Omnibus packages (highly recommended):
-1. [Install GitLab Enterprise Edition](https://about.gitlab.com/install/) on the nodes that will serve as the **secondary** site. Do not create an account or log in to the new **secondary** site.
+1. [Install GitLab Enterprise Edition](https://about.gitlab.com/install/) on the nodes that will serve as the **secondary** site. Do not create an account or log in to the new **secondary** site. The **GitLab version must match** across primary and secondary sites.
1. [Add the GitLab License](../../../user/admin_area/license.md) on the **primary** site to unlock Geo. The license must be for [GitLab Premium](https://about.gitlab.com/pricing/) or higher.
1. [Set up the database replication](database.md) (`primary (read-write) <-> secondary (read-only)` topology).
1. [Configure fast lookup of authorized SSH keys in the database](../../operations/fast_ssh_key_lookup.md). This step is required and needs to be done on **both** the **primary** and **secondary** sites.
diff --git a/doc/administration/instance_limits.md b/doc/administration/instance_limits.md
index 2fc48495601..6bb49c808b9 100644
--- a/doc/administration/instance_limits.md
+++ b/doc/administration/instance_limits.md
@@ -554,6 +554,26 @@ Plan.default.actual_limits.update!(ci_daily_pipeline_schedule_triggers: 1440)
This limit is [enabled on GitLab.com](../user/gitlab_com/index.md#gitlab-cicd).
+### Limit the number of schedule rules defined for security policy project
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/335659) in GitLab 15.1.
+
+You can limit the total number of schedule rules per security policy project. This limit is
+checked each time policies with schedule rules are updated. If a new schedule rule would
+cause the total number of schedule rules to exceed the limit, the new schedule rule is
+not processed.
+
+By default, self-managed instances do not limit the number of processable schedule rules.
+
+To set this limit for a self-managed installation, run the following in the
+[GitLab Rails console](operations/rails_console.md#starting-a-rails-console-session):
+
+```ruby
+Plan.default.actual_limits.update!(security_policy_scan_execution_schedules: 100)
+```
+
+This limit is [enabled on GitLab.com](../user/gitlab_com/index.md#gitlab-cicd).
+
### Number of instance level variables
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216097) in GitLab 13.1.
diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md
index d7da5b4d045..a3d17cea5b8 100644
--- a/doc/ci/yaml/index.md
+++ b/doc/ci/yaml/index.md
@@ -1883,6 +1883,52 @@ image:
- [Override the entrypoint of an image](../docker/using_docker_images.md#override-the-entrypoint-of-an-image).
+#### `image:pull_policy`
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/21619) in GitLab 15.1 [with a flag](../../administration/feature_flags.md) named `ci_docker_image_pull_policy`. Disabled by default.
+> - Requires GitLab Runner 15.1 or later.
+
+FLAG:
+On self-managed GitLab, by default this feature is not available. To make it available,
+ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `ci_docker_image_pull_policy`.
+The feature is not ready for production use.
+
+The pull policy that the runner uses to fetch the Docker image.
+
+**Keyword type**: Job keyword. You can use it only as part of a job or in the [`default` section](#default).
+
+**Possible inputs**:
+
+- A single pull policy, or multiple pull policies in an array.
+ Can be `always`, `if-not-present`, or `never`.
+
+**Examples of `image:pull_policy`**:
+
+```yaml
+job1:
+ script: echo "A single pull policy."
+ image:
+ name: ruby:3.0
+ pull_policy: if-not-present
+
+job2:
+ script: echo "Multiple pull policies."
+ image:
+ name: ruby:3.0
+ pull_policy: [always, if-not-present]
+```
+
+**Additional details**:
+
+- If the runner does not support the defined pull policy, the job fails with an error similar to:
+ `ERROR: Job failed (system failure): the configured PullPolicies ([always]) are not allowed by AllowedPullPolicies ([never])`.
+
+**Related topics**:
+
+- [Run your CI/CD jobs in Docker containers](../docker/using_docker_images.md).
+- [How runner pull policies work](https://docs.gitlab.com/runner/executors/docker.html#how-pull-policies-work).
+- [Using multiple pull policies](https://docs.gitlab.com/runner/executors/docker.html#using-multiple-pull-policies).
+
### `inherit`
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207484) in GitLab 12.9.
diff --git a/doc/development/adding_service_component.md b/doc/development/adding_service_component.md
index f5acf0d26eb..2894250ec1b 100644
--- a/doc/development/adding_service_component.md
+++ b/doc/development/adding_service_component.md
@@ -23,7 +23,7 @@ The following outline re-uses the [maturity metric](https://about.gitlab.com/dir
- [Release management](#release-management)
- [Enabled on GitLab.com](feature_flags/controls.md#enabling-a-feature-for-gitlabcom)
- Complete
- - [Configurable by the GitLab orchestrator](https://gitlab.com/gitlab-org/gitlab-orchestrator)
+ - [Configurable by the GitLab Environment Toolkit](https://gitlab.com/gitlab-org/gitlab-environment-toolkit)
- Lovable
- Enabled by default for the majority of users
diff --git a/doc/development/code_review.md b/doc/development/code_review.md
index 1a806b633c9..3425dc90284 100644
--- a/doc/development/code_review.md
+++ b/doc/development/code_review.md
@@ -185,6 +185,7 @@ See the [test engineering process](https://about.gitlab.com/handbook/engineering
##### Observability instrumentation
1. I have included enough instrumentation to facilitate debugging and proactive performance improvements through observability.
+ See [example](https://gitlab.com/gitlab-org/gitlab/-/issues/346124#expectations) of adding feature flags, logging, and instrumentation.
##### Documentation
diff --git a/doc/integration/saml.md b/doc/integration/saml.md
index 1a1eb17cf63..e9ad443bd23 100644
--- a/doc/integration/saml.md
+++ b/doc/integration/saml.md
@@ -185,7 +185,7 @@ gitlab_rails['omniauth_providers'] = [
name: 'saml_1',
args: {
name: 'saml_1', # This is mandatory and must match the provider name
- strategy_class: 'OmniAuth::Strategies::SAML'
+ strategy_class: 'OmniAuth::Strategies::SAML',
assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml_1/callback', # URL must match the name of the provider
... # Put here all the required arguments similar to a single provider
},
@@ -195,7 +195,7 @@ gitlab_rails['omniauth_providers'] = [
name: 'saml_2',
args: {
name: 'saml_2', # This is mandatory and must match the provider name
- strategy_class: 'OmniAuth::Strategies::SAML'
+ strategy_class: 'OmniAuth::Strategies::SAML',
assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml_2/callback', # URL must match the name of the provider
... # Put here all the required arguments similar to a single provider
},
diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md
index 5077b9c5ef0..adccfc3d88b 100644
--- a/doc/user/gitlab_com/index.md
+++ b/doc/user/gitlab_com/index.md
@@ -156,6 +156,7 @@ the related documentation.
| Maximum number of pipeline triggers in a project | `25000` for Free tier, Unlimited for all paid tiers | See [Limit the number of pipeline triggers](../../administration/instance_limits.md#limit-the-number-of-pipeline-triggers) |
| Maximum pipeline schedules in projects | `10` for Free tier, `50` for all paid tiers | See [Number of pipeline schedules](../../administration/instance_limits.md#number-of-pipeline-schedules) |
| Maximum pipelines per schedule | `24` for Free tier, `288` for all paid tiers | See [Limit the number of pipelines created by a pipeline schedule per day](../../administration/instance_limits.md#limit-the-number-of-pipelines-created-by-a-pipeline-schedule-per-day) |
+| Maximum number of schedule rules defined for each security policy project | Unlimited for all paid tiers | See [Number of schedule rules defined for each security policy project](../../administration/instance_limits.md#limit-the-number-of-schedule-rules-defined-for-security-policy-project) |
| Scheduled job archiving | 3 months (from June 22, 2020). Jobs created before that date were archived after September 22, 2020. | Never |
| Maximum test cases per [unit test report](../../ci/testing/unit_test_reports.md) | `500000` | Unlimited |
| Maximum registered runners | Free tier: `50` per-group / `50` per-project<br/>All paid tiers: `1000` per-group / `1000` per-project | See [Number of registered runners per scope](../../administration/instance_limits.md#number-of-registered-runners-per-scope) |
diff --git a/lib/api/entities/ci/job_request/image.rb b/lib/api/entities/ci/job_request/image.rb
index 8e404a8fa02..83f64da6050 100644
--- a/lib/api/entities/ci/job_request/image.rb
+++ b/lib/api/entities/ci/job_request/image.rb
@@ -7,6 +7,8 @@ module API
class Image < Grape::Entity
expose :name, :entrypoint
expose :ports, using: Entities::Ci::JobRequest::Port
+
+ expose :pull_policy, if: ->(_) { ::Feature.enabled?(:ci_docker_image_pull_policy) }
end
end
end
diff --git a/lib/api/entities/ci/job_request/service.rb b/lib/api/entities/ci/job_request/service.rb
index 0dae5d5a933..d9da2c92ec7 100644
--- a/lib/api/entities/ci/job_request/service.rb
+++ b/lib/api/entities/ci/job_request/service.rb
@@ -4,7 +4,10 @@ module API
module Entities
module Ci
module JobRequest
- class Service < Entities::Ci::JobRequest::Image
+ class Service < Grape::Entity
+ expose :name, :entrypoint
+ expose :ports, using: Entities::Ci::JobRequest::Port
+
expose :alias, :command
expose :variables
end
diff --git a/lib/gitlab/ci/build/image.rb b/lib/gitlab/ci/build/image.rb
index 8ddcf1d523e..7dc375e05eb 100644
--- a/lib/gitlab/ci/build/image.rb
+++ b/lib/gitlab/ci/build/image.rb
@@ -4,7 +4,7 @@ module Gitlab
module Ci
module Build
class Image
- attr_reader :alias, :command, :entrypoint, :name, :ports, :variables
+ attr_reader :alias, :command, :entrypoint, :name, :ports, :variables, :pull_policy
class << self
def from_image(job)
@@ -34,6 +34,7 @@ module Gitlab
@name = image[:name]
@ports = build_ports(image).select(&:valid?)
@variables = build_variables(image)
+ @pull_policy = image[:pull_policy]
end
end
diff --git a/lib/gitlab/ci/config/entry/image.rb b/lib/gitlab/ci/config/entry/image.rb
index 21c42857895..79443f69b03 100644
--- a/lib/gitlab/ci/config/entry/image.rb
+++ b/lib/gitlab/ci/config/entry/image.rb
@@ -12,11 +12,13 @@ module Gitlab
include ::Gitlab::Config::Entry::Attributable
include ::Gitlab::Config::Entry::Configurable
- ALLOWED_KEYS = %i[name entrypoint ports].freeze
+ ALLOWED_KEYS = %i[name entrypoint ports pull_policy].freeze
+ LEGACY_ALLOWED_KEYS = %i[name entrypoint ports].freeze
validations do
validates :config, hash_or_string: true
- validates :config, allowed_keys: ALLOWED_KEYS
+ validates :config, allowed_keys: ALLOWED_KEYS, if: :ci_docker_image_pull_policy_enabled?
+ validates :config, allowed_keys: LEGACY_ALLOWED_KEYS, unless: :ci_docker_image_pull_policy_enabled?
validates :config, disallowed_keys: %i[ports], unless: :with_image_ports?
validates :name, type: String, presence: true
@@ -26,7 +28,10 @@ module Gitlab
entry :ports, Entry::Ports,
description: 'Ports used to expose the image'
- attributes :ports
+ entry :pull_policy, Entry::PullPolicy,
+ description: 'Pull policy for the image'
+
+ attributes :ports, :pull_policy
def name
value[:name]
@@ -37,16 +42,28 @@ module Gitlab
end
def value
- return { name: @config } if string?
- return @config if hash?
-
- {}
+ if string?
+ { name: @config }
+ elsif hash?
+ {
+ name: @config[:name],
+ entrypoint: @config[:entrypoint],
+ ports: ports_value,
+ pull_policy: (ci_docker_image_pull_policy_enabled? ? pull_policy_value : nil)
+ }.compact
+ else
+ {}
+ end
end
def with_image_ports?
opt(:with_image_ports)
end
+ def ci_docker_image_pull_policy_enabled?
+ ::Feature.enabled?(:ci_docker_image_pull_policy)
+ end
+
def skip_config_hash_validation?
true
end
diff --git a/lib/gitlab/ci/config/entry/pull_policy.rb b/lib/gitlab/ci/config/entry/pull_policy.rb
new file mode 100644
index 00000000000..f597134dd2c
--- /dev/null
+++ b/lib/gitlab/ci/config/entry/pull_policy.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ class Config
+ module Entry
+ ##
+ # Entry that represents a configuration of the pull policies of an image.
+ #
+ class PullPolicy < ::Gitlab::Config::Entry::Node
+ include ::Gitlab::Config::Entry::Validatable
+
+ ALLOWED_POLICIES = %w[always never if-not-present].freeze
+
+ validations do
+ validates :config, array_of_strings_or_string: true
+ validates :config,
+ allowed_array_values: { in: ALLOWED_POLICIES },
+ presence: true,
+ if: :array?
+ validates :config,
+ inclusion: { in: ALLOWED_POLICIES },
+ if: :string?
+ end
+
+ def value
+ # We either return an array with policies or nothing
+ Array(@config).presence
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/config/entry/node.rb b/lib/gitlab/config/entry/node.rb
index 6ce7046262b..40418a4c797 100644
--- a/lib/gitlab/config/entry/node.rb
+++ b/lib/gitlab/config/entry/node.rb
@@ -106,6 +106,10 @@ module Gitlab
@config.is_a?(Hash)
end
+ def array?
+ @config.is_a?(Array)
+ end
+
def string?
@config.is_a?(String)
end
diff --git a/lib/gitlab/import_export/lfs_saver.rb b/lib/gitlab/import_export/lfs_saver.rb
index 22a7a8dd7cd..6ad368a5d2f 100644
--- a/lib/gitlab/import_export/lfs_saver.rb
+++ b/lib/gitlab/import_export/lfs_saver.rb
@@ -56,7 +56,11 @@ module Gitlab
end
def copy_file_for_lfs_object(lfs_object)
- copy_files(lfs_object.file.path, destination_path_for_object(lfs_object))
+ file_path = lfs_object.file.path
+
+ return unless File.exist?(file_path)
+
+ copy_files(file_path, destination_path_for_object(lfs_object))
end
def append_lfs_json_for_batch(lfs_objects_batch)
diff --git a/lib/tasks/gitlab/db/validate_config.rake b/lib/tasks/gitlab/db/validate_config.rake
index 66aa949cc94..7430f50d0cf 100644
--- a/lib/tasks/gitlab/db/validate_config.rake
+++ b/lib/tasks/gitlab/db/validate_config.rake
@@ -4,6 +4,23 @@ databases = ActiveRecord::Tasks::DatabaseTasks.setup_initial_database_yaml
namespace :gitlab do
namespace :db do
+ DB_CONFIG_NAME_KEY = 'gitlab_db_config_name'
+
+ DB_IDENTIFIER_SQL = <<-SQL
+ SELECT system_identifier, current_database()
+ FROM pg_control_system()
+ SQL
+
+ # We fetch timestamp as a way to properly handle race conditions
+ # fail in such cases, which should not really happen in production environment
+ DB_IDENTIFIER_WITH_DB_CONFIG_NAME_SQL = <<-SQL
+ SELECT
+ system_identifier, current_database(),
+ value as db_config_name, created_at as timestamp
+ FROM pg_control_system()
+ LEFT JOIN ar_internal_metadata ON ar_internal_metadata.key=$1
+ SQL
+
desc 'Validates `config/database.yml` to ensure a correct behavior is configured'
task validate_config: :environment do
original_db_config = ActiveRecord::Base.connection_db_config # rubocop:disable Database/MultipleDatabases
@@ -14,26 +31,22 @@ namespace :gitlab do
db_configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, include_replicas: true)
db_configs = db_configs.reject(&:replica?)
+ # The `pg_control_system()` is not enough to properly discover matching database systems
+ # since in case of cluster promotion it will return the same identifier as main cluster
+ # We instead set an `ar_internal_metadata` information with configured database name
+ db_configs.reverse_each do |db_config|
+ insert_db_identifier(db_config)
+ end
+
# Map each database connection into unique identifier of system+database
- # rubocop:disable Database/MultipleDatabases
all_connections = db_configs.map do |db_config|
- identifier =
- begin
- ActiveRecord::Base.establish_connection(db_config) # rubocop: disable Database/EstablishConnection
- ActiveRecord::Base.connection.select_one("SELECT system_identifier, current_database() FROM pg_control_system()")
- rescue ActiveRecord::ConnectionNotEstablished, PG::ConnectionBad => err
- warn "WARNING: Could not establish database connection for #{db_config.name}: #{err.message}"
- rescue ActiveRecord::NoDatabaseError
- end
-
{
name: db_config.name,
config: db_config,
database_tasks?: db_config.database_tasks?,
- identifier: identifier
+ identifier: get_db_identifier(db_config)
}
- end.compact
- # rubocop:enable Database/MultipleDatabases
+ end
unique_connections = all_connections.group_by { |connection| connection[:identifier] }
primary_connection = all_connections.find { |connection| ActiveRecord::Base.configurations.primary?(connection[:name]) }
@@ -111,5 +124,39 @@ namespace :gitlab do
Rake::Task["db:schema:load:#{name}"].enhance(['gitlab:db:validate_config'])
Rake::Task["db:schema:dump:#{name}"].enhance(['gitlab:db:validate_config'])
end
+
+ def insert_db_identifier(db_config)
+ ActiveRecord::Base.establish_connection(db_config) # rubocop: disable Database/EstablishConnection
+
+ if ActiveRecord::InternalMetadata.table_exists?
+ ts = Time.zone.now
+
+ ActiveRecord::InternalMetadata.upsert(
+ { key: DB_CONFIG_NAME_KEY,
+ value: db_config.name,
+ created_at: ts,
+ updated_at: ts }
+ )
+ end
+ rescue ActiveRecord::ConnectionNotEstablished, PG::ConnectionBad => err
+ warn "WARNING: Could not establish database connection for #{db_config.name}: #{err.message}"
+ rescue ActiveRecord::NoDatabaseError
+ end
+
+ def get_db_identifier(db_config)
+ ActiveRecord::Base.establish_connection(db_config) # rubocop: disable Database/EstablishConnection
+
+ # rubocop:disable Database/MultipleDatabases
+ if ActiveRecord::InternalMetadata.table_exists?
+ ActiveRecord::Base.connection.select_one(
+ DB_IDENTIFIER_WITH_DB_CONFIG_NAME_SQL, nil, [DB_CONFIG_NAME_KEY])
+ else
+ ActiveRecord::Base.connection.select_one(DB_IDENTIFIER_SQL)
+ end
+ # rubocop:enable Database/MultipleDatabases
+ rescue ActiveRecord::ConnectionNotEstablished, PG::ConnectionBad => err
+ warn "WARNING: Could not establish database connection for #{db_config.name}: #{err.message}"
+ rescue ActiveRecord::NoDatabaseError
+ end
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 78c916c2411..4ba538f3b79 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -14996,6 +14996,9 @@ msgstr ""
msgid "Estimated"
msgstr ""
+msgid "Even if you reach the number of seats in your subscription, you can continue to add users, and GitLab will bill you for the overage."
+msgstr ""
+
msgid "EventFilterBy|Filter by all"
msgstr ""
@@ -15543,6 +15546,9 @@ msgstr ""
msgid "Failed to load groups, users and deploy keys."
msgstr ""
+msgid "Failed to load groups."
+msgstr ""
+
msgid "Failed to load iteration cadences."
msgstr ""
@@ -20995,6 +21001,9 @@ msgstr ""
msgid "InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier."
msgstr ""
+msgid "InviteMembersModal|To make more space, you can remove members who no longer need access."
+msgstr ""
+
msgid "InviteMembersModal|Username or email address"
msgstr ""
@@ -21004,6 +21013,9 @@ msgstr ""
msgid "InviteMembersModal|You only have space for %{count} more %{members} in %{name}"
msgstr ""
+msgid "InviteMembersModal|You only have space for %{count} more %{members} in your personal projects"
+msgstr ""
+
msgid "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group."
msgstr ""
@@ -21019,6 +21031,9 @@ msgstr ""
msgid "InviteMembersModal|You've reached your %{count} %{members} limit for %{name}"
msgstr ""
+msgid "InviteMembersModal|You've reached your %{count} %{members} limit for your personal projects"
+msgstr ""
+
msgid "InviteMembers|Invite a group"
msgstr ""
@@ -30785,6 +30800,9 @@ msgstr ""
msgid "ProtectedEnvironment|Environment"
msgstr ""
+msgid "ProtectedEnvironment|Only specified groups can execute deployments in protected environments."
+msgstr ""
+
msgid "ProtectedEnvironment|Only specified users can execute deployments in a protected environment."
msgstr ""
@@ -30803,6 +30821,9 @@ msgstr ""
msgid "ProtectedEnvironment|Select an environment"
msgstr ""
+msgid "ProtectedEnvironment|Select groups"
+msgstr ""
+
msgid "ProtectedEnvironment|Select users"
msgstr ""
@@ -33789,6 +33810,9 @@ msgstr ""
msgid "SecurityOrchestration|%{scanners} %{severities} in an open merge request targeting %{branches}."
msgstr ""
+msgid "SecurityOrchestration|+%{count} more"
+msgstr ""
+
msgid "SecurityOrchestration|.yaml mode"
msgstr ""
@@ -34122,9 +34146,6 @@ msgstr ""
msgid "SecurityOrchestration|vulnerability"
msgstr ""
-msgid "SecurityPolicies|+%{count} more"
-msgstr ""
-
msgid "SecurityReports|%{count} Selected"
msgstr ""
@@ -44169,8 +44190,10 @@ msgstr ""
msgid "Your subscription expired!"
msgstr ""
-msgid "Your subscription has %{remaining_seats_count} out of %{total_seats_count} seats remaining. Even if you reach the number of seats in your subscription, you can continue to add users, and GitLab will bill you for the overage."
-msgstr ""
+msgid "Your subscription has %{remaining_seat_count} out of %{total_seat_count} seat remaining."
+msgid_plural "Your subscription has %{remaining_seat_count} out of %{total_seat_count} seats remaining."
+msgstr[0] ""
+msgstr[1] ""
msgid "Your subscription is now expired. To renew, export your license usage file and email it to %{renewal_service_email}. A new license will be emailed to the email address registered in the %{customers_dot}. You can add this license to your instance. To use Free tier, remove your current license."
msgstr ""
@@ -45885,9 +45908,6 @@ msgstr ""
msgid "repository:"
msgstr ""
-msgid "required"
-msgstr ""
-
msgid "satisfied"
msgstr ""
diff --git a/qa/Dockerfile b/qa/Dockerfile
index f527edbed4a..5d046636984 100644
--- a/qa/Dockerfile
+++ b/qa/Dockerfile
@@ -1,4 +1,7 @@
-FROM registry.gitlab.com/gitlab-org/gitlab-build-images/debian-bullseye-ruby-2.7:bundler-2.3-git-2.33-lfs-2.9-chrome-99-docker-20.10.14-gcloud-383-kubectl-1.23
+ARG DOCKER_VERSION=20.10.14
+ARG CHROME_VERSION=101
+
+FROM registry.gitlab.com/gitlab-org/gitlab-build-images/debian-bullseye-ruby-2.7:bundler-2.3-git-2.33-lfs-2.9-chrome-${CHROME_VERSION}-docker-${DOCKER_VERSION}-gcloud-383-kubectl-1.23
LABEL maintainer="GitLab Quality Department <quality@gitlab.com>"
ENV DEBIAN_FRONTEND="noninteractive"
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index 3f5a55410d2..704171a737b 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -3,1453 +3,7 @@
require 'spec_helper'
RSpec.describe IssuesFinder do
- using RSpec::Parameterized::TableSyntax
include_context 'IssuesFinder context'
- describe '#execute' do
- include_context 'IssuesFinder#execute context'
-
- context 'scope: all' do
- let(:scope) { 'all' }
-
- it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5)
- end
-
- context 'user does not have read permissions' do
- let(:search_user) { user2 }
-
- context 'when filtering by project id' do
- let(:params) { { project_id: project1.id } }
-
- it 'returns no issues' do
- expect(issues).to be_empty
- end
- end
-
- context 'when filtering by group id' do
- let(:params) { { group_id: group.id } }
-
- it 'returns no issues' do
- expect(issues).to be_empty
- end
- end
- end
-
- context 'assignee filtering' do
- let(:issuables) { issues }
-
- it_behaves_like 'assignee ID filter' do
- let(:params) { { assignee_id: user.id } }
- let(:expected_issuables) { [issue1, issue2, issue5] }
- end
-
- it_behaves_like 'assignee NOT ID filter' do
- let(:params) { { not: { assignee_id: user.id } } }
- let(:expected_issuables) { [issue3, issue4] }
- end
-
- it_behaves_like 'assignee OR filter' do
- let(:params) { { or: { assignee_id: [user.id, user2.id] } } }
- let(:expected_issuables) { [issue1, issue2, issue3, issue5] }
- end
-
- context 'when assignee_id does not exist' do
- it_behaves_like 'assignee NOT ID filter' do
- let(:params) { { not: { assignee_id: -100 } } }
- let(:expected_issuables) { [issue1, issue2, issue3, issue4, issue5] }
- end
- end
-
- context 'filter by username' do
- let_it_be(:user3) { create(:user) }
-
- before do
- project2.add_developer(user3)
- issue2.assignees = [user2]
- issue3.assignees = [user3]
- end
-
- it_behaves_like 'assignee username filter' do
- let(:params) { { assignee_username: [user2.username] } }
- let(:expected_issuables) { [issue2] }
- end
-
- it_behaves_like 'assignee NOT username filter' do
- before do
- issue2.assignees = [user2]
- end
-
- let(:params) { { not: { assignee_username: [user.username, user2.username] } } }
- let(:expected_issuables) { [issue3, issue4] }
- end
-
- it_behaves_like 'assignee OR filter' do
- let(:params) { { or: { assignee_username: [user2.username, user3.username] } } }
- let(:expected_issuables) { [issue2, issue3] }
- end
-
- context 'when assignee_username does not exist' do
- it_behaves_like 'assignee NOT username filter' do
- before do
- issue2.assignees = [user2]
- end
-
- let(:params) { { not: { assignee_username: 'non_existent_username' } } }
- let(:expected_issuables) { [issue1, issue2, issue3, issue4, issue5] }
- end
- end
- end
-
- it_behaves_like 'no assignee filter' do
- let_it_be(:user3) { create(:user) }
- let(:expected_issuables) { [issue4] }
- end
-
- it_behaves_like 'any assignee filter' do
- let(:expected_issuables) { [issue1, issue2, issue3, issue5] }
- end
- end
-
- context 'filtering by release' do
- context 'when the release tag is none' do
- let(:params) { { release_tag: 'none' } }
-
- it 'returns issues without releases' do
- expect(issues).to contain_exactly(issue2, issue3, issue4, issue5)
- end
- end
-
- context 'when the release tag exists' do
- let(:params) { { project_id: project1.id, release_tag: release.tag } }
-
- it 'returns the issues associated with that release' do
- expect(issues).to contain_exactly(issue1)
- end
- end
- end
-
- context 'filtering by projects' do
- context 'when projects are passed in a list of ids' do
- let(:params) { { projects: [project1.id] } }
-
- it 'returns the issue belonging to the projects' do
- expect(issues).to contain_exactly(issue1, issue5)
- end
- end
-
- context 'when projects are passed in a subquery' do
- let(:params) { { projects: Project.id_in(project1.id) } }
-
- it 'returns the issue belonging to the projects' do
- expect(issues).to contain_exactly(issue1, issue5)
- end
- end
- end
-
- context 'filtering by group_id' do
- let(:params) { { group_id: group.id } }
-
- context 'when include_subgroup param not set' do
- it 'returns all group issues' do
- expect(issues).to contain_exactly(issue1, issue5)
- end
-
- context 'when projects outside the group are passed' do
- let(:params) { { group_id: group.id, projects: [project2.id] } }
-
- it 'returns no issues' do
- expect(issues).to be_empty
- end
- end
-
- context 'when projects of the group are passed' do
- let(:params) { { group_id: group.id, projects: [project1.id] } }
-
- it 'returns the issue within the group and projects' do
- expect(issues).to contain_exactly(issue1, issue5)
- end
- end
-
- context 'when projects of the group are passed as a subquery' do
- let(:params) { { group_id: group.id, projects: Project.id_in(project1.id) } }
-
- it 'returns the issue within the group and projects' do
- expect(issues).to contain_exactly(issue1, issue5)
- end
- end
-
- context 'when release_tag is passed as a parameter' do
- let(:params) { { group_id: group.id, release_tag: 'dne-release-tag' } }
-
- it 'ignores the release_tag parameter' do
- expect(issues).to contain_exactly(issue1, issue5)
- end
- end
- end
-
- context 'when include_subgroup param is true' do
- before do
- params[:include_subgroups] = true
- end
-
- it 'returns all group and subgroup issues' do
- expect(issues).to contain_exactly(issue1, issue4, issue5)
- end
-
- context 'when mixed projects are passed' do
- let(:params) { { group_id: group.id, projects: [project2.id, project3.id] } }
-
- it 'returns the issue within the group and projects' do
- expect(issues).to contain_exactly(issue4)
- end
- end
- end
- end
-
- context 'filtering by author' do
- context 'by author ID' do
- let(:params) { { author_id: user2.id } }
-
- it 'returns issues created by that user' do
- expect(issues).to contain_exactly(issue3)
- end
- end
-
- context 'using OR' do
- let(:issue6) { create(:issue, project: project2) }
- let(:params) { { or: { author_username: [issue3.author.username, issue6.author.username] } } }
-
- it 'returns issues created by any of the given users' do
- expect(issues).to contain_exactly(issue3, issue6)
- end
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(or_issuable_queries: false)
- end
-
- it 'does not add any filter' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, issue6)
- end
- end
- end
-
- context 'filtering by NOT author ID' do
- let(:params) { { not: { author_id: user2.id } } }
-
- it 'returns issues not created by that user' do
- expect(issues).to contain_exactly(issue1, issue2, issue4, issue5)
- end
- end
-
- context 'filtering by nonexistent author ID and issue term using CTE for search' do
- let(:params) do
- {
- author_id: 'does-not-exist',
- search: 'git',
- attempt_group_search_optimizations: true
- }
- end
-
- it 'returns no results' do
- expect(issues).to be_empty
- end
- end
- end
-
- context 'filtering by milestone' do
- let(:params) { { milestone_title: milestone.title } }
-
- it 'returns issues assigned to that milestone' do
- expect(issues).to contain_exactly(issue1)
- end
- end
-
- context 'filtering by not milestone' do
- let(:params) { { not: { milestone_title: milestone.title } } }
-
- it 'returns issues not assigned to that milestone' do
- expect(issues).to contain_exactly(issue2, issue3, issue4, issue5)
- end
- end
-
- context 'filtering by group milestone' do
- let!(:group) { create(:group, :public) }
- let(:group_milestone) { create(:milestone, group: group) }
- let!(:group_member) { create(:group_member, group: group, user: user) }
- let(:params) { { milestone_title: group_milestone.title } }
-
- before do
- project2.update!(namespace: group)
- issue2.update!(milestone: group_milestone)
- issue3.update!(milestone: group_milestone)
- end
-
- it 'returns issues assigned to that group milestone' do
- expect(issues).to contain_exactly(issue2, issue3)
- end
-
- context 'using NOT' do
- let(:params) { { not: { milestone_title: group_milestone.title } } }
-
- it 'returns issues not assigned to that group milestone' do
- expect(issues).to contain_exactly(issue1, issue4, issue5)
- end
- end
- end
-
- context 'filtering by no milestone' do
- let(:params) { { milestone_title: 'None' } }
-
- it 'returns issues with no milestone' do
- expect(issues).to contain_exactly(issue2, issue3, issue4, issue5)
- end
-
- it 'returns issues with no milestone (deprecated)' do
- params[:milestone_title] = Milestone::None.title
-
- expect(issues).to contain_exactly(issue2, issue3, issue4, issue5)
- end
- end
-
- context 'filtering by any milestone' do
- let(:params) { { milestone_title: 'Any' } }
-
- it 'returns issues with any assigned milestone' do
- expect(issues).to contain_exactly(issue1)
- end
-
- it 'returns issues with any assigned milestone (deprecated)' do
- params[:milestone_title] = Milestone::Any.title
-
- expect(issues).to contain_exactly(issue1)
- end
- end
-
- context 'filtering by upcoming milestone' do
- let(:params) { { milestone_title: Milestone::Upcoming.name } }
-
- let!(:group) { create(:group, :public) }
- let!(:group_member) { create(:group_member, group: group, user: user) }
-
- let(:project_no_upcoming_milestones) { create(:project, :public) }
- let(:project_next_1_1) { create(:project, :public) }
- let(:project_next_8_8) { create(:project, :public) }
- let(:project_in_group) { create(:project, :public, namespace: group) }
-
- let(:yesterday) { Date.current - 1.day }
- let(:tomorrow) { Date.current + 1.day }
- let(:two_days_from_now) { Date.current + 2.days }
- let(:ten_days_from_now) { Date.current + 10.days }
-
- let(:milestones) do
- [
- create(:milestone, :closed, project: project_no_upcoming_milestones),
- create(:milestone, project: project_next_1_1, title: '1.1', due_date: two_days_from_now),
- create(:milestone, project: project_next_1_1, title: '8.9', due_date: ten_days_from_now),
- create(:milestone, project: project_next_8_8, title: '1.2', due_date: yesterday),
- create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow),
- create(:milestone, group: group, title: '9.9', due_date: tomorrow)
- ]
- end
-
- before do
- @created_issues = milestones.map do |milestone|
- create(:issue, project: milestone.project || project_in_group, milestone: milestone, author: user, assignees: [user])
- end
- end
-
- it 'returns issues in the upcoming milestone for each project or group' do
- expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.1', '8.8', '9.9')
- expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now, tomorrow)
- end
-
- context 'using NOT' do
- let(:params) { { not: { milestone_title: Milestone::Upcoming.name } } }
-
- it 'returns issues not in upcoming milestones for each project or group, but must have a due date' do
- target_issues = @created_issues.select do |issue|
- issue.milestone&.due_date && issue.milestone.due_date <= Date.current
- end
-
- expect(issues).to contain_exactly(*target_issues)
- end
- end
- end
-
- context 'filtering by started milestone' do
- let(:params) { { milestone_title: Milestone::Started.name } }
-
- let(:project_no_started_milestones) { create(:project, :public) }
- let(:project_started_1_and_2) { create(:project, :public) }
- let(:project_started_8) { create(:project, :public) }
-
- let(:yesterday) { Date.current - 1.day }
- let(:tomorrow) { Date.current + 1.day }
- let(:two_days_ago) { Date.current - 2.days }
- let(:three_days_ago) { Date.current - 3.days }
-
- let(:milestones) do
- [
- create(:milestone, project: project_no_started_milestones, start_date: tomorrow),
- create(:milestone, project: project_started_1_and_2, title: '1.0', start_date: two_days_ago),
- create(:milestone, project: project_started_1_and_2, title: '2.0', start_date: yesterday),
- create(:milestone, project: project_started_1_and_2, title: '3.0', start_date: tomorrow),
- create(:milestone, :closed, project: project_started_1_and_2, title: '4.0', start_date: three_days_ago),
- create(:milestone, :closed, project: project_started_8, title: '6.0', start_date: three_days_ago),
- create(:milestone, project: project_started_8, title: '7.0'),
- create(:milestone, project: project_started_8, title: '8.0', start_date: yesterday),
- create(:milestone, project: project_started_8, title: '9.0', start_date: tomorrow)
- ]
- end
-
- before do
- milestones.each do |milestone|
- create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user])
- end
- end
-
- it 'returns issues in the started milestones for each project' do
- expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.0', '2.0', '8.0')
- expect(issues.map { |issue| issue.milestone.start_date }).to contain_exactly(two_days_ago, yesterday, yesterday)
- end
-
- context 'using NOT' do
- let(:params) { { not: { milestone_title: Milestone::Started.name } } }
-
- it 'returns issues not in the started milestones for each project' do
- target_issues = Issue.where(milestone: Milestone.not_started)
-
- expect(issues).to contain_exactly(*target_issues)
- end
- end
- end
-
- context 'filtering by label' do
- let(:params) { { label_name: label.title } }
-
- it 'returns issues with that label' do
- expect(issues).to contain_exactly(issue2)
- end
-
- context 'using NOT' do
- let(:params) { { not: { label_name: label.title } } }
-
- it 'returns issues that do not have that label' do
- expect(issues).to contain_exactly(issue1, issue3, issue4, issue5)
- end
-
- # IssuableFinder first filters using the outer params (the ones not inside the `not` key.)
- # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param
- # do not take precedence over the outer params with the same name.
- context 'shadowing the same outside param' do
- let(:params) { { label_name: label2.title, not: { label_name: label.title } } }
-
- it 'does not take precedence over labels outside NOT' do
- expect(issues).to contain_exactly(issue3)
- end
- end
-
- context 'further filtering outside params' do
- let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } }
-
- it 'further filters on the returned resultset' do
- expect(issues).to be_empty
- end
- end
- end
- end
-
- context 'filtering by multiple labels' do
- let(:params) { { label_name: [label.title, label2.title].join(',') } }
- let(:label2) { create(:label, project: project2) }
-
- before do
- create(:label_link, label: label2, target: issue2)
- end
-
- it 'returns the unique issues with all those labels' do
- expect(issues).to contain_exactly(issue2)
- end
-
- context 'using NOT' do
- let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
-
- it 'returns issues that do not have any of the labels provided' do
- expect(issues).to contain_exactly(issue1, issue4, issue5)
- end
- end
- end
-
- context 'filtering by a label that includes any or none in the title' do
- let(:params) { { label_name: [label.title, label2.title].join(',') } }
- let(:label) { create(:label, title: 'any foo', project: project2) }
- let(:label2) { create(:label, title: 'bar none', project: project2) }
-
- before do
- create(:label_link, label: label2, target: issue2)
- end
-
- it 'returns the unique issues with all those labels' do
- expect(issues).to contain_exactly(issue2)
- end
-
- context 'using NOT' do
- let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
-
- it 'returns issues that do not have ANY ONE of the labels provided' do
- expect(issues).to contain_exactly(issue1, issue4, issue5)
- end
- end
- end
-
- context 'filtering by no label' do
- let(:params) { { label_name: described_class::Params::FILTER_NONE } }
-
- it 'returns issues with no labels' do
- expect(issues).to contain_exactly(issue1, issue4, issue5)
- end
- end
-
- context 'filtering by any label' do
- let(:params) { { label_name: described_class::Params::FILTER_ANY } }
-
- it 'returns issues that have one or more label' do
- create_list(:label_link, 2, label: create(:label, project: project2), target: issue3)
-
- expect(issues).to contain_exactly(issue2, issue3)
- end
- end
-
- context 'when the same label exists on project and group levels' do
- let(:issue1) { create(:issue, project: project1) }
- let(:issue2) { create(:issue, project: project1) }
-
- # Skipping validation to reproduce a "real-word" scenario.
- # We still have legacy labels on PRD that have the same title on the group and project levels, example: `bug`
- let(:project_label) { build(:label, title: 'somelabel', project: project1).tap { |r| r.save!(validate: false) } }
- let(:group_label) { create(:group_label, title: 'somelabel', group: project1.group) }
-
- let(:params) { { label_name: 'somelabel' } }
-
- before do
- create(:label_link, label: group_label, target: issue1)
- create(:label_link, label: project_label, target: issue2)
- end
-
- it 'finds both issue records' do
- expect(issues).to contain_exactly(issue1, issue2)
- end
- end
-
- context 'filtering by issue term' do
- let(:params) { { search: search_term } }
-
- let_it_be(:english) { create(:issue, project: project1, title: 'title', description: 'something english') }
- let_it_be(:japanese) { create(:issue, project: project1, title: '日本語 title', description: 'another english description') }
-
- context 'with latin search term' do
- let(:search_term) { 'title english' }
-
- it 'returns matching issues' do
- expect(issues).to contain_exactly(english, japanese)
- end
- end
-
- context 'with non-latin search term' do
- let(:search_term) { '日本語' }
-
- it 'returns matching issues' do
- expect(issues).to contain_exactly(japanese)
- end
- end
-
- context 'when full-text search is disabled' do
- let(:search_term) { 'somet' }
-
- before do
- stub_feature_flags(issues_full_text_search: false)
- end
-
- it 'allows partial word matches' do
- expect(issues).to contain_exactly(english)
- end
- end
-
- context 'with anonymous user' do
- let_it_be(:public_project) { create(:project, :public, group: subgroup) }
- let_it_be(:issue6) { create(:issue, project: public_project, title: 'tanuki') }
- let_it_be(:issue7) { create(:issue, project: public_project, title: 'ikunat') }
-
- let(:search_user) { nil }
- let(:params) { { search: 'tanuki' } }
-
- context 'with disable_anonymous_search feature flag enabled' do
- before do
- stub_feature_flags(disable_anonymous_search: true)
- end
-
- it 'does not perform search' do
- expect(issues).to contain_exactly(issue6, issue7)
- end
- end
-
- context 'with disable_anonymous_search feature flag disabled' do
- before do
- stub_feature_flags(disable_anonymous_search: false)
- end
-
- it 'finds one public issue' do
- expect(issues).to contain_exactly(issue6)
- end
- end
- end
- end
-
- context 'filtering by issue term in title' do
- let(:params) { { search: 'git', in: 'title' } }
-
- it 'returns issues with title match for search term' do
- expect(issues).to contain_exactly(issue1)
- end
- end
-
- context 'filtering by issues iids' do
- let(:params) { { iids: [issue3.iid] } }
-
- it 'returns issues where iids match' do
- expect(issues).to contain_exactly(issue3, issue5)
- end
-
- context 'using NOT' do
- let(:params) { { not: { iids: [issue3.iid] } } }
-
- it 'returns issues with no iids match' do
- expect(issues).to contain_exactly(issue1, issue2, issue4)
- end
- end
- end
-
- context 'filtering by state' do
- context 'with opened' do
- let(:params) { { state: 'opened' } }
-
- it 'returns only opened issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5)
- end
- end
-
- context 'with closed' do
- let(:params) { { state: 'closed' } }
-
- it 'returns only closed issues' do
- expect(issues).to contain_exactly(closed_issue)
- end
- end
-
- context 'with all' do
- let(:params) { { state: 'all' } }
-
- it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, closed_issue, issue4, issue5)
- end
- end
-
- context 'with invalid state' do
- let(:params) { { state: 'invalid_state' } }
-
- it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, closed_issue, issue4, issue5)
- end
- end
- end
-
- context 'filtering by created_at' do
- context 'through created_after' do
- let(:params) { { created_after: issue3.created_at } }
-
- it 'returns issues created on or after the given date' do
- expect(issues).to contain_exactly(issue3)
- end
- end
-
- context 'through created_before' do
- let(:params) { { created_before: issue1.created_at } }
-
- it 'returns issues created on or before the given date' do
- expect(issues).to contain_exactly(issue1)
- end
- end
-
- context 'through created_after and created_before' do
- let(:params) { { created_after: issue2.created_at, created_before: issue3.created_at } }
-
- it 'returns issues created between the given dates' do
- expect(issues).to contain_exactly(issue2, issue3)
- end
- end
- end
-
- context 'filtering by updated_at' do
- context 'through updated_after' do
- let(:params) { { updated_after: issue3.updated_at } }
-
- it 'returns issues updated on or after the given date' do
- expect(issues).to contain_exactly(issue3)
- end
- end
-
- context 'through updated_before' do
- let(:params) { { updated_before: issue1.updated_at } }
-
- it 'returns issues updated on or before the given date' do
- expect(issues).to contain_exactly(issue1)
- end
- end
-
- context 'through updated_after and updated_before' do
- let(:params) { { updated_after: issue2.updated_at, updated_before: issue3.updated_at } }
-
- it 'returns issues updated between the given dates' do
- expect(issues).to contain_exactly(issue2, issue3)
- end
- end
- end
-
- context 'filtering by closed_at' do
- let!(:closed_issue1) { create(:issue, project: project1, state: :closed, closed_at: 1.week.ago) }
- let!(:closed_issue2) { create(:issue, project: project2, state: :closed, closed_at: 1.week.from_now) }
- let!(:closed_issue3) { create(:issue, project: project2, state: :closed, closed_at: 2.weeks.from_now) }
-
- context 'through closed_after' do
- let(:params) { { state: :closed, closed_after: closed_issue3.closed_at } }
-
- it 'returns issues closed on or after the given date' do
- expect(issues).to contain_exactly(closed_issue3)
- end
- end
-
- context 'through closed_before' do
- let(:params) { { state: :closed, closed_before: closed_issue1.closed_at } }
-
- it 'returns issues closed on or before the given date' do
- expect(issues).to contain_exactly(closed_issue1)
- end
- end
-
- context 'through closed_after and closed_before' do
- let(:params) { { state: :closed, closed_after: closed_issue2.closed_at, closed_before: closed_issue3.closed_at } }
-
- it 'returns issues closed between the given dates' do
- expect(issues).to contain_exactly(closed_issue2, closed_issue3)
- end
- end
- end
-
- context 'filtering by reaction name' do
- context 'user searches by no reaction' do
- let(:params) { { my_reaction_emoji: 'None' } }
-
- it 'returns issues that the user did not react to' do
- expect(issues).to contain_exactly(issue2, issue4, issue5)
- end
- end
-
- context 'user searches by any reaction' do
- let(:params) { { my_reaction_emoji: 'Any' } }
-
- it 'returns issues that the user reacted to' do
- expect(issues).to contain_exactly(issue1, issue3)
- end
- end
-
- context 'user searches by "thumbsup" reaction' do
- let(:params) { { my_reaction_emoji: 'thumbsup' } }
-
- it 'returns issues that the user thumbsup to' do
- expect(issues).to contain_exactly(issue1)
- end
-
- context 'using NOT' do
- let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } }
-
- it 'returns issues that the user did not thumbsup to' do
- expect(issues).to contain_exactly(issue2, issue3, issue4, issue5)
- end
- end
- end
-
- context 'user2 searches by "thumbsup" reaction' do
- let(:search_user) { user2 }
-
- let(:params) { { my_reaction_emoji: 'thumbsup' } }
-
- it 'returns issues that the user2 thumbsup to' do
- expect(issues).to contain_exactly(issue2)
- end
-
- context 'using NOT' do
- let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } }
-
- it 'returns issues that the user2 thumbsup to' do
- expect(issues).to contain_exactly(issue3)
- end
- end
- end
-
- context 'user searches by "thumbsdown" reaction' do
- let(:params) { { my_reaction_emoji: 'thumbsdown' } }
-
- it 'returns issues that the user thumbsdown to' do
- expect(issues).to contain_exactly(issue3)
- end
-
- context 'using NOT' do
- let(:params) { { not: { my_reaction_emoji: 'thumbsdown' } } }
-
- it 'returns issues that the user thumbsdown to' do
- expect(issues).to contain_exactly(issue1, issue2, issue4, issue5)
- end
- end
- end
- end
-
- context 'filtering by confidential' do
- let_it_be(:confidential_issue) { create(:issue, project: project1, confidential: true) }
-
- context 'no filtering' do
- it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, confidential_issue)
- end
- end
-
- context 'user filters confidential issues' do
- let(:params) { { confidential: true } }
-
- it 'returns only confidential issues' do
- expect(issues).to contain_exactly(confidential_issue)
- end
- end
-
- context 'user filters only public issues' do
- let(:params) { { confidential: false } }
-
- it 'returns only public issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5)
- end
- end
- end
-
- context 'filtering by issue type' do
- let_it_be(:incident_issue) { create(:incident, project: project1) }
-
- context 'no type given' do
- let(:params) { { issue_types: [] } }
-
- it 'returns all issues' do
- expect(issues).to contain_exactly(incident_issue, issue1, issue2, issue3, issue4, issue5)
- end
- end
-
- context 'incident type' do
- let(:params) { { issue_types: ['incident'] } }
-
- it 'returns incident issues' do
- expect(issues).to contain_exactly(incident_issue)
- end
- end
-
- context 'issue type' do
- let(:params) { { issue_types: ['issue'] } }
-
- it 'returns all issues with type issue' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5)
- end
- end
-
- context 'multiple params' do
- let(:params) { { issue_types: %w(issue incident) } }
-
- it 'returns all issues' do
- expect(issues).to contain_exactly(incident_issue, issue1, issue2, issue3, issue4, issue5)
- end
- end
-
- context 'without array' do
- let(:params) { { issue_types: 'incident' } }
-
- it 'returns incident issues' do
- expect(issues).to contain_exactly(incident_issue)
- end
- end
-
- context 'invalid params' do
- let(:params) { { issue_types: ['nonsense'] } }
-
- it 'returns no issues' do
- expect(issues).to eq(Issue.none)
- end
- end
- end
-
- context 'filtering by crm contact' do
- let_it_be(:contact1) { create(:contact, group: group) }
- let_it_be(:contact2) { create(:contact, group: group) }
-
- let_it_be(:contact1_issue1) { create(:issue, project: project1) }
- let_it_be(:contact1_issue2) { create(:issue, project: project1) }
- let_it_be(:contact2_issue1) { create(:issue, project: project1) }
-
- let(:params) { { crm_contact_id: contact1.id } }
-
- it 'returns for that contact' do
- create(:issue_customer_relations_contact, issue: contact1_issue1, contact: contact1)
- create(:issue_customer_relations_contact, issue: contact1_issue2, contact: contact1)
- create(:issue_customer_relations_contact, issue: contact2_issue1, contact: contact2)
-
- expect(issues).to contain_exactly(contact1_issue1, contact1_issue2)
- end
- end
-
- context 'filtering by crm organization' do
- let_it_be(:organization) { create(:organization, group: group) }
- let_it_be(:contact1) { create(:contact, group: group, organization: organization) }
- let_it_be(:contact2) { create(:contact, group: group, organization: organization) }
-
- let_it_be(:contact1_issue1) { create(:issue, project: project1) }
- let_it_be(:contact1_issue2) { create(:issue, project: project1) }
- let_it_be(:contact2_issue1) { create(:issue, project: project1) }
-
- let(:params) { { crm_organization_id: organization.id } }
-
- it 'returns for that contact' do
- create(:issue_customer_relations_contact, issue: contact1_issue1, contact: contact1)
- create(:issue_customer_relations_contact, issue: contact1_issue2, contact: contact1)
- create(:issue_customer_relations_contact, issue: contact2_issue1, contact: contact2)
-
- expect(issues).to contain_exactly(contact1_issue1, contact1_issue2, contact2_issue1)
- end
- end
-
- context 'when the user is unauthorized' do
- let(:search_user) { nil }
-
- it 'returns no results' do
- expect(issues).to be_empty
- end
- end
-
- context 'when the user can see some, but not all, issues' do
- let(:search_user) { user2 }
-
- it 'returns only issues they can see' do
- expect(issues).to contain_exactly(issue2, issue3)
- end
- end
-
- it 'finds issues user can access due to group' do
- group = create(:group)
- project = create(:project, group: group)
- issue = create(:issue, project: project)
- group.add_user(user, :owner)
-
- expect(issues).to include(issue)
- end
- end
-
- context 'personal scope' do
- let(:scope) { 'assigned_to_me' }
-
- it 'returns issue assigned to the user' do
- expect(issues).to contain_exactly(issue1, issue2, issue5)
- end
-
- context 'filtering by project' do
- let(:params) { { project_id: project1.id } }
-
- it 'returns issues assigned to the user in that project' do
- expect(issues).to contain_exactly(issue1, issue5)
- end
- end
- end
-
- context 'when project restricts issues' do
- let(:scope) { nil }
-
- it "doesn't return team-only issues to non team members" do
- project = create(:project, :public, :issues_private)
- issue = create(:issue, project: project)
-
- expect(issues).not_to include(issue)
- end
-
- it "doesn't return issues if feature disabled" do
- [project1, project2, project3].each do |project|
- project.project_feature.update!(issues_access_level: ProjectFeature::DISABLED)
- end
-
- expect(issues.count).to eq 0
- end
- end
-
- context 'external authorization' do
- it_behaves_like 'a finder with external authorization service' do
- let!(:subject) { create(:issue, project: project) }
- let(:project_params) { { project_id: project.id } }
- end
- end
-
- context 'filtering by due date' do
- let_it_be(:issue_due_today) { create(:issue, project: project1, due_date: Date.current) }
- let_it_be(:issue_due_tomorrow) { create(:issue, project: project1, due_date: 1.day.from_now) }
- let_it_be(:issue_overdue) { create(:issue, project: project1, due_date: 2.days.ago) }
- let_it_be(:issue_due_soon) { create(:issue, project: project1, due_date: 2.days.from_now) }
-
- let(:scope) { 'all' }
- let(:base_params) { { project_id: project1.id } }
-
- context 'with param set to no due date' do
- let(:params) { base_params.merge(due_date: Issue::NoDueDate.name) }
-
- it 'returns issues with no due date' do
- expect(issues).to contain_exactly(issue1, issue5)
- end
- end
-
- context 'with param set to any due date' do
- let(:params) { base_params.merge(due_date: Issue::AnyDueDate.name) }
-
- it 'returns issues with any due date' do
- expect(issues).to contain_exactly(issue_due_today, issue_due_tomorrow, issue_overdue, issue_due_soon)
- end
- end
-
- context 'with param set to due today' do
- let(:params) { base_params.merge(due_date: Issue::DueToday.name) }
-
- it 'returns issues due today' do
- expect(issues).to contain_exactly(issue_due_today)
- end
- end
-
- context 'with param set to due tomorrow' do
- let(:params) { base_params.merge(due_date: Issue::DueTomorrow.name) }
-
- it 'returns issues due today' do
- expect(issues).to contain_exactly(issue_due_tomorrow)
- end
- end
-
- context 'with param set to overdue' do
- let(:params) { base_params.merge(due_date: Issue::Overdue.name) }
-
- it 'returns overdue issues' do
- expect(issues).to contain_exactly(issue_overdue)
- end
- end
-
- context 'with param set to next month and previous two weeks' do
- let(:params) { base_params.merge(due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name) }
-
- it 'returns issues due in the previous two weeks and next month' do
- expect(issues).to contain_exactly(issue_due_today, issue_due_tomorrow, issue_overdue, issue_due_soon)
- end
- end
-
- context 'with invalid param' do
- let(:params) { base_params.merge(due_date: 'foo') }
-
- it 'returns no issues' do
- expect(issues).to be_empty
- end
- end
- end
- end
-
- describe '#row_count', :request_store do
- let_it_be(:admin) { create(:admin) }
-
- context 'when admin mode is enabled', :enable_admin_mode do
- it 'returns the number of rows for the default state' do
- finder = described_class.new(admin)
-
- expect(finder.row_count).to eq(5)
- end
-
- it 'returns the number of rows for a given state' do
- finder = described_class.new(admin, state: 'closed')
-
- expect(finder.row_count).to be_zero
- end
- end
-
- context 'when admin mode is disabled' do
- it 'returns no rows' do
- finder = described_class.new(admin)
-
- expect(finder.row_count).to be_zero
- end
- end
-
- it 'returns -1 if the query times out' do
- finder = described_class.new(admin)
-
- expect_next_instance_of(described_class) do |subfinder|
- expect(subfinder).to receive(:execute).and_raise(ActiveRecord::QueryCanceled)
- end
-
- expect(finder.row_count).to eq(-1)
- end
- end
-
- describe '#with_confidentiality_access_check' do
- let(:guest) { create(:user) }
-
- let_it_be(:authorized_user) { create(:user) }
- let_it_be(:banned_user) { create(:user, :banned) }
- let_it_be(:project) { create(:project, namespace: authorized_user.namespace) }
- let_it_be(:public_issue) { create(:issue, project: project) }
- let_it_be(:confidential_issue) { create(:issue, project: project, confidential: true) }
- let_it_be(:hidden_issue) { create(:issue, project: project, author: banned_user) }
-
- shared_examples 'returns public, does not return hidden or confidential' do
- it 'returns only public issues' do
- expect(subject).to include(public_issue)
- expect(subject).not_to include(confidential_issue, hidden_issue)
- end
- end
-
- shared_examples 'returns public and confidential, does not return hidden' do
- it 'returns only public and confidential issues' do
- expect(subject).to include(public_issue, confidential_issue)
- expect(subject).not_to include(hidden_issue)
- end
- end
-
- shared_examples 'returns public and hidden, does not return confidential' do
- it 'returns only public and hidden issues' do
- expect(subject).to include(public_issue, hidden_issue)
- expect(subject).not_to include(confidential_issue)
- end
- end
-
- shared_examples 'returns public, confidential, and hidden' do
- it 'returns all issues' do
- expect(subject).to include(public_issue, confidential_issue, hidden_issue)
- end
- end
-
- context 'when no project filter is given' do
- let(:params) { {} }
-
- context 'for an anonymous user' do
- subject { described_class.new(nil, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
- end
-
- context 'for a user without project membership' do
- subject { described_class.new(user, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
- end
-
- context 'for a guest user' do
- subject { described_class.new(guest, params).with_confidentiality_access_check }
-
- before do
- project.add_guest(guest)
- end
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
- end
-
- context 'for a project member with access to view confidential issues' do
- subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public and confidential, does not return hidden'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public, confidential, and hidden'
- end
- end
-
- context 'for an admin' do
- let(:admin_user) { create(:user, :admin) }
-
- subject { described_class.new(admin_user, params).with_confidentiality_access_check }
-
- context 'when admin mode is enabled', :enable_admin_mode do
- it_behaves_like 'returns public, confidential, and hidden'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public, confidential, and hidden'
- end
- end
-
- context 'when admin mode is disabled' do
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
- end
- end
- end
-
- context 'when searching within a specific project' do
- let(:params) { { project_id: project.id } }
-
- context 'for an anonymous user' do
- subject { described_class.new(nil, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
-
- it 'does not filter by confidentiality' do
- expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
- subject
- end
- end
-
- context 'for a user without project membership' do
- subject { described_class.new(user, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
-
- it 'filters by confidentiality' do
- expect(subject.to_sql).to match("issues.confidential")
- end
- end
-
- context 'for a guest user' do
- subject { described_class.new(guest, params).with_confidentiality_access_check }
-
- before do
- project.add_guest(guest)
- end
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
-
- it 'filters by confidentiality' do
- expect(subject.to_sql).to match("issues.confidential")
- end
- end
-
- context 'for a project member with access to view confidential issues' do
- subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public and confidential, does not return hidden'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public, confidential, and hidden'
- end
-
- it 'does not filter by confidentiality' do
- expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
-
- subject
- end
- end
-
- context 'for an admin' do
- let(:admin_user) { create(:user, :admin) }
-
- subject { described_class.new(admin_user, params).with_confidentiality_access_check }
-
- context 'when admin mode is enabled', :enable_admin_mode do
- it_behaves_like 'returns public, confidential, and hidden'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public, confidential, and hidden'
- end
-
- it 'does not filter by confidentiality' do
- expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
-
- subject
- end
- end
-
- context 'when admin mode is disabled' do
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
-
- it 'filters by confidentiality' do
- expect(subject.to_sql).to match("issues.confidential")
- end
- end
- end
- end
- end
-
- describe '#use_cte_for_search?' do
- let(:finder) { described_class.new(nil, params) }
-
- context 'when there is no search param' do
- let(:params) { { attempt_group_search_optimizations: true } }
-
- it 'returns false' do
- expect(finder.use_cte_for_search?).to be_falsey
- end
- end
-
- context 'when the force_cte param is falsey' do
- let(:params) { { search: '日本語' } }
-
- it 'returns false' do
- expect(finder.use_cte_for_search?).to be_falsey
- end
- end
-
- context 'when a non-simple sort is given' do
- let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: 'popularity' } }
-
- it 'returns false' do
- expect(finder.use_cte_for_search?).to be_falsey
- end
- end
-
- context 'when all conditions are met' do
- context "uses group search optimization" do
- let(:params) { { search: '日本語', attempt_group_search_optimizations: true } }
-
- it 'returns true' do
- expect(finder.use_cte_for_search?).to be_truthy
- expect(finder.execute.to_sql).to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/)
- end
- end
-
- context "uses project search optimization" do
- let(:params) { { search: '日本語', attempt_project_search_optimizations: true } }
-
- it 'returns true' do
- expect(finder.use_cte_for_search?).to be_truthy
- expect(finder.execute.to_sql).to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/)
- end
- end
-
- context 'with simple sort' do
- let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: 'updated_desc' } }
-
- it 'returns true' do
- expect(finder.use_cte_for_search?).to be_truthy
- expect(finder.execute.to_sql).to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/)
- end
- end
-
- context 'with simple sort as a symbol' do
- let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: :updated_desc } }
-
- it 'returns true' do
- expect(finder.use_cte_for_search?).to be_truthy
- expect(finder.execute.to_sql).to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/)
- end
- end
- end
- end
-
- describe '#parent_param=' do
- let(:finder) { described_class.new(nil) }
-
- subject { finder.parent_param = obj }
-
- where(:klass, :param) do
- :Project | :project_id
- :Group | :group_id
- end
-
- with_them do
- let(:obj) { Object.const_get(klass, false).new }
-
- it 'sets the params' do
- subject
-
- expect(finder.params[param]).to eq(obj)
- end
- end
-
- context 'unexpected parent' do
- let(:obj) { MergeRequest.new }
-
- it 'raises an error' do
- expect { subject }.to raise_error('Unexpected parent: MergeRequest')
- end
- end
- end
+ it_behaves_like 'issues or work items finder', :issue, 'IssuesFinder#execute context'
end
diff --git a/spec/finders/work_items/work_items_finder_spec.rb b/spec/finders/work_items/work_items_finder_spec.rb
new file mode 100644
index 00000000000..fe400688a23
--- /dev/null
+++ b/spec/finders/work_items/work_items_finder_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::WorkItemsFinder do
+ using RSpec::Parameterized::TableSyntax
+ include_context 'WorkItemsFinder context'
+
+ it_behaves_like 'issues or work items finder', :work_item, 'WorkItemsFinder#execute context'
+end
diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js
index 010f7b999fc..cc19e90a5fa 100644
--- a/spec/frontend/invite_members/components/invite_modal_base_spec.js
+++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js
@@ -200,6 +200,30 @@ describe('InviteModalBase', () => {
});
});
+ describe('when user limit is close on a personal namespace', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ closeToLimit: true,
+ reachedLimit: false,
+ usersLimitDataset: { membersPath, userNamespace: true },
+ },
+ { GlModal, GlFormGroup },
+ );
+ });
+
+ it('renders correct buttons', () => {
+ const cancelButton = findCancelButton();
+ const actionButton = findActionButton();
+
+ expect(cancelButton.text()).toBe(INVITE_BUTTON_TEXT_DISABLED);
+ expect(cancelButton.attributes('href')).toBe(membersPath);
+
+ expect(actionButton.text()).toBe(INVITE_BUTTON_TEXT);
+ expect(actionButton.attributes('href')).toBe(); // default submit button
+ });
+ });
+
describe('when users limit is not reached', () => {
const textRegex = /Select a role.+Read more about role permissions Access expiration date \(optional\)/;
diff --git a/spec/frontend/invite_members/components/user_limit_notification_spec.js b/spec/frontend/invite_members/components/user_limit_notification_spec.js
index 4c9adbfcc44..bbc17932a49 100644
--- a/spec/frontend/invite_members/components/user_limit_notification_spec.js
+++ b/spec/frontend/invite_members/components/user_limit_notification_spec.js
@@ -14,9 +14,15 @@ describe('UserLimitNotification', () => {
const findAlert = () => wrapper.findComponent(GlAlert);
- const createComponent = (reachedLimit = false, usersLimitDataset = {}) => {
+ const createComponent = (
+ closeToLimit = false,
+ reachedLimit = false,
+ usersLimitDataset = {},
+ props = {},
+ ) => {
wrapper = shallowMountExtended(UserLimitNotification, {
propsData: {
+ closeToLimit,
reachedLimit,
usersLimitDataset: {
freeUsersLimit,
@@ -25,6 +31,7 @@ describe('UserLimitNotification', () => {
purchasePath: 'purchasePath',
...usersLimitDataset,
},
+ ...props,
},
provide: { name: 'my group' },
stubs: { GlSprintf },
@@ -43,9 +50,26 @@ describe('UserLimitNotification', () => {
});
});
+ describe('when close to limit with a personal namepace', () => {
+ beforeEach(() => {
+ createComponent(true, false, { membersCount: 3, userNamespace: true });
+ });
+
+ it('renders the limit for a personal namespace', () => {
+ const alert = findAlert();
+
+ expect(alert.attributes('title')).toEqual(
+ 'You only have space for 2 more members in your personal projects',
+ );
+ expect(alert.text()).toEqual(
+ 'To make more space, you can remove members who no longer need access.',
+ );
+ });
+ });
+
describe('when close to limit', () => {
it("renders user's limit notification", () => {
- createComponent(false, { membersCount: 3 });
+ createComponent(true, false, { membersCount: 3 });
const alert = findAlert();
@@ -61,7 +85,7 @@ describe('UserLimitNotification', () => {
describe('when limit is reached', () => {
it("renders user's limit notification", () => {
- createComponent(true);
+ createComponent(true, true);
const alert = findAlert();
@@ -71,12 +95,12 @@ describe('UserLimitNotification', () => {
describe('when free user namespace', () => {
it("renders user's limit notification", () => {
- createComponent(true, { userNamespace: true });
+ createComponent(true, true, { userNamespace: true });
const alert = findAlert();
expect(alert.attributes('title')).toEqual(
- "You've reached your 5 members limit for my group",
+ "You've reached your 5 members limit for your personal projects",
);
expect(alert.text()).toEqual(REACHED_LIMIT_MESSAGE);
diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
index 1e693184d73..906f4b560f1 100644
--- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
@@ -373,16 +373,16 @@ describe('Linked pipeline', () => {
describe('expand button', () => {
it.each`
- pipelineType | anglePosition | buttonBorderClasses | expanded
- ${downstreamProps} | ${'angle-right'} | ${'gl-border-l-0!'} | ${false}
- ${downstreamProps} | ${'angle-left'} | ${'gl-border-l-0!'} | ${true}
- ${upstreamProps} | ${'angle-left'} | ${'gl-border-r-0!'} | ${false}
- ${upstreamProps} | ${'angle-right'} | ${'gl-border-r-0!'} | ${true}
+ pipelineType | chevronPosition | buttonBorderClasses | expanded
+ ${downstreamProps} | ${'chevron-lg-right'} | ${'gl-border-l-0!'} | ${false}
+ ${downstreamProps} | ${'chevron-lg-left'} | ${'gl-border-l-0!'} | ${true}
+ ${upstreamProps} | ${'chevron-lg-left'} | ${'gl-border-r-0!'} | ${false}
+ ${upstreamProps} | ${'chevron-lg-right'} | ${'gl-border-r-0!'} | ${true}
`(
- '$pipelineType.columnTitle pipeline button icon should be $anglePosition with $buttonBorderClasses if expanded state is $expanded',
- ({ pipelineType, anglePosition, buttonBorderClasses, expanded }) => {
+ '$pipelineType.columnTitle pipeline button icon should be $chevronPosition with $buttonBorderClasses if expanded state is $expanded',
+ ({ pipelineType, chevronPosition, buttonBorderClasses, expanded }) => {
createWrapper({ propsData: { ...pipelineType, expanded } });
- expect(findExpandButton().props('icon')).toBe(anglePosition);
+ expect(findExpandButton().props('icon')).toBe(chevronPosition);
expect(findExpandButton().classes()).toContain(buttonBorderClasses);
},
);
diff --git a/spec/lib/api/entities/ci/job_request/image_spec.rb b/spec/lib/api/entities/ci/job_request/image_spec.rb
index 55aade03129..3ab14ffc3ae 100644
--- a/spec/lib/api/entities/ci/job_request/image_spec.rb
+++ b/spec/lib/api/entities/ci/job_request/image_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe API::Entities::Ci::JobRequest::Image do
let(:ports) { [{ number: 80, protocol: 'http', name: 'name' }]}
- let(:image) { double(name: 'image_name', entrypoint: ['foo'], ports: ports)}
+ let(:image) { double(name: 'image_name', entrypoint: ['foo'], ports: ports, pull_policy: ['if-not-present']) }
let(:entity) { described_class.new(image) }
subject { entity.as_json }
@@ -28,4 +28,18 @@ RSpec.describe API::Entities::Ci::JobRequest::Image do
expect(subject[:ports]).to be_nil
end
end
+
+ it 'returns the pull policy' do
+ expect(subject[:pull_policy]).to eq(['if-not-present'])
+ end
+
+ context 'when the FF ci_docker_image_pull_policy is disabled' do
+ before do
+ stub_feature_flags(ci_docker_image_pull_policy: false)
+ end
+
+ it 'does not return the pull policy' do
+ expect(subject).not_to have_key(:pull_policy)
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/build/image_spec.rb b/spec/lib/gitlab/ci/build/image_spec.rb
index 630dfcd06bb..8f77a1f60ad 100644
--- a/spec/lib/gitlab/ci/build/image_spec.rb
+++ b/spec/lib/gitlab/ci/build/image_spec.rb
@@ -28,8 +28,14 @@ RSpec.describe Gitlab::Ci::Build::Image do
context 'when image is defined as hash' do
let(:entrypoint) { '/bin/sh' }
+ let(:pull_policy) { %w[always if-not-present] }
- let(:job) { create(:ci_build, options: { image: { name: image_name, entrypoint: entrypoint, ports: [80] } } ) }
+ let(:job) do
+ create(:ci_build, options: { image: { name: image_name,
+ entrypoint: entrypoint,
+ ports: [80],
+ pull_policy: pull_policy } } )
+ end
it 'fabricates an object of the proper class' do
is_expected.to be_kind_of(described_class)
@@ -38,6 +44,7 @@ RSpec.describe Gitlab::Ci::Build::Image do
it 'populates fabricated object with the proper attributes' do
expect(subject.name).to eq(image_name)
expect(subject.entrypoint).to eq(entrypoint)
+ expect(subject.pull_policy).to eq(pull_policy)
end
it 'populates the ports' do
diff --git a/spec/lib/gitlab/ci/config/entry/image_spec.rb b/spec/lib/gitlab/ci/config/entry/image_spec.rb
index e16a9a7a74a..bd1ab5d8c41 100644
--- a/spec/lib/gitlab/ci/config/entry/image_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/image_spec.rb
@@ -1,8 +1,16 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'fast_spec_helper'
+require 'support/helpers/stubbed_feature'
+require 'support/helpers/stub_feature_flags'
RSpec.describe Gitlab::Ci::Config::Entry::Image do
+ include StubFeatureFlags
+
+ before do
+ stub_feature_flags(ci_docker_image_pull_policy: true)
+ end
+
let(:entry) { described_class.new(config) }
context 'when configuration is a string' do
@@ -43,6 +51,12 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
expect(entry.ports).to be_nil
end
end
+
+ describe '#pull_policy' do
+ it "returns nil" do
+ expect(entry.pull_policy).to be_nil
+ end
+ end
end
context 'when configuration is a hash' do
@@ -109,6 +123,56 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
end
end
end
+
+ context 'when configuration has pull_policy' do
+ let(:config) { { name: 'image:1.0', pull_policy: 'if-not-present' } }
+
+ describe '#valid?' do
+ it 'is valid' do
+ entry.compose!
+
+ expect(entry).to be_valid
+ end
+
+ context 'when the feature flag ci_docker_image_pull_policy is disabled' do
+ before do
+ stub_feature_flags(ci_docker_image_pull_policy: false)
+ end
+
+ it 'is not valid' do
+ entry.compose!
+
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include('image config contains unknown keys: pull_policy')
+ end
+ end
+ end
+
+ describe '#value' do
+ it "returns value" do
+ entry.compose!
+
+ expect(entry.value).to eq(
+ name: 'image:1.0',
+ pull_policy: ['if-not-present']
+ )
+ end
+
+ context 'when the feature flag ci_docker_image_pull_policy is disabled' do
+ before do
+ stub_feature_flags(ci_docker_image_pull_policy: false)
+ end
+
+ it 'is not valid' do
+ entry.compose!
+
+ expect(entry.value).to eq(
+ name: 'image:1.0'
+ )
+ end
+ end
+ end
+ end
end
context 'when entry value is not correct' do
diff --git a/spec/lib/gitlab/ci/config/entry/pull_policy_spec.rb b/spec/lib/gitlab/ci/config/entry/pull_policy_spec.rb
new file mode 100644
index 00000000000..c35355b10c6
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/entry/pull_policy_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Entry::PullPolicy do
+ let(:entry) { described_class.new(config) }
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ context 'when config value is nil' do
+ let(:config) { nil }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when retry value is an empty array' do
+ let(:config) { [] }
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'when retry value is string' do
+ let(:config) { "always" }
+
+ it { is_expected.to eq(%w[always]) }
+ end
+
+ context 'when retry value is array' do
+ let(:config) { %w[always if-not-present] }
+
+ it { is_expected.to eq(%w[always if-not-present]) }
+ end
+ end
+
+ describe 'validation' do
+ subject(:valid?) { entry.valid? }
+
+ context 'when retry value is nil' do
+ let(:config) { nil }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when retry value is an empty array' do
+ let(:config) { [] }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when retry value is a hash' do
+ let(:config) { {} }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when retry value is string' do
+ let(:config) { "always" }
+
+ it { is_expected.to eq(true) }
+
+ context 'when it is an invalid policy' do
+ let(:config) { "invalid" }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when it is an empty string' do
+ let(:config) { "" }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'when retry value is array' do
+ let(:config) { %w[always if-not-present] }
+
+ it { is_expected.to eq(true) }
+
+ context 'when config contains an invalid policy' do
+ let(:config) { %w[always invalid] }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index 1910057622b..3dd9ca35881 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -7,7 +7,7 @@ module Gitlab
RSpec.describe YamlProcessor do
include StubRequests
- subject { described_class.new(config, user: nil).execute }
+ subject(:processor) { described_class.new(config, user: nil).execute }
shared_examples 'returns errors' do |error_message|
it 'adds a message when an error is encountered' do
@@ -965,6 +965,51 @@ module Gitlab
})
end
end
+
+ context 'when image has pull_policy' do
+ let(:config) do
+ <<~YAML
+ image:
+ name: ruby:2.7
+ pull_policy: if-not-present
+
+ test:
+ script: exit 0
+ YAML
+ end
+
+ it { is_expected.to be_valid }
+
+ it "returns image and service when defined" do
+ expect(processor.stage_builds_attributes("test")).to contain_exactly({
+ stage: "test",
+ stage_idx: 2,
+ name: "test",
+ only: { refs: %w[branches tags] },
+ options: {
+ script: ["exit 0"],
+ image: { name: "ruby:2.7", pull_policy: ["if-not-present"] }
+ },
+ allow_failure: false,
+ when: "on_success",
+ job_variables: [],
+ root_variables_inheritance: true,
+ scheduling_type: :stage
+ })
+ end
+
+ context 'when the feature flag ci_docker_image_pull_policy is disabled' do
+ before do
+ stub_feature_flags(ci_docker_image_pull_policy: false)
+ end
+
+ it { is_expected.not_to be_valid }
+
+ it "returns no job" do
+ expect(processor.jobs).to eq({})
+ end
+ end
+ end
end
describe 'Variables' do
diff --git a/spec/lib/gitlab/import_export/lfs_saver_spec.rb b/spec/lib/gitlab/import_export/lfs_saver_spec.rb
index 84bd782c467..aa456736f78 100644
--- a/spec/lib/gitlab/import_export/lfs_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/lfs_saver_spec.rb
@@ -45,6 +45,18 @@ RSpec.describe Gitlab::ImportExport::LfsSaver do
expect(File).to exist("#{shared.export_path}/lfs-objects/#{lfs_object.oid}")
end
+ context 'when lfs object has file on disk missing' do
+ it 'does not attempt to copy non-existent file' do
+ FileUtils.rm(lfs_object.file.path)
+ expect(saver).not_to receive(:copy_files)
+
+ saver.save # rubocop:disable Rails/SaveBang
+
+ expect(shared.errors).to be_empty
+ expect(File).not_to exist("#{shared.export_path}/lfs-objects/#{lfs_object.oid}")
+ end
+ end
+
describe 'saving a json file' do
before do
# Create two more LfsObjectProject records with different `repository_type`s
diff --git a/spec/models/concerns/limitable_spec.rb b/spec/models/concerns/limitable_spec.rb
index 850282d54c7..c0a6aea2075 100644
--- a/spec/models/concerns/limitable_spec.rb
+++ b/spec/models/concerns/limitable_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe Limitable do
it 'triggers scoped validations' do
instance = MinimalTestClass.new
- expect(instance).to receive(:validate_scoped_plan_limit_not_exceeded)
+ expect(instance).to receive(:scoped_plan_limits)
instance.valid?(:create)
end
@@ -94,7 +94,7 @@ RSpec.describe Limitable do
it 'triggers scoped validations' do
instance = MinimalTestClass.new
- expect(instance).to receive(:validate_global_plan_limit_not_exceeded)
+ expect(instance).to receive(:global_plan_limits)
instance.valid?(:create)
end
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 5fe3882cee9..fd89a3a2e22 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -587,22 +587,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
expect(action.user).to eq(user)
end
- context 'env_stopped_on_stop_success feature flag' do
- it 'environment is not stopped when flag is enabled' do
- stub_feature_flags(env_stopped_on_stop_success: true)
-
- subject
-
- expect(environment).not_to be_stopped
- end
-
- it 'environment is stopped when flag is disabled' do
- stub_feature_flags(env_stopped_on_stop_success: false)
-
- subject
+ it 'environment is not stopped' do
+ subject
- expect(environment).to be_stopped
- end
+ expect(environment).not_to be_stopped
end
end
diff --git a/spec/models/plan_limits_spec.rb b/spec/models/plan_limits_spec.rb
index 381e42978f4..78521e4bdf2 100644
--- a/spec/models/plan_limits_spec.rb
+++ b/spec/models/plan_limits_spec.rb
@@ -215,6 +215,7 @@ RSpec.describe PlanLimits do
web_hook_calls
ci_daily_pipeline_schedule_triggers
repository_size
+ security_policy_scan_execution_schedules
] + disabled_max_artifact_size_columns
end
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index 23e4641e0d5..dfb625abc1b 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe ProjectPolicy do
end
end
- it 'does not include the read_issue permission when the issue author is not a member of the private project' do
+ it 'does not include the read permissions when the issue author is not a member of the private project' do
project = create(:project, :private)
issue = create(:issue, project: project, author: create(:user))
user = issue.author
@@ -40,6 +40,7 @@ RSpec.describe ProjectPolicy do
expect(project.team.member?(issue.author)).to be false
expect(Ability).not_to be_allowed(user, :read_issue, project)
+ expect(Ability).not_to be_allowed(user, :read_work_item, project)
end
it_behaves_like 'model with wiki policies' do
@@ -61,7 +62,7 @@ RSpec.describe ProjectPolicy do
end
it 'does not include the issues permissions' do
- expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident, :create_work_item, :create_task
+ expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident, :create_work_item, :create_task, :read_work_item
end
it 'disables boards and lists permissions' do
@@ -73,7 +74,7 @@ RSpec.describe ProjectPolicy do
it 'does not include the issues permissions' do
create(:jira_integration, project: project)
- expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident, :create_work_item, :create_task
+ expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident, :create_work_item, :create_task, :read_work_item
end
end
end
@@ -752,14 +753,14 @@ RSpec.describe ProjectPolicy do
allow(project).to receive(:service_desk_enabled?).and_return(true)
end
- it { expect_allowed(:reporter_access, :create_note, :read_issue) }
+ it { expect_allowed(:reporter_access, :create_note, :read_issue, :read_work_item) }
context 'when issues are protected members only' do
before do
project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
end
- it { expect_allowed(:reporter_access, :create_note, :read_issue) }
+ it { expect_allowed(:reporter_access, :create_note, :read_issue, :read_work_item) }
end
end
end
diff --git a/spec/policies/work_item_policy_spec.rb b/spec/policies/work_item_policy_spec.rb
index b19f7d2557d..9cfc4455979 100644
--- a/spec/policies/work_item_policy_spec.rb
+++ b/spec/policies/work_item_policy_spec.rb
@@ -37,6 +37,12 @@ RSpec.describe WorkItemPolicy do
let(:current_user) { guest_author }
it { is_expected.to be_allowed(:read_work_item) }
+
+ context 'when work_item is confidential' do
+ let(:work_item_subject) { create(:work_item, confidential: true, project: project) }
+
+ it { is_expected.not_to be_allowed(:read_work_item) }
+ end
end
end
diff --git a/spec/requests/api/ci/runner/jobs_request_post_spec.rb b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
index dbc5f0e74e2..3c6f9ac2816 100644
--- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
@@ -216,7 +216,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
expect(json_response['token']).to eq(job.token)
expect(json_response['job_info']).to eq(expected_job_info)
expect(json_response['git_info']).to eq(expected_git_info)
- expect(json_response['image']).to eq({ 'name' => 'image:1.0', 'entrypoint' => '/bin/sh', 'ports' => [] })
+ expect(json_response['image']).to eq({ 'name' => 'image:1.0', 'entrypoint' => '/bin/sh', 'ports' => [], 'pull_policy' => nil })
expect(json_response['services']).to eq([{ 'name' => 'postgres', 'entrypoint' => nil,
'alias' => nil, 'command' => nil, 'ports' => [], 'variables' => nil },
{ 'name' => 'docker:stable-dind', 'entrypoint' => '/bin/sh',
@@ -810,6 +810,45 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
end
+ context 'when image has pull_policy' do
+ let(:job) { create(:ci_build, :pending, :queued, pipeline: pipeline, options: options) }
+
+ let(:options) do
+ {
+ image: {
+ name: 'ruby',
+ pull_policy: ['if-not-present']
+ }
+ }
+ end
+
+ it 'returns the image with pull policy' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to include(
+ 'id' => job.id,
+ 'image' => { 'name' => 'ruby', 'pull_policy' => ['if-not-present'], 'entrypoint' => nil, 'ports' => [] }
+ )
+ end
+
+ context 'when the FF ci_docker_image_pull_policy is disabled' do
+ before do
+ stub_feature_flags(ci_docker_image_pull_policy: false)
+ end
+
+ it 'returns the image without pull policy' do
+ request_job
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to include(
+ 'id' => job.id,
+ 'image' => { 'name' => 'ruby', 'entrypoint' => nil, 'ports' => [] }
+ )
+ end
+ end
+ end
+
describe 'a job with excluded artifacts' do
context 'when excluded paths are defined' do
let(:job) do
diff --git a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
index 5ae54ed309b..894789c7941 100644
--- a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
+++ b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
@@ -53,6 +53,18 @@ RSpec.describe BulkImports::LfsObjectsExportService do
)
end
+ context 'when lfs object has file on disk missing' do
+ it 'does not attempt to copy non-existent file' do
+ FileUtils.rm(lfs_object.file.path)
+
+ expect(service).not_to receive(:copy_files)
+
+ service.execute
+
+ expect(File).not_to exist(File.join(export_path, lfs_object.oid))
+ end
+ end
+
context 'when lfs object is remotely stored' do
let(:lfs_object) { create(:lfs_object, :object_storage) }
diff --git a/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb
index d9cbea58406..afb3976e3b8 100644
--- a/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb
+++ b/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb
@@ -12,7 +12,7 @@ RSpec.shared_context 'IssuesFinder context' do
let_it_be(:milestone) { create(:milestone, project: project1, releases: [release]) }
let_it_be(:label) { create(:label, project: project2) }
let_it_be(:label2) { create(:label, project: project2) }
- let_it_be(:issue1, reload: true) do
+ let_it_be(:item1, reload: true) do
create(:issue,
author: user,
assignees: [user],
@@ -23,7 +23,7 @@ RSpec.shared_context 'IssuesFinder context' do
updated_at: 1.week.ago)
end
- let_it_be(:issue2, reload: true) do
+ let_it_be(:item2, reload: true) do
create(:issue,
author: user,
assignees: [user],
@@ -33,7 +33,7 @@ RSpec.shared_context 'IssuesFinder context' do
updated_at: 1.week.from_now)
end
- let_it_be(:issue3, reload: true) do
+ let_it_be(:item3, reload: true) do
create(:issue,
author: user2,
assignees: [user2],
@@ -44,8 +44,8 @@ RSpec.shared_context 'IssuesFinder context' do
updated_at: 2.weeks.from_now)
end
- let_it_be(:issue4, reload: true) { create(:issue, project: project3) }
- let_it_be(:issue5, reload: true) do
+ let_it_be(:item4, reload: true) { create(:issue, project: project3) }
+ let_it_be(:item5, reload: true) do
create(:issue,
author: user,
assignees: [user],
@@ -55,18 +55,20 @@ RSpec.shared_context 'IssuesFinder context' do
updated_at: 3.days.ago)
end
- let_it_be(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: issue1) }
- let_it_be(:award_emoji2) { create(:award_emoji, name: 'thumbsup', user: user2, awardable: issue2) }
- let_it_be(:award_emoji3) { create(:award_emoji, name: 'thumbsdown', user: user, awardable: issue3) }
+ let_it_be(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: item1) }
+ let_it_be(:award_emoji2) { create(:award_emoji, name: 'thumbsup', user: user2, awardable: item2) }
+ let_it_be(:award_emoji3) { create(:award_emoji, name: 'thumbsdown', user: user, awardable: item3) }
+
+ let(:items_model) { Issue }
end
RSpec.shared_context 'IssuesFinder#execute context' do
- let!(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') }
- let!(:label_link) { create(:label_link, label: label, target: issue2) }
- let!(:label_link2) { create(:label_link, label: label2, target: issue3) }
+ let!(:closed_item) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') }
+ let!(:label_link) { create(:label_link, label: label, target: item2) }
+ let!(:label_link2) { create(:label_link, label: label2, target: item3) }
let(:search_user) { user }
let(:params) { {} }
- let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute }
+ let(:items) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute }
before_all do
project1.add_maintainer(user)
diff --git a/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb
new file mode 100644
index 00000000000..8c5bc339db5
--- /dev/null
+++ b/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'WorkItemsFinder context' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:project1, reload: true) { create(:project, group: group) }
+ let_it_be(:project2, reload: true) { create(:project) }
+ let_it_be(:project3, reload: true) { create(:project, group: subgroup) }
+ let_it_be(:release) { create(:release, project: project1, tag: 'v1.0.0') }
+ let_it_be(:milestone) { create(:milestone, project: project1, releases: [release]) }
+ let_it_be(:label) { create(:label, project: project2) }
+ let_it_be(:label2) { create(:label, project: project2) }
+ let_it_be(:item1, reload: true) do
+ create(:work_item,
+ author: user,
+ assignees: [user],
+ project: project1,
+ milestone: milestone,
+ title: 'gitlab',
+ created_at: 1.week.ago,
+ updated_at: 1.week.ago)
+ end
+
+ let_it_be(:item2, reload: true) do
+ create(:work_item,
+ author: user,
+ assignees: [user],
+ project: project2,
+ description: 'gitlab',
+ created_at: 1.week.from_now,
+ updated_at: 1.week.from_now)
+ end
+
+ let_it_be(:item3, reload: true) do
+ create(:work_item,
+ author: user2,
+ assignees: [user2],
+ project: project2,
+ title: 'tanuki',
+ description: 'tanuki',
+ created_at: 2.weeks.from_now,
+ updated_at: 2.weeks.from_now)
+ end
+
+ let_it_be(:item4, reload: true) { create(:work_item, project: project3) }
+ let_it_be(:item5, reload: true) do
+ create(:work_item,
+ author: user,
+ assignees: [user],
+ project: project1,
+ title: 'wotnot',
+ created_at: 3.days.ago,
+ updated_at: 3.days.ago)
+ end
+
+ let_it_be(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: item1) }
+ let_it_be(:award_emoji2) { create(:award_emoji, name: 'thumbsup', user: user2, awardable: item2) }
+ let_it_be(:award_emoji3) { create(:award_emoji, name: 'thumbsdown', user: user, awardable: item3) }
+
+ let(:items_model) { WorkItem }
+end
+
+RSpec.shared_context 'WorkItemsFinder#execute context' do
+ let!(:closed_item) { create(:work_item, author: user2, assignees: [user2], project: project2, state: 'closed') }
+ let!(:label_link) { create(:label_link, label: label, target: item2) }
+ let!(:label_link2) { create(:label_link, label: label2, target: item3) }
+ let(:search_user) { user }
+ let(:params) { {} }
+ let(:items) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute }
+
+ before_all do
+ project1.add_maintainer(user)
+ project2.add_developer(user)
+ project2.add_developer(user2)
+ project3.add_developer(user)
+ end
+end
diff --git a/spec/support/shared_examples/finders/issues_finder_shared_examples.rb b/spec/support/shared_examples/finders/issues_finder_shared_examples.rb
new file mode 100644
index 00000000000..622a88e8323
--- /dev/null
+++ b/spec/support/shared_examples/finders/issues_finder_shared_examples.rb
@@ -0,0 +1,1471 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'issues or work items finder' do |factory, execute_context|
+ describe '#execute' do
+ include_context execute_context
+
+ context 'scope: all' do
+ let(:scope) { 'all' }
+
+ it 'returns all items' do
+ expect(items).to contain_exactly(item1, item2, item3, item4, item5)
+ end
+
+ context 'user does not have read permissions' do
+ let(:search_user) { user2 }
+
+ context 'when filtering by project id' do
+ let(:params) { { project_id: project1.id } }
+
+ it 'returns no items' do
+ expect(items).to be_empty
+ end
+ end
+
+ context 'when filtering by group id' do
+ let(:params) { { group_id: group.id } }
+
+ it 'returns no items' do
+ expect(items).to be_empty
+ end
+ end
+ end
+
+ context 'assignee filtering' do
+ let(:issuables) { items }
+
+ it_behaves_like 'assignee ID filter' do
+ let(:params) { { assignee_id: user.id } }
+ let(:expected_issuables) { [item1, item2, item5] }
+ end
+
+ it_behaves_like 'assignee NOT ID filter' do
+ let(:params) { { not: { assignee_id: user.id } } }
+ let(:expected_issuables) { [item3, item4] }
+ end
+
+ it_behaves_like 'assignee OR filter' do
+ let(:params) { { or: { assignee_id: [user.id, user2.id] } } }
+ let(:expected_issuables) { [item1, item2, item3, item5] }
+ end
+
+ context 'when assignee_id does not exist' do
+ it_behaves_like 'assignee NOT ID filter' do
+ let(:params) { { not: { assignee_id: -100 } } }
+ let(:expected_issuables) { [item1, item2, item3, item4, item5] }
+ end
+ end
+
+ context 'filter by username' do
+ let_it_be(:user3) { create(:user) }
+
+ before do
+ project2.add_developer(user3)
+ item2.assignees = [user2]
+ item3.assignees = [user3]
+ end
+
+ it_behaves_like 'assignee username filter' do
+ let(:params) { { assignee_username: [user2.username] } }
+ let(:expected_issuables) { [item2] }
+ end
+
+ it_behaves_like 'assignee NOT username filter' do
+ before do
+ item2.assignees = [user2]
+ end
+
+ let(:params) { { not: { assignee_username: [user.username, user2.username] } } }
+ let(:expected_issuables) { [item3, item4] }
+ end
+
+ it_behaves_like 'assignee OR filter' do
+ let(:params) { { or: { assignee_username: [user2.username, user3.username] } } }
+ let(:expected_issuables) { [item2, item3] }
+ end
+
+ context 'when assignee_username does not exist' do
+ it_behaves_like 'assignee NOT username filter' do
+ before do
+ item2.assignees = [user2]
+ end
+
+ let(:params) { { not: { assignee_username: 'non_existent_username' } } }
+ let(:expected_issuables) { [item1, item2, item3, item4, item5] }
+ end
+ end
+ end
+
+ it_behaves_like 'no assignee filter' do
+ let_it_be(:user3) { create(:user) }
+ let(:expected_issuables) { [item4] }
+ end
+
+ it_behaves_like 'any assignee filter' do
+ let(:expected_issuables) { [item1, item2, item3, item5] }
+ end
+ end
+
+ context 'filtering by release' do
+ context 'when the release tag is none' do
+ let(:params) { { release_tag: 'none' } }
+
+ it 'returns items without releases' do
+ expect(items).to contain_exactly(item2, item3, item4, item5)
+ end
+ end
+
+ context 'when the release tag exists' do
+ let(:params) { { project_id: project1.id, release_tag: release.tag } }
+
+ it 'returns the items associated with that release' do
+ expect(items).to contain_exactly(item1)
+ end
+ end
+ end
+
+ context 'filtering by projects' do
+ context 'when projects are passed in a list of ids' do
+ let(:params) { { projects: [project1.id] } }
+
+ it 'returns the item belonging to the projects' do
+ expect(items).to contain_exactly(item1, item5)
+ end
+ end
+
+ context 'when projects are passed in a subquery' do
+ let(:params) { { projects: Project.id_in(project1.id) } }
+
+ it 'returns the item belonging to the projects' do
+ expect(items).to contain_exactly(item1, item5)
+ end
+ end
+ end
+
+ context 'filtering by group_id' do
+ let(:params) { { group_id: group.id } }
+
+ context 'when include_subgroup param not set' do
+ it 'returns all group items' do
+ expect(items).to contain_exactly(item1, item5)
+ end
+
+ context 'when projects outside the group are passed' do
+ let(:params) { { group_id: group.id, projects: [project2.id] } }
+
+ it 'returns no items' do
+ expect(items).to be_empty
+ end
+ end
+
+ context 'when projects of the group are passed' do
+ let(:params) { { group_id: group.id, projects: [project1.id] } }
+
+ it 'returns the item within the group and projects' do
+ expect(items).to contain_exactly(item1, item5)
+ end
+ end
+
+ context 'when projects of the group are passed as a subquery' do
+ let(:params) { { group_id: group.id, projects: Project.id_in(project1.id) } }
+
+ it 'returns the item within the group and projects' do
+ expect(items).to contain_exactly(item1, item5)
+ end
+ end
+
+ context 'when release_tag is passed as a parameter' do
+ let(:params) { { group_id: group.id, release_tag: 'dne-release-tag' } }
+
+ it 'ignores the release_tag parameter' do
+ expect(items).to contain_exactly(item1, item5)
+ end
+ end
+ end
+
+ context 'when include_subgroup param is true' do
+ before do
+ params[:include_subgroups] = true
+ end
+
+ it 'returns all group and subgroup items' do
+ expect(items).to contain_exactly(item1, item4, item5)
+ end
+
+ context 'when mixed projects are passed' do
+ let(:params) { { group_id: group.id, projects: [project2.id, project3.id] } }
+
+ it 'returns the item within the group and projects' do
+ expect(items).to contain_exactly(item4)
+ end
+ end
+ end
+ end
+
+ context 'filtering by author' do
+ context 'by author ID' do
+ let(:params) { { author_id: user2.id } }
+
+ it 'returns items created by that user' do
+ expect(items).to contain_exactly(item3)
+ end
+ end
+
+ context 'using OR' do
+ let(:item6) { create(factory, project: project2) }
+ let(:params) { { or: { author_username: [item3.author.username, item6.author.username] } } }
+
+ it 'returns items created by any of the given users' do
+ expect(items).to contain_exactly(item3, item6)
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(or_issuable_queries: false)
+ end
+
+ it 'does not add any filter' do
+ expect(items).to contain_exactly(item1, item2, item3, item4, item5, item6)
+ end
+ end
+ end
+
+ context 'filtering by NOT author ID' do
+ let(:params) { { not: { author_id: user2.id } } }
+
+ it 'returns items not created by that user' do
+ expect(items).to contain_exactly(item1, item2, item4, item5)
+ end
+ end
+
+ context 'filtering by nonexistent author ID and issue term using CTE for search' do
+ let(:params) do
+ {
+ author_id: 'does-not-exist',
+ search: 'git',
+ attempt_group_search_optimizations: true
+ }
+ end
+
+ it 'returns no results' do
+ expect(items).to be_empty
+ end
+ end
+ end
+
+ context 'filtering by milestone' do
+ let(:params) { { milestone_title: milestone.title } }
+
+ it 'returns items assigned to that milestone' do
+ expect(items).to contain_exactly(item1)
+ end
+ end
+
+ context 'filtering by not milestone' do
+ let(:params) { { not: { milestone_title: milestone.title } } }
+
+ it 'returns items not assigned to that milestone' do
+ expect(items).to contain_exactly(item2, item3, item4, item5)
+ end
+ end
+
+ context 'filtering by group milestone' do
+ let!(:group) { create(:group, :public) }
+ let(:group_milestone) { create(:milestone, group: group) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+ let(:params) { { milestone_title: group_milestone.title } }
+
+ before do
+ project2.update!(namespace: group)
+ item2.update!(milestone: group_milestone)
+ item3.update!(milestone: group_milestone)
+ end
+
+ it 'returns items assigned to that group milestone' do
+ expect(items).to contain_exactly(item2, item3)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { milestone_title: group_milestone.title } } }
+
+ it 'returns items not assigned to that group milestone' do
+ expect(items).to contain_exactly(item1, item4, item5)
+ end
+ end
+ end
+
+ context 'filtering by no milestone' do
+ let(:params) { { milestone_title: 'None' } }
+
+ it 'returns items with no milestone' do
+ expect(items).to contain_exactly(item2, item3, item4, item5)
+ end
+
+ it 'returns items with no milestone (deprecated)' do
+ params[:milestone_title] = Milestone::None.title
+
+ expect(items).to contain_exactly(item2, item3, item4, item5)
+ end
+ end
+
+ context 'filtering by any milestone' do
+ let(:params) { { milestone_title: 'Any' } }
+
+ it 'returns items with any assigned milestone' do
+ expect(items).to contain_exactly(item1)
+ end
+
+ it 'returns items with any assigned milestone (deprecated)' do
+ params[:milestone_title] = Milestone::Any.title
+
+ expect(items).to contain_exactly(item1)
+ end
+ end
+
+ context 'filtering by upcoming milestone' do
+ let(:params) { { milestone_title: Milestone::Upcoming.name } }
+
+ let!(:group) { create(:group, :public) }
+ let!(:group_member) { create(:group_member, group: group, user: user) }
+
+ let(:project_no_upcoming_milestones) { create(:project, :public) }
+ let(:project_next_1_1) { create(:project, :public) }
+ let(:project_next_8_8) { create(:project, :public) }
+ let(:project_in_group) { create(:project, :public, namespace: group) }
+
+ let(:yesterday) { Date.current - 1.day }
+ let(:tomorrow) { Date.current + 1.day }
+ let(:two_days_from_now) { Date.current + 2.days }
+ let(:ten_days_from_now) { Date.current + 10.days }
+
+ let(:milestones) do
+ [
+ create(:milestone, :closed, project: project_no_upcoming_milestones),
+ create(:milestone, project: project_next_1_1, title: '1.1', due_date: two_days_from_now),
+ create(:milestone, project: project_next_1_1, title: '8.9', due_date: ten_days_from_now),
+ create(:milestone, project: project_next_8_8, title: '1.2', due_date: yesterday),
+ create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow),
+ create(:milestone, group: group, title: '9.9', due_date: tomorrow)
+ ]
+ end
+
+ let!(:created_items) do
+ milestones.map do |milestone|
+ create(factory, project: milestone.project || project_in_group,
+ milestone: milestone, author: user, assignees: [user])
+ end
+ end
+
+ it 'returns items in the upcoming milestone for each project or group' do
+ expect(items.map { |item| item.milestone.title })
+ .to contain_exactly('1.1', '8.8', '9.9')
+ expect(items.map { |item| item.milestone.due_date })
+ .to contain_exactly(tomorrow, two_days_from_now, tomorrow)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { milestone_title: Milestone::Upcoming.name } } }
+
+ it 'returns items not in upcoming milestones for each project or group, but must have a due date' do
+ target_items = created_items.select do |item|
+ item.milestone&.due_date && item.milestone.due_date <= Date.current
+ end
+
+ expect(items).to contain_exactly(*target_items)
+ end
+ end
+ end
+
+ context 'filtering by started milestone' do
+ let(:params) { { milestone_title: Milestone::Started.name } }
+
+ let(:project_no_started_milestones) { create(:project, :public) }
+ let(:project_started_1_and_2) { create(:project, :public) }
+ let(:project_started_8) { create(:project, :public) }
+
+ let(:yesterday) { Date.current - 1.day }
+ let(:tomorrow) { Date.current + 1.day }
+ let(:two_days_ago) { Date.current - 2.days }
+ let(:three_days_ago) { Date.current - 3.days }
+
+ let(:milestones) do
+ [
+ create(:milestone, project: project_no_started_milestones, start_date: tomorrow),
+ create(:milestone, project: project_started_1_and_2, title: '1.0', start_date: two_days_ago),
+ create(:milestone, project: project_started_1_and_2, title: '2.0', start_date: yesterday),
+ create(:milestone, project: project_started_1_and_2, title: '3.0', start_date: tomorrow),
+ create(:milestone, :closed, project: project_started_1_and_2, title: '4.0', start_date: three_days_ago),
+ create(:milestone, :closed, project: project_started_8, title: '6.0', start_date: three_days_ago),
+ create(:milestone, project: project_started_8, title: '7.0'),
+ create(:milestone, project: project_started_8, title: '8.0', start_date: yesterday),
+ create(:milestone, project: project_started_8, title: '9.0', start_date: tomorrow)
+ ]
+ end
+
+ before do
+ milestones.each do |milestone|
+ create(factory, project: milestone.project, milestone: milestone, author: user, assignees: [user])
+ end
+ end
+
+ it 'returns items in the started milestones for each project' do
+ expect(items.map { |item| item.milestone.title })
+ .to contain_exactly('1.0', '2.0', '8.0')
+ expect(items.map { |item| item.milestone.start_date })
+ .to contain_exactly(two_days_ago, yesterday, yesterday)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { milestone_title: Milestone::Started.name } } }
+
+ it 'returns items not in the started milestones for each project' do
+ target_items = items_model.where(milestone: Milestone.not_started)
+
+ expect(items).to contain_exactly(*target_items)
+ end
+ end
+ end
+
+ context 'filtering by label' do
+ let(:params) { { label_name: label.title } }
+
+ it 'returns items with that label' do
+ expect(items).to contain_exactly(item2)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { label_name: label.title } } }
+
+ it 'returns items that do not have that label' do
+ expect(items).to contain_exactly(item1, item3, item4, item5)
+ end
+
+ # IssuableFinder first filters using the outer params (the ones not inside the `not` key.)
+ # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param
+ # do not take precedence over the outer params with the same name.
+ context 'shadowing the same outside param' do
+ let(:params) { { label_name: label2.title, not: { label_name: label.title } } }
+
+ it 'does not take precedence over labels outside NOT' do
+ expect(items).to contain_exactly(item3)
+ end
+ end
+
+ context 'further filtering outside params' do
+ let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } }
+
+ it 'further filters on the returned resultset' do
+ expect(items).to be_empty
+ end
+ end
+ end
+ end
+
+ context 'filtering by multiple labels' do
+ let(:params) { { label_name: [label.title, label2.title].join(',') } }
+ let(:label2) { create(:label, project: project2) }
+
+ before do
+ create(:label_link, label: label2, target: item2)
+ end
+
+ it 'returns the unique items with all those labels' do
+ expect(items).to contain_exactly(item2)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
+
+ it 'returns items that do not have any of the labels provided' do
+ expect(items).to contain_exactly(item1, item4, item5)
+ end
+ end
+ end
+
+ context 'filtering by a label that includes any or none in the title' do
+ let(:params) { { label_name: [label.title, label2.title].join(',') } }
+ let(:label) { create(:label, title: 'any foo', project: project2) }
+ let(:label2) { create(:label, title: 'bar none', project: project2) }
+
+ before do
+ create(:label_link, label: label2, target: item2)
+ end
+
+ it 'returns the unique items with all those labels' do
+ expect(items).to contain_exactly(item2)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
+
+ it 'returns items that do not have ANY ONE of the labels provided' do
+ expect(items).to contain_exactly(item1, item4, item5)
+ end
+ end
+ end
+
+ context 'filtering by no label' do
+ let(:params) { { label_name: described_class::Params::FILTER_NONE } }
+
+ it 'returns items with no labels' do
+ expect(items).to contain_exactly(item1, item4, item5)
+ end
+ end
+
+ context 'filtering by any label' do
+ let(:params) { { label_name: described_class::Params::FILTER_ANY } }
+
+ it 'returns items that have one or more label' do
+ create_list(:label_link, 2, label: create(:label, project: project2), target: item3)
+
+ expect(items).to contain_exactly(item2, item3)
+ end
+ end
+
+ context 'when the same label exists on project and group levels' do
+ let(:item1) { create(factory, project: project1) }
+ let(:item2) { create(factory, project: project1) }
+
+ # Skipping validation to reproduce a "real-word" scenario.
+ # We still have legacy labels on PRD that have the same title on the group and project levels, example: `bug`
+ let(:project_label) do
+ build(:label, title: 'somelabel', project: project1).tap { |r| r.save!(validate: false) }
+ end
+
+ let(:group_label) { create(:group_label, title: 'somelabel', group: project1.group) }
+
+ let(:params) { { label_name: 'somelabel' } }
+
+ before do
+ create(:label_link, label: group_label, target: item1)
+ create(:label_link, label: project_label, target: item2)
+ end
+
+ it 'finds both item records' do
+ expect(items).to contain_exactly(item1, item2)
+ end
+ end
+
+ context 'filtering by item term' do
+ let(:params) { { search: search_term } }
+
+ let_it_be(:english) { create(factory, project: project1, title: 'title', description: 'something english') }
+
+ let_it_be(:japanese) do
+ create(factory, project: project1, title: '日本語 title', description: 'another english description')
+ end
+
+ context 'with latin search term' do
+ let(:search_term) { 'title english' }
+
+ it 'returns matching items' do
+ expect(items).to contain_exactly(english, japanese)
+ end
+ end
+
+ context 'with non-latin search term' do
+ let(:search_term) { '日本語' }
+
+ it 'returns matching items' do
+ expect(items).to contain_exactly(japanese)
+ end
+ end
+
+ context 'when full-text search is disabled' do
+ let(:search_term) { 'somet' }
+
+ before do
+ stub_feature_flags(issues_full_text_search: false)
+ end
+
+ it 'allows partial word matches' do
+ expect(items).to contain_exactly(english)
+ end
+ end
+
+ context 'with anonymous user' do
+ let_it_be(:public_project) { create(:project, :public, group: subgroup) }
+ let_it_be(:item6) { create(factory, project: public_project, title: 'tanuki') }
+ let_it_be(:item7) { create(factory, project: public_project, title: 'ikunat') }
+
+ let(:search_user) { nil }
+ let(:params) { { search: 'tanuki' } }
+
+ context 'with disable_anonymous_search feature flag enabled' do
+ before do
+ stub_feature_flags(disable_anonymous_search: true)
+ end
+
+ it 'does not perform search' do
+ expect(items).to contain_exactly(item6, item7)
+ end
+ end
+
+ context 'with disable_anonymous_search feature flag disabled' do
+ before do
+ stub_feature_flags(disable_anonymous_search: false)
+ end
+
+ it 'finds one public item' do
+ expect(items).to contain_exactly(item6)
+ end
+ end
+ end
+ end
+
+ context 'filtering by item term in title' do
+ let(:params) { { search: 'git', in: 'title' } }
+
+ it 'returns items with title match for search term' do
+ expect(items).to contain_exactly(item1)
+ end
+ end
+
+ context 'filtering by items iids' do
+ let(:params) { { iids: [item3.iid] } }
+
+ it 'returns items where iids match' do
+ expect(items).to contain_exactly(item3, item5)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { iids: [item3.iid] } } }
+
+ it 'returns items with no iids match' do
+ expect(items).to contain_exactly(item1, item2, item4)
+ end
+ end
+ end
+
+ context 'filtering by state' do
+ context 'with opened' do
+ let(:params) { { state: 'opened' } }
+
+ it 'returns only opened items' do
+ expect(items).to contain_exactly(item1, item2, item3, item4, item5)
+ end
+ end
+
+ context 'with closed' do
+ let(:params) { { state: 'closed' } }
+
+ it 'returns only closed items' do
+ expect(items).to contain_exactly(closed_item)
+ end
+ end
+
+ context 'with all' do
+ let(:params) { { state: 'all' } }
+
+ it 'returns all items' do
+ expect(items).to contain_exactly(item1, item2, item3, closed_item, item4, item5)
+ end
+ end
+
+ context 'with invalid state' do
+ let(:params) { { state: 'invalid_state' } }
+
+ it 'returns all items' do
+ expect(items).to contain_exactly(item1, item2, item3, closed_item, item4, item5)
+ end
+ end
+ end
+
+ context 'filtering by created_at' do
+ context 'through created_after' do
+ let(:params) { { created_after: item3.created_at } }
+
+ it 'returns items created on or after the given date' do
+ expect(items).to contain_exactly(item3)
+ end
+ end
+
+ context 'through created_before' do
+ let(:params) { { created_before: item1.created_at } }
+
+ it 'returns items created on or before the given date' do
+ expect(items).to contain_exactly(item1)
+ end
+ end
+
+ context 'through created_after and created_before' do
+ let(:params) { { created_after: item2.created_at, created_before: item3.created_at } }
+
+ it 'returns items created between the given dates' do
+ expect(items).to contain_exactly(item2, item3)
+ end
+ end
+ end
+
+ context 'filtering by updated_at' do
+ context 'through updated_after' do
+ let(:params) { { updated_after: item3.updated_at } }
+
+ it 'returns items updated on or after the given date' do
+ expect(items).to contain_exactly(item3)
+ end
+ end
+
+ context 'through updated_before' do
+ let(:params) { { updated_before: item1.updated_at } }
+
+ it 'returns items updated on or before the given date' do
+ expect(items).to contain_exactly(item1)
+ end
+ end
+
+ context 'through updated_after and updated_before' do
+ let(:params) { { updated_after: item2.updated_at, updated_before: item3.updated_at } }
+
+ it 'returns items updated between the given dates' do
+ expect(items).to contain_exactly(item2, item3)
+ end
+ end
+ end
+
+ context 'filtering by closed_at' do
+ let!(:closed_item1) { create(factory, project: project1, state: :closed, closed_at: 1.week.ago) }
+ let!(:closed_item2) { create(factory, project: project2, state: :closed, closed_at: 1.week.from_now) }
+ let!(:closed_item3) { create(factory, project: project2, state: :closed, closed_at: 2.weeks.from_now) }
+
+ context 'through closed_after' do
+ let(:params) { { state: :closed, closed_after: closed_item3.closed_at } }
+
+ it 'returns items closed on or after the given date' do
+ expect(items).to contain_exactly(closed_item3)
+ end
+ end
+
+ context 'through closed_before' do
+ let(:params) { { state: :closed, closed_before: closed_item1.closed_at } }
+
+ it 'returns items closed on or before the given date' do
+ expect(items).to contain_exactly(closed_item1)
+ end
+ end
+
+ context 'through closed_after and closed_before' do
+ let(:params) do
+ { state: :closed, closed_after: closed_item2.closed_at, closed_before: closed_item3.closed_at }
+ end
+
+ it 'returns items closed between the given dates' do
+ expect(items).to contain_exactly(closed_item2, closed_item3)
+ end
+ end
+ end
+
+ context 'filtering by reaction name' do
+ context 'user searches by no reaction' do
+ let(:params) { { my_reaction_emoji: 'None' } }
+
+ it 'returns items that the user did not react to' do
+ expect(items).to contain_exactly(item2, item4, item5)
+ end
+ end
+
+ context 'user searches by any reaction' do
+ let(:params) { { my_reaction_emoji: 'Any' } }
+
+ it 'returns items that the user reacted to' do
+ expect(items).to contain_exactly(item1, item3)
+ end
+ end
+
+ context 'user searches by "thumbsup" reaction' do
+ let(:params) { { my_reaction_emoji: 'thumbsup' } }
+
+ it 'returns items that the user thumbsup to' do
+ expect(items).to contain_exactly(item1)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } }
+
+ it 'returns items that the user did not thumbsup to' do
+ expect(items).to contain_exactly(item2, item3, item4, item5)
+ end
+ end
+ end
+
+ context 'user2 searches by "thumbsup" reaction' do
+ let(:search_user) { user2 }
+
+ let(:params) { { my_reaction_emoji: 'thumbsup' } }
+
+ it 'returns items that the user2 thumbsup to' do
+ expect(items).to contain_exactly(item2)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } }
+
+ it 'returns items that the user2 thumbsup to' do
+ expect(items).to contain_exactly(item3)
+ end
+ end
+ end
+
+ context 'user searches by "thumbsdown" reaction' do
+ let(:params) { { my_reaction_emoji: 'thumbsdown' } }
+
+ it 'returns items that the user thumbsdown to' do
+ expect(items).to contain_exactly(item3)
+ end
+
+ context 'using NOT' do
+ let(:params) { { not: { my_reaction_emoji: 'thumbsdown' } } }
+
+ it 'returns items that the user thumbsdown to' do
+ expect(items).to contain_exactly(item1, item2, item4, item5)
+ end
+ end
+ end
+ end
+
+ context 'filtering by confidential' do
+ let_it_be(:confidential_item) { create(factory, project: project1, confidential: true) }
+
+ context 'no filtering' do
+ it 'returns all items' do
+ expect(items).to contain_exactly(item1, item2, item3, item4, item5, confidential_item)
+ end
+ end
+
+ context 'user filters confidential items' do
+ let(:params) { { confidential: true } }
+
+ it 'returns only confidential items' do
+ expect(items).to contain_exactly(confidential_item)
+ end
+ end
+
+ context 'user filters only public items' do
+ let(:params) { { confidential: false } }
+
+ it 'returns only public items' do
+ expect(items).to contain_exactly(item1, item2, item3, item4, item5)
+ end
+ end
+ end
+
+ context 'filtering by item type' do
+ let_it_be(:incident_item) { create(factory, issue_type: :incident, project: project1) }
+
+ context 'no type given' do
+ let(:params) { { issue_types: [] } }
+
+ it 'returns all items' do
+ expect(items).to contain_exactly(incident_item, item1, item2, item3, item4, item5)
+ end
+ end
+
+ context 'incident type' do
+ let(:params) { { issue_types: ['incident'] } }
+
+ it 'returns incident items' do
+ expect(items).to contain_exactly(incident_item)
+ end
+ end
+
+ context 'item type' do
+ let(:params) { { issue_types: ['issue'] } }
+
+ it 'returns all items with type issue' do
+ expect(items).to contain_exactly(item1, item2, item3, item4, item5)
+ end
+ end
+
+ context 'multiple params' do
+ let(:params) { { issue_types: %w(issue incident) } }
+
+ it 'returns all items' do
+ expect(items).to contain_exactly(incident_item, item1, item2, item3, item4, item5)
+ end
+ end
+
+ context 'without array' do
+ let(:params) { { issue_types: 'incident' } }
+
+ it 'returns incident items' do
+ expect(items).to contain_exactly(incident_item)
+ end
+ end
+
+ context 'invalid params' do
+ let(:params) { { issue_types: ['nonsense'] } }
+
+ it 'returns no items' do
+ expect(items).to eq(items_model.none)
+ end
+ end
+ end
+
+ context 'filtering by crm contact' do
+ let_it_be(:contact1) { create(:contact, group: group) }
+ let_it_be(:contact2) { create(:contact, group: group) }
+
+ let_it_be(:contact1_item1) { create(factory, project: project1) }
+ let_it_be(:contact1_item2) { create(factory, project: project1) }
+ let_it_be(:contact2_item1) { create(factory, project: project1) }
+
+ let(:params) { { crm_contact_id: contact1.id } }
+
+ it 'returns for that contact' do
+ create(:issue_customer_relations_contact, issue: contact1_item1, contact: contact1)
+ create(:issue_customer_relations_contact, issue: contact1_item2, contact: contact1)
+ create(:issue_customer_relations_contact, issue: contact2_item1, contact: contact2)
+
+ expect(items).to contain_exactly(contact1_item1, contact1_item2)
+ end
+ end
+
+ context 'filtering by crm organization' do
+ let_it_be(:organization) { create(:organization, group: group) }
+ let_it_be(:contact1) { create(:contact, group: group, organization: organization) }
+ let_it_be(:contact2) { create(:contact, group: group, organization: organization) }
+
+ let_it_be(:contact1_item1) { create(factory, project: project1) }
+ let_it_be(:contact1_item2) { create(factory, project: project1) }
+ let_it_be(:contact2_item1) { create(factory, project: project1) }
+
+ let(:params) { { crm_organization_id: organization.id } }
+
+ it 'returns for that contact' do
+ create(:issue_customer_relations_contact, issue: contact1_item1, contact: contact1)
+ create(:issue_customer_relations_contact, issue: contact1_item2, contact: contact1)
+ create(:issue_customer_relations_contact, issue: contact2_item1, contact: contact2)
+
+ expect(items).to contain_exactly(contact1_item1, contact1_item2, contact2_item1)
+ end
+ end
+
+ context 'when the user is unauthorized' do
+ let(:search_user) { nil }
+
+ it 'returns no results' do
+ expect(items).to be_empty
+ end
+ end
+
+ context 'when the user can see some, but not all, items' do
+ let(:search_user) { user2 }
+
+ it 'returns only items they can see' do
+ expect(items).to contain_exactly(item2, item3)
+ end
+ end
+
+ it 'finds items user can access due to group' do
+ group = create(:group)
+ project = create(:project, group: group)
+ item = create(factory, project: project)
+ group.add_user(user, :owner)
+
+ expect(items).to include(item)
+ end
+ end
+
+ context 'personal scope' do
+ let(:scope) { 'assigned_to_me' }
+
+ it 'returns item assigned to the user' do
+ expect(items).to contain_exactly(item1, item2, item5)
+ end
+
+ context 'filtering by project' do
+ let(:params) { { project_id: project1.id } }
+
+ it 'returns items assigned to the user in that project' do
+ expect(items).to contain_exactly(item1, item5)
+ end
+ end
+ end
+
+ context 'when project restricts items' do
+ let(:scope) { nil }
+
+ it "doesn't return team-only items to non team members" do
+ project = create(:project, :public, :issues_private)
+ item = create(factory, project: project)
+
+ expect(items).not_to include(item)
+ end
+
+ it "doesn't return items if feature disabled" do
+ [project1, project2, project3].each do |project|
+ project.project_feature.update!(issues_access_level: ProjectFeature::DISABLED)
+ end
+
+ expect(items.count).to eq 0
+ end
+ end
+
+ context 'external authorization' do
+ it_behaves_like 'a finder with external authorization service' do
+ let!(:subject) { create(factory, project: project) }
+ let(:project_params) { { project_id: project.id } }
+ end
+ end
+
+ context 'filtering by due date' do
+ let_it_be(:item_due_today) { create(factory, project: project1, due_date: Date.current) }
+ let_it_be(:item_due_tomorrow) { create(factory, project: project1, due_date: 1.day.from_now) }
+ let_it_be(:item_overdue) { create(factory, project: project1, due_date: 2.days.ago) }
+ let_it_be(:item_due_soon) { create(factory, project: project1, due_date: 2.days.from_now) }
+
+ let(:scope) { 'all' }
+ let(:base_params) { { project_id: project1.id } }
+
+ context 'with param set to no due date' do
+ let(:params) { base_params.merge(due_date: items_model::NoDueDate.name) }
+
+ it 'returns items with no due date' do
+ expect(items).to contain_exactly(item1, item5)
+ end
+ end
+
+ context 'with param set to any due date' do
+ let(:params) { base_params.merge(due_date: items_model::AnyDueDate.name) }
+
+ it 'returns items with any due date' do
+ expect(items).to contain_exactly(item_due_today, item_due_tomorrow, item_overdue, item_due_soon)
+ end
+ end
+
+ context 'with param set to due today' do
+ let(:params) { base_params.merge(due_date: items_model::DueToday.name) }
+
+ it 'returns items due today' do
+ expect(items).to contain_exactly(item_due_today)
+ end
+ end
+
+ context 'with param set to due tomorrow' do
+ let(:params) { base_params.merge(due_date: items_model::DueTomorrow.name) }
+
+ it 'returns items due today' do
+ expect(items).to contain_exactly(item_due_tomorrow)
+ end
+ end
+
+ context 'with param set to overdue' do
+ let(:params) { base_params.merge(due_date: items_model::Overdue.name) }
+
+ it 'returns overdue items' do
+ expect(items).to contain_exactly(item_overdue)
+ end
+ end
+
+ context 'with param set to next month and previous two weeks' do
+ let(:params) { base_params.merge(due_date: items_model::DueNextMonthAndPreviousTwoWeeks.name) }
+
+ it 'returns items due in the previous two weeks and next month' do
+ expect(items).to contain_exactly(item_due_today, item_due_tomorrow, item_overdue, item_due_soon)
+ end
+ end
+
+ context 'with invalid param' do
+ let(:params) { base_params.merge(due_date: 'foo') }
+
+ it 'returns no items' do
+ expect(items).to be_empty
+ end
+ end
+ end
+ end
+
+ describe '#row_count', :request_store do
+ let_it_be(:admin) { create(:admin) }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'returns the number of rows for the default state' do
+ finder = described_class.new(admin)
+
+ expect(finder.row_count).to eq(5)
+ end
+
+ it 'returns the number of rows for a given state' do
+ finder = described_class.new(admin, state: 'closed')
+
+ expect(finder.row_count).to be_zero
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'returns no rows' do
+ finder = described_class.new(admin)
+
+ expect(finder.row_count).to be_zero
+ end
+ end
+
+ it 'returns -1 if the query times out' do
+ finder = described_class.new(admin)
+
+ expect_next_instance_of(described_class) do |subfinder|
+ expect(subfinder).to receive(:execute).and_raise(ActiveRecord::QueryCanceled)
+ end
+
+ expect(finder.row_count).to eq(-1)
+ end
+ end
+
+ describe '#with_confidentiality_access_check' do
+ let(:guest) { create(:user) }
+
+ let_it_be(:authorized_user) { create(:user) }
+ let_it_be(:banned_user) { create(:user, :banned) }
+ let_it_be(:project) { create(:project, namespace: authorized_user.namespace) }
+ let_it_be(:public_item) { create(factory, project: project) }
+ let_it_be(:confidential_item) { create(factory, project: project, confidential: true) }
+ let_it_be(:hidden_item) { create(factory, project: project, author: banned_user) }
+
+ shared_examples 'returns public, does not return hidden or confidential' do
+ it 'returns only public items' do
+ expect(subject).to include(public_item)
+ expect(subject).not_to include(confidential_item, hidden_item)
+ end
+ end
+
+ shared_examples 'returns public and confidential, does not return hidden' do
+ it 'returns only public and confidential items' do
+ expect(subject).to include(public_item, confidential_item)
+ expect(subject).not_to include(hidden_item)
+ end
+ end
+
+ shared_examples 'returns public and hidden, does not return confidential' do
+ it 'returns only public and hidden items' do
+ expect(subject).to include(public_item, hidden_item)
+ expect(subject).not_to include(confidential_item)
+ end
+ end
+
+ shared_examples 'returns public, confidential, and hidden' do
+ it 'returns all items' do
+ expect(subject).to include(public_item, confidential_item, hidden_item)
+ end
+ end
+
+ context 'when no project filter is given' do
+ let(:params) { {} }
+
+ context 'for an anonymous user' do
+ subject { described_class.new(nil, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
+ end
+
+ context 'for a user without project membership' do
+ subject { described_class.new(user, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
+ end
+
+ context 'for a guest user' do
+ subject { described_class.new(guest, params).with_confidentiality_access_check }
+
+ before do
+ project.add_guest(guest)
+ end
+
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
+ end
+
+ context 'for a project member with access to view confidential items' do
+ subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public and confidential, does not return hidden'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public, confidential, and hidden'
+ end
+ end
+
+ context 'for an admin' do
+ let(:admin_user) { create(:user, :admin) }
+
+ subject { described_class.new(admin_user, params).with_confidentiality_access_check }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it_behaves_like 'returns public, confidential, and hidden'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public, confidential, and hidden'
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
+ end
+ end
+ end
+
+ context 'when searching within a specific project' do
+ let(:params) { { project_id: project.id } }
+
+ context 'for an anonymous user' do
+ subject { described_class.new(nil, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
+
+ it 'does not filter by confidentiality' do
+ expect(items_model).not_to receive(:where).with(a_string_matching('confidential'), anything)
+ subject
+ end
+ end
+
+ context 'for a user without project membership' do
+ subject { described_class.new(user, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
+
+ it 'filters by confidentiality' do
+ expect(subject.to_sql).to match("issues.confidential")
+ end
+ end
+
+ context 'for a guest user' do
+ subject { described_class.new(guest, params).with_confidentiality_access_check }
+
+ before do
+ project.add_guest(guest)
+ end
+
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
+
+ it 'filters by confidentiality' do
+ expect(subject.to_sql).to match("issues.confidential")
+ end
+ end
+
+ context 'for a project member with access to view confidential items' do
+ subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
+
+ it_behaves_like 'returns public and confidential, does not return hidden'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public, confidential, and hidden'
+ end
+
+ it 'does not filter by confidentiality' do
+ expect(items_model).not_to receive(:where).with(a_string_matching('confidential'), anything)
+
+ subject
+ end
+ end
+
+ context 'for an admin' do
+ let(:admin_user) { create(:user, :admin) }
+
+ subject { described_class.new(admin_user, params).with_confidentiality_access_check }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it_behaves_like 'returns public, confidential, and hidden'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public, confidential, and hidden'
+ end
+
+ it 'does not filter by confidentiality' do
+ expect(items_model).not_to receive(:where).with(a_string_matching('confidential'), anything)
+
+ subject
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it_behaves_like 'returns public, does not return hidden or confidential'
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it_behaves_like 'returns public and hidden, does not return confidential'
+ end
+
+ it 'filters by confidentiality' do
+ expect(subject.to_sql).to match("issues.confidential")
+ end
+ end
+ end
+ end
+ end
+
+ describe '#use_cte_for_search?' do
+ let(:finder) { described_class.new(nil, params) }
+
+ context 'when there is no search param' do
+ let(:params) { { attempt_group_search_optimizations: true } }
+
+ it 'returns false' do
+ expect(finder.use_cte_for_search?).to be_falsey
+ end
+ end
+
+ context 'when the force_cte param is falsey' do
+ let(:params) { { search: '日本語' } }
+
+ it 'returns false' do
+ expect(finder.use_cte_for_search?).to be_falsey
+ end
+ end
+
+ context 'when a non-simple sort is given' do
+ let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: 'popularity' } }
+
+ it 'returns false' do
+ expect(finder.use_cte_for_search?).to be_falsey
+ end
+ end
+
+ context 'when all conditions are met' do
+ context "uses group search optimization" do
+ let(:params) { { search: '日本語', attempt_group_search_optimizations: true } }
+
+ it 'returns true' do
+ expect(finder.use_cte_for_search?).to be_truthy
+ expect(finder.execute.to_sql)
+ .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/)
+ end
+ end
+
+ context "uses project search optimization" do
+ let(:params) { { search: '日本語', attempt_project_search_optimizations: true } }
+
+ it 'returns true' do
+ expect(finder.use_cte_for_search?).to be_truthy
+ expect(finder.execute.to_sql)
+ .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/)
+ end
+ end
+
+ context 'with simple sort' do
+ let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: 'updated_desc' } }
+
+ it 'returns true' do
+ expect(finder.use_cte_for_search?).to be_truthy
+ expect(finder.execute.to_sql)
+ .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/)
+ end
+ end
+
+ context 'with simple sort as a symbol' do
+ let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: :updated_desc } }
+
+ it 'returns true' do
+ expect(finder.use_cte_for_search?).to be_truthy
+ expect(finder.execute.to_sql)
+ .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/)
+ end
+ end
+ end
+ end
+
+ describe '#parent_param=' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:finder) { described_class.new(nil) }
+
+ subject { finder.parent_param = obj }
+
+ where(:klass, :param) do
+ :Project | :project_id
+ :Group | :group_id
+ end
+
+ with_them do
+ let(:obj) { Object.const_get(klass, false).new }
+
+ it 'sets the params' do
+ subject
+
+ expect(finder.params[param]).to eq(obj)
+ end
+ end
+
+ context 'unexpected parent' do
+ let(:obj) { MergeRequest.new }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error('Unexpected parent: MergeRequest')
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb
index 0ff0895b861..3d393e6dcb5 100644
--- a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb
@@ -1,6 +1,30 @@
# frozen_string_literal: true
RSpec.shared_examples 'includes Limitable concern' do
+ describe '#exceeds_limits?' do
+ let(:plan_limits) { create(:plan_limits, :default_plan) }
+
+ context 'without plan limits configured' do
+ it { expect(subject.exceeds_limits?).to eq false }
+ end
+
+ context 'without plan limits configured' do
+ before do
+ plan_limits.update!(subject.class.limit_name => 1)
+ end
+
+ it { expect(subject.exceeds_limits?).to eq false }
+
+ context 'with an existing model' do
+ before do
+ subject.clone.save!
+ end
+
+ it { expect(subject.exceeds_limits?).to eq true }
+ end
+ end
+ end
+
describe 'validations' do
let(:plan_limits) { create(:plan_limits, :default_plan) }
diff --git a/spec/tasks/gitlab/db/validate_config_rake_spec.rb b/spec/tasks/gitlab/db/validate_config_rake_spec.rb
index 0b2c844a91f..b4bad3dd7b9 100644
--- a/spec/tasks/gitlab/db/validate_config_rake_spec.rb
+++ b/spec/tasks/gitlab/db/validate_config_rake_spec.rb
@@ -3,6 +3,10 @@
require 'rake_helper'
RSpec.describe 'gitlab:db:validate_config', :silence_stdout do
+ # We don't need to delete this data since it only modifies `ar_internal_metadata`
+ # which would not be cleaned either by `DbCleaner`
+ self.use_transactional_tests = false
+
before :all do
Rake.application.rake_require 'active_record/railties/databases'
Rake.application.rake_require 'tasks/seed_fu'
@@ -111,6 +115,26 @@ RSpec.describe 'gitlab:db:validate_config', :silence_stdout do
end
it_behaves_like 'validates successfully'
+
+ context 'when config is pointing to incorrect server' do
+ let(:test_config) do
+ {
+ main: main_database_config.merge(port: 11235)
+ }
+ end
+
+ it_behaves_like 'validates successfully'
+ end
+
+ context 'when config is pointing to non-existent database' do
+ let(:test_config) do
+ {
+ main: main_database_config.merge(database: 'non_existent_database')
+ }
+ end
+
+ it_behaves_like 'validates successfully'
+ end
end
context 'when main: uses database_tasks=false' do
diff --git a/spec/workers/build_success_worker_spec.rb b/spec/workers/build_success_worker_spec.rb
index 2456acef53f..3241c931dc5 100644
--- a/spec/workers/build_success_worker_spec.rb
+++ b/spec/workers/build_success_worker_spec.rb
@@ -28,22 +28,10 @@ RSpec.describe BuildSuccessWorker do
it 'does not stop the environment' do
expect(environment).to be_available
- stub_feature_flags(env_stopped_on_stop_success: true)
-
subject
expect(environment.reload).not_to be_stopped
end
-
- it 'does stop the environment when feature flag is disabled' do
- expect(environment).to be_available
-
- stub_feature_flags(env_stopped_on_stop_success: false)
-
- subject
-
- expect(environment.reload).to be_stopped
- end
end
end
end