diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-17 21:09:13 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-17 21:09:13 +0300 |
commit | 5150ecc452f4cf1c899f79d35d52af978ff2d43f (patch) | |
tree | ed36b7982b574d6b4ec5b4e3f68a61a0f7e762d1 | |
parent | 3884d9d7160e80a70ad327813ada6cab03cded65 (diff) |
Add latest changes from gitlab-org/gitlab@master
118 files changed, 1556 insertions, 809 deletions
diff --git a/.rubocop_todo/metrics/abc_size.yml b/.rubocop_todo/metrics/abc_size.yml index 747ef3823f5..f5646151592 100644 --- a/.rubocop_todo/metrics/abc_size.yml +++ b/.rubocop_todo/metrics/abc_size.yml @@ -12,3 +12,4 @@ Metrics/AbcSize: - 'lib/gitlab/analytics/cycle_analytics/request_params.rb' - 'lib/gitlab/sidekiq_middleware/server_metrics.rb' - 'qa/qa/resource/repository/push.rb' + - 'ee/db/seeds/awesome_co/**/*.rb' diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index cd99e6ef64f..e56932a9a31 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -103,6 +103,7 @@ "workflow": { "type": "object", "properties": { + "name": { "$ref": "#/definitions/workflowName" }, "rules": { "type": "array", "items": { @@ -714,6 +715,12 @@ ] } }, + "workflowName": { + "type": "string", + "markdownDescription": "Defines the pipeline name. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#workflowname).", + "minLength": 1, + "maxLength": 255 + }, "globalVariables": { "markdownDescription": "Defines default variables for all jobs. Job level property overrides global variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variables).", "type": "object", diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index f4b939fb20f..8fc0ce48e61 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -14,6 +14,7 @@ import { visitUrl } from '~/lib/utils/url_utility'; import { truncate } from '~/lib/utils/text_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { s__, sprintf } from '~/locale'; +import Tracking from '~/tracking'; import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; import { FIRST_DROPDOWN_INDEX, @@ -163,8 +164,17 @@ export default { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { this.showDropdown = true; - this.isFocused = true; - this.$emit('expandSearchBar', true); + + // check isFocused state to avoid firing duplicate events + if (!this.isFocused) { + this.isFocused = true; + this.$emit('expandSearchBar', true); + + Tracking.event(undefined, 'focus_input', { + label: 'global_search', + property: 'top_navigation', + }); + } }, closeDropdown() { this.showDropdown = false; @@ -178,6 +188,11 @@ export default { this.showDropdown = false; this.isFocused = false; this.$emit('collapseSearchBar'); + + Tracking.event(undefined, 'blur_input', { + label: 'global_search', + property: 'top_navigation', + }); }, 200); }, submitSearch() { diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js index 2eeb0a77032..7eacbf7fcdd 100644 --- a/app/assets/javascripts/listbox/index.js +++ b/app/assets/javascripts/listbox/index.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlListbox } from '@gitlab/ui'; +import { GlListbox } from '@gitlab/ui'; import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -31,59 +31,25 @@ export function initListbox(el, { onChange } = {}) { }, }, render(h) { - if (gon.features?.glListboxForSortDropdowns) { - return h(GlListbox, { - props: { - items, - right, - selected: this.selected, - toggleText: this.text, - }, - class: className, - on: { - select: (selectedValue) => { - this.selected = selectedValue; - const selectedItem = items.find(({ value }) => value === selectedValue); - - if (typeof onChange === 'function') { - onChange(selectedItem); - } - }, - }, - }); - } - - return h( - GlDropdown, - { - props: { - text: this.text, - right, + return h(GlListbox, { + props: { + items, + right, + selected: this.selected, + toggleText: this.text, + }, + class: className, + on: { + select: (selectedValue) => { + this.selected = selectedValue; + const selectedItem = items.find(({ value }) => value === selectedValue); + + if (typeof onChange === 'function') { + onChange(selectedItem); + } }, - class: className, }, - items.map((item) => - h( - GlDropdownItem, - { - props: { - isCheckItem: true, - isChecked: this.selected === item.value, - }, - on: { - click: () => { - this.selected = item.value; - - if (typeof onChange === 'function') { - onChange(item); - } - }, - }, - }, - item.text, - ), - ), - ); + }); }, }); } diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue index ca6e6567f74..e55bf25a60c 100644 --- a/app/assets/javascripts/nav/components/top_nav_app.vue +++ b/app/assets/javascripts/nav/components/top_nav_app.vue @@ -1,5 +1,6 @@ <script> import { GlNav, GlIcon, GlNavItemDropdown, GlDropdownForm, GlTooltipDirective } from '@gitlab/ui'; +import Tracking from '~/tracking'; import TopNavDropdownMenu from './top_nav_dropdown_menu.vue'; export default { @@ -19,6 +20,14 @@ export default { required: true, }, }, + methods: { + trackToggleEvent() { + Tracking.event(undefined, 'click_nav', { + label: 'hamburger_menu', + property: 'top_navigation', + }); + }, + }, }; </script> @@ -32,6 +41,7 @@ export default { toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!" no-flip no-caret + @toggle="trackToggleEvent" > <template #button-content> <gl-icon name="hamburger" /> diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index ddc880db227..f35f9341fa1 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -1,9 +1,10 @@ <script> -import pdfjsLib from 'pdfjs-dist/build/pdf'; -import workerSrc from 'pdfjs-dist/build/pdf.worker.min'; +import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist/legacy/build/pdf'; import Page from './page/index.vue'; +GlobalWorkerOptions.workerSrc = '/assets/webpack/pdfjs/pdf.worker.min.js'; + export default { components: { Page }, props: { @@ -30,18 +31,16 @@ export default { }, watch: { pdf: 'load' }, mounted() { - pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc; if (this.hasPDF) this.load(); }, methods: { load() { this.pages = []; - return pdfjsLib - .getDocument({ - url: this.document, - cMapUrl: '/assets/webpack/cmaps/', - cMapPacked: true, - }) + return getDocument({ + url: this.document, + cMapUrl: '/assets/webpack/pdfjs/cmaps/', + cMapPacked: true, + }) .promise.then(this.renderPages) .then((pages) => { this.pages = pages; diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index 34c44321a9b..dbaabb35cde 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -17,8 +17,6 @@ import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_cou import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; -import RunnerBulkDelete from '../components/runner_bulk_delete.vue'; -import RunnerBulkDeleteCheckbox from '../components/runner_bulk_delete_checkbox.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; import RunnerName from '../components/runner_name.vue'; @@ -45,8 +43,6 @@ export default { RegistrationDropdown, RunnerStackedLayoutBanner, RunnerFilteredSearchBar, - RunnerBulkDelete, - RunnerBulkDeleteCheckbox, RunnerList, RunnerListEmptyState, RunnerName, @@ -56,7 +52,7 @@ export default { RunnerActionsCell, }, mixins: [glFeatureFlagMixin()], - inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath', 'localMutations'], + inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], props: { registrationToken: { type: String, @@ -155,12 +151,6 @@ export default { reportToSentry(error) { captureException({ error, component: this.$options.name }); }, - onChecked({ runner, isChecked }) { - this.localMutations.setRunnerChecked({ - runner, - isChecked, - }); - }, onPaginationInput(value) { this.search.pagination = value; }, @@ -211,16 +201,12 @@ export default { :filtered-svg-path="emptyStateFilteredSvgPath" /> <template v-else> - <runner-bulk-delete :runners="runners.items" @deleted="onDeleted" /> <runner-list :runners="runners.items" :loading="runnersLoading" :checkable="true" - @checked="onChecked" + @deleted="onDeleted" > - <template #head-checkbox> - <runner-bulk-delete-checkbox :runners="runners.items" /> - </template> <template #runner-name="{ runner }"> <gl-link :href="runner.adminUrl"> <runner-name :runner="runner" /> diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue index c4e7cad9da9..75afb7a00bc 100644 --- a/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue +++ b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue @@ -26,14 +26,17 @@ export default { }, }, computed: { + deletableRunners() { + return this.runners.filter((runner) => runner.userPermissions?.deleteRunner); + }, disabled() { - return !this.runners.length; + return !this.deletableRunners.length; }, checked() { - return Boolean(this.runners.length) && this.runners.every(this.isChecked); + return Boolean(this.deletableRunners.length) && this.deletableRunners.every(this.isChecked); }, indeterminate() { - return !this.checked && this.runners.some(this.isChecked); + return !this.checked && this.deletableRunners.some(this.isChecked); }, label() { return this.checked ? s__('Runners|Unselect all') : s__('Runners|Select all'); @@ -45,7 +48,7 @@ export default { }, onChange($event) { this.localMutations.setRunnersChecked({ - runners: this.runners, + runners: this.deletableRunners, isChecked: $event, }); }, diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue index 79f934764c6..3d72abcd393 100644 --- a/app/assets/javascripts/runner/components/runner_details.vue +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -4,7 +4,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants'; import RunnerDetail from './runner_detail.vue'; @@ -29,7 +28,6 @@ export default { RunnerTags, TimeAgo, }, - mixins: [glFeatureFlagMixin()], props: { runner: { type: Object, @@ -117,10 +115,7 @@ export default { </template> </runner-detail> <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> - <runner-detail - v-if="glFeatures.enforceRunnerTokenExpiresAt" - :empty-value="s__('Runners|Never expires')" - > + <runner-detail :empty-value="s__('Runners|Never expires')"> <template #label> {{ s__('Runners|Token expiry') }} <help-popover :options="tokenExpirationHelpPopoverOptions"> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index d4a3311ff9f..e895537dcdc 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -5,6 +5,8 @@ import { s__ } from '~/locale'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; import { formatJobCount, tableField } from '../utils'; +import RunnerBulkDelete from './runner_bulk_delete.vue'; +import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue'; import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue'; import RunnerStatusPopover from './runner_status_popover.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; @@ -23,6 +25,8 @@ export default { GlTableLite, GlSkeletonLoader, HelpPopover, + RunnerBulkDelete, + RunnerBulkDeleteCheckbox, RunnerStatusPopover, RunnerStackedSummaryCell, RunnerStatusCell, @@ -39,6 +43,7 @@ export default { }, }, }, + inject: ['localMutations'], props: { checkable: { type: Boolean, @@ -55,7 +60,7 @@ export default { required: true, }, }, - emits: ['checked'], + emits: ['deleted'], data() { return { checkedRunnerIds: [] }; }, @@ -84,6 +89,12 @@ export default { }, }, methods: { + canDelete(runner) { + return runner.userPermissions?.deleteRunner; + }, + onDeleted(event) { + this.$emit('deleted', event); + }, formatJobCount(jobCount) { return formatJobCount(jobCount); }, @@ -96,7 +107,7 @@ export default { return {}; }, onCheckboxChange(runner, isChecked) { - this.$emit('checked', { + this.localMutations.setRunnerChecked({ runner, isChecked, }); @@ -109,6 +120,7 @@ export default { </script> <template> <div> + <runner-bulk-delete v-if="checkable" :runners="runners" @deleted="onDeleted" /> <gl-table-lite :aria-busy="loading" :class="tableClass" @@ -121,11 +133,15 @@ export default { fixed > <template #head(checkbox)> - <slot name="head-checkbox"></slot> + <runner-bulk-delete-checkbox :runners="runners" /> </template> <template #cell(checkbox)="{ item }"> - <gl-form-checkbox :checked="isChecked(item)" @change="onCheckboxChange(item, $event)" /> + <gl-form-checkbox + v-if="canDelete(item)" + :checked="isChecked(item)" + @change="onCheckboxChange(item, $event)" + /> </template> <template #head(status)="{ label }"> diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js index 4e1625cb1ac..e0477c660b4 100644 --- a/app/assets/javascripts/runner/graphql/list/local_state.js +++ b/app/assets/javascripts/runner/graphql/list/local_state.js @@ -48,16 +48,18 @@ export const createLocalState = () => { const localMutations = { setRunnerChecked({ runner, isChecked }) { - checkedRunnerIdsVar({ - ...checkedRunnerIdsVar(), - [runner.id]: isChecked, - }); + const { id, userPermissions } = runner; + if (userPermissions?.deleteRunner) { + checkedRunnerIdsVar({ + ...checkedRunnerIdsVar(), + [id]: isChecked, + }); + } }, setRunnersChecked({ runners, isChecked }) { - const newVal = runners.reduce( - (acc, { id }) => ({ ...acc, [id]: isChecked }), - checkedRunnerIdsVar(), - ); + const newVal = runners + .filter(({ userPermissions }) => userPermissions?.deleteRunner) + .reduce((acc, { id }) => ({ ...acc, [id]: isChecked }), checkedRunnerIdsVar()); checkedRunnerIdsVar(newVal); }, clearChecked() { diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index bc943c03a62..7f56d895682 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -12,6 +12,7 @@ import { } from 'ee_else_ce/runner/runner_search_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql'; +import groupRunnersCountQuery from 'ee_else_ce/runner/graphql/list/group_runners_count.query.graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue'; @@ -173,13 +174,17 @@ export default { editUrl(runner) { return this.runners.urlsById[runner.id]?.edit; }, + refetchCounts() { + this.$apollo.getClient().refetchQueries({ include: [groupRunnersCountQuery] }); + }, onToggledPaused() { // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. - this.$refs['runner-type-tabs'].refetch(); + this.refetchCounts(); }, onDeleted({ message }) { this.$root.$toast?.show(message); + this.refetchCounts(); }, reportToSentry(error) { captureException({ error, component: this.$options.name }); @@ -245,7 +250,7 @@ export default { :filtered-svg-path="emptyStateFilteredSvgPath" /> <template v-else> - <runner-list :runners="runners.items" :loading="runnersLoading"> + <runner-list :runners="runners.items" :loading="runnersLoading" @deleted="onDeleted"> <template #runner-name="{ runner }"> <gl-link :href="webUrl(runner)"> <runner-name :runner="runner" /> diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js index feed6b0ceb7..0e7efd2b8a1 100644 --- a/app/assets/javascripts/runner/group_runners/index.js +++ b/app/assets/javascripts/runner/group_runners/index.js @@ -2,6 +2,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { createLocalState } from '../graphql/list/local_state'; import GroupRunnersApp from './group_runners_app.vue'; Vue.use(GlToast); @@ -26,8 +27,10 @@ export const initGroupRunners = (selector = '#js-group-runners') => { emptyStateFilteredSvgPath, } = el.dataset; + const { cacheConfig, typeDefs, localMutations } = createLocalState(); + const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { cacheConfig, typeDefs }), }); return new Vue({ @@ -35,6 +38,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => { apolloProvider, provide: { runnerInstallHelpPage, + localMutations, groupId, onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10), staleTimeoutSecs: parseInt(staleTimeoutSecs, 10), diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 3255c7e5e4d..ccb58706fe0 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -23,6 +23,7 @@ import { WIDGET_TYPE_WEIGHT, WIDGET_TYPE_HIERARCHY, WORK_ITEM_VIEWED_STORAGE_KEY, + WIDGET_TYPE_ITERATION, } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; @@ -65,6 +66,7 @@ export default { WorkItemInformation, LocalStorageSync, WorkItemTypeIcon, + WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), }, mixins: [glFeatureFlagMixin()], props: { @@ -134,7 +136,7 @@ export default { }; }, skip() { - return !this.workItemDueDate; + return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE); }, }, { @@ -145,7 +147,7 @@ export default { }; }, skip() { - return !this.workItemAssignees; + return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); }, }, ], @@ -170,28 +172,8 @@ export default { workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, - hasDescriptionWidget() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); - }, - workItemAssignees() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES); - }, - workItemLabels() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); - }, - workItemDueDate() { - return this.workItem?.widgets?.find( - (widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE, - ); - }, - workItemWeight() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); - }, - workItemHierarchy() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); - }, parentWorkItem() { - return this.workItemHierarchy?.parent; + return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; }, parentWorkItemConfidentiality() { return this.parentWorkItem?.confidential; @@ -205,6 +187,27 @@ export default { noAccessSvgPath() { return `data:image/svg+xml;utf8,${encodeURIComponent(noAccessSvg)}`; }, + hasDescriptionWidget() { + return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION); + }, + workItemAssignees() { + return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); + }, + workItemLabels() { + return this.isWidgetPresent(WIDGET_TYPE_LABELS); + }, + workItemDueDate() { + return this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE); + }, + workItemWeight() { + return this.isWidgetPresent(WIDGET_TYPE_WEIGHT); + }, + workItemHierarchy() { + return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY); + }, + workItemIteration() { + return this.isWidgetPresent(WIDGET_TYPE_ITERATION); + }, }, beforeDestroy() { /** make sure that if the user has not even dismissed the alert , @@ -212,6 +215,9 @@ export default { this.dismissBanner(); }, methods: { + isWidgetPresent(type) { + return this.workItem?.widgets?.find((widget) => widget.type === type); + }, dismissBanner() { this.showInfoBanner = false; }, @@ -416,6 +422,17 @@ export default { :work-item-type="workItemType" @error="updateError = $event" /> + <template v-if="workItemsMvc2Enabled"> + <work-item-iteration + v-if="workItemIteration" + class="gl-mb-5" + :iteration="workItemIteration.iteration" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="updateError = $event" + /> + </template> <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 8f31b07b6a3..37aa48be6e5 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -16,7 +16,13 @@ export default function initWorkItemLinks() { return; } - const { projectPath, wiHasIssueWeightsFeature, iid } = workItemLinksRoot.dataset; + const { + projectPath, + wiHasIssueWeightsFeature, + iid, + wiHasIterationsFeature, + projectNamespace, + } = workItemLinksRoot.dataset; // eslint-disable-next-line no-new new Vue({ @@ -31,6 +37,8 @@ export default function initWorkItemLinks() { iid, fullPath: projectPath, hasIssueWeightsFeature: wiHasIssueWeightsFeature, + hasIterationsFeature: wiHasIterationsFeature, + projectNamespace, }, render: (createElement) => createElement('work-item-links', { diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 827ec64f98a..0d3e951de7e 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -5,7 +5,7 @@ import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; -import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import { isMetaKey } from '~/lib/utils/common_utils'; import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -59,7 +59,7 @@ export default { }, }, parentIssue: { - query: issueConfidentialQuery, + query: getIssueDetailsQuery, variables() { return { fullPath: this.projectPath, @@ -86,6 +86,9 @@ export default { confidential() { return this.parentIssue?.confidential || this.workItem?.confidential || false; }, + issuableIteration() { + return this.parentIssue?.iteration; + }, children() { return ( this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children @@ -305,6 +308,7 @@ export default { :issuable-gid="issuableGid" :children-ids="childrenIds" :parent-confidential="confidential" + :parent-iteration="issuableIteration" @cancel="hideAddForm" @addWorkItemChild="addChild" /> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index eafd9ee88dd..a01f4616cab 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -16,7 +16,7 @@ export default { GlFormGroup, GlFormInput, }, - inject: ['projectPath'], + inject: ['projectPath', 'hasIterationsFeature'], props: { issuableGid: { type: String, @@ -33,6 +33,11 @@ export default { required: false, default: false, }, + parentIteration: { + type: Object, + required: false, + default: () => {}, + }, }, apollo: { workItemTypes: { @@ -77,6 +82,9 @@ export default { taskWorkItemType() { return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; }, + parentIterationId() { + return this.parentIteration?.id; + }, }, methods: { getIdFromGraphQLId, @@ -133,6 +141,13 @@ export default { } else { this.unsetError(); this.$emit('addWorkItemChild', data.workItemCreate.workItem); + /** + * call update mutation only when there is an iteration associated with the issue + */ + // TODO: setting the iteration should be moved to the creation mutation once the backend is done + if (this.parentIterationId && this.hasIterationsFeature) { + this.addIterationToWorkItem(data.workItemCreate.workItem.id); + } } }) .catch(() => { @@ -143,6 +158,19 @@ export default { this.childToCreateTitle = null; }); }, + async addIterationToWorkItem(workItemId) { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: workItemId, + iterationWidget: { + iterationId: this.parentIterationId, + }, + }, + }, + }); + }, }, i18n: { inputLabel: __('Title'), diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 97c445de711..0d426299408 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -17,6 +17,8 @@ export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; +export const WIDGET_TYPE_ITERATION = 'ITERATION'; + export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; @@ -54,6 +56,10 @@ export const I18N_WORK_ITEM_ARE_YOU_SURE_DELETE = s__( ); export const I18N_WORK_ITEM_DELETED = s__('WorkItem|%{workItemType} deleted'); +export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__( + 'WorkItem|Something went wrong when fetching iterations. Please try again.', +); + export const sprintfWorkItem = (msg, workItemTypeArg) => { const workItemType = workItemTypeArg || s__('WorkItem|Work item'); return capitalizeFirstCharacter( diff --git a/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql new file mode 100644 index 00000000000..6edb6c89f16 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql @@ -0,0 +1,9 @@ +query issuableDetails($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + id + issuable: issue(iid: $iid) { + id + confidential + } + } +} diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index bb4c7052238..f872d8c6b12 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -6,7 +6,13 @@ import { createRouter } from './router'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); - const { fullPath, hasIssueWeightsFeature, issuesListPath } = el.dataset; + const { + fullPath, + hasIssueWeightsFeature, + issuesListPath, + projectNamespace, + hasIterationsFeature, + } = el.dataset; return new Vue({ el, @@ -17,6 +23,8 @@ export const initWorkItemsRoot = () => { fullPath, hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), issuesListPath, + projectNamespace, + hasIterationsFeature: parseBoolean(hasIterationsFeature), }, render(createElement) { return createElement(App); diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index d0fc011dde7..7a5cc72ceb8 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -63,3 +63,23 @@ display: none; } } + +.work-item-iteration { + .gl-dropdown-toggle { + background: none !important; + + &:hover, + &:focus { + box-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-darkest, $gray-darkest) !important; + } + + &.is-not-focused:not(:hover, :focus) { + box-shadow: none; + + .gl-button-icon { + display: none; + } + } + } +} + diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 2e0807cdab4..96fe0c9331d 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -5,10 +5,6 @@ class Admin::RunnersController < Admin::ApplicationController before_action :runner, except: [:index, :tag_list, :runner_setup_scripts] - before_action only: [:show] do - push_frontend_feature_flag(:enforce_runner_token_expires_at) - end - feature_category :runner urgency :low diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index 01c1529e831..18b055b3f05 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -5,10 +5,6 @@ class Groups::RunnersController < Groups::ApplicationController before_action :authorize_update_runner!, only: [:edit, :update, :destroy, :pause, :resume] before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show] - before_action only: [:show] do - push_frontend_feature_flag(:enforce_runner_token_expires_at) - end - feature_category :runner urgency :low diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 853437b2d27..4287c0b7884 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -617,6 +617,15 @@ module Ci # auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation # execute_async - if true cancel the children asyncronously def cancel_running(retries: 1, cascade_to_children: true, auto_canceled_by_pipeline_id: nil, execute_async: true) + Gitlab::AppJsonLogger.info( + event: 'pipeline_cancel_running', + pipeline_id: id, + auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, + cascade_to_children: cascade_to_children, + execute_async: execute_async, + **Gitlab::ApplicationContext.current + ) + update(auto_canceled_by_id: auto_canceled_by_pipeline_id) if auto_canceled_by_pipeline_id cancel_jobs(cancelable_statuses, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id) diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 2bd26e15953..3be627989b1 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -14,7 +14,7 @@ module Ci include Presentable include EachBatch - add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced? + add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration enum access_level: { not_protected: 0, @@ -480,10 +480,6 @@ module Ci end end - def self.token_expiration_enforced? - Feature.enabled?(:enforce_runner_token_expires_at) - end - private scope :with_upgrade_status, ->(upgrade_status) do diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index f7f29b3e82e..ed04b0c3d1f 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -7,7 +7,7 @@ class WebHook < ApplicationRecord MAX_FAILURES = 100 FAILURE_THRESHOLD = 3 # three strikes - INITIAL_BACKOFF = 10.minutes + INITIAL_BACKOFF = 1.minute MAX_BACKOFF = 1.day BACKOFF_GROWTH_FACTOR = 2.0 diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index e676dae37b3..b08a549148d 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -17,14 +17,14 @@ .form-group = f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light' - = f.number_field :receive_max_input_size, class: 'form-control gl-form-input qa-receive-max-input-size-field', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body' } + = f.number_field :receive_max_input_size, class: 'form-control gl-form-input', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'receive_max_input_size_field' } .form-group = f.label :max_export_size, _('Maximum export size (MB)'), class: 'label-light' = f.number_field :max_export_size, class: 'form-control gl-form-input', title: _('Maximum size of export files.'), data: { toggle: 'tooltip', container: 'body' } %span.form-text.text-muted= _('Set to 0 for no size limit.') .form-group = f.label :max_import_size, _('Maximum import size (MB)'), class: 'label-light' - = f.number_field :max_import_size, class: 'form-control gl-form-input qa-receive-max-import-size-field', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' } + = f.number_field :max_import_size, class: 'form-control gl-form-input', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' } %span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.') .form-group = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light' @@ -69,4 +69,4 @@ = render 'admin/application_settings/invitation_flow_enforcement', form: f = render 'admin/application_settings/user_restrictions', form: f = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f - = f.submit _('Save changes'), class: 'qa-save-changes-button', pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 05aea2b343d..f6635ad17ef 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -53,8 +53,7 @@ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'specify-a-custom-cicd-configuration-file'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.gitlab_ui_checkbox_component :suggest_pipeline_enabled, s_('AdminSettings|Enable pipeline suggestion banner'), help_text: s_('AdminSettings|Display a banner on merge requests in projects with no pipelines to initiate steps to add a .gitlab-ci.yml file.') - - if Feature.enabled?(:enforce_runner_token_expires_at) - #js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes } + #js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes } = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index 823bc0380af..d4f6d84ea74 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -10,4 +10,4 @@ = f.label :performance_bar_allowed_group_path, _('Allow access to members of the following group'), class: 'label-bold' = f.text_field :performance_bar_allowed_group_path, class: 'form-control gl-form-input', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path - = f.submit _('Save changes'), class: 'qa-save-changes-button', pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/ci/variables/_url_query_variable_row.html.haml b/app/views/ci/variables/_url_query_variable_row.html.haml index 9c34daf88bd..77bcacdb94b 100644 --- a/app/views/ci/variables/_url_query_variable_row.html.haml +++ b/app/views/ci/variables/_url_query_variable_row.html.haml @@ -15,12 +15,12 @@ %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name } = options_for_select(ci_variable_type_options, variable_type) - %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.table-section.section-15{ type: "text", + %input.js-ci-variable-input-key.ci-variable-body-item.form-control.table-section.section-15{ type: "text", name: key_input_name, value: key, placeholder: s_('CiVariables|Input variable key') } .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0 - %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ rows: 1, + %textarea.js-ci-variable-input-value.js-secret-value.form-control{ rows: 1, name: value_input_name, placeholder: s_('CiVariables|Input variable value') } = value diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 483c767d029..c5e518d8526 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -18,14 +18,14 @@ %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name } = options_for_select(ci_variable_type_options, variable_type) - %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.gl-form-input.table-section.section-15{ type: "text", + %input.js-ci-variable-input-key.ci-variable-body-item.form-control.gl-form-input.table-section.section-15{ type: "text", name: key_input_name, value: key, placeholder: s_('CiVariables|Input variable key') } .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0 - .form-control.js-secret-value-placeholder.qa-ci-variable-input-value.overflow-hidden{ class: ('hide' unless id) } + .form-control.js-secret-value-placeholder.overflow-hidden{ class: ('hide' unless id) } = '*' * 17 - %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control.gl-form-input{ class: ('hide' if id), + %textarea.js-ci-variable-input-value.js-secret-value.form-control.gl-form-input{ class: ('hide' if id), rows: 1, name: value_input_name, placeholder: s_('CiVariables|Input variable value') } diff --git a/app/views/clusters/clusters/_health.html.haml b/app/views/clusters/clusters/_health.html.haml index 50facdf91a2..9e7820d3136 100644 --- a/app/views/clusters/clusters/_health.html.haml +++ b/app/views/clusters/clusters/_health.html.haml @@ -1,6 +1,6 @@ - add_page_specific_style 'page_bundles/prometheus' -%section.settings.no-animate.expanded.cluster-health-graphs.qa-cluster-health-section#cluster-health +%section.settings.no-animate.expanded.cluster-health-graphs#cluster-health - if @cluster&.integration_prometheus_available? #prometheus-graphs{ data: @cluster.health_data(clusterable) } diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index bf7b24181c1..557c95f8478 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -36,7 +36,7 @@ = platform_kubernetes_field.form_group :authorization_type, { help: '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link } } do = platform_kubernetes_field.check_box :authorization_type, - { class: 'qa-rbac-checkbox', label: s_('ClusterIntegration|RBAC-enabled cluster'), + { data: { qa_selector: 'rbac_checkbox'}, label: s_('ClusterIntegration|RBAC-enabled cluster'), label_class: 'label-bold', inline: true }, 'rbac', 'abac' .form-group diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 800dcef5b72..b74dfd4d3a1 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -132,7 +132,7 @@ - if header_link?(:user_dropdown) %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu', testid: 'user-menu' }, class: ('mr-0' if has_impersonation_link) } = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do - = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar qa-user-avatar') + = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar', avatar_options: { data: { qa_selector: 'user_avatar_content' } }) = render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 56f333664df..8815dec5a6b 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -1,4 +1,4 @@ -%aside.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation') } +%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation'), data: { qa_selector: 'admin_sidebar_content' } } .nav-sidebar-inner-scroll .context-header = link_to admin_root_path, title: _('Admin Overview'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do @@ -6,7 +6,7 @@ = sprite_icon('admin', size: 18) %span.sidebar-context-title = _('Admin Area') - %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } } + %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_overview_submenu_content' } } = nav_link(controller: %w[dashboard admin admin/projects users groups admin/topics jobs runners gitaly_servers cohorts], html_options: {class: 'home'}) do = link_to admin_root_path, class: 'has-sub-items' do .nav-icon-container @@ -28,15 +28,15 @@ %span = _('Projects') = nav_link(controller: %w[users cohorts]) do - = link_to admin_users_path, title: _('Users'), data: { qa_selector: 'users_overview_link' } do + = link_to admin_users_path, title: _('Users'), data: { qa_selector: 'admin_overview_users_link' } do %span = _('Users') = nav_link(controller: :groups) do - = link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'groups_overview_link' } do + = link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'admin_overview_groups_link' } do %span = _('Groups') = nav_link(controller: [:admin, 'admin/topics']) do - = link_to admin_topics_path, title: _('Topics'), data: { qa_selector: 'topics_overview_link' } do + = link_to admin_topics_path, title: _('Topics') do %span = _('Topics') = nav_link path: 'jobs#index' do @@ -75,13 +75,13 @@ = _('Usage Trends') = nav_link(controller: admin_monitoring_nav_links) do - = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_link' }, class: 'has-sub-items' do + = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_menu_link' }, class: 'has-sub-items' do .nav-icon-container = sprite_icon('monitor') %span.nav-item-name = _('Monitoring') - %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_monitoring_submenu_content' } } + %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_monitoring_submenu_content' } } = nav_link(controller: admin_monitoring_nav_links, html_options: { class: "fly-out-top-item" } ) do = link_to admin_system_info_path do %strong.fly-out-top-item-name @@ -222,10 +222,10 @@ = link_to general_admin_application_settings_path, class: 'has-sub-items' do .nav-icon-container = sprite_icon('settings') - %span.nav-item-name.qa-admin-settings-item + %span.nav-item-name{ data: { qa_selector: 'admin_settings_menu_link' } } = _('Settings') - %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_settings_submenu_content' } } + %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_settings_submenu_content' } } -# This active_nav_link check is also used in `app/views/layouts/admin.html.haml` = nav_link(controller: [:application_settings, :integrations, :appearances], html_options: { class: "fly-out-top-item" } ) do = link_to general_admin_application_settings_path do @@ -233,24 +233,24 @@ = _('Settings') %li.divider.fly-out-top-item = nav_link(path: 'application_settings#general') do - = link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do + = link_to general_admin_application_settings_path, title: _('General'), data: { qa_selector: 'admin_settings_general_link' } do %span = _('General') - = render_if_exists 'layouts/nav/sidebar/advanced_search', class: 'qa-admin-settings-advanced-search' + = render_if_exists 'layouts/nav/sidebar/advanced_search', data: { qa_selector: 'admin_settings_advanced_search_link' } - if instance_level_integrations? = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do - = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'integration_settings_link' } do + = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'admin_settings_integrations_link' } do %span = _('Integrations') = nav_link(path: 'application_settings#repository') do - = link_to repository_admin_application_settings_path, title: _('Repository'), class: 'qa-admin-settings-repository-item' do + = link_to repository_admin_application_settings_path, title: _('Repository'), data: { qa_selector: 'admin_settings_repository_link' } do %span = _('Repository') - if Gitlab.ee? && License.feature_available?(:custom_file_templates) = nav_link(path: 'application_settings#templates') do - = link_to templates_admin_application_settings_path, title: _('Templates'), class: 'qa-admin-settings-template-item' do + = link_to templates_admin_application_settings_path, title: _('Templates'), data: { qa_selector: 'admin_settings_templates_link' } do %span = _('Templates') = nav_link(path: 'application_settings#ci_cd') do @@ -262,7 +262,7 @@ %span = _('Reporting') = nav_link(path: 'application_settings#metrics_and_profiling') do - = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), class: 'qa-admin-settings-metrics-and-profiling-item' do + = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), data: { qa_selector: 'admin_settings_metrics_and_profiling_link' } do %span = _('Metrics and profiling') = nav_link(path: ['application_settings#service_usage_data']) do @@ -270,7 +270,7 @@ %span = _('Service usage data') = nav_link(path: 'application_settings#network') do - = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do + = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_link' } do %span = _('Network') = nav_link(controller: :appearances ) do diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 91848b4f54b..032be73f70c 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -25,7 +25,7 @@ %ul.nav.navbar-nav %li.header-user.dropdown = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do - = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'gl-mr-3', avatar_options: { data: { qa_selector: 'user_avatar' } }) + = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'gl-mr-3', avatar_options: { data: { qa_selector: 'user_avatar_content' } }) = sprite_icon('chevron-down') .dropdown-menu.dropdown-menu-right = render 'layouts/header/current_user_dropdown' diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml index a76e61bc3dd..249c474587c 100644 --- a/app/views/projects/blob/_template_selectors.html.haml +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -2,14 +2,14 @@ .template-selector-dropdowns-wrap .template-type-selector.js-template-type-selector-wrap.hidden - toggle_text = should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : 'Select a template type' - = dropdown_tag(_(toggle_text), options: { toggle_class: 'js-template-type-selector qa-template-type-dropdown', dropdown_class: 'dropdown-menu-selectable' }) + = dropdown_tag(_(toggle_text), options: { toggle_class: 'js-template-type-selector', dropdown_class: 'dropdown-menu-selectable', data: { qa_selector: 'template_type_dropdown' } }) .license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector qa-license-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } } ) .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project) } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project), qa_selector: 'gitignore_dropdown' } } ) .metrics-dashboard-selector.js-metrics-dashboard-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector qa-metrics-dashboard-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project) } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project), qa_selector: 'metrics_dashboard_dropdown' } } ) #gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template] } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template], qa_selector: 'gitlab_ci_yml_dropdown' } } ) .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project) } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project), qa_selector: 'dockerfile_dropdown' } } ) diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index 10a6bc6b524..34aecd31c57 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -2,17 +2,17 @@ - dropdown_class = local_assigns.fetch(:dropdown_class, '') .git-clone-holder.js-git-clone-holder - %a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } + %a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } } %span.gl-mr-2.js-clone-dropdown-label = _('Clone') = sprite_icon("chevron-down", css_class: "icon") - %ul.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options{ class: dropdown_class } + %ul.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown{ class: dropdown_class, data: { qa_selector: 'clone_dropdown_content' } } - if ssh_enabled? %li{ class: 'gl-px-4!' } %label.label-bold = _('Clone with SSH') .input-group.btn-group - = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: _('Repository clone URL') } + = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' } .input-group-append = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") = render_if_exists 'projects/buttons/geo' @@ -21,7 +21,7 @@ %label.label-bold = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } .input-group.btn-group - = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: _('Repository clone URL') } + = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' } .input-group-append = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") = render_if_exists 'projects/buttons/geo' diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml index bc2136b89fb..c0de711136a 100644 --- a/app/views/projects/issues/_work_item_links.html.haml +++ b/app/views/projects/issues/_work_item_links.html.haml @@ -1,2 +1,2 @@ - if Feature.enabled?(:work_items_hierarchy, @project) - .js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_path: @project.full_path, wi: work_items_index_data(@project) } } + .js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_namespace: @project.namespace.path, project_path: @project.full_path, wi: work_items_index_data(@project) } } diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 6b502ee928e..abaf250fa69 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -9,14 +9,14 @@ %span.js-clone-dropdown-label = default_clone_protocol.upcase = sprite_icon('chevron-down', css_class: 'gl-icon') - %ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown + %ul.dropdown-menu.dropdown-menu-selectable{ data: { qa_selector: 'clone_dropdown_content' } } %li = ssh_clone_button(container) %li = http_clone_button(container) = render_if_exists 'shared/kerberos_clone_button', container: container - = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') } + = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'clone_url_content' } .input-group-append = clipboard_button(target: '#clone_url', title: _("Copy URL"), class: "input-group-text gl-button btn-default btn-clipboard") diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml index d95efe83e15..098cc19c435 100644 --- a/app/views/shared/web_hooks/_hook_errors.html.haml +++ b/app/views/shared/web_hooks/_hook_errors.html.haml @@ -4,16 +4,12 @@ - link_end = '</a>'.html_safe - if hook.rate_limited? - - support_path = 'https://support.gitlab.com/hc/en-us/requests/new' - - placeholders = { strong_start: strong_start, - strong_end: strong_end, - limit: hook.rate_limit, - support_link_start: link_start % { url: support_path }, - support_link_end: link_end } - = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook was automatically disabled'), + - placeholders = { limit: number_with_delimiter(hook.rate_limit), + root_namespace: hook.parent.root_namespace.path } + = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook rate limit has been reached'), variant: :danger) do |c| = c.body do - = s_('Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook.').html_safe % placeholders + = s_("Webhooks|Webhooks for %{root_namespace} are now disabled because they've been triggered more than %{limit} times per minute. They'll be automatically re-enabled in the next minute.").html_safe % placeholders - elsif hook.permanently_disabled? = render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook failed to connect'), variant: :danger) do |c| diff --git a/bin/profile-url b/bin/profile-url index 9e8585aabba..6047cb70b8d 100755 --- a/bin/profile-url +++ b/bin/profile-url @@ -8,15 +8,15 @@ opt_parser = OptionParser.new do |opt| Profile a URL on this GitLab instance. Usage: - #{__FILE__} url --output=<profile-html> --sql=<sql-log> [--user=<user>] [--post=<post-data>] + #{__FILE__} url --output=<profile-dump> --sql=<sql-log> [--user=<user>] [--post=<post-data>] Example: - #{__FILE__} /dashboard/issues --output=dashboard-profile.html --sql=dashboard.log --user=root + #{__FILE__} /dashboard/issues --output=dashboard-profile.dump --sql=dashboard.log --user=root DOCSTRING opt.separator '' opt.separator 'Options:' - opt.on('-o', '--output=/tmp/profile.html', 'profile output filename') do |output| + opt.on('-o', '--output=/tmp/profile.dump', 'profile output filename') do |output| options[:profile_output] = output end @@ -45,13 +45,9 @@ end require File.expand_path('../config/environment', File.dirname(__FILE__)) -result = Gitlab::Profiler.profile(options[:url], - logger: Logger.new(options[:sql_output]), - post_data: options[:post_data], - user: UserFinder.new(options[:username]).find_by_username, - private_token: ENV['PRIVATE_TOKEN']) - -printer = RubyProf::CallStackPrinter.new(result) -file = File.open(options[:profile_output], 'w') -printer.print(file) -file.close +Gitlab::Profiler.profile(options[:url], + logger: Logger.new(options[:sql_output]), + post_data: options[:post_data], + user: UserFinder.new(options[:username]).find_by_username, + private_token: ENV['PRIVATE_TOKEN'], + profiler_options: { out: options[:profile_output] }) diff --git a/bin/rubocop-profile b/bin/rubocop-profile new file mode 100755 index 00000000000..d1e31edbeed --- /dev/null +++ b/bin/rubocop-profile @@ -0,0 +1,39 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Profile bundled RuboCop version. +# +# See https://github.com/rubocop/rubocop/blob/master/bin/rubocop-profile + +if ARGV.include?('-h') || ARGV.include?('--help') + puts "Usage: same as main `rubocop` command but gathers profiling info" + puts "Additional option: `--memory` to print memory usage" + exit(0) +end +with_mem = ARGV.delete('--memory') +ARGV.unshift '--cache', 'false' unless ARGV.include?('--cache') + +require 'stackprof' +if with_mem + require 'memory_profiler' + MemoryProfiler.start +end +StackProf.start +start = Process.clock_gettime(Process::CLOCK_MONOTONIC) +begin + require 'rubocop' + + exit RuboCop::CLI.new.run +ensure + delta = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + puts "Finished in #{delta.round(1)} seconds" + StackProf.stop + if with_mem + puts "Building memory report..." + report = MemoryProfiler.stop + end + Dir.mkdir('tmp') unless File.exist?('tmp') + StackProf.results('tmp/stackprof.dump') + report&.pretty_print(scale_bytes: true) + puts "StackProf written to `tmp/stackprof.dump`." +end diff --git a/config/feature_flags/development/enforce_runner_token_expires_at.yml b/config/feature_flags/development/enforce_runner_token_expires_at.yml deleted file mode 100644 index a1cb3bdcfdd..00000000000 --- a/config/feature_flags/development/enforce_runner_token_expires_at.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: enforce_runner_token_expires_at -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78557 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/352008 -milestone: '14.8' -type: development -group: group::runner -default_enabled: false diff --git a/config/feature_flags/development/graphql_keyset_pagination_without_next_page_query.yml b/config/feature_flags/development/graphql_keyset_pagination_without_next_page_query.yml index e289ad9af50..7b4c884a82f 100644 --- a/config/feature_flags/development/graphql_keyset_pagination_without_next_page_query.yml +++ b/config/feature_flags/development/graphql_keyset_pagination_without_next_page_query.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/373792 milestone: '15.4' type: development group: group::optimize -default_enabled: false +default_enabled: true diff --git a/config/feature_flags/development/gl_listbox_for_sort_dropdowns.yml b/config/feature_flags/development/pipeline_name.yml index 03d930012ab..40557a7d01e 100644 --- a/config/feature_flags/development/gl_listbox_for_sort_dropdowns.yml +++ b/config/feature_flags/development/pipeline_name.yml @@ -1,8 +1,8 @@ --- -name: gl_listbox_for_sort_dropdowns -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/98363 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364715 +name: pipeline_name +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/97502 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/376095 milestone: '15.5' type: development -group: group::foundations +group: group::delivery default_enabled: false diff --git a/config/webpack.config.js b/config/webpack.config.js index 65b38265b52..05523952769 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -646,7 +646,11 @@ module.exports = { patterns: [ { from: path.join(ROOT_PATH, 'node_modules/pdfjs-dist/cmaps/'), - to: path.join(WEBPACK_OUTPUT_PATH, 'cmaps/'), + to: path.join(WEBPACK_OUTPUT_PATH, 'pdfjs/cmaps/'), + }, + { + from: path.join(ROOT_PATH, 'node_modules/pdfjs-dist/legacy/build/pdf.worker.min.js'), + to: path.join(WEBPACK_OUTPUT_PATH, 'pdfjs/'), }, { from: path.join(ROOT_PATH, 'node_modules', SOURCEGRAPH_PACKAGE, '/'), diff --git a/config/webpack.vendor.config.js b/config/webpack.vendor.config.js index 248a34d9c2e..1300bf16e56 100644 --- a/config/webpack.vendor.config.js +++ b/config/webpack.vendor.config.js @@ -26,8 +26,6 @@ module.exports = { entry: { vendor: [ 'jquery/dist/jquery.slim.js', - 'pdfjs-dist/build/pdf', - 'pdfjs-dist/build/pdf.worker.min', 'core-js', 'echarts', 'lodash', diff --git a/db/migrate/20221006131506_add_free_user_cap_over_limit_notified_at_to_namespace_details.rb b/db/migrate/20221006131506_add_free_user_cap_over_limit_notified_at_to_namespace_details.rb new file mode 100644 index 00000000000..7237eee18f0 --- /dev/null +++ b/db/migrate/20221006131506_add_free_user_cap_over_limit_notified_at_to_namespace_details.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddFreeUserCapOverLimitNotifiedAtToNamespaceDetails < Gitlab::Database::Migration[2.0] + disable_ddl_transaction! + + TABLE_NAME = 'namespace_details' + COLUMN_NAME = 'free_user_cap_over_limit_notified_at' + + def up + with_lock_retries do + add_column(TABLE_NAME, COLUMN_NAME, :datetime_with_timezone) + end + end + + def down + with_lock_retries do + remove_column TABLE_NAME, COLUMN_NAME + end + end +end diff --git a/db/schema_migrations/20221006131506 b/db/schema_migrations/20221006131506 new file mode 100644 index 00000000000..cafb6518fdd --- /dev/null +++ b/db/schema_migrations/20221006131506 @@ -0,0 +1 @@ +2652f733d5998b4dacc89a7c43af45e6d411235efcdc120be02bbf04eb1c55d6
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index b8779dd9416..15af16ada65 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -17866,7 +17866,8 @@ CREATE TABLE namespace_details ( cached_markdown_version integer, description text, description_html text, - free_user_cap_over_limt_notified_at timestamp with time zone + free_user_cap_over_limt_notified_at timestamp with time zone, + free_user_cap_over_limit_notified_at timestamp with time zone ); CREATE TABLE namespace_limits ( diff --git a/doc/ci/runners/configure_runners.md b/doc/ci/runners/configure_runners.md index 7f28ef77814..28481a7e7ab 100644 --- a/doc/ci/runners/configure_runners.md +++ b/doc/ci/runners/configure_runners.md @@ -915,12 +915,8 @@ To determine which runners need to be upgraded: ## Authentication token security -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/30942) in GitLab 15.3 [with a flag](../../administration/feature_flags.md) named `enforce_runner_token_expires_at`. Disabled by default. - -FLAG: -On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to -[enable the feature flag](../../administration/feature_flags.md) named `enforce_runner_token_expires_at`. -On GitLab.com, this feature is not available. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/30942) in GitLab 15.3 [with a flag](../../administration/feature_flags.md) named `enforce_runner_token_expires_at`. Disabled by default. +> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/377902) in GitLab 15.5. Feature flag `enforce_runner_token_expires_at` removed. Each runner has an [authentication token](../../api/runners.md#registration-and-authentication-tokens) to connect with the GitLab instance. diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index aff4dfbb67b..87333f59589 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -398,6 +398,30 @@ Use [`workflow`](workflow.md) to control pipeline behavior. - [`workflow: rules` examples](workflow.md#workflow-rules-examples) - [Switch between branch pipelines and merge request pipelines](workflow.md#switch-between-branch-pipelines-and-merge-request-pipelines) +#### `workflow:name` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/372538) in GitLab 15.5 [with a flag](../../administration/feature_flags.md) named `pipeline_name`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available, +ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `pipeline_name`. +The feature is not ready for production use. + +You can use `name` in `workflow:` to define a name for pipelines. + +All pipelines are assigned the defined name. Any leading or trailing spaces in the name are removed. + +**Possible inputs**: + +- A string. + +**Example of `workflow:name`**: + +```yaml +workflow: + name: 'Pipeline name' +``` + #### `workflow:rules` The `rules` keyword in `workflow` is similar to [`rules` defined in jobs](#rules), diff --git a/doc/development/profiling.md b/doc/development/profiling.md index 01db208f1cf..3eb2c7c9144 100644 --- a/doc/development/profiling.md +++ b/doc/development/profiling.md @@ -20,6 +20,9 @@ The first argument to the profiler is either a full URL (including the instance hostname) or an absolute path, including the leading slash. +By default the report dump will be stored in a temporary file, which can be +interacted with using the [stackprof API](#reading-a-gitlabprofiler-report). + When using the script, command-line documentation is available by passing no arguments. @@ -31,10 +34,11 @@ For example: ```ruby Gitlab::Profiler.profile('/my-user') -# Returns a RubyProf::Profile for the regular operation of this request +# Returns the location of the temp file where the report dump is stored class UsersController; def show; sleep 100; end; end Gitlab::Profiler.profile('/my-user') -# Returns a RubyProf::Profile where 100 seconds is spent in UsersController#show +# Returns the location of the temp file where the report dump is stored +# where 100 seconds is spent in UsersController#show ``` For routes that require authorization you must provide a user to @@ -52,57 +56,14 @@ documented with the method source. Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first, logger: Logger.new($stdout)) ``` -There is also a RubyProf printer available: -`Gitlab::Profiler::TotalTimeFlatPrinter`. This acts like -`RubyProf::FlatPrinter`, but its `min_percent` option works on the method's -total time, not its self time. (This is because we often spend most of our time -in library code, but this comes from calls in our application.) It also offers a -`max_percent` option to help filter out outer calls that aren't useful (like -`ActionDispatch::Integration::Session#process`). - -There is a convenience method for using this, -`Gitlab::Profiler.print_by_total_time`: - -```ruby -result = Gitlab::Profiler.profile('/my-user') -Gitlab::Profiler.print_by_total_time(result, max_percent: 60, min_percent: 2) -# Measure Mode: wall_time -# Thread ID: 70005223698240 -# Fiber ID: 70004894952580 -# Total: 1.768912 -# Sort by: total_time -# -# %self total self wait child calls name -# 0.00 1.017 0.000 0.000 1.017 14 *ActionView::Helpers::RenderingHelper#render -# 0.00 1.017 0.000 0.000 1.017 14 *ActionView::Renderer#render_partial -# 0.00 1.017 0.000 0.000 1.017 14 *ActionView::PartialRenderer#render -# 0.00 1.007 0.000 0.000 1.007 14 *ActionView::PartialRenderer#render_partial -# 0.00 0.930 0.000 0.000 0.930 14 Hamlit::TemplateHandler#call -# 0.00 0.928 0.000 0.000 0.928 14 Temple::Engine#call -# 0.02 0.865 0.000 0.000 0.864 638 *Enumerable#inject -``` - -To print the profile in HTML format, use the following example: - -```ruby -result = Gitlab::Profiler.profile('/my-user') - -printer = RubyProf::CallStackPrinter.new(result) -printer.print(File.open('/tmp/profile.html', 'w')) -``` - -### Stackprof support - -By default, `Gitlab::Profiler.profile` uses a tracing profiler called [`ruby-prof`](https://ruby-prof.github.io/). However, sampling profilers -[run faster and use less memory](https://jvns.ca/blog/2017/12/17/how-do-ruby---python-profilers-work-/), so they might be preferred. - -You can switch to [Stackprof](https://github.com/tmm1/stackprof) (a sampling profiler) to generate a profile by passing `sampling_mode: true`. Pass in a `profiler_options` hash to configure the output file (`out`) of the sampling data. For example: ```ruby -Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first, sampling_mode: true, profiler_options: { out: 'tmp/profile.dump' }) +Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first, profiler_options: { out: 'tmp/profile.dump' }) ``` +## Reading a GitLab::Profiler report + You can get a summary of where time was spent by running Stackprof against the sampling data. For example: ```shell diff --git a/doc/topics/awesome_co.md b/doc/topics/awesome_co.md new file mode 100644 index 00000000000..0d725f64f3a --- /dev/null +++ b/doc/topics/awesome_co.md @@ -0,0 +1,143 @@ +--- +stage: Ecosystem +group: Foundations +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +description: AwesomeCo test data harness created by the Test Data Working Group https://about.gitlab.com/company/team/structure/working-groups/demo-test-data/ +comments: false +--- + +# AwesomeCo + +AwesomeCo is a test data seeding harness, that can seed test data into a user or group namespace. + +AwesomeCo uses FactoryBot in the backend which makes maintenance extremely easy. When a Model is changed, +FactoryBot will already be reflected to account for the change. + +## Docker Setup + +See [AwesomeCo Docker Demo](https://gitlab.com/-/snippets/2390362) + +## GDK Setup + +```shell +$ gdk start db +ok: run: services/postgresql: (pid n) 0s, normally down +ok: run: services/redis: (pid n) 74s, normally down +$ bundle install +Bundle complete! +$ bundle exec rake db:migrate +main: migrated +ci: migrated +``` + +### Run + +The `gitlab:seed:awesome_co` Rake task takes two arguments. `:name` and `:namespace_id`. + +```shell +$ bundle exec rake "gitlab:seed:awesome_co[awesome_co,1]" +Seeding AwesomeCo for Administrator +``` + +#### `:name` + +Where `:name` is the name of the AwesomeCo. (This will reflect .rb files located in db/seeds/awesome_co/*.rb) + +#### `:namespace_id` + +Where `:namespace_id` is the ID of the User or Group Namespace + +## List of Awesome Companies + +Each company (i.e. test data template) is represented as a Ruby file (.rb) in `db/seeds/awesome_co`. + +### AwesomeCo (db/seeds/awesome_co/awesome_co.rb) + +```shell +$ bundle exec rake "gitlab:seed:awesome_co[awesome_co,:namespace_id]" +Seeding AwesomeCo for :namespace_id +``` + +AwesomeCo is an automated seeding of [this demo repository](https://gitlab.com/tech-marketing/demos/gitlab-agile-demo/awesome-co). + +## Develop + +AwesomeCo seeding uses FactoryBot definitions from `spec/factories` which ... + +1. Saves time on development +1. Are easy-to-read +1. Are easy to maintain +1. Do not rely on an API that may change in the future +1. Are always up-to-date +1. Execute on the lowest-level (`ActiveRecord`) possible to create data as quickly as possible + +> _from the [FactoryBot README](https://github.com/thoughtbot/factory_bot#readme_) : factory_bot is a fixtures replacement with a straightforward definition syntax, support for multiple build +> strategies (saved instances, unsaved instances, attribute hashes, and stubbed objects), and support for multiple factories for the same class, including factory +> inheritance + +Factories reside in `spec/factories/*` and are fixtures for Rails models found in `app/models/*`. For example, For a model named `app/models/issue.rb`, the factory will +be named `spec/factories/issues.rb`. For a model named `app/models/project.rb`, the factory will be named `app/models/projects.rb`. + +### Taxonomy of a Factory + +Factories consist of three main parts - the **Name** of the factory, the **Traits** and the **Attributes**. + +Given: `create(:iteration, :with_title, :current, title: 'My Iteration')` + +||| +|:-|:-| +| **:iteration** | This is the **Name** of the factory. The file name will be the plural form of this **Name** and reside under either `spec/factories/iterations.rb` or `ee/spec/factories/iterations.rb`. | +| **:with_title** | This is a **Trait** of the factory. [See how it's defined](https://gitlab.com/gitlab-org/gitlab/-/blob/9c2a1f98483921dd006d70fdaed316e21fc5652f/ee/spec/factories/iterations.rb#L21-23). | +| **:current** | This is a **Trait** of the factory. [See how it's defined](https://gitlab.com/gitlab-org/gitlab/-/blob/9c2a1f98483921dd006d70fdaed316e21fc5652f/ee/spec/factories/iterations.rb#L29-31). | +| **title: 'My Iteration'** | This is an **Attribute** of the factory that will be passed to the Model for creation. | + +### Examples + +In these examples, you will see an instance variable `@owner`. This is the `root` user (`User.first`). + +#### Create a Group + +```ruby +my_group = create(:group, name: 'My Group', path: 'my-group-path') +``` + +#### Create a Project + +```ruby +# create a Project belonging to a Group +my_project = create(:project, :public, name: 'My Project', namespace: my_group, creator: @owner) +``` + +#### Create an Issue + +```ruby +# create an Issue belonging to a Project +my_issue = create(:issue, title: 'My Issue', project: my_project, weight: 2) +``` + +#### Create an Iteration + +```ruby +# create an Iteration under a Group +my_iteration = create(:iteration, :with_title, :current, title: 'My Iteration', group: my_group) +``` + +### Frequently encountered issues + +#### ActiveRecord::RecordInvalid: Validation failed: Email has already been taken, Username has already been taken + +This is because, by default, our factories are written to backfill any data that is missing. For instance, when a project +is created, the project must have somebody that created it. If the owner is not specified, the factory attempts to create it. + +**How to fix** + +Check the respective Factory to find out what key is required. Usually `:author` or `:owner`. + +```ruby +# This throws ActiveRecord::RecordInvalid +create(:project, name: 'Throws Error', namespace: create(:group, name: 'Some Group')) + +# Specify the user where @owner is a [User] record +create(:project, name: 'No longer throws error', owner: @owner, namespace: create(:group, name: 'Some Group')) +create(:epic, group: create(:group), author: @owner) +``` diff --git a/doc/user/clusters/integrations.md b/doc/user/clusters/integrations.md index a7854176e81..c5e56fcd3a7 100644 --- a/doc/user/clusters/integrations.md +++ b/doc/user/clusters/integrations.md @@ -88,11 +88,11 @@ Prometheus as long as you meet the requirements above. To enable the Prometheus integration for your cluster: 1. Go to the cluster's page: - - For a [project-level cluster](../project/clusters/index.md), navigate to your project's + - For a [project-level cluster](../project/clusters/index.md), go to your project's **Infrastructure > Kubernetes clusters**. - - For a [group-level cluster](../group/clusters/index.md), navigate to your group's + - For a [group-level cluster](../group/clusters/index.md), go to your group's **Kubernetes** page. - - For an [instance-level cluster](../instance/clusters/index.md), navigate to your instance's + - For an [instance-level cluster](../instance/clusters/index.md), go to your instance's **Kubernetes** page. 1. Select the **Integrations** tab. 1. Check the **Enable Prometheus integration** checkbox. diff --git a/doc/user/crm/index.md b/doc/user/crm/index.md index 43d2d67c53b..d7ab2195e2d 100644 --- a/doc/user/crm/index.md +++ b/doc/user/crm/index.md @@ -186,7 +186,7 @@ When you use the `/add_contacts` or `/remove_contacts` quick actions, follow the The root group is the topmost group in the group hierarchy. -When you move an issue, project, or group **within the same group hierarchy**, +When you move an issue, project, or group **in the same group hierarchy**, issues retain their contacts. When you move an issue or project and the **root group changes**, diff --git a/doc/user/group/epics/index.md b/doc/user/group/epics/index.md index 565174d69d5..da6e675f0eb 100644 --- a/doc/user/group/epics/index.md +++ b/doc/user/group/epics/index.md @@ -35,6 +35,22 @@ graph TD Also, read more about possible [planning hierarchies](../planning_hierarchy/index.md). +### Child issues from different group hierarchies + +<!-- When feature flag is removed, integrate this info as a sentence in +https://docs.gitlab.com/ee/user/group/epics/manage_epics.html#add-an-existing-issue-to-an-epic --> + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/371081) in GitLab 15.5 [with a flag](../../../administration/feature_flags.md) named `epic_issues_from_different_hierarchies`. Disabled by default. +> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/373304) in GitLab 15.5. + +FLAG: +On self-managed GitLab, by default this feature is unavailable. To make it available, ask an administrator to [enable the feature flag](../../../administration/feature_flags.md) named `epic_issues_from_different_hierarchies`. +On GitLab.com, this feature is available. + +You can add issues from a different group hierarchy to an epic. +To do it, paste the issue URL when +[adding an existing issue](manage_epics.md#add-an-existing-issue-to-an-epic). + ## Roadmap in epics **(ULTIMATE)** If your epic contains one or more [child epics](manage_epics.md#multi-level-child-epics) that diff --git a/doc/user/project/issues/managing_issues.md b/doc/user/project/issues/managing_issues.md index 8241fb882d8..213c615326f 100644 --- a/doc/user/project/issues/managing_issues.md +++ b/doc/user/project/issues/managing_issues.md @@ -577,6 +577,7 @@ Or: > - Filtering by type was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322755) in GitLab 13.10 [with a flag](../../../administration/feature_flags.md) named `vue_issues_list`. Disabled by default. > - Filtering by type was [enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/322755) in GitLab 14.10. > - Filtering by type is generally available in GitLab 15.1. [Feature flag `vue_issues_list`](https://gitlab.com/gitlab-org/gitlab/-/issues/359966) removed. +> - Filtering by health status [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218711) in GitLab 15.5. To filter the list of issues: diff --git a/doc/user/project/wiki/index.md b/doc/user/project/wiki/index.md index 69f6e055240..eedc44be3f9 100644 --- a/doc/user/project/wiki/index.md +++ b/doc/user/project/wiki/index.md @@ -390,6 +390,11 @@ line of your Apache configuration to ensure your page slugs render correctly. WARNING: This operation deletes all data in the wiki. +WARNING: +Any command that changes data directly could be damaging if not run correctly, or under the +right conditions. We highly recommend running them in a test environment with a backup of the +instance ready to be restored, just in case. + To clear all data from a project wiki and recreate it in a blank state: 1. [Start a Rails console session](../../../administration/operations/rails_console.md#starting-a-rails-console-session). diff --git a/doc/user/tasks.md b/doc/user/tasks.md index f2d9f777849..ae24460c9f9 100644 --- a/doc/user/tasks.md +++ b/doc/user/tasks.md @@ -185,3 +185,21 @@ To set issue weight of a task: The task window opens. 1. Next to **Weight**, enter a whole, positive number. 1. Select the close icon (**{close}**). + +## Add a task to an iteration **(PREMIUM)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/362550) in GitLab 15.5. + +You can add a task to an [iteration](group/iterations/index.md). +You can see the iteration title and period only when you view a task. + +Prerequisites: + +- You must have at least the Reporter role for the project. + +To add a task to an iteration: + +1. In the issue description, in the **Tasks** section, select the title of the task you want to edit. + The task window opens. +1. Next to **Iteration**, select **Add to iteration**. +1. From the dropdown list, select the iteration to be associated with the task. diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 800954a4311..a0e7d0b10cd 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -218,6 +218,7 @@ module API [ current_user&.cache_key, mr.merge_status, + mr.labels.map(&:cache_key), mr.merge_request_assignees.map(&:cache_key), mr.merge_request_reviewers.map(&:cache_key) ].join(":") diff --git a/lib/api/search.rb b/lib/api/search.rb index 44bb4228786..ff17696ed3e 100644 --- a/lib/api/search.rb +++ b/lib/api/search.rb @@ -63,7 +63,7 @@ module API @results = search_service(additional_params).search_objects(preload_method) end - set_global_search_log_information + set_global_search_log_information(additional_params) Gitlab::Metrics::GlobalSearchSlis.record_apdex( elapsed: @search_duration_s, @@ -105,7 +105,7 @@ module API # EE, without having to modify this file directly. end - def search_type + def search_type(additional_params = {}) 'basic' end @@ -113,10 +113,10 @@ module API params[:scope] end - def set_global_search_log_information + def set_global_search_log_information(additional_params) Gitlab::Instrumentation::GlobalSearchApi.set_information( - type: search_type, - level: search_service.level, + type: search_type(additional_params), + level: search_service(additional_params).level, scope: search_scope, search_duration_s: @search_duration_s ) diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index 438fa1cb3b2..661c6fb87e3 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -85,6 +85,10 @@ module Gitlab root.workflow_entry.rules_value end + def workflow_name + root.workflow_entry.name + end + def normalized_jobs @normalized_jobs ||= Ci::Config::Normalizer.new(jobs).normalize_jobs end diff --git a/lib/gitlab/ci/config/entry/workflow.rb b/lib/gitlab/ci/config/entry/workflow.rb index 5bc992a38a0..691d9e2d48b 100644 --- a/lib/gitlab/ci/config/entry/workflow.rb +++ b/lib/gitlab/ci/config/entry/workflow.rb @@ -6,12 +6,17 @@ module Gitlab module Entry class Workflow < ::Gitlab::Config::Entry::Node include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable - ALLOWED_KEYS = %i[rules].freeze + ALLOWED_KEYS = %i[rules name].freeze + + attributes :name validations do validates :config, type: Hash validates :config, allowed_keys: ALLOWED_KEYS + validates :name, allow_nil: true, length: { minimum: 1, maximum: 255 } end entry :rules, Entry::Rules, diff --git a/lib/gitlab/ci/pipeline/chain/populate.rb b/lib/gitlab/ci/pipeline/chain/populate.rb index 654e24be8e1..4bec8355732 100644 --- a/lib/gitlab/ci/pipeline/chain/populate.rb +++ b/lib/gitlab/ci/pipeline/chain/populate.rb @@ -25,6 +25,8 @@ module Gitlab return error('Failed to build the pipeline!') end + set_pipeline_name + raise Populate::PopulateError if pipeline.persisted? end @@ -34,6 +36,15 @@ module Gitlab private + def set_pipeline_name + return if Feature.disabled?(:pipeline_name, pipeline.project) || + @command.yaml_processor_result.workflow_name.blank? + + name = @command.yaml_processor_result.workflow_name + + pipeline.build_pipeline_metadata(project: pipeline.project, title: name) + end + def stage_names # We filter out `.pre/.post` stages, as they alone are not considered # a complete pipeline: diff --git a/lib/gitlab/ci/yaml_processor/result.rb b/lib/gitlab/ci/yaml_processor/result.rb index c0097cd84de..5c3864362da 100644 --- a/lib/gitlab/ci/yaml_processor/result.rb +++ b/lib/gitlab/ci/yaml_processor/result.rb @@ -36,6 +36,10 @@ module Gitlab @workflow_rules ||= @ci_config.workflow_rules end + def workflow_name + @workflow_name ||= @ci_config.workflow_name&.strip + end + def root_variables @root_variables ||= transform_to_array(@ci_config.variables) end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 29ef8d2fa74..814040d29e1 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -56,7 +56,6 @@ module Gitlab push_frontend_feature_flag(:security_auto_fix) push_frontend_feature_flag(:new_header_search) push_frontend_feature_flag(:source_editor_toolbar) - push_frontend_feature_flag(:gl_listbox_for_sort_dropdowns) push_frontend_feature_flag(:integration_slack_app_notifications) end diff --git a/lib/gitlab/profiler.rb b/lib/gitlab/profiler.rb index fd9f73d18c1..f8a85f693bc 100644 --- a/lib/gitlab/profiler.rb +++ b/lib/gitlab/profiler.rb @@ -43,12 +43,9 @@ module Gitlab # - private_token: instead of providing a user instance, the token can be # given as a string. Takes precedence over the user option. # - # - sampling_mode: When true, uses a sampling profiler (StackProf) instead of a tracing profiler (RubyProf). - # - # - profiler_options: A keyword Hash of arguments passed to the profiler. Defaults by profiler type: - # RubyProf - {} - # StackProf - { mode: :wall, out: <some temporary file>, interval: 1000, raw: true } - def self.profile(url, logger: nil, post_data: nil, user: nil, private_token: nil, sampling_mode: false, profiler_options: {}) + # - profiler_options: A keyword Hash of arguments passed to the profiler. Defaults: + # { mode: :wall, out: <some temporary file>, interval: 1000, raw: true } + def self.profile(url, logger: nil, post_data: nil, user: nil, private_token: nil, profiler_options: {}) app = ActionDispatch::Integration::Session.new(Rails.application) verb = :get headers = {} @@ -80,7 +77,7 @@ module Gitlab with_custom_logger(logger) do with_user(user) do - with_profiler(sampling_mode, profiler_options) do + with_profiler(profiler_options) do app.public_send(verb, url, params: post_data, headers: headers) # rubocop:disable GitlabSecurity/PublicSend end end @@ -174,21 +171,11 @@ module Gitlab end # rubocop: enable CodeReuse/ActiveRecord - def self.print_by_total_time(result, options = {}) - default_options = { sort_method: :total_time, filter_by: :total_time } - - RubyProf::FlatPrinter.new(result).print($stdout, default_options.merge(options)) - end - - def self.with_profiler(sampling_mode, profiler_options) - if sampling_mode - require 'stackprof' - args = { mode: :wall, interval: 1000, raw: true }.merge!(profiler_options) - args[:out] ||= ::Tempfile.new(["profile-#{Time.now.to_i}-", ".dump"]).path - ::StackProf.run(**args) { yield } - else - RubyProf.profile(**profiler_options) { yield } - end + def self.with_profiler(profiler_options) + require 'stackprof' + args = { mode: :wall, interval: 1000, raw: true }.merge!(profiler_options) + args[:out] ||= ::Tempfile.new(["profile-#{Time.now.to_i}-", ".dump"]).path + ::StackProf.run(**args) { yield } end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e40527d7301..6e155e9bc7b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -45295,9 +45295,6 @@ msgstr "" msgid "Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below." msgstr "" -msgid "Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook." -msgstr "" - msgid "Webhooks|Trigger" msgstr "" @@ -45322,7 +45319,10 @@ msgstr "" msgid "Webhooks|Webhook fails to connect" msgstr "" -msgid "Webhooks|Webhook was automatically disabled" +msgid "Webhooks|Webhook rate limit has been reached" +msgstr "" + +msgid "Webhooks|Webhooks for %{root_namespace} are now disabled because they've been triggered more than %{limit} times per minute. They'll be automatically re-enabled in the next minute." msgstr "" msgid "Webhooks|Wiki page events" @@ -45731,6 +45731,9 @@ msgstr "" msgid "WorkItem|Add task" msgstr "" +msgid "WorkItem|Add to iteration" +msgstr "" + msgid "WorkItem|Are you sure you want to cancel editing?" msgstr "" @@ -45781,9 +45784,18 @@ msgstr "" msgid "WorkItem|Issue" msgstr "" +msgid "WorkItem|Iteration" +msgstr "" + msgid "WorkItem|Learn about tasks." msgstr "" +msgid "WorkItem|No iteration" +msgstr "" + +msgid "WorkItem|No matching results" +msgstr "" + msgid "WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts." msgstr "" @@ -45814,6 +45826,9 @@ msgstr "" msgid "WorkItem|Something went wrong when deleting the task. Please try again." msgstr "" +msgid "WorkItem|Something went wrong when fetching iterations. Please try again." +msgstr "" + msgid "WorkItem|Something went wrong when fetching labels. Please try again." msgstr "" diff --git a/package.json b/package.json index 6f4a7c9eaf8..bfeb065cfd1 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,7 @@ "mousetrap": "1.6.5", "papaparse": "^5.3.1", "patch-package": "^6.4.7", - "pdfjs-dist": "^2.0.943", + "pdfjs-dist": "^2.16.105", "pikaday": "^1.8.0", "popper.js": "^1.16.1", "portal-vue": "^2.1.7", diff --git a/qa/qa/fixtures/package_managers/maven/project/request_forwarding/gitlab_ci.yaml.erb b/qa/qa/fixtures/package_managers/maven/project/request_forwarding/gitlab_ci.yaml.erb new file mode 100644 index 00000000000..a41bdc4d650 --- /dev/null +++ b/qa/qa/fixtures/package_managers/maven/project/request_forwarding/gitlab_ci.yaml.erb @@ -0,0 +1,8 @@ +install: + image: maven:3.6-jdk-11 + script: + - 'mvn install -U -s settings.xml' + only: + - "<%= imported_project.default_branch %>" + tags: + - "runner-for-<%= imported_project.name %>"
\ No newline at end of file diff --git a/qa/qa/fixtures/package_managers/maven/project/request_forwarding/settings.xml.erb b/qa/qa/fixtures/package_managers/maven/project/request_forwarding/settings.xml.erb new file mode 100644 index 00000000000..caf1fc9b761 --- /dev/null +++ b/qa/qa/fixtures/package_managers/maven/project/request_forwarding/settings.xml.erb @@ -0,0 +1,23 @@ +<settings> + <servers> + <server> + <id>central-proxy</id> + <configuration> + <httpHeaders> + <property> + <name>Private-Token</name> + <value><%= personal_access_token %></value> + </property> + </httpHeaders> + </configuration> + </server> + </servers> + <mirrors> + <mirror> + <id>central-proxy</id> + <name>GitLab proxy of central repo</name> + <url><%= gitlab_address_with_port %>/api/v4/projects/<%= imported_project.id %>/packages/maven</url> + <mirrorOf>central</mirrorOf> + </mirror> + </mirrors> +</settings> diff --git a/qa/qa/mobile/page/main/menu.rb b/qa/qa/mobile/page/main/menu.rb index 40bb421b383..73d3b9f7982 100644 --- a/qa/qa/mobile/page/main/menu.rb +++ b/qa/qa/mobile/page/main/menu.rb @@ -22,10 +22,10 @@ module QA end def open_mobile_menu - if has_no_element?(:user_avatar) + if has_no_element?(:user_avatar_content) Support::Retrier.retry_until do click_element(:mobile_navbar_button) - has_element?(:user_avatar) + has_element?(:user_avatar_content) end end end diff --git a/qa/qa/page/admin/menu.rb b/qa/qa/page/admin/menu.rb index e55e156fb8a..3164676f8e4 100644 --- a/qa/qa/page/admin/menu.rb +++ b/qa/qa/page/admin/menu.rb @@ -5,78 +5,79 @@ module QA module Admin class Menu < Page::Base view 'app/views/layouts/nav/sidebar/_admin.html.haml' do - element :admin_sidebar - element :admin_sidebar_settings_submenu_content - element :admin_settings_item - element :admin_settings_repository_item - element :admin_settings_general_item - element :admin_settings_metrics_and_profiling_item + element :admin_sidebar_content + element :admin_monitoring_menu_link + element :admin_monitoring_submenu_content + element :admin_overview_submenu_content + element :admin_overview_users_link + element :admin_overview_groups_link + element :admin_settings_menu_link + element :admin_settings_submenu_content + element :admin_settings_general_link + element :admin_settings_integrations_link + element :admin_settings_metrics_and_profiling_link + element :admin_settings_network_link element :admin_settings_preferences_link - element :admin_monitoring_link - element :admin_sidebar_monitoring_submenu_content - element :admin_sidebar_overview_submenu_content - element :users_overview_link - element :groups_overview_link - element :integration_settings_link + element :admin_settings_repository_link end def go_to_preferences_settings - hover_element(:admin_settings_item) do - within_submenu(:admin_sidebar_settings_submenu_content) do + hover_element(:admin_settings_menu_link) do + within_submenu(:admin_settings_submenu_content) do click_element :admin_settings_preferences_link end end end def go_to_repository_settings - hover_element(:admin_settings_item) do - within_submenu(:admin_sidebar_settings_submenu_content) do - click_element :admin_settings_repository_item + hover_element(:admin_settings_menu_link) do + within_submenu(:admin_settings_submenu_content) do + click_element :admin_settings_repository_link end end end def go_to_integration_settings - hover_element(:admin_settings_item) do - within_submenu(:admin_sidebar_settings_submenu_content) do - click_element :integration_settings_link + hover_element(:admin_settings_menu_link) do + within_submenu(:admin_settings_submenu_content) do + click_element :admin_settings_integrations_link end end end def go_to_general_settings - hover_element(:admin_settings_item) do - within_submenu(:admin_sidebar_settings_submenu_content) do - click_element :admin_settings_general_item + hover_element(:admin_settings_menu_link) do + within_submenu(:admin_settings_submenu_content) do + click_element :admin_settings_general_link end end end def go_to_metrics_and_profiling_settings - hover_element(:admin_settings_item) do - within_submenu(:admin_sidebar_settings_submenu_content) do - click_element :admin_settings_metrics_and_profiling_item + hover_element(:admin_settings_menu_link) do + within_submenu(:admin_settings_submenu_content) do + click_element :admin_settings_metrics_and_profiling_link end end end def go_to_network_settings - hover_element(:admin_settings_item) do - within_submenu(:admin_sidebar_settings_submenu_content) do - click_element :admin_settings_network_item + hover_element(:admin_settings_menu_link) do + within_submenu(:admin_settings_submenu_content) do + click_element :admin_settings_network_link end end end def go_to_users_overview - within_submenu(:admin_sidebar_overview_submenu_content) do - click_element :users_overview_link + within_submenu(:admin_overview_submenu_content) do + click_element :admin_overview_users_link end end def go_to_groups_overview - within_submenu(:admin_sidebar_overview_submenu_content) do - click_element :groups_overview_link + within_submenu(:admin_overview_submenu_content) do + click_element :admin_overview_groups_link end end @@ -92,7 +93,7 @@ module QA end def within_sidebar - within_element(:admin_sidebar) do + within_element(:admin_sidebar_content) do yield end end diff --git a/qa/qa/page/component/clone_panel.rb b/qa/qa/page/component/clone_panel.rb index a0aea6fe44d..3ea29ff63da 100644 --- a/qa/qa/page/component/clone_panel.rb +++ b/qa/qa/page/component/clone_panel.rb @@ -11,18 +11,18 @@ module QA base.view 'app/views/projects/buttons/_clone.html.haml' do element :clone_dropdown - element :clone_options - element :ssh_clone_url - element :http_clone_url + element :clone_dropdown_content + element :ssh_clone_url_content + element :http_clone_url_content end end def repository_clone_http_location - repository_clone_location(:http_clone_url) + repository_clone_location(:http_clone_url_content) end def repository_clone_ssh_location - repository_clone_location(:ssh_clone_url) + repository_clone_location(:ssh_clone_url_content) end private @@ -31,7 +31,7 @@ module QA wait_until(reload: false) do click_element :clone_dropdown - within_element :clone_options do + within_element :clone_dropdown_content do Git::Location.new(find_element(kind).value) end end diff --git a/qa/qa/page/component/legacy_clone_panel.rb b/qa/qa/page/component/legacy_clone_panel.rb index f15d159a712..ee372a3f9aa 100644 --- a/qa/qa/page/component/legacy_clone_panel.rb +++ b/qa/qa/page/component/legacy_clone_panel.rb @@ -11,8 +11,8 @@ module QA base.view 'app/views/shared/_clone_panel.html.haml' do element :clone_dropdown - element :clone_options_dropdown, '.clone-options-dropdown' # rubocop:disable QA/ElementWithPattern - element :clone_url, 'text_field_tag :clone_url' # rubocop:disable QA/ElementWithPattern + element :clone_dropdown_content + element :clone_url_content end end @@ -28,7 +28,7 @@ module QA end def repository_location - Git::Location.new(find('#clone_url').value) + Git::Location.new(find_element(:clone_url_content).text) end private @@ -37,7 +37,7 @@ module QA wait_until(reload: false) do click_element :clone_dropdown - page.within('.clone-options-dropdown') do + within_element(:clone_dropdown_content) do click_link(kind) end diff --git a/qa/qa/page/main/menu.rb b/qa/qa/page/main/menu.rb index aaf10e12e82..2f618224a73 100644 --- a/qa/qa/page/main/menu.rb +++ b/qa/qa/page/main/menu.rb @@ -14,7 +14,7 @@ module QA view 'app/views/layouts/header/_default.html.haml' do element :navbar, required: true element :canary_badge_link - element :user_avatar, required: !QA::Runtime::Env.mobile_layout? + element :user_avatar_content, required: !QA::Runtime::Env.mobile_layout? element :user_menu, required: !QA::Runtime::Env.mobile_layout? element :stop_impersonation_link element :issues_shortcut_button, required: !QA::Runtime::Env.mobile_layout? @@ -184,11 +184,11 @@ module QA end def has_personal_area?(wait: Capybara.default_max_wait_time) - has_element?(:user_avatar, wait: wait) + has_element?(:user_avatar_content, wait: wait) end def has_no_personal_area?(wait: Capybara.default_max_wait_time) - has_no_element?(:user_avatar, wait: wait) + has_no_element?(:user_avatar_content, wait: wait) end def has_admin_area_link?(wait: Capybara.default_max_wait_time) @@ -227,7 +227,7 @@ module QA def within_user_menu(&block) within_top_menu do - click_element :user_avatar unless has_element?(:user_profile_link, wait: 1) + click_element :user_avatar_content unless has_element?(:user_profile_link, wait: 1) within_element(:user_menu, &block) end diff --git a/qa/qa/page/main/terms.rb b/qa/qa/page/main/terms.rb index 024510c33cf..24f6b03549b 100644 --- a/qa/qa/page/main/terms.rb +++ b/qa/qa/page/main/terms.rb @@ -5,7 +5,7 @@ module QA module Main class Terms < Page::Base view 'app/views/layouts/terms.html.haml' do - element :user_avatar, required: true + element :user_avatar_content, required: true end view 'app/assets/javascripts/terms/components/app.vue' do diff --git a/qa/qa/specs/features/browser_ui/5_package/package_registry/maven/maven_project_level_spec.rb b/qa/qa/specs/features/browser_ui/5_package/package_registry/maven/maven_project_level_spec.rb index a67c6addc4d..c3ddfb25357 100644 --- a/qa/qa/specs/features/browser_ui/5_package/package_registry/maven/maven_project_level_spec.rb +++ b/qa/qa/specs/features/browser_ui/5_package/package_registry/maven/maven_project_level_spec.rb @@ -1,7 +1,11 @@ # frozen_string_literal: true module QA - RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable do + RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable, + feature_flag: { + name: 'maven_central_request_forwarding', + scope: :global + } do describe 'Maven project level endpoint' do include Runtime::Fixtures @@ -143,5 +147,103 @@ module QA end end end + + describe 'Maven request forwarding' do + include Runtime::Fixtures + + let(:group_id) { 'com.gitlab.qa' } + let(:artifact_id) { "maven-#{SecureRandom.hex(8)}" } + let(:package_name) { "#{group_id}/#{artifact_id}".tr('.', '/') } + let(:package_version) { '1.3.7' } + let(:package_type) { 'maven' } + let(:personal_access_token) { Runtime::Env.personal_access_token } + let(:group) { Resource::Group.fabricate_via_api! } + + let(:gitlab_address_with_port) do + uri = URI.parse(Runtime::Scenario.gitlab_address) + "#{uri.scheme}://#{uri.host}:#{uri.port}" + end + + let(:package) do + Resource::Package.init do |package| + package.name = package_name + package.project = imported_project + end + end + + let(:runner) do + Resource::Runner.fabricate! do |runner| + runner.name = "qa-runner-#{Time.now.to_i}" + runner.tags = ["runner-for-#{imported_project.name}"] + runner.executor = :docker + runner.token = group.reload!.runners_token + end + end + + let(:imported_project) do + Resource::ProjectImportedFromURL.fabricate_via_browser_ui! do |project| + project.name = "#{package_type}_imported_project" + project.group = group + project.gitlab_repository_path = 'https://gitlab.com/gitlab-org/quality/imported-projects/maven.git' + end + end + + before do + Runtime::Feature.enable(:maven_central_request_forwarding) + Flow::Login.sign_in_unless_signed_in + + imported_project + runner + end + + after do + Runtime::Feature.disable(:maven_central_request_forwarding) + + runner.remove_via_api! + package.remove_via_api! + imported_project.remove_via_api! + end + + it( + 'uses GitLab as a mirror of the central proxy', + :skip_live_env, + testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/375767' + ) do + Support::Retrier.retry_on_exception(max_attempts: 3, sleep_interval: 2) do + Resource::Repository::Commit.fabricate_via_api! do |commit| + gitlab_ci_yaml = ERB.new(read_fixture('package_managers/maven/project/request_forwarding', + 'gitlab_ci.yaml.erb' + ) + ) + .result(binding) + settings_xml = ERB.new(read_fixture('package_managers/maven/project/request_forwarding', + 'settings.xml.erb' + ) + ) + .result(binding) + + commit.project = imported_project + commit.commit_message = 'Add files' + commit.add_files( + [ + { file_path: '.gitlab-ci.yml', content: gitlab_ci_yaml }, + { file_path: 'settings.xml', content: settings_xml } + ]) + end + end + + imported_project.visit! + + Flow::Pipeline.visit_latest_pipeline + + Page::Project::Pipeline::Show.perform do |pipeline| + pipeline.click_job('install') + end + + Page::Project::Job::Show.perform do |job| + expect(job).to be_successful(timeout: 800) + end + end + end end end diff --git a/rubocop/ext/path_util.rb b/rubocop/ext/path_util.rb new file mode 100644 index 00000000000..3b54f046c7b --- /dev/null +++ b/rubocop/ext/path_util.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RuboCop + module PathUtil + def match_path?(pattern, path) + case pattern + when String + matched = if /[*{}]/.match?(pattern) + File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) + else + pattern == path + end + + matched || hidden_file_in_not_hidden_dir?(pattern, path) + when Regexp + begin + pattern.match?(path) + rescue ArgumentError => e + return false if e.message.start_with?('invalid byte sequence') + + raise e + end + end + end + end +end diff --git a/rubocop/ext/variable_force.rb b/rubocop/ext/variable_force.rb new file mode 100644 index 00000000000..def284513ed --- /dev/null +++ b/rubocop/ext/variable_force.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RuboCop + module Ext + module VariableForce + def scanned_node?(node) + scanned_nodes.include?(node) + end + + def scanned_nodes + @scanned_nodes ||= Set.new.compare_by_identity + end + end + end +end + +RuboCop::Cop::VariableForce.prepend RuboCop::Ext::VariableForce diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index 5a5e76a87e2..6b5491b27fc 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -1,6 +1,11 @@ # rubocop:disable Naming/FileName # frozen_string_literal: true +# Performance improvements to be upstreamed soon: +# See https://gitlab.com/gitlab-org/gitlab/-/issues/377469 +require_relative 'ext/path_util' +require_relative 'ext/variable_force' + # Auto-require all cops under `rubocop/cop/**/*.rb` Dir[File.join(__dir__, 'cop', '**', '*.rb')].sort.each(&method(:require)) diff --git a/spec/features/dashboard/user_filters_projects_spec.rb b/spec/features/dashboard/user_filters_projects_spec.rb index cb9188cf171..e25da5854ab 100644 --- a/spec/features/dashboard/user_filters_projects_spec.rb +++ b/spec/features/dashboard/user_filters_projects_spec.rb @@ -9,8 +9,6 @@ RSpec.describe 'Dashboard > User filters projects' do let(:project2) { create(:project, name: 'Treasure', namespace: user2.namespace, created_at: 1.second.ago, updated_at: 1.second.ago) } before do - stub_feature_flags(gl_listbox_for_sort_dropdowns: false) - project.add_maintainer(user) sign_in(user) @@ -147,7 +145,14 @@ RSpec.describe 'Dashboard > User filters projects' do end it 'filters any project' do + # Selecting the same option in the `GlListbox` does not emit `select` event + # and that is why URL update won't be triggered. Given that `Any` is a default option + # we need to explicitly switch from some other option (e.g. `Internal`) to `Any` + # to trigger the page update + select_dropdown_option '#filtered-search-visibility-dropdown > .dropdown', 'Internal', '.dropdown-item' + select_dropdown_option '#filtered-search-visibility-dropdown > .dropdown', 'Any', '.dropdown-item' + list = page.all('.projects-list .project-name').map(&:text) expect(list).to contain_exactly("Internal project", "Private project", "Treasure", "Victorialand") diff --git a/spec/features/groups/labels/sort_labels_spec.rb b/spec/features/groups/labels/sort_labels_spec.rb index af4d39bc6fa..9d05703aae6 100644 --- a/spec/features/groups/labels/sort_labels_spec.rb +++ b/spec/features/groups/labels/sort_labels_spec.rb @@ -9,8 +9,6 @@ RSpec.describe 'Sort labels', :js do let!(:label2) { create(:group_label, title: 'Bar', description: 'Fusce consequat', group: group) } before do - stub_feature_flags(gl_listbox_for_sort_dropdowns: false) - group.add_maintainer(user) sign_in(user) @@ -30,7 +28,7 @@ RSpec.describe 'Sort labels', :js do it 'sorts by date' do click_button 'Name' - sort_options = find('ul.dropdown-menu').all('li').collect(&:text) + sort_options = find('ul[role="listbox"]').all('li').collect(&:text) expect(sort_options[0]).to eq('Name') expect(sort_options[1]).to eq('Name, descending') @@ -39,7 +37,7 @@ RSpec.describe 'Sort labels', :js do expect(sort_options[4]).to eq('Updated date') expect(sort_options[5]).to eq('Oldest updated') - click_button 'Name, descending' + find('li', text: 'Name, descending').click # assert default sorting within '.other-labels' do diff --git a/spec/features/groups/milestones_sorting_spec.rb b/spec/features/groups/milestones_sorting_spec.rb index 631aa940270..125bf9ce3a7 100644 --- a/spec/features/groups/milestones_sorting_spec.rb +++ b/spec/features/groups/milestones_sorting_spec.rb @@ -14,7 +14,6 @@ RSpec.describe 'Milestones sorting', :js do let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user } before do - stub_feature_flags(gl_listbox_for_sort_dropdowns: false) sign_in(user) end @@ -30,9 +29,9 @@ RSpec.describe 'Milestones sorting', :js do within '[data-testid=milestone_sort_by_dropdown]' do click_button 'Due soon' - expect(find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending']) + expect(find('ul[role="listbox"]').all('li').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending']) - click_button 'Due later' + find('li', text: 'Due later').click expect(page).to have_button('Due later') end diff --git a/spec/features/projects/labels/sort_labels_spec.rb b/spec/features/projects/labels/sort_labels_spec.rb index 6a16f474056..f2f1acd2348 100644 --- a/spec/features/projects/labels/sort_labels_spec.rb +++ b/spec/features/projects/labels/sort_labels_spec.rb @@ -9,8 +9,6 @@ RSpec.describe 'Sort labels', :js do let!(:label2) { create(:label, title: 'Bar', description: 'Fusce consequat', project: project) } before do - stub_feature_flags(gl_listbox_for_sort_dropdowns: false) - project.add_maintainer(user) sign_in(user) @@ -30,7 +28,7 @@ RSpec.describe 'Sort labels', :js do it 'sorts by date' do click_button 'Name' - sort_options = find('ul.dropdown-menu').all('li').collect(&:text) + sort_options = find('ul[role="listbox"]').all('li').collect(&:text) expect(sort_options[0]).to eq('Name') expect(sort_options[1]).to eq('Name, descending') @@ -39,7 +37,7 @@ RSpec.describe 'Sort labels', :js do expect(sort_options[4]).to eq('Updated date') expect(sort_options[5]).to eq('Oldest updated') - click_button 'Name, descending' + find('li', text: 'Name, descending').click # assert default sorting within '.other-labels' do diff --git a/spec/features/projects/milestones/milestones_sorting_spec.rb b/spec/features/projects/milestones/milestones_sorting_spec.rb index 5c379ac1034..5ba4289fd11 100644 --- a/spec/features/projects/milestones/milestones_sorting_spec.rb +++ b/spec/features/projects/milestones/milestones_sorting_spec.rb @@ -21,7 +21,6 @@ RSpec.describe 'Milestones sorting', :js do end before do - stub_feature_flags(gl_listbox_for_sort_dropdowns: false) create(:milestone, start_date: 7.days.from_now, due_date: 10.days.from_now, title: "a", project: project) create(:milestone, start_date: 6.days.from_now, due_date: 11.days.from_now, title: "c", project: project) create(:milestone, start_date: 5.days.from_now, due_date: 12.days.from_now, title: "b", project: project) @@ -43,10 +42,10 @@ RSpec.describe 'Milestones sorting', :js do milestones_for_sort_by.each do |sort_by, expected_milestones| within '[data-testid=milestone_sort_by_dropdown]' do click_button selected_sort_order - milestones = find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text) + milestones = find('ul[role="listbox"]').all('li').map(&:text) expect(milestones).to eq(ordered_milestones) - click_button sort_by + find('li', text: sort_by).click expect(page).to have_button(sort_by) end diff --git a/spec/features/projects/user_sorts_projects_spec.rb b/spec/features/projects/user_sorts_projects_spec.rb index 49b9052ed3c..c40f01f3aa1 100644 --- a/spec/features/projects/user_sorts_projects_spec.rb +++ b/spec/features/projects/user_sorts_projects_spec.rb @@ -42,7 +42,6 @@ RSpec.describe 'User sorts projects and order persists' do context "from explore projects", :js do before do - stub_feature_flags(gl_listbox_for_sort_dropdowns: false) sign_in(user) visit(explore_projects_path) find('#sort-projects-dropdown').click @@ -54,7 +53,6 @@ RSpec.describe 'User sorts projects and order persists' do context 'from dashboard projects', :js do before do - stub_feature_flags(gl_listbox_for_sort_dropdowns: false) sign_in(user) visit(dashboard_projects_path) find('#sort-projects-dropdown').click diff --git a/spec/features/projects/wikis_spec.rb b/spec/features/projects/wikis_spec.rb index 8ac17413df3..879ffd2932b 100644 --- a/spec/features/projects/wikis_spec.rb +++ b/spec/features/projects/wikis_spec.rb @@ -3,10 +3,6 @@ require "spec_helper" RSpec.describe 'Project wikis', :js do - before do - stub_feature_flags(gl_listbox_for_sort_dropdowns: false) - end - let_it_be(:user) { create(:user) } let(:wiki) { create(:project_wiki, user: user, project: project) } diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml index 37cae6b4264..ef604f707b5 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml @@ -15,7 +15,9 @@ rules:changes as array of strings: # valid workflow:rules:exists # valid rules:changes:path +# valid workflow:name workflow: + name: 'Pipeline name' rules: - changes: paths: diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index 6a138f9a247..b0bfe2b45f0 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -2,6 +2,7 @@ import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; import { s__, sprintf } from '~/locale'; import HeaderSearchApp from '~/header_search/components/app.vue'; import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; @@ -360,22 +361,43 @@ describe('HeaderSearchApp', () => { describe('Header Search Input', () => { describe('when dropdown is closed', () => { - it('onFocus opens dropdown', async () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it('onFocus opens dropdown and triggers snowplow event', async () => { expect(findHeaderSearchDropdown().exists()).toBe(false); findHeaderSearchInput().vm.$emit('focus'); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(true); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { + label: 'global_search', + property: 'top_navigation', + }); }); - it('onClick opens dropdown', async () => { + it('onClick opens dropdown and triggers snowplow event', async () => { expect(findHeaderSearchDropdown().exists()).toBe(false); findHeaderSearchInput().vm.$emit('click'); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(true); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { + label: 'global_search', + property: 'top_navigation', + }); + }); + + it('onClick followed by onFocus only triggers a single snowplow event', async () => { + findHeaderSearchInput().vm.$emit('click'); + findHeaderSearchInput().vm.$emit('focus'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/listbox/index_spec.js b/spec/frontend/listbox/index_spec.js index c973960e683..fd41531796b 100644 --- a/spec/frontend/listbox/index_spec.js +++ b/spec/frontend/listbox/index_spec.js @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; -import { getAllByRole, getByRole, getByTestId } from '@testing-library/dom'; -import { GlDropdown, GlListbox } from '@gitlab/ui'; +import { getAllByRole, getByTestId } from '@testing-library/dom'; +import { GlListbox } from '@gitlab/ui'; import { createWrapper } from '@vue/test-utils'; import { initListbox, parseAttributes } from '~/listbox'; import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; @@ -39,141 +39,65 @@ describe('initListbox', () => { }); describe('given a valid element', () => { - describe('when `glListboxForSortDropdowns` FF is enabled', () => { - let onChangeSpy; + let onChangeSpy; - const listbox = () => createWrapper(instance).findComponent(GlListbox); - const findToggleButton = () => getByTestId(document.body, 'base-dropdown-toggle'); - const findSelectedItems = () => getAllByRole(document.body, 'option', { selected: true }); + const listbox = () => createWrapper(instance).findComponent(GlListbox); + const findToggleButton = () => getByTestId(document.body, 'base-dropdown-toggle'); + const findSelectedItems = () => getAllByRole(document.body, 'option', { selected: true }); - beforeEach(async () => { - window.gon.features = { glListboxForSortDropdowns: true }; - setHTMLFixture(fixture); - onChangeSpy = jest.fn(); - setup(document.querySelector('.js-redirect-listbox'), { onChange: onChangeSpy }); - - await nextTick(); - }); + beforeEach(async () => { + setHTMLFixture(fixture); + onChangeSpy = jest.fn(); + setup(document.querySelector('.js-redirect-listbox'), { onChange: onChangeSpy }); - afterEach(() => { - resetHTMLFixture(); - }); - - it('returns an instance', () => { - expect(instance).not.toBe(null); - }); + await nextTick(); + }); - it('renders button with selected item text', () => { - expect(findToggleButton().textContent.trim()).toBe('Bar'); - }); + afterEach(() => { + resetHTMLFixture(); + }); - it('has the correct item selected', () => { - const selectedItems = findSelectedItems(); - expect(selectedItems).toHaveLength(1); - expect(selectedItems[0].textContent.trim()).toBe('Bar'); - }); + it('returns an instance', () => { + expect(instance).not.toBe(null); + }); - it('applies additional classes from the original element', () => { - expect(instance.$el.classList).toContain('test-class-1', 'test-class-2'); - }); + it('renders button with selected item text', () => { + expect(findToggleButton().textContent.trim()).toBe('Bar'); + }); - describe.each(parsedAttributes.items)('selecting an item', (item) => { - beforeEach(async () => { - listbox().vm.$emit('select', item.value); - await nextTick(); - }); - - it('calls the onChange callback with the item', () => { - expect(onChangeSpy).toHaveBeenCalledWith(item); - }); - - it('updates the toggle button text', () => { - expect(findToggleButton().textContent.trim()).toBe(item.text); - }); - - it('marks the item as selected', () => { - const selectedItems = findSelectedItems(); - expect(selectedItems).toHaveLength(1); - expect(selectedItems[0].textContent.trim()).toBe(item.text); - }); - }); + it('has the correct item selected', () => { + const selectedItems = findSelectedItems(); + expect(selectedItems).toHaveLength(1); + expect(selectedItems[0].textContent.trim()).toBe('Bar'); + }); - it('passes the "right" prop through to the underlying component', () => { - expect(listbox().props('right')).toBe(parsedAttributes.right); - }); + it('applies additional classes from the original element', () => { + expect(instance.$el.classList).toContain('test-class-1', 'test-class-2'); }); - describe('when `glListboxForSortDropdowns` FF is disabled', () => { - let onChangeSpy; - - const ITEM_ROLE = 'menuitem'; - const dropdown = () => createWrapper(instance).findComponent(GlDropdown); - - const findToggleButton = () => document.body.querySelector('.gl-dropdown-toggle'); - const findItem = (text) => getByRole(document.body, ITEM_ROLE, { name: text }); - const findItems = () => getAllByRole(document.body, ITEM_ROLE); - const findSelectedItems = () => - findItems().filter( - (item) => - !item - .querySelector('.gl-new-dropdown-item-check-icon') - .classList.contains('gl-visibility-hidden'), - ); + describe.each(parsedAttributes.items)('selecting an item', (item) => { beforeEach(async () => { - window.gon.features = { glListboxForSortDropdowns: false }; - setHTMLFixture(fixture); - onChangeSpy = jest.fn(); - setup(document.querySelector('.js-redirect-listbox'), { onChange: onChangeSpy }); - + listbox().vm.$emit('select', item.value); await nextTick(); }); - afterEach(() => { - resetHTMLFixture(); - }); - - it('returns an instance', () => { - expect(instance).not.toBe(null); + it('calls the onChange callback with the item', () => { + expect(onChangeSpy).toHaveBeenCalledWith(item); }); - it('renders button with selected item text', () => { - expect(findToggleButton().textContent.trim()).toBe('Bar'); + it('updates the toggle button text', () => { + expect(findToggleButton().textContent.trim()).toBe(item.text); }); - it('has the correct item selected', () => { + it('marks the item as selected', () => { const selectedItems = findSelectedItems(); expect(selectedItems).toHaveLength(1); - expect(selectedItems[0].textContent.trim()).toBe('Bar'); - }); - - it('applies additional classes from the original element', () => { - expect(instance.$el.classList).toContain('test-class-1', 'test-class-2'); - }); - - describe.each(parsedAttributes.items)('selecting an item', (item) => { - beforeEach(async () => { - findItem(item.text).click(); - await nextTick(); - }); - - it('calls the onChange callback with the item', () => { - expect(onChangeSpy).toHaveBeenCalledWith(item); - }); - - it('updates the toggle button text', () => { - expect(findToggleButton().textContent.trim()).toBe(item.text); - }); - - it('marks the item as selected', () => { - const selectedItems = findSelectedItems(); - expect(selectedItems).toHaveLength(1); - expect(selectedItems[0].textContent.trim()).toBe(item.text); - }); + expect(selectedItems[0].textContent.trim()).toBe(item.text); }); + }); - it('passes the "right" prop through to the underlying component', () => { - expect(dropdown().props('right')).toBe(parsedAttributes.right); - }); + it('passes the "right" prop through to the underlying component', () => { + expect(listbox().props('right')).toBe(parsedAttributes.right); }); }); }); diff --git a/spec/frontend/nav/components/top_nav_app_spec.js b/spec/frontend/nav/components/top_nav_app_spec.js index 745707c1d28..b32ab5ebe09 100644 --- a/spec/frontend/nav/components/top_nav_app_spec.js +++ b/spec/frontend/nav/components/top_nav_app_spec.js @@ -1,5 +1,6 @@ import { GlNavItemDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; +import { mockTracking } from 'helpers/tracking_helper'; import TopNavApp from '~/nav/components/top_nav_app.vue'; import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; import { TEST_NAV_DATA } from '../mock_data'; @@ -8,6 +9,14 @@ describe('~/nav/components/top_nav_app.vue', () => { let wrapper; const createComponent = () => { + wrapper = mount(TopNavApp, { + propsData: { + navData: TEST_NAV_DATA, + }, + }); + }; + + const createComponentShallow = () => { wrapper = shallowMount(TopNavApp, { propsData: { navData: TEST_NAV_DATA, @@ -16,6 +25,7 @@ describe('~/nav/components/top_nav_app.vue', () => { }; const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown); + const findNavItemDropdowToggle = () => findNavItemDropdown().find('.js-top-nav-dropdown-toggle'); const findMenu = () => wrapper.findComponent(TopNavDropdownMenu); afterEach(() => { @@ -24,7 +34,7 @@ describe('~/nav/components/top_nav_app.vue', () => { describe('default', () => { beforeEach(() => { - createComponent(); + createComponentShallow(); }); it('renders nav item dropdown', () => { @@ -45,4 +55,18 @@ describe('~/nav/components/top_nav_app.vue', () => { }); }); }); + + describe('tracking', () => { + it('emits a tracking event when the toggle is clicked', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + createComponent(); + + findNavItemDropdowToggle().trigger('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_nav', { + label: 'hamburger_menu', + property: 'top_navigation', + }); + }); + }); }); diff --git a/spec/frontend/pdf/page_spec.js b/spec/frontend/pdf/page_spec.js index 608516385d9..4cf83a3252d 100644 --- a/spec/frontend/pdf/page_spec.js +++ b/spec/frontend/pdf/page_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import PageComponent from '~/pdf/page/index.vue'; jest.mock('pdfjs-dist/webpack', () => { - return { default: jest.requireActual('pdfjs-dist/build/pdf') }; + return { default: jest.requireActual('pdfjs-dist/legacy/build/pdf') }; }); describe('Page component', () => { diff --git a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js index 7ab4aeee9bc..64f66d8f3ba 100644 --- a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -104,6 +104,10 @@ describe('AdminRunnerShowApp', () => { Platform darwin Configuration Runs untagged jobs Maximum job timeout None + Token expiry + Runner authentication token expiration + Runner authentication tokens will expire based on a set interval. + They will automatically rotate once expired. Learn more Never expires Tags None`.replace(/\s+/g, ' '); expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 25924296f00..7afde3bdc96 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -20,8 +20,6 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerStackedLayoutBanner from '~/runner/components/runner_stacked_layout_banner.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; -import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; -import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue'; import RunnerList from '~/runner/components/runner_list.vue'; import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; @@ -84,8 +82,6 @@ const COUNT_QUERIES = 7; // 4 tabs + 3 status queries describe('AdminRunnersApp', () => { let wrapper; - let cacheConfig; - let localMutations; let showToast; const findRunnerStackedLayoutBanner = () => wrapper.findComponent(RunnerStackedLayoutBanner); @@ -93,8 +89,6 @@ describe('AdminRunnersApp', () => { const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); - const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); - const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -107,7 +101,7 @@ describe('AdminRunnersApp', () => { provide, ...options } = {}) => { - ({ cacheConfig, localMutations } = createLocalState()); + const { cacheConfig, localMutations } = createLocalState(); const handlers = [ [allRunnersQuery, mockRunnersHandler], @@ -373,38 +367,9 @@ describe('AdminRunnersApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('runner bulk delete is available', () => { - expect(findRunnerBulkDelete().props('runners')).toEqual(mockRunners); - }); - - it('runner bulk delete checkbox is available', () => { - expect(findRunnerBulkDeleteCheckbox().props('runners')).toEqual(mockRunners); - }); - it('runner list is checkable', () => { expect(findRunnerList().props('checkable')).toBe(true); }); - - it('responds to checked items by updating the local cache', () => { - const setRunnerCheckedMock = jest - .spyOn(localMutations, 'setRunnerChecked') - .mockImplementation(() => {}); - - const runner = mockRunners[0]; - - expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0); - - findRunnerList().vm.$emit('checked', { - runner, - isChecked: true, - }); - - expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); - expect(setRunnerCheckedMock).toHaveBeenCalledWith({ - runner, - isChecked: true, - }); - }); }); describe('When runners are deleted', () => { @@ -415,7 +380,7 @@ describe('AdminRunnersApp', () => { it('count data is refetched', async () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); - findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' }); + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); }); @@ -423,7 +388,7 @@ describe('AdminRunnersApp', () => { it('toast is shown', async () => { expect(showToast).toHaveBeenCalledTimes(0); - findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' }); + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Runners deleted'); diff --git a/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js b/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js index 0ac89e82314..424a4e61ccd 100644 --- a/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js +++ b/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js @@ -5,11 +5,21 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createLocalState } from '~/runner/graphql/list/local_state'; -import { allRunnersData } from '../mock_data'; Vue.use(VueApollo); -jest.mock('~/flash'); +const makeRunner = (id, deleteRunner = true) => ({ + id, + userPermissions: { deleteRunner }, +}); + +// Multi-select checkbox possible states: +const stateToAttrs = { + unchecked: { disabled: undefined, checked: undefined, indeterminate: undefined }, + checked: { disabled: undefined, checked: 'true', indeterminate: undefined }, + indeterminate: { disabled: undefined, checked: undefined, indeterminate: 'true' }, + disabled: { disabled: 'true', checked: undefined, indeterminate: undefined }, +}; describe('RunnerBulkDeleteCheckbox', () => { let wrapper; @@ -18,12 +28,14 @@ describe('RunnerBulkDeleteCheckbox', () => { const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); - const mockRunners = allRunnersData.data.runners.nodes; - const mockIds = allRunnersData.data.runners.nodes.map(({ id }) => id); - const mockId = mockIds[0]; - const mockIdAnotherPage = 'RUNNER_IN_ANOTHER_PAGE_ID'; + const expectCheckboxToBe = (state) => { + const expected = stateToAttrs[state]; + expect(findCheckbox().attributes('disabled')).toBe(expected.disabled); + expect(findCheckbox().attributes('checked')).toBe(expected.checked); + expect(findCheckbox().attributes('indeterminate')).toBe(expected.indeterminate); + }; - const createComponent = ({ props = {} } = {}) => { + const createComponent = ({ runners = [] } = {}) => { const { cacheConfig, localMutations } = mockState; const apolloProvider = createMockApollo(undefined, undefined, cacheConfig); @@ -33,8 +45,7 @@ describe('RunnerBulkDeleteCheckbox', () => { localMutations, }, propsData: { - runners: mockRunners, - ...props, + runners, }, }); }; @@ -49,31 +60,61 @@ describe('RunnerBulkDeleteCheckbox', () => { jest.spyOn(mockState.localMutations, 'setRunnersChecked'); }); - describe.each` - case | is | checkedRunnerIds | disabled | checked | indeterminate - ${'no runners'} | ${'unchecked'} | ${[]} | ${undefined} | ${undefined} | ${undefined} - ${'no runners in this page'} | ${'unchecked'} | ${[mockIdAnotherPage]} | ${undefined} | ${undefined} | ${undefined} - ${'all runners'} | ${'checked'} | ${mockIds} | ${undefined} | ${'true'} | ${undefined} - ${'some runners'} | ${'indeterminate'} | ${[mockId]} | ${undefined} | ${undefined} | ${'true'} - ${'all plus other runners'} | ${'checked'} | ${[...mockIds, mockIdAnotherPage]} | ${undefined} | ${'true'} | ${undefined} - `('When $case are checked', ({ is, checkedRunnerIds, disabled, checked, indeterminate }) => { - beforeEach(async () => { + describe('when all runners can be deleted', () => { + const mockIds = ['1', '2', '3']; + const mockIdAnotherPage = '4'; + const mockRunners = mockIds.map((id) => makeRunner(id)); + + it.each` + case | checkedRunnerIds | state + ${'no runners'} | ${[]} | ${'unchecked'} + ${'no runners in this page'} | ${[mockIdAnotherPage]} | ${'unchecked'} + ${'all runners'} | ${mockIds} | ${'checked'} + ${'some runners'} | ${[mockIds[0]]} | ${'indeterminate'} + ${'all plus other runners'} | ${[...mockIds, mockIdAnotherPage]} | ${'checked'} + `('if $case are checked, checkbox is $state', ({ checkedRunnerIds, state }) => { mockCheckedRunnerIds = checkedRunnerIds; - createComponent(); + createComponent({ runners: mockRunners }); + expectCheckboxToBe(state); }); + }); + + describe('when some runners cannot be deleted', () => { + it('all allowed runners are selected, checkbox is checked', () => { + mockCheckedRunnerIds = ['a', 'b', 'c']; + createComponent({ + runners: [makeRunner('a'), makeRunner('b'), makeRunner('c', false)], + }); - it(`is ${is}`, () => { - expect(findCheckbox().attributes('disabled')).toBe(disabled); - expect(findCheckbox().attributes('checked')).toBe(checked); - expect(findCheckbox().attributes('indeterminate')).toBe(indeterminate); + expectCheckboxToBe('checked'); + }); + + it('some allowed runners are selected, checkbox is indeterminate', () => { + mockCheckedRunnerIds = ['a', 'b']; + createComponent({ + runners: [makeRunner('a'), makeRunner('b'), makeRunner('c')], + }); + + expectCheckboxToBe('indeterminate'); + }); + + it('no allowed runners are selected, checkbox is disabled', () => { + mockCheckedRunnerIds = ['a', 'b']; + createComponent({ + runners: [makeRunner('a', false), makeRunner('b', false)], + }); + + expectCheckboxToBe('disabled'); }); }); describe('When user selects', () => { + const mockRunners = [makeRunner('1'), makeRunner('2')]; + beforeEach(() => { - mockCheckedRunnerIds = mockIds; - createComponent(); + mockCheckedRunnerIds = ['1', '2']; + createComponent({ runners: mockRunners }); }); it.each([[true], [false]])('sets checked to %s', (checked) => { @@ -89,13 +130,11 @@ describe('RunnerBulkDeleteCheckbox', () => { describe('When runners are loading', () => { beforeEach(() => { - createComponent({ props: { runners: [] } }); + createComponent(); }); - it(`is disabled`, () => { - expect(findCheckbox().attributes('disabled')).toBe('true'); - expect(findCheckbox().attributes('checked')).toBe(undefined); - expect(findCheckbox().attributes('indeterminate')).toBe(undefined); + it('is disabled', () => { + expectCheckboxToBe('disabled'); }); }); }); diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js index f2281223a25..e6cc936e260 100644 --- a/spec/frontend/runner/components/runner_details_spec.js +++ b/spec/frontend/runner/components/runner_details_spec.js @@ -25,12 +25,7 @@ describe('RunnerDetails', () => { const findDetailGroups = () => wrapper.findComponent(RunnerGroups); - const createComponent = ({ - props = {}, - stubs, - mountFn = shallowMountExtended, - enforceRunnerTokenExpiresAt = false, - } = {}) => { + const createComponent = ({ props = {}, stubs, mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(RunnerDetails, { propsData: { ...props, @@ -39,9 +34,6 @@ describe('RunnerDetails', () => { RunnerDetail, ...stubs, }, - provide: { - glFeatures: { enforceRunnerTokenExpiresAt }, - }, }); }; @@ -82,7 +74,6 @@ describe('RunnerDetails', () => { ...runner, }, }, - enforceRunnerTokenExpiresAt: true, stubs: { GlIntersperse, GlSprintf, @@ -135,22 +126,5 @@ describe('RunnerDetails', () => { expect(findDetailGroups().props('runner')).toEqual(mockGroupRunner); }); }); - - describe('Token expiration field', () => { - it.each` - case | flag | shown - ${'is shown when feature flag is enabled'} | ${true} | ${true} - ${'is not shown when feature flag is disabled'} | ${false} | ${false} - `('$case', ({ flag, shown }) => { - createComponent({ - props: { - runner: mockGroupRunner, - }, - enforceRunnerTokenExpiresAt: flag, - }); - - expect(findDd('Token expiry', wrapper).exists()).toBe(shown); - }); - }); }); }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index f20644a9269..a31990f8f7e 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -5,9 +5,15 @@ import { shallowMountExtended, mountExtended, } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { createLocalState } from '~/runner/graphql/list/local_state'; + import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; +import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue'; + import { I18N_PROJECT_TYPE, I18N_STATUS_NEVER_CONTACTED } from '~/runner/constants'; import { allRunnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; @@ -16,6 +22,8 @@ const mockActiveRunnersCount = mockRunners.length; describe('RunnerList', () => { let wrapper; + let cacheConfig; + let localMutations; const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findTable = () => wrapper.findComponent(GlTableLite); @@ -23,18 +31,24 @@ describe('RunnerList', () => { const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]'); const findCell = ({ row = 0, fieldKey }) => extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); + const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); + const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox); const createComponent = ( { props = {}, provide = {}, ...options } = {}, mountFn = shallowMountExtended, ) => { + ({ cacheConfig, localMutations } = createLocalState()); + wrapper = mountFn(RunnerList, { + apolloProvider: createMockApollo([], {}, cacheConfig), propsData: { runners: mockRunners, activeRunnersCount: mockActiveRunnersCount, ...props, }, provide: { + localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, ...provide, @@ -126,21 +140,40 @@ describe('RunnerList', () => { ); }); + it('runner bulk delete is available', () => { + expect(findRunnerBulkDelete().props('runners')).toEqual(mockRunners); + }); + + it('runner bulk delete checkbox is available', () => { + expect(findRunnerBulkDeleteCheckbox().props('runners')).toEqual(mockRunners); + }); + it('Displays a checkbox field', () => { expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true); }); - it('Emits a checked event', async () => { - const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); + it('Sets a runner as checked', async () => { + const runner = mockRunners[0]; + const setRunnerCheckedMock = jest + .spyOn(localMutations, 'setRunnerChecked') + .mockImplementation(() => {}); + const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); await checkbox.setChecked(); - expect(wrapper.emitted('checked')).toHaveLength(1); - expect(wrapper.emitted('checked')[0][0]).toEqual({ + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); + expect(setRunnerCheckedMock).toHaveBeenCalledWith({ + runner, isChecked: true, - runner: mockRunners[0], }); }); + + it('Emits a deleted event', async () => { + const event = { message: 'Deleted!' }; + findRunnerBulkDelete().vm.$emit('deleted', event); + + expect(wrapper.emitted('deleted')).toEqual([[event]]); + }); }); describe('Scoped cell slots', () => { diff --git a/spec/frontend/runner/graphql/local_state_spec.js b/spec/frontend/runner/graphql/local_state_spec.js index ae874fef00d..915170b53f9 100644 --- a/spec/frontend/runner/graphql/local_state_spec.js +++ b/spec/frontend/runner/graphql/local_state_spec.js @@ -4,6 +4,13 @@ import { createLocalState } from '~/runner/graphql/list/local_state'; import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql'; import { RUNNER_TYPENAME } from '~/runner/constants'; +const makeRunner = (id, deleteRunner = true) => ({ + id, + userPermissions: { + deleteRunner, + }, +}); + describe('~/runner/graphql/list/local_state', () => { let localState; let apolloClient; @@ -57,16 +64,21 @@ describe('~/runner/graphql/list/local_state', () => { }); it('returns checked runners that have a reference in the cache', () => { - addMockRunnerToCache('a'); - localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true }); + const id = 'a'; + + addMockRunnerToCache(id); + localState.localMutations.setRunnerChecked({ + runner: makeRunner(id), + isChecked: true, + }); expect(queryCheckedRunnerIds()).toEqual(['a']); }); it('return checked runners that are not dangling references', () => { addMockRunnerToCache('a'); // 'b' is missing from the cache, perhaps because it was deleted - localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true }); - localState.localMutations.setRunnerChecked({ runner: { id: 'b' }, isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner('a'), isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner('b'), isChecked: true }); expect(queryCheckedRunnerIds()).toEqual(['a']); }); @@ -81,7 +93,7 @@ describe('~/runner/graphql/list/local_state', () => { beforeEach(() => { inputs.forEach(([id, isChecked]) => { addMockRunnerToCache(id); - localState.localMutations.setRunnerChecked({ runner: { id }, isChecked }); + localState.localMutations.setRunnerChecked({ runner: makeRunner(id), isChecked }); }); }); it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => { @@ -102,7 +114,7 @@ describe('~/runner/graphql/list/local_state', () => { ids.forEach(addMockRunnerToCache); localState.localMutations.setRunnersChecked({ - runners: ids.map((id) => ({ id })), + runners: ids.map((id) => makeRunner(id)), isChecked, }); }); @@ -117,7 +129,7 @@ describe('~/runner/graphql/list/local_state', () => { it('clears all checked items', () => { ['a', 'b', 'c'].forEach((id) => { addMockRunnerToCache(id); - localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner(id), isChecked: true }); }); expect(queryCheckedRunnerIds()).toEqual(['a', 'b', 'c']); @@ -127,4 +139,29 @@ describe('~/runner/graphql/list/local_state', () => { expect(queryCheckedRunnerIds()).toEqual([]); }); }); + + describe('when some runners cannot be deleted', () => { + beforeEach(() => { + addMockRunnerToCache('a'); + addMockRunnerToCache('b'); + }); + + it('setRunnerChecked does not check runner that cannot be deleted', () => { + localState.localMutations.setRunnerChecked({ + runner: makeRunner('a', false), + isChecked: true, + }); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + + it('setRunnersChecked does not check runner that cannot be deleted', () => { + localState.localMutations.setRunnersChecked({ + runners: [makeRunner('a', false), makeRunner('b', false)], + isChecked: true, + }); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); }); diff --git a/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js index cee1d436942..a3b67674c94 100644 --- a/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js +++ b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js @@ -101,6 +101,11 @@ describe('GroupRunnerShowApp', () => { Platform darwin Configuration Runs untagged jobs Maximum job timeout None + Token expiry + Runner authentication token expiration + Runner authentication tokens will expire based on a set interval. + They will automatically rotate once expired. Learn more + Never expires Tags None`.replace(/\s+/g, ' '); expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 5f355e27d9e..7482926e151 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -14,6 +14,7 @@ import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config'; +import { createLocalState } from '~/runner/graphql/list/local_state'; import RunnerStackedLayoutBanner from '~/runner/components/runner_stacked_layout_banner.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; @@ -101,13 +102,15 @@ describe('GroupRunnersApp', () => { mountFn = shallowMountExtended, ...options } = {}) => { + const { cacheConfig, localMutations } = createLocalState(); + const handlers = [ [groupRunnersQuery, mockGroupRunnersHandler], [groupRunnersCountQuery, mockGroupRunnersCountHandler], ]; wrapper = mountFn(GroupRunnersApp, { - apolloProvider: createMockApollo(handlers), + apolloProvider: createMockApollo(handlers, {}, cacheConfig), propsData: { registrationToken: mockRegistrationToken, groupFullPath: mockGroupFullPath, @@ -115,6 +118,7 @@ describe('GroupRunnersApp', () => { ...props, }, provide: { + localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, emptyStateSvgPath, @@ -260,7 +264,7 @@ describe('GroupRunnersApp', () => { const { id: graphqlId, shortSha } = node; const id = getIdFromGraphQLId(graphqlId); const COUNT_QUERIES = 6; // Smart queries that display a filtered count of runners - const FILTERED_COUNT_QUERIES = 3; // Smart queries that display a count of runners in tabs + const FILTERED_COUNT_QUERIES = 6; // Smart queries that display a count of runners in tabs and single stats beforeEach(async () => { await createComponent({ mountFn: mountExtended }); @@ -387,6 +391,11 @@ describe('GroupRunnersApp', () => { expect(findRunnerPagination().attributes('disabled')).toBe('true'); }); + it('runners cannot be deleted in bulk', () => { + createComponent(); + expect(findRunnerList().props('checkable')).toBe(false); + }); + describe('when no runners are found', () => { beforeEach(async () => { mockGroupRunnersHandler.mockResolvedValue({ diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 3660d00cf0b..96f1c5275b2 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -9,7 +9,6 @@ import { import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import workItemWeightSubscription from 'ee_component/work_items/graphql/work_item_weight.subscription.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; @@ -35,7 +34,6 @@ import { workItemDatesSubscriptionResponse, workItemResponseFactory, workItemTitleSubscriptionResponse, - workItemWeightSubscriptionResponse, workItemAssigneesSubscriptionResponse, } from '../mock_data'; @@ -57,7 +55,6 @@ describe('WorkItemDetail component', () => { const assigneesSubscriptionHandler = jest .fn() .mockResolvedValue(workItemAssigneesSubscriptionResponse); - const weightSubscriptionHandler = jest.fn().mockResolvedValue(workItemWeightSubscriptionResponse); const findAlert = () => wrapper.findComponent(GlAlert); const findEmptyState = () => wrapper.findComponent(GlEmptyState); @@ -95,10 +92,6 @@ describe('WorkItemDetail component', () => { confidentialityMock, ]; - if (IS_EE) { - handlers.push([workItemWeightSubscription, weightSubscriptionHandler]); - } - wrapper = shallowMount(WorkItemDetail, { apolloProvider: createMockApollo(handlers), propsData: { isModal, workItemId }, @@ -113,6 +106,12 @@ describe('WorkItemDetail component', () => { workItemsMvc2: workItemsMvc2Enabled, }, hasIssueWeightsFeature: true, + hasIterationsFeature: true, + projectNamespace: 'namespace', + }, + stubs: { + WorkItemWeight: true, + WorkItemIteration: true, }, }); }; diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index 434c1db8a2c..ab3ea623e3e 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -28,6 +28,7 @@ describe('WorkItemLinksForm', () => { listResponse = availableWorkItemsResponse, typesResponse = projectWorkItemTypesQueryResponse, parentConfidential = false, + hasIterationsFeature = false, } = {}) => { wrapper = shallowMountExtended(WorkItemLinksForm, { apolloProvider: createMockApollo([ @@ -39,6 +40,7 @@ describe('WorkItemLinksForm', () => { propsData: { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential }, provide: { projectPath: 'project/path', + hasIterationsFeature, }, }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 876aedff08b..6961996f912 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -5,7 +5,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import issueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; @@ -21,16 +21,29 @@ import { Vue.use(VueApollo); -const issueConfidentialityResponse = (confidential = false) => ({ +const issueDetailsResponse = (confidential = false) => ({ data: { workspace: { - id: '1', - __typename: 'Project', + id: 'gid://gitlab/Project/1', issuable: { - __typename: 'Issue', id: 'gid://gitlab/Issue/4', confidential, + iteration: { + id: 'gid://gitlab/Iteration/1124', + title: null, + startDate: '2022-06-22', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1124', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1101', + title: 'Quod voluptates quidem ea eaque eligendi ex corporis.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + }, + __typename: 'Issue', }, + __typename: 'Project', }, }, }); @@ -55,14 +68,15 @@ describe('WorkItemLinks', () => { data = {}, fetchHandler = jest.fn().mockResolvedValue(workItemHierarchyResponse), mutationHandler = mutationChangeParentHandler, - confidentialQueryHandler = jest.fn().mockResolvedValue(issueConfidentialityResponse()), + issueDetailsQueryHandler = jest.fn().mockResolvedValue(issueDetailsResponse()), + hasIterationsFeature = false, } = {}) => { mockApollo = createMockApollo( [ [getWorkItemLinksQuery, fetchHandler], [changeWorkItemParentMutation, mutationHandler], [workItemQuery, childWorkItemQueryHandler], - [issueConfidentialQuery, confidentialQueryHandler], + [issueDetailsQuery, issueDetailsQueryHandler], ], {}, { addTypename: true }, @@ -77,6 +91,7 @@ describe('WorkItemLinks', () => { provide: { projectPath: 'project/path', iid: '1', + hasIterationsFeature, }, propsData: { issuableId: 1 }, apolloProvider: mockApollo, @@ -266,7 +281,7 @@ describe('WorkItemLinks', () => { describe('when parent item is confidential', () => { it('passes correct confidentiality status to form', async () => { await createComponent({ - confidentialQueryHandler: jest.fn().mockResolvedValue(issueConfidentialityResponse(true)), + issueDetailsQueryHandler: jest.fn().mockResolvedValue(issueDetailsResponse(true)), }); findToggleAddFormButton().vm.$emit('click'); await nextTick(); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 66bee44ae49..a0ed4ed1425 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -186,6 +186,7 @@ export const workItemResponseFactory = ({ datesWidgetPresent = true, labelsWidgetPresent = true, weightWidgetPresent = true, + iterationWidgetPresent = true, confidential = false, canInviteMembers = false, allowsScopedLabels = false, @@ -264,6 +265,20 @@ export const workItemResponseFactory = ({ weight: 0, } : { type: 'MOCK TYPE' }, + iterationWidgetPresent + ? { + __typename: 'WorkItemWidgetIteration', + type: 'ITERATION', + iteration: { + description: null, + id: 'gid://gitlab/Iteration/1215', + iid: '182', + title: 'Iteration default title', + startDate: '2022-09-22', + dueDate: '2022-09-30', + }, + } + : { type: 'MOCK TYPE' }, { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', @@ -520,6 +535,27 @@ export const workItemLabelsSubscriptionResponse = { }, }; +export const workItemIterationSubscriptionResponse = { + data: { + issuableIterationUpdated: { + id: 'gid://gitlab/WorkItem/1', + widgets: [ + { + __typename: 'WorkItemWidgetIteration', + iteration: { + description: 'Iteration description', + dueDate: '2022-07-29', + id: 'gid://gitlab/Iteration/1125', + iid: '95', + startDate: '2022-06-22', + title: 'Iteration subcription title', + }, + }, + ], + }, + }, +}; + export const workItemHierarchyEmptyResponse = { data: { workItem: { @@ -944,3 +980,82 @@ export const projectLabelsResponse = { }, }, }; + +export const mockIterationWidgetResponse = { + description: 'Iteration description', + dueDate: '2022-07-19', + id: 'gid://gitlab/Iteration/1124', + iid: '91', + startDate: '2022-06-22', + title: 'Iteration title widget', +}; + +export const groupIterationsResponse = { + data: { + workspace: { + id: 'gid://gitlab/Group/22', + attributes: { + nodes: [ + { + id: 'gid://gitlab/Iteration/1124', + title: null, + startDate: '2022-06-22', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1124', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1101', + title: 'Quod voluptates quidem ea eaque eligendi ex corporis.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + state: 'current', + }, + { + id: 'gid://gitlab/Iteration/1185', + title: null, + startDate: '2022-07-06', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1185', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1144', + title: 'Quo velit perspiciatis saepe aut omnis voluptas ab eos.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + state: 'current', + }, + { + id: 'gid://gitlab/Iteration/1194', + title: null, + startDate: '2022-07-06', + dueDate: '2022-07-19', + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1194', + iterationCadence: { + id: 'gid://gitlab/Iterations::Cadence/1152', + title: + 'Minima aut consequatur magnam vero doloremque accusamus maxime repellat voluptatem qui.', + __typename: 'IterationCadence', + }, + __typename: 'Iteration', + state: 'current', + }, + ], + __typename: 'IterationConnection', + }, + __typename: 'Group', + }, + }, +}; + +export const groupIterationsResponseWithNoIterations = { + data: { + workspace: { + id: 'gid://gitlab/Group/22', + attributes: { + nodes: [], + __typename: 'IterationConnection', + }, + __typename: 'Group', + }, + }, +}; diff --git a/spec/lib/gitlab/ci/config/entry/workflow_spec.rb b/spec/lib/gitlab/ci/config/entry/workflow_spec.rb index 3d19832e13d..97ac199f47d 100644 --- a/spec/lib/gitlab/ci/config/entry/workflow_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/workflow_spec.rb @@ -65,6 +65,54 @@ RSpec.describe Gitlab::Ci::Config::Entry::Workflow do end end end + + context 'with workflow name' do + let(:factory) { Gitlab::Config::Entry::Factory.new(described_class).value(workflow_hash) } + + context 'with a blank name' do + let(:workflow_hash) do + { name: '' } + end + + it 'is invalid' do + expect(config).not_to be_valid + end + + it 'returns error about invalid name' do + expect(config.errors).to include('workflow name is too short (minimum is 1 character)') + end + end + + context 'with too long name' do + let(:workflow_hash) do + { name: 'a' * 256 } + end + + it 'is invalid' do + expect(config).not_to be_valid + end + + it 'returns error about invalid name' do + expect(config.errors).to include('workflow name is too long (maximum is 255 characters)') + end + end + + context 'when name is nil' do + let(:workflow_hash) { { name: nil } } + + it 'is valid' do + expect(config).to be_valid + end + end + + context 'when name is not provided' do + let(:workflow_hash) { { rules: [{ if: '$VAR' }] } } + + it 'is valid' do + expect(config).to be_valid + end + end + end end end diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb index 055114769ea..475503de7da 100644 --- a/spec/lib/gitlab/ci/config_spec.rb +++ b/spec/lib/gitlab/ci/config_spec.rb @@ -889,4 +889,31 @@ RSpec.describe Gitlab::Ci::Config do it { is_expected.to eq([{ if: '$CI_COMMIT_REF_NAME == "master"' }]) } end + + describe '#workflow_name' do + subject(:workflow_name) { config.workflow_name } + + let(:yml) do + <<-EOS + workflow: + name: 'Pipeline name' + + rspec: + script: exit 0 + EOS + end + + it { is_expected.to eq('Pipeline name') } + + context 'with no name' do + let(:yml) do + <<-EOS + rspec: + script: exit 0 + EOS + end + + it { is_expected.to be_nil } + end + end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb index 62de4d2e96d..51d1661b586 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb @@ -236,4 +236,47 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Populate do end end end + + context 'with pipeline name' do + let(:config) do + { workflow: { name: ' Pipeline name ' }, rspec: { script: 'rspec' } } + end + + context 'with feature flag disabled' do + before do + stub_feature_flags(pipeline_name: false) + end + + it 'does not build pipeline_metadata' do + run_chain + + expect(pipeline.pipeline_metadata).to be_nil + end + end + + context 'with feature flag enabled' do + before do + stub_feature_flags(pipeline_name: true) + end + + it 'builds pipeline_metadata' do + run_chain + + expect(pipeline.pipeline_metadata.title).to eq('Pipeline name') + expect(pipeline.pipeline_metadata.project).to eq(pipeline.project) + end + + context 'with empty name' do + let(:config) do + { workflow: { name: ' ' }, rspec: { script: 'rspec' } } + end + + it 'strips whitespace from name' do + run_chain + + expect(pipeline.pipeline_metadata).to be_nil + end + end + end + end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 398f8b16f95..ebf8422489e 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -514,6 +514,35 @@ module Gitlab expect(subject.root_variables).to eq([]) end end + + context 'with name' do + let(:config) do + <<-EOYML + workflow: + name: 'Pipeline name' + + hello: + script: echo world + EOYML + end + + it 'parses the workflow:name as workflow_name' do + expect(subject.workflow_name).to eq('Pipeline name') + end + end + + context 'with no name' do + let(:config) do + <<-EOYML + hello: + script: echo world + EOYML + end + + it 'parses the workflow:name' do + expect(subject.workflow_name).to be_nil + end + end end describe '#warnings' do diff --git a/spec/lib/gitlab/profiler_spec.rb b/spec/lib/gitlab/profiler_spec.rb index bfe1a588489..7c365990627 100644 --- a/spec/lib/gitlab/profiler_spec.rb +++ b/spec/lib/gitlab/profiler_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Gitlab::Profiler do end it 'returns a profile result' do - expect(described_class.profile('/')).to be_an_instance_of(RubyProf::Profile) + expect(described_class.profile('/')).to be_an_instance_of(File) end it 'uses the custom logger given' do @@ -59,28 +59,26 @@ RSpec.describe Gitlab::Profiler do described_class.profile('/', user: user, private_token: private_token) end - context 'with sampling profiler' do - it 'generates sampling data' do - user = double(:user) - temp_data = Tempfile.new + it 'generates sampling data' do + user = double(:user) + temp_data = Tempfile.new - expect(described_class).to receive(:with_user).with(user).and_call_original - described_class.profile('/', user: user, sampling_mode: true, profiler_options: { out: temp_data.path }) + expect(described_class).to receive(:with_user).with(user).and_call_original + described_class.profile('/', user: user, profiler_options: { out: temp_data.path }) - expect(File.stat(temp_data).size).to be > 0 - File.unlink(temp_data) - end + expect(File.stat(temp_data).size).to be > 0 + File.unlink(temp_data) + end - it 'saves sampling data with a randomly-generated filename' do - user = double(:user) + it 'saves sampling data with a randomly-generated filename' do + user = double(:user) - expect(described_class).to receive(:with_user).with(user).and_call_original - result = described_class.profile('/', user: user, sampling_mode: true) + expect(described_class).to receive(:with_user).with(user).and_call_original + result = described_class.profile('/', user: user) - expect(result).to be_a(File) - expect(File.stat(result.path).size).to be > 0 - File.unlink(result.path) - end + expect(result).to be_a(File) + expect(File.stat(result.path).size).to be > 0 + File.unlink(result.path) end end @@ -211,54 +209,4 @@ RSpec.describe Gitlab::Profiler do expect(described_class.log_load_times_by_model(null_logger)).to be_nil end end - - describe '.print_by_total_time' do - let(:stdout) { StringIO.new } - let(:regexp) { /^\s+\d+\.\d+\s+(\d+\.\d+)/ } - - let(:output) do - stdout.rewind - stdout.read - end - - let_it_be(:result) do - Thread.new { sleep 1 } - - RubyProf.profile do - sleep 0.1 - 1.to_s - end - end - - around do |example| - original_stdout = $stdout - - $stdout = stdout # rubocop: disable RSpec/ExpectOutput - example.run - $stdout = original_stdout # rubocop: disable RSpec/ExpectOutput - end - - it 'prints a profile result sorted by total time' do - described_class.print_by_total_time(result) - - expect(output).to include('Kernel#sleep') - - thread_profiles = output.split('Sort by: total_time').select { |x| x =~ regexp } - - thread_profiles.each do |profile| - total_times = - profile - .scan(regexp) - .map { |(total)| total.to_f } - - expect(total_times).to eq(total_times.sort.reverse) - end - end - - it 'accepts a max_percent option' do - described_class.print_by_total_time(result, max_percent: 50) - - expect(output).not_to include('Kernel#sleep') - end - end end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index d854943e277..751a303739c 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2983,6 +2983,24 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + it 'logs the event' do + allow(Gitlab::AppJsonLogger).to receive(:info) + + pipeline.cancel_running + + expect(Gitlab::AppJsonLogger) + .to have_received(:info) + .with( + a_hash_including( + event: 'pipeline_cancel_running', + pipeline_id: pipeline.id, + auto_canceled_by_pipeline_id: nil, + cascade_to_children: true, + execute_async: true + ) + ) + end + context 'when there is a running external job and a regular job' do before do create(:ci_build, :running, pipeline: pipeline) diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb index 3f6bbe795cc..e8db83b7144 100644 --- a/spec/models/concerns/token_authenticatable_spec.rb +++ b/spec/models/concerns/token_authenticatable_spec.rb @@ -314,52 +314,22 @@ RSpec.describe Ci::Runner, 'TokenAuthenticatable', :freeze_time do describe '#token_expired?' do subject { runner.token_expired? } - context 'when enforce_runner_token_expires_at feature flag is disabled' do - before do - stub_feature_flags(enforce_runner_token_expires_at: false) - end - - context 'when runner has no token expiration' do - let(:runner) { non_expirable_runner } - - it { is_expected.to eq(false) } - end - - context 'when runner token is not expired' do - let(:runner) { non_expired_runner } + context 'when runner has no token expiration' do + let(:runner) { non_expirable_runner } - it { is_expected.to eq(false) } - end - - context 'when runner token is expired' do - let(:runner) { expired_runner } - - it { is_expected.to eq(false) } - end + it { is_expected.to eq(false) } end - context 'when enforce_runner_token_expires_at feature flag is enabled' do - before do - stub_feature_flags(enforce_runner_token_expires_at: true) - end - - context 'when runner has no token expiration' do - let(:runner) { non_expirable_runner } - - it { is_expected.to eq(false) } - end + context 'when runner token is not expired' do + let(:runner) { non_expired_runner } - context 'when runner token is not expired' do - let(:runner) { non_expired_runner } - - it { is_expected.to eq(false) } - end + it { is_expected.to eq(false) } + end - context 'when runner token is expired' do - let(:runner) { expired_runner } + context 'when runner token is expired' do + let(:runner) { expired_runner } - it { is_expected.to eq(true) } - end + it { is_expected.to eq(true) } end end @@ -386,52 +356,22 @@ RSpec.describe Ci::Runner, 'TokenAuthenticatable', :freeze_time do describe '.find_by_token' do subject { Ci::Runner.find_by_token(runner.token) } - context 'when enforce_runner_token_expires_at feature flag is disabled' do - before do - stub_feature_flags(enforce_runner_token_expires_at: false) - end - - context 'when runner has no token expiration' do - let(:runner) { non_expirable_runner } - - it { is_expected.to eq(non_expirable_runner) } - end - - context 'when runner token is not expired' do - let(:runner) { non_expired_runner } - - it { is_expected.to eq(non_expired_runner) } - end - - context 'when runner token is expired' do - let(:runner) { expired_runner } + context 'when runner has no token expiration' do + let(:runner) { non_expirable_runner } - it { is_expected.to eq(expired_runner) } - end + it { is_expected.to eq(non_expirable_runner) } end - context 'when enforce_runner_token_expires_at feature flag is enabled' do - before do - stub_feature_flags(enforce_runner_token_expires_at: true) - end - - context 'when runner has no token expiration' do - let(:runner) { non_expirable_runner } - - it { is_expected.to eq(non_expirable_runner) } - end - - context 'when runner token is not expired' do - let(:runner) { non_expired_runner } + context 'when runner token is not expired' do + let(:runner) { non_expired_runner } - it { is_expected.to eq(non_expired_runner) } - end + it { is_expected.to eq(non_expired_runner) } + end - context 'when runner token is expired' do - let(:runner) { expired_runner } + context 'when runner token is expired' do + let(:runner) { expired_runner } - it { is_expected.to be_nil } - end + it { is_expected.to be_nil } end end end diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index 93c47422f37..6419a9fc9d9 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -316,7 +316,7 @@ RSpec.describe WebHook do end it 'is twice the initial value' do - expect(hook.next_backoff).to eq(20.minutes) + expect(hook.next_backoff).to eq(2 * described_class::INITIAL_BACKOFF) end end @@ -326,7 +326,7 @@ RSpec.describe WebHook do end it 'grows exponentially' do - expect(hook.next_backoff).to eq(80.minutes) + expect(hook.next_backoff).to eq(2 * 2 * 2 * described_class::INITIAL_BACKOFF) end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 39da84ed20a..d593e369d27 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1044,6 +1044,14 @@ RSpec.describe API::MergeRequests do it_behaves_like 'a non-cached MergeRequest api request', 1 end + context 'when the label changes' do + before do + merge_request.labels << create(:label, project: merge_request.project) + end + + it_behaves_like 'a non-cached MergeRequest api request', 1 + end + context 'when the assignees change' do before do merge_request.assignees << create(:user) diff --git a/spec/views/projects/hooks/edit.html.haml_spec.rb b/spec/views/projects/hooks/edit.html.haml_spec.rb index c4ec2149794..2a95656645e 100644 --- a/spec/views/projects/hooks/edit.html.haml_spec.rb +++ b/spec/views/projects/hooks/edit.html.haml_spec.rb @@ -27,7 +27,7 @@ RSpec.describe 'projects/hooks/edit' do it 'renders alert' do render - expect(rendered).to have_text(s_('Webhooks|Webhook was automatically disabled')) + expect(rendered).to have_text(s_('Webhooks|Webhook rate limit has been reached')) end end diff --git a/yarn.lock b/yarn.lock index bb1811bb625..3e6bb447351 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4913,6 +4913,11 @@ domhandler@^4.0.0, domhandler@^4.2.0: dependencies: domelementtype "^2.2.0" +dommatrix@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dommatrix/-/dommatrix-1.0.3.tgz#e7c18e8d6f3abdd1fef3dd4aa74c4d2e620a0525" + integrity sha512-l32Xp/TLgWb8ReqbVJAFIvXmY7go4nTxxlWiAFyhoQw9RKEOHBZNnyGvJWqDVSPmq3Y9HlM4npqF/T6VMOXhww== + dompurify@2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.8.tgz#224fe9ae57d7ebd9a1ae1ac18c1c1ca3f532226f" @@ -9182,11 +9187,6 @@ node-domexception@1.0.0: resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -node-ensure@^0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/node-ensure/-/node-ensure-0.0.0.tgz#ecae764150de99861ec5c810fd5d096b183932a7" - integrity sha1-7K52QVDemYYexcgQ/V0Jaxg5Mqc= - node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -9737,13 +9737,13 @@ pbkdf2@^3.0.3: safe-buffer "^5.0.1" sha.js "^2.4.8" -pdfjs-dist@^2.0.943: - version "2.1.266" - resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-2.1.266.tgz#cded02268b389559e807f410d2a729db62160026" - integrity sha512-Jy7o1wE3NezPxozexSbq4ltuLT0Z21ew/qrEiAEeUZzHxMHGk4DUV1D7RuCXg5vJDvHmjX1YssN+we9QfRRgXQ== +pdfjs-dist@^2.16.105: + version "2.16.105" + resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-2.16.105.tgz#937b9c4a918f03f3979c88209d84c1ce90122c2a" + integrity sha512-J4dn41spsAwUxCpEoVf6GVoz908IAA3mYiLmNxg8J9kfRXc2jxpbUepcP0ocp0alVNLFthTAM8DZ1RaHh8sU0A== dependencies: - node-ensure "^0.0.0" - worker-loader "^2.0.0" + dommatrix "^1.0.3" + web-streams-polyfill "^3.2.1" picocolors@^0.2.1: version "0.2.1" @@ -10666,9 +10666,9 @@ saxes@^5.0.1: xmlchars "^2.2.0" schema-utils@^0.4.0: - version "0.4.5" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.5.tgz#21836f0608aac17b78f9e3e24daff14a5ca13a3e" - integrity sha512-yYrjb9TX2k/J1Y5UNy3KYdZq10xhYcF8nMpAW6o3hy6Q8WSIEf9lJHG/ePnOBfziPM3fvQwfOwa13U/Fh8qTfA== + version "0.4.7" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.7.tgz#ba74f597d2be2ea880131746ee17d0a093c68187" + integrity sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ== dependencies: ajv "^6.1.0" ajv-keywords "^3.1.0" @@ -11725,7 +11725,7 @@ tsconfig-paths@^3.14.1: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@2.3.0, tslib@~2.3.0: +tslib@2.3.0, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== @@ -11735,7 +11735,7 @@ tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.4.0: +tslib@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== @@ -12349,10 +12349,10 @@ web-streams-polyfill@4.0.0-beta.1: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.1.tgz#3b19b9817374b7cee06d374ba7eeb3aeb80e8c95" integrity sha512-3ux37gEX670UUphBF9AMCq8XM6iQ8Ac6A+DSRRjDoRBm1ufCkaCDdNVbaqq60PsEkdNlLKrGtv/YBP4EJXqNtQ== -web-streams-polyfill@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz#a6b74026b38e4885869fb5c589e90b95ccfc7965" - integrity sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA== +web-streams-polyfill@^3.2.0, web-streams-polyfill@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" + integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== web-vitals@^0.2.4: version "0.2.4" |