diff options
47 files changed, 719 insertions, 527 deletions
diff --git a/.vale.ini b/.vale.ini index 89a669ec7ff..13b198b9148 100644 --- a/.vale.ini +++ b/.vale.ini @@ -1,40 +1,9 @@ -# Vale configuration file, taken from https://errata-ai.github.io/vale/config/ +# Vale configuration file. +# +# For more information, see https://errata-ai.gitbook.io/vale/getting-started/configuration. -# The relative path to the folder containing linting rules (styles) -# ----------------------------------------------------------------- -StylesPath = doc/.linting/vale/styles - -# Minimum alert level -# ------------------- -# The minimum alert level to display (suggestion, warning, or error). -# If integrated into CI, builds fail by default on error-level alerts, -# unless you execute Vale with the --no-exit flag +StylesPath = doc/.vale MinAlertLevel = suggestion -# Should Vale parse any file formats other than .md files as Markdown? -# -------------------------------------------------------------------- -[formats] -mdx = md - -# What file types should Vale test? -# ---------------------------------- [*.md] - -# Styles to load -# -------------- -# What styles, located in the StylesPath folder, should Vale load? -# Vale also currently includes write-good, proselint, joblint, and vale BasedOnStyles = gitlab - -# Enabling or disabling specific rules in a style -# ----------------------------------------------- -# To disable a rule in an enabled style, use the following format: -# {style}.{filename} = NO -# To enable a single rule in a disabled style, use the following format: -# vale.Editorializing = YES - -# Altering the severity of a rule in a style -# ------------------------------------------ -# To change the reporting level (suggestion, warning, error) of a rule, -# use the following format: {style}.{filename} = {level} -# vale.Hedging = error diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index dc6ea148047..022d79ecf49 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,5 +1,3 @@ -import $ from 'jquery'; -import _ from 'underscore'; import axios from './lib/utils/axios_utils'; import { joinPaths } from './lib/utils/url_utility'; import flash from '~/flash'; @@ -70,7 +68,7 @@ const Api = { }, // Return groups list. Filtered by query - groups(query, options, callback = $.noop) { + groups(query, options, callback = () => {}) { const url = Api.buildUrl(Api.groupsPath); return axios .get(url, { @@ -108,7 +106,7 @@ const Api = { }, // Return projects list. Filtered by query - projects(query, options, callback = _.noop) { + projects(query, options, callback = () => {}) { const url = Api.buildUrl(Api.projectsPath); const defaults = { search: query, diff --git a/app/assets/javascripts/environments/components/enable_review_app_button.vue b/app/assets/javascripts/environments/components/enable_review_app_button.vue index 2f9e9cb628f..8fbbc5189bf 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_button.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_button.vue @@ -26,15 +26,17 @@ export default { modalInfo: { closeText: s__('EnableReviewApp|Close'), copyToClipboardText: s__('EnableReviewApp|Copy snippet text'), - copyString: `deploy_review + copyString: `deploy_review: stage: deploy script: - echo "Deploy a review app" environment: name: review/$CI_COMMIT_REF_NAME url: https://$CI_ENVIRONMENT_SLUG.example.com - only: branches - except: master`, + only: + - branches + except: + - master`, id: 'enable-review-app-info', title: s__('ReviewApp|Enable Review App'), }, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 2e273d45506..a15e22d4742 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -94,7 +94,7 @@ export default { data-boundary="viewport" @click="openDiscardModal" > - <icon :size="16" name="remove-all" class="ml-auto mr-auto" /> + <icon :size="16" name="remove-all" class="ml-auto mr-auto position-top-0" /> </button> </div> </div> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index b61d0a47795..3a63fc32639 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -59,7 +59,7 @@ export default { <gl-loading-icon v-if="showLoadingIcon" :size="2" class="prepend-top-default" /> <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> - <ci-icon :status="latestPipeline.details.status" :size="24" /> + <ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" /> <span class="prepend-left-8"> <strong> {{ __('Pipeline') }} </strong> <a @@ -76,6 +76,7 @@ export default { :help-page-path="links.ciHelpPagePath" :empty-state-svg-path="pipelinesEmptyStateSvgPath" :can-set-ci="true" + class="mb-auto mt-auto" /> <div v-else-if="latestPipeline.yamlError" class="bs-callout bs-callout-danger"> <p class="append-bottom-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p> diff --git a/app/assets/javascripts/lib/utils/icon_utils.js b/app/assets/javascripts/lib/utils/icon_utils.js index 7b8dd9bbef7..97ee773358d 100644 --- a/app/assets/javascripts/lib/utils/icon_utils.js +++ b/app/assets/javascripts/lib/utils/icon_utils.js @@ -1,18 +1,40 @@ -/* eslint-disable import/prefer-default-export */ - +import { memoize } from 'lodash'; import axios from '~/lib/utils/axios_utils'; /** - * Retrieve SVG icon path content from gitlab/svg sprite icons - * @param {String} name + * Resolves to a DOM that contains GitLab icons + * in svg format. Memoized to avoid duplicate requests */ -export const getSvgIconPathContent = name => +const getSvgDom = memoize(() => axios .get(gon.sprite_icons) - .then(({ data: svgs }) => - new DOMParser() - .parseFromString(svgs, 'text/xml') - .querySelector(`#${name} path`) - .getAttribute('d'), - ) + .then(({ data: svgs }) => new DOMParser().parseFromString(svgs, 'text/xml')) + .catch(() => { + getSvgDom.cache.clear(); + }), +); + +/** + * Clears the memoized SVG content. + * + * You probably don't need to invoke this function unless + * sprite_icons are updated. + */ +export const clearSvgIconPathContentCache = () => { + getSvgDom.cache.clear(); +}; + +/** + * Retrieve SVG icon path content from gitlab/svg sprite icons. + * + * Content loaded is cached. + * + * @param {String} name - Icon name + * @returns A promise that resolves to the svg path + */ +export const getSvgIconPathContent = name => + getSvgDom() + .then(doc => { + return doc.querySelector(`#${name} path`).getAttribute('d'); + }) .catch(() => null); diff --git a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss new file mode 100644 index 00000000000..c47901dc177 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss @@ -0,0 +1,146 @@ + +// stylelint-disable selector-class-pattern +// stylelint-disable selector-max-compound-selectors +// stylelint-disable stylelint-gitlab/duplicate-selectors +// stylelint-disable stylelint-gitlab/utility-classes + +.blob-editor-container { + flex: 1; + height: 0; + display: flex; + flex-direction: column; + justify-content: center; + + .vertical-center { + min-height: auto; + } + + .monaco-editor .lines-content .cigr { + display: none; + } + + .monaco-editor .selected-text { + z-index: 1; + } + + .monaco-editor .view-lines { + z-index: 2; + } + + .is-readonly, + .editor.original { + .view-lines { + cursor: default; + } + + .cursors-layer { + display: none; + } + } + + .is-deleted { + .editor.modified { + .margin-view-overlays, + .lines-content, + .decorationsOverviewRuler { + // !important to override monaco inline styles + display: none !important; + } + } + + .diffOverviewRuler.modified { + // !important to override monaco inline styles + display: none !important; + } + } + + .is-added { + .editor.original { + .margin-view-overlays, + .lines-content, + .decorationsOverviewRuler { + // !important to override monaco inline styles + display: none !important; + } + } + + .diffOverviewRuler.original { + // !important to override monaco inline styles + display: none !important; + } + } + + .monaco-diff-editor.vs { + .editor.modified { + box-shadow: none; + } + + .diagonal-fill { + display: none !important; + } + + .diffOverview { + background-color: $white-light; + border-left: 1px solid $white-dark; + cursor: ns-resize; + } + + .diffViewport { + display: none; + } + + .char-insert { + background-color: $line-added-dark; + } + + .char-delete { + background-color: $line-removed-dark; + } + + .line-numbers { + color: $black-transparent; + } + + .view-overlays { + .line-insert { + background-color: $line-added; + } + + .line-delete { + background-color: $line-removed; + } + } + + .margin { + background-color: $white-light; + border-right: 1px solid $gray-100; + + .line-insert { + border-right: 1px solid $line-added-dark; + } + + .line-delete { + border-right: 1px solid $line-removed-dark; + } + } + + .margin-view-overlays .insert-sign, + .margin-view-overlays .delete-sign { + opacity: 0.4; + } + } +} + +.multi-file-editor-holder { + height: 100%; + min-height: 0; // firefox fix + + &.is-readonly .vs, + .vs .editor.original { + .monaco-editor, + .monaco-editor-background, + .monaco-editor .inputarea.ime-input { + background-color: $gray-50; + } + } +} diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index a748c669ee8..c37f75d1533 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -1,6 +1,7 @@ @import 'framework/variables'; @import 'framework/mixins'; @import './ide_mixins'; +@import './ide_monaco_overrides'; $search-list-icon-width: 18px; $ide-activity-bar-width: 60px; @@ -16,11 +17,6 @@ $ide-commit-header-height: 48px; display: inline-block; } -.fade-enter, -.fade-leave-to { - opacity: 0; -} - .commit-message { @include str-truncated(250px); } @@ -49,10 +45,6 @@ $ide-commit-header-height: 48px; flex-direction: column; flex: 1; min-height: 0; // firefox fix - - a { - color: $gl-text-color; - } } .multi-file-loading-container { @@ -160,157 +152,6 @@ $ide-commit-header-height: 48px; height: 0; } -// stylelint-disable selector-class-pattern -// stylelint-disable selector-max-compound-selectors -// stylelint-disable stylelint-gitlab/duplicate-selectors -// stylelint-disable stylelint-gitlab/utility-classes - -.blob-editor-container { - flex: 1; - height: 0; - display: flex; - flex-direction: column; - justify-content: center; - - .vertical-center { - min-height: auto; - } - - .monaco-editor .lines-content .cigr { - display: none; - } - - .monaco-editor .selected-text { - z-index: 1; - } - - .monaco-editor .view-lines { - z-index: 2; - } - - .is-readonly, - .editor.original { - .view-lines { - cursor: default; - } - - .cursors-layer { - display: none; - } - } - - .is-deleted { - .editor.modified { - .margin-view-overlays, - .lines-content, - .decorationsOverviewRuler { - // !important to override monaco inline styles - display: none !important; - } - } - - .diffOverviewRuler.modified { - // !important to override monaco inline styles - display: none !important; - } - } - - .is-added { - .editor.original { - .margin-view-overlays, - .lines-content, - .decorationsOverviewRuler { - // !important to override monaco inline styles - display: none !important; - } - } - - .diffOverviewRuler.original { - // !important to override monaco inline styles - display: none !important; - } - } - - .monaco-diff-editor.vs { - .editor.modified { - box-shadow: none; - } - - .diagonal-fill { - display: none !important; - } - - .diffOverview { - background-color: $white-light; - border-left: 1px solid $white-dark; - cursor: ns-resize; - } - - .diffViewport { - display: none; - } - - .char-insert { - background-color: $line-added-dark; - } - - .char-delete { - background-color: $line-removed-dark; - } - - .line-numbers { - color: $black-transparent; - } - - .view-overlays { - .line-insert { - background-color: $line-added; - } - - .line-delete { - background-color: $line-removed; - } - } - - .margin { - background-color: $white-light; - border-right: 1px solid $gray-100; - - .line-insert { - border-right: 1px solid $line-added-dark; - } - - .line-delete { - border-right: 1px solid $line-removed-dark; - } - } - - .margin-view-overlays .insert-sign, - .margin-view-overlays .delete-sign { - opacity: 0.4; - } - } -} - -.multi-file-editor-holder { - height: 100%; - min-height: 0; // firefox fix - - &.is-readonly .vs, - .vs .editor.original { - .monaco-editor, - .monaco-editor-background, - .monaco-editor .inputarea.ime-input { - background-color: $gray-50; - } - } -} - -// stylelint-enable selector-class-pattern -// stylelint-enable selector-max-compound-selectors -// stylelint-enable stylelint-gitlab/duplicate-selectors -// stylelint-enable stylelint-gitlab/utility-classes - .preview-container { flex-grow: 1; position: relative; @@ -671,10 +512,6 @@ $ide-commit-header-height: 48px; width: $ide-commit-row-height; height: $ide-commit-row-height; color: inherit; - - > svg { - top: 0; - } } .ide-commit-file-count { @@ -864,39 +701,39 @@ $ide-commit-header-height: 48px; margin-left: auto; } - .ide-nav-dropdown { - width: 100%; - margin-bottom: 12px; + button { + color: $gl-text-color; + } +} - .dropdown-menu { - width: 385px; - max-height: initial; - } +.ide-nav-dropdown { + width: 100%; + margin-bottom: 12px; - .dropdown-menu-toggle { - svg { - vertical-align: middle; - color: $gray-700; + .dropdown-menu { + width: 385px; + max-height: initial; + } - &:hover { - color: $gray-700; - } - } + .dropdown-menu-toggle { + svg { + vertical-align: middle; + color: $gray-700; &:hover { - background-color: $white-normal; + color: $gray-700; } } - &.show { - .dropdown-menu-toggle { - background-color: $white-dark; - } + &:hover { + background-color: $white-normal; } } - button { - color: $gl-text-color; + &.show { + .dropdown-menu-toggle { + background-color: $white-dark; + } } } @@ -945,6 +782,8 @@ $ide-commit-header-height: 48px; transform: translateY(0); } +.fade-enter, +.fade-leave-to, .commit-form-slide-up-enter, .commit-form-slide-up-leave-to { opacity: 0; @@ -1063,9 +902,6 @@ $ide-commit-header-height: 48px; @include ide-trace-view(); .empty-state { - margin-top: auto; - margin-bottom: auto; - p { margin: $grid-size 0; text-align: center; @@ -1092,10 +928,6 @@ $ide-commit-header-height: 48px; min-height: 55px; padding-left: $gl-padding; padding-right: $gl-padding; - - .ci-status-icon { - display: flex; - } } .ide-job-item { @@ -1135,7 +967,7 @@ $ide-commit-header-height: 48px; } .ide-nav-form { - .nav-links li { + li { width: 50%; padding-left: 0; padding-right: 0; @@ -1222,10 +1054,6 @@ $ide-commit-header-height: 48px; background-color: $blue-500; outline: 0; } - - svg { - fill: currentColor; - } } .ide-new-btn { diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 8a0f44b4e93..1b9f5971f73 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -241,6 +241,10 @@ module SystemNoteService def zoom_link_removed(issue, project, author) ::SystemNotes::ZoomService.new(noteable: issue, project: project, author: author).zoom_link_removed end + + def auto_resolve_prometheus_alert(noteable, project, author) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).auto_resolve_prometheus_alert + end end SystemNoteService.prepend_if_ee('EE::SystemNoteService') diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index b555760f88e..275c64bea89 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -288,6 +288,12 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'closed')) end + def auto_resolve_prometheus_alert + body = 'automatically closed this issue because the alert resolved.' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'closed')) + end + private def cross_reference_note_content(gfm_reference) diff --git a/changelogs/unreleased/stop_environments.yml b/changelogs/unreleased/stop_environments.yml new file mode 100644 index 00000000000..ea92be202af --- /dev/null +++ b/changelogs/unreleased/stop_environments.yml @@ -0,0 +1,5 @@ +--- +title: 'Fixes stop_review job upon expired artifacts from previous stages' +merge_request: 27258 +author: Jack Lei +type: fixed diff --git a/doc/.linting/vale/styles/gitlab/Contractions.yml b/doc/.vale/gitlab/Contractions.yml index 5f389bd1ea4..5f389bd1ea4 100644 --- a/doc/.linting/vale/styles/gitlab/Contractions.yml +++ b/doc/.vale/gitlab/Contractions.yml diff --git a/doc/.linting/vale/styles/gitlab/FirstPerson.yml b/doc/.vale/gitlab/FirstPerson.yml index 18c5265b0a6..18c5265b0a6 100644 --- a/doc/.linting/vale/styles/gitlab/FirstPerson.yml +++ b/doc/.vale/gitlab/FirstPerson.yml diff --git a/doc/.linting/vale/styles/gitlab/InternalLinkExtension.yml b/doc/.vale/gitlab/InternalLinkExtension.yml index d07a2600798..d07a2600798 100644 --- a/doc/.linting/vale/styles/gitlab/InternalLinkExtension.yml +++ b/doc/.vale/gitlab/InternalLinkExtension.yml diff --git a/doc/.linting/vale/styles/gitlab/LatinTerms.yml b/doc/.vale/gitlab/LatinTerms.yml index 8412631f8fe..8412631f8fe 100644 --- a/doc/.linting/vale/styles/gitlab/LatinTerms.yml +++ b/doc/.vale/gitlab/LatinTerms.yml diff --git a/doc/.linting/vale/styles/gitlab/OxfordComma.yml b/doc/.vale/gitlab/OxfordComma.yml index 4b37ba8c2b9..4b37ba8c2b9 100644 --- a/doc/.linting/vale/styles/gitlab/OxfordComma.yml +++ b/doc/.vale/gitlab/OxfordComma.yml diff --git a/doc/.linting/vale/styles/gitlab/RelativeLinks.yml b/doc/.vale/gitlab/RelativeLinks.yml index 95bd60dd6e4..95bd60dd6e4 100644 --- a/doc/.linting/vale/styles/gitlab/RelativeLinks.yml +++ b/doc/.vale/gitlab/RelativeLinks.yml diff --git a/doc/.linting/vale/styles/gitlab/SentenceSpacing.yml b/doc/.vale/gitlab/SentenceSpacing.yml index b061f7f6f9e..b061f7f6f9e 100644 --- a/doc/.linting/vale/styles/gitlab/SentenceSpacing.yml +++ b/doc/.vale/gitlab/SentenceSpacing.yml diff --git a/doc/.linting/vale/styles/gitlab/Substitutions.yml b/doc/.vale/gitlab/Substitutions.yml index b32a03e17d5..b32a03e17d5 100644 --- a/doc/.linting/vale/styles/gitlab/Substitutions.yml +++ b/doc/.vale/gitlab/Substitutions.yml diff --git a/doc/ci/review_apps/img/enable_review_app_v12_8.png b/doc/ci/review_apps/img/enable_review_app_v12_8.png Binary files differindex 7d40f49725f..264e4834e72 100644 --- a/doc/ci/review_apps/img/enable_review_app_v12_8.png +++ b/doc/ci/review_apps/img/enable_review_app_v12_8.png diff --git a/doc/development/contributing/issue_workflow.md b/doc/development/contributing/issue_workflow.md index 4d84e921acf..46d1d4c2414 100644 --- a/doc/development/contributing/issue_workflow.md +++ b/doc/development/contributing/issue_workflow.md @@ -81,6 +81,8 @@ already reserved for category labels). The descriptions on the [labels page](https://gitlab.com/groups/gitlab-org/-/labels) explain what falls under each type label. +The GitLab handbook documents [when something is a bug and when it is a feature request.](https://about.gitlab.com/handbook/product/product-management/process/feature-or-bug.html) + ### Facet labels Sometimes it's useful to refine the type of an issue. In those cases, you can diff --git a/doc/development/event_tracking/backend.md b/doc/development/event_tracking/backend.md new file mode 100644 index 00000000000..dc4d7279671 --- /dev/null +++ b/doc/development/event_tracking/backend.md @@ -0,0 +1,5 @@ +--- +redirect_to: '../../telemetry/backend.md' +--- + +This document was moved to [another location](../../telemetry/backend.md). diff --git a/doc/development/event_tracking/frontend.md b/doc/development/event_tracking/frontend.md new file mode 100644 index 00000000000..0e98daf15bb --- /dev/null +++ b/doc/development/event_tracking/frontend.md @@ -0,0 +1,5 @@ +--- +redirect_to: '../../telemetry/frontend.md' +--- + +This document was moved to [another location](../../telemetry/frontend.md). diff --git a/doc/development/event_tracking/index.md b/doc/development/event_tracking/index.md new file mode 100644 index 00000000000..ae555e99c6b --- /dev/null +++ b/doc/development/event_tracking/index.md @@ -0,0 +1,5 @@ +--- +redirect_to: '../../telemetry/index.md' +--- + +This document was moved to [another location](../../telemetry/index.md). diff --git a/doc/policy/maintenance.md b/doc/policy/maintenance.md index 1739c07ccd5..028e372985d 100644 --- a/doc/policy/maintenance.md +++ b/doc/policy/maintenance.md @@ -127,7 +127,7 @@ one major version. For example, it is safe to: - `9.5.5` -> `9.5.9` - `10.6.3` -> `10.6.6` - `11.11.1` -> `11.11.8` - - `12.0.4` -> `12.0.9` + - `12.0.4` -> `12.0.12` - Upgrade the minor version: - `8.9.4` -> `8.12.3` - `9.2.3` -> `9.5.5` @@ -144,9 +144,10 @@ It's also important to ensure that any background migrations have been fully com before upgrading to a new major version. To see the current size of the `background_migration` queue, [Check for background migrations before upgrading](../update/README.md#checking-for-background-migrations-before-upgrading). -To ensure background migrations are successful, increment by one minor version during the version jump before installing newer releases. +From version 12 onwards, an additional step is required. More significant migrations may occur during major release upgrades. To ensure these are successful, increment to the first minor version (`x.0.x`) during the major version jump. Then proceed with upgrading to a newer release. + +For example: `11.11.x` -> `12.0.x` -> `12.8.x` -For example: `11.11.x` -> `12.0.x` Please see the table below for some examples: | Latest stable version | Your version | Recommended upgrade path | Note | @@ -154,7 +155,8 @@ Please see the table below for some examples: | 9.4.5 | 8.13.4 | `8.13.4` -> `8.17.7` -> `9.4.5` | `8.17.7` is the last version in version `8` | | 10.1.4 | 8.13.4 | `8.13.4 -> 8.17.7 -> 9.5.10 -> 10.1.4` | `8.17.7` is the last version in version `8`, `9.5.10` is the last version in version `9` | | 11.3.4 | 8.13.4 | `8.13.4` -> `8.17.7` -> `9.5.10` -> `10.8.7` -> `11.3.4` | `8.17.7` is the last version in version `8`, `9.5.10` is the last version in version `9`, `10.8.7` is the last version in version `10` | -| 12.5.8 | 11.3.4 | `11.3.4` -> `11.11.8` -> `12.0.9` -> `12.5.8` | `11.11.8` is the last version in version `11` | +| 12.5.8 | 11.3.4 | `11.3.4` -> `11.11.8` -> `12.0.12` -> `12.5.8` | `11.11.8` is the last version in version `11`. `12.0.x` [is a required step.](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23211#note_272842444) | +| 12.8.5 | 9.2.6 | `9.2.6` -> `9.5.10` -> `10.8.7` -> `11.11.8` -> `12.0.12` -> `12.8.5` | Four intermediate versions required: the final 9.5, 10.8, 11.11 releases, plus 12.0 | More information about the release procedures can be found in our [release documentation](https://gitlab.com/gitlab-org/release/docs). You may also want to read our diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml index bbfcf53b3d4..c6c8256b4bb 100644 --- a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml @@ -40,6 +40,7 @@ stop_review: environment: name: review/$CI_COMMIT_REF_NAME action: stop + dependencies: [] when: manual allow_failure: true only: diff --git a/lib/gitlab/import_export/group/tree_restorer.rb b/lib/gitlab/import_export/group/tree_restorer.rb index cbaa6929efa..247e39a68b9 100644 --- a/lib/gitlab/import_export/group/tree_restorer.rb +++ b/lib/gitlab/import_export/group/tree_restorer.rb @@ -17,9 +17,17 @@ module Gitlab end def restore - @tree_hash = @group_hash || read_tree_hash - @group_members = @tree_hash.delete('members') - @children = @tree_hash.delete('children') + @relation_reader ||= + if @group_hash.present? + ImportExport::JSON::LegacyReader::User.new(@group_hash, reader.group_relation_names) + else + ImportExport::JSON::LegacyReader::File.new(@path, reader.group_relation_names) + end + + @group_members = @relation_reader.consume_relation('members') + @children = @relation_reader.consume_attribute('children') + @relation_reader.consume_attribute('name') + @relation_reader.consume_attribute('path') if members_mapper.map && restorer.restore @children&.each do |group_hash| @@ -45,21 +53,12 @@ module Gitlab private - def read_tree_hash - json = IO.read(@path) - ActiveSupport::JSON.decode(json) - rescue => e - @shared.error(e) - - raise Gitlab::ImportExport::Error.new('Incorrect JSON format') - end - def restorer @relation_tree_restorer ||= RelationTreeRestorer.new( user: @user, shared: @shared, importable: @group, - tree_hash: @tree_hash.except('name', 'path'), + relation_reader: @relation_reader, members_mapper: members_mapper, object_builder: object_builder, relation_factory: relation_factory, diff --git a/lib/gitlab/import_export/json/legacy_reader.rb b/lib/gitlab/import_export/json/legacy_reader.rb new file mode 100644 index 00000000000..477e41ae3eb --- /dev/null +++ b/lib/gitlab/import_export/json/legacy_reader.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Gitlab + module ImportExport + module JSON + class LegacyReader + class File < LegacyReader + def initialize(path, relation_names) + @path = path + super(relation_names) + end + + def valid? + ::File.exist?(@path) + end + + private + + def tree_hash + @tree_hash ||= read_hash + end + + def read_hash + ActiveSupport::JSON.decode(IO.read(@path)) + rescue => e + Gitlab::ErrorTracking.log_exception(e) + raise Gitlab::ImportExport::Error.new('Incorrect JSON format') + end + end + + class User < LegacyReader + def initialize(tree_hash, relation_names) + @tree_hash = tree_hash + super(relation_names) + end + + def valid? + @tree_hash.present? + end + + protected + + attr_reader :tree_hash + end + + def initialize(relation_names) + @relation_names = relation_names.map(&:to_s) + end + + def valid? + raise NotImplementedError + end + + def legacy? + true + end + + def root_attributes(excluded_attributes = []) + attributes.except(*excluded_attributes.map(&:to_s)) + end + + def consume_relation(key) + value = relations.delete(key) + + return value unless block_given? + + return if value.nil? + + if value.is_a?(Array) + value.each.with_index do |item, idx| + yield(item, idx) + end + else + yield(value, 0) + end + end + + def consume_attribute(key) + attributes.delete(key) + end + + def sort_ci_pipelines_by_id + relations['ci_pipelines']&.sort_by! { |hash| hash['id'] } + end + + private + + attr_reader :relation_names + + def tree_hash + raise NotImplementedError + end + + def attributes + @attributes ||= tree_hash.slice!(*relation_names) + end + + def relations + @relations ||= tree_hash.extract!(*relation_names) + end + end + end + end +end diff --git a/lib/gitlab/import_export/project/tree_loader.rb b/lib/gitlab/import_export/project/tree_loader.rb deleted file mode 100644 index 6d4737a2d00..00000000000 --- a/lib/gitlab/import_export/project/tree_loader.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module ImportExport - module Project - class TreeLoader - def load(path, dedup_entries: false) - tree_hash = ActiveSupport::JSON.decode(IO.read(path)) - - if dedup_entries - dedup_tree(tree_hash) - else - tree_hash - end - end - - private - - # This function removes duplicate entries from the given tree recursively - # by caching nodes it encounters repeatedly. We only consider nodes for - # which there can actually be multiple equivalent instances (e.g. strings, - # hashes and arrays, but not `nil`s, numbers or booleans.) - # - # The algorithm uses a recursive depth-first descent with 3 cases, starting - # with a root node (the tree/hash itself): - # - a node has already been cached; in this case we return it from the cache - # - a node has not been cached yet but should be; descend into its children - # - a node is neither cached nor qualifies for caching; this is a no-op - def dedup_tree(node, nodes_seen = {}) - if nodes_seen.key?(node) && distinguishable?(node) - yield nodes_seen[node] - elsif should_dedup?(node) - nodes_seen[node] = node - - case node - when Array - node.each_index do |idx| - dedup_tree(node[idx], nodes_seen) do |cached_node| - node[idx] = cached_node - end - end - when Hash - node.each do |k, v| - dedup_tree(v, nodes_seen) do |cached_node| - node[k] = cached_node - end - end - end - else - node - end - end - - # We do not need to consider nodes for which there cannot be multiple instances - def should_dedup?(node) - node && !(node.is_a?(Numeric) || node.is_a?(TrueClass) || node.is_a?(FalseClass)) - end - - # We can only safely de-dup values that are distinguishable. True value objects - # are always distinguishable by nature. Hashes however can represent entities, - # which are identified by ID, not value. We therefore disallow de-duping hashes - # that do not have an `id` field, since we might risk dropping entities that - # have equal attributes yet different identities. - def distinguishable?(node) - if node.is_a?(Hash) - node.key?('id') - else - true - end - end - end - end - end -end diff --git a/lib/gitlab/import_export/project/tree_restorer.rb b/lib/gitlab/import_export/project/tree_restorer.rb index 295e0d5f348..f8d25e14c02 100644 --- a/lib/gitlab/import_export/project/tree_restorer.rb +++ b/lib/gitlab/import_export/project/tree_restorer.rb @@ -4,8 +4,6 @@ module Gitlab module ImportExport module Project class TreeRestorer - LARGE_PROJECT_FILE_SIZE_BYTES = 500.megabyte - attr_reader :user attr_reader :shared attr_reader :project @@ -14,12 +12,12 @@ module Gitlab @user = user @shared = shared @project = project - @tree_loader = TreeLoader.new end def restore - @tree_hash = read_tree_hash - @project_members = @tree_hash.delete('project_members') + @relation_reader = ImportExport::JSON::LegacyReader::File.new(File.join(shared.export_path, 'project.json'), reader.project_relation_names) + + @project_members = @relation_reader.consume_relation('project_members') if relation_tree_restorer.restore import_failure_service.with_retry(action: 'set_latest_merge_request_diff_ids!') do @@ -37,24 +35,12 @@ module Gitlab private - def large_project?(path) - File.size(path) >= LARGE_PROJECT_FILE_SIZE_BYTES - end - - def read_tree_hash - path = File.join(@shared.export_path, 'project.json') - @tree_loader.load(path, dedup_entries: large_project?(path)) - rescue => e - Rails.logger.error("Import/Export error: #{e.message}") # rubocop:disable Gitlab/RailsLogger - raise Gitlab::ImportExport::Error.new('Incorrect JSON format') - end - def relation_tree_restorer @relation_tree_restorer ||= RelationTreeRestorer.new( user: @user, shared: @shared, importable: @project, - tree_hash: @tree_hash, + relation_reader: @relation_reader, object_builder: object_builder, members_mapper: members_mapper, relation_factory: relation_factory, diff --git a/lib/gitlab/import_export/reader.rb b/lib/gitlab/import_export/reader.rb index 1390770acef..8d36d05ca6f 100644 --- a/lib/gitlab/import_export/reader.rb +++ b/lib/gitlab/import_export/reader.rb @@ -17,10 +17,18 @@ module Gitlab tree_by_key(:project) end + def project_relation_names + attributes_finder.find_relations_tree(:project).keys + end + def group_tree tree_by_key(:group) end + def group_relation_names + attributes_finder.find_relations_tree(:group).keys + end + def group_members_tree tree_by_key(:group_members) end diff --git a/lib/gitlab/import_export/relation_tree_restorer.rb b/lib/gitlab/import_export/relation_tree_restorer.rb index 8359eefc846..466cb03862e 100644 --- a/lib/gitlab/import_export/relation_tree_restorer.rb +++ b/lib/gitlab/import_export/relation_tree_restorer.rb @@ -9,13 +9,13 @@ module Gitlab attr_reader :user attr_reader :shared attr_reader :importable - attr_reader :tree_hash + attr_reader :relation_reader - def initialize(user:, shared:, importable:, tree_hash:, members_mapper:, object_builder:, relation_factory:, reader:) + def initialize(user:, shared:, importable:, relation_reader:, members_mapper:, object_builder:, relation_factory:, reader:) @user = user @shared = shared @importable = importable - @tree_hash = tree_hash + @relation_reader = relation_reader @members_mapper = members_mapper @object_builder = object_builder @relation_factory = relation_factory @@ -30,7 +30,7 @@ module Gitlab bulk_inserts_enabled = @importable.class == ::Project && Feature.enabled?(:import_bulk_inserts, @importable.group) BulkInsertableAssociations.with_bulk_insert(enabled: bulk_inserts_enabled) do - update_relation_hashes! + fix_ci_pipelines_not_sorted_on_legacy_project_json! create_relations! end end @@ -57,18 +57,8 @@ module Gitlab end def process_relation!(relation_key, relation_definition) - data_hashes = @tree_hash.delete(relation_key) - return unless data_hashes - - # we do not care if we process array or hash - data_hashes = [data_hashes] unless data_hashes.is_a?(Array) - - relation_index = 0 - - # consume and remove objects from memory - while data_hash = data_hashes.shift + @relation_reader.consume_relation(relation_key) do |data_hash, relation_index| process_relation_item!(relation_key, relation_definition, relation_index, data_hash) - relation_index += 1 end end @@ -103,10 +93,7 @@ module Gitlab end def update_params! - params = @tree_hash.reject do |key, _| - relations.include?(key) - end - + params = @relation_reader.root_attributes(relations.keys) params = params.merge(present_override_params) # Cleaning all imported and overridden params @@ -223,8 +210,13 @@ module Gitlab } end - def update_relation_hashes! - @tree_hash['ci_pipelines']&.sort_by! { |hash| hash['id'] } + # Temporary fix for https://gitlab.com/gitlab-org/gitlab/-/issues/27883 when import from legacy project.json + # This should be removed once legacy JSON format is deprecated. + # Ndjson export file will fix the order during project export. + def fix_ci_pipelines_not_sorted_on_legacy_project_json! + return unless relation_reader.legacy? + + relation_reader.sort_ci_pipelines_by_id end end end diff --git a/lib/gitlab/import_export/relation_tree_saver.rb b/lib/gitlab/import_export/relation_tree_saver.rb index a0452071ccf..ed5392c13d0 100644 --- a/lib/gitlab/import_export/relation_tree_saver.rb +++ b/lib/gitlab/import_export/relation_tree_saver.rb @@ -18,7 +18,7 @@ module Gitlab def save(tree, dir_path, filename) mkdir_p(dir_path) - tree_json = JSON.generate(tree) + tree_json = ::JSON.generate(tree) File.write(File.join(dir_path, filename), tree_json) end diff --git a/spec/fixtures/lib/gitlab/import_export/invalid_json/project.json b/spec/fixtures/lib/gitlab/import_export/invalid_json/project.json new file mode 100644 index 00000000000..83cb34eea91 --- /dev/null +++ b/spec/fixtures/lib/gitlab/import_export/invalid_json/project.json @@ -0,0 +1,3 @@ +{ + "invalid" json +} diff --git a/spec/frontend/__mocks__/mousetrap/index.js b/spec/frontend/__mocks__/mousetrap/index.js new file mode 100644 index 00000000000..63c92fa9a09 --- /dev/null +++ b/spec/frontend/__mocks__/mousetrap/index.js @@ -0,0 +1,6 @@ +/* global Mousetrap */ +// `mousetrap` uses amd which webpack understands but Jest does not +// Thankfully it also writes to a global export so we can es6-ify it +import 'mousetrap'; + +export default Mousetrap; diff --git a/spec/javascripts/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 5f97182489e..15f91871437 100644 --- a/spec/javascripts/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -1,6 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'spec/test_constants'; import Mousetrap from 'mousetrap'; import App from '~/diffs/components/app.vue'; @@ -12,14 +13,17 @@ import CommitWidget from '~/diffs/components/commit_widget.vue'; import TreeList from '~/diffs/components/tree_list.vue'; import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants'; import createDiffsStore from '../create_diffs_store'; +import axios from '~/lib/utils/axios_utils'; import diffsMockData from '../mock_data/merge_request_diffs'; const mergeRequestDiff = { version_index: 1 }; +const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`; describe('diffs/components/app', () => { const oldMrTabs = window.mrTabs; let store; let wrapper; + let mock; function createComponent(props = {}, extendStore = () => {}) { const localVue = createLocalVue(); @@ -34,7 +38,7 @@ describe('diffs/components/app', () => { wrapper = shallowMount(localVue.extend(App), { localVue, propsData: { - endpoint: `${TEST_HOST}/diff/endpoint`, + endpoint: TEST_ENDPOINT, endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`, endpointBatch: `${TEST_HOST}/diff/endpointBatch`, projectPath: 'namespace/project', @@ -61,8 +65,12 @@ describe('diffs/components/app', () => { beforeEach(() => { // setup globals (needed for component to mount :/) - window.mrTabs = jasmine.createSpyObj('mrTabs', ['resetViewContainer']); - window.mrTabs.expandViewContainer = jasmine.createSpy(); + window.mrTabs = { + resetViewContainer: jest.fn(), + }; + window.mrTabs.expandViewContainer = jest.fn(); + mock = new MockAdapter(axios); + mock.onGet(TEST_ENDPOINT).reply(200, {}); }); afterEach(() => { @@ -71,6 +79,8 @@ describe('diffs/components/app', () => { // reset component wrapper.destroy(); + + mock.restore(); }); describe('fetch diff methods', () => { @@ -80,15 +90,15 @@ describe('diffs/components/app', () => { store.state.notes.discussions = 'test'; return Promise.resolve({ real_size: 100 }); }; - spyOn(window, 'requestIdleCallback').and.callFake(fn => fn()); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(fn => fn()); createComponent(); - spyOn(wrapper.vm, 'fetchDiffFiles').and.callFake(fetchResolver); - spyOn(wrapper.vm, 'fetchDiffFilesMeta').and.callFake(fetchResolver); - spyOn(wrapper.vm, 'fetchDiffFilesBatch').and.callFake(fetchResolver); - spyOn(wrapper.vm, 'setDiscussions'); - spyOn(wrapper.vm, 'startRenderDiffsQueue'); - spyOn(wrapper.vm, 'unwatchDiscussions'); - spyOn(wrapper.vm, 'unwatchRetrievingBatches'); + jest.spyOn(wrapper.vm, 'fetchDiffFiles').mockImplementation(fetchResolver); + jest.spyOn(wrapper.vm, 'fetchDiffFilesMeta').mockImplementation(fetchResolver); + jest.spyOn(wrapper.vm, 'fetchDiffFilesBatch').mockImplementation(fetchResolver); + jest.spyOn(wrapper.vm, 'setDiscussions').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'startRenderDiffsQueue').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'unwatchDiscussions').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'unwatchRetrievingBatches').mockImplementation(() => {}); store.state.diffs.retrievingBatches = true; store.state.diffs.diffFiles = []; wrapper.vm.$nextTick(done); @@ -236,7 +246,7 @@ describe('diffs/components/app', () => { wrapper.vm.fetchData(false); expect(wrapper.vm.fetchDiffFiles).toHaveBeenCalled(); - setTimeout(() => { + setImmediate(() => { expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).not.toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled(); @@ -255,7 +265,7 @@ describe('diffs/components/app', () => { wrapper.vm.fetchData(false); expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); - setTimeout(() => { + setImmediate(() => { expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); @@ -272,7 +282,7 @@ describe('diffs/components/app', () => { wrapper.vm.fetchData(false); expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); - setTimeout(() => { + setImmediate(() => { expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); @@ -350,23 +360,21 @@ describe('diffs/components/app', () => { }); // Component uses $nextTick so we wait until that has finished - setTimeout(() => { + setImmediate(() => { expect(store.state.diffs.highlightedRow).toBe('ABC_123'); done(); }); }); - it('marks current diff file based on currently highlighted row', done => { + it('marks current diff file based on currently highlighted row', () => { createComponent({ shouldShow: true, }); // Component uses $nextTick so we wait until that has finished - setTimeout(() => { + return wrapper.vm.$nextTick().then(() => { expect(store.state.diffs.currentDiffFileId).toBe('ABC'); - - done(); }); }); }); @@ -403,7 +411,7 @@ describe('diffs/components/app', () => { }); // Component uses $nextTick so we wait until that has finished - setTimeout(() => { + setImmediate(() => { expect(store.state.diffs.currentDiffFileId).toBe('ABC'); done(); @@ -449,7 +457,7 @@ describe('diffs/components/app', () => { describe('visible app', () => { beforeEach(() => { - spy = jasmine.createSpy('spy'); + spy = jest.fn(); createComponent({ shouldShow: true, @@ -459,21 +467,18 @@ describe('diffs/components/app', () => { }); }); - it('calls `jumpToFile()` with correct parameter whenever pre-defined key is pressed', done => { - wrapper.vm - .$nextTick() - .then(() => { - Object.keys(mappings).forEach(function(key) { - Mousetrap.trigger(key); + it.each(Object.keys(mappings))( + 'calls `jumpToFile()` with correct parameter whenever pre-defined %s is pressed', + key => { + return wrapper.vm.$nextTick().then(() => { + expect(spy).not.toHaveBeenCalled(); - expect(spy.calls.mostRecent().args).toEqual([mappings[key]]); - }); + Mousetrap.trigger(key); - expect(spy.calls.count()).toEqual(Object.keys(mappings).length); - }) - .then(done) - .catch(done.fail); - }); + expect(spy).toHaveBeenCalledWith(mappings[key]); + }); + }, + ); it('does not call `jumpToFile()` when unknown key is pressed', done => { wrapper.vm @@ -490,7 +495,7 @@ describe('diffs/components/app', () => { describe('hideen app', () => { beforeEach(() => { - spy = jasmine.createSpy('spy'); + spy = jest.fn(); createComponent({ shouldShow: false, @@ -504,7 +509,7 @@ describe('diffs/components/app', () => { wrapper.vm .$nextTick() .then(() => { - Object.keys(mappings).forEach(function(key) { + Object.keys(mappings).forEach(key => { Mousetrap.trigger(key); expect(spy).not.toHaveBeenCalled(); @@ -520,7 +525,7 @@ describe('diffs/components/app', () => { let spy; beforeEach(() => { - spy = jasmine.createSpy(); + spy = jest.fn(); createComponent({}, () => { store.state.diffs.diffFiles = [ @@ -545,15 +550,15 @@ describe('diffs/components/app', () => { .then(() => { wrapper.vm.jumpToFile(+1); - expect(spy.calls.mostRecent().args).toEqual(['222.js']); + expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual(['222.js']); store.state.diffs.currentDiffFileId = '222'; wrapper.vm.jumpToFile(+1); - expect(spy.calls.mostRecent().args).toEqual(['333.js']); + expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual(['333.js']); store.state.diffs.currentDiffFileId = '333'; wrapper.vm.jumpToFile(-1); - expect(spy.calls.mostRecent().args).toEqual(['222.js']); + expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual(['222.js']); }) .then(done) .catch(done.fail); @@ -602,7 +607,7 @@ describe('diffs/components/app', () => { expect(wrapper.contains(CompareVersions)).toBe(true); expect(wrapper.find(CompareVersions).props()).toEqual( - jasmine.objectContaining({ + expect.objectContaining({ targetBranch: { branchName: 'target-branch', versionIndex: -1, @@ -625,7 +630,7 @@ describe('diffs/components/app', () => { expect(wrapper.contains(HiddenFilesWarning)).toBe(true); expect(wrapper.find(HiddenFilesWarning).props()).toEqual( - jasmine.objectContaining({ + expect.objectContaining({ total: '5', plainDiffPath: 'plain diff path', emailPatchPath: 'email patch path', @@ -663,7 +668,7 @@ describe('diffs/components/app', () => { let toggleShowTreeList; beforeEach(() => { - toggleShowTreeList = jasmine.createSpy('toggleShowTreeList'); + toggleShowTreeList = jest.fn(); }); afterEach(() => { diff --git a/spec/frontend/diffs/create_diffs_store.js b/spec/frontend/diffs/create_diffs_store.js new file mode 100644 index 00000000000..aacde99964c --- /dev/null +++ b/spec/frontend/diffs/create_diffs_store.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import diffsModule from '~/diffs/store/modules'; +import notesModule from '~/notes/stores/modules'; + +Vue.use(Vuex); + +export default function createDiffsStore() { + return new Vuex.Store({ + modules: { + diffs: diffsModule(), + notes: notesModule(), + }, + }); +} diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap index 177cd4559ca..efa58a4a47b 100644 --- a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap +++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap @@ -8,6 +8,7 @@ exports[`IDE pipelines list when loaded renders empty state when no latestPipeli <empty-state-stub cansetci="true" + class="mb-auto mt-auto" emptystatesvgpath="http://test.host" helppagepath="http://test.host" /> diff --git a/spec/frontend/lib/utils/icon_utils_spec.js b/spec/frontend/lib/utils/icon_utils_spec.js index 816d634ad15..f798dc6744d 100644 --- a/spec/frontend/lib/utils/icon_utils_spec.js +++ b/spec/frontend/lib/utils/icon_utils_spec.js @@ -1,10 +1,14 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import * as iconUtils from '~/lib/utils/icon_utils'; +import { clearSvgIconPathContentCache, getSvgIconPathContent } from '~/lib/utils/icon_utils'; describe('Icon utils', () => { describe('getSvgIconPathContent', () => { let spriteIcons; + let axiosMock; + const mockName = 'mockIconName'; + const mockPath = 'mockPath'; + const mockIcons = `<svg><symbol id="${mockName}"><path d="${mockPath}"/></symbol></svg>`; beforeAll(() => { spriteIcons = gon.sprite_icons; @@ -15,45 +19,63 @@ describe('Icon utils', () => { gon.sprite_icons = spriteIcons; }); - let axiosMock; - let mockEndpoint; - const mockName = 'mockIconName'; - const mockPath = 'mockPath'; - const getIcon = () => iconUtils.getSvgIconPathContent(mockName); - beforeEach(() => { axiosMock = new MockAdapter(axios); - mockEndpoint = axiosMock.onGet(gon.sprite_icons); }); afterEach(() => { axiosMock.restore(); + clearSvgIconPathContentCache(); }); - it('extracts svg icon path content from sprite icons', () => { - mockEndpoint.replyOnce( - 200, - `<svg><symbol id="${mockName}"><path d="${mockPath}"/></symbol></svg>`, - ); - - return getIcon().then(path => { - expect(path).toBe(mockPath); + describe('when the icons can be loaded', () => { + beforeEach(() => { + axiosMock.onGet(gon.sprite_icons).reply(200, mockIcons); }); - }); - it('returns null if icon path content does not exist', () => { - mockEndpoint.replyOnce(200, ``); + it('extracts svg icon path content from sprite icons', () => { + return getSvgIconPathContent(mockName).then(path => { + expect(path).toBe(mockPath); + }); + }); - return getIcon().then(path => { - expect(path).toBe(null); + it('returns null if icon path content does not exist', () => { + return getSvgIconPathContent('missing-icon').then(path => { + expect(path).toBe(null); + }); }); }); - it('returns null if an http error occurs', () => { - mockEndpoint.replyOnce(500); + describe('when the icons cannot be loaded on the first 2 tries', () => { + beforeEach(() => { + axiosMock + .onGet(gon.sprite_icons) + .replyOnce(500) + .onGet(gon.sprite_icons) + .replyOnce(500) + .onGet(gon.sprite_icons) + .reply(200, mockIcons); + }); + + it('returns null', () => { + return getSvgIconPathContent(mockName).then(path => { + expect(path).toBe(null); + }); + }); - return getIcon().then(path => { - expect(path).toBe(null); + it('extracts svg icon path content, after 2 attempts', () => { + return getSvgIconPathContent(mockName) + .then(path1 => { + expect(path1).toBe(null); + return getSvgIconPathContent(mockName); + }) + .then(path2 => { + expect(path2).toBe(null); + return getSvgIconPathContent(mockName); + }) + .then(path3 => { + expect(path3).toBe(mockPath); + }); }); }); }); diff --git a/spec/frontend/mocks/ce/diffs/workers/tree_worker.js b/spec/frontend/mocks/ce/diffs/workers/tree_worker.js new file mode 100644 index 00000000000..a33ddbbfe63 --- /dev/null +++ b/spec/frontend/mocks/ce/diffs/workers/tree_worker.js @@ -0,0 +1,8 @@ +/* eslint-disable class-methods-use-this */ +export default class TreeWorkerMock { + addEventListener() {} + + terminate() {} + + postMessage() {} +} diff --git a/spec/javascripts/diffs/create_diffs_store.js b/spec/javascripts/diffs/create_diffs_store.js index aacde99964c..cfefd4238b8 100644 --- a/spec/javascripts/diffs/create_diffs_store.js +++ b/spec/javascripts/diffs/create_diffs_store.js @@ -1,15 +1 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import diffsModule from '~/diffs/store/modules'; -import notesModule from '~/notes/stores/modules'; - -Vue.use(Vuex); - -export default function createDiffsStore() { - return new Vuex.Store({ - modules: { - diffs: diffsModule(), - notes: notesModule(), - }, - }); -} +export { default } from '../../frontend/diffs/create_diffs_store'; diff --git a/spec/lib/gitlab/import_export/json/legacy_reader_spec.rb b/spec/lib/gitlab/import_export/json/legacy_reader_spec.rb new file mode 100644 index 00000000000..0009a5f81de --- /dev/null +++ b/spec/lib/gitlab/import_export/json/legacy_reader_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ImportExport::JSON::LegacyReader::User do + let(:relation_names) { [] } + let(:legacy_reader) { described_class.new(tree_hash, relation_names) } + + describe '#valid?' do + subject { legacy_reader.valid? } + + context 'tree_hash not present' do + let(:tree_hash) { nil } + + it { is_expected.to be false } + end + + context 'tree_hash presents' do + let(:tree_hash) { { "issues": [] } } + + it { is_expected.to be true } + end + end +end + +describe Gitlab::ImportExport::JSON::LegacyReader::File do + let(:fixture) { 'spec/fixtures/lib/gitlab/import_export/light/project.json' } + let(:project_tree) { JSON.parse(File.read(fixture)) } + let(:relation_names) { [] } + let(:legacy_reader) { described_class.new(path, relation_names) } + + describe '#valid?' do + subject { legacy_reader.valid? } + + context 'given valid path' do + let(:path) { fixture } + + it { is_expected.to be true } + end + + context 'given invalid path' do + let(:path) { 'spec/non-existing-folder/do-not-create-this-file.json' } + + it { is_expected.to be false } + end + end + + describe '#root_attributes' do + let(:path) { fixture } + + subject { legacy_reader.root_attributes(excluded_attributes) } + + context 'No excluded attributes' do + let(:excluded_attributes) { [] } + let(:relation_names) { [] } + + it 'returns the whole tree from parsed JSON' do + expect(subject).to eq(project_tree) + end + end + + context 'Some attributes are excluded' do + let(:excluded_attributes) { %w[milestones labels issues services snippets] } + let(:relation_names) { %w[import_type archived] } + + it 'returns hash without excluded attributes and relations' do + expect(subject).not_to include('milestones', 'labels', 'issues', 'services', 'snippets', 'import_type', 'archived') + end + end + end + + describe '#consume_relation' do + let(:path) { fixture } + let(:key) { 'description' } + + context 'block not given' do + it 'returns value of the key' do + expect(legacy_reader).to receive(:relations).and_return({ key => 'test value' }) + expect(legacy_reader.consume_relation(key)).to eq('test value') + end + end + + context 'key has been consumed' do + before do + legacy_reader.consume_relation(key) + end + + it 'does not yield' do + expect do |blk| + legacy_reader.consume_relation(key, &blk) + end.not_to yield_control + end + end + + context 'value is nil' do + before do + expect(legacy_reader).to receive(:relations).and_return({ key => nil }) + end + + it 'does not yield' do + expect do |blk| + legacy_reader.consume_relation(key, &blk) + end.not_to yield_control + end + end + + context 'value is not array' do + before do + expect(legacy_reader).to receive(:relations).and_return({ key => 'value' }) + end + + it 'yield the value with index 0' do + expect do |blk| + legacy_reader.consume_relation(key, &blk) + end.to yield_with_args('value', 0) + end + end + + context 'value is an array' do + before do + expect(legacy_reader).to receive(:relations).and_return({ key => %w[item1 item2 item3] }) + end + + it 'yield each array element with index' do + expect do |blk| + legacy_reader.consume_relation(key, &blk) + end.to yield_successive_args(['item1', 0], ['item2', 1], ['item3', 2]) + end + end + end + + describe '#tree_hash' do + let(:path) { fixture } + + subject { legacy_reader.send(:tree_hash) } + + it 'parses the JSON into the expected tree' do + expect(subject).to eq(project_tree) + end + + context 'invalid JSON' do + let(:path) { 'spec/fixtures/lib/gitlab/import_export/invalid_json/project.json' } + + it 'raise Exception' do + expect { subject }.to raise_exception(Gitlab::ImportExport::Error, 'Incorrect JSON format') + end + end + end +end diff --git a/spec/lib/gitlab/import_export/project/tree_loader_spec.rb b/spec/lib/gitlab/import_export/project/tree_loader_spec.rb deleted file mode 100644 index e683eefa7c0..00000000000 --- a/spec/lib/gitlab/import_export/project/tree_loader_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Gitlab::ImportExport::Project::TreeLoader do - let(:fixture) { 'spec/fixtures/lib/gitlab/import_export/with_duplicates.json' } - let(:project_tree) { JSON.parse(File.read(fixture)) } - - context 'without de-duplicating entries' do - let(:parsed_tree) do - subject.load(fixture) - end - - it 'parses the JSON into the expected tree' do - expect(parsed_tree).to eq(project_tree) - end - - it 'does not de-duplicate entries' do - expect(parsed_tree['duped_hash_with_id']).not_to be(parsed_tree['array'][0]['duped_hash_with_id']) - end - end - - context 'with de-duplicating entries' do - let(:parsed_tree) do - subject.load(fixture, dedup_entries: true) - end - - it 'parses the JSON into the expected tree' do - expect(parsed_tree).to eq(project_tree) - end - - it 'de-duplicates equal values' do - expect(parsed_tree['duped_hash_with_id']).to be(parsed_tree['array'][0]['duped_hash_with_id']) - expect(parsed_tree['duped_hash_with_id']).to be(parsed_tree['nested']['duped_hash_with_id']) - expect(parsed_tree['duped_array']).to be(parsed_tree['array'][1]['duped_array']) - expect(parsed_tree['duped_array']).to be(parsed_tree['nested']['duped_array']) - end - - it 'does not de-duplicate hashes without IDs' do - expect(parsed_tree['duped_hash_no_id']).to eq(parsed_tree['array'][2]['duped_hash_no_id']) - expect(parsed_tree['duped_hash_no_id']).not_to be(parsed_tree['array'][2]['duped_hash_no_id']) - end - - it 'keeps single entries intact' do - expect(parsed_tree['simple']).to eq(42) - expect(parsed_tree['nested']['array']).to eq(["don't touch"]) - end - end -end diff --git a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb index 9c2b202d5bb..e38ef75d085 100644 --- a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb @@ -783,7 +783,8 @@ describe Gitlab::ImportExport::Project::TreeRestorer do end before do - expect(restorer).to receive(:read_tree_hash) { tree_hash } + allow_any_instance_of(Gitlab::ImportExport::JSON::LegacyReader::File).to receive(:valid?).and_return(true) + allow_any_instance_of(Gitlab::ImportExport::JSON::LegacyReader::File).to receive(:tree_hash) { tree_hash } end context 'no group visibility' do diff --git a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb index 80901feb893..578418998c0 100644 --- a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # This spec is a lightweight version of: -# * project_tree_restorer_spec.rb +# * project/tree_restorer_spec.rb # # In depth testing is being done in the above specs. # This spec tests that restore project works @@ -25,7 +25,7 @@ describe Gitlab::ImportExport::RelationTreeRestorer do described_class.new( user: user, shared: shared, - tree_hash: tree_hash, + relation_reader: relation_reader, importable: importable, object_builder: object_builder, members_mapper: members_mapper, @@ -36,14 +36,7 @@ describe Gitlab::ImportExport::RelationTreeRestorer do subject { relation_tree_restorer.restore } - context 'when restoring a project' do - let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/project.json' } - let(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') } - let(:object_builder) { Gitlab::ImportExport::Project::ObjectBuilder } - let(:relation_factory) { Gitlab::ImportExport::Project::RelationFactory } - let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) } - let(:tree_hash) { importable_hash } - + shared_examples 'import project successfully' do it 'restores project tree' do expect(subject).to eq(true) end @@ -66,4 +59,18 @@ describe Gitlab::ImportExport::RelationTreeRestorer do end end end + + context 'when restoring a project' do + let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/project.json' } + let(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') } + let(:object_builder) { Gitlab::ImportExport::Project::ObjectBuilder } + let(:relation_factory) { Gitlab::ImportExport::Project::RelationFactory } + let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) } + + context 'using legacy reader' do + let(:relation_reader) { Gitlab::ImportExport::JSON::LegacyReader::File.new(path, reader.project_relation_names) } + + it_behaves_like 'import project successfully' + end + end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 3df620d1fea..5b87ec022ae 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -625,4 +625,14 @@ describe SystemNoteService do described_class.discussion_lock(issuable, double) end end + + describe '.auto_resolve_prometheus_alert' do + it 'calls IssuableService' do + expect_next_instance_of(::SystemNotes::IssuablesService) do |service| + expect(service).to receive(:auto_resolve_prometheus_alert) + end + + described_class.auto_resolve_prometheus_alert(noteable, project, author) + end + end end diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb index c0aaa65971a..477f9eae39e 100644 --- a/spec/services/system_notes/issuables_service_spec.rb +++ b/spec/services/system_notes/issuables_service_spec.rb @@ -654,4 +654,16 @@ describe ::SystemNotes::IssuablesService do .to eq('resolved the corresponding error and closed the issue.') end end + + describe '#auto_resolve_prometheus_alert' do + subject { service.auto_resolve_prometheus_alert } + + it_behaves_like 'a system note' do + let(:action) { 'closed' } + end + + it 'creates the expected system note' do + expect(subject.note).to eq('automatically closed this issue because the alert resolved.') + end + end end |