diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2024-01-11 21:09:22 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2024-01-11 21:09:22 +0300 |
commit | 1bd9d2d9499d0d28e62254a28fcd3d913a8704af (patch) | |
tree | ea9969a5a4c3ac77858be20d69869674bed5ca43 | |
parent | d8877c12347443fa02e0ba53ad8d5cd318f6fa28 (diff) |
Add latest changes from gitlab-org/gitlab@master
94 files changed, 1846 insertions, 545 deletions
diff --git a/.gitlab/ci/as-if-foss.gitlab-ci.yml b/.gitlab/ci/as-if-foss.gitlab-ci.yml index 128a6195c4c..fc8db3ba974 100644 --- a/.gitlab/ci/as-if-foss.gitlab-ci.yml +++ b/.gitlab/ci/as-if-foss.gitlab-ci.yml @@ -1,11 +1,17 @@ +.as-if-foss-variables: + variables: + AS_IF_FOSS_BRANCH: "as-if-foss/${CI_COMMIT_REF_NAME}" + FOSS_REPOSITORY: "https://dummy:${AS_IF_FOSS_TOKEN}@gitlab.com/gitlab-org/gitlab-foss.git" + prepare-as-if-foss-branch: extends: - .as-if-foss:rules:start-as-if-foss + - .as-if-foss-variables stage: prepare needs: [] - variables: - AS_IF_FOSS_BRANCH: "as-if-foss/${CI_COMMIT_REF_NAME}" - FOSS_REPOSITORY: "https://dummy:${AS_IF_FOSS_TOKEN}@gitlab.com/gitlab-org/gitlab-foss.git" + environment: + name: "as-if-foss/${CI_MERGE_REQUEST_IID}" + on_stop: delete-as-if-foss-branch before_script: - git clone --single-branch --branch master "${FOSS_REPOSITORY}" gitlab-foss - git -C gitlab-foss checkout -b "${AS_IF_FOSS_BRANCH}" master @@ -29,15 +35,16 @@ prepare-as-if-foss-branch: prepare-as-if-foss-env: extends: - .as-if-foss:rules:start-as-if-foss + - .fast-no-clone-job stage: prepare needs: [] variables: BUILD_ENV: build.env - before_script: - - source scripts/utils.sh - - install_gitlab_gem + FILES_TO_DOWNLOAD: > + scripts/setup/generate-as-if-foss-env.rb script: - - scripts/setup/generate-as-if-foss-env.rb | tee $BUILD_ENV + - install_gitlab_gem + - ruby scripts/setup/generate-as-if-foss-env.rb | tee "${BUILD_ENV}" artifacts: expire_in: 3 days reports: @@ -82,3 +89,45 @@ start-as-if-foss: project: gitlab-org/gitlab-foss branch: as-if-foss/${CI_COMMIT_REF_NAME} strategy: depend + +delete-as-if-foss-branch: + extends: + - .as-if-foss:rules:start-as-if-foss:allow-failure:manual + - .as-if-foss-variables + image: + name: alpine/git + entrypoint: [""] + stage: prepare + needs: + - prepare-as-if-foss-branch + environment: + name: "as-if-foss/${CI_MERGE_REQUEST_IID}" + action: stop + variables: + GIT_STRATEGY: "none" + script: + - | + git init + git push -d "${FOSS_REPOSITORY}" "${AS_IF_FOSS_BRANCH}" + +# We can only delete the environment after it's stopped, therefore +# we need to use another job to delete the environment, not in the +# job where it's stopping the environment. See: +# https://docs.gitlab.com/ee/ci/environments/#delete-an-environment +delete-as-if-foss-environment: + extends: + - .as-if-foss:rules:start-as-if-foss:allow-failure + image: alpine:latest + stage: prepare + needs: + - delete-as-if-foss-branch + variables: + GIT_STRATEGY: "none" + ESCAPED_ENVIRONMENT_NAME: "as-if-foss%2f${CI_MERGE_REQUEST_IID}" + ENVIRONMENT_API_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/environments" + before_script: + - apk add jq curl + script: + - | + ENV_ID=$(curl --silent --fail --request GET --header "Private-Token: ${PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE}" "${ENVIRONMENT_API_URL}?name=${ESCAPED_ENVIRONMENT_NAME}" | jq '.[0].id') + curl --silent --request DELETE --header "Private-Token: $PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE" "${ENVIRONMENT_API_URL}/${ENV_ID}" diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index 7db2c15db9b..ab603ecb9f2 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -3002,6 +3002,23 @@ - !reference [".strict-ee-only-rules", rules] - <<: *if-merge-request-labels-as-if-foss-cross-project +.as-if-foss:rules:start-as-if-foss:allow-failure:manual: + rules: + - if: '$AS_IF_FOSS_TOKEN == null' + when: never + - !reference [".strict-ee-only-rules", rules] + - <<: *if-merge-request-labels-as-if-foss-cross-project + when: manual + allow_failure: true + +.as-if-foss:rules:start-as-if-foss:allow-failure: + rules: + - if: '$AS_IF_FOSS_TOKEN == null' + when: never + - !reference [".strict-ee-only-rules", rules] + - <<: *if-merge-request-labels-as-if-foss-cross-project + allow_failure: true + ################## # as-if-jh rules # ################## diff --git a/.rubocop_todo/gitlab/strong_memoize_attr.yml b/.rubocop_todo/gitlab/strong_memoize_attr.yml index 2429c2f8d7d..21287d1bd2b 100644 --- a/.rubocop_todo/gitlab/strong_memoize_attr.yml +++ b/.rubocop_todo/gitlab/strong_memoize_attr.yml @@ -452,7 +452,6 @@ Gitlab/StrongMemoizeAttr: - 'lib/api/container_repositories.rb' - 'lib/api/entities/basic_project_details.rb' - 'lib/api/helpers/authentication.rb' - - 'lib/api/terraform/modules/v1/packages.rb' - 'lib/api/unleash.rb' - 'lib/atlassian/jira_connect/jwt/asymmetric.rb' - 'lib/atlassian/jira_connect/jwt/symmetric.rb' diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index 8ca02a6a1d0..060b55f00d0 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -2429,7 +2429,6 @@ Layout/LineLength: - 'lib/api/suggestions.rb' - 'lib/api/tags.rb' - 'lib/api/templates.rb' - - 'lib/api/terraform/modules/v1/packages.rb' - 'lib/api/terraform/state.rb' - 'lib/api/todos.rb' - 'lib/api/users.rb' @@ -4311,7 +4310,7 @@ Layout/LineLength: - 'spec/requests/api/search_spec.rb' - 'spec/requests/api/settings_spec.rb' - 'spec/requests/api/snippets_spec.rb' - - 'spec/requests/api/terraform/modules/v1/packages_spec.rb' + - 'spec/requests/api/terraform/modules/v1/namespace_packages_spec.rb' - 'spec/requests/api/terraform/modules/v1/project_packages_spec.rb' - 'spec/requests/api/todos_spec.rb' - 'spec/requests/api/topics_spec.rb' diff --git a/.rubocop_todo/rspec/before_all_role_assignment.yml b/.rubocop_todo/rspec/before_all_role_assignment.yml index d1078cf4735..1d015869b37 100644 --- a/.rubocop_todo/rspec/before_all_role_assignment.yml +++ b/.rubocop_todo/rspec/before_all_role_assignment.yml @@ -1326,7 +1326,6 @@ RSpec/BeforeAllRoleAssignment: - 'spec/requests/api/rpm_project_packages_spec.rb' - 'spec/requests/api/rubygem_packages_spec.rb' - 'spec/requests/api/search_spec.rb' - - 'spec/requests/api/terraform/modules/v1/packages_spec.rb' - 'spec/requests/api/wikis_spec.rb' - 'spec/requests/concerns/planning_hierarchy_spec.rb' - 'spec/requests/groups/deploy_tokens_controller_spec.rb' diff --git a/.rubocop_todo/rspec/context_wording.yml b/.rubocop_todo/rspec/context_wording.yml index fa8f44aeb9f..d8303d8beb2 100644 --- a/.rubocop_todo/rspec/context_wording.yml +++ b/.rubocop_todo/rspec/context_wording.yml @@ -2385,7 +2385,6 @@ RSpec/ContextWording: - 'spec/requests/api/tags_spec.rb' - 'spec/requests/api/task_completion_status_spec.rb' - 'spec/requests/api/templates_spec.rb' - - 'spec/requests/api/terraform/modules/v1/packages_spec.rb' - 'spec/requests/api/terraform/state_spec.rb' - 'spec/requests/api/terraform/state_version_spec.rb' - 'spec/requests/api/todos_spec.rb' diff --git a/.rubocop_todo/rspec/named_subject.yml b/.rubocop_todo/rspec/named_subject.yml index ab6ddc4c89a..a72575d26ba 100644 --- a/.rubocop_todo/rspec/named_subject.yml +++ b/.rubocop_todo/rspec/named_subject.yml @@ -2971,7 +2971,6 @@ RSpec/NamedSubject: - 'spec/requests/api/releases_spec.rb' - 'spec/requests/api/rubygem_packages_spec.rb' - 'spec/requests/api/snippets_spec.rb' - - 'spec/requests/api/terraform/modules/v1/packages_spec.rb' - 'spec/requests/groups/settings/access_tokens_controller_spec.rb' - 'spec/requests/health_controller_spec.rb' - 'spec/requests/ide_controller_spec.rb' diff --git a/.rubocop_todo/style/if_unless_modifier.yml b/.rubocop_todo/style/if_unless_modifier.yml index c99e96f3e55..cb5e5134dcf 100644 --- a/.rubocop_todo/style/if_unless_modifier.yml +++ b/.rubocop_todo/style/if_unless_modifier.yml @@ -683,7 +683,6 @@ Style/IfUnlessModifier: - 'lib/api/settings.rb' - 'lib/api/snippets.rb' - 'lib/api/tags.rb' - - 'lib/api/terraform/modules/v1/packages.rb' - 'lib/api/users.rb' - 'lib/backup/gitaly_backup.rb' - 'lib/backup/manager.rb' @@ -512,7 +512,7 @@ group :test do gem 'test-prof', '~> 1.3.1' # rubocop:todo Gemfile/MissingFeatureCategory gem 'rspec_junit_formatter' # rubocop:todo Gemfile/MissingFeatureCategory gem 'guard-rspec' # rubocop:todo Gemfile/MissingFeatureCategory - gem 'axe-core-rspec' # rubocop:todo Gemfile/MissingFeatureCategory + gem 'axe-core-rspec', '~> 4.8.0', feature_category: :tooling # Moved in `test` because https://gitlab.com/gitlab-org/gitlab/-/issues/217527 gem 'derailed_benchmarks', require: false # rubocop:todo Gemfile/MissingFeatureCategory diff --git a/Gemfile.checksum b/Gemfile.checksum index 45c0a47eb63..c95e366374d 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -41,8 +41,8 @@ {"name":"aws-sdk-kms","version":"1.76.0","platform":"ruby","checksum":"e7f75013cba9ba357144f66bbc600631c192e2cda9dd572794be239654e2cf49"}, {"name":"aws-sdk-s3","version":"1.142.0","platform":"ruby","checksum":"79cd888eca66fd2ef3ae8b74d76173a2eccbeff6a1bba62a60b7c7dadc8dd7e9"}, {"name":"aws-sigv4","version":"1.8.0","platform":"ruby","checksum":"84dd99768b91b93b63d1d8e53ee837cfd06ab402812772a7899a78f9f9117cbc"}, -{"name":"axe-core-api","version":"4.6.0","platform":"ruby","checksum":"1b0ddec3353f108dc10363baf2282f43a5ff7f13d4e25f99071294e78f8a6c62"}, -{"name":"axe-core-rspec","version":"4.6.0","platform":"ruby","checksum":"11c25bc9dd388c137ba4e5e63d64d20092bf22c884d8ffc829a22acfbacd747f"}, +{"name":"axe-core-api","version":"4.8.0","platform":"ruby","checksum":"88cf44fdbd5d501ae429f9ca6b37c4a46ba27ac673d478ab688eea3e353da62f"}, +{"name":"axe-core-rspec","version":"4.8.0","platform":"ruby","checksum":"b2c51fad40f3fc487d3de522e337d96d5144d88b3e2e00b87d2b53d17056d11e"}, {"name":"axiom-types","version":"0.1.1","platform":"ruby","checksum":"c1ff113f3de516fa195b2db7e0a9a95fd1b08475a502ff660d04507a09980383"}, {"name":"azure-storage-blob","version":"2.0.3","platform":"ruby","checksum":"61b76118843c91776bd24bee22c74adafeb7c4bb3a858a325047dae3b59d0363"}, {"name":"azure-storage-common","version":"2.0.4","platform":"ruby","checksum":"608f4daab0e06b583b73dcffd3246ea39e78056de31630286b0cf97af7d6956b"}, diff --git a/Gemfile.lock b/Gemfile.lock index f124754654b..97b5fe4f236 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -315,10 +315,10 @@ GEM aws-sigv4 (~> 1.8) aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) - axe-core-api (4.6.0) + axe-core-api (4.8.0) dumb_delegator virtus - axe-core-rspec (4.6.0) + axe-core-rspec (4.8.0) axe-core-api dumb_delegator virtus @@ -1819,7 +1819,7 @@ DEPENDENCIES aws-sdk-cloudformation (~> 1) aws-sdk-core (~> 3.190.2) aws-sdk-s3 (~> 1.142.0) - axe-core-rspec + axe-core-rspec (~> 4.8.0) babosa (~> 2.0) base32 (~> 0.3.0) batch-loader (~> 2.0.1) diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue index ccafec0fb7a..ad4b7b790d0 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue @@ -518,9 +518,15 @@ export default { > {{ $options.i18n.variableReferenceDescription }} </gl-alert> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close" - >{{ $options.i18n.cancel }} + <div class="gl-display-flex"> + <gl-button + category="primary" + class="gl-mr-3" + variant="confirm" + :disabled="!canSubmit" + data-testid="ci-variable-confirm-button" + @click="submit" + >{{ modalActionText }} </gl-button> <gl-button v-if="isEditing" @@ -531,13 +537,8 @@ export default { data-testid="ci-variable-delete-button" >{{ $options.i18n.deleteVariable }}</gl-button > - <gl-button - category="primary" - variant="confirm" - :disabled="!canSubmit" - data-testid="ci-variable-confirm-button" - @click="submit" - >{{ modalActionText }} + <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close" + >{{ $options.i18n.cancel }} </gl-button> </div> </gl-drawer> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue index e287e4e17d1..63957d9b7fc 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -33,16 +33,13 @@ export default { </script> <template> - <div> + <div class="gl-display-flex gl-flex-wrap gl-gap-2"> <runner-status-badge :contacted-at="contactedAt" :status="status" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" - /> - <runner-paused-badge - v-if="paused" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" + class="gl-max-w-full gl-text-truncate" /> + <runner-paused-badge v-if="paused" class="gl-max-w-full gl-text-truncate" /> <slot :runner="runner" name="runner-job-status-badge"></slot> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue index bed592e3f30..0dc23882cdc 100644 --- a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue +++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue @@ -26,12 +26,12 @@ export default { switch (this.jobStatus) { case JOB_STATUS_RUNNING: return { - classes: 'gl-text-blue-600! gl-border gl-border-blue-600!', + classes: 'gl-text-blue-600! gl-inset-border-1-gray-400 gl-border-blue-600!', label: I18N_JOB_STATUS_RUNNING, }; case JOB_STATUS_IDLE: return { - classes: 'gl-text-gray-700! gl-border gl-border-gray-500!', + classes: 'gl-text-gray-700! gl-inset-border-1-gray-400 gl-border-gray-500!', label: I18N_JOB_STATUS_IDLE, }; default: @@ -45,7 +45,7 @@ export default { <gl-badge v-if="badge" v-bind="$attrs" - class="gl-display-inline-block gl-max-w-full gl-text-truncate gl-bg-transparent!" + class="gl-max-w-full gl-text-truncate gl-bg-transparent!" variant="muted" :class="badge.classes" > diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 00fd9f43a4f..698fd3909ed 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -144,6 +144,11 @@ export default { required: false, default: '', }, + pinnedFileUrl: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -153,6 +158,7 @@ export default { autoScrolled: false, activeProject: undefined, hasScannerError: false, + pinnedFileStatus: '', }; }, apollo: { @@ -215,7 +221,6 @@ export default { ...mapState('findingsDrawer', ['activeDrawer']), ...mapState('diffs', [ 'isLoading', - 'diffFiles', 'diffViewType', 'commit', 'renderOverflowWarning', @@ -245,6 +250,7 @@ export default { 'isBatchLoading', 'isBatchLoadingError', 'flatBlobsList', + 'diffFiles', ]), ...mapGetters(['isNotesFetched', 'getNoteableData']), ...mapGetters('findingsDrawer', ['activeDrawer']), @@ -355,7 +361,7 @@ export default { const id = window?.location?.hash; if (id && id.indexOf('#note') !== 0) { - this.setHighlightedRow(id.split('diff-content').pop().slice(1)); + this.setHighlightedRow({ lineCode: id.split('diff-content').pop().slice(1) }); } const events = []; @@ -438,6 +444,7 @@ export default { 'setFileByFile', 'disableVirtualScroller', 'setGenerateTestFilePath', + 'fetchPinnedFile', ]), ...mapActions('findingsDrawer', ['setDrawer']), closeDrawer() { @@ -509,6 +516,20 @@ export default { return !this.diffFiles.length; }, fetchData({ toggleTree = true, fetchMeta = true } = {}) { + if (this.pinnedFileUrl && this.pinnedFileStatus !== 'loaded') { + this.pinnedFileStatus = 'loading'; + this.fetchPinnedFile(this.pinnedFileUrl) + .then(() => { + this.pinnedFileStatus = 'loaded'; + if (toggleTree) this.setTreeDisplay(); + }) + .catch(() => { + this.pinnedFileStatus = 'error'; + createAlert({ + message: __("Couldn't fetch the pinned file."), + }); + }); + } if (fetchMeta) { this.fetchDiffFilesMeta() .then((data) => { @@ -539,7 +560,7 @@ export default { } if (!this.viewDiffsFileByFile) { - this.fetchDiffFilesBatch() + this.fetchDiffFilesBatch(Boolean(this.pinnedFileUrl)) .then(() => { if (toggleTree) this.setTreeDisplay(); // Guarantee the discussions are assigned after the batch finishes. @@ -724,6 +745,9 @@ export default { <gl-loading-icon size="lg" /> </div> <template v-else-if="renderDiffFiles"> + <div v-if="pinnedFileStatus === 'loading'" class="loading"> + <gl-loading-icon size="lg" /> + </div> <dynamic-scroller v-if="isVirtualScrollingEnabled" :items="diffs" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 82b721da493..39f642b0831 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -17,6 +17,7 @@ import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; import NoteForm from '~/notes/components/note_form.vue'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; +import { fileContentsId } from '~/diffs/components/diff_row_utils'; import { DIFF_FILE_AUTOMATIC_COLLAPSE, DIFF_FILE_MANUAL_COLLAPSE, @@ -110,7 +111,10 @@ export default { 'canMerge', ]), ...mapGetters(['isNotesFetched', 'getNoteableData', 'noteableType']), - ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']), + ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled', 'pinnedFile']), + isPinnedFile() { + return this.file === this.pinnedFile; + }, viewBlobHref() { return escape(this.file.view_path); }, @@ -206,6 +210,9 @@ export default { diffFileHash() { return this.file.file_hash; }, + fileId() { + return fileContentsId(this.file); + }, }, watch: { 'file.id': { @@ -293,7 +300,7 @@ export default { }, handleToggle({ viaUserInteraction = false } = {}) { const collapsingNow = !this.isCollapsed; - const contentElement = this.$el.querySelector(`#diff-content-${this.file.file_hash}`); + const contentElement = this.$el.querySelector(`#${fileContentsId(this.file)}`); this.setFileCollapsedByUser({ filePath: this.file.file_path, @@ -386,6 +393,7 @@ export default { 'comments-disabled': Boolean(file.brokenSymlink), 'has-body': showBody, 'is-virtual-scrolling': isVirtualScrollingEnabled, + 'pinned-file': isPinnedFile, }" :data-path="file.new_path" class="diff-file file-holder gl-border-none gl-mb-0! gl-pb-5" @@ -400,6 +408,7 @@ export default { :add-merge-request-buttons="true" :view-diffs-file-by-file="viewDiffsFileByFile" :show-local-file-reviews="showLocalFileReviews" + :pinned="isPinnedFile" class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100" :class="hasBodyClasses.header" @toggleFile="handleToggle({ viaUserInteraction: true })" @@ -428,7 +437,7 @@ export default { </div> <template v-else> <div - :id="`diff-content-${file.file_hash}`" + :id="fileId" :class="hasBodyClasses.contentByHash" class="diff-content" data-testid="content-area" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index e45fd508a5b..97db0fc1c24 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -22,6 +22,7 @@ import { __, s__, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { fileContentsId, pinnedFileHref } from '~/diffs/components/diff_row_utils'; import { DIFF_FILE_AUTOMATIC_COLLAPSE } from '../constants'; import { DIFF_FILE_HEADER } from '../i18n'; import { collapsedType, isCollapsed } from '../utils/diff_file'; @@ -102,6 +103,11 @@ export default { required: false, default: false, }, + pinned: { + type: Boolean, + required: false, + default: false, + }, }, idState() { return { @@ -113,9 +119,8 @@ export default { ...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']), ...mapGetters(['getNoteableData']), diffContentIDSelector() { - return `#diff-content-${this.diffFile.file_hash}`; + return `${pinnedFileHref(this.diffFile)}#${fileContentsId(this.diffFile)}`; }, - titleLink() { if (this.diffFile.submodule) { return this.diffFile.submodule_tree_url || this.diffFile.submodule_link; @@ -222,6 +227,7 @@ export default { 'setFileForcedOpen', 'setGenerateTestFilePath', 'toggleFileCommentForm', + 'unpinFile', ]), handleToggleFile() { this.setFileForcedOpen({ @@ -295,7 +301,19 @@ export default { > <div class="file-header-content"> <gl-button - v-if="collapsible" + v-if="pinned" + v-gl-tooltip.hover.focus + :title="__('Unpin the file')" + :aria-label="__('Unpin the file')" + icon="thumbtack" + size="small" + class="btn-icon gl-mr-2" + category="tertiary" + data-testid="unpin-button" + @click="unpinFile" + /> + <gl-button + v-else-if="collapsible" ref="collapseButton" class="gl-mr-2" category="tertiary" @@ -305,10 +323,10 @@ export default { @click.stop="handleToggleFile" /> <a - ref="titleWrapper" :v-once="!viewDiffsFileByFile" class="gl-mr-2 gl-text-decoration-none! gl-word-break-all" :href="titleLink" + data-testid="file-title" @click="handleFileNameClick" > <span v-if="isFileRenamed"> @@ -354,7 +372,7 @@ export default { <small v-if="isModeChanged" ref="fileMode" - v-gl-tooltip.hover + v-gl-tooltip.hover.focus class="mr-1" :title="$options.i18n.fileModeTooltip" > @@ -377,7 +395,7 @@ export default { /> <gl-form-checkbox v-if="isReviewable && showLocalFileReviews" - v-gl-tooltip.hover + v-gl-tooltip.hover.focus data-testid="fileReviewCheckbox" class="gl-mr-5 gl-mb-n3 gl-display-flex gl-align-items-center" :title="$options.i18n.fileReviewTooltip" @@ -388,7 +406,7 @@ export default { </gl-form-checkbox> <gl-button v-if="showCommentButton" - v-gl-tooltip.hover + v-gl-tooltip.hover.focus :title="__('Comment on this file')" :aria-label="__('Comment on this file')" icon="comment" @@ -402,7 +420,7 @@ export default { <gl-button v-if="diffFile.external_url" ref="externalLink" - v-gl-tooltip.hover + v-gl-tooltip.hover.focus :href="diffFile.external_url" :title="externalUrlLabel" :aria-label="externalUrlLabel" diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 3dad7a1a8e4..a9da77104d4 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -292,7 +292,7 @@ export default { v-if="props.line.left.old_line && props.line.left.type !== $options.CONFLICT_THEIR" :data-linenumber="props.line.left.old_line" :href="props.line.lineHrefOld" - @click="listeners.setHighlightedRow(props.line.lineCode)" + @click="listeners.setHighlightedRow({ lineCode: props.line.lineCode, event: $event })" > </a> <component @@ -318,7 +318,7 @@ export default { v-if="props.line.left.new_line && props.line.left.type !== $options.CONFLICT_OUR" :data-linenumber="props.line.left.new_line" :href="props.line.lineHrefOld" - @click="listeners.setHighlightedRow(props.line.lineCode)" + @click="listeners.setHighlightedRow({ lineCode: props.line.lineCode, event: $event })" > </a> </div> @@ -446,7 +446,7 @@ export default { v-if="props.line.right.new_line" :data-linenumber="props.line.right.new_line" :href="props.line.lineHrefNew" - @click="listeners.setHighlightedRow(props.line.lineCode)" + @click="listeners.setHighlightedRow({ lineCode: props.line.lineCode, event: $event })" > </a> <component diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index a489c96b0c9..5c62e0179ac 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -33,7 +33,19 @@ export const shouldRenderCommentButton = (isLoggedIn, isCommentButtonRendered) = export const hasDiscussions = (line) => line?.discussions?.length > 0; -export const lineHref = (line) => `#${line?.line_code || ''}`; +export const pinnedFileHref = (diffFile) => { + if (!window?.gon?.features?.pinnedFile) return ''; + return `?pin=${diffFile.file_hash}`; +}; + +export const lineHref = (line, content) => { + if (!line || !line.line_code) return ''; + return `${pinnedFileHref(content.diffFile)}#${line.line_code}`; +}; + +export const fileContentsId = (diffFile) => { + return `diff-content-${diffFile.file_hash}`; +}; export const lineCode = (line) => { if (!line) return undefined; @@ -179,8 +191,8 @@ export const mapParallel = (content) => (line) => { isContextLineRight: isContextLine(right?.type), hasDiscussionsLeft: hasDiscussions(left), hasDiscussionsRight: hasDiscussions(right), - lineHrefOld: lineHref(left), - lineHrefNew: lineHref(right), + lineHrefOld: lineHref(left, content), + lineHrefNew: lineHref(right, content), lineCode: lineCode(line), isMetaLineLeft: isMetaLine(left?.type), isMetaLineRight: isMetaLine(right?.type), diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 07984beb709..ab21391b364 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -34,7 +34,7 @@ export default { }, computed: { ...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']), - ...mapGetters('diffs', ['allBlobs']), + ...mapGetters('diffs', ['allBlobs', 'pinnedFile']), filteredTreeList() { let search = this.search.toLowerCase().trim(); @@ -71,21 +71,59 @@ export default { // out: [{ path: 'a', tree: [{ path: 'b' }] }, { path: 'b' }, { path: 'c' }] flatFilteredTreeList() { const result = []; - const createFlatten = (level) => (item) => { + const createFlatten = (level, hidden) => (item) => { result.push({ ...item, + hidden, level: item.isHeader ? 0 : level, key: item.key || item.path, }); - if (item.opened || item.isHeader) { - item.tree.forEach(createFlatten(level + 1)); - } + const isHidden = hidden || (item.type === 'tree' && !item.opened); + item.tree.forEach(createFlatten(level + 1, isHidden)); }; this.filteredTreeList.forEach(createFlatten(0)); return result; }, + flatListWithPinnedFile() { + const result = [...this.flatFilteredTreeList]; + const pinnedIndex = result.findIndex((item) => item.path === this.pinnedFile.file_path); + const [pinnedItem] = result.splice(pinnedIndex, 1); + + if (pinnedItem.parentPath === '/') + return [{ ...pinnedItem, level: 0, pinned: true, hidden: false }, ...result]; + + // remove detached folder from the tree + const next = result[pinnedIndex]; + const prev = result[pinnedIndex - 1]; + const hasContainingFolder = + prev && prev.type === 'tree' && prev.level === pinnedItem.level - 1; + const hasSibling = next && next.type !== 'tree' && next.level === pinnedItem.level; + if (hasContainingFolder && !hasSibling) { + // folder tree is always condensed so we only need to remove the parent folder + result.splice(pinnedIndex - 1, 1); + } + + return [ + { + level: 0, + key: 'pinned-path', + isHeader: true, + opened: true, + path: pinnedItem.parentPath, + type: 'tree', + hidden: false, + }, + { ...pinnedItem, level: 1, pinned: true, hidden: false }, + ...result, + ]; + }, + treeList() { + const list = this.pinnedFile ? this.flatListWithPinnedFile : this.flatFilteredTreeList; + if (this.search) return list; + return list.filter((item) => !item.hidden); + }, }, methods: { ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']), @@ -125,13 +163,13 @@ export default { </button> </div> </div> - <tree-list-height class="gl-flex-grow-1 gl-min-h-0" :items-count="flatFilteredTreeList.length"> + <tree-list-height class="gl-flex-grow-1 gl-min-h-0" :items-count="treeList.length"> <template #default="{ scrollerHeight, rowHeight }"> <div :class="{ 'tree-list-blobs': !renderTreeList || search }" class="mr-tree-list"> <recycle-scroller - v-if="flatFilteredTreeList.length" + v-if="treeList.length" :style="{ height: `${scrollerHeight}px` }" - :items="flatFilteredTreeList" + :items="treeList" :item-size="rowHeight" :buffer="100" key-field="key" diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 15e4225f062..b219b5499c9 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -93,6 +93,7 @@ export default function initDiffsApp(store = notesStore) { helpPagePath: this.helpPagePath, shouldShow: this.activeTab === 'diffs', changesEmptyStateIllustration: this.changesEmptyStateIllustration, + pinnedFileUrl: dataset.pinnedFileUrl, }, }); }, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 1c0e20183e2..061ea7a421e 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -54,7 +54,6 @@ import { } from '../constants'; import { DISCUSSION_SINGLE_DIFF_FAILED, - LOAD_SINGLE_DIFF_FAILED, BUILDING_YOUR_MR, SOMETHING_WENT_WRONG, ERROR_LOADING_FULL_DIFF, @@ -210,7 +209,7 @@ export const fetchFileByFile = async ({ state, getters, commit }) => { } }; -export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { +export const fetchDiffFilesBatch = ({ commit, state, dispatch }, pinnedFileLoading = false) => { let perPage = state.viewDiffsFileByFile ? 1 : state.perPage; let increaseAmount = 1.4; const startPage = 0; @@ -224,8 +223,10 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { let totalLoaded = 0; let scrolledVirtualScroller = hash === ''; - commit(types.SET_BATCH_LOADING_STATE, 'loading'); - commit(types.SET_RETRIEVING_BATCHES, true); + if (!pinnedFileLoading) { + commit(types.SET_BATCH_LOADING_STATE, 'loading'); + commit(types.SET_RETRIEVING_BATCHES, true); + } eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START); const getBatch = (page = startPage) => @@ -237,7 +238,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffFiles }); commit(types.SET_BATCH_LOADING_STATE, 'loaded'); - if (!scrolledVirtualScroller) { + if (!scrolledVirtualScroller && !pinnedFileLoading) { const index = state.diffFiles.findIndex( (f) => f.file_hash === hash || f[INLINE_DIFF_LINES_KEY].find((l) => l.line_code === hash), @@ -301,9 +302,10 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { return null; }) - .catch(() => { + .catch((error) => { commit(types.SET_RETRIEVING_BATCHES, false); commit(types.SET_BATCH_LOADING_STATE, 'error'); + throw error; }); return getBatch(); @@ -384,7 +386,11 @@ export const fetchCoverageFiles = ({ commit, state }) => { coveragePoll.makeRequest(); }; -export const setHighlightedRow = ({ commit }, lineCode) => { +export const setHighlightedRow = ({ commit }, { lineCode, event }) => { + if (event && event.target.href) { + event.preventDefault(); + window.history.replaceState(null, undefined, event.target.href); + } const fileHash = lineCode.split('_')[0]; commit(types.SET_HIGHLIGHTED_ROW, lineCode); commit(types.SET_CURRENT_DIFF_FILE, fileHash); @@ -657,6 +663,8 @@ export const goToFile = ({ state, commit, dispatch, getters }, { path }) => { } else { if (!state.treeEntries[path]) return; + dispatch('unpinFile'); + const { fileHash } = state.treeEntries[path]; commit(types.SET_CURRENT_DIFF_FILE, fileHash); @@ -667,11 +675,7 @@ export const goToFile = ({ state, commit, dispatch, getters }, { path }) => { scrollToElement('.diff-files-holder', { duration: 0 }); if (!getters.isTreePathLoaded(path)) { - dispatch('fetchFileByFile').catch(() => { - createAlert({ - message: LOAD_SINGLE_DIFF_FAILED, - }); - }); + dispatch('fetchFileByFile'); } } }; @@ -995,6 +999,8 @@ export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, n }; export const navigateToDiffFileIndex = ({ state, getters, commit, dispatch }, index) => { + dispatch('unpinFile'); + const { fileHash } = getters.flatBlobsList[index]; document.location.hash = fileHash; @@ -1055,3 +1061,50 @@ export const toggleFileCommentForm = ({ state, commit }, filePath) => { export const addDraftToFile = ({ commit }, { filePath, draft }) => commit(types.ADD_DRAFT_TO_FILE, { filePath, draft }); + +export const fetchPinnedFile = ({ state, commit }, pinnedFileUrl) => { + const isNoteLink = isUrlHashNoteLink(window?.location?.hash); + + commit(types.SET_BATCH_LOADING_STATE, 'loading'); + commit(types.SET_RETRIEVING_BATCHES, true); + + return axios + .get(pinnedFileUrl) + .then(({ data: diffData }) => { + const [{ file_hash }] = diffData.diff_files; + + // we must store pinned file in the `diffs`, otherwise collapsing and commenting on a file won't work + // once the same file arrives in a file batch we must only update its' position + // we also must not update file's position since it's loaded out of order + commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files, updatePosition: false }); + commit(types.SET_PINNED_FILE_HASH, file_hash); + + if (!isNoteLink && !state.currentDiffFileId) { + commit(types.SET_CURRENT_DIFF_FILE, file_hash); + } + + commit(types.SET_BATCH_LOADING_STATE, 'loaded'); + + setTimeout(() => { + handleLocationHash(); + }); + + eventHub.$emit('diffFilesModified'); + }) + .catch((error) => { + commit(types.SET_BATCH_LOADING_STATE, 'error'); + throw error; + }) + .finally(() => { + commit(types.SET_RETRIEVING_BATCHES, false); + }); +}; + +export const unpinFile = ({ getters, commit }) => { + if (!getters.pinnedFile) return; + commit(types.SET_PINNED_FILE_HASH, null); + const newUrl = new URL(window.location); + newUrl.searchParams.delete('pin'); + newUrl.hash = ''; + window.history.replaceState(null, undefined, newUrl); +}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index bfafb4d281d..f15c3d1693c 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -201,3 +201,18 @@ export const isVirtualScrollingEnabled = (state) => { export const isBatchLoading = (state) => state.batchLoadingState === 'loading'; export const isBatchLoadingError = (state) => state.batchLoadingState === 'error'; + +export const diffFiles = (state, getters) => { + const { pinnedFile } = getters; + if (pinnedFile) { + const diffs = state.diffFiles.slice(0); + diffs.splice(diffs.indexOf(pinnedFile), 1); + return [pinnedFile, ...diffs]; + } + return state.diffFiles; +}; + +export const pinnedFile = (state) => { + if (!state.pinnedFileHash) return null; + return state.diffFiles.find((file) => file.file_hash === state.pinnedFileHash); +}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index d5e1a05f4a5..3b6408e6d78 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -37,4 +37,5 @@ export default () => ({ mrReviews: {}, latestDiff: true, disableVirtualScroller: false, + pinnedFileHash: null, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index b155804c70c..7b19f96b151 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -6,6 +6,7 @@ export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES'; export const SET_DIFF_METADATA = 'SET_DIFF_METADATA'; export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH'; export const SET_DIFF_FILES = 'SET_DIFF_FILES'; +export const SET_DIFF_TREE_ENTRY = 'SET_DIFF_TREE_ENTRY'; export const SET_MR_FILE_REVIEWS = 'SET_MR_FILE_REVIEWS'; @@ -23,6 +24,7 @@ export const TREE_ENTRY_DIFF_LOADING = 'TREE_ENTRY_DIFF_LOADING'; export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST'; export const SET_CURRENT_DIFF_FILE = 'SET_CURRENT_DIFF_FILE'; export const SET_DIFF_FILE_VIEWED = 'SET_DIFF_FILE_VIEWED'; +export const SET_PINNED_FILE_HASH = 'SET_PINNED_FILE_HASH'; export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index bc5ed3c40df..3c750830baa 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -81,15 +81,27 @@ export default { }); }, - [types.SET_DIFF_DATA_BATCH](state, data) { + [types.SET_DIFF_DATA_BATCH](state, { diff_files: diffFiles, updatePosition = true }) { Object.assign(state, { diffFiles: prepareDiffData({ - diff: data, + diff: { diff_files: diffFiles }, priorFiles: state.diffFiles, + // when a pinned file is added to diffs its position may be incorrect since it's loaded out of order + // we need to ensure when we load it in batched request it updates it position + updatePosition, }), treeEntries: markTreeEntriesLoaded({ priorEntries: state.treeEntries, - loadedFiles: data.diff_files, + loadedFiles: diffFiles, + }), + }); + }, + + [types.SET_DIFF_TREE_ENTRY](state, diffFile) { + Object.assign(state, { + treeEntries: markTreeEntriesLoaded({ + priorEntries: state.treeEntries, + loadedFiles: [diffFile], }), }); }, @@ -404,4 +416,7 @@ export default { file?.drafts.push(draft); }, + [types.SET_PINNED_FILE_HASH](state, fileHash) { + state.pinnedFileHash = fileHash; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index fb467a606b9..ad8cacf8504 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -395,9 +395,15 @@ function finalizeDiffFile(file) { return file; } -function deduplicateFilesList(files) { +function deduplicateFilesList(files, updatePosition) { const dedupedFiles = files.reduce((newList, file) => { const id = diffFileUniqueId(file); + if (updatePosition && id in newList) { + // Object.values preserves key order but doesn't update order when writing to the same key + // In order to update position of the item we have to delete it first and then add it back + // eslint-disable-next-line no-param-reassign + delete newList[id]; + } return { ...newList, @@ -408,14 +414,20 @@ function deduplicateFilesList(files) { return Object.values(dedupedFiles); } -export function prepareDiffData({ diff, priorFiles = [], meta = false }) { - const cleanedFiles = (diff.diff_files || []) - .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta, index })) - .map(ensureBasicDiffFileLines) - .map(prepareDiffFileLines) - .map((file) => finalizeDiffFile(file)); +export function prepareDiffData({ diff, priorFiles = [], meta = false, updatePosition = false }) { + const transformersChain = [ + (file, index, allFiles) => prepareRawDiffFile({ file, index, allFiles, meta }), + ensureBasicDiffFileLines, + prepareDiffFileLines, + finalizeDiffFile, + ]; + const cleanedFiles = (diff.diff_files || []).map((file, index, allFiles) => { + return transformersChain.reduce((fileResult, transformer) => { + return transformer(fileResult, index, allFiles); + }, file); + }); - return deduplicateFilesList([...priorFiles, ...cleanedFiles]); + return deduplicateFilesList([...priorFiles, ...cleanedFiles], updatePosition); } export function getDiffPositionByLineCode(diffFiles) { diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 3a08e3e546f..6e347a3c95b 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -20,6 +20,7 @@ import { VISIBILITY_LEVELS_STRING_TO_INTEGER, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, + PROJECT_VISIBILITY_TYPE, } from '~/visibility_level/constants'; import { ITEM_TYPE, ACTIVE_TAB_SHARED } from '../constants'; @@ -105,7 +106,8 @@ export default { return VISIBILITY_TYPE_ICON[this.group.visibility]; }, visibilityTooltip() { - return GROUP_VISIBILITY_TYPE[this.group.visibility]; + if (this.isGroup) return GROUP_VISIBILITY_TYPE[this.group.visibility]; + return PROJECT_VISIBILITY_TYPE[this.group.visibility]; }, microdata() { return this.group.microdata || {}; diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue index 32f05d0e298..d9177778803 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue @@ -40,6 +40,9 @@ export default { totalItems() { return this.packageProtectionRules.length; }, + isLoadingPackageProtectionRules() { + return this.$apollo.queries.packageProtectionRules.loading; + }, }, apollo: { packageProtectionRules: { @@ -99,6 +102,7 @@ export default { show-empty stacked="md" class="mb-3" + :busy="isLoadingPackageProtectionRules" > <template #table-busy> <gl-loading-icon size="sm" class="gl-my-5" /> diff --git a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js index df99aac6b9e..89ae2a82c6d 100644 --- a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js +++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js @@ -25,10 +25,11 @@ export const getUsers = (query, states) => { }); }; -export const getGroups = () => { +export const getGroups = ({ withProjectAccess = false }) => { return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), { params: { project_id: gon.current_project_id, + with_project_access: withProjectAccess, }, }); }; diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index 2dd7633e2c8..c863973cd2e 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -94,6 +94,11 @@ export default { required: false, default: true, }, + groupsWithProjectAccess: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -229,7 +234,9 @@ export default { Promise.all([ getDeployKeys(this.query), getUsers(this.query), - this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(), + this.groups.length + ? Promise.resolve({ data: this.groups }) + : getGroups({ withProjectAccess: this.groupsWithProjectAccess }), ]) .then(([deployKeysResponse, usersResponse, groupsResponse]) => { this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data); diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 49c55efca7b..612f801300e 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -83,6 +83,7 @@ export default class ProtectedBranchCreate { block: true, accessLevel, accessLevelsData, + groupsWithProjectAccess: true, testId, }); diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 66da3de516a..67ae33e1fc8 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -97,6 +97,7 @@ export default class ProtectedBranchEdit { block: true, accessLevel, accessLevelsData, + groupsWithProjectAccess: true, testId, }); diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js index b3754cecce4..e1cd069fd92 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_create.js +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -40,6 +40,7 @@ export default class ProtectedTagCreate { hasLicense: this.hasLicense, accessLevel: ACCESS_LEVELS.CREATE, accessLevelsData: gon.create_access_levels, + groupsWithProjectAccess: true, searchEnabled: dropdownEl.dataset.filter !== undefined, testId: 'allowed-to-create-dropdown', }); diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.vue b/app/assets/javascripts/protected_tags/protected_tag_edit.vue index 7fe1dc9c01a..d5ec88c171c 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.vue +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.vue @@ -107,6 +107,7 @@ export default { :access-levels-data="accessLevelsData" :preselected-items="selected" :search-enabled="searchEnabled" + groups-with-project-access :block="true" @hidden="updatePermissions" /> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 6ac75230d88..3419e6eb1b6 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -1,5 +1,5 @@ <script> -import { GlTruncate } from '@gitlab/ui'; +import { GlTruncate, GlIcon } from '@gitlab/ui'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import FileHeader from '~/vue_shared/components/file_row_header.vue'; @@ -10,6 +10,7 @@ export default { FileHeader, FileIcon, GlTruncate, + GlIcon, }, props: { file: { @@ -141,6 +142,7 @@ export default { data-testid="file-row-name-container" :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]" > + <gl-icon v-if="file.pinned" name="thumbtack" :size="16" /> <file-icon class="file-row-icon" :class="{ 'text-secondary': file.type === 'tree' }" diff --git a/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue index 5d86f90880d..5f197066cb5 100644 --- a/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue +++ b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue @@ -1,5 +1,6 @@ <script> import { GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { truncate } from '~/lib/utils/text_utility'; import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from './constants'; @@ -12,6 +13,7 @@ export default { GlBadge, GlIcon, }, + mixins: [glFeatureFlagsMixin()], props: { user: { type: Object, @@ -65,7 +67,12 @@ export default { <div v-if="user.note" class="gl-text-gray-500 gl-p-1"> <gl-icon v-gl-tooltip="userNoteShort" name="document" /> </div> - <div v-for="(badge, idx) in user.badges" :key="idx" class="gl-p-1"> + <div + v-for="(badge, idx) in user.badges" + :key="idx" + class="gl-p-1" + :class="{ 'gl-pb-0': glFeatures.simplifiedBadges }" + > <gl-badge class="gl-display-flex!" size="sm" :variant="badge.variant">{{ badge.text }}</gl-badge> diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss index 3f1d742ca14..7c3684f7c2e 100644 --- a/app/assets/stylesheets/framework/badges.scss +++ b/app/assets/stylesheets/framework/badges.scss @@ -12,3 +12,56 @@ color: $green; } } + +// FF :simplified_badges +// +// Temporarily override badge styles +// globally +// +// Once verified we will update the +// badge component in GitLab UI +// refactor GitLab and remove this +// custom code +// +// see https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3307 +.ff-simplified-badges-enabled { + // These changes will be moved to + // GitLab UI's badge component + .gl-badge, + .gl-badge.sm, + .gl-badge.md, + .gl-badge.lg { + @include gl-font-sm; + padding-block: $gl-spacing-scale-1; + padding-inline: calc(#{$gl-spacing-scale-3} - 2px); + + > .gl-icon { + @include gl-ml-0; + } + } + + // These changes will be moved to + // GitLab UI's button component + .gl-button .gl-badge { + @include gl-py-0; + } + + // These changes will be moved to + // app/assets/stylesheets/framework/super_sidebar.scss + .super-sidebar-nav-item .gl-badge { + vertical-align: 2px; + } + + // These changes will be moved to + // GitLab UI's tab component + .gl-tab-nav-item .gl-badge { + margin-block: -2px; + } + + // Temporarily needed because of the + // speciality this FF adds + // the utility class gets overriden + .gl-badge.ci-icon { + @include gl-p-2; + } +} diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index c9ef9349c36..497a8a08a6f 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -944,3 +944,12 @@ table.code { left: -2px !important; } } + +.diff-file.pinned-file .file-title { + background-color: $blue-50; + border-color: $blue-200; +} + +.diff-file.pinned-file .diff-content { + border-color: $blue-200; +} diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index ee78d5a8c35..3a0618c0d40 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -9,6 +9,10 @@ class Admin::UsersController < Admin::ApplicationController before_action :ensure_destroy_prerequisites_met, only: [:destroy] before_action :set_shared_view_parameters, only: [:show, :projects, :keys] + before_action only: [:index] do + push_frontend_feature_flag(:simplified_badges) + end + feature_category :user_management PAGINATION_WITH_COUNT_LIMIT = 1000 diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 09ba6e40efd..6cb00fea922 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -47,6 +47,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:mr_request_changes, current_user) push_frontend_feature_flag(:merge_blocked_component, current_user) push_frontend_feature_flag(:mention_autocomplete_backend_filtering, project) + push_frontend_feature_flag(:pinned_file, project) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions] @@ -449,6 +450,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @update_current_user_path = expose_path(api_v4_user_preferences_path) @endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request) @endpoint_diff_batch_url = endpoint_diff_batch_url(@project, @merge_request) + if params[:pin] && Feature.enabled?(:pinned_file) + @pinned_file_url = diff_by_file_hash_namespace_project_merge_request_path( + format: 'json', + id: merge_request.iid, + namespace_id: project&.namespace.to_param, + project_id: project&.path, + file_hash: params[:pin] + ) + end if merge_request.diffs_batch_cache_with_max_age? @diffs_batch_cache_key = @merge_request.merge_head_diff&.patch_id_sha diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 767838df076..522fb134002 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -204,7 +204,8 @@ module MergeRequestsHelper is_forked: project.forked?.to_s, new_comment_template_path: profile_comment_templates_path, iid: merge_request.iid, - per_page: DIFF_BATCH_ENDPOINT_PER_PAGE + per_page: DIFF_BATCH_ENDPOINT_PER_PAGE, + pinned_file_url: @pinned_file_url } end diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb index f9084ed67d3..6ebf1215a25 100644 --- a/app/services/system_notes/time_tracking_service.rb +++ b/app/services/system_notes/time_tracking_service.rb @@ -43,18 +43,11 @@ module SystemNotes # # Returns the created Note object def change_time_estimate - parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate) - body = if noteable.time_estimate == 0 - "removed time estimate" - else - "changed time estimate to #{parsed_time}" - end - if noteable.is_a?(Issue) issue_activity_counter.track_issue_time_estimate_changed_action(author: author, project: project) end - create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + create_note(NoteSummary.new(noteable, project, author, time_estimate_system_note, action: 'time_tracking')) end # Called when the spent time of a Noteable is changed @@ -160,5 +153,19 @@ module SystemNotes def work_item_activity_counter Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter end + + def time_estimate_system_note + parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate) + previous_estimate = noteable.previous_changes['time_estimate']&.at(0) || 0 + parsed_previous_restimate = Gitlab::TimeTrackingFormatter.output(previous_estimate) + + if previous_estimate == 0 + "added time estimate of #{parsed_time}" + elsif noteable.time_estimate == 0 + "removed time estimate of #{parsed_previous_restimate}" + else + "changed time estimate to #{parsed_time} from #{parsed_previous_restimate}" + end + end end end diff --git a/app/views/devise/shared/_footer.html.haml b/app/views/devise/shared/_footer.html.haml index c35e43b909e..44f34e3f342 100644 --- a/app/views/devise/shared/_footer.html.haml +++ b/app/views/devise/shared/_footer.html.haml @@ -7,5 +7,8 @@ = link_to _("Help"), help_path = link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}" = link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer' + - if one_trust_enabled? + = render Pajamas::ButtonComponent.new(category: :tertiary, size: :small, button_options: { class: 'ot-sdk-show-settings' }) do + = _("Cookie Preferences") = render 'devise/shared/language_switcher' = footer_message diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 71c5e2d24a7..b9257bcedc9 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -2,11 +2,12 @@ - page_classes = [user_application_theme, page_classes.flatten.compact] - body_classes = [user_tab_width, @body_class, client_class_list, *custom_diff_color_classes] - ff_simplified_labels_enabled = Feature.enabled?(:simplified_labels) ? 'ff-simplified-labels-enabled' : '' +- ff_simplified_badges_class = Feature.enabled?(:simplified_badges) ? 'ff-simplified-badges-enabled' : '' !!! 5 %html{ lang: I18n.locale, class: page_classes } = render "layouts/head" - %body{ class: [body_classes, ff_simplified_labels_enabled], data: body_data } + %body{ class: [body_classes, ff_simplified_labels_enabled, ff_simplified_badges_class], data: body_data } = render "layouts/init_auto_complete" if @gfm_form = render "layouts/init_client_detection_flags" = render "layouts/visual_review" if review_apps_enabled? diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index 03a1f2f3179..af8ad22fa50 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -16,6 +16,7 @@ - add_page_specific_style 'page_bundles/ci_status' - add_page_startup_api_call @endpoint_metadata_url +- add_page_startup_api_call @pinned_file_url if @pinned_file_url - if mr_action == 'diffs' && !@file_by_file_default - add_page_startup_api_call @endpoint_diff_batch_url diff --git a/config/feature_flags/development/group_analytics_dashboards.yml b/config/feature_flags/development/group_analytics_dashboards.yml index 55001b99452..6609b4d2c7e 100644 --- a/config/feature_flags/development/group_analytics_dashboards.yml +++ b/config/feature_flags/development/group_analytics_dashboards.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/416970 milestone: '16.2' type: development group: group::optimize -default_enabled: false +default_enabled: true diff --git a/config/feature_flags/development/pinned_file.yml b/config/feature_flags/development/pinned_file.yml new file mode 100644 index 00000000000..712a35fd790 --- /dev/null +++ b/config/feature_flags/development/pinned_file.yml @@ -0,0 +1,8 @@ +--- +name: pinned_file +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/137544 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/433250 +milestone: '16.9' +type: development +group: group::code review +default_enabled: false diff --git a/config/feature_flags/development/simplified_badges.yml b/config/feature_flags/development/simplified_badges.yml new file mode 100644 index 00000000000..95f9b00d306 --- /dev/null +++ b/config/feature_flags/development/simplified_badges.yml @@ -0,0 +1,8 @@ +--- +name: simplified_badges +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/140231 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/437861 +milestone: '16.8' +type: development +group: group::ux paper cuts +default_enabled: false diff --git a/db/migrate/20240111134328_routing_table_prepare_async_constraint_for_pipeline_variables.rb b/db/migrate/20240111134328_routing_table_prepare_async_constraint_for_pipeline_variables.rb new file mode 100644 index 00000000000..4cf3d9c1721 --- /dev/null +++ b/db/migrate/20240111134328_routing_table_prepare_async_constraint_for_pipeline_variables.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class RoutingTablePrepareAsyncConstraintForPipelineVariables < Gitlab::Database::Migration[2.2] + include Gitlab::Database::PartitioningMigrationHelpers::TableManagementHelpers + + milestone '16.8' + + disable_ddl_transaction! + + TABLE_NAME = :ci_pipeline_variables + PARENT_TABLE_NAME = :p_ci_pipeline_variables + FIRST_PARTITION = [100, 101] + PARTITION_COLUMN = :partition_id + + def up + prepare_constraint_for_list_partitioning( + table_name: TABLE_NAME, + partitioning_column: PARTITION_COLUMN, + parent_table_name: PARENT_TABLE_NAME, + initial_partitioning_value: FIRST_PARTITION, + async: true + ) + end + + def down + revert_preparing_constraint_for_list_partitioning( + table_name: TABLE_NAME, + partitioning_column: PARTITION_COLUMN, + parent_table_name: PARENT_TABLE_NAME, + initial_partitioning_value: FIRST_PARTITION + ) + end +end diff --git a/db/schema_migrations/20240111134328 b/db/schema_migrations/20240111134328 new file mode 100644 index 00000000000..268782c5acc --- /dev/null +++ b/db/schema_migrations/20240111134328 @@ -0,0 +1 @@ +0fc191808377dfe56ece157bc9d44899ce8635260e894972f2d058822707b080
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index deca0111644..913c244b820 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -30071,6 +30071,9 @@ ALTER TABLE ONLY pages_domain_acme_orders ALTER TABLE ONLY pages_domains ADD CONSTRAINT pages_domains_pkey PRIMARY KEY (id); +ALTER TABLE ci_pipeline_variables + ADD CONSTRAINT partitioning_constraint CHECK ((partition_id = ANY (ARRAY[(100)::bigint, (101)::bigint]))) NOT VALID; + ALTER TABLE ONLY path_locks ADD CONSTRAINT path_locks_pkey PRIMARY KEY (id); diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index e433277d560..767ceb154a7 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -17235,8 +17235,8 @@ Represents a product analytics dashboard visualization. | <a id="customizablepermissionavailablefor"></a>`availableFor` | [`[String!]!`](#string) | Objects the permission is available for. | | <a id="customizablepermissiondescription"></a>`description` | [`String`](#string) | Description of the permission. | | <a id="customizablepermissionname"></a>`name` | [`String!`](#string) | Localized name of the permission. | -| <a id="customizablepermissionrequirement"></a>`requirement` | [`String`](#string) | Requirement of the permission. | -| <a id="customizablepermissionvalue"></a>`value` | [`String!`](#string) | Value of the permission. | +| <a id="customizablepermissionrequirement"></a>`requirement` | [`MemberRolePermission`](#memberrolepermission) | Requirement of the permission. | +| <a id="customizablepermissionvalue"></a>`value` | [`MemberRolePermission!`](#memberrolepermission) | Value of the permission. | ### `DastPreScanVerification` @@ -21487,7 +21487,7 @@ Represents a member role. | ---- | ---- | ----------- | | <a id="memberrolebaseaccesslevel"></a>`baseAccessLevel` **{warning-solid}** | [`AccessLevel!`](#accesslevel) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Base access level for the custom role. | | <a id="memberroledescription"></a>`description` | [`String`](#string) | Description of the member role. | -| <a id="memberroleenabledpermissions"></a>`enabledPermissions` **{warning-solid}** | [`[MemberRolePermission!]`](#memberrolepermission) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Array of all permissions enabled for the custom role. | +| <a id="memberroleenabledpermissions"></a>`enabledPermissions` **{warning-solid}** | [`CustomizablePermissionConnection`](#customizablepermissionconnection) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Array of all permissions enabled for the custom role. | | <a id="memberroleid"></a>`id` | [`MemberRoleID!`](#memberroleid) | ID of the member role. | | <a id="memberrolememberscount"></a>`membersCount` **{warning-solid}** | [`Int!`](#int) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. Total number of members with the custom role. | | <a id="memberrolename"></a>`name` | [`String!`](#string) | Name of the member role. | diff --git a/doc/ci/jobs/job_artifacts_troubleshooting.md b/doc/ci/jobs/job_artifacts_troubleshooting.md index 0b7777d2d82..470c1bf4b55 100644 --- a/doc/ci/jobs/job_artifacts_troubleshooting.md +++ b/doc/ci/jobs/job_artifacts_troubleshooting.md @@ -155,3 +155,26 @@ To troubleshoot this error, verify that: parent-child pipeline hierarchy. - The `pipeline` and `job` combination exists and resolves to an existing pipeline. - `dependency-job` has run and finished successfully. + +## Jobs show `UnlockPipelinesInQueueWorker` after an upgrade + +Jobs might stall and show an error that states `UnlockPipelinesInQueueWorker`. + +This issue occurs after an upgrade. + +The workaround is to enable the `ci_unlock_pipelines_extra_low` feature flag. +To toggle feature flags, you must be an administrator. + +On GitLab SaaS: + +- Run the following [ChatOps](../chatops/index.md) command: + + ```ruby + /chatops run feature set ci_unlock_pipelines_extra_low true + ``` + +On GitLab self-managed: + +- [Enable the feature flag](../../administration/feature_flags.md) named `ci_unlock_pipelines_extra_low`. + +For more information see the comment in [merge request 140318](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/140318#note_1718600424). diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index 447e0bd93da..156874d196d 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -1361,15 +1361,7 @@ job: > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223273) in GitLab 13.8 [with a flag](../../user/feature_flags.md) named `non_public_artifacts`, disabled by default. > - [Updated](https://gitlab.com/gitlab-org/gitlab/-/issues/322454) in GitLab 15.10. Artifacts created with `artifacts:public` before 15.10 are not guaranteed to remain private after this update. -> - [Updated](https://gitlab.com/gitlab-org/gitlab/-/issues/294503) in GitLab 16.7. Rolled out and removed a feature flag named `non_public_artifacts` - -WARNING: -On self-managed GitLab, by default this feature is not available. To make it available, -an administrator can [enable the feature flag](../../administration/feature_flags.md) named `non_public_artifacts`. On -GitLab.com, this feature is not available. Due to [issue 413822](https://gitlab.com/gitlab-org/gitlab/-/issues/413822), -the keyword can be used when the feature flag is disabled, but the feature does not work. -Do not attempt to use this feature when the feature flag is disabled, and always test -with non-production data first. +> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/294503) in GitLab 16.7. Feature flag `non_public_artifacts` removed. Use `artifacts:public` to determine whether the job artifacts should be publicly available. diff --git a/doc/development/documentation/styleguide/index.md b/doc/development/documentation/styleguide/index.md index af6a536b2e2..a18b376a1cc 100644 --- a/doc/development/documentation/styleguide/index.md +++ b/doc/development/documentation/styleguide/index.md @@ -243,7 +243,7 @@ by default. Capitalize names of: - GitLab [product tiers](https://about.gitlab.com/pricing/). For example, - GitLab Free and GitLab Ultimate. (Tested in [`BadgeCapitalization.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/BadgeCapitalization.yml).) + GitLab Free and GitLab Ultimate. - Third-party organizations, software, and products. For example, Prometheus, Kubernetes, Git, and The Linux Foundation. - Methods or methodologies. For example, Continuous Integration, diff --git a/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md b/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md index 6f48f83e7ca..ae33eeb49f4 100644 --- a/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md +++ b/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md @@ -50,6 +50,53 @@ It is encouraged to fill out as many of `user`, `namespace` and `project` as pos If a `project` but no `namespace` is provided, the `project.namespace` is used as the `namespace` for the event. +#### Controller and API helpers + +There is a helper module `ProductAnalyticsTracking` for controllers you can use to track internal events for particular controller actions by calling `#track_internal_event`: + +```ruby +class Projects::PipelinesController < Projects::ApplicationController + include ProductAnalyticsTracking + + track_internal_event :charts, name: 'p_analytics_ci_cd_pipelines', conditions: -> { should_track_ci_cd_pipelines? } + + def charts + ... + end + + private + + def should_track_ci_cd_pipelines? + params[:chart].blank? || params[:chart] == 'pipelines' + end +end +``` + +You need to add these two methods to the controller body, so that the helper can get the current project and namespace for the event: + +```ruby + private + + def tracking_namespace_source + project.namespace + end + + def tracking_project_source + project + end +``` + +Also, there is an API helper: + +```ruby +track_event( + event_name, + user: current_user, + namespace_id: namespace_id, + project_id: project_id +) +``` + ### Frontend tracking Any frontend tracking call automatically passes the values `user.id`, `namespace.id`, and `project.id` from the current context of the page. diff --git a/doc/development/pipelines/internals.md b/doc/development/pipelines/internals.md index df9a9d9c4ad..16d0bfdfa30 100644 --- a/doc/development/pipelines/internals.md +++ b/doc/development/pipelines/internals.md @@ -435,6 +435,8 @@ For this scenario, you have to: - `scripts/merge-simplecov` - `spec/simplecov_env_core.rb` - `spec/simplecov_env.rb` + - `prepare-as-if-foss-env` for: + - `scripts/setup/generate-as-if-foss-env.rb` Additionally, `scripts/utils.sh` is always downloaded from the API when this pattern is used (this file contains the code for `.fast-no-clone-job`). diff --git a/doc/integration/diffblue-cover.md b/doc/integration/diffblue-cover.md deleted file mode 100644 index fb181f8e95d..00000000000 --- a/doc/integration/diffblue-cover.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -redirect_to: 'diffblue_cover.md' -remove_date: '2024-01-10' ---- - -This document was moved to [another location](diffblue_cover.md). - -<!-- This redirect file can be deleted after <2024-01-10>. --> -<!-- Redirects that point to other docs in the same project expire in three months. --> -<!-- Redirects that point to docs in a different project or site (link is not relative and starts with `https:`) expire in one year. --> -<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html --> diff --git a/doc/user/analytics/analytics_dashboards.md b/doc/user/analytics/analytics_dashboards.md index 8355a7db5e0..e647dcf170a 100644 --- a/doc/user/analytics/analytics_dashboards.md +++ b/doc/user/analytics/analytics_dashboards.md @@ -122,12 +122,11 @@ To view the Value Streams Dashboard as an analytics dashboard for a project: ## View group dashboards -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/390542) in GitLab 16.2 [with a flag](../../administration/feature_flags.md) named `group_analytics_dashboards`. Disabled by default. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/390542) in GitLab 16.2 [with a flag](../../administration/feature_flags.md) named `group_analytics_dashboards`. Disabled by default. +> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/416970) in GitLab 16.8. FLAG: -On self-managed GitLab, by default this feature is not available. To make it available per project or for your entire instance, an administrator can [enable the feature flag](../../administration/feature_flags.md) named `group_analytics_dashboards`. -On GitLab.com, this feature is not available. -This feature is not ready for production use. +On self-managed GitLab, by default this feature is available. To hide the feature, an administrator can [disable the feature flag](../../administration/feature_flags.md) named `group_analytics_dashboards`. On GitLab.com, this feature is available. Prerequisites: diff --git a/doc/user/clusters/agent/index.md b/doc/user/clusters/agent/index.md index 66e67f56172..a764d0006a1 100644 --- a/doc/user/clusters/agent/index.md +++ b/doc/user/clusters/agent/index.md @@ -63,9 +63,9 @@ GitLab in a Kubernetes cluster, you might need a different version of Kubernetes You can upgrade your Kubernetes version to a supported version at any time: -- 1.27 (support ends on July 18, 2024 or when 1.30 becomes supported) -- 1.26 (support ends on March 21, 2024 or when 1.29 becomes supported) -- 1.25 (support ends on October 22, 2023 or when 1.28 becomes supported) +- 1.28 (support ends when GitLab version 17.5 is released or when 1.31 becomes supported) +- 1.27 (support ends when GitLab version 17.2 is released or when 1.30 becomes supported) +- 1.26 (support ends when GitLab version 16.10 is released or when 1.29 becomes supported) GitLab aims to support a new minor Kubernetes version three months after its initial release. GitLab supports at least three production-ready Kubernetes minor versions at any given time. diff --git a/doc/user/clusters/agent/install/index.md b/doc/user/clusters/agent/install/index.md index 8e4af7b0af4..b8caf6d0837 100644 --- a/doc/user/clusters/agent/install/index.md +++ b/doc/user/clusters/agent/install/index.md @@ -224,13 +224,19 @@ For the best experience, the version of the agent installed in your cluster shou ### Update the agent version +NOTE: +Instead of using `--reuse-values`, you should specify all needed values. +If you use `--reuse-values`, you might miss new defaults or use deprecated values. +To retrieve previous `--set` arguments, use `helm get values <release name>`. +You can save the values to a file with `helm get values gitlab-agent > agent.yaml`, and pass the file to Helm with `-f`: +`helm upgrade gitlab-agent gitlab/gitlab-agent -f agent.yaml`. This safely replaces the behavior of `--reuse-values`. + To update the agent to the latest version, you can run: ```shell helm repo update helm upgrade --install gitlab-agent gitlab/gitlab-agent \ --namespace gitlab-agent \ - --reuse-values ``` To set a specific version, you can override the `image.tag` value. For example, to install version `v14.9.1`, run: @@ -238,7 +244,6 @@ To set a specific version, you can override the `image.tag` value. For example, ```shell helm upgrade gitlab-agent gitlab/gitlab-agent \ --namespace gitlab-agent \ - --reuse-values \ --set image.tag=v14.9.1 ``` diff --git a/gems/gitlab-rspec_flaky/lib/gitlab/rspec_flaky/listener.rb b/gems/gitlab-rspec_flaky/lib/gitlab/rspec_flaky/listener.rb index 6f4dce9df33..c40d9e90b56 100644 --- a/gems/gitlab-rspec_flaky/lib/gitlab/rspec_flaky/listener.rb +++ b/gems/gitlab-rspec_flaky/lib/gitlab/rspec_flaky/listener.rb @@ -36,6 +36,10 @@ module Gitlab end def dump_summary(_) + rails_logger_warn( + "\n#{flaky_examples.count} known flaky example(s) detected. " \ + "Writing this to #{Config.flaky_examples_report_path}.\n" + ) Report.new(flaky_examples).write(Config.flaky_examples_report_path) return unless new_flaky_examples.any? diff --git a/gems/gitlab-rspec_flaky/spec/gitlab/rspec_flaky/listener_spec.rb b/gems/gitlab-rspec_flaky/spec/gitlab/rspec_flaky/listener_spec.rb index b46044e4521..a830226d88f 100644 --- a/gems/gitlab-rspec_flaky/spec/gitlab/rspec_flaky/listener_spec.rb +++ b/gems/gitlab-rspec_flaky/spec/gitlab/rspec_flaky/listener_spec.rb @@ -197,6 +197,8 @@ RSpec.describe Gitlab::RspecFlaky::Listener, :aggregate_failures do end describe '#dump_summary' do + subject { listener.dump_summary(nil) } + let(:listener) { described_class.new(suite_flaky_example_report.to_json) } let(:new_flaky_rspec_example) { double(new_example_attrs.merge(attempts: 2)) } let(:already_flaky_rspec_example) { double(already_flaky_example_attrs.merge(attempts: 2)) } @@ -207,6 +209,39 @@ RSpec.describe Gitlab::RspecFlaky::Listener, :aggregate_failures do allow(Kernel).to receive(:warn) end + context 'when not flaky tests were found' do + it 'prints a message in the console' do + allow(Kernel).to receive(:warn).and_call_original + + expect { subject }.to output( + %r{0 known flaky example\(s\) detected\. Writing this to rspec/flaky/report\.json} + ).to_stderr + end + end + + context 'when existing flaky tests were found' do + before do + listener.example_passed(notification_already_flaky_rspec_example) + end + + it 'does write them in the correct report' do + report = double + + expect(Gitlab::RspecFlaky::Report).to receive(:new).with(listener.flaky_examples).and_return(report) + expect(report).to receive(:write).with(Gitlab::RspecFlaky::Config.flaky_examples_report_path) + + subject + end + + it 'prints a message in the console' do + allow(Kernel).to receive(:warn).and_call_original + + expect { subject }.to output( + %r{1 known flaky example\(s\) detected\. Writing this to rspec/flaky/report\.json} + ).to_stderr + end + end + context 'when a report file path is set by FLAKY_RSPEC_REPORT_PATH' do it 'delegates the writes to RspecFlaky::Report' do listener.example_passed(notification_new_flaky_rspec_example) @@ -222,7 +257,7 @@ RSpec.describe Gitlab::RspecFlaky::Listener, :aggregate_failures do .to receive(:new).with(listener.__send__(:new_flaky_examples)).and_return(report2) expect(report2).to receive(:write).with(Gitlab::RspecFlaky::Config.new_flaky_examples_report_path) - listener.dump_summary(nil) + subject end end end diff --git a/lib/api/api.rb b/lib/api/api.rb index 97e09795f49..94b433193dd 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -330,7 +330,7 @@ module API mount ::API::Suggestions mount ::API::SystemHooks mount ::API::Tags - mount ::API::Terraform::Modules::V1::Packages + mount ::API::Terraform::Modules::V1::NamespacePackages mount ::API::Terraform::Modules::V1::ProjectPackages mount ::API::Terraform::State mount ::API::Terraform::StateVersion diff --git a/lib/api/terraform/modules/v1/packages.rb b/lib/api/terraform/modules/v1/namespace_packages.rb index 9e82a849c98..1999fc42aba 100644 --- a/lib/api/terraform/modules/v1/packages.rb +++ b/lib/api/terraform/modules/v1/namespace_packages.rb @@ -4,7 +4,7 @@ module API module Terraform module Modules module V1 - class Packages < ::API::Base + class NamespacePackages < ::API::Base include ::API::Helpers::Authentication helpers ::API::Helpers::PackagesHelpers helpers ::API::Helpers::Packages::BasicAuthHelpers @@ -29,8 +29,10 @@ module API end helpers do + include ::Gitlab::Utils::StrongMemoize + params :module_name do - requires :module_name, type: String, desc: "", regexp: API::NO_SLASH_URL_PART_REGEX + requires :module_name, type: String, desc: '', regexp: API::NO_SLASH_URL_PART_REGEX requires :module_system, type: String, regexp: API::NO_SLASH_URL_PART_REGEX end @@ -39,10 +41,9 @@ module API end def module_namespace - strong_memoize(:module_namespace) do - find_namespace(params[:module_namespace]) - end + find_namespace(params[:module_namespace]) end + strong_memoize_attr :module_namespace def finder_params { @@ -55,26 +56,23 @@ module API end def packages - strong_memoize(:packages) do - ::Packages::GroupPackagesFinder.new( - current_user, - module_namespace, - finder_params - ).execute - end + ::Packages::GroupPackagesFinder.new( + current_user, + module_namespace, + finder_params + ).execute end + strong_memoize_attr :packages def package - strong_memoize(:package) do - packages.first - end + packages.first end + strong_memoize_attr :package def package_file - strong_memoize(:package_file) do - package.installable_package_files.first - end + package.installable_package_files.first end + strong_memoize_attr :package_file end params do @@ -82,7 +80,8 @@ module API includes :module_name end - namespace 'packages/terraform/modules/v1/:module_namespace/:module_name/:module_system', requirements: TERRAFORM_MODULE_REQUIREMENTS do + namespace 'packages/terraform/modules/v1/:module_namespace/:module_name/:module_system', + requirements: TERRAFORM_MODULE_REQUIREMENTS do authenticate_with do |accept| accept.token_types(:personal_access_token, :deploy_token, :job_token) .sent_through(:http_bearer_token) @@ -118,7 +117,9 @@ module API get 'download' do latest_version = packages.order_version.last&.version - render_api_error!({ error: "No version found for #{params[:module_name]} module" }, :not_found) if latest_version.nil? + if latest_version.nil? + render_api_error!({ error: "No version found for #{params[:module_name]} module" }, :not_found) + end download_path = api_v4_packages_terraform_modules_v1_module_version_download_path( { @@ -145,7 +146,9 @@ module API get do latest_package = packages.order_version.last - render_api_error!({ error: "No version found for #{params[:module_name]} module" }, :not_found) if latest_package&.version.nil? + if latest_package&.version.nil? + render_api_error!({ error: "No version found for #{params[:module_name]} module" }, :not_found) + end presenter = ::Terraform::ModuleVersionPresenter.new(latest_package, params[:module_system]) present presenter, with: ::API::Entities::Terraform::ModuleVersion @@ -181,13 +184,18 @@ module API jwt_token = Gitlab::TerraformRegistryToken.from_token(token_from_namespace_inheritable).encoded end - header 'X-Terraform-Get', module_file_path.sub(%r{module_version/file$}, "#{params[:module_version]}/file?token=#{jwt_token}&archive=tgz") + header 'X-Terraform-Get', + module_file_path.sub( + %r{module_version/file$}, + "#{params[:module_version]}/file?token=#{jwt_token}&archive=tgz" + ) status :no_content end namespace 'file' do authenticate_with do |accept| - accept.token_types(:deploy_token_from_jwt, :job_token_from_jwt, :personal_access_token_from_jwt).sent_through(:token_param) + accept.token_types(:deploy_token_from_jwt, :job_token_from_jwt, :personal_access_token_from_jwt) + .sent_through(:token_param) end desc 'Download specific version of a module' do @@ -200,9 +208,14 @@ module API tags %w[terraform_registry] end get do - track_package_event('pull_package', :terraform_module, project: package.project, namespace: module_namespace) - - present_carrierwave_file!(package_file.file) + track_package_event( + 'pull_package', + :terraform_module, + project: package.project, + namespace: module_namespace + ) + + present_package_file!(package_file) end end diff --git a/lib/gitlab/database/partitioning/list/convert_table.rb b/lib/gitlab/database/partitioning/list/convert_table.rb index 9889d01be76..542a7d0a78d 100644 --- a/lib/gitlab/database/partitioning/list/convert_table.rb +++ b/lib/gitlab/database/partitioning/list/convert_table.rb @@ -22,7 +22,7 @@ module Gitlab @table_name = table_name @parent_table_name = parent_table_name @partitioning_column = partitioning_column - @zero_partition_value = zero_partition_value + @zero_partition_value = Array.wrap(zero_partition_value) end def prepare_for_partitioning(async: false) @@ -126,10 +126,11 @@ module Gitlab .check_constraints .including_column(partitioning_column) - check_body = "CHECK ((#{partitioning_column} = #{zero_partition_value}))" + array_prefix = "CHECK ((#{partitioning_column} = ANY " + single_prefix = "CHECK ((#{partitioning_column} = #{zero_partition_value.join(',')}))" constraints_on_column.find do |constraint| - constraint.definition.start_with?(check_body) + constraint.definition.start_with?(array_prefix, single_prefix) end end @@ -138,14 +139,14 @@ module Gitlab raise UnableToPartition, <<~MSG Table #{table_name} is not ready for partitioning. - Before partitioning, a check constraint must enforce that (#{partitioning_column} = #{zero_partition_value}) + Before partitioning, a check constraint must enforce that (#{partitioning_column} IN (#{zero_partition_value.join(',')})) MSG end def add_partitioning_check_constraint(async: false) return validate_partitioning_constraint_synchronously if partitioning_constraint.present? - check_body = "#{partitioning_column} = #{connection.quote(zero_partition_value)}" + check_body = "#{partitioning_column} IN (#{zero_partition_value.join(',')})" # Any constraint name would work. The constraint is found based on its definition before partitioning migration_context.add_check_constraint( table_name, check_body, PARTITIONING_CONSTRAINT_NAME, @@ -214,7 +215,7 @@ module Gitlab <<~SQL ALTER TABLE #{quote_table_name(parent_table_name)} ATTACH PARTITION #{table_name} - FOR VALUES IN (#{zero_partition_value}) + FOR VALUES IN (#{zero_partition_value.join(',')}) SQL end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 574b52eb337..d82ab30385e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14031,6 +14031,9 @@ msgstr "" msgid "Converts work item to %{type}. Widgets not supported in new type are removed." msgstr "" +msgid "Cookie Preferences" +msgstr "" + msgid "Cookie domain" msgstr "" @@ -14352,6 +14355,9 @@ msgstr "" msgid "Couldn't assign policy to project or group" msgstr "" +msgid "Couldn't fetch the pinned file." +msgstr "" + msgid "Couldn't find event type filters where audit event type(s): %{missing_filters}" msgstr "" @@ -52375,6 +52381,9 @@ msgstr "" msgid "Unlocks the discussion." msgstr "" +msgid "Unpin the file" +msgstr "" + msgid "Unreachable" msgstr "" diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb index 028a6d37af4..c7ad909e6de 100644 --- a/qa/qa/page/main/login.rb +++ b/qa/qa/page/main/login.rb @@ -211,8 +211,7 @@ module QA def redirect_to_login_page(address) Menu.perform(&:sign_out_if_signed_in) - desired_host = URI(Runtime::Scenario.send("#{address}_address")).host - Runtime::Browser.visit(address, Page::Main::Login) if desired_host != current_host + Runtime::Browser.visit(address, Page::Main::Login) end private diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 55741a82862..d04cda240fa 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -244,6 +244,24 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review expect(response).to have_gitlab_http_status(:moved_permanently) end end + + context 'when has pinned file' do + let(:file) { merge_request.merge_request_diff.diffs.diff_files.first } + let(:file_hash) { file.file_hash } + + it 'adds pinned file url' do + go(pin: file_hash) + + expect(assigns['pinned_file_url']).to eq( + diff_by_file_hash_namespace_project_merge_request_path( + format: 'json', + id: merge_request.iid, + namespace_id: project.namespace.to_param, + project_id: project.path, + file_hash: file_hash + )) + end + end end context 'when user is setting notes filters' do diff --git a/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js index c4476d01386..adb07d4086d 100644 --- a/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js +++ b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js @@ -35,8 +35,7 @@ describe('RunnerTypeBadge', () => { expect(findBadge().classes().sort()).toEqual( [ ...classes, - 'gl-border', - 'gl-display-inline-block', + 'gl-inset-border-1-gray-400', 'gl-max-w-full', 'gl-text-truncate', 'gl-bg-transparent!', diff --git a/spec/frontend/diffs/components/__snapshots__/tree_list_spec.js.snap b/spec/frontend/diffs/components/__snapshots__/tree_list_spec.js.snap new file mode 100644 index 00000000000..605f6335b5c --- /dev/null +++ b/spec/frontend/diffs/components/__snapshots__/tree_list_spec.js.snap @@ -0,0 +1,160 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Diffs tree list component pinned file files in folders pins 1.rb file 1`] = ` +Array [ + "📁folder/", + "──1.rb", + "📁folder", + "──📁sub-folder", + "────nested-1.rb", + "────nested-2.rb", + "────nested-3.rb", + "──2.rb", + "──3.rb", + "📁folder-single", + "──single.rb", + "root-first.rb", + "root-last.rb", +] +`; + +exports[`Diffs tree list component pinned file files in folders pins 2.rb file 1`] = ` +Array [ + "📁folder/", + "──2.rb", + "📁folder", + "──📁sub-folder", + "────nested-1.rb", + "────nested-2.rb", + "────nested-3.rb", + "──1.rb", + "──3.rb", + "📁folder-single", + "──single.rb", + "root-first.rb", + "root-last.rb", +] +`; + +exports[`Diffs tree list component pinned file files in folders pins 3.rb file 1`] = ` +Array [ + "📁folder/", + "──3.rb", + "📁folder", + "──📁sub-folder", + "────nested-1.rb", + "────nested-2.rb", + "────nested-3.rb", + "──1.rb", + "──2.rb", + "📁folder-single", + "──single.rb", + "root-first.rb", + "root-last.rb", +] +`; + +exports[`Diffs tree list component pinned file files in folders pins nested-1.rb file 1`] = ` +Array [ + "📁folder/sub-folder/", + "──nested-1.rb", + "📁folder", + "──📁sub-folder", + "────nested-2.rb", + "────nested-3.rb", + "──1.rb", + "──2.rb", + "──3.rb", + "📁folder-single", + "──single.rb", + "root-first.rb", + "root-last.rb", +] +`; + +exports[`Diffs tree list component pinned file files in folders pins nested-2.rb file 1`] = ` +Array [ + "📁folder/sub-folder/", + "──nested-2.rb", + "📁folder", + "──📁sub-folder", + "────nested-1.rb", + "────nested-3.rb", + "──1.rb", + "──2.rb", + "──3.rb", + "📁folder-single", + "──single.rb", + "root-first.rb", + "root-last.rb", +] +`; + +exports[`Diffs tree list component pinned file files in folders pins nested-3.rb file 1`] = ` +Array [ + "📁folder/sub-folder/", + "──nested-3.rb", + "📁folder", + "──📁sub-folder", + "────nested-1.rb", + "────nested-2.rb", + "──1.rb", + "──2.rb", + "──3.rb", + "📁folder-single", + "──single.rb", + "root-first.rb", + "root-last.rb", +] +`; + +exports[`Diffs tree list component pinned file files in folders pins root-first.rb file 1`] = ` +Array [ + "root-first.rb", + "📁folder", + "──📁sub-folder", + "────nested-1.rb", + "────nested-2.rb", + "────nested-3.rb", + "──1.rb", + "──2.rb", + "──3.rb", + "📁folder-single", + "──single.rb", + "root-last.rb", +] +`; + +exports[`Diffs tree list component pinned file files in folders pins root-last.rb file 1`] = ` +Array [ + "root-last.rb", + "📁folder", + "──📁sub-folder", + "────nested-1.rb", + "────nested-2.rb", + "────nested-3.rb", + "──1.rb", + "──2.rb", + "──3.rb", + "📁folder-single", + "──single.rb", + "root-first.rb", +] +`; + +exports[`Diffs tree list component pinned file files in folders pins single.rb file 1`] = ` +Array [ + "📁folder-single/", + "──single.rb", + "📁folder", + "──📁sub-folder", + "────nested-1.rb", + "────nested-2.rb", + "────nested-3.rb", + "──1.rb", + "──2.rb", + "──3.rb", + "root-first.rb", + "root-last.rb", +] +`; diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 63d9a2471b6..813db12e83f 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -31,6 +31,9 @@ import * as urlUtils from '~/lib/utils/url_utility'; import * as commonUtils from '~/lib/utils/common_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { stubPerformanceWebAPI } from 'helpers/performance'; +import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file'; +import waitForPromises from 'helpers/wait_for_promises'; +import { diffMetadata } from 'jest/diffs/mock_data/diff_metadata'; import createDiffsStore from '../create_diffs_store'; import diffsMockData from '../mock_data/merge_request_diffs'; @@ -38,6 +41,8 @@ const mergeRequestDiff = { version_index: 1 }; const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`; const COMMIT_URL = `${TEST_HOST}/COMMIT/OLD`; const UPDATED_COMMIT_URL = `${TEST_HOST}/COMMIT/NEW`; +const ENDPOINT_BATCH_URL = `${TEST_HOST}/diff/endpointBatch`; +const ENDPOINT_METADATA_URL = `${TEST_HOST}/diff/endpointMetadata`; Vue.use(Vuex); Vue.use(VueApollo); @@ -77,8 +82,8 @@ describe('diffs/components/app', () => { store.dispatch('diffs/setBaseConfig', { endpoint: TEST_ENDPOINT, - endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`, - endpointBatch: `${TEST_HOST}/diff/endpointBatch`, + endpointMetadata: ENDPOINT_METADATA_URL, + endpointBatch: ENDPOINT_BATCH_URL, endpointDiffForPath: TEST_ENDPOINT, projectPath: 'namespace/project', dismissEndpoint: '', @@ -126,7 +131,7 @@ describe('diffs/components/app', () => { const fetchResolver = () => { store.state.diffs.retrievingBatches = false; store.state.notes.doneFetchingBatchDiscussions = true; - store.state.notes.discussions = 'test'; + store.state.notes.discussions = []; return Promise.resolve({ real_size: 100 }); }; jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn()); @@ -861,4 +866,32 @@ describe('diffs/components/app', () => { expect(loadSpy).not.toHaveBeenCalledWith({ file: store.state.diffs.diffFiles[0] }); }); }); + + describe('pinned file', () => { + const pinnedFileUrl = 'http://localhost.test/pinned-file'; + let pinnedFile; + + beforeEach(() => { + pinnedFile = getDiffFileMock(); + mock.onGet(pinnedFileUrl).reply(HTTP_STATUS_OK, { diff_files: [pinnedFile] }); + mock + .onGet(new RegExp(ENDPOINT_BATCH_URL)) + .reply(HTTP_STATUS_OK, { diff_files: [], pagination: {} }); + mock.onGet(new RegExp(ENDPOINT_METADATA_URL)).reply(HTTP_STATUS_OK, diffMetadata); + + createComponent({ shouldShow: true, pinnedFileUrl }); + }); + + it('fetches and displays pinned file', async () => { + await waitForPromises(); + + expect(wrapper.findComponent({ name: 'DynamicScroller' }).props('items')[0].file_hash).toBe( + pinnedFile.file_hash, + ); + }); + + it('shows a spinner during loading', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index d6539a5bffa..c02875963fd 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -1,8 +1,8 @@ -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import { cloneDeep } from 'lodash'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; @@ -20,6 +20,7 @@ import { truncateSha } from '~/lib/utils/text_utility'; import { __, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { TEST_HOST } from 'spec/test_constants'; import testAction from '../../__helpers__/vuex_action_helper'; import diffDiscussionsMockData from '../mock_data/diff_discussions'; @@ -73,6 +74,7 @@ describe('DiffFileHeader component', () => { setFileCollapsedByUser: jest.fn(), setFileForcedOpen: jest.fn(), reviewFile: jest.fn(), + unpinFile: jest.fn(), }, }, }, @@ -87,7 +89,7 @@ describe('DiffFileHeader component', () => { }); const findHeader = () => wrapper.findComponent({ ref: 'header' }); - const findTitleLink = () => wrapper.findComponent({ ref: 'titleWrapper' }); + const findTitleLink = () => wrapper.findByTestId('file-title'); const findExpandButton = () => wrapper.findComponent({ ref: 'expandDiffToFullFileButton' }); const findFileActions = () => wrapper.find('.file-actions'); const findModeChangedLine = () => wrapper.findComponent({ ref: 'fileMode' }); @@ -105,7 +107,7 @@ describe('DiffFileHeader component', () => { mockStoreConfig = cloneDeep(defaultMockStoreConfig); const store = new Vuex.Store({ ...mockStoreConfig, ...options.store }); - wrapper = shallowMount(DiffFileHeader, { + wrapper = shallowMountExtended(DiffFileHeader, { propsData: { diffFile, canCurrentUserFork: false, @@ -711,4 +713,23 @@ describe('DiffFileHeader component', () => { expect(wrapper.find('[data-testid="comment-files-button"]').exists()).toEqual(true); }); + + describe('pinned file', () => { + beforeEach(() => { + window.gon.features = { pinnedFile: true }; + }); + + it('has pinned URL search param', () => { + createComponent(); + const url = new URL(TEST_HOST + findTitleLink().attributes('href')); + expect(url.searchParams.get('pin')).toBe(diffFile.file_hash); + }); + + it('can unpin file', () => { + createComponent({ props: { addMergeRequestButtons: true, pinned: true } }); + const unpinButton = wrapper.findComponentByTestId('unpin-button'); + unpinButton.vm.$emit('click'); + expect(mockStoreConfig.modules.diffs.actions.unpinFile).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index a9fbf4632ac..444f4102e26 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -29,6 +29,7 @@ import createNotesStore from '~/notes/stores/modules'; import diffsModule from '~/diffs/store/modules'; import { SOMETHING_WENT_WRONG, SAVING_THE_COMMENT_FAILED } from '~/diffs/i18n'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; +import { SET_PINNED_FILE_HASH } from '~/diffs/store/mutation_types'; import { getDiffFileMock } from '../mock_data/diff_file'; import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable'; import diffsMockData from '../mock_data/merge_request_diffs'; @@ -90,49 +91,6 @@ function markFileToBeRendered(store, index = 0) { }); } -function createComponent({ file, first = false, last = false, options = {}, props = {} }) { - const diffs = diffsModule(); - diffs.actions = { - ...diffs.actions, - prefetchFileNeighbors: prefetchFileNeighborsMock, - saveDiffDiscussion: saveDiffDiscussionMock, - }; - - diffs.getters = { - ...diffs.getters, - diffCompareDropdownTargetVersions: () => [], - diffCompareDropdownSourceVersions: () => [], - }; - - const store = new Vuex.Store({ - ...createNotesStore(), - modules: { diffs }, - }); - - store.state.diffs = { - mergeRequestDiff: diffsMockData[0], - diffFiles: [file], - }; - - const wrapper = shallowMountExtended(DiffFileComponent, { - store, - propsData: { - file, - canCurrentUserFork: false, - viewDiffsFileByFile: false, - isFirstFile: first, - isLastFile: last, - ...props, - }, - ...options, - }); - - return { - wrapper, - store, - }; -} - const findDiffHeader = (wrapper) => wrapper.findComponent(DiffFileHeaderComponent); const findDiffContentArea = (wrapper) => wrapper.findByTestId('content-area'); const findLoader = (wrapper) => wrapper.findByTestId('loader-icon'); @@ -159,15 +117,58 @@ const triggerSaveDraftNote = (wrapper, note, parent, error) => findNoteForm(wrapper).vm.$emit('handleFormUpdateAddToReview', note, false, parent, error); describe('DiffFile', () => { - let readableFile; let wrapper; let store; let axiosMock; + function createComponent({ + file = getReadableFile(), + first = false, + last = false, + options = {}, + props = {}, + } = {}) { + const diffs = diffsModule(); + diffs.actions = { + ...diffs.actions, + prefetchFileNeighbors: prefetchFileNeighborsMock, + saveDiffDiscussion: saveDiffDiscussionMock, + }; + + diffs.getters = { + ...diffs.getters, + diffCompareDropdownTargetVersions: () => [], + diffCompareDropdownSourceVersions: () => [], + }; + + store = new Vuex.Store({ + ...createNotesStore(), + modules: { diffs }, + }); + + store.state.diffs = { + ...store.state.diffs, + mergeRequestDiff: diffsMockData[0], + diffFiles: [file], + }; + + wrapper = shallowMountExtended(DiffFileComponent, { + store, + propsData: { + file, + canCurrentUserFork: false, + viewDiffsFileByFile: false, + isFirstFile: first, + isLastFile: last, + ...props, + }, + ...options, + }); + } + beforeEach(() => { - readableFile = getReadableFile(); axiosMock = new MockAdapter(axios); - ({ wrapper, store } = createComponent({ file: readableFile })); + createComponent(); }); afterEach(() => { @@ -186,7 +187,6 @@ describe('DiffFile', () => { `('$description', ({ fileByFile }) => { createComponent({ props: { viewDiffsFileByFile: fileByFile }, - file: readableFile, }); if (fileByFile) { @@ -217,11 +217,11 @@ describe('DiffFile', () => { forceHasDiff({ store, ...file }); } - ({ wrapper, store } = createComponent({ + createComponent({ file: store.state.diffs.diffFiles[0], first, last, - })); + }); await nextTick(); @@ -233,14 +233,13 @@ describe('DiffFile', () => { ); it('emits the "first file shown" and "files end" events when in File-by-File mode', async () => { - ({ wrapper, store } = createComponent({ - file: getReadableFile(), + createComponent({ first: false, last: false, props: { viewDiffsFileByFile: true, }, - })); + }); await nextTick(); @@ -253,11 +252,11 @@ describe('DiffFile', () => { describe('after loading the diff', () => { it('indicates that it loaded the file', async () => { forceHasDiff({ store, inlineLines: [], parallelLines: [], readableText: true }); - ({ wrapper, store } = createComponent({ + createComponent({ file: store.state.diffs.diffFiles[0], first: true, last: true, - })); + }); jest.spyOn(wrapper.vm, 'loadCollapsedDiff').mockResolvedValue(getReadableFile()); jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn()); @@ -314,11 +313,11 @@ describe('DiffFile', () => { `('should be $bool when { userIsLoggedIn: $loggedIn }', ({ loggedIn, bool }) => { setLoggedIn(loggedIn); - ({ wrapper } = createComponent({ + createComponent({ props: { file: store.state.diffs.diffFiles[0], }, - })); + }); expect(wrapper.vm.showLocalFileReviews).toBe(bool); }); @@ -556,7 +555,7 @@ describe('DiffFile', () => { describe('general (other) collapsed', () => { it('should be expandable for unreadable files', async () => { - ({ wrapper, store } = createComponent({ file: getUnreadableFile() })); + createComponent({ file: getUnreadableFile() }); makeFileAutomaticallyCollapsed(store); await nextTick(); @@ -622,7 +621,7 @@ describe('DiffFile', () => { renderIt: true, }; - ({ wrapper, store } = createComponent({ file })); + createComponent({ file }); expect(wrapper.findByTestId('conflictsAlert').exists()).toBe(false); }); @@ -634,7 +633,7 @@ describe('DiffFile', () => { renderIt: true, }; - ({ wrapper, store } = createComponent({ file })); + createComponent({ file }); expect(wrapper.findByTestId('conflictsAlert').exists()).toBe(true); }); @@ -656,9 +655,9 @@ describe('DiffFile', () => { ...extraProps, }; - ({ wrapper, store } = createComponent({ + createComponent({ file, - })); + }); expect(wrapper.findByTestId('file-discussions').exists()).toEqual(exists); }, @@ -676,9 +675,9 @@ describe('DiffFile', () => { hasCommentForm, }; - ({ wrapper, store } = createComponent({ + createComponent({ file, - })); + }); expect(findNoteForm(wrapper).exists()).toEqual(exists); }, @@ -694,9 +693,9 @@ describe('DiffFile', () => { discussions, }; - ({ wrapper, store } = createComponent({ + createComponent({ file, - })); + }); expect(wrapper.findByTestId('diff-file-discussions').exists()).toEqual(exists); }); @@ -712,10 +711,10 @@ describe('DiffFile', () => { const errorCallback = jest.fn(); beforeEach(() => { - ({ wrapper, store } = createComponent({ + createComponent({ file, options: { provide: { glFeatures: { commentOnFiles: true } } }, - })); + }); }); it('calls saveDiffDiscussionMock', () => { @@ -771,10 +770,10 @@ describe('DiffFile', () => { const errorCallback = jest.fn(); beforeEach(async () => { - ({ wrapper, store } = createComponent({ + createComponent({ file, options: { provide: { glFeatures: { commentOnFiles: true } } }, - })); + }); triggerSaveDraftNote(wrapper, note, parentElement, errorCallback); @@ -791,4 +790,13 @@ describe('DiffFile', () => { }); }); }); + + describe('pinned file', () => { + it('passes down pinned prop', async () => { + createComponent(); + store.commit(`diffs/${SET_PINNED_FILE_HASH}`, getReadableFile().file_hash); + await nextTick(); + expect(wrapper.findComponent(DiffFileHeaderComponent).props('pinned')).toBe(true); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js index 6e9eb433924..bd9592e4f5e 100644 --- a/spec/frontend/diffs/components/diff_row_utils_spec.js +++ b/spec/frontend/diffs/components/diff_row_utils_spec.js @@ -6,6 +6,7 @@ import { NEW_NO_NEW_LINE_TYPE, EMPTY_CELL_TYPE, } from '~/diffs/constants'; +import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file'; const LINE_CODE = 'abc123'; @@ -108,15 +109,47 @@ describe('diff_row_utils', () => { describe('lineHref', () => { it(`should return #${LINE_CODE}`, () => { - expect(utils.lineHref({ line_code: LINE_CODE })).toEqual(`#${LINE_CODE}`); + expect(utils.lineHref({ line_code: LINE_CODE }, {})).toEqual(`#${LINE_CODE}`); }); it(`should return '#' if line is undefined`, () => { - expect(utils.lineHref()).toEqual('#'); + expect(utils.lineHref()).toEqual(''); }); it(`should return '#' if line_code is undefined`, () => { - expect(utils.lineHref({})).toEqual('#'); + expect(utils.lineHref({}, {})).toEqual(''); + }); + + describe('pinned file', () => { + beforeEach(() => { + window.gon.features = { pinnedFile: true }; + }); + + afterEach(() => { + delete window.gon.features; + }); + + it(`should return pinned file URL`, () => { + const diffFile = getDiffFileMock(); + expect(utils.lineHref({ line_code: LINE_CODE }, { diffFile })).toEqual( + `?pin=${diffFile.file_hash}#${LINE_CODE}`, + ); + }); + }); + }); + + describe('pinnedFileHref', () => { + beforeEach(() => { + window.gon.features = { pinnedFile: true }; + }); + + afterEach(() => { + delete window.gon.features; + }); + + it(`should return pinned file URL`, () => { + const diffFile = getDiffFileMock(); + expect(utils.pinnedFileHref(diffFile)).toEqual(`?pin=${diffFile.file_hash}`); }); }); diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js index a54cf9b8bff..230839f0ecf 100644 --- a/spec/frontend/diffs/components/tree_list_spec.js +++ b/spec/frontend/diffs/components/tree_list_spec.js @@ -7,6 +7,9 @@ import batchComments from '~/batch_comments/stores/modules/batch_comments'; import DiffFileRow from '~/diffs/components//diff_file_row.vue'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { SET_PINNED_FILE_HASH, SET_TREE_DATA, SET_DIFF_FILES } from '~/diffs/store/mutation_types'; +import { generateTreeList } from '~/diffs/utils/tree_worker_utils'; +import { sortTree } from '~/ide/stores/utils'; describe('Diffs tree list component', () => { let wrapper; @@ -58,6 +61,14 @@ describe('Diffs tree list component', () => { const setupFilesInState = () => { const treeEntries = { + app: { + key: 'app', + path: 'app', + name: 'app', + type: 'tree', + tree: [], + opened: true, + }, 'index.js': { addedLines: 0, changed: true, @@ -71,6 +82,8 @@ describe('Diffs tree list component', () => { type: 'blob', parentPath: 'app', tree: [], + file_path: 'app/index.js', + file_hash: 'app-index', }, 'test.rb': { addedLines: 0, @@ -85,20 +98,39 @@ describe('Diffs tree list component', () => { type: 'blob', parentPath: 'app', tree: [], + file_path: 'app/test.rb', + file_hash: 'app-test', }, - app: { - key: 'app', - path: 'app', - name: 'app', - type: 'tree', + LICENSE: { + addedLines: 0, + changed: true, + deleted: false, + fileHash: 'LICENSE', + key: 'LICENSE', + name: 'LICENSE', + path: 'LICENSE', + removedLines: 0, + tempFile: true, + type: 'blob', + parentPath: '/', tree: [], + file_path: 'LICENSE', + file_hash: 'LICENSE', }, }; Object.assign(store.state.diffs, { treeEntries, - tree: [treeEntries['index.js'], treeEntries.app], + tree: [ + treeEntries.LICENSE, + { + ...treeEntries.app, + tree: [treeEntries['index.js'], treeEntries['test.rb']], + }, + ], }); + + return treeEntries; }; describe('default', () => { @@ -149,7 +181,7 @@ describe('Diffs tree list component', () => { }); it('renders tree', () => { - expect(getScroller().props('items')).toHaveLength(2); + expect(getScroller().props('items')).toHaveLength(4); }); it('hides file stats', () => { @@ -169,7 +201,7 @@ describe('Diffs tree list component', () => { store.state.diffs.renderTreeList = false; await nextTick(); - expect(getScroller().props('items')).toHaveLength(3); + expect(getScroller().props('items')).toHaveLength(5); }); }); @@ -188,4 +220,59 @@ describe('Diffs tree list component', () => { expect(getFileRow().props('viewedFiles')).toBe(viewedDiffFileIds); }); }); + + describe('pinned file', () => { + const filePaths = [ + ['nested-1.rb', 'folder/sub-folder/'], + ['nested-2.rb', 'folder/sub-folder/'], + ['nested-3.rb', 'folder/sub-folder/'], + ['1.rb', 'folder/'], + ['2.rb', 'folder/'], + ['3.rb', 'folder/'], + ['single.rb', 'folder-single/'], + ['root-first.rb'], + ['root-last.rb'], + ]; + + const pinFile = (fileHash) => { + store.commit(`diffs/${SET_PINNED_FILE_HASH}`, fileHash); + }; + + const setupFiles = (diffFiles) => { + const { treeEntries, tree } = generateTreeList(diffFiles); + store.commit(`diffs/${SET_DIFF_FILES}`, diffFiles); + store.commit(`diffs/${SET_TREE_DATA}`, { + treeEntries, + tree: sortTree(tree), + }); + }; + + const createFile = (name, path = '') => ({ + file_hash: name, + path: `${path}${name}`, + new_path: `${path}${name}`, + file_path: `${path}${name}`, + }); + + beforeEach(() => { + createComponent(); + setupFiles(filePaths.map(([name, path]) => createFile(name, path))); + }); + + describe('files in folders', () => { + it.each(filePaths.map((path) => path[0]))('pins %s file', async (pinnedFile) => { + pinFile(pinnedFile); + await nextTick(); + const items = getScroller().props('items'); + expect( + items.map( + (item) => + `${'─'.repeat(item.level * 2)}${item.type === 'tree' ? '📁' : ''}${ + item.name || item.path + }`, + ), + ).toMatchSnapshot(); + }); + }); + }); }); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index be3b30e8e7a..c68f3c5991e 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -11,7 +11,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, EVT_MR_PREPARED, } from '~/diffs/constants'; -import { LOAD_SINGLE_DIFF_FAILED, BUILDING_YOUR_MR, SOMETHING_WENT_WRONG } from '~/diffs/i18n'; +import { BUILDING_YOUR_MR, SOMETHING_WENT_WRONG } from '~/diffs/i18n'; import * as diffActions from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; @@ -28,6 +28,8 @@ import { import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '~/notes/event_hub'; import diffsEventHub from '~/diffs/event_hub'; +import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { diffMetadata } from '../mock_data/diff_metadata'; jest.mock('~/alert'); @@ -37,6 +39,8 @@ jest.mock('~/lib/utils/secret_detection', () => ({ containsSensitiveToken: jest.requireActual('~/lib/utils/secret_detection').containsSensitiveToken, })); +const endpointDiffForPath = '/diffs/set/endpoint/path'; + describe('DiffsStoreActions', () => { let mock; @@ -78,7 +82,6 @@ describe('DiffsStoreActions', () => { const endpoint = '/diffs/set/endpoint'; const endpointMetadata = '/diffs/set/endpoint/metadata'; const endpointBatch = '/diffs/set/endpoint/batch'; - const endpointDiffForPath = '/diffs/set/endpoint/path'; const endpointCoverage = '/diffs/set/coverage_reports'; const projectPath = '/root/project'; const dismissEndpoint = '/-/user_callouts'; @@ -181,7 +184,6 @@ describe('DiffsStoreActions', () => { w: '1', view: 'inline', }; - const endpointDiffForPath = '/diffs/set/endpoint/path'; const diffForPath = mergeUrlParams(defaultParams, endpointDiffForPath); const treeEntry = { fileHash: 'e334a2a10f036c00151a04cea7938a5d4213a818', @@ -350,7 +352,6 @@ describe('DiffsStoreActions', () => { w: '1', view: 'inline', }; - const endpointDiffForPath = '/diffs/set/endpoint/path'; const diffForPath = mergeUrlParams(defaultParams, endpointDiffForPath); const treeEntry = { fileHash: 'e334a2a10f036c00151a04cea7938a5d4213a818', @@ -490,8 +491,8 @@ describe('DiffsStoreActions', () => { describe('fetchDiffFilesBatch', () => { it('should fetch batch diff files', () => { const endpointBatch = '/fetch/diffs_batch'; - const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { total_pages: 7 } }; - const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: { total_pages: 7 } }; + const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { total_pages: 2 } }; + const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: { total_pages: 2 } }; mock .onGet( mergeUrlParams( @@ -520,7 +521,7 @@ describe('DiffsStoreActions', () => { return testAction( diffActions.fetchDiffFilesBatch, - {}, + undefined, { endpointBatch, diffViewType: 'inline', diffFiles: [], perPage: 5 }, [ { type: types.SET_BATCH_LOADING_STATE, payload: 'loading' }, @@ -532,7 +533,6 @@ describe('DiffsStoreActions', () => { { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' }, { type: types.SET_CURRENT_DIFF_FILE, payload: 'test2' }, { type: types.SET_RETRIEVING_BATCHES, payload: false }, - { type: types.SET_BATCH_LOADING_STATE, payload: 'error' }, ], [], ); @@ -690,7 +690,7 @@ describe('DiffsStoreActions', () => { describe('setHighlightedRow', () => { it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => { - return testAction(diffActions.setHighlightedRow, 'ABC_123', {}, [ + return testAction(diffActions.setHighlightedRow, { lineCode: 'ABC_123' }, {}, [ { type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' }, { type: types.SET_CURRENT_DIFF_FILE, payload: 'ABC' }, ]); @@ -1310,14 +1310,17 @@ describe('DiffsStoreActions', () => { diffActions.goToFile({ state, dispatch, getters, commit }, file); expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash); - expect(dispatch).toHaveBeenCalledTimes(0); + expect(dispatch).not.toHaveBeenCalledWith('fetchFileByFile'); }); describe('when the tree entry has not been loaded', () => { it('updates location hash', () => { diffActions.goToFile({ state, commit, getters, dispatch }, file); - expect(document.location.hash).toBe('#test'); + expect(historyPushState).toHaveBeenCalledWith(new URL(`${TEST_HOST}#test`), { + skipScrolling: true, + }); + expect(scrollToElement).toHaveBeenCalledWith('.diff-files-holder', { duration: 0 }); }); it('loads the file and then scrolls to it', async () => { @@ -1333,21 +1336,12 @@ describe('DiffsStoreActions', () => { expect(commonUtils.scrollToElement).toHaveBeenCalledWith('.diff-files-holder', { duration: 0, }); - expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith('fetchFileByFile'); }); - it('shows an alert when there was an error fetching the file', async () => { - dispatch = jest.fn().mockRejectedValue(); - + it('unpins the file', () => { diffActions.goToFile({ state, commit, getters, dispatch }, file); - - // Wait for the fetchFileByFile dispatch to return, to trigger the catch - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledTimes(1); - expect(createAlert).toHaveBeenCalledWith({ - message: expect.stringMatching(LOAD_SINGLE_DIFF_FAILED), - }); + expect(dispatch).toHaveBeenCalledWith('unpinFile'); }); }); }); @@ -1969,7 +1963,7 @@ describe('DiffsStoreActions', () => { 0, { flatBlobsList: [{ fileHash: '123' }] }, [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], - [], + [{ type: 'unpinFile' }], ); }); @@ -1979,7 +1973,7 @@ describe('DiffsStoreActions', () => { 0, { viewDiffsFileByFile: true, flatBlobsList: [{ fileHash: '123' }] }, [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], - [{ type: 'fetchFileByFile' }], + [{ type: 'unpinFile' }, { type: 'fetchFileByFile' }], ); }); }); @@ -2120,4 +2114,84 @@ describe('DiffsStoreActions', () => { ); }); }); + + describe('fetchPinnedFile', () => { + it('fetches pinned file', async () => { + const pinnedFileHref = `${TEST_HOST}/pinned-file`; + const pinnedFile = getDiffFileMock(); + const diffFiles = [pinnedFile]; + const hubSpy = jest.spyOn(diffsEventHub, '$emit'); + mock.onGet(new RegExp(pinnedFileHref)).reply(HTTP_STATUS_OK, { diff_files: diffFiles }); + + await testAction( + diffActions.fetchPinnedFile, + pinnedFileHref, + {}, + [ + { type: types.SET_BATCH_LOADING_STATE, payload: 'loading' }, + { type: types.SET_RETRIEVING_BATCHES, payload: true }, + { + type: types.SET_DIFF_DATA_BATCH, + payload: { diff_files: diffFiles, updatePosition: false }, + }, + { type: types.SET_PINNED_FILE_HASH, payload: pinnedFile.file_hash }, + { type: types.SET_CURRENT_DIFF_FILE, payload: pinnedFile.file_hash }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' }, + { type: types.SET_RETRIEVING_BATCHES, payload: false }, + ], + [], + ); + + jest.runAllTimers(); + expect(hubSpy).toHaveBeenCalledWith('diffFilesModified'); + expect(handleLocationHash).toHaveBeenCalled(); + }); + + it('handles load error', async () => { + const pinnedFileHref = `${TEST_HOST}/pinned-file`; + const hubSpy = jest.spyOn(diffsEventHub, '$emit'); + mock.onGet(new RegExp(pinnedFileHref)).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + try { + await testAction( + diffActions.fetchPinnedFile, + pinnedFileHref, + {}, + [ + { type: types.SET_BATCH_LOADING_STATE, payload: 'loading' }, + { type: types.SET_RETRIEVING_BATCHES, payload: true }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'error' }, + { type: types.SET_RETRIEVING_BATCHES, payload: false }, + ], + [], + ); + } catch (error) { + expect(error.response.status).toBe(HTTP_STATUS_INTERNAL_SERVER_ERROR); + } + + jest.runAllTimers(); + expect(hubSpy).not.toHaveBeenCalledWith('diffFilesModified'); + expect(handleLocationHash).not.toHaveBeenCalled(); + }); + }); + + describe('unpinFile', () => { + it('unpins pinned file', () => { + const pinnedFile = getDiffFileMock(); + setWindowLocation(`${TEST_HOST}/?pin=${pinnedFile.file_hash}#${pinnedFile.file_hash}_10_10`); + testAction( + diffActions.unpinFile, + undefined, + { pinnedFile }, + [{ type: types.SET_PINNED_FILE_HASH, payload: null }], + [], + ); + expect(window.location.hash).toBe(''); + expect(window.location.search).toBe(''); + }); + + it('does nothing when no pinned file present', () => { + testAction(diffActions.unpinFile, undefined, {}, [], []); + }); + }); }); diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js index 8097f0976f6..cb0f40534fe 100644 --- a/spec/frontend/diffs/store/getters_spec.js +++ b/spec/frontend/diffs/store/getters_spec.js @@ -1,6 +1,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; import * as getters from '~/diffs/store/getters'; import state from '~/diffs/store/modules/diff_state'; +import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file'; import discussion from '../mock_data/diff_discussions'; describe('Diffs Module Getters', () => { @@ -495,4 +496,35 @@ describe('Diffs Module Getters', () => { }, ); }); + + describe('diffFiles', () => { + it('proxies diffFiles state', () => { + const diffFiles = [getDiffFileMock()]; + expect(getters.diffFiles({ diffFiles }, {})).toBe(diffFiles); + }); + + it('pins the file', () => { + const pinnedFile = getDiffFileMock(); + const regularFile = getDiffFileMock(); + const diffFiles = [regularFile, pinnedFile]; + expect(getters.diffFiles({ diffFiles }, { pinnedFile })).toStrictEqual([ + pinnedFile, + regularFile, + ]); + }); + }); + + describe('pinnedFile', () => { + it('returns pinnedFile', () => { + const pinnedFile = getDiffFileMock(); + const diffFiles = [pinnedFile]; + expect(getters.pinnedFile({ diffFiles, pinnedFileHash: pinnedFile.file_hash }, {})).toBe( + pinnedFile, + ); + }); + + it('returns null if no pinned file is set', () => { + expect(getters.pinnedFile({}, {})).toBe(null); + }); + }); }); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index a5be41aa69f..8d52cd39542 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -92,7 +92,7 @@ describe('DiffsStoreMutations', () => { }); }); - describe('SET_DIFF_DATA_BATCH_DATA', () => { + describe('SET_DIFF_DATA_BATCH', () => { it('should set diff data batch type properly', () => { const mockFile = getDiffFileMock(); const state = { @@ -108,6 +108,39 @@ describe('DiffsStoreMutations', () => { expect(state.diffFiles[0].collapsed).toEqual(false); expect(state.treeEntries[mockFile.file_path].diffLoaded).toBe(true); }); + + it('should update diff position by default', () => { + const mockFile = getDiffFileMock(); + const state = { + diffFiles: [mockFile, { ...mockFile, file_hash: 'foo', file_path: 'foo' }], + treeEntries: { [mockFile.file_path]: { fileHash: mockFile.file_hash } }, + }; + const diffMock = { + diff_files: [mockFile], + }; + + mutations[types.SET_DIFF_DATA_BATCH](state, diffMock); + + expect(state.diffFiles[1].file_hash).toBe(mockFile.file_hash); + expect(state.treeEntries[mockFile.file_path].diffLoaded).toBe(true); + }); + + it('should not update diff position', () => { + const mockFile = getDiffFileMock(); + const state = { + diffFiles: [mockFile, { ...mockFile, file_hash: 'foo', file_path: 'foo' }], + treeEntries: { [mockFile.file_path]: { fileHash: mockFile.file_hash } }, + }; + const diffMock = { + diff_files: [mockFile], + updatePosition: false, + }; + + mutations[types.SET_DIFF_DATA_BATCH](state, diffMock); + + expect(state.diffFiles[0].file_hash).toBe(mockFile.file_hash); + expect(state.treeEntries[mockFile.file_path].diffLoaded).toBe(true); + }); }); describe('SET_COVERAGE_DATA', () => { @@ -122,6 +155,17 @@ describe('DiffsStoreMutations', () => { }); }); + describe('SET_DIFF_TREE_ENTRY', () => { + it('should set tree entry', () => { + const file = getDiffFileMock(); + const state = { treeEntries: { [file.file_path]: {} } }; + + mutations[types.SET_DIFF_TREE_ENTRY](state, file); + + expect(state.treeEntries[file.file_path].diffLoaded).toBe(true); + }); + }); + describe('SET_DIFF_VIEW_TYPE', () => { it('should set diff view type properly', () => { const state = {}; @@ -1076,4 +1120,15 @@ describe('DiffsStoreMutations', () => { expect(state.diffFiles[0].viewer.forceOpen).toBe(true); }); }); + + describe('SET_PINNED_FILE_HASH', () => { + it('set pinned file hash', () => { + const state = {}; + const file = getDiffFileMock(); + + mutations[types.SET_PINNED_FILE_HASH](state, file.file_hash); + + expect(state.pinnedFileHash).toBe(file.file_hash); + }); + }); }); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 6331269d6e8..019ed663d82 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -476,6 +476,17 @@ describe('DiffsStoreUtils', () => { expect(updatedFilesList).toEqual([mock, fakeNewFile]); }); + it('updates diff position', () => { + const priorFiles = [mock, { ...mock, file_hash: 'foo', file_path: 'foo' }]; + const updatedFilesList = utils.prepareDiffData({ + diff: { diff_files: [mock] }, + priorFiles, + updatePosition: true, + }); + + expect(updatedFilesList[1].file_hash).toEqual(mock.file_hash); + }); + it('completes an existing split diff without overwriting existing diffs', () => { // The current state has a file that has only loaded inline lines const priorFiles = [{ ...mock }]; diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index f2d11f91eb9..26c97a7cb41 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -11,6 +11,8 @@ import { VISIBILITY_LEVEL_PRIVATE_STRING, VISIBILITY_LEVEL_INTERNAL_STRING, VISIBILITY_LEVEL_PUBLIC_STRING, + GROUP_VISIBILITY_TYPE, + PROJECT_VISIBILITY_TYPE, } from '~/visibility_level/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -115,6 +117,51 @@ describe('GroupItemComponent', () => { wrapper.destroy(); }); }); + + describe('visibilityTooltip', () => { + describe('if item represents group', () => { + it.each` + visibilityLevel | visibilityTooltip + ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${GROUP_VISIBILITY_TYPE[VISIBILITY_LEVEL_PUBLIC_STRING]} + ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${GROUP_VISIBILITY_TYPE[VISIBILITY_LEVEL_INTERNAL_STRING]} + ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${GROUP_VISIBILITY_TYPE[VISIBILITY_LEVEL_PRIVATE_STRING]} + `( + 'should return corresponding text when visibility level is $visibilityLevel', + ({ visibilityLevel, visibilityTooltip }) => { + const group = { ...mockParentGroupItem }; + + group.type = 'group'; + group.visibility = visibilityLevel; + wrapper = createComponent({ group }); + + expect(wrapper.vm.visibilityTooltip).toBe(visibilityTooltip); + wrapper.destroy(); + }, + ); + }); + + describe('if item represents project', () => { + it.each` + visibilityLevel | visibilityTooltip + ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_PUBLIC_STRING]} + ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_INTERNAL_STRING]} + ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_PRIVATE_STRING]} + `( + 'should return corresponding text when visibility level is $visibilityLevel', + ({ visibilityLevel, visibilityTooltip }) => { + const group = { ...mockParentGroupItem }; + + group.type = 'project'; + group.lastActivityAt = '2017-04-09T18:40:39.101Z'; + group.visibility = visibilityLevel; + wrapper = createComponent({ group }); + + expect(wrapper.vm.visibilityTooltip).toBe(visibilityTooltip); + wrapper.destroy(); + }, + ); + }); + }); }); describe('methods', () => { diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js index 26d4e4dbb4f..bdb3db7a1b9 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js @@ -1,4 +1,4 @@ -import { GlTable } from '@gitlab/ui'; +import { GlTable, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -21,6 +21,7 @@ describe('Packages protection rules project settings', () => { }; const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); const findTable = () => wrapper.findComponent(GlTable); + const findTableLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findTableRows = () => findTable().find('tbody').findAll('tr'); const mountComponent = (mountFn = shallowMount, provide = defaultProvidedValues, config) => { @@ -56,25 +57,41 @@ describe('Packages protection rules project settings', () => { expect(findTable().exists()).toBe(true); }); - it('renders table with container registry protection rules', async () => { - createComponent({ mountFn: mount }); + describe('table package protection rules', () => { + it('renders table with packages protection rules', async () => { + createComponent({ mountFn: mount }); - await waitForPromises(); + await waitForPromises(); - expect(findTable().exists()).toBe(true); + expect(findTable().exists()).toBe(true); - packagesProtectionRulesData.forEach((protectionRule, i) => { - expect(findTableRows().at(i).text()).toContain(protectionRule.packageNamePattern); - expect(findTableRows().at(i).text()).toContain(protectionRule.packageType); - expect(findTableRows().at(i).text()).toContain(protectionRule.pushProtectedUpToAccessLevel); + packagesProtectionRulesData.forEach((protectionRule, i) => { + expect(findTableRows().at(i).text()).toContain(protectionRule.packageNamePattern); + expect(findTableRows().at(i).text()).toContain(protectionRule.packageType); + expect(findTableRows().at(i).text()).toContain(protectionRule.pushProtectedUpToAccessLevel); + }); }); - }); - it('renders table with pagination', async () => { - createComponent(); + it('displays table in busy state and shows loading icon inside table', async () => { + createComponent({ mountFn: mount }); - await waitForPromises(); + expect(findTableLoadingIcon().exists()).toBe(true); + expect(findTableLoadingIcon().attributes('aria-label')).toBe('Loading'); - expect(findTable().exists()).toBe(true); + expect(findTable().attributes('aria-busy')).toBe('true'); + + await waitForPromises(); + + expect(findTableLoadingIcon().exists()).toBe(false); + expect(findTable().attributes('aria-busy')).toBe('false'); + }); + + it('renders table', async () => { + createComponent(); + + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js index 4e3554131c6..75b239d2d94 100644 --- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js +++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js @@ -74,20 +74,14 @@ describe('Access Level Dropdown', () => { const createComponent = ({ accessLevelsData = mockAccessLevelsData, accessLevel = ACCESS_LEVELS.PUSH, - hasLicense, - label, - disabled, - preselectedItems, stubs = {}, + ...optionalProps } = {}) => { wrapper = shallowMountExtended(AccessDropdown, { propsData: { accessLevelsData, accessLevel, - hasLicense, - label, - disabled, - preselectedItems, + ...optionalProps, }, stubs: { GlSprintf, @@ -114,10 +108,19 @@ describe('Access Level Dropdown', () => { it('should make an api call for users, groups && deployKeys when user has a license', () => { createComponent(); expect(getUsers).toHaveBeenCalled(); - expect(getGroups).toHaveBeenCalled(); + expect(getGroups).toHaveBeenCalledWith({ withProjectAccess: false }); expect(getDeployKeys).toHaveBeenCalled(); }); + describe('withProjectAccess', () => { + it('should make an api call for users, groups && deployKeys when user has a license', () => { + createComponent({ groupsWithProjectAccess: true }); + expect(getUsers).toHaveBeenCalled(); + expect(getGroups).toHaveBeenCalledWith({ withProjectAccess: true }); + expect(getDeployKeys).toHaveBeenCalled(); + }); + }); + it('should make an api call for deployKeys but not for users or groups when user does not have a license', () => { createComponent({ hasLicense: false }); expect(getUsers).not.toHaveBeenCalled(); @@ -132,7 +135,7 @@ describe('Access Level Dropdown', () => { findSearchBox().vm.$emit('input', query); await nextTick(); expect(getUsers).toHaveBeenCalledWith(query); - expect(getGroups).toHaveBeenCalled(); + expect(getGroups).toHaveBeenCalledWith({ withProjectAccess: false }); expect(getDeployKeys).toHaveBeenCalledWith(query); }); }); diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index 976866af27c..d063db1e34b 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import { nextTick } from 'vue'; import { file } from 'jest/ide/helpers'; import { escapeFileUrl } from '~/lib/utils/url_utility'; @@ -153,4 +154,16 @@ describe('File row component', () => { expect(wrapper.findComponent(FileIcon).props('submodule')).toBe(submodule); }); + + it('renders pinned icon', () => { + createComponent({ + file: { + ...file(), + pinned: true, + }, + level: 0, + }); + + expect(wrapper.findComponent(GlIcon).props('name')).toBe('thumbtack'); + }); }); diff --git a/spec/lib/gitlab/database/partitioning/list/convert_table_spec.rb b/spec/lib/gitlab/database/partitioning/list/convert_table_spec.rb index b30501cce21..e0b090f7ff9 100644 --- a/spec/lib/gitlab/database/partitioning/list/convert_table_spec.rb +++ b/spec/lib/gitlab/database/partitioning/list/convert_table_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Gitlab::Database::Partitioning::List::ConvertTable, feature_categ table_name: table_name, partitioning_column: partitioning_column, parent_table_name: parent_table_name, - zero_partition_value: partitioning_default + zero_partition_value: zero_partition_value ) end @@ -24,107 +24,121 @@ RSpec.describe Gitlab::Database::Partitioning::List::ConvertTable, feature_categ let(:async) { false } - it 'adds a check constraint' do - expect { prepare }.to change { - Gitlab::Database::PostgresConstraint - .check_constraints - .by_table_identifier(table_identifier) - .count - }.from(0).to(1) - end - - context 'when it fails to add constraint' do - before do - allow(migration_context).to receive(:add_check_constraint) - end - - it 'raises UnableToPartition error' do - expect { prepare } - .to raise_error(described_class::UnableToPartition) - .and change { - Gitlab::Database::PostgresConstraint - .check_constraints - .by_table_identifier(table_identifier) - .count - }.by(0) - end - end - - context 'when async' do - let(:async) { true } - - it 'adds a NOT VALID check constraint' do + shared_examples 'runs #prepare_for_partitioning' do + it 'adds a check constraint' do expect { prepare }.to change { Gitlab::Database::PostgresConstraint .check_constraints .by_table_identifier(table_identifier) .count }.from(0).to(1) + end - constraint = - Gitlab::Database::PostgresConstraint - .check_constraints - .by_table_identifier(table_identifier) - .last + context 'when it fails to add constraint' do + before do + allow(migration_context).to receive(:add_check_constraint) + end - expect(constraint.definition).to end_with('NOT VALID') + it 'raises UnableToPartition error' do + expect { prepare } + .to raise_error(described_class::UnableToPartition) + .and change { + Gitlab::Database::PostgresConstraint + .check_constraints + .by_table_identifier(table_identifier) + .count + }.by(0) + end end - it 'adds a PostgresAsyncConstraintValidation record' do - expect { prepare }.to change { - Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation.count - }.by(1) + context 'when async' do + let(:async) { true } - record = Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation - .where(table_name: table_name).last + it 'adds a NOT VALID check constraint' do + expect { prepare }.to change { + Gitlab::Database::PostgresConstraint + .check_constraints + .by_table_identifier(table_identifier) + .count + }.from(0).to(1) - expect(record.name).to eq described_class::PARTITIONING_CONSTRAINT_NAME - expect(record).to be_check_constraint - end + constraint = + Gitlab::Database::PostgresConstraint + .check_constraints + .by_table_identifier(table_identifier) + .last - context 'when constraint exists but is not valid' do - before do - converter.prepare_for_partitioning(async: true) + expect(constraint.definition).to end_with('NOT VALID') end - it 'validates the check constraint' do + it 'adds a PostgresAsyncConstraintValidation record' do expect { prepare }.to change { - Gitlab::Database::PostgresConstraint - .check_constraints - .by_table_identifier(table_identifier).first.constraint_valid? - }.from(false).to(true) + Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation.count + }.by(1) + + record = Gitlab::Database::AsyncConstraints::PostgresAsyncConstraintValidation + .where(table_name: table_name).last + + expect(record.name).to eq described_class::PARTITIONING_CONSTRAINT_NAME + expect(record).to be_check_constraint end - context 'when it fails to validate constraint' do + context 'when constraint exists but is not valid' do before do - allow(migration_context).to receive(:validate_check_constraint) + converter.prepare_for_partitioning(async: true) end - it 'raises UnableToPartition error' do - expect { prepare } - .to raise_error(described_class::UnableToPartition, - starting_with('Error validating partitioning constraint')) - .and change { - Gitlab::Database::PostgresConstraint - .check_constraints - .by_table_identifier(table_identifier) - .count - }.by(0) + it 'validates the check constraint' do + expect { prepare }.to change { + Gitlab::Database::PostgresConstraint + .check_constraints + .by_table_identifier(table_identifier).first.constraint_valid? + }.from(false).to(true) end - end - end - context 'when constraint exists and is valid' do - before do - converter.prepare_for_partitioning(async: false) + context 'when it fails to validate constraint' do + before do + allow(migration_context).to receive(:validate_check_constraint) + end + + it 'raises UnableToPartition error' do + expect { prepare } + .to raise_error(described_class::UnableToPartition, + starting_with('Error validating partitioning constraint')) + .and change { + Gitlab::Database::PostgresConstraint + .check_constraints + .by_table_identifier(table_identifier) + .count + }.by(0) + end + end end - it 'raises UnableToPartition error' do - expect(Gitlab::AppLogger).to receive(:info).with(starting_with('Nothing to do')) - prepare + context 'when constraint exists and is valid' do + before do + converter.prepare_for_partitioning(async: false) + end + + it 'raises UnableToPartition error' do + expect(Gitlab::AppLogger).to receive(:info).with(starting_with('Nothing to do')) + prepare + end end end end + + context 'when a single partitioning value is given' do + let(:zero_partition_value) { single_partitioning_value } + + include_examples 'runs #prepare_for_partitioning' + end + + context 'when multiple partitioning values are given' do + let(:zero_partition_value) { multiple_partitioning_values } + + include_examples 'runs #prepare_for_partitioning' + end end describe '#revert_preparation_for_partitioning' do @@ -132,15 +146,29 @@ RSpec.describe Gitlab::Database::Partitioning::List::ConvertTable, feature_categ converter.prepare_for_partitioning end + shared_examples 'runs #revert_preparation_for_partitioning' do + it 'removes a check constraint' do + expect { revert_prepare }.to change { + Gitlab::Database::PostgresConstraint + .check_constraints + .by_table_identifier("#{connection.current_schema}.#{table_name}") + .count + }.from(1).to(0) + end + end + subject(:revert_prepare) { converter.revert_preparation_for_partitioning } - it 'removes a check constraint' do - expect { revert_prepare }.to change { - Gitlab::Database::PostgresConstraint - .check_constraints - .by_table_identifier("#{connection.current_schema}.#{table_name}") - .count - }.from(1).to(0) + context 'when a single partitioning value is given' do + let(:zero_partition_value) { single_partitioning_value } + + include_examples 'runs #revert_preparation_for_partitioning' + end + + context 'when multiple partitioning values are given' do + let(:zero_partition_value) { multiple_partitioning_values } + + include_examples 'runs #revert_preparation_for_partitioning' end end @@ -153,128 +181,146 @@ RSpec.describe Gitlab::Database::Partitioning::List::ConvertTable, feature_categ converter.prepare_for_partitioning(async: async) end - context 'when the primary key is incorrect' do - before do - connection.execute(<<~SQL) - alter table #{referencing_table_name} drop constraint fk_referencing; -- this depends on the primary key - alter table #{other_referencing_table_name} drop constraint fk_referencing_other; -- this does too - alter table #{table_name} drop constraint #{table_name}_pkey; - alter table #{table_name} add constraint #{table_name}_pkey PRIMARY KEY (id); - SQL - end + shared_examples 'runs partition method' do + context 'when the primary key is incorrect' do + before do + connection.execute(<<~SQL) + alter table #{referencing_table_name} drop constraint fk_referencing; -- this depends on the primary key + alter table #{other_referencing_table_name} drop constraint fk_referencing_other; -- this does too + alter table #{table_name} drop constraint #{table_name}_pkey; + alter table #{table_name} add constraint #{table_name}_pkey PRIMARY KEY (id); + SQL + end - it 'throws a reasonable error message' do - expect { partition }.to raise_error(described_class::UnableToPartition, /#{partitioning_column}/) + it 'throws a reasonable error message' do + expect { partition }.to raise_error(described_class::UnableToPartition, /#{partitioning_column}/) + end end - end - context 'when there is not a supporting check constraint' do - before do - connection.execute(<<~SQL) - alter table #{table_name} drop constraint partitioning_constraint; - SQL - end + context 'when there is not a supporting check constraint' do + before do + connection.execute(<<~SQL) + alter table #{table_name} drop constraint partitioning_constraint; + SQL + end - it 'throws a reasonable error message' do - expect { partition }.to raise_error(described_class::UnableToPartition, /is not ready for partitioning./) + it 'throws a reasonable error message' do + expect { partition }.to raise_error(described_class::UnableToPartition, /is not ready for partitioning./) + end end - end - context 'when supporting check constraint is not valid' do - let(:async) { true } + context 'when supporting check constraint is not valid' do + let(:async) { true } - it 'throws a reasonable error message' do - expect { partition }.to raise_error(described_class::UnableToPartition, /is not ready for partitioning./) + it 'throws a reasonable error message' do + expect { partition }.to raise_error(described_class::UnableToPartition, /is not ready for partitioning./) + end end - end - - it 'migrates the table to a partitioned table' do - fks_before = migration_context.foreign_keys(table_name) - - partition - expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(1) - expect(migration_context.foreign_keys(parent_table_name).map(&:options)).to match_array(fks_before.map(&:options)) + it 'migrates the table to a partitioned table' do + fks_before = migration_context.foreign_keys(table_name) - connection.execute(<<~SQL) - insert into #{table_name} (referenced_id, other_referenced_id) select #{referenced_table_name}.id, #{other_referenced_table_name}.id from #{referenced_table_name}, #{other_referenced_table_name}; - SQL + partition - # Create a second partition - connection.execute(<<~SQL) - create table #{table_name}2 partition of #{parent_table_name} FOR VALUES IN (2) - SQL + expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(1) + expect(migration_context.foreign_keys(parent_table_name) + .map(&:options)).to match_array(fks_before.map(&:options)) - parent_model.create!(partitioning_column => 2, :referenced_id => 1, :other_referenced_id => 1) - expect(parent_model.pluck(:id)).to match_array([1, 2, 3]) - - expect { referencing_model.create!(partitioning_column => 1, :ref_id => 1) }.not_to raise_error - end + connection.execute(<<~SQL) + insert into #{table_name} (referenced_id, other_referenced_id) select #{referenced_table_name}.id, #{other_referenced_table_name}.id from #{referenced_table_name}, #{other_referenced_table_name}; + SQL - context 'when the existing table is owned by a different user' do - before do + # Create a second partition connection.execute(<<~SQL) - CREATE USER other_user SUPERUSER; - ALTER TABLE #{table_name} OWNER TO other_user; + create table #{table_name}2 partition of #{parent_table_name} FOR VALUES IN (2) SQL - end - let(:current_user) { model.connection.select_value('select current_user') } + parent_model.create!(partitioning_column => 2, :referenced_id => 1, :other_referenced_id => 1) + expect(parent_model.pluck(:id)).to match_array([1, 2, 3]) - it 'partitions without error' do - expect { partition }.not_to raise_error + expect { referencing_model.create!(partitioning_column => 1, :ref_id => 1) }.not_to raise_error end - end - context 'when an error occurs during the conversion' do - before do - # Set up the fault that we'd like to inject - fault.call - end + context 'when the existing table is owned by a different user' do + before do + connection.execute(<<~SQL) + CREATE USER other_user SUPERUSER; + ALTER TABLE #{table_name} OWNER TO other_user; + SQL + end - let(:old_fks) do - Gitlab::Database::PostgresForeignKey.by_referenced_table_identifier(table_identifier).not_inherited - end + let(:current_user) { model.connection.select_value('select current_user') } - let(:new_fks) do - Gitlab::Database::PostgresForeignKey.by_referenced_table_identifier(parent_table_identifier).not_inherited + it 'partitions without error' do + expect { partition }.not_to raise_error + end end - context 'when partitioning fails the first time' do - where(:case_name, :fault) do - [ - ["creating parent table", lazy { fail_sql_matching(/CREATE/i) }], - ["adding the first foreign key", lazy { fail_adding_fk(parent_table_name, referenced_table_name) }], - ["adding the second foreign key", lazy { fail_adding_fk(parent_table_name, other_referenced_table_name) }], - ["attaching table", lazy { fail_sql_matching(/ATTACH/i) }] - ] + context 'when an error occurs during the conversion' do + before do + # Set up the fault that we'd like to inject + fault.call end - with_them do - it 'recovers from a fault', :aggregate_failures do - expect { converter.partition }.to raise_error(/fault/) + let(:old_fks) do + Gitlab::Database::PostgresForeignKey.by_referenced_table_identifier(table_identifier).not_inherited + end - expect { converter.partition }.not_to raise_error - expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(1) + let(:new_fks) do + Gitlab::Database::PostgresForeignKey.by_referenced_table_identifier(parent_table_identifier).not_inherited + end + + context 'when partitioning fails the first time' do + where(:case_name, :fault) do + [ + ["creating parent table", lazy { fail_sql_matching(/CREATE/i) }], + ["adding the first foreign key", lazy { fail_adding_fk(parent_table_name, referenced_table_name) }], + ["adding the second foreign key", lazy do + fail_adding_fk(parent_table_name, other_referenced_table_name) + end], + ["attaching table", lazy { fail_sql_matching(/ATTACH/i) }] + ] + end + + with_them do + it 'recovers from a fault', :aggregate_failures do + expect { converter.partition }.to raise_error(/fault/) + + expect { converter.partition }.not_to raise_error + expect(Gitlab::Database::PostgresPartition.for_parent_table(parent_table_name).count).to eq(1) + end end end end - end - context 'when table has LFK triggers' do - before do - migration_context.track_record_deletions(table_name) - end + context 'when table has LFK triggers' do + before do + migration_context.track_record_deletions(table_name) + end - it 'moves the trigger on the parent table', :aggregate_failures do - expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy + it 'moves the trigger on the parent table', :aggregate_failures do + expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy - expect { partition }.not_to raise_error + expect { partition }.not_to raise_error - expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy - expect(migration_context.has_loose_foreign_key?(parent_table_name)).to be_truthy + expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy + expect(migration_context.has_loose_foreign_key?(parent_table_name)).to be_truthy + end end end + + context 'when a single partitioning value is given' do + let(:zero_partition_value) { single_partitioning_value } + + include_examples 'runs partition method' + end + + context 'when multiple partitioning values are given' do + # Because of the common spec on line 220 + let(:zero_partition_value) { [1, 3, 4] } + + include_examples 'runs partition method' + end end describe '#revert_partitioning' do @@ -285,49 +331,67 @@ RSpec.describe Gitlab::Database::Partitioning::List::ConvertTable, feature_categ subject(:revert_conversion) { converter.revert_partitioning } - it 'detaches the partition' do - expect { revert_conversion }.to change { - Gitlab::Database::PostgresPartition - .for_parent_table(parent_table_name).count - }.from(1).to(0) - end - - it 'does not drop the child partition' do - expect { revert_conversion }.not_to change { table_oid(table_name) } - end + shared_examples 'runs #revert_partitioning' do + it 'detaches the partition' do + expect { revert_conversion }.to change { + Gitlab::Database::PostgresPartition + .for_parent_table(parent_table_name).count + }.from(1).to(0) + end - it 'removes the parent table' do - expect { revert_conversion }.to change { table_oid(parent_table_name).present? }.from(true).to(false) - end + it 'does not drop the child partition' do + expect { revert_conversion }.not_to change { table_oid(table_name) } + end - it 're-adds the check constraint' do - expect { revert_conversion }.to change { - Gitlab::Database::PostgresConstraint - .check_constraints - .by_table_identifier(table_identifier) - .count - }.by(1) - end + it 'removes the parent table' do + expect { revert_conversion }.to change { table_oid(parent_table_name).present? }.from(true).to(false) + end - it 'moves sequences back to the original table' do - expect { revert_conversion }.to change { converter.send(:sequences_owned_by, table_name).count }.from(0) - .and change { converter.send(:sequences_owned_by, parent_table_name).count }.to(0) - end + it 're-adds the check constraint' do + expect { revert_conversion }.to change { + Gitlab::Database::PostgresConstraint + .check_constraints + .by_table_identifier(table_identifier) + .count + }.by(1) + end - context 'when table has LFK triggers' do - before do - migration_context.track_record_deletions(parent_table_name) - migration_context.track_record_deletions(table_name) + it 'moves sequences back to the original table' do + expect { revert_conversion }.to change { converter.send(:sequences_owned_by, table_name).count } + .from(0) + .and change { + converter.send( + :sequences_owned_by, parent_table_name).count + }.to(0) end - it 'restores the trigger on the partition', :aggregate_failures do - expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy - expect(migration_context.has_loose_foreign_key?(parent_table_name)).to be_truthy + context 'when table has LFK triggers' do + before do + migration_context.track_record_deletions(parent_table_name) + migration_context.track_record_deletions(table_name) + end + + it 'restores the trigger on the partition', :aggregate_failures do + expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy + expect(migration_context.has_loose_foreign_key?(parent_table_name)).to be_truthy - expect { revert_conversion }.not_to raise_error + expect { revert_conversion }.not_to raise_error - expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy + expect(migration_context.has_loose_foreign_key?(table_name)).to be_truthy + end end end + + context 'when a single partitioning value is given' do + let(:zero_partition_value) { single_partitioning_value } + + include_examples 'runs #revert_partitioning' + end + + context 'when multiple partitioning values are given' do + let(:zero_partition_value) { multiple_partitioning_values } + + include_examples 'runs #revert_partitioning' + end end end diff --git a/spec/requests/api/terraform/modules/v1/packages_spec.rb b/spec/requests/api/terraform/modules/v1/namespace_packages_spec.rb index 949acdb17e1..d655085a30f 100644 --- a/spec/requests/api/terraform/modules/v1/packages_spec.rb +++ b/spec/requests/api/terraform/modules/v1/namespace_packages_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package_registry do +RSpec.describe API::Terraform::Modules::V1::NamespacePackages, feature_category: :package_registry do include_context 'for terraform modules api setup' using RSpec::Parameterized::TableSyntax @@ -10,17 +10,19 @@ RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package let(:url) { api("/packages/terraform/modules/v1/#{group.path}/#{package.name}/versions") } let(:headers) { { 'Authorization' => "Bearer #{tokens[:job_token]}" } } - subject { get(url, headers: headers) } + subject(:get_versions) { get(url, headers: headers) } context 'with a conflicting package name' do - let!(:conflicting_package) { create(:terraform_module_package, project: project, name: "conflict-#{package.name}", version: '2.0.0') } + let!(:conflicting_package) do + create(:terraform_module_package, project: project, name: "conflict-#{package.name}", version: '2.0.0') + end before do group.add_developer(user) end it 'returns only one version' do - subject + get_versions expect(json_response['modules'][0]['versions'].size).to eq(1) expect(json_response['modules'][0]['versions'][0]['version']).to eq('1.0.0') @@ -77,14 +79,14 @@ RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package end describe 'GET /api/v4/packages/terraform/modules/v1/:module_namespace/:module_name/:module_system/download' do - context 'empty registry' do + context 'with empty registry' do let(:url) { api("/packages/terraform/modules/v1/#{group.path}/module-2/system/download") } let(:headers) { {} } - subject { get(url, headers: headers) } + subject(:get_download) { get(url, headers: headers) } it 'returns not found when there is no module' do - subject + get_download expect(response).to have_gitlab_http_status(:not_found) end @@ -150,14 +152,14 @@ RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package end describe 'GET /api/v4/packages/terraform/modules/v1/:module_namespace/:module_name/:module_system' do - context 'empty registry' do + context 'with empty registry' do let(:url) { api("/packages/terraform/modules/v1/#{group.path}/non-existent/system") } let(:headers) { { 'Authorization' => "Bearer #{tokens[:personal_access_token]}" } } - subject { get(url, headers: headers) } + subject(:get_module) { get(url, headers: headers) } it 'returns not found when there is no module' do - subject + get_module expect(response).to have_gitlab_http_status(:not_found) end @@ -221,16 +223,16 @@ RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package let(:url) { api("/packages/terraform/modules/v1/#{group.path}/#{package.name}/#{package.version}") } let(:headers) { {} } - subject { get(url, headers: headers) } + subject(:get_module_version) { get(url, headers: headers) } - context 'not found' do + context 'when not found' do let(:url) { api("/packages/terraform/modules/v1/#{group.path}/#{package.name}/2.0.0") } let(:headers) { { 'Authorization' => "Bearer #{tokens[:job_token]}" } } subject { get(url, headers: headers) } it 'returns not found when the specified version is not present in the registry' do - subject + get_module_version expect(response).to have_gitlab_http_status(:not_found) end @@ -343,7 +345,10 @@ RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package end describe 'GET /api/v4/packages/terraform/modules/v1/:module_namespace/:module_name/:module_system/:module_version/file' do - let(:url) { api("/packages/terraform/modules/v1/#{group.path}/#{package.name}/#{package.version}/file?token=#{token}") } + let(:url) do + api("/packages/terraform/modules/v1/#{group.path}/#{package.name}/#{package.version}/file?token=#{token}") + end + let(:tokens) do { personal_access_token: ::Gitlab::JWTToken.new.tap { |jwt| jwt['token'] = personal_access_token.id }.encoded, @@ -352,7 +357,7 @@ RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package } end - subject { get(url, headers: headers) } + subject(:get_file) { get(url, headers: headers) } context 'with valid namespace' do where(:visibility, :user_role, :member, :token_type, :shared_examples_name, :expected_status) do @@ -414,8 +419,15 @@ RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package end context 'with package file pending destruction' do - let_it_be(:package) { create(:package, package_type: :terraform_module, project: project, name: "module-555/pending-destruction", version: '1.0.0') } - let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, :xml, package: package) } + let_it_be(:package) do + create(:package, package_type: :terraform_module, project: project, name: "module-555/pending-destruction", + version: '1.0.0') + end + + let_it_be(:package_file_pending_destruction) do + create(:package_file, :pending_destruction, :xml, package: package) + end + let_it_be(:package_file) { create(:package_file, :terraform_module, package: package) } let(:token) { tokens[:personal_access_token] } @@ -426,7 +438,7 @@ RSpec.describe API::Terraform::Modules::V1::Packages, feature_category: :package end it 'does not return them' do - subject + get_file expect(response).to have_gitlab_http_status(:ok) expect(response.body).not_to eq(package_file_pending_destruction.file.file.read) diff --git a/spec/services/ci/create_pipeline_service/partitioning_spec.rb b/spec/services/ci/create_pipeline_service/partitioning_spec.rb index 70c4eb49698..574bc05827a 100644 --- a/spec/services/ci/create_pipeline_service/partitioning_spec.rb +++ b/spec/services/ci/create_pipeline_service/partitioning_spec.rb @@ -37,7 +37,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes end let(:pipeline) { service.execute(:push).payload } - let(:current_partition_id) { ci_testing_partition_id } + let(:current_partition_id) { ci_testing_partition_id_for_check_constraints } before do stub_ci_pipeline_yaml_file(config) diff --git a/spec/services/issuable/common_system_notes_service_spec.rb b/spec/services/issuable/common_system_notes_service_spec.rb index fb2292bfe64..4ea7bb89d61 100644 --- a/spec/services/issuable/common_system_notes_service_spec.rb +++ b/spec/services/issuable/common_system_notes_service_spec.rb @@ -43,7 +43,7 @@ RSpec.describe Issuable::CommonSystemNotesService, feature_category: :team_plann it_behaves_like 'system note creation', { title: 'New title' }, 'changed title' it_behaves_like 'system note creation', { description: 'New description' }, 'changed the description' it_behaves_like 'system note creation', { discussion_locked: true }, 'locked the discussion in this issue' - it_behaves_like 'system note creation', { time_estimate: 5 }, 'changed time estimate' + it_behaves_like 'system note creation', { time_estimate: 5 }, 'added time estimate of 5s' context 'when new label is added' do let(:label) { create(:label, project: project) } @@ -144,7 +144,7 @@ RSpec.describe Issuable::CommonSystemNotesService, feature_category: :team_plann end context 'when setting an estimae' do - it_behaves_like 'system note creation', { time_estimate: 5 }, 'changed time estimate', false + it_behaves_like 'system note creation', { time_estimate: 5 }, 'added time estimate of 5s', false end end end diff --git a/spec/services/system_notes/time_tracking_service_spec.rb b/spec/services/system_notes/time_tracking_service_spec.rb index cf994220946..6502aa5d2a2 100644 --- a/spec/services/system_notes/time_tracking_service_spec.rb +++ b/spec/services/system_notes/time_tracking_service_spec.rb @@ -198,11 +198,13 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann let(:action) { 'time_tracking' } end - context 'with a time estimate' do - it 'sets the note text' do + context 'when adding a time estimate' do + before do noteable.update_attribute(:time_estimate, 277200) + end - expect(subject.note).to eq "changed time estimate to 1w 4d 5h" + it 'sets the note text' do + expect(subject.note).to eq "added time estimate of 1w 4d 5h" end context 'when time_tracking_limit_to_hours setting is true' do @@ -211,16 +213,32 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann end it 'sets the note text' do - noteable.update_attribute(:time_estimate, 277200) - - expect(subject.note).to eq "changed time estimate to 77h" + expect(subject.note).to eq "added time estimate of 77h" end end end - context 'without a time estimate' do + context 'when removing a time estimate' do + before do + noteable.update_attribute(:time_estimate, 277200) + noteable.save! + noteable.update_attribute(:time_estimate, 0) + end + + it 'sets the note text' do + expect(subject.note).to eq "removed time estimate of 1w 4d 5h" + end + end + + context 'when changing a time estimate' do + before do + noteable.update_attribute(:time_estimate, 277200) + noteable.save! + noteable.update_attribute(:time_estimate, 3600) + end + it 'sets the note text' do - expect(subject.note).to eq "removed time estimate" + expect(subject.note).to eq "changed time estimate to 1h from 1w 4d 5h" end end diff --git a/spec/support/helpers/models/ci/partitioning_testing/partition_identifiers.rb b/spec/support/helpers/models/ci/partitioning_testing/partition_identifiers.rb index aa091095fb6..e139f0c9fb3 100644 --- a/spec/support/helpers/models/ci/partitioning_testing/partition_identifiers.rb +++ b/spec/support/helpers/models/ci/partitioning_testing/partition_identifiers.rb @@ -8,6 +8,10 @@ module Ci def ci_testing_partition_id 99999 end + + def ci_testing_partition_id_for_check_constraints + 101 + end end end end diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml index b93442a87a9..6aac031be6f 100644 --- a/spec/support/rspec_order_todo.yml +++ b/spec/support/rspec_order_todo.yml @@ -8115,7 +8115,6 @@ - './spec/requests/api/tags_spec.rb' - './spec/requests/api/task_completion_status_spec.rb' - './spec/requests/api/templates_spec.rb' -- './spec/requests/api/terraform/modules/v1/packages_spec.rb' - './spec/requests/api/terraform/state_spec.rb' - './spec/requests/api/terraform/state_version_spec.rb' - './spec/requests/api/todos_spec.rb' diff --git a/spec/support/shared_contexts/lib/gitlab/database/partitioning/list_partitioning_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/database/partitioning/list_partitioning_shared_context.rb index 3d978a6fde4..fec11349b62 100644 --- a/spec/support/shared_contexts/lib/gitlab/database/partitioning/list_partitioning_shared_context.rb +++ b/spec/support/shared_contexts/lib/gitlab/database/partitioning/list_partitioning_shared_context.rb @@ -13,6 +13,8 @@ RSpec.shared_context 'with a table structure for converting a table to a list pa let(:table_identifier) { "#{connection.current_schema}.#{table_name}" } let(:partitioning_column) { :partition_number } let(:partitioning_default) { 1 } + let(:single_partitioning_value) { 1 } + let(:multiple_partitioning_values) { [1, 2, 3, 4] } let(:referenced_table_name) { '_test_referenced_table' } let(:other_referenced_table_name) { '_test_other_referenced_table' } let(:referencing_table_name) { '_test_referencing_table' } diff --git a/spec/views/devise/shared/_footer.html.haml_spec.rb b/spec/views/devise/shared/_footer.html.haml_spec.rb new file mode 100644 index 00000000000..58fe9c1073c --- /dev/null +++ b/spec/views/devise/shared/_footer.html.haml_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'devise/shared/_footer', feature_category: :system_access do + subject { render && rendered } + + context 'when public visibility is restricted' do + before do + allow(view).to receive(:public_visibility_restricted?).and_return(true) + end + + it { is_expected.not_to have_link(_('Explore'), href: explore_root_path) } + it { is_expected.not_to have_link(_('Help'), href: help_path) } + end + + context 'when public visibility is not restricted' do + before do + allow(view).to receive(:public_visibility_restricted?).and_return(false) + end + + it { is_expected.to have_link(_('Explore'), href: explore_root_path) } + it { is_expected.to have_link(_('Help'), href: help_path) } + end + + it { is_expected.to have_link(_('About GitLab'), href: "https://#{ApplicationHelper.promo_host}") } + it { is_expected.to have_link(_('Community forum'), href: ApplicationHelper.community_forum) } + + context 'when one trust is enabled' do + before do + allow(view).to receive(:one_trust_enabled?).and_return(true) + end + + it { is_expected.to have_button(_('Cookie Preferences'), class: 'ot-sdk-show-settings') } + end + + context 'when one trust is disabled' do + before do + allow(view).to receive(:one_trust_enabled?).and_return(false) + end + + it { is_expected.not_to have_button(_('Cookie Preferences'), class: 'ot-sdk-show-settings') } + end + + it { is_expected.to have_css('.js-language-switcher') } +end |