diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-30 12:09:12 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-30 12:09:12 +0300 |
commit | 4ac9f1b8eaef29daa484b27a3113505cfa6a6dcb (patch) | |
tree | bda090fb8cf7c97765ee891bd58f7f9ee4271301 | |
parent | abbedc2027e64b11b03be4639411f1943b81f7ce (diff) |
Add latest changes from gitlab-org/gitlab@master
47 files changed, 670 insertions, 619 deletions
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 1aaefcaa13b..abd13a30156 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -37,6 +37,7 @@ import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; import 'ee_else_ce/main_ee'; +import 'jh_else_ce/main_jh'; applyGitLabUIConfig(); diff --git a/app/assets/javascripts/main_jh.js b/app/assets/javascripts/main_jh.js new file mode 100644 index 00000000000..13a6b8f3d3d --- /dev/null +++ b/app/assets/javascripts/main_jh.js @@ -0,0 +1 @@ +// This is an empty file to satisfy jh_else_ce import for the JH main entry point diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 031472a7d20..831f3987041 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -258,6 +258,7 @@ export function mountSidebarLabels() { allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels), variant: DropdownVariant.Sidebar, + canUpdate: parseBoolean(el.dataset.canEdit), }, render: (createElement) => createElement(SidebarLabels), }); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index 6694e349b6e..b5c3ffabae0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -60,7 +60,7 @@ export default { }, }, methods: { - ...mapActions(['toggleDropdownContentsCreateView', 'toggleDropdownContents']), + ...mapActions(['toggleDropdownContentsCreateView']), }, }; </script> @@ -83,7 +83,7 @@ export default { size="small" class="js-btn-back dropdown-header-button p-0" icon="arrow-left" - @click="toggleDropdownContentsCreateView" + @click.stop="toggleDropdownContentsCreateView" /> <span class="flex-grow-1">{{ dropdownTitle }}</span> <gl-button @@ -92,7 +92,7 @@ export default { size="small" class="dropdown-header-button gl-p-0!" icon="close" - @click="toggleDropdownContents" + @click="$emit('closeDropdown')" /> </div> <component @@ -103,7 +103,7 @@ export default { :footer-create-label-title="footerCreateLabelTitle" :footer-manage-label-title="footerManageLabelTitle" @hideCreateView="toggleDropdownContentsCreateView" - @closeDropdown="$emit('closeDropdown', $event)" + @setLabels="$emit('setLabels', $event)" @toggleDropdownContentsCreateView="toggleDropdownContentsCreateView" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index 4651e7a1576..2e31b386fdd 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -1,8 +1,10 @@ <script> import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import produce from 'immer'; import createFlash from '~/flash'; import { __ } from '~/locale'; import createLabelMutation from './graphql/create_label.mutation.graphql'; +import projectLabelsQuery from './graphql/project_labels.query.graphql'; const errorMessage = __('Error creating label.'); @@ -47,6 +49,25 @@ export default { handleColorClick(color) { this.selectedColor = this.getColorCode(color); }, + updateLabelsInCache(store, label) { + const sourceData = store.readQuery({ + query: projectLabelsQuery, + variables: { fullPath: this.projectPath, searchTerm: '' }, + }); + + const collator = new Intl.Collator('en'); + const data = produce(sourceData, (draftData) => { + const { nodes } = draftData.workspace.labels; + nodes.push(label); + nodes.sort((a, b) => collator.compare(a.title, b.title)); + }); + + store.writeQuery({ + query: projectLabelsQuery, + variables: { fullPath: this.projectPath, searchTerm: '' }, + data, + }); + }, async createLabel() { this.labelCreateInProgress = true; try { @@ -59,6 +80,14 @@ export default { color: this.selectedColor, projectPath: this.projectPath, }, + update: ( + store, + { + data: { + labelCreate: { label }, + }, + }, + ) => this.updateLabelsInCache(store, label), }); if (labelCreate.errors.length) { createFlash({ message: errorMessage }); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index ffa37424c2c..224dbd3e29b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -112,7 +112,7 @@ export default { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, beforeDestroy() { - this.$emit('closeDropdown', this.localSelectedLabels); + this.$emit('setLabels', this.localSelectedLabels); this.debouncedSearchKeyUpdate.cancel(); }, methods: { @@ -166,7 +166,7 @@ export default { this.updateSelectedLabels(this.visibleLabels[this.currentHighlightItem]); this.searchKey = ''; } else if (e.keyCode === ESC_KEY_CODE) { - this.$emit('closeDropdown', this.localSelectedLabels); + this.$emit('setLabels', this.localSelectedLabels); } if (e.keyCode !== ESC_KEY_CODE) { @@ -180,7 +180,7 @@ export default { handleLabelClick(label) { this.updateSelectedLabels(label); if (!this.allowMultiselect) { - this.$emit('closeDropdown', this.localSelectedLabels); + this.$emit('setLabels', this.localSelectedLabels); } }, setSearchKey(value) { @@ -240,7 +240,7 @@ export default { <gl-link class="gl-display-flex gl-flex-direction-row gl-w-full gl-overflow-break-word label-item" data-testid="create-label-button" - @click="$emit('toggleDropdownContentsCreateView')" + @click.stop="$emit('toggleDropdownContentsCreateView')" > {{ footerCreateLabelTitle }} </gl-link> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue deleted file mode 100644 index 46edfa1c42a..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue +++ /dev/null @@ -1,40 +0,0 @@ -<script> -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; - -export default { - components: { - GlButton, - GlLoadingIcon, - }, - props: { - labelsSelectInProgress: { - type: Boolean, - required: true, - }, - }, - computed: { - ...mapState(['allowLabelEdit', 'labelsFetchInProgress']), - }, - methods: { - ...mapActions(['toggleDropdownContents']), - }, -}; -</script> - -<template> - <div class="title hide-collapsed gl-mb-3"> - {{ __('Labels') }} - <template v-if="allowLabelEdit"> - <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline /> - <gl-button - category="tertiary" - size="small" - class="float-right js-sidebar-dropdown-toggle gl-mr-n2" - data-qa-selector="labels_edit_button" - @click="toggleDropdownContents" - >{{ __('Edit') }}</gl-button - > - </template> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql index 9aa4f5d165e..eb478645a03 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql @@ -6,9 +6,7 @@ mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPa id color description - descriptionHtml title - textColor } errors } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 0499dfe468f..efe524996b2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -1,14 +1,12 @@ <script> -import $ from 'jquery'; import Vue from 'vue'; import Vuex, { mapState, mapActions, mapGetters } from 'vuex'; import { isInViewport } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; - +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { DropdownVariant } from './constants'; import DropdownButton from './dropdown_button.vue'; import DropdownContents from './dropdown_contents.vue'; -import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import issueLabelsQuery from './graphql/issue_labels.query.graphql'; @@ -19,11 +17,11 @@ Vue.use(Vuex); export default { store: new Vuex.Store(labelsSelectModule()), components: { - DropdownTitle, DropdownValue, DropdownButton, DropdownContents, DropdownValueCollapsed, + SidebarEditableItem, }, inject: ['iid', 'projectPath'], props: { @@ -139,15 +137,12 @@ export default { }, }, computed: { - ...mapState(['showDropdownButton', 'showDropdownContents']), + ...mapState(['showDropdownContents']), ...mapGetters([ 'isDropdownVariantSidebar', 'isDropdownVariantStandalone', 'isDropdownVariantEmbedded', ]), - dropdownButtonVisible() { - return this.isDropdownVariantSidebar ? this.showDropdownButton : true; - }, }, watch: { selectedLabels(selectedLabels) { @@ -182,99 +177,20 @@ export default { footerCreateLabelTitle: this.footerCreateLabelTitle, footerManageLabelTitle: this.footerManageLabelTitle, }); - - this.$store.subscribeAction({ - after: this.handleVuexActionDispatch, - }); - - document.addEventListener('mousedown', this.handleDocumentMousedown); - document.addEventListener('click', this.handleDocumentClick); - }, - beforeDestroy() { - document.removeEventListener('mousedown', this.handleDocumentMousedown); - document.removeEventListener('click', this.handleDocumentClick); }, methods: { - ...mapActions(['setInitialState', 'toggleDropdownContents']), - /** - * This method stores a mousedown event's target. - * Required by the click listener because the click - * event itself has no reference to this element. - */ - handleDocumentMousedown({ target }) { - this.mousedownTarget = target; - }, - /** - * This method listens for document-wide click event - * and toggle dropdown if user clicks anywhere outside - * the dropdown while dropdown is visible. - */ - handleDocumentClick({ target }) { - // We also perform the toggle exception check for the - // last mousedown event's target to avoid hiding the - // box when the mousedown happened inside the box and - // only the mouseup did not. - if ( - this.showDropdownContents && - !this.preventDropdownToggleOnClick(target) && - !this.preventDropdownToggleOnClick(this.mousedownTarget) - ) { - this.toggleDropdownContents(); - } - }, - /** - * This method checks whether a given click target - * should prevent the dropdown from being toggled. - */ - preventDropdownToggleOnClick(target) { - // This approach of element detection is needed - // as the dropdown wrapper is not using `GlDropdown` as - // it will also require us to use `BDropdownForm` - // which is yet to be implemented in GitLab UI. - const hasExceptionClass = [ - 'js-dropdown-button', - 'js-btn-cancel-create', - 'js-sidebar-dropdown-toggle', - ].some( - (className) => - target?.classList.contains(className) || - target?.parentElement?.classList.contains(className), - ); - - const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some( - (className) => $(target).parents(className).length, - ); - - const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target); - - const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target); - - return ( - hasExceptionClass || - hasExceptionParent || - isInDropdownButtonCollapsed || - isInDropdownContents - ); - }, + ...mapActions(['setInitialState']), handleDropdownClose(labels) { - // Only emit label updates if there are any labels to update - // on UI. - if (this.showDropdownContents) { - this.toggleDropdownContents(); - } if (labels.length) this.$emit('updateSelectedLabels', labels); this.$emit('onDropdownClose'); }, + collapseDropdown() { + this.$refs.editable.collapse(); + }, handleCollapsedValueClick() { this.$emit('toggleCollapse'); }, - setContentIsOnViewport(showDropdownContents) { - if (!showDropdownContents) { - this.contentIsOnViewport = true; - - return; - } - + setContentIsOnViewport() { this.$nextTick(() => { if (this.$refs.dropdownContents) { this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el); @@ -299,48 +215,55 @@ export default { :labels="issueLabels" @onValueClick="handleCollapsedValueClick" /> - <dropdown-title - :allow-label-edit="allowLabelEdit" - :labels-select-in-progress="labelsSelectInProgress" - /> - <dropdown-value - :disable-labels="labelsSelectInProgress" - :selected-labels="issueLabels" - :allow-label-remove="allowLabelRemove" - :allow-scoped-labels="allowScopedLabels" - :labels-filter-base-path="labelsFilterBasePath" - :labels-filter-param="labelsFilterParam" - @onLabelRemove="$emit('onLabelRemove', $event)" + <sidebar-editable-item + ref="editable" + :title="__('Labels')" + :loading="labelsSelectInProgress" + @open="setContentIsOnViewport" + @close="contentIsOnViewport = true" > - <slot></slot> - </dropdown-value> - <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> - <dropdown-contents - v-if="dropdownButtonVisible && showDropdownContents" - ref="dropdownContents" - :allow-multiselect="allowMultiselect" - :labels-list-title="labelsListTitle" - :footer-create-label-title="footerCreateLabelTitle" - :footer-manage-label-title="footerManageLabelTitle" - :render-on-top="!contentIsOnViewport" - :labels-create-title="labelsCreateTitle" - :selected-labels="selectedLabels" - @closeDropdown="handleDropdownClose" - /> - </template> - <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded"> - <dropdown-button v-show="dropdownButtonVisible" /> - <dropdown-contents - v-if="dropdownButtonVisible && showDropdownContents" - ref="dropdownContents" - :allow-multiselect="allowMultiselect" - :labels-list-title="labelsListTitle" - :footer-create-label-title="footerCreateLabelTitle" - :footer-manage-label-title="footerManageLabelTitle" - :render-on-top="!contentIsOnViewport" - :selected-labels="selectedLabels" - @closeDropdown="handleDropdownClose" - /> + <template #collapsed> + <dropdown-value + :disable-labels="labelsSelectInProgress" + :selected-labels="issueLabels" + :allow-label-remove="allowLabelRemove" + :allow-scoped-labels="allowScopedLabels" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" + @onLabelRemove="$emit('onLabelRemove', $event)" + > + <slot></slot> + </dropdown-value> + </template> + <template #default="{ edit }"> + <dropdown-value + :disable-labels="labelsSelectInProgress" + :selected-labels="issueLabels" + :allow-label-remove="allowLabelRemove" + :allow-scoped-labels="allowScopedLabels" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" + class="gl-mb-2" + @onLabelRemove="$emit('onLabelRemove', $event)" + > + <slot></slot> + </dropdown-value> + <dropdown-button /> + <dropdown-contents + v-if="edit" + ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + :render-on-top="!contentIsOnViewport" + :labels-create-title="labelsCreateTitle" + :selected-labels="selectedLabels" + @closeDropdown="collapseDropdown" + @setLabels="handleDropdownClose" + /> + </template> + </sidebar-editable-item> </template> </div> </template> diff --git a/app/finders/packages/helm/package_files_finder.rb b/app/finders/packages/helm/package_files_finder.rb index ba400b27554..c6504d09dce 100644 --- a/app/finders/packages/helm/package_files_finder.rb +++ b/app/finders/packages/helm/package_files_finder.rb @@ -6,6 +6,8 @@ module Packages DEFAULT_PACKAGE_FILES_COUNT = 20 MAX_PACKAGE_FILES_COUNT = 1000 + delegate :most_recent!, to: :execute + def initialize(project, channel, params = {}) @project = project @channel = channel diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index 8aa19397086..807c6c409c7 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -77,6 +77,10 @@ class Packages::PackageFile < ApplicationRecord .where(packages_conan_file_metadata: { conan_package_reference: conan_package_reference }) end + def self.most_recent! + recent.first! + end + mount_file_store_uploader Packages::PackageFileUploader update_project_statistics project_statistics_name: :packages_size diff --git a/app/models/user.rb b/app/models/user.rb index 12f167d1385..3920e61c953 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1000,7 +1000,11 @@ class User < ApplicationRecord # Returns the groups a user is a member of, either directly or through a parent group def membership_groups - Gitlab::ObjectHierarchy.new(groups).base_and_descendants + if Feature.enabled?(:linear_user_membership_groups, self, default_enabled: :yaml) + groups.self_and_descendants + else + Gitlab::ObjectHierarchy.new(groups).base_and_descendants + end end # Returns a relation of groups the user has access to, including their parent diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 80a8ee5cb3c..066f4786cff 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -431,22 +431,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated private def integrations_anchor_data - experiment(:repo_integrations_link, project: project) do |e| - e.exclude! unless can?(current_user, :admin_project, project) - - e.use {} # nil control - e.try do - label = statistic_icon('settings') + _('Configure Integrations') - AnchorData.new(false, label, project_settings_integrations_path(project), nil, nil, nil, { - 'track-event': 'click', - 'track-experiment': e.name - }) - end - - e.run # call run so the return value will be the AnchorData (or nil) + return unless can?(current_user, :admin_project, project) - e.track(:view, value: project.id) # track an event for the view, with project id - end + label = statistic_icon('settings') + _('Configure Integrations') + AnchorData.new(false, label, project_settings_integrations_path(project), nil, nil, nil) end def cicd_missing? diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 8818cd9e6c4..ec0b59785ca 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -26,6 +26,13 @@ = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referer), class: link_classes + 'btn gl-button btn-default btn-icon', title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = sprite_icon('error') + - verified_gpg_keys = @user.gpg_keys.select(&:verified?) + - if verified_gpg_keys.any? + = link_to user_gpg_keys_path, + class: link_classes + 'btn btn-default btn-md gl-button btn-icon has-tooltip', + title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length), + data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do + = sprite_icon('key', css_class: 'gl-button-icon gl-icon') - if can?(current_user, :read_user_profile, @user) = link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do diff --git a/app/workers/packages/debian/process_changes_worker.rb b/app/workers/packages/debian/process_changes_worker.rb index 3ddeb858429..0d10e18cc01 100644 --- a/app/workers/packages/debian/process_changes_worker.rb +++ b/app/workers/packages/debian/process_changes_worker.rb @@ -22,12 +22,7 @@ module Packages return unless package_file && user ::Packages::Debian::ProcessChangesService.new(package_file, user).execute - rescue ArgumentError, - Packages::Debian::ExtractChangesMetadataService::ExtractionError, - Packages::Debian::ExtractDebMetadataService::CommandFailedError, - Packages::Debian::ExtractMetadataService::ExtractionError, - Packages::Debian::ParseDebian822Service::InvalidDebian822Error, - ActiveRecord::RecordNotFound => e + rescue StandardError => e Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id) package_file.destroy! end diff --git a/app/workers/packages/helm/extraction_worker.rb b/app/workers/packages/helm/extraction_worker.rb index 1010a0833b1..0ba2d149f77 100644 --- a/app/workers/packages/helm/extraction_worker.rb +++ b/app/workers/packages/helm/extraction_worker.rb @@ -20,9 +20,7 @@ module Packages ::Packages::Helm::ProcessFileService.new(channel, package_file).execute - rescue ::Packages::Helm::ExtractFileMetadataService::ExtractionError, - ::Packages::Helm::ProcessFileService::ExtractionError, - ::ActiveModel::ValidationError => e + rescue StandardError => e Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id) package_file.package.update_column(:status, :error) end diff --git a/config/feature_flags/experiment/repo_integrations_link.yml b/config/feature_flags/development/linear_user_membership_groups.yml index 943429a84e7..19bca849090 100644 --- a/config/feature_flags/experiment/repo_integrations_link.yml +++ b/config/feature_flags/development/linear_user_membership_groups.yml @@ -1,8 +1,8 @@ --- -name: repo_integrations_link -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54652/ -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/285154 -milestone: '13.10' -type: experiment -group: group::adoption +name: linear_user_membership_groups +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68842 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339432 +milestone: '14.3' +type: development +group: group::access default_enabled: false diff --git a/config/helpers/incremental_webpack_compiler.js b/config/helpers/incremental_webpack_compiler.js deleted file mode 100644 index 5d4f9bd040d..00000000000 --- a/config/helpers/incremental_webpack_compiler.js +++ /dev/null @@ -1,131 +0,0 @@ -/* eslint-disable max-classes-per-file, no-underscore-dangle */ -const fs = require('fs'); -const path = require('path'); - -const log = (msg, ...rest) => console.log(`IncrementalWebpackCompiler: ${msg}`, ...rest); - -// If we force a recompile immediately, the page reload doesn't seem to work. -// Five seconds seem to work fine and the user can read the message -const TIMEOUT = 5000; - -/* eslint-disable class-methods-use-this */ -class NoopCompiler { - constructor() { - this.enabled = false; - } - - filterEntryPoints(entryPoints) { - return entryPoints; - } - - logStatus() {} - - setupMiddleware() {} -} -/* eslint-enable class-methods-use-this */ - -class IncrementalWebpackCompiler { - constructor(historyFilePath) { - this.enabled = true; - this.history = {}; - this.compiledEntryPoints = new Set([ - // Login page - 'pages.sessions.new', - // Explore page - 'pages.root', - ]); - this.historyFilePath = historyFilePath; - this._loadFromHistory(); - } - - filterEntryPoints(entrypoints) { - return Object.fromEntries( - Object.entries(entrypoints).map(([key, val]) => { - if (this.compiledEntryPoints.has(key)) { - return [key, val]; - } - return [key, ['./webpack_non_compiled_placeholder.js']]; - }), - ); - } - - logStatus(totalCount) { - const current = this.compiledEntryPoints.size; - log(`Currently compiling route entrypoints: ${current} of ${totalCount}`); - } - - setupMiddleware(app, server) { - app.use((req, res, next) => { - const fileName = path.basename(req.url); - - /** - * We are only interested in files that have a name like `pages.foo.bar.chunk.js` - * because those are the ones corresponding to our entry points. - * - * This filters out hot update files that are for example named "pages.foo.bar.[hash].hot-update.js" - */ - if (fileName.startsWith('pages.') && fileName.endsWith('.chunk.js')) { - const chunk = fileName.replace(/\.chunk\.js$/, ''); - - this._addToHistory(chunk); - - if (!this.compiledEntryPoints.has(chunk)) { - log(`First time we are seeing ${chunk}. Adding to compilation.`); - - this.compiledEntryPoints.add(chunk); - - setTimeout(() => { - server.middleware.invalidate(() => { - if (server.sockets) { - server.sockWrite(server.sockets, 'content-changed'); - } - }); - }, TIMEOUT); - } - } - - next(); - }); - } - - // private methods - - _addToHistory(chunk) { - if (!this.history[chunk]) { - this.history[chunk] = { lastVisit: null, count: 0 }; - } - this.history[chunk].lastVisit = Date.now(); - this.history[chunk].count += 1; - - try { - fs.writeFileSync(this.historyFilePath, JSON.stringify(this.history), 'utf8'); - } catch (e) { - log('Warning – Could not write to history', e.message); - } - } - - _loadFromHistory() { - try { - this.history = JSON.parse(fs.readFileSync(this.historyFilePath, 'utf8')); - const entryPoints = Object.keys(this.history); - log(`Successfully loaded history containing ${entryPoints.length} entry points`); - /* - TODO: Let's ask a few folks to give us their history file after a milestone of usage - Then we can make smarter decisions on when to throw out rather than rendering everything - Something like top 20/30/40 entries visited in the last 7/10/15 days might be sufficient - */ - this.compiledEntryPoints = new Set([...this.compiledEntryPoints, ...entryPoints]); - } catch (e) { - log(`No history found...`); - } - } -} - -module.exports = (enabled, historyFilePath) => { - log(`Status – ${enabled ? 'enabled' : 'disabled'}`); - - if (enabled) { - return new IncrementalWebpackCompiler(historyFilePath); - } - return new NoopCompiler(); -}; diff --git a/config/helpers/incremental_webpack_compiler/compiler.js b/config/helpers/incremental_webpack_compiler/compiler.js new file mode 100644 index 00000000000..480d7fa3263 --- /dev/null +++ b/config/helpers/incremental_webpack_compiler/compiler.js @@ -0,0 +1,117 @@ +/* eslint-disable max-classes-per-file */ + +const path = require('path'); +const { History, HistoryWithTTL } = require('./history'); +const log = require('./log'); + +const onRequestEntryPoint = (app, callback) => { + app.use((req, res, next) => { + const fileName = path.basename(req.url); + + /** + * We are only interested in files that have a name like `pages.foo.bar.chunk.js` + * because those are the ones corresponding to our entry points. + * + * This filters out hot update files that are for example named "pages.foo.bar.[hash].hot-update.js" + */ + if (fileName.startsWith('pages.') && fileName.endsWith('.chunk.js')) { + const entryPoint = fileName.replace(/\.chunk\.js$/, ''); + callback(entryPoint); + } + + next(); + }); +}; + +/** + * The NoopCompiler does nothing, following the null object pattern. + */ +class NoopCompiler { + constructor() { + this.enabled = false; + } + + // eslint-disable-next-line class-methods-use-this + filterEntryPoints(entryPoints) { + return entryPoints; + } + + // eslint-disable-next-line class-methods-use-this + logStatus() {} + + // eslint-disable-next-line class-methods-use-this + setupMiddleware() {} +} + +/** + * The HistoryOnlyCompiler only records which entry points have been requested. + * This is so that if the user disables incremental compilation, history is + * still recorded. If they later enable incremental compilation, that history + * can be used. + */ +class HistoryOnlyCompiler extends NoopCompiler { + constructor(historyFilePath) { + super(); + this.history = new History(historyFilePath); + } + + setupMiddleware(app) { + onRequestEntryPoint(app, (entryPoint) => { + this.history.onRequestEntryPoint(entryPoint); + }); + } +} + +// If we force a recompile immediately, the page reload doesn't seem to work. +// Five seconds seem to work fine and the user can read the message +const TIMEOUT = 5000; + +/** + * The IncrementalWebpackCompiler tracks which entry points have been + * requested, and only compiles entry points visited within the last `ttl` + * days. + */ +class IncrementalWebpackCompiler { + constructor(historyFilePath, ttl) { + this.enabled = true; + this.history = new HistoryWithTTL(historyFilePath, ttl); + } + + filterEntryPoints(entrypoints) { + return Object.fromEntries( + Object.entries(entrypoints).map(([entryPoint, paths]) => { + if (this.history.isRecentlyVisited(entryPoint)) { + return [entryPoint, paths]; + } + return [entryPoint, ['./webpack_non_compiled_placeholder.js']]; + }), + ); + } + + logStatus(totalCount) { + log(`Currently compiling route entrypoints: ${this.history.size} of ${totalCount}`); + } + + setupMiddleware(app, server) { + onRequestEntryPoint(app, (entryPoint) => { + const wasVisitedRecently = this.history.onRequestEntryPoint(entryPoint); + if (!wasVisitedRecently) { + log(`Have not visited ${entryPoint} recently. Adding to compilation.`); + + setTimeout(() => { + server.middleware.invalidate(() => { + if (server.sockets) { + server.sockWrite(server.sockets, 'content-changed'); + } + }); + }, TIMEOUT); + } + }); + } +} + +module.exports = { + NoopCompiler, + HistoryOnlyCompiler, + IncrementalWebpackCompiler, +}; diff --git a/config/helpers/incremental_webpack_compiler/history.js b/config/helpers/incremental_webpack_compiler/history.js new file mode 100644 index 00000000000..24599900011 --- /dev/null +++ b/config/helpers/incremental_webpack_compiler/history.js @@ -0,0 +1,176 @@ +/* eslint-disable max-classes-per-file, no-underscore-dangle */ + +const fs = require('fs'); +const log = require('./log'); + +const ESSENTIAL_ENTRY_POINTS = [ + // Login page + 'pages.sessions.new', + // Explore page + 'pages.root', +]; + +// TODO: Find a way to keep this list up-to-date/relevant. +const COMMON_ENTRY_POINTS = [ + ...ESSENTIAL_ENTRY_POINTS, + 'pages.admin', + 'pages.admin.dashboard', + 'pages.dashboard.groups.index', + 'pages.dashboard.projects.index', + 'pages.groups.new', + 'pages.groups.show', + 'pages.profiles.preferences.show', + 'pages.projects.commit.show', + 'pages.projects.edit', + 'pages.projects.issues.index', + 'pages.projects.issues.new', + 'pages.projects.issues.show', + 'pages.projects.jobs.show', + 'pages.projects.merge_requests.index', + 'pages.projects.merge_requests.show', + 'pages.projects.milestones.index', + 'pages.projects.new', + 'pages.projects.pipelines.index', + 'pages.projects.pipelines.show', + 'pages.projects.settings.ci_cd.show', + 'pages.projects.settings.repository.show', + 'pages.projects.show', + 'pages.users', +]; + +/** + * The History class is responsible for tracking which entry points have been + * requested, and persisting/loading the history to/from disk. + */ +class History { + constructor(historyFilePath) { + this._historyFilePath = historyFilePath; + this._history = {}; + + this._loadHistoryFile(); + } + + onRequestEntryPoint(entryPoint) { + const wasVisitedRecently = this.isRecentlyVisited(entryPoint); + + this._addEntryPoint(entryPoint); + + this._writeHistoryFile(); + + return wasVisitedRecently; + } + + // eslint-disable-next-line class-methods-use-this + isRecentlyVisited() { + return true; + } + + // eslint-disable-next-line class-methods-use-this + get size() { + return 0; + } + + // Private methods + + _addEntryPoint(entryPoint) { + if (!this._history[entryPoint]) { + this._history[entryPoint] = { lastVisit: null, count: 0 }; + } + + this._history[entryPoint].lastVisit = Date.now(); + this._history[entryPoint].count += 1; + } + + _writeHistoryFile() { + try { + fs.writeFileSync(this._historyFilePath, JSON.stringify(this._history), 'utf8'); + } catch (error) { + log('Warning – Could not write to history', error.message); + } + } + + _loadHistoryFile() { + try { + fs.accessSync(this._historyFilePath); + } catch (e) { + // History file doesn't exist; attempt to seed it, and return early + this._seedHistory(); + return; + } + + // History file already exists; attempt to load its contents into memory + try { + this._history = JSON.parse(fs.readFileSync(this._historyFilePath, 'utf8')); + const historySize = Object.keys(this._history).length; + log(`Successfully loaded history containing ${historySize} entry points`); + } catch (error) { + log('Could not load history', error.message); + } + } + + /** + * Seeds a reasonable set of approximately the most common entry points to + * seed the history. This helps to avoid fresh GDK installs showing the + * compiling overlay too often. + */ + _seedHistory() { + log('Seeding history...'); + COMMON_ENTRY_POINTS.forEach((entryPoint) => this._addEntryPoint(entryPoint)); + this._writeHistoryFile(); + } +} + +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +/** + * The HistoryWithTTL class adds LRU-like behaviour onto the base History + * behaviour. Entry points visited within the last `ttl` days are considered + * "recent", and therefore should be eagerly compiled. + */ +class HistoryWithTTL extends History { + constructor(historyFilePath, ttl) { + super(historyFilePath); + this._ttl = ttl; + this._calculateRecentEntryPoints(); + } + + onRequestEntryPoint(entryPoint) { + const wasVisitedRecently = super.onRequestEntryPoint(entryPoint); + + this._calculateRecentEntryPoints(); + + return wasVisitedRecently; + } + + isRecentlyVisited(entryPoint) { + return this._recentEntryPoints.has(entryPoint); + } + + get size() { + return this._recentEntryPoints.size; + } + + // Private methods + + _calculateRecentEntryPoints() { + const oldestVisitAllowed = Date.now() - MS_PER_DAY * this._ttl; + + const recentEntryPoints = Object.entries(this._history).reduce( + (acc, [entryPoint, { lastVisit }]) => { + if (lastVisit > oldestVisitAllowed) { + acc.push(entryPoint); + } + + return acc; + }, + [], + ); + + this._recentEntryPoints = new Set([...ESSENTIAL_ENTRY_POINTS, ...recentEntryPoints]); + } +} + +module.exports = { + History, + HistoryWithTTL, +}; diff --git a/config/helpers/incremental_webpack_compiler/index.js b/config/helpers/incremental_webpack_compiler/index.js new file mode 100644 index 00000000000..81826607490 --- /dev/null +++ b/config/helpers/incremental_webpack_compiler/index.js @@ -0,0 +1,17 @@ +const { NoopCompiler, HistoryOnlyCompiler, IncrementalWebpackCompiler } = require('./compiler'); +const log = require('./log'); + +module.exports = (recordHistory, enabled, historyFilePath, ttl) => { + if (!recordHistory) { + log(`Status – disabled`); + return new NoopCompiler(); + } + + if (enabled) { + log(`Status – enabled, ttl=${ttl}`); + return new IncrementalWebpackCompiler(historyFilePath, ttl); + } + + log(`Status – history-only`); + return new HistoryOnlyCompiler(historyFilePath); +}; diff --git a/config/helpers/incremental_webpack_compiler/log.js b/config/helpers/incremental_webpack_compiler/log.js new file mode 100644 index 00000000000..6336cb0a78b --- /dev/null +++ b/config/helpers/incremental_webpack_compiler/log.js @@ -0,0 +1,3 @@ +const log = (msg, ...rest) => console.log(`IncrementalWebpackCompiler: ${msg}`, ...rest); + +module.exports = log; diff --git a/config/routes/user.rb b/config/routes/user.rb index 109179f76f1..7d61aa089fb 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -64,7 +64,7 @@ constraints(::Constraints::UserUrlConstrainer.new) do get ':username.keys' => 'users#ssh_keys', constraints: { username: Gitlab::PathRegex.root_namespace_route_regex } # Get all GPG keys of user - get ':username.gpg' => 'users#gpg_keys', constraints: { username: Gitlab::PathRegex.root_namespace_route_regex } + get ':username.gpg' => 'users#gpg_keys', as: 'user_gpg_keys', constraints: { username: Gitlab::PathRegex.root_namespace_route_regex } scope(path: ':username', as: :user, diff --git a/config/webpack.config.js b/config/webpack.config.js index b81b5611041..f5cd0b154e4 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -48,6 +48,8 @@ const INCREMENTAL_COMPILER_ENABLED = IS_DEV_SERVER && process.env.DEV_SERVER_INCREMENTAL && process.env.DEV_SERVER_INCREMENTAL !== 'false'; +const INCREMENTAL_COMPILER_TTL = Number(process.env.DEV_SERVER_INCREMENTAL_TTL) || Infinity; +const INCREMENTAL_COMPILER_RECORD_HISTORY = IS_DEV_SERVER && !process.env.CI; const WEBPACK_REPORT = process.env.WEBPACK_REPORT && process.env.WEBPACK_REPORT !== 'false'; const WEBPACK_MEMORY_TEST = process.env.WEBPACK_MEMORY_TEST && process.env.WEBPACK_MEMORY_TEST !== 'false'; @@ -69,8 +71,10 @@ let watchAutoEntries = []; const defaultEntries = ['./main']; const incrementalCompiler = createIncrementalWebpackCompiler( + INCREMENTAL_COMPILER_RECORD_HISTORY, INCREMENTAL_COMPILER_ENABLED, path.join(CACHE_PATH, 'incremental-webpack-compiler-history.json'), + INCREMENTAL_COMPILER_TTL, ); function generateEntries() { @@ -157,6 +161,9 @@ const alias = { // the following resolves files which are different between CE and EE ee_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'), + // the following resolves files which are different between CE and JH + jh_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'), + // override loader path for icons.svg so we do not duplicate this asset '@gitlab/svgs/dist/icons.svg': path.join( ROOT_PATH, @@ -180,10 +187,13 @@ if (IS_EE) { if (IS_JH) { Object.assign(alias, { jh: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), + jh_component: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), + jh_empty_states: path.join(ROOT_PATH, 'jh/app/views/shared/empty_states'), jh_icons: path.join(ROOT_PATH, 'jh/app/views/shared/icons'), jh_images: path.join(ROOT_PATH, 'jh/app/assets/images'), jh_spec: path.join(ROOT_PATH, 'jh/spec/javascripts'), jh_jest: path.join(ROOT_PATH, 'jh/spec/frontend'), + jh_else_ce: path.join(ROOT_PATH, 'jh/app/assets/javascripts'), }); } @@ -519,6 +529,15 @@ module.exports = { ); }), + !IS_JH && + new webpack.NormalModuleReplacementPlugin(/^jh_component\/(.*)\.vue/, (resource) => { + // eslint-disable-next-line no-param-reassign + resource.request = path.join( + ROOT_PATH, + 'app/assets/javascripts/vue_shared/components/empty_component.js', + ); + }), + new CopyWebpackPlugin({ patterns: [ { @@ -634,10 +653,12 @@ module.exports = { }), new webpack.DefinePlugin({ - // This one is used to define window.gon.ee and other things properly in tests: + // These are used to define window.gon.ee, window.gon.jh and other things properly in tests: 'process.env.IS_EE': JSON.stringify(IS_EE), - // This one is used to check against "EE" properly in application code + 'process.env.IS_JH': JSON.stringify(IS_JH), + // These are used to check against "EE" properly in application code IS_EE: IS_EE ? 'window.gon && window.gon.ee' : JSON.stringify(false), + IS_JH: IS_JH ? 'window.gon && window.gon.jh' : JSON.stringify(false), // This is used by Sourcegraph because these assets are loaded dnamically 'process.env.SOURCEGRAPH_PUBLIC_PATH': JSON.stringify(SOURCEGRAPH_PUBLIC_PATH), }), diff --git a/doc/user/project/repository/gpg_signed_commits/index.md b/doc/user/project/repository/gpg_signed_commits/index.md index c41b3ed8615..91d3a06d7da 100644 --- a/doc/user/project/repository/gpg_signed_commits/index.md +++ b/doc/user/project/repository/gpg_signed_commits/index.md @@ -19,6 +19,11 @@ NOTE: The term GPG is used for all OpenPGP/PGP/GPG related material and implementations. +To view a user's public GPG key, you can: + +- Go to `https://gitlab.example.com/<username>.gpg`. +- Select **View public GPG keys** (**{key}**) in the top right of the user's profile. + GPG verified tags are not supported yet. See the [further reading](#further-reading) section for more details on GPG. diff --git a/jest.config.base.js b/jest.config.base.js index 997f3c254b4..3ace87c49bc 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -1,10 +1,12 @@ const IS_EE = require('./config/helpers/is_ee_env'); const isESLint = require('./config/helpers/is_eslint'); +const IS_JH = require('./config/helpers/is_jh_env'); module.exports = (path, options = {}) => { const { moduleNameMapper: extModuleNameMapper = {}, moduleNameMapperEE: extModuleNameMapperEE = {}, + moduleNameMapperJH: extModuleNameMapperJH = {}, } = options; const reporters = ['default']; @@ -29,6 +31,9 @@ module.exports = (path, options = {}) => { testMatch.push(`<rootDir>/ee/${glob}`); } + if (IS_JH) { + testMatch.push(`<rootDir>/jh/${glob}`); + } // workaround for eslint-import-resolver-jest only resolving in test files // see https://github.com/JoinColony/eslint-import-resolver-jest#note if (isESLint(module)) { @@ -41,8 +46,11 @@ module.exports = (path, options = {}) => { '^~(/.*)$': '<rootDir>/app/assets/javascripts$1', '^ee_component(/.*)$': '<rootDir>/app/assets/javascripts/vue_shared/components/empty_component.js', + '^jh_component(/.*)$': + '<rootDir>/app/assets/javascripts/vue_shared/components/empty_component.js', '^shared_queries(/.*)$': '<rootDir>/app/graphql/queries$1', '^ee_else_ce(/.*)$': '<rootDir>/app/assets/javascripts$1', + '^jh_else_ce(/.*)$': '<rootDir>/app/assets/javascripts$1', '^helpers(/.*)$': '<rootDir>/spec/frontend/__helpers__$1', '^vendor(/.*)$': '<rootDir>/vendor/assets/javascripts$1', [TEST_FIXTURES_PATTERN]: '<rootDir>/tmp/tests/frontend/fixtures$1', @@ -70,6 +78,19 @@ module.exports = (path, options = {}) => { collectCoverageFrom.push(rootDirEE.replace('$1', '/**/*.{js,vue}')); } + if (IS_JH) { + const rootDirJH = '<rootDir>/jh/app/assets/javascripts$1'; + Object.assign(moduleNameMapper, { + '^jh(/.*)$': rootDirJH, + '^jh_component(/.*)$': rootDirJH, + '^jh_else_ce(/.*)$': rootDirJH, + '^jh_jest/(.*)$': '<rootDir>/jh/spec/frontend/$1', + ...extModuleNameMapperJH, + }); + + collectCoverageFrom.push(rootDirJH.replace('$1', '/**/*.{js,vue}')); + } + const coverageDirectory = () => { if (process.env.CI_NODE_INDEX && process.env.CI_NODE_TOTAL) { return `<rootDir>/coverage-frontend/jest-${process.env.CI_NODE_INDEX}-${process.env.CI_NODE_TOTAL}`; @@ -107,6 +128,7 @@ module.exports = (path, options = {}) => { testEnvironment: '<rootDir>/spec/frontend/environment.js', testEnvironmentOptions: { IS_EE, + IS_JH, }, }; }; diff --git a/jest.config.integration.js b/jest.config.integration.js index 92296fb751e..da8e813a2cb 100644 --- a/jest.config.integration.js +++ b/jest.config.integration.js @@ -8,9 +8,13 @@ module.exports = { moduleNameMapper: { '^test_helpers(/.*)$': '<rootDir>/spec/frontend_integration/test_helpers$1', '^ee_else_ce_test_helpers(/.*)$': '<rootDir>/spec/frontend_integration/test_helpers$1', + '^jh_else_ce_test_helpers(/.*)$': '<rootDir>/spec/frontend_integration/test_helpers$1', }, moduleNameMapperEE: { '^ee_else_ce_test_helpers(/.*)$': '<rootDir>/ee/spec/frontend_integration/test_helpers$1', }, + moduleNameMapperJH: { + '^jh_else_ce_test_helpers(/.*)$': '<rootDir>/jh/spec/frontend_integration/test_helpers$1', + }, }), }; diff --git a/lib/api/helm_packages.rb b/lib/api/helm_packages.rb index 4280744d8b4..d6281c3772f 100644 --- a/lib/api/helm_packages.rb +++ b/lib/api/helm_packages.rb @@ -66,7 +66,7 @@ module API get ":channel/charts/:file_name.tgz", requirements: FILE_NAME_REQUIREMENTS do authorize_read_package!(authorized_user_project) - package_file = Packages::Helm::PackageFilesFinder.new(authorized_user_project, params[:channel], file_name: "#{params[:file_name]}.tgz").execute.last! + package_file = Packages::Helm::PackageFilesFinder.new(authorized_user_project, params[:channel], file_name: "#{params[:file_name]}.tgz").most_recent! track_package_event('pull_package', :helm, project: authorized_user_project, namespace: authorized_user_project.namespace) diff --git a/lib/gitlab/database/load_balancing/service_discovery.rb b/lib/gitlab/database/load_balancing/service_discovery.rb index f04ecfa5e72..dfd4892371c 100644 --- a/lib/gitlab/database/load_balancing/service_discovery.rb +++ b/lib/gitlab/database/load_balancing/service_discovery.rb @@ -89,8 +89,10 @@ module Gitlab # Sentry, instead of silently terminating this thread. Gitlab::ErrorTracking.track_exception(error) - Gitlab::AppLogger.error( - "Service discovery encountered an error: #{error.message}" + Gitlab::Database::LoadBalancing::Logger.error( + event: :service_discovery_failure, + message: "Service discovery encountered an error: #{error.message}", + host_list_length: load_balancer.host_list.length ) # Slightly randomize the retry delay so that, in the case of a total diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 452f8d8f72e..861ca1e757c 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -37,6 +37,7 @@ module Gitlab gon.first_day_of_week = current_user&.first_day_of_week || Gitlab::CurrentSettings.first_day_of_week gon.time_display_relative = true gon.ee = Gitlab.ee? + gon.jh = Gitlab.jh? gon.dot_com = Gitlab.com? if current_user diff --git a/locale/gitlab.pot b/locale/gitlab.pot index db78be8c0af..c3d05698790 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -36989,6 +36989,11 @@ msgstr "" msgid "View project labels" msgstr "" +msgid "View public GPG key" +msgid_plural "View public GPG keys" +msgstr[0] "" +msgstr[1] "" + msgid "View replaced file @ " msgstr "" diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb index deb25b675a0..6f66cf5e3ae 100644 --- a/spec/features/users/show_spec.rb +++ b/spec/features/users/show_spec.rb @@ -429,4 +429,27 @@ RSpec.describe 'User page' do end end end + + context 'GPG keys' do + context 'when user has verified GPG keys' do + let_it_be(:user) { create(:user, email: GpgHelpers::User1.emails.first) } + let_it_be(:gpg_key) { create(:gpg_key, user: user, key: GpgHelpers::User1.public_key) } + let_it_be(:gpg_key2) { create(:gpg_key, user: user, key: GpgHelpers::User1.public_key2) } + + it 'shows link to public GPG keys' do + subject + + expect(page).to have_link('View public GPG keys', href: user_gpg_keys_path(user)) + end + end + + context 'when user does not have verified GPG keys' do + it 'does not show link to public GPG keys' do + subject + + expect(page).not_to have_link('View public GPG key', href: user_gpg_keys_path(user)) + expect(page).not_to have_link('View public GPG keys', href: user_gpg_keys_path(user)) + end + end + end end diff --git a/spec/finders/packages/helm/package_files_finder_spec.rb b/spec/finders/packages/helm/package_files_finder_spec.rb index 2b84fd2b2d2..5f1378f837d 100644 --- a/spec/finders/packages/helm/package_files_finder_spec.rb +++ b/spec/finders/packages/helm/package_files_finder_spec.rb @@ -6,42 +6,51 @@ RSpec.describe ::Packages::Helm::PackageFilesFinder do let_it_be(:project1) { create(:project) } let_it_be(:project2) { create(:project) } let_it_be(:helm_package) { create(:helm_package, project: project1) } - let_it_be(:helm_package_file) { helm_package.package_files.first } + let_it_be(:helm_package_file1) { helm_package.package_files.first } + let_it_be(:helm_package_file2) { create(:helm_package_file, package: helm_package) } let_it_be(:debian_package) { create(:debian_package, project: project1) } - describe '#execute' do - let(:project) { project1 } - let(:channel) { 'stable' } - let(:params) { {} } + let(:project) { project1 } + let(:channel) { 'stable' } + let(:params) { {} } + + let(:service) { described_class.new(project, channel, params) } - subject { described_class.new(project, channel, params).execute } + describe '#execute' do + subject { service.execute } context 'with empty params' do - it { is_expected.to match_array([helm_package_file]) } + it { is_expected.to eq([helm_package_file2, helm_package_file1]) } end context 'with another project' do let(:project) { project2 } - it { is_expected.to match_array([]) } + it { is_expected.to eq([]) } end context 'with another channel' do let(:channel) { 'staging' } - it { is_expected.to match_array([]) } + it { is_expected.to eq([]) } end - context 'with file_name' do - let(:params) { { file_name: helm_package_file.file_name } } + context 'with matching file_name' do + let(:params) { { file_name: helm_package_file1.file_name } } - it { is_expected.to match_array([helm_package_file]) } + it { is_expected.to eq([helm_package_file2, helm_package_file1]) } end context 'with another file_name' do let(:params) { { file_name: 'foobar.tgz' } } - it { is_expected.to match_array([]) } + it { is_expected.to eq([]) } end end + + describe '#most_recent!' do + subject { service.most_recent! } + + it { is_expected.to eq(helm_package_file2) } + end end diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index a17efdd61a9..f3233bcd771 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -349,7 +349,7 @@ describe('Tracking', () => { it('includes experiment data if linked to an experiment', () => { const mockExperimentData = { variant: 'candidate', - experiment: 'repo_integrations_link', + experiment: 'example', key: '2bff73f6bb8cc11156c50a8ba66b9b8b', }; getExperimentData.mockReturnValue(mockExperimentData); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index 90bc1980ac3..843298a1406 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -7,7 +7,12 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; -import { mockSuggestedColors, createLabelSuccessfulResponse } from './mock_data'; +import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import { + mockSuggestedColors, + createLabelSuccessfulResponse, + labelsQueryResponse, +} from './mock_data'; jest.mock('~/flash'); @@ -44,6 +49,14 @@ describe('DropdownContentsCreateView', () => { const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => { const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: projectLabelsQuery, + data: labelsQueryResponse.data, + variables: { + fullPath: '', + searchTerm: '', + }, + }); wrapper = shallowMount(DropdownContentsCreateView, { localVue, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index 8bd944a3d54..6279fe8ed57 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -137,12 +137,6 @@ describe('DropdownContentsLabelsView', () => { expect(findLabels().at(0).attributes('islabelset')).toBe('true'); }); - - it('emits `closeDropdown event` when Esc button is pressed', () => { - findDropdownWrapper().trigger('keydown.esc'); - - expect(wrapper.emitted('closeDropdown')).toEqual([[selectedLabels]]); - }); }); it('when search returns 0 results', async () => { @@ -205,7 +199,7 @@ describe('DropdownContentsLabelsView', () => { }); it('emits `toggleDropdownContentsCreateView` event on create label button click', () => { - findCreateLabelButton().vm.$emit('click'); + findCreateLabelButton().vm.$emit('click', new MouseEvent('click')); expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js deleted file mode 100644 index d2401a1f725..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; - -import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue'; - -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - -import { mockConfig } from './mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -const createComponent = (initialState = mockConfig) => { - const store = new Vuex.Store(labelsSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownTitle, { - localVue, - store, - propsData: { - labelsSelectInProgress: false, - }, - }); -}; - -describe('DropdownTitle', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('template', () => { - it('renders component container element with string "Labels"', () => { - expect(wrapper.text()).toContain('Labels'); - }); - - it('renders edit link', () => { - const editBtnEl = wrapper.find(GlButton); - - expect(editBtnEl.exists()).toBe(true); - expect(editBtnEl.text()).toBe('Edit'); - }); - - it('renders loading icon element when `labelsSelectInProgress` prop is true', () => { - wrapper.setProps({ - labelsSelectInProgress: true, - }); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index e17dfd93efc..d91d87ee6f0 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -1,11 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; - -import { isInViewport } from '~/lib/utils/common_utils'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; -import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; @@ -32,11 +28,13 @@ describe('LabelsSelectRoot', () => { store, propsData: config, stubs: { - 'dropdown-contents': DropdownContents, + DropdownContents, + SidebarEditableItem, }, provide: { iid: '1', projectPath: 'test', + canUpdate: true, }, }); }; @@ -49,145 +47,44 @@ describe('LabelsSelectRoot', () => { wrapper.destroy(); }); - describe('methods', () => { - describe('handleDropdownClose', () => { - beforeEach(() => { - createComponent(); - }); - - it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => { - wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]); - - expect(wrapper.emitted().updateSelectedLabels).toBeTruthy(); - expect(wrapper.emitted().onDropdownClose).toBeTruthy(); - }); - - it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => { - wrapper.vm.handleDropdownClose([]); - - expect(wrapper.emitted().updateSelectedLabels).toBeFalsy(); - expect(wrapper.emitted().onDropdownClose).toBeTruthy(); - }); - }); - - describe('handleCollapsedValueClick', () => { - it('emits `toggleCollapse` event on component', () => { - createComponent(); - wrapper.vm.handleCollapsedValueClick(); - - expect(wrapper.emitted().toggleCollapse).toBeTruthy(); - }); - }); + it('renders component with classes `labels-select-wrapper position-relative`', () => { + createComponent(); + expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'position-relative']); }); - describe('template', () => { - it('renders component with classes `labels-select-wrapper position-relative`', () => { - createComponent(); - expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); - }); - - it.each` - variant | cssClass - ${'standalone'} | ${'is-standalone'} - ${'embedded'} | ${'is-embedded'} - `( - 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', - ({ variant, cssClass }) => { - createComponent({ - ...mockConfig, - variant, - }); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain(cssClass); - }); - }, - ); - - it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { - createComponent(); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); - }); - - it('renders `dropdown-title` component', async () => { - createComponent(); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownTitle).exists()).toBe(true); - }); - - it('renders `dropdown-value` component', async () => { - createComponent(mockConfig, { - default: 'None', + it.each` + variant | cssClass + ${'standalone'} | ${'is-standalone'} + ${'embedded'} | ${'is-embedded'} + `( + 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', + ({ variant, cssClass }) => { + createComponent({ + ...mockConfig, + variant, }); - await wrapper.vm.$nextTick; - - const valueComp = wrapper.find(DropdownValue); - - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); - }); - - it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => { - createComponent(); - wrapper.vm.$store.dispatch('toggleDropdownButton'); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownButton).exists()).toBe(true); - }); - - it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => { - createComponent(); - wrapper.vm.$store.dispatch('toggleDropdownContents'); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownContents).exists()).toBe(true); - }); - - describe('sets content direction based on viewport', () => { - describe.each(Object.values(DropdownVariant))( - 'when labels variant is "%s"', - ({ variant }) => { - beforeEach(() => { - createComponent({ ...mockConfig, variant }); - wrapper.vm.$store.dispatch('toggleDropdownContents'); - }); - it('set direction when out of viewport', () => { - isInViewport.mockImplementation(() => false); - wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true); - }); - }); - - it('does not set direction when inside of viewport', () => { - isInViewport.mockImplementation(() => true); - wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); - }); - }); - }, - ); - }); - }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.classes()).toContain(cssClass); + }); + }, + ); - it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => { + it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { createComponent(); - - jest.spyOn(store, 'dispatch').mockResolvedValue(); - await wrapper.setProps({ isEditing: true }); - - expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents'); + await wrapper.vm.$nextTick; + expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); }); - it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => { - createComponent(); + it('renders `dropdown-value` component', async () => { + createComponent(mockConfig, { + default: 'None', + }); + await wrapper.vm.$nextTick; - jest.spyOn(store, 'dispatch').mockResolvedValue(); - await wrapper.setProps({ isEditing: false }); + const valueComp = wrapper.find(DropdownValue); - expect(store.dispatch).not.toHaveBeenCalled(); + expect(valueComp.exists()).toBe(true); + expect(valueComp.text()).toBe('None'); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js index 5dd8fc1b8b2..982b2e15e46 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js @@ -83,9 +83,7 @@ export const createLabelSuccessfulResponse = { id: 'gid://gitlab/ProjectLabel/126', color: '#dc143c', description: null, - descriptionHtml: '', title: 'ewrwrwer', - textColor: '#FFFFFF', __typename: 'Label', }, errors: [], diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb index 90910fcb7ce..f9fd59551d1 100644 --- a/spec/models/packages/package_file_spec.rb +++ b/spec/models/packages/package_file_spec.rb @@ -139,6 +139,10 @@ RSpec.describe Packages::PackageFile, type: :model do end end + describe '.most_recent!' do + it { expect(described_class.most_recent!).to eq(debian_package.package_files.last) } + end + describe '#update_file_store callback' do let_it_be(:package_file) { build(:package_file, :nuget, size: nil) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 5245df10a71..9a6fd67d6c5 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -3389,17 +3389,32 @@ RSpec.describe User do end describe '#membership_groups' do - let!(:user) { create(:user) } - let!(:parent_group) { create(:group) } - let!(:child_group) { create(:group, parent: parent_group) } + let_it_be(:user) { create(:user) } - before do - parent_group.add_user(user, Gitlab::Access::MAINTAINER) + let_it_be(:parent_group) do + create(:group).tap do |g| + g.add_user(user, Gitlab::Access::MAINTAINER) + end end + let_it_be(:child_group) { create(:group, parent: parent_group) } + let_it_be(:other_group) { create(:group) } + subject { user.membership_groups } - it { is_expected.to contain_exactly parent_group, child_group } + shared_examples 'returns groups where the user is a member' do + specify { is_expected.to contain_exactly(parent_group, child_group) } + end + + it_behaves_like 'returns groups where the user is a member' + + context 'when feature flag :linear_user_membership_groups is disabled' do + before do + stub_feature_flags(linear_user_membership_groups: false) + end + + it_behaves_like 'returns groups where the user is a member' + end end describe '#authorizations_for_projects' do diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index fd75c8411d5..5f789f59908 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -649,36 +649,18 @@ RSpec.describe ProjectPresenter do end end - describe 'experiment(:repo_integrations_link)' do - context 'when enabled' do - before do - stub_experiments(repo_integrations_link: :candidate) - end - - it 'includes a button to configure integrations for maintainers' do - project.add_maintainer(user) - - expect(empty_repo_statistics_buttons.map(&:label)).to include( - a_string_including('Configure Integration') - ) - end - - it 'does not include a button if not a maintainer' do - expect(empty_repo_statistics_buttons.map(&:label)).not_to include( - a_string_including('Configure Integration') - ) - end - end + it 'includes a button to configure integrations for maintainers' do + project.add_maintainer(user) - context 'when disabled' do - it 'does not include a button' do - project.add_maintainer(user) + expect(empty_repo_statistics_buttons.map(&:label)).to include( + a_string_including('Configure Integration') + ) + end - expect(empty_repo_statistics_buttons.map(&:label)).not_to include( - a_string_including('Configure Integration') - ) - end - end + it 'does not include a button if not a maintainer' do + expect(empty_repo_statistics_buttons.map(&:label)).not_to include( + a_string_including('Configure Integration') + ) end context 'for a developer' do diff --git a/spec/requests/api/helm_packages_spec.rb b/spec/requests/api/helm_packages_spec.rb index 08b4489a6e3..7b37a95b204 100644 --- a/spec/requests/api/helm_packages_spec.rb +++ b/spec/requests/api/helm_packages_spec.rb @@ -9,16 +9,18 @@ RSpec.describe API::HelmPackages do let_it_be_with_reload(:project) { create(:project, :public) } let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } - let_it_be(:package) { create(:helm_package, project: project) } + let_it_be(:package) { create(:helm_package, project: project, without_package_files: true) } + let_it_be(:package_file1) { create(:helm_package_file, package: package) } + let_it_be(:package_file2) { create(:helm_package_file, package: package) } describe 'GET /api/v4/projects/:id/packages/helm/:channel/index.yaml' do it_behaves_like 'handling helm chart index requests' do - let(:url) { "/projects/#{project.id}/packages/helm/#{package.package_files.first.helm_channel}/index.yaml" } + let(:url) { "/projects/#{project.id}/packages/helm/stable/index.yaml" } end end describe 'GET /api/v4/projects/:id/packages/helm/:channel/charts/:file_name.tgz' do - let(:url) { "/projects/#{project.id}/packages/helm/#{package.package_files.first.helm_channel}/charts/#{package.name}-#{package.version}.tgz" } + let(:url) { "/projects/#{project.id}/packages/helm/stable/charts/#{package.name}-#{package.version}.tgz" } subject { get api(url), headers: headers } diff --git a/spec/support/database/prevent_cross_joins.rb b/spec/support/database/prevent_cross_joins.rb index 8ec946997fb..d5ec3f61570 100644 --- a/spec/support/database/prevent_cross_joins.rb +++ b/spec/support/database/prevent_cross_joins.rb @@ -72,13 +72,10 @@ ALLOW_LIST = Set.new(YAML.load_file(Rails.root.join('.cross-join-allowlist.yml') RSpec.configure do |config| config.include(::Database::PreventCrossJoins::SpecHelpers) - config.around do |example| + # TODO: remove `:prevent_cross_joins` to enable the check by default + config.around(:each, :prevent_cross_joins) do |example| Thread.current[:has_cross_join_exception] = false - if ALLOW_LIST.include?(example.file_path) - example.run - else - with_cross_joins_prevented { example.run } - end + with_cross_joins_prevented { example.run } end end diff --git a/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb index 1ad38a17f9c..d4801af4980 100644 --- a/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb @@ -41,7 +41,7 @@ RSpec.shared_examples 'process helm service index request' do |user_type, status package_entry = yaml_response['entries'][package.name] - expect(package_entry.length).to eq(1) + expect(package_entry.length).to eq(2) expect(package_entry.first.keys).to contain_exactly('name', 'version', 'apiVersion', 'created', 'digest', 'urls') expect(package_entry.first['digest']).to eq('fd2b2fa0329e80a2a602c2bb3b40608bcd6ee5cf96cf46fd0d2800a4c129c9db') expect(package_entry.first['urls']).to eq(["charts/#{package.name}-#{package.version}.tgz"]) @@ -174,6 +174,13 @@ RSpec.shared_examples 'process helm download content request' do |user_type, sta context "for user type #{user_type}" do before do project.send("add_#{user_type}", user) if user_type != :anonymous && user_type != :not_a_member + + expect_next_found_instance_of(::Packages::PackageFile) do |package_file| + expect(package_file).to receive(:file).and_wrap_original do |m, *args| + expect(package_file.id).to eq(package_file2.id) + m.call(*args) + end + end end it_behaves_like 'a package tracking event', 'API::HelmPackages', 'pull_package' diff --git a/spec/support_specs/database/prevent_cross_joins_spec.rb b/spec/support_specs/database/prevent_cross_joins_spec.rb index b26b862b34b..dd4ed9c40b8 100644 --- a/spec/support_specs/database/prevent_cross_joins_spec.rb +++ b/spec/support_specs/database/prevent_cross_joins_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Database::PreventCrossJoins do - context 'when running in a default scope' do + context 'when running in :prevent_cross_joins scope', :prevent_cross_joins do context 'when only non-CI tables are used' do it 'does not raise exception' do expect { main_only_query }.not_to raise_error @@ -32,6 +32,14 @@ RSpec.describe Database::PreventCrossJoins do end end + context 'when running in a default scope' do + context 'when CI and non-CI tables are used' do + it 'does not raise exception' do + expect { main_and_ci_query }.not_to raise_error + end + end + end + private def main_only_query diff --git a/spec/workers/packages/helm/extraction_worker_spec.rb b/spec/workers/packages/helm/extraction_worker_spec.rb index 258413a3410..daebbda3077 100644 --- a/spec/workers/packages/helm/extraction_worker_spec.rb +++ b/spec/workers/packages/helm/extraction_worker_spec.rb @@ -23,10 +23,10 @@ RSpec.describe Packages::Helm::ExtractionWorker, type: :worker do subject { described_class.new.perform(channel, package_file_id) } - shared_examples 'handling error' do + shared_examples 'handling error' do |error_class = Packages::Helm::ExtractFileMetadataService::ExtractionError| it 'mark the package as errored', :aggregate_failures do expect(Gitlab::ErrorTracking).to receive(:log_exception).with( - instance_of(Packages::Helm::ExtractFileMetadataService::ExtractionError), + instance_of(error_class), project_id: package_file.package.project_id ) expect { subject } @@ -88,5 +88,15 @@ RSpec.describe Packages::Helm::ExtractionWorker, type: :worker do it_behaves_like 'handling error' end + + context 'with an invalid Chart.yaml' do + before do + expect_next_instance_of(Gem::Package::TarReader::Entry) do |entry| + expect(entry).to receive(:read).and_return('{}') + end + end + + it_behaves_like 'handling error', ActiveRecord::RecordInvalid + end end end |