diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-08 15:08:46 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-08 15:08:46 +0300 |
commit | cdda3d117c99cadf295f26abc92cb2456033b762 (patch) | |
tree | 30315b1ea79ee4639f44a407978ed719c62a1653 | |
parent | f4ea1f8998fd64bcd02280514b91f103f830d5ce (diff) |
Add latest changes from gitlab-org/gitlab@master
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 |