Commit fc1df8c8 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent c8df22c5
# 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
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,
......
......@@ -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'),
},
......
......@@ -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>
......
......@@ -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>
......
/* 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);
// 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;
}
}
}
@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,7 +701,12 @@ $ide-commit-header-height: 48px;
margin-left: auto;
}
.ide-nav-dropdown {
button {
color: $gl-text-color;
}
}
.ide-nav-dropdown {
width: 100%;
margin-bottom: 12px;
......@@ -893,11 +735,6 @@ $ide-commit-header-height: 48px;
background-color: $white-dark;
}
}
}
button {
color: $gl-text-color;
}
}
.ide-tree-body {
......@@ -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 {
......
......@@ -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')
......@@ -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)
......
---
title: 'Fixes stop_review job upon expired artifacts from previous stages'
merge_request: 27258
author: Jack Lei
type: fixed
......@@ -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
......
---
redirect_to: '../../telemetry/backend.md'
---
This document was moved to [another location](../../telemetry/backend.md).
---
redirect_to: '../../telemetry/frontend.md'
---
This document was moved to [another location](../../telemetry/frontend.md).
---
redirect_to: '../../telemetry/index.md'
---
This document was moved to [another location](../../telemetry/index.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
......
......@@ -40,6 +40,7 @@ stop_review:
environment:
name: review/$CI_COMMIT_REF_NAME
action: stop
dependencies: []
when: manual
allow_failure: true
only:
......
......@@ -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,
......
# 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
# 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
......@@ -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,
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
/* 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;
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(() => {
......
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(),
},
});
}
......@@ -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"
/>
......
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,46 +19,64 @@ 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>`,
);
describe('when the icons can be loaded', () => {
beforeEach(() => {
axiosMock.onGet(gon.sprite_icons).reply(200, mockIcons);
});
return getIcon().then(path => {
it('extracts svg icon path content from sprite icons', () => {
return getSvgIconPathContent(mockName).then(path => {
expect(path).toBe(mockPath);
});
});
it('returns null if icon path content does not exist', () => {
mockEndpoint.replyOnce(200, ``);
return getIcon().then(path => {
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);
});
return getIcon().then(path => {
it('returns null', () => {
return getSvgIconPathContent(mockName).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);
});
});
});
});
});
/* eslint-disable class-methods-use-this */
export default class TreeWorkerMock {
addEventListener() {}
terminate() {}
postMessage() {}
}
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';
# 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
# 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
......@@ -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
......
# 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
......@@ -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
......@@ -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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment