Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
c1cea595b6
commit
6c44b67631
|
@ -61,6 +61,7 @@ export default {
|
|||
<gl-button
|
||||
variant="confirm"
|
||||
class="gl-mt-3"
|
||||
data-testid="create_new_ci_button"
|
||||
data-qa-selector="create_new_ci_button"
|
||||
@click="createEmptyConfigFile"
|
||||
>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { truncatePathMiddleToLength } from '~/lib/utils/text_utility';
|
||||
import { TREE_TYPE } from '../constants';
|
||||
|
||||
export const getLowestSingleFolder = (folder) => {
|
||||
|
@ -28,7 +27,7 @@ export const getLowestSingleFolder = (folder) => {
|
|||
const { path, tree } = getFolder(folder, [folder.name]);
|
||||
|
||||
return {
|
||||
path: truncatePathMiddleToLength(path.join('/'), 40),
|
||||
path: path.join('/'),
|
||||
treeAcc: tree.length ? tree[tree.length - 1].tree : null,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -167,36 +167,6 @@ export const truncateWidth = (string, options = {}) => {
|
|||
*/
|
||||
export const truncateSha = (sha) => sha.substring(0, 8);
|
||||
|
||||
const ELLIPSIS_CHAR = '…';
|
||||
export const truncatePathMiddleToLength = (text, maxWidth) => {
|
||||
let returnText = text;
|
||||
let ellipsisCount = 0;
|
||||
|
||||
while (returnText.length >= maxWidth) {
|
||||
const textSplit = returnText.split('/').filter((s) => s !== ELLIPSIS_CHAR);
|
||||
|
||||
if (textSplit.length === 0) {
|
||||
// There are n - 1 path separators for n segments, so 2n - 1 <= maxWidth
|
||||
const maxSegments = Math.floor((maxWidth + 1) / 2);
|
||||
return new Array(maxSegments).fill(ELLIPSIS_CHAR).join('/');
|
||||
}
|
||||
|
||||
const middleIndex = Math.floor(textSplit.length / 2);
|
||||
|
||||
returnText = textSplit
|
||||
.slice(0, middleIndex)
|
||||
.concat(
|
||||
new Array(ellipsisCount + 1).fill().map(() => ELLIPSIS_CHAR),
|
||||
textSplit.slice(middleIndex + 1),
|
||||
)
|
||||
.join('/');
|
||||
|
||||
ellipsisCount += 1;
|
||||
}
|
||||
|
||||
return returnText;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capitalizes first character
|
||||
*
|
||||
|
|
|
@ -16,8 +16,6 @@ import DynamicContent from './dynamic_content.vue';
|
|||
import StatusIcon from './status_icon.vue';
|
||||
import ActionButtons from './action_buttons.vue';
|
||||
|
||||
const FETCH_TYPE_COLLAPSED = 'collapsed';
|
||||
const FETCH_TYPE_EXPANDED = 'expanded';
|
||||
const WIDGET_PREFIX = 'Widget';
|
||||
const MISSING_RESPONSE_HEADERS =
|
||||
'MR Widget: raesponse object should contain status and headers object. Make sure to include that in your `fetchCollapsedData` and `fetchExpandedData` functions.';
|
||||
|
@ -49,15 +47,6 @@ export default {
|
|||
SafeHtml,
|
||||
},
|
||||
props: {
|
||||
/**
|
||||
* @param {value.collapsed} Object
|
||||
* @param {value.expanded} Object
|
||||
*/
|
||||
value: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
required: false,
|
||||
|
@ -238,7 +227,7 @@ export default {
|
|||
|
||||
try {
|
||||
if (this.fetchCollapsedData) {
|
||||
await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED);
|
||||
await this.fetch(this.fetchCollapsedData);
|
||||
}
|
||||
} catch {
|
||||
this.summaryError = this.errorText;
|
||||
|
@ -271,7 +260,7 @@ export default {
|
|||
this.contentError = null;
|
||||
|
||||
try {
|
||||
await this.fetch(this.fetchExpandedData, FETCH_TYPE_EXPANDED);
|
||||
await this.fetch(this.fetchExpandedData);
|
||||
} catch {
|
||||
this.contentError = this.errorText;
|
||||
|
||||
|
@ -282,7 +271,7 @@ export default {
|
|||
|
||||
this.isLoadingExpandedContent = false;
|
||||
},
|
||||
fetch(handler, dataType) {
|
||||
fetch(handler) {
|
||||
const requests = this.multiPolling ? handler() : [handler];
|
||||
|
||||
const promises = requests.map((request) => {
|
||||
|
@ -319,9 +308,7 @@ export default {
|
|||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises).then((data) => {
|
||||
this.$emit('input', { ...this.value, [dataType]: this.multiPolling ? data : data[0] });
|
||||
});
|
||||
return Promise.all(promises);
|
||||
},
|
||||
},
|
||||
failedStatusIcon: EXTENSION_ICONS.failed,
|
||||
|
|
|
@ -110,7 +110,7 @@ module Projects
|
|||
|
||||
update_pending_builds
|
||||
|
||||
post_update_hooks(project)
|
||||
post_update_hooks(project, @old_group)
|
||||
rescue Exception # rubocop:disable Lint/RescueException
|
||||
rollback_side_effects
|
||||
raise
|
||||
|
@ -119,7 +119,7 @@ module Projects
|
|||
end
|
||||
|
||||
# Overridden in EE
|
||||
def post_update_hooks(project)
|
||||
def post_update_hooks(project, _old_group)
|
||||
ensure_personal_project_owner_membership(project)
|
||||
invalidate_personal_projects_counts
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
.form-group
|
||||
= f.label s_('SubgroupCreationLevel|Roles allowed to create subgroups'), class: 'label-bold'
|
||||
= f.select :subgroup_creation_level, options_for_select(::Gitlab::Access.subgroup_creation_options, group.subgroup_creation_level), {}, class: 'form-control'
|
||||
- ::Gitlab::Access.subgroup_creation_options.each do |label, action|
|
||||
= f.gitlab_ui_radio_component :subgroup_creation_level, action, label
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: ci_interpolation_inputs_refactor
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125632
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/418198
|
||||
milestone: '16.3'
|
||||
type: development
|
||||
group: group::pipeline authoring
|
||||
default_enabled: false
|
|
@ -371,6 +371,11 @@ module.exports = {
|
|||
include: /node_modules/,
|
||||
loader: 'babel-loader',
|
||||
},
|
||||
{
|
||||
test: /gridstack\/.*\.js$/,
|
||||
include: /node_modules/,
|
||||
loader: 'babel-loader',
|
||||
},
|
||||
{
|
||||
test: /_worker\.js$/,
|
||||
resourceQuery: /worker/,
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class EnsureBackfillForCiPipelineVariablesPipelineIdIsFinished < Gitlab::Database::Migration[2.1]
|
||||
include Gitlab::Database::MigrationHelpers::ConvertToBigint
|
||||
|
||||
restrict_gitlab_migration gitlab_schema: :gitlab_ci
|
||||
disable_ddl_transaction!
|
||||
|
||||
TABLE_NAME = :ci_pipeline_variables
|
||||
|
||||
def up
|
||||
ensure_batched_background_migration_is_finished(
|
||||
job_class_name: 'CopyColumnUsingBackgroundMigrationJob',
|
||||
table_name: TABLE_NAME,
|
||||
column_name: 'pipeline_id',
|
||||
job_arguments: [['pipeline_id'], ['id_convert_to_bigint']]
|
||||
)
|
||||
end
|
||||
|
||||
def down
|
||||
# no-op
|
||||
end
|
||||
end
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateAsyncIndexForCiPiplineVariablesPipelineId < Gitlab::Database::Migration[2.1]
|
||||
TABLE_NAME = :ci_pipeline_variables
|
||||
INDEX_NAME = "index_ci_pipeline_variables_on_pipeline_id_bigint_and_key"
|
||||
|
||||
def up
|
||||
prepare_async_index TABLE_NAME, [:pipeline_id_convert_to_bigint, :key], unique: true, name: INDEX_NAME
|
||||
end
|
||||
|
||||
def down
|
||||
unprepare_async_index TABLE_NAME, [:pipeline_id_convert_to_bigint, :key], unique: true, name: INDEX_NAME
|
||||
end
|
||||
end
|
|
@ -0,0 +1 @@
|
|||
e2a7487d4da68819a1bdde6626b32ef8399a917a75d4cbc8ecf2fa140ba1ff16
|
|
@ -0,0 +1 @@
|
|||
54ac5a22e121379b1ffcefc7b4c1f26cadd15a9b0cabfd0d9e3cba3886777d46
|
|
@ -52,9 +52,9 @@ during indexing and searching operations. Some of the benefits and tradeoffs to
|
|||
- Routing is not used if too many shards would be hit for global and group scoped searches.
|
||||
- Shard size imbalance might occur.
|
||||
|
||||
## Existing Analyzers/Tokenizers/Filters
|
||||
## Existing analyzers and tokenizers
|
||||
|
||||
These are all defined in [`ee/lib/elastic/latest/config.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/elastic/latest/config.rb)
|
||||
The following analyzers and tokenizers are defined in [`ee/lib/elastic/latest/config.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/elastic/latest/config.rb).
|
||||
|
||||
### Analyzers
|
||||
|
||||
|
@ -72,7 +72,7 @@ Please see the `sha_tokenizer` explanation later below for an example.
|
|||
|
||||
#### `code_analyzer`
|
||||
|
||||
Used when indexing a blob's filename and content. Uses the `whitespace` tokenizer and the filters: [`code`](#code), `lowercase`, and `asciifolding`
|
||||
Used when indexing a blob's filename and content. Uses the `whitespace` tokenizer and the [`word_delimiter_graph`](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-word-delimiter-graph-tokenfilter.html), `lowercase`, and `asciifolding` filters.
|
||||
|
||||
The `whitespace` tokenizer was selected to have more control over how tokens are split. For example the string `Foo::bar(4)` needs to generate tokens like `Foo` and `bar(4)` to be properly searched.
|
||||
|
||||
|
@ -81,10 +81,6 @@ Please see the `code` filter for an explanation on how tokens are split.
|
|||
NOTE:
|
||||
The [Elasticsearch `code_analyzer` doesn't account for all code cases](../integration/advanced_search/elasticsearch_troubleshooting.md#elasticsearch-code_analyzer-doesnt-account-for-all-code-cases).
|
||||
|
||||
#### `code_search_analyzer`
|
||||
|
||||
Not directly used for indexing, but rather used to transform a search input. Uses the `whitespace` tokenizer and the `lowercase` and `asciifolding` filters.
|
||||
|
||||
### Tokenizers
|
||||
|
||||
#### `sha_tokenizer`
|
||||
|
@ -115,27 +111,10 @@ Example:
|
|||
- `'path/application.js'`
|
||||
- `'application.js'`
|
||||
|
||||
### Filters
|
||||
|
||||
#### `code`
|
||||
|
||||
Uses a [Pattern Capture token filter](https://www.elastic.co/guide/en/elasticsearch/reference/5.5/analysis-pattern-capture-tokenfilter.html) to split tokens into more easily searched versions of themselves.
|
||||
|
||||
Patterns:
|
||||
|
||||
- `"(\\p{Ll}+|\\p{Lu}\\p{Ll}+|\\p{Lu}+)"`: captures CamelCase and lowerCamelCase strings as separate tokens
|
||||
- `"(\\d+)"`: extracts digits
|
||||
- `"(?=([\\p{Lu}]+[\\p{L}]+))"`: captures CamelCase strings recursively. For example: `ThisIsATest` => `[ThisIsATest, IsATest, ATest, Test]`
|
||||
- `'"((?:\\"|[^"]|\\")*)"'`: captures terms inside quotes, removing the quotes
|
||||
- `"'((?:\\'|[^']|\\')*)'"`: same as above, for single-quotes
|
||||
- `'\.([^.]+)(?=\.|\s|\Z)'`: separate terms with periods in-between
|
||||
- `'([\p{L}_.-]+)'`: some common chars in file names to keep the whole filename intact (for example `my_file-ñame.txt`)
|
||||
- `'([\p{L}\d_]+)'`: letters, numbers and underscores are the most common tokens in programming. Always capture them greedily regardless of context.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Searches can have their own analyzers. Remember to check when editing analyzers
|
||||
- `Character` filters (as opposed to token filters) always replace the original character, so they're not a good choice as they can hinder exact searches
|
||||
- Searches can have their own analyzers. Remember to check when editing analyzers.
|
||||
- `Character` filters (as opposed to token filters) always replace the original character. These filters can hinder exact searches.
|
||||
|
||||
## Zero downtime reindexing with multiple indices
|
||||
|
||||
|
|
|
@ -63,6 +63,24 @@ This page shows groups that you are a member of:
|
|||
- Through membership of a subgroup's parent group.
|
||||
- Through direct or inherited membership of a project in the group or subgroup.
|
||||
|
||||
## View group activity
|
||||
|
||||
To view the activity of a project:
|
||||
|
||||
1. On the left sidebar, at the top, select **Search GitLab** (**{search}**) to find your group.
|
||||
1. Select **Manage > Activity**.
|
||||
1. Optional. To filter activity by contribution type, select a tab:
|
||||
|
||||
- **All**: All contributions by group members in the group and group's projects.
|
||||
- **Push events**: Push events in the group's projects.
|
||||
- **Merge events**: Accepted merge requests in the group's projects.
|
||||
- **Issue events**: Issues opened and closed in the group's projects.
|
||||
- **Epic events**: Epics opened and closed in the group.
|
||||
- **Comments**: Comments posted by group members in the group's projects.
|
||||
- **Wiki**: Updates to wiki pages in the group.
|
||||
- **Designs**: Designs added, updated, and removed in the group's projects.
|
||||
- **Team**: Group members who joined and left the group's projects.
|
||||
|
||||
## Create a group
|
||||
|
||||
To create a group:
|
||||
|
|
|
@ -354,7 +354,16 @@ To view your activity:
|
|||
1. On the left sidebar, expand the top-most chevron (**{chevron-down}**).
|
||||
1. Select **Your work**.
|
||||
1. Select **Activity**.
|
||||
1. Optional. To filter your activity by contribution type, in the **Your Activity** tab, select a tab.
|
||||
1. Optional. To filter your activity by contribution type, in the **Your Activity** tab, select a tab:
|
||||
|
||||
- **All**: All contributions you made in your groups and projects.
|
||||
- **Push events**: Push events you made in your projects.
|
||||
- **Merge events**: Merge requests you accepted in your projects.
|
||||
- **Issue events**: Issues you opened and closed in your projects.
|
||||
- **Comments**: Comments you posted in your projects.
|
||||
- **Wiki**: Wiki pages you created and updated in your projects.
|
||||
- **Designs**: Designs you added, updated, and removed in your projects.
|
||||
- **Team**: Projects you joined and left.
|
||||
|
||||
## Session duration
|
||||
|
||||
|
|
|
@ -178,7 +178,15 @@ To view the activity of a project:
|
|||
|
||||
1. On the left sidebar, at the top, select **Search GitLab** (**{search}**) to find your project.
|
||||
1. Select **Manage > Activity**.
|
||||
1. Select a tab to view the type of project activity.
|
||||
1. Optional. To filter activity by contribution type, select a tab:
|
||||
|
||||
- **All**: All contributions by project members.
|
||||
- **Push events**: Push events in the project.
|
||||
- **Merge events**: Accepted merge requests in the project.
|
||||
- **Issue events**: Issues opened and closed in the project.
|
||||
- **Comments**: Comments posted by project members.
|
||||
- **Designs**: Designs added, updated, and removed in the project.
|
||||
- **Team**: Members who joined and left the project.
|
||||
|
||||
## Search in projects
|
||||
|
||||
|
|
|
@ -203,6 +203,7 @@ module.exports = (path, options = {}) => {
|
|||
'@gitlab/favicon-overlay',
|
||||
'@gitlab/cluster-client',
|
||||
'bootstrap-vue',
|
||||
'gridstack',
|
||||
'three',
|
||||
'monaco-editor',
|
||||
'monaco-yaml',
|
||||
|
|
|
@ -73,7 +73,11 @@ module Gitlab
|
|||
end
|
||||
|
||||
def inputs
|
||||
@inputs ||= Ci::Input::Inputs.new(spec, args)
|
||||
@inputs ||= if Feature.enabled?(:ci_interpolation_inputs_refactor)
|
||||
Ci::Interpolation::Inputs.new(spec, args)
|
||||
else
|
||||
Ci::Input::Inputs.new(spec, args)
|
||||
end
|
||||
end
|
||||
|
||||
def context
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
module Interpolation
|
||||
# Interpolation inputs provided by the user.
|
||||
class Inputs
|
||||
UnknownInputTypeError = Class.new(StandardError)
|
||||
|
||||
TYPES = [
|
||||
StringInput
|
||||
].freeze
|
||||
|
||||
def initialize(specs, args)
|
||||
@specs = specs.to_h
|
||||
@args = args.to_h
|
||||
@inputs = []
|
||||
@errors = []
|
||||
|
||||
validate!
|
||||
fabricate!
|
||||
end
|
||||
|
||||
def errors
|
||||
@errors + @inputs.flat_map(&:errors)
|
||||
end
|
||||
|
||||
def valid?
|
||||
errors.none?
|
||||
end
|
||||
|
||||
def to_hash
|
||||
@inputs.inject({}) do |hash, input|
|
||||
hash.merge(input.to_hash)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate!
|
||||
unknown_inputs = @args.keys - @specs.keys
|
||||
return if unknown_inputs.empty?
|
||||
|
||||
@errors.push("unknown input arguments: #{unknown_inputs.join(', ')}")
|
||||
end
|
||||
|
||||
def fabricate!
|
||||
@specs.each do |input_name, spec|
|
||||
input_type = TYPES.find { |klass| klass.matches?(spec) }
|
||||
|
||||
unless input_type
|
||||
@errors.push(
|
||||
"unknown input specification for `#{input_name}` (valid types: #{valid_type_names.join(', ')})")
|
||||
next
|
||||
end
|
||||
|
||||
@inputs.push(input_type.new(
|
||||
name: input_name,
|
||||
spec: spec,
|
||||
value: @args[input_name]))
|
||||
end
|
||||
end
|
||||
|
||||
def valid_type_names
|
||||
TYPES.map(&:type_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,92 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
module Interpolation
|
||||
class Inputs
|
||||
##
|
||||
# This is a common abstraction for all input types
|
||||
class BaseInput
|
||||
ArgumentNotValidError = Class.new(StandardError)
|
||||
|
||||
# Checks whether the class matches the type in the specification
|
||||
def self.matches?(spec)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
# Human readable type used in error messages
|
||||
def self.type_name
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
# Checks whether the provided value is of the given type
|
||||
def valid_value?(value)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
attr_reader :errors, :name, :spec, :value
|
||||
|
||||
def initialize(name:, spec:, value:)
|
||||
@name = name
|
||||
@errors = []
|
||||
|
||||
# Treat minimal spec definition (nil) as a valid hash:
|
||||
# spec:
|
||||
# inputs:
|
||||
# website:
|
||||
@spec = spec || {} # specification from input definition
|
||||
@value = value # actual value provided by the user
|
||||
|
||||
validate!
|
||||
end
|
||||
|
||||
def to_hash
|
||||
raise ArgumentNotValidError unless valid?
|
||||
|
||||
{ name => actual_value }
|
||||
end
|
||||
|
||||
def valid?
|
||||
@errors.none?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate!
|
||||
return error('required value has not been provided') if required_input? && value.nil?
|
||||
|
||||
# validate default value
|
||||
return error("default value is not a #{self.class.type_name}") if !required_input? && !valid_value?(default)
|
||||
|
||||
# validate provided value
|
||||
error("provided value is not a #{self.class.type_name}") unless valid_value?(value)
|
||||
end
|
||||
|
||||
def error(message)
|
||||
@errors.push("`#{name}` input: #{message}")
|
||||
end
|
||||
|
||||
def actual_value
|
||||
# nil check is to support boolean values.
|
||||
value.nil? ? default : value
|
||||
end
|
||||
|
||||
# An input specification without a default value is required.
|
||||
# For example:
|
||||
# ```yaml
|
||||
# spec:
|
||||
# inputs:
|
||||
# website:
|
||||
# ```
|
||||
def required_input?
|
||||
!spec.key?(:default)
|
||||
end
|
||||
|
||||
def default
|
||||
spec[:default]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
module Interpolation
|
||||
class Inputs
|
||||
class StringInput < BaseInput
|
||||
def self.matches?(spec)
|
||||
# The input spec can be `nil` when using a minimal specification
|
||||
# and also when `type` is not specified.
|
||||
#
|
||||
# ```yaml
|
||||
# spec:
|
||||
# inputs:
|
||||
# foo:
|
||||
# ```
|
||||
spec.nil? || (spec.is_a?(Hash) && [nil, type_name].include?(spec[:type]))
|
||||
end
|
||||
|
||||
def self.type_name
|
||||
'string'
|
||||
end
|
||||
|
||||
def valid_value?(value)
|
||||
value.nil? || value.is_a?(String)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -51441,6 +51441,9 @@ msgstr ""
|
|||
msgid "Vulnerability|Try it out"
|
||||
msgstr ""
|
||||
|
||||
msgid "Vulnerability|URL:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Vulnerability|Unmodified Response"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -145,7 +145,7 @@
|
|||
"gettext-parser": "^6.0.0",
|
||||
"graphql": "^15.7.2",
|
||||
"graphql-tag": "^2.11.0",
|
||||
"gridstack": "^7.3.0",
|
||||
"gridstack": "^8.3.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"immer": "^9.0.15",
|
||||
"ipaddr.js": "^1.9.1",
|
||||
|
|
|
@ -27,13 +27,6 @@ module QA
|
|||
Page::Project::PipelineEditor::New.perform(&:create_new_ci)
|
||||
|
||||
Page::Project::PipelineEditor::Show.perform do |show|
|
||||
# Editor should display default content when project does not have CI file yet
|
||||
# New MR checkbox should not be rendered when a new target branch is yet to be provided
|
||||
aggregate_failures 'check editor default conditions' do
|
||||
expect(show.editing_content).not_to be_empty
|
||||
expect(show).to have_no_new_mr_checkbox
|
||||
end
|
||||
|
||||
# The new MR checkbox is visible after a new branch name is set
|
||||
show.set_source_branch(SecureRandom.hex(10))
|
||||
expect(show).to have_new_mr_checkbox
|
||||
|
|
|
@ -11,6 +11,7 @@ RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_composition d
|
|||
let(:default_branch) { 'main' }
|
||||
let(:other_branch) { 'test' }
|
||||
let(:branch_with_invalid_ci) { 'despair' }
|
||||
let(:branch_without_ci) { 'empty' }
|
||||
|
||||
let(:default_content) { 'Default' }
|
||||
|
||||
|
@ -45,6 +46,7 @@ RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_composition d
|
|||
project.repository.create_file(user, project.ci_config_path_or_default, default_content, message: 'Create CI file for main', branch_name: default_branch)
|
||||
project.repository.create_file(user, project.ci_config_path_or_default, valid_content, message: 'Create CI file for test', branch_name: other_branch)
|
||||
project.repository.create_file(user, project.ci_config_path_or_default, invalid_content, message: 'Create CI file for test', branch_name: branch_with_invalid_ci)
|
||||
project.repository.create_file(user, 'index.js', "file", message: 'New js file', branch_name: branch_without_ci)
|
||||
|
||||
visit project_ci_pipeline_editor_path(project)
|
||||
wait_for_requests
|
||||
|
@ -62,6 +64,31 @@ RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_composition d
|
|||
end
|
||||
end
|
||||
|
||||
describe 'when there are no CI config file' do
|
||||
before do
|
||||
visit project_ci_pipeline_editor_path(project, branch_name: branch_without_ci)
|
||||
end
|
||||
|
||||
it 'renders the empty page', :aggregate_failures do
|
||||
expect(page).to have_content 'Optimize your workflow with CI/CD Pipelines'
|
||||
expect(page).to have_selector '[data-testid="create_new_ci_button"]'
|
||||
end
|
||||
|
||||
context 'when clicking on the create new CI button' do
|
||||
before do
|
||||
click_button 'Configure pipeline'
|
||||
end
|
||||
|
||||
it 'renders the source editor with default content', :aggregate_failures do
|
||||
expect(page).to have_selector('#source-editor-')
|
||||
|
||||
page.within('#source-editor-') do
|
||||
expect(page).to have_content('This file is a template, and might need editing before it works on your project.')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When CI yml has valid syntax' do
|
||||
before do
|
||||
visit project_ci_pipeline_editor_path(project, branch_name: other_branch)
|
||||
|
@ -149,15 +176,6 @@ RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_composition d
|
|||
end
|
||||
|
||||
shared_examples 'default branch switcher behavior' do
|
||||
def switch_to_branch(branch)
|
||||
find('[data-testid="branch-selector"]').click
|
||||
|
||||
page.within '[data-testid="branch-selector"]' do
|
||||
click_button branch
|
||||
wait_for_requests
|
||||
end
|
||||
end
|
||||
|
||||
it 'displays current branch' do
|
||||
page.within('[data-testid="branch-selector"]') do
|
||||
expect(page).to have_content(default_branch)
|
||||
|
@ -195,12 +213,20 @@ RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_composition d
|
|||
end
|
||||
|
||||
describe 'Branch Switcher' do
|
||||
def switch_to_branch(branch)
|
||||
# close button for the popover
|
||||
find('[data-testid="close-button"]').click
|
||||
find('[data-testid="branch-selector"]').click
|
||||
|
||||
page.within '[data-testid="branch-selector"]' do
|
||||
click_button branch
|
||||
wait_for_requests
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
visit project_ci_pipeline_editor_path(project)
|
||||
wait_for_requests
|
||||
|
||||
# close button for the popover
|
||||
find('[data-testid="close-button"]').click
|
||||
end
|
||||
|
||||
it_behaves_like 'default branch switcher behavior'
|
||||
|
@ -262,6 +288,24 @@ RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_composition d
|
|||
end
|
||||
|
||||
describe 'Commit Form' do
|
||||
context 'when targetting the main branch' do
|
||||
it 'does not show the option to create a Merge request', :aggregate_failures do
|
||||
expect(page).not_to have_selector('[data-testid="new-mr-checkbox"]')
|
||||
expect(page).not_to have_content('Start a new merge request with these changes')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when targetting any non-main branch' do
|
||||
before do
|
||||
find('#source-branch-field').set('new_branch', clear: :backspace)
|
||||
end
|
||||
|
||||
it 'shows the option to create a Merge request', :aggregate_failures do
|
||||
expect(page).to have_selector('[data-testid="new-mr-checkbox"]')
|
||||
expect(page).to have_content('Start a new merge request with these changes')
|
||||
end
|
||||
end
|
||||
|
||||
it 'is preserved when changing tabs' do
|
||||
find('#commit-message').set('message', clear: :backspace)
|
||||
find('#source-branch-field').set('new_branch', clear: :backspace)
|
||||
|
|
|
@ -382,7 +382,7 @@ describe('~/diffs/utils/tree_worker_utils', () => {
|
|||
},
|
||||
{
|
||||
type: 'tree',
|
||||
name: 'ee/lib/…/…/…/longtreenametomakepath',
|
||||
name: 'ee/lib/ee/gitlab/checks/longtreenametomakepath',
|
||||
tree: [
|
||||
{
|
||||
name: 'diff_check.rb',
|
||||
|
|
|
@ -238,36 +238,6 @@ describe('text_utility', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('truncatePathMiddleToLength', () => {
|
||||
it('does not truncate text', () => {
|
||||
expect(textUtils.truncatePathMiddleToLength('app/test', 50)).toEqual('app/test');
|
||||
});
|
||||
|
||||
it('truncates middle of the path', () => {
|
||||
expect(textUtils.truncatePathMiddleToLength('app/test/diff', 13)).toEqual('app/…/diff');
|
||||
});
|
||||
|
||||
it('truncates multiple times in the middle of the path', () => {
|
||||
expect(textUtils.truncatePathMiddleToLength('app/test/merge_request/diff', 13)).toEqual(
|
||||
'app/…/…/diff',
|
||||
);
|
||||
});
|
||||
|
||||
describe('given a path too long for the maxWidth', () => {
|
||||
it.each`
|
||||
path | maxWidth | result
|
||||
${'aa/bb/cc'} | ${1} | ${'…'}
|
||||
${'aa/bb/cc'} | ${2} | ${'…'}
|
||||
${'aa/bb/cc'} | ${3} | ${'…/…'}
|
||||
${'aa/bb/cc'} | ${4} | ${'…/…'}
|
||||
${'aa/bb/cc'} | ${5} | ${'…/…/…'}
|
||||
`('truncates ($path, $maxWidth) to $result', ({ path, maxWidth, result }) => {
|
||||
expect(result.length).toBeLessThanOrEqual(maxWidth);
|
||||
expect(textUtils.truncatePathMiddleToLength(path, maxWidth)).toEqual(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugifyWithUnderscore', () => {
|
||||
it('should replaces whitespaces with underscore and convert to lower case', () => {
|
||||
expect(textUtils.slugifyWithUnderscore('My Input String')).toEqual('my_input_string');
|
||||
|
|
|
@ -121,14 +121,15 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
|
|||
});
|
||||
|
||||
describe('fetch', () => {
|
||||
it('sets the data.collapsed property after a successfull call - multiPolling: false', async () => {
|
||||
it('calls fetchCollapsedData properly when multiPolling is false', async () => {
|
||||
const mockData = { headers: {}, status: HTTP_STATUS_OK, data: { vulnerabilities: [] } };
|
||||
createComponent({ propsData: { fetchCollapsedData: () => Promise.resolve(mockData) } });
|
||||
const fetchCollapsedData = jest.fn().mockResolvedValue(mockData);
|
||||
createComponent({ propsData: { fetchCollapsedData } });
|
||||
await waitForPromises();
|
||||
expect(wrapper.emitted('input')[0][0]).toEqual({ collapsed: mockData.data, expanded: null });
|
||||
expect(fetchCollapsedData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sets the data.collapsed property after a successfull call - multiPolling: true', async () => {
|
||||
it('calls fetchCollapsedData properly when multiPolling is true', async () => {
|
||||
const mockData1 = {
|
||||
headers: {},
|
||||
status: HTTP_STATUS_OK,
|
||||
|
@ -140,22 +141,22 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
|
|||
data: { vulnerabilities: [{ vuln: 2 }] },
|
||||
};
|
||||
|
||||
const fetchCollapsedData = [
|
||||
jest.fn().mockResolvedValue(mockData1),
|
||||
jest.fn().mockResolvedValue(mockData2),
|
||||
];
|
||||
|
||||
createComponent({
|
||||
propsData: {
|
||||
multiPolling: true,
|
||||
fetchCollapsedData: () => [
|
||||
() => Promise.resolve(mockData1),
|
||||
() => Promise.resolve(mockData2),
|
||||
],
|
||||
fetchCollapsedData: () => fetchCollapsedData,
|
||||
},
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.emitted('input')[0][0]).toEqual({
|
||||
collapsed: [mockData1.data, mockData2.data],
|
||||
expanded: null,
|
||||
});
|
||||
expect(fetchCollapsedData[0]).toHaveBeenCalledTimes(1);
|
||||
expect(fetchCollapsedData[1]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws an error when the handler does not include headers or status objects', async () => {
|
||||
|
@ -328,11 +329,12 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
|
|||
};
|
||||
|
||||
const fetchExpandedData = jest.fn().mockResolvedValue(mockDataExpanded);
|
||||
const fetchCollapsedData = jest.fn().mockResolvedValue(mockDataCollapsed);
|
||||
|
||||
await createComponent({
|
||||
propsData: {
|
||||
isCollapsible: true,
|
||||
fetchCollapsedData: () => Promise.resolve(mockDataCollapsed),
|
||||
fetchCollapsedData,
|
||||
fetchExpandedData,
|
||||
},
|
||||
});
|
||||
|
@ -340,17 +342,8 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
|
|||
findToggleButton().vm.$emit('click');
|
||||
await waitForPromises();
|
||||
|
||||
// First fetches the collapsed data
|
||||
expect(wrapper.emitted('input')[0][0]).toEqual({
|
||||
collapsed: mockDataCollapsed.data,
|
||||
expanded: null,
|
||||
});
|
||||
|
||||
// Then fetches the expanded data
|
||||
expect(wrapper.emitted('input')[1][0]).toEqual({
|
||||
collapsed: null,
|
||||
expanded: mockDataExpanded.data,
|
||||
});
|
||||
expect(fetchCollapsedData).toHaveBeenCalledTimes(1);
|
||||
expect(fetchExpandedData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Triggering a click does not call the expanded data again
|
||||
findToggleButton().vm.$emit('click');
|
||||
|
@ -371,14 +364,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
|
|||
findToggleButton().vm.$emit('click');
|
||||
await waitForPromises();
|
||||
|
||||
// First fetches the collapsed data
|
||||
expect(wrapper.emitted('input')[0][0]).toEqual({
|
||||
collapsed: undefined,
|
||||
expanded: null,
|
||||
});
|
||||
|
||||
expect(fetchExpandedData).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.emitted('input')).toHaveLength(1); // Should not an emit an input call because request failed
|
||||
|
||||
findToggleButton().vm.$emit('click');
|
||||
await waitForPromises();
|
||||
|
|
|
@ -9,6 +9,8 @@ RSpec.describe Gitlab::Ci::Config::Yaml::Interpolator, feature_category: :pipeli
|
|||
|
||||
subject { described_class.new(result, arguments) }
|
||||
|
||||
# Remove shared examples when ci_interpolation_inputs_refactor is removed.
|
||||
shared_examples 'interpolator' do
|
||||
context 'when input data is valid' do
|
||||
let(:header) do
|
||||
{ spec: { inputs: { website: nil } } }
|
||||
|
@ -25,6 +27,7 @@ RSpec.describe Gitlab::Ci::Config::Yaml::Interpolator, feature_category: :pipeli
|
|||
it 'correctly interpolates the config' do
|
||||
subject.interpolate!
|
||||
|
||||
expect(subject).to be_interpolated
|
||||
expect(subject).to be_valid
|
||||
expect(subject.to_hash).to eq({ test: 'deploy gitlab.com' })
|
||||
end
|
||||
|
@ -90,28 +93,6 @@ RSpec.describe Gitlab::Ci::Config::Yaml::Interpolator, feature_category: :pipeli
|
|||
end
|
||||
end
|
||||
|
||||
context 'when provided interpolation argument is invalid' do
|
||||
let(:header) do
|
||||
{ spec: { inputs: { website: nil } } }
|
||||
end
|
||||
|
||||
let(:content) do
|
||||
{ test: 'deploy $[[ inputs.website ]]' }
|
||||
end
|
||||
|
||||
let(:arguments) do
|
||||
{ website: ['gitlab.com'] }
|
||||
end
|
||||
|
||||
it 'correctly interpolates the config' do
|
||||
subject.interpolate!
|
||||
|
||||
expect(subject).not_to be_valid
|
||||
expect(subject.error_message).to eq subject.errors.first
|
||||
expect(subject.errors).to include 'unsupported value in input argument `website`'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple interpolation blocks are invalid' do
|
||||
let(:header) do
|
||||
{ spec: { inputs: { website: nil } } }
|
||||
|
@ -129,7 +110,8 @@ RSpec.describe Gitlab::Ci::Config::Yaml::Interpolator, feature_category: :pipeli
|
|||
subject.interpolate!
|
||||
|
||||
expect(subject).not_to be_valid
|
||||
expect(subject.error_message).to eq 'interpolation interrupted by errors, unknown interpolation key: `something`'
|
||||
expect(subject.error_message)
|
||||
.to eq 'interpolation interrupted by errors, unknown interpolation key: `something`'
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -148,7 +130,6 @@ RSpec.describe Gitlab::Ci::Config::Yaml::Interpolator, feature_category: :pipeli
|
|||
it 'returns original content' do
|
||||
subject.interpolate!
|
||||
|
||||
expect(subject).not_to be_interpolated
|
||||
expect(subject.to_hash).to eq(content)
|
||||
end
|
||||
end
|
||||
|
@ -169,9 +150,63 @@ RSpec.describe Gitlab::Ci::Config::Yaml::Interpolator, feature_category: :pipeli
|
|||
it 'correctly interpolates content' do
|
||||
subject.interpolate!
|
||||
|
||||
expect(subject).to be_interpolated
|
||||
expect(subject.to_hash).to eq({ test: 'deploy gitlab.com' })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'interpolator' do
|
||||
context 'when provided interpolation argument is invalid' do
|
||||
let(:header) do
|
||||
{ spec: { inputs: { website: nil } } }
|
||||
end
|
||||
|
||||
let(:content) do
|
||||
{ test: 'deploy $[[ inputs.website ]]' }
|
||||
end
|
||||
|
||||
let(:arguments) do
|
||||
{ website: ['gitlab.com'] }
|
||||
end
|
||||
|
||||
it 'returns an error' do
|
||||
subject.interpolate!
|
||||
|
||||
expect(subject).not_to be_valid
|
||||
expect(subject.error_message).to eq subject.errors.first
|
||||
expect(subject.errors).to include '`website` input: provided value is not a string'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature flag ci_interpolation_inputs_refactor is disabled' do
|
||||
before do
|
||||
stub_feature_flags(ci_interpolation_inputs_refactor: false)
|
||||
end
|
||||
|
||||
it_behaves_like 'interpolator'
|
||||
|
||||
context 'when provided interpolation argument is invalid' do
|
||||
let(:header) do
|
||||
{ spec: { inputs: { website: nil } } }
|
||||
end
|
||||
|
||||
let(:content) do
|
||||
{ test: 'deploy $[[ inputs.website ]]' }
|
||||
end
|
||||
|
||||
let(:arguments) do
|
||||
{ website: ['gitlab.com'] }
|
||||
end
|
||||
|
||||
it 'returns an error' do
|
||||
subject.interpolate!
|
||||
|
||||
expect(subject).not_to be_valid
|
||||
expect(subject.error_message).to eq subject.errors.first
|
||||
expect(subject.errors).to include 'unsupported value in input argument `website`'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Ci::Interpolation::Inputs::BaseInput, feature_category: :pipeline_composition do
|
||||
describe '.matches?' do
|
||||
it 'is not implemented' do
|
||||
expect { described_class.matches?(double) }.to raise_error(NotImplementedError)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.type_name' do
|
||||
it 'is not implemented' do
|
||||
expect { described_class.type_name }.to raise_error(NotImplementedError)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#valid_value?' do
|
||||
it 'is not implemented' do
|
||||
expect do
|
||||
described_class.new(
|
||||
name: 'website', spec: { website: nil }, value: { website: 'example.com' }
|
||||
).valid_value?('test')
|
||||
end.to raise_error(NotImplementedError)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,97 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Ci::Interpolation::Inputs, feature_category: :pipeline_composition do
|
||||
let(:inputs) { described_class.new(specs, args) }
|
||||
let(:specs) { { foo: { default: 'bar' } } }
|
||||
let(:args) { {} }
|
||||
|
||||
context 'when inputs are valid' do
|
||||
where(:specs, :args, :merged) do
|
||||
[
|
||||
[
|
||||
{ foo: { default: 'bar' } }, {},
|
||||
{ foo: 'bar' }
|
||||
],
|
||||
[
|
||||
{ foo: { default: 'bar' } }, { foo: 'test' },
|
||||
{ foo: 'test' }
|
||||
],
|
||||
[
|
||||
{ foo: nil }, { foo: 'bar' },
|
||||
{ foo: 'bar' }
|
||||
],
|
||||
[
|
||||
{ foo: { type: 'string' } }, { foo: 'bar' },
|
||||
{ foo: 'bar' }
|
||||
],
|
||||
[
|
||||
{ foo: { type: 'string', default: 'bar' } }, { foo: 'test' },
|
||||
{ foo: 'test' }
|
||||
],
|
||||
[
|
||||
{ foo: { type: 'string', default: 'bar' } }, {},
|
||||
{ foo: 'bar' }
|
||||
],
|
||||
[
|
||||
{ foo: { default: 'bar' }, baz: nil }, { baz: 'test' },
|
||||
{ foo: 'bar', baz: 'test' }
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
with_them do
|
||||
it 'contains the merged inputs' do
|
||||
expect(inputs).to be_valid
|
||||
expect(inputs.to_hash).to eq(merged)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inputs are invalid' do
|
||||
where(:specs, :args, :errors) do
|
||||
[
|
||||
[
|
||||
{ foo: nil }, { foo: 'bar', test: 'bar' },
|
||||
['unknown input arguments: test']
|
||||
],
|
||||
[
|
||||
{ foo: nil }, { test: 'bar', gitlab: '1' },
|
||||
['unknown input arguments: test, gitlab', '`foo` input: required value has not been provided']
|
||||
],
|
||||
[
|
||||
{ foo: 123 }, {},
|
||||
['unknown input specification for `foo` (valid types: string)']
|
||||
],
|
||||
[
|
||||
{ a: nil, foo: 123 }, { a: '123' },
|
||||
['unknown input specification for `foo` (valid types: string)']
|
||||
],
|
||||
[
|
||||
{ foo: nil }, {},
|
||||
['`foo` input: required value has not been provided']
|
||||
],
|
||||
[
|
||||
{ foo: { default: 123 } }, { foo: 'test' },
|
||||
['`foo` input: default value is not a string']
|
||||
],
|
||||
[
|
||||
{ foo: { default: 'test' } }, { foo: 123 },
|
||||
['`foo` input: provided value is not a string']
|
||||
],
|
||||
[
|
||||
{ foo: nil }, { foo: 123 },
|
||||
['`foo` input: provided value is not a string']
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
with_them do
|
||||
it 'contains the merged inputs', :aggregate_failures do
|
||||
expect(inputs).not_to be_valid
|
||||
expect(inputs.errors).to contain_exactly(*errors)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -117,6 +117,10 @@ module.exports = function storybookWebpackConfig({ config }) {
|
|||
config.resolve.extensions = Array.from(
|
||||
new Set([...config.resolve.extensions, ...gitlabWebpackConfig.resolve.extensions]),
|
||||
);
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
gridstack: require.resolve('gridstack/dist/es5/gridstack.js'),
|
||||
};
|
||||
|
||||
// Replace any Storybook-defined CSS loaders with our custom one.
|
||||
config.module.rules = [
|
||||
|
|
|
@ -6746,10 +6746,10 @@ graphql@^15.7.2:
|
|||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.7.2.tgz#85ab0eeb83722977151b3feb4d631b5f2ab287ef"
|
||||
integrity sha512-AnnKk7hFQFmU/2I9YSQf3xw44ctnSFCfp3zE0N6W174gqe9fWG/2rKaKxROK7CcI3XtERpjEKFqts8o319Kf7A==
|
||||
|
||||
gridstack@^7.3.0:
|
||||
version "7.3.0"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-7.3.0.tgz#7b32395edcd885bc39b84068ac86f2831f7a2451"
|
||||
integrity sha512-JKZgsHzm1ljkn1NnBZpf8j4NDOBCXTuw0m1ZC0sr6NKUh0BFWzXAONIxtX1hWGUVeKLj5l1VcmnTwCXw5ypDNw==
|
||||
gridstack@^8.3.0:
|
||||
version "8.3.0"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-8.3.0.tgz#4c79f8b8c4cffeb3664266108e38ed91b3d0f7b4"
|
||||
integrity sha512-RcL2xskAYKOpakvpSwHdKheG7C7YgNY7777C5m+T1JMjSgcmEc3qPBM573l0NuyjMz4Errx1/3p+rMgUfF4+mw==
|
||||
|
||||
gzip-size@^6.0.0:
|
||||
version "6.0.0"
|
||||
|
|
Loading…
Reference in New Issue