提交 f149549c 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 b001207c
<script>
import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import ImportedProjectTableRow from './imported_project_table_row.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue';
import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue';
import eventHub from '../event_hub';
const reposFetchThrottleDelay = 1000;
......@@ -15,8 +15,9 @@ export default {
components: {
ImportedProjectTableRow,
ProviderRepoTableRow,
LoadingButton,
IncompatibleRepoTableRow,
GlLoadingIcon,
GlButton,
},
props: {
providerTitle: {
......@@ -26,8 +27,25 @@ export default {
},
computed: {
...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos', 'filter']),
...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']),
...mapState([
'importedProjects',
'providerRepos',
'incompatibleRepos',
'isLoadingRepos',
'filter',
]),
...mapGetters([
'isImportingAnyRepo',
'hasProviderRepos',
'hasImportedProjects',
'hasIncompatibleRepos',
]),
importAllButtonText() {
return this.hasIncompatibleRepos
? __('Import all compatible repositories')
: __('Import all repositories');
},
emptyStateText() {
return sprintf(__('No %{providerTitle} repositories found'), {
......@@ -68,7 +86,6 @@ export default {
},
throttledFetchRepos: throttle(function fetch() {
eventHub.$off('importAll');
this.fetchRepos();
}, reposFetchThrottleDelay),
},
......@@ -80,17 +97,24 @@ export default {
<p class="light text-nowrap mt-2">
{{ s__('ImportProjects|Select the projects you want to import') }}
</p>
<div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
<loading-button
container-class="btn btn-success js-import-all"
<template v-if="hasIncompatibleRepos">
<slot name="incompatible-repos-warning"> </slot>
</template>
<div
v-if="!isLoadingRepos"
class="d-flex justify-content-between align-items-end flex-wrap mb-3"
>
<gl-button
variant="success"
:loading="isImportingAnyRepo"
:label="__('Import all repositories')"
:disabled="!hasProviderRepos"
type="button"
@click="importAll"
/>
<form novalidate @submit.prevent>
>
{{ importAllButtonText }}
</gl-button>
<slot name="actions"></slot>
<form class="gl-ml-auto" novalidate @submit.prevent>
<input
:value="filter"
data-qa-selector="githubish_import_filter_field"
......@@ -109,7 +133,10 @@ export default {
class="js-loading-button-icon import-projects-loading-icon"
size="md"
/>
<div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive">
<div
v-else-if="hasProviderRepos || hasImportedProjects || hasIncompatibleRepos"
class="table-responsive"
>
<table class="table import-table">
<thead>
<th class="import-jobs-from-col">{{ fromHeaderText }}</th>
......@@ -124,6 +151,11 @@ export default {
:project="project"
/>
<provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" />
<incompatible-repo-table-row
v-for="repo in incompatibleRepos"
:key="repo.id"
:repo="repo"
/>
</tbody>
</table>
</div>
......
<script>
import { GlBadge } from '@gitlab/ui';
export default {
components: {
GlBadge,
},
props: {
repo: {
type: Object,
required: true,
},
},
};
</script>
<template>
<tr class="import-row">
<td>
<a :href="repo.providerLink" rel="noreferrer noopener" target="_blank">
{{ repo.fullName }}
</a>
</td>
<td></td>
<td></td>
<td>
<gl-badge variant="danger">{{ __('Incompatible project') }}</gl-badge>
</td>
</tr>
</template>
......@@ -53,7 +53,11 @@ export default {
},
created() {
eventHub.$on('importAll', () => this.importRepo());
eventHub.$on('importAll', this.importRepo);
},
beforeDestroy() {
eventHub.$off('importAll', this.importRepo);
},
methods: {
......
import Vue from 'vue';
import { mapActions } from 'vuex';
import Translate from '../vue_shared/translate';
import ImportProjectsTable from './components/import_projects_table.vue';
import { parseBoolean } from '../lib/utils/common_utils';
......@@ -7,42 +6,44 @@ import createStore from './store';
Vue.use(Translate);
export default function mountImportProjectsTable(mountElement) {
if (!mountElement) return undefined;
export function initStoreFromElement(element) {
const {
reposPath,
provider,
providerTitle,
canSelectNamespace,
jobsPath,
importPath,
ciCdOnly,
} = mountElement.dataset;
} = element.dataset;
const store = createStore();
return new Vue({
el: mountElement,
store,
return createStore({
reposPath,
provider,
jobsPath,
importPath,
defaultTargetNamespace: gon.current_username,
ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace),
});
}
created() {
this.setInitialData({
reposPath,
provider,
jobsPath,
importPath,
defaultTargetNamespace: gon.current_username,
ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace),
});
},
export function initPropsFromElement(element) {
return {
providerTitle: element.dataset.providerTitle,
};
}
methods: {
...mapActions(['setInitialData', 'setFilter']),
},
export default function mountImportProjectsTable(mountElement) {
if (!mountElement) return undefined;
const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement);
return new Vue({
el: mountElement,
store,
render(createElement) {
return createElement(ImportProjectsTable, { props: { providerTitle } });
return createElement(ImportProjectsTable, { props });
},
});
}
......@@ -19,23 +19,18 @@ export const restartJobsPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter);
export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos);
export const receiveReposSuccess = ({ commit }, repos) =>
commit(types.RECEIVE_REPOS_SUCCESS, repos);
export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR);
export const fetchRepos = ({ state, dispatch }) => {
export const fetchRepos = ({ state, dispatch, commit }) => {
dispatch('stopJobsPolling');
dispatch('requestRepos');
commit(types.REQUEST_REPOS);
const { provider } = state;
return axios
.get(reposPathWithFilter(state))
.then(({ data }) =>
dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })),
)
.then(() => dispatch('fetchJobs'))
.catch(() => {
......@@ -45,19 +40,14 @@ export const fetchRepos = ({ state, dispatch }) => {
}),
);
dispatch('receiveReposError');
commit(types.RECEIVE_REPOS_ERROR);
});
};
export const requestImport = ({ commit, state }, repoId) => {
if (!state.reposBeingImported.includes(repoId)) commit(types.REQUEST_IMPORT, repoId);
};
export const receiveImportSuccess = ({ commit }, { importedProject, repoId }) =>
commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject, repoId });
export const receiveImportError = ({ commit }, repoId) =>
commit(types.RECEIVE_IMPORT_ERROR, repoId);
export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, repo }) => {
dispatch('requestImport', repo.id);
export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo }) => {
if (!state.reposBeingImported.includes(repo.id)) {
commit(types.REQUEST_IMPORT, repo.id);
}
return axios
.post(state.importPath, {
......@@ -67,7 +57,7 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep
target_namespace: targetNamespace,
})
.then(({ data }) =>
dispatch('receiveImportSuccess', {
commit(types.RECEIVE_IMPORT_SUCCESS, {
importedProject: convertObjectPropsToCamelCase(data, { deep: true }),
repoId: repo.id,
}),
......@@ -75,13 +65,14 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep
.catch(() => {
createFlash(s__('ImportProjects|Importing the project failed'));
dispatch('receiveImportError', { repoId: repo.id });
commit(types.RECEIVE_IMPORT_ERROR, repo.id);
});
};
export const receiveJobsSuccess = ({ commit }, updatedProjects) =>
commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects);
export const fetchJobs = ({ state, dispatch }) => {
export const fetchJobs = ({ state, commit, dispatch }) => {
const { filter } = state;
if (eTagPoll) {
......@@ -95,7 +86,7 @@ export const fetchJobs = ({ state, dispatch }) => {
},
method: 'fetchJobs',
successCallback: ({ data }) =>
dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
commit(types.RECEIVE_JOBS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })),
errorCallback: () =>
createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')),
data: { filter },
......
......@@ -21,6 +21,8 @@ export const hasProviderRepos = state => state.providerRepos.length > 0;
export const hasImportedProjects = state => state.importedProjects.length > 0;
export const hasIncompatibleRepos = state => state.incompatibleRepos.length > 0;
export const reposPathWithFilter = ({ reposPath, filter = '' }) =>
filter ? `${reposPath}?filter=${filter}` : reposPath;
export const jobsPathWithFilter = ({ jobsPath, filter = '' }) =>
......
......@@ -9,9 +9,9 @@ Vue.use(Vuex);
export { state, actions, getters, mutations };
export default () =>
export default initialState =>
new Vuex.Store({
state: state(),
state: { ...state(), ...initialState },
actions,
mutations,
getters,
......
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const REQUEST_REPOS = 'REQUEST_REPOS';
export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS';
export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR';
......
......@@ -2,10 +2,6 @@ import Vue from 'vue';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_DATA](state, data) {
Object.assign(state, data);
},
[types.SET_FILTER](state, filter) {
state.filter = filter;
},
......@@ -14,11 +10,15 @@ export default {
state.isLoadingRepos = true;
},
[types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) {
[types.RECEIVE_REPOS_SUCCESS](
state,
{ importedProjects, providerRepos, incompatibleRepos, namespaces },
) {
state.isLoadingRepos = false;
state.importedProjects = importedProjects;
state.providerRepos = providerRepos;
state.incompatibleRepos = incompatibleRepos ?? [];
state.namespaces = namespaces;
},
......
......@@ -7,6 +7,7 @@ export default () => ({
currentUsername: '',
importedProjects: [],
providerRepos: [],
incompatibleRepos: [],
namespaces: [],
reposBeingImported: [],
isLoadingRepos: false,
......
......@@ -245,11 +245,11 @@ export default {
<div class="zen-backdrop">
<slot name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave"
class="zen-control zen-control-leave js-zen-leave gl-text-gray-700"
href="#"
:aria-label="__('Enter zen mode')"
:aria-label="__('Leave zen mode')"
>
<icon :size="32" name="screen-normal" />
<icon :size="16" name="screen-normal" />
</a>
<markdown-toolbar
:markdown-docs-path="markdownDocsPath"
......
......@@ -10,6 +10,9 @@ module Types
field :path, GraphQL::STRING_TYPE, null: true,
description: 'Path to a file with the dashboard definition'
field :schema_validation_warnings, [GraphQL::STRING_TYPE], null: true,
description: 'Dashboard schema validation warnings'
field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true,
description: 'Annotations added to the dashboard',
resolver: Resolvers::Metrics::Dashboards::AnnotationResolver
......
......@@ -39,7 +39,8 @@ module Ci
dotenv: '.env',
cobertura: 'cobertura-coverage.xml',
terraform: 'tfplan.json',
cluster_applications: 'gl-cluster-applications.json'
cluster_applications: 'gl-cluster-applications.json',
requirements: 'requirements.json'
}.freeze
INTERNAL_TYPES = {
......@@ -71,7 +72,8 @@ module Ci
license_management: :raw,
license_scanning: :raw,
performance: :raw,
terraform: :raw
terraform: :raw,
requirements: :raw
}.freeze
DOWNLOADABLE_TYPES = %w[
......@@ -90,6 +92,7 @@ module Ci
metrics
performance
sast
requirements
].freeze
TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
......@@ -182,7 +185,8 @@ module Ci
terraform: 18, # Transformed json
accessibility: 19,
cluster_applications: 20,
secret_detection: 21 ## EE-specific
secret_detection: 21, ## EE-specific
requirements: 22 ## EE-specific
}
enum file_format: {
......
......@@ -15,15 +15,17 @@ module PerformanceMonitoring
end
def find_for(project:, user:, path:, options: {})
dashboard_response = Gitlab::Metrics::Dashboard::Finder.find(project, user, options.merge(dashboard_path: path))
return unless dashboard_response[:status] == :success
new(
{
path: path,
environment: options[:environment]
}.merge(dashboard_response[:dashboard])
)
template = { path: path, environment: options[:environment] }
rsp = Gitlab::Metrics::Dashboard::Finder.find(project, user, options.merge(dashboard_path: path))
case rsp[:http_status] || rsp[:status]
when :success
new(template.merge(rsp[:dashboard] || {})) # when there is empty dashboard file returned rsp is still a success
when :unprocessable_entity
new(template) # validation error
else
nil # any other error
end
end
private
......@@ -42,6 +44,15 @@ module PerformanceMonitoring
self.as_json(only: yaml_valid_attributes).to_yaml
end
# This method is planned to be refactored as a part of https://gitlab.com/gitlab-org/gitlab/-/issues/219398
# implementation. For new existing logic was reused to faster deliver MVC
def schema_validation_warnings
self.class.from_json(self.as_json)
nil
rescue ActiveModel::ValidationError => exception
exception.model.errors.map { |attr, error| "#{attr}: #{error}" }
end
private
def yaml_valid_attributes
......
......@@ -3,7 +3,7 @@
- if defined?(@breadcrumb_dropdown_links) && @breadcrumb_dropdown_links.key?(dropdown_location)
%li.dropdown
%button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander{ type: "button", data: { toggle: "dropdown", container: "body" }, "aria-label": button_tooltip, title: button_tooltip }
= icon("ellipsis-h")
= sprite_icon("ellipsis_h", size: 12)
= sprite_icon("angle-right", size: 8, css_class: "breadcrumbs-list-angle")
.dropdown-menu
%ul
......
......@@ -15,5 +15,5 @@
qa_selector: qa_selector }
- else
= text_area_tag attr, current_text, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
= icon('compress')
%a.zen-control.zen-control-leave.js-zen-leave.gl-text-gray-700{ href: "#" }
= sprite_icon('compress', size: 16)
---
title: Add dashboard schema validation warnings as metrics dashboard GraphQL field
merge_request: 33592
author:
type: added
......@@ -242,6 +242,8 @@
- 1
- - repository_update_remote_mirror
- 1
- - requirements_management_process_requirements_reports
- 1
- - security_scans
- 2
- - self_monitoring_project_create
......
# frozen_string_literal: true
class ScheduleFixRubyObjectInAuditEvents < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_audit_events_on_ruby_object_in_details'
INTERVAL = 2.minutes.to_i
BATCH_SIZE = 1_000
MIGRATION = 'FixRubyObjectInAuditEvents'
disable_ddl_transaction!
class AuditEvent < ActiveRecord::Base
self.table_name = 'audit_events'
include ::EachBatch
end
def up
return unless Gitlab.ee?
# create temporary index for audit_events with ruby/object in details field, may take well over 1h
add_concurrent_index(:audit_events, :id, where: "details ~~ '%ruby/object%'", name: INDEX_NAME)
relation = AuditEvent.where("details ~~ '%ruby/object%'")
queue_background_migration_jobs_by_range_at_intervals(
relation,
MIGRATION,
INTERVAL,
batch_size: BATCH_SIZE
)
end
def down
# temporary index is to be dropped in a different migration in an upcoming release
# https://gitlab.com/gitlab-org/gitlab/issues/196842
remove_concurrent_index_by_name(:audit_events, INDEX_NAME)
end
end
......@@ -9229,6 +9229,8 @@ CREATE INDEX index_approvers_on_user_id ON public.approvers USING btree (user_id
CREATE INDEX index_audit_events_on_entity_id_and_entity_type_and_id_desc ON public.audit_events USING btree (entity_id, entity_type, id DESC);
CREATE INDEX index_audit_events_on_ruby_object_in_details ON public.audit_events USING btree (id) WHERE (details ~~ '%ruby/object%'::text);
CREATE INDEX index_award_emoji_on_awardable_type_and_awardable_id ON public.award_emoji USING btree (awardable_type, awardable_id);
CREATE INDEX index_award_emoji_on_user_id_and_name ON public.award_emoji USING btree (user_id, name);
......@@ -13764,6 +13766,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200514000340
20200515155620
20200518091745
20200518114540
20200518133123
20200519074709
20200519101002
......
......@@ -1725,6 +1725,13 @@ type CreateSnippetPayload {
snippet: Snippet
}
enum DastScanTypeEnum {
"""
Passive DAST scan. This scan will not make active attacks against the target site.
"""
PASSIVE
}
"""
Autogenerated input type of DeleteAnnotation
"""
......@@ -7040,6 +7047,11 @@ type MetricsDashboard {
Path to a file with the dashboard definition
"""
path: String
"""
Dashboard schema validation warnings
"""
schemaValidationWarnings: [String!]
}
type MetricsDashboardAnnotation {
......@@ -7269,6 +7281,7 @@ type Mutation {
mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload
removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload
removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload
runDastScan(input: RunDASTScanInput!): RunDASTScanPayload
todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload
todoRestore(input: TodoRestoreInput!): TodoRestorePayload
todoRestoreMany(input: TodoRestoreManyInput!): TodoRestoreManyPayload
......@@ -10297,6 +10310,56 @@ type RootStorageStatistics {
wikiSize: Float!
}
"""
Autogenerated input type of RunDASTScan
"""
input RunDASTScanInput {
"""
The branch to be associated with the scan.
"""
branch: String!
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The project the DAST scan belongs to.
"""
projectPath: ID!
"""
The type of scan to be run.
"""
scanType: DastScanTypeEnum!
"""
The URL of the target to be scanned.
"""
targetUrl: String!
}
"""
Autogenerated return type of RunDASTScan
"""
type RunDASTScanPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
URL of the pipeline that was created.
"""
pipelineUrl: String
}
"""
A Sentry error.
"""
......
......@@ -4570,6 +4570,23 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "DastScanTypeEnum",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "PASSIVE",
"description": "Passive DAST scan. This scan will not make active attacks against the target site.",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DeleteAnnotationInput",
......@@ -19616,6 +19633,28 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "schemaValidationWarnings",
"description": "Dashboard schema validation warnings",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
......@@ -21284,6 +21323,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "runDastScan",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "RunDASTScanInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "RunDASTScanPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "todoMarkDone",
"description": null,
......@@ -30138,6 +30204,150 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "RunDASTScanInput",
"description": "Autogenerated input type of RunDASTScan",
"fields": null,
"inputFields": [
{
"name": "projectPath",
"description": "The project the DAST scan belongs to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "targetUrl",
"description": "The URL of the target to be scanned.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "branch",
"description": "The branch to be associated with the scan.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "scanType",
"description": "The type of scan to be run.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "DastScanTypeEnum",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "RunDASTScanPayload",
"description": "Autogenerated return type of RunDASTScan",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelineUrl",
"description": "URL of the pipeline that was created.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SentryDetailedError",
......@@ -1060,6 +1060,7 @@ Autogenerated return type of MergeRequestSetWip
| Name | Type | Description |
| --- | ---- | ---------- |
| `path` | String | Path to a file with the dashboard definition |
| `schemaValidationWarnings` | String! => Array | Dashboard schema validation warnings |
## MetricsDashboardAnnotation
......@@ -1450,6 +1451,16 @@ Counts of requirements by their state.
| `storageSize` | Float! | The total storage in bytes |
| `wikiSize` | Float! | The wiki size in bytes |
## RunDASTScanPayload
Autogenerated return type of RunDASTScan
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipelineUrl` | String | URL of the pipeline that was created. |
## SentryDetailedError
A Sentry error.
......
......@@ -383,6 +383,18 @@ Example:
Additional instructions here.
-->
### 13.0.1
As part of [deprecating Rack Attack throttles on Omnibus GitLab](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/4750), Rack Attack initializer on GitLab
was renamed from [`config/initializers/rack_attack_new.rb` to `config/initializers/rack_attack.rb`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33072).
If this file exists on your installation, consider creating a backup before updating:
```shell
cd /home/git/gitlab
cp config/initializers/rack_attack.rb config/initializers/rack_attack_backup.rb
```
## Troubleshooting
### 1. Revert the code to the previous version
......
......@@ -9,9 +9,10 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/14707) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.9.
The **Threat Monitoring** page provides metrics for the GitLab
application runtime security features. You can access these metrics by
navigating to your project's **Security & Compliance > Threat Monitoring** page.
The **Threat Monitoring** page provides metrics and policy management
for the GitLab application runtime security features. You can access
these by navigating to your project's **Security & Compliance > Threat
Monitoring** page.
GitLab supports statistics for the following security features:
......@@ -77,3 +78,41 @@ about your packet flow:
If a significant percentage of packets is dropped, you should
investigate it for potential threats by
[examining the Cilium logs](../../clusters/applications.md#install-cilium-using-gitlab-cicd).
## Container Network Policy management
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3328) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.1.
The **Threat Monitoring** page's **Policy** tab displays deployed
network policies for all available environments. You can check a
network policy's `yaml` manifest and toggle the policy's enforcement
status. This section has the following prerequisites:
- Your project contains at least one [environment](../../../ci/environments/index.md)
- You've [installed Cilium](../../clusters/applications.md#install-cilium-using-gitlab-cicd)
Network policies are fetched directly from the selected environment's
deployment platform. Changes performed outside of this tab are
reflected upon refresh. Enforcement status changes are deployed
directly to a deployment namespace of the selected environment.
NOTE: **Note:**
If you're using [Auto DevOps](../../../topics/autodevops/index.md) and
change a policy in this section, your `auto-deploy-values.yaml` file
doesn't update. Auto DevOps users must make changes by following
the [Container Network Policy documentation](../../../topics/autodevops/stages.md#network-policy).
### Changing enforcement status
To change a network policy's enforcement status:
- Click the network policy you want to update.
- Click the **Enforcement status** toggle to update the selected policy.
- Click the **Apply changes** button to deploy network policy changes.
NOTE: **Note:**
Disabled network policies have the
`network-policy.gitlab.com/disabled_by: gitlab` selector inside the
`podSelector` block. This narrows the scope of such a policy and as a
result it doesn't affect any pods. The policy itself is still deployed
to the corresponding deployment namespace.
......@@ -80,6 +80,14 @@ Once set, Code Owners are displayed in merge requests widgets:
NOTE: **Note**:
While the`CODEOWNERS` file can be used in addition to Merge Request [Approval Rules](merge_requests/merge_request_approvals.md#approval-rules) it can also be used as the sole driver of a Merge Request approval (without using [Approval Rules](merge_requests/merge_request_approvals.md#approval-rules)) by simply creating the file in one of the three locations specified above, configuring the Code Owners to be required approvers for [protected branches](protected_branches.md#protected-branches-approval-by-code-owners-premium) and then using [the syntax of Code Owners files](code_owners.md#the-syntax-of-code-owners-files) to specify the actual owners and granular permissions.
NOTE: **Note**:
Using Code Owners in conjunction with [Protected Branches Approvals](protected_branches.md#protected-branches-approval-by-code-owners-premium)
will prevent any user who is not specified in the `CODEOWNERS` file from pushing changes
for the specified files/paths, even if their role is included in the **Allowed to push** column.
This allows for a more inclusive push strategy, as administrators don't have to restrict developers
from pushing directly to the protected branch, but can restrict pushing to certain
files where a review by Code Owners is required.
## The syntax of Code Owners files
Files can be specified using the same kind of patterns you would use
......
......@@ -362,6 +362,8 @@ When **Metrics Dashboard YAML definition is invalid** at least one of the follow
1. `query_range: can't be blank` [learn more](#metrics-metrics-properties)
1. `unit: can't be blank` [learn more](#metrics-metrics-properties)
Metrics Dashboard YAML definition validation information is also available as a [GraphQL API field](../../../api/graphql/reference/index.md#metricsdashboard)
#### Dashboard YAML properties
Dashboards have several components:
......
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# Remove serialized Ruby object in audit_events
class FixRubyObjectInAuditEvents
def perform(start_id, stop_id)
end
end
end
end
Gitlab::BackgroundMigration::FixRubyObjectInAuditEvents.prepend_if_ee('EE::Gitlab::BackgroundMigration::FixRubyObjectInAuditEvents')
......@@ -14,7 +14,8 @@ module Gitlab
ALLOWED_KEYS =
%i[junit codequality sast secret_detection dependency_scanning container_scanning
dast performance license_management license_scanning metrics lsif
dotenv cobertura terraform accessibility cluster_applications].freeze
dotenv cobertura terraform accessibility cluster_applications
requirements].freeze
attributes ALLOWED_KEYS
......@@ -40,6 +41,7 @@ module Gitlab
validates :terraform, array_of_strings_or_string: true
validates :accessibility, array_of_strings_or_string: true
validates :cluster_applications, array_of_strings_or_string: true
validates :requirements, array_of_strings_or_string: true
end
end
......
......@@ -3091,9 +3091,6 @@ msgstr ""
msgid "Auto DevOps, runners and job artifacts"
msgstr ""
msgid "Auto License Compliance"
msgstr ""
msgid "Auto stop successfully canceled."
msgstr ""
......@@ -8417,9 +8414,6 @@ msgstr ""
msgid "Enter your password to approve"
msgstr ""
msgid "Enter zen mode"
msgstr ""
msgid "Environment"
msgstr ""
......@@ -10637,9 +10631,6 @@ msgstr ""
msgid "Gitea Import"
msgstr ""
msgid "Gitlab CI/CD"
msgstr ""
msgid "Gitlab Pages"
msgstr ""
......@@ -11700,6 +11691,9 @@ msgstr ""
msgid "Import all compatible projects"
msgstr ""
msgid "Import all compatible repositories"
msgstr ""
msgid "Import all projects"
msgstr ""
......@@ -11889,6 +11883,9 @@ msgstr ""
msgid "Incompatible options set!"
msgstr ""
msgid "Incompatible project"
msgstr ""
msgid "Indent"
msgstr ""
......@@ -12886,6 +12883,9 @@ msgstr ""
msgid "Leave the \"File type\" and \"Delivery method\" options on their default values."
msgstr ""
msgid "Leave zen mode"
msgstr ""
msgid "Let's Encrypt does not accept emails on example.com"
msgstr ""
......@@ -12925,15 +12925,9 @@ msgstr ""
msgid "LicenseCompliance|Deny"
msgstr ""
msgid "LicenseCompliance|Here you can allow or deny licenses for this project. Using %{ci} or %{license} will allow you to see if there are any unmanaged licenses and allow or deny them in merge request."
msgstr ""
msgid "LicenseCompliance|License"
msgstr ""
msgid "LicenseCompliance|License Compliance"
msgstr ""
msgid "LicenseCompliance|License Compliance detected %d license and policy violation for the source branch only; approval required"
msgid_plural "LicenseCompliance|License Compliance detected %d licenses and policy violations for the source branch only; approval required"
msgstr[0] ""
......
......@@ -42,5 +42,3 @@ module QA
end
end
end
QA::Page::Project::Settings::CICD.prepend_if_ee('QA::EE::Page::Project::Settings::CICD')
import { nextTick } from 'vue';
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import { state, actions, getters, mutations } from '~/import_projects/store';
import importProjectsTable from '~/import_projects/components/import_projects_table.vue';
import STATUS_MAP from '~/import_projects/constants';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import { state, getters } from '~/import_projects/store';
import eventHub from '~/import_projects/event_hub';
import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue';
import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue';
import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue';
import IncompatibleRepoTableRow from '~/import_projects/components/incompatible_repo_table_row.vue';
jest.mock('~/import_projects/event_hub', () => ({
$emit: jest.fn(),
}));
describe('ImportProjectsTable', () => {
let vm;
let wrapper;
const providerTitle = 'THE PROVIDER';
const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' };
const importedProject = {
......@@ -16,176 +26,164 @@ describe('ImportProjectsTable', () => {
importSource: 'importSource',
};
function initStore() {
const stubbedActions = {
...actions,
fetchJobs: jest.fn(),
fetchRepos: jest.fn(actions.requestRepos),
fetchImport: jest.fn(actions.requestImport),
};
const store = new Vuex.Store({
state: state(),
actions: stubbedActions,
mutations,
getters,
});
return store;
}
const findImportAllButton = () =>
wrapper
.findAll(GlButton)
.filter(w => w.props().variant === 'success')
.at(0);
function mountComponent() {
function createComponent({ state: initialState, getters: customGetters, slots } = {}) {
const localVue = createLocalVue();
localVue.use(Vuex);
const store = initStore();
const store = new Vuex.Store({
state: { ...state(), ...initialState },
getters: {
...getters,
...customGetters,
},
actions: {
fetchRepos: jest.fn(),
fetchReposFiltered: jest.fn(),
fetchJobs: jest.fn(),
stopJobsPolling: jest.fn(),
clearJobsEtagPoll: jest.fn(),
setFilter: jest.fn(),
},
});
const component = mount(importProjectsTable, {
wrapper = shallowMount(ImportProjectsTable, {
localVue,
store,
propsData: {
providerTitle,
},
slots,
});
return component.vm;
}
beforeEach(() => {
vm = mountComponent();
});
afterEach(() => {
vm.$destroy();
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
it('renders a loading icon while repos are loading', () =>
vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.js-loading-button-icon')).not.toBeNull();
}));
it('renders a table with imported projects and provider repos', () => {
vm.$store.dispatch('receiveReposSuccess', {
importedProjects: [importedProject],
providerRepos: [providerRepo],
namespaces: [{ path: 'path' }],
it('renders a loading icon while repos are loading', () => {
createComponent({
state: {
isLoadingRepos: true,
},
});
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull();
expect(vm.$el.querySelector('.table')).not.toBeNull();
expect(vm.$el.querySelector('.import-jobs-from-col').innerText).toMatch(
`From ${providerTitle}`,
);
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull();
});
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
});
it('renders an empty state if there are no imported projects or provider repos', () => {
vm.$store.dispatch('receiveReposSuccess', {
importedProjects: [],
providerRepos: [],
namespaces: [],
it('renders a table with imported projects and provider repos', () => {
createComponent({
state: {
importedProjects: [importedProject],
providerRepos: [providerRepo],
incompatibleRepos: [{ ...providerRepo, id: 11 }],
namespaces: [{ path: 'path' }],
},
});
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull();
expect(vm.$el.querySelector('.table')).toBeNull();
expect(vm.$el.innerText).toMatch(`No ${providerTitle} repositories found`);
});
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
expect(wrapper.contains('table')).toBe(true);
expect(
wrapper
.findAll('th')
.filter(w => w.text() === `From ${providerTitle}`)
.isEmpty(),
).toBe(false);
expect(wrapper.contains(ProviderRepoTableRow)).toBe(true);
expect(wrapper.contains(ImportedProjectTableRow)).toBe(true);
expect(wrapper.contains(IncompatibleRepoTableRow)).toBe(true);
});
it('shows loading spinner when bulk import button is clicked', () => {
vm.$store.dispatch('receiveReposSuccess', {
importedProjects: [],
providerRepos: [providerRepo],
namespaces: [{ path: 'path' }],
});
return vm
.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.js-imported-project')).toBeNull();
expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull();
vm.$el.querySelector('.js-import-all').click();
})
.then(() => vm.$nextTick())
.then(() => {
expect(vm.$el.querySelector('.js-import-all .js-loading-button-icon')).not.toBeNull();
it.each`
hasIncompatibleRepos | buttonText
${false} | ${'Import all repositories'}
${true} | ${'Import all compatible repositories'}
`(
'import all button has "$buttonText" text when hasIncompatibleRepos is $hasIncompatibleRepos',
({ hasIncompatibleRepos, buttonText }) => {
createComponent({
state: {
providerRepos: [providerRepo],
},
getters: {
hasIncompatibleRepos: () => hasIncompatibleRepos,
},
});
});
it('imports provider repos if bulk import button is clicked', () => {
mountComponent();
expect(findImportAllButton().text()).toBe(buttonText);
},
);
vm.$store.dispatch('receiveReposSuccess', {
importedProjects: [],
providerRepos: [providerRepo],
namespaces: [{ path: 'path' }],
it('renders an empty state if there are no projects available', () => {
createComponent({
state: {
importedProjects: [],
providerRepos: [],
incompatibleProjects: [],
},
});
return vm
.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.js-imported-project')).toBeNull();
expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull();
vm.$store.dispatch('receiveImportSuccess', { importedProject, repoId: providerRepo.id });
})
.then(() => vm.$nextTick())
.then(() => {
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
expect(vm.$el.querySelector('.js-provider-repo')).toBeNull();
});
expect(wrapper.contains(ProviderRepoTableRow)).toBe(false);
expect(wrapper.contains(ImportedProjectTableRow)).toBe(false);
expect(wrapper.text()).toContain(`No ${providerTitle} repositories found`);
});
it('polls to update the status of imported projects', () => {
const updatedProjects = [
{
id: importedProject.id,
importStatus: 'finished',
it('sends importAll event when import button is clicked', async () => {
createComponent({
state: {
providerRepos: [providerRepo],
},
];
vm.$store.dispatch('receiveReposSuccess', {
importedProjects: [importedProject],
providerRepos: [],
namespaces: [{ path: 'path' }],
});
return vm
.$nextTick()
.then(() => {
const statusObject = STATUS_MAP[importedProject.importStatus];
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
statusObject.text,
);
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
vm.$store.dispatch('receiveJobsSuccess', updatedProjects);
})
.then(() => vm.$nextTick())
.then(() => {
const statusObject = STATUS_MAP[updatedProjects[0].importStatus];
findImportAllButton().vm.$emit('click');
await nextTick();
expect(eventHub.$emit).toHaveBeenCalledWith('importAll');
});
expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull();
expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch(
statusObject.text,
);
it('shows loading spinner when import is in progress', () => {
createComponent({
getters: {
isImportingAnyRepo: () => true,
},
});
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
});
expect(findImportAllButton().props().loading).toBe(true);
});
it('renders filtering input field', () => {
expect(
vm.$el.querySelector('input[data-qa-selector="githubish_import_filter_field"]'),
).not.toBeNull();
createComponent();
expect(wrapper.contains('input[data-qa-selector="githubish_import_filter_field"]')).toBe(true);
});
it.each`
hasIncompatibleRepos | shouldRenderSlot | action
${false} | ${false} | ${'does not render'}
${true} | ${true} | ${'render'}
`(
'$action incompatible-repos-warning slot if hasIncompatibleRepos is $hasIncompatibleRepos',
({ hasIncompatibleRepos, shouldRenderSlot }) => {
const INCOMPATIBLE_TEXT = 'INCOMPATIBLE!';
createComponent({
getters: {
hasIncompatibleRepos: () => hasIncompatibleRepos,
},
slots: {
'incompatible-repos-warning': INCOMPATIBLE_TEXT,
},
});
expect(wrapper.text().includes(INCOMPATIBLE_TEXT)).toBe(shouldRenderSlot);
},
);
});
......@@ -6,7 +6,7 @@ import STATUS_MAP, { STATUSES } from '~/import_projects/constants';
describe('ProviderRepoTableRow', () => {
let vm;
const fetchImport = jest.fn((context, data) => actions.requestImport(context, data));
const fetchImport = jest.fn();
const importPath = '/import-path';
const defaultTargetNamespace = 'user';
const ciCdOnly = true;
......@@ -17,11 +17,11 @@ describe('ProviderRepoTableRow', () => {
providerLink: 'providerLink',
};
function initStore() {
function initStore(initialState) {
const stubbedActions = { ...actions, fetchImport };
const store = new Vuex.Store({
state: state(),
state: { ...state(), ...initialState },
actions: stubbedActions,
mutations,
getters,
......@@ -30,12 +30,11 @@ describe('ProviderRepoTableRow', () => {
return store;
}
function mountComponent() {
function mountComponent(initialState) {
const localVue = createLocalVue();
localVue.use(Vuex);
const store = initStore();
store.dispatch('setInitialData', { importPath, defaultTargetNamespace, ciCdOnly });
const store = initStore({ importPath, defaultTargetNamespace, ciCdOnly, ...initialState });
const component = mount(providerRepoTableRow, {
localVue,
......
......@@ -4,7 +4,6 @@ import { TEST_HOST } from 'helpers/test_constants';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
SET_INITIAL_DATA,
REQUEST_REPOS,
RECEIVE_REPOS_SUCCESS,
RECEIVE_REPOS_ERROR,
......@@ -14,14 +13,7 @@ import {
RECEIVE_JOBS_SUCCESS,
} from '~/import_projects/store/mutation_types';
import {
setInitialData,
requestRepos,
receiveReposSuccess,
receiveReposError,
fetchRepos,
requestImport,
receiveImportSuccess,
receiveImportError,
fetchImport,
receiveJobsSuccess,
fetchJobs,
......@@ -32,7 +24,6 @@ import state from '~/import_projects/store/state';
describe('import_projects store actions', () => {
let localState;
const repoId = 1;
const repos = [{ id: 1 }, { id: 2 }];
const importPayload = { newName: 'newName', targetNamespace: 'targetNamespace', repo: { id: 1 } };
......@@ -40,61 +31,6 @@ describe('import_projects store actions', () => {
localState = state();
});
describe('setInitialData', () => {
it(`commits ${SET_INITIAL_DATA} mutation`, done => {
const initialData = {
reposPath: 'reposPath',
provider: 'provider',
jobsPath: 'jobsPath',
importPath: 'impapp/assets/javascripts/vue_shared/components/select2_select.vueortPath',
defaultTargetNamespace: 'defaultTargetNamespace',
ciCdOnly: 'ciCdOnly',
canSelectNamespace: 'canSelectNamespace',
};
testAction(
setInitialData,
initialData,
localState,
[{ type: SET_INITIAL_DATA, payload: initialData }],
[],
done,
);
});
});
describe('requestRepos', () => {
it(`requestRepos commits ${REQUEST_REPOS} mutation`, done => {
testAction(
requestRepos,
null,
localState,
[{ type: REQUEST_REPOS, payload: null }],
[],
done,
);
});
});
describe('receiveReposSuccess', () => {
it(`commits ${RECEIVE_REPOS_SUCCESS} mutation`, done => {
testAction(
receiveReposSuccess,
repos,
localState,
[{ type: RECEIVE_REPOS_SUCCESS, payload: repos }],
[],
done,
);
});
});
describe('receiveReposError', () => {
it(`commits ${RECEIVE_REPOS_ERROR} mutation`, done => {
testAction(receiveReposError, repos, localState, [{ type: RECEIVE_REPOS_ERROR }], [], done);
});
});
describe('fetchRepos', () => {
let mock;
const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] };
......@@ -106,39 +42,33 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
it('dispatches stopJobsPolling, requestRepos and receiveReposSuccess actions on a successful request', done => {
it('dispatches stopJobsPolling actions and commits REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, payload);
testAction(
return testAction(
fetchRepos,
null,
localState,
[],
[
{ type: 'stopJobsPolling' },
{ type: 'requestRepos' },
{ type: REQUEST_REPOS },
{
type: 'receiveReposSuccess',
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
{
type: 'fetchJobs',
},
],
done,
[{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }],
);
});
it('dispatches stopJobsPolling, requestRepos and receiveReposError actions on an unsuccessful request', done => {
it('dispatches stopJobsPolling action and commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
testAction(
return testAction(
fetchRepos,
null,
localState,
[],
[{ type: 'stopJobsPolling' }, { type: 'requestRepos' }, { type: 'receiveReposError' }],
done,
[{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }],
[{ type: 'stopJobsPolling' }],
);
});
......@@ -147,72 +77,26 @@ describe('import_projects store actions', () => {
localState.filter = 'filter';
});
it('fetches repos with filter applied', done => {
it('fetches repos with filter applied', () => {
mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload);
testAction(
return testAction(
fetchRepos,
null,
localState,
[],
[
{ type: 'stopJobsPolling' },
{ type: 'requestRepos' },
{ type: REQUEST_REPOS },
{
type: 'receiveReposSuccess',
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
},
{
type: 'fetchJobs',
},
],
done,
[{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }],
);
});
});
});
describe('requestImport', () => {
it(`commits ${REQUEST_IMPORT} mutation`, done => {
testAction(
requestImport,
repoId,
localState,
[{ type: REQUEST_IMPORT, payload: repoId }],
[],
done,
);
});
});
describe('receiveImportSuccess', () => {
it(`commits ${RECEIVE_IMPORT_SUCCESS} mutation`, done => {
const payload = { importedProject: { name: 'imported/project' }, repoId: 2 };
testAction(
receiveImportSuccess,
payload,
localState,
[{ type: RECEIVE_IMPORT_SUCCESS, payload }],
[],
done,
);
});
});
describe('receiveImportError', () => {
it(`commits ${RECEIVE_IMPORT_ERROR} mutation`, done => {
testAction(
receiveImportError,
repoId,
localState,
[{ type: RECEIVE_IMPORT_ERROR, payload: repoId }],
[],
done,
);
});
});
describe('fetchImport', () => {
let mock;
......@@ -223,56 +107,53 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
it('dispatches requestImport and receiveImportSuccess actions on a successful request', done => {
it('commits REQUEST_IMPORT and REQUEST_IMPORT_SUCCESS mutations on a successful request', () => {
const importedProject = { name: 'imported/project' };
const importRepoId = importPayload.repo.id;
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(200, importedProject);
testAction(
return testAction(
fetchImport,
importPayload,
localState,
[],
[
{ type: 'requestImport', payload: importRepoId },
{ type: REQUEST_IMPORT, payload: importRepoId },
{
type: 'receiveImportSuccess',
type: RECEIVE_IMPORT_SUCCESS,
payload: {
importedProject: convertObjectPropsToCamelCase(importedProject, { deep: true }),
repoId: importRepoId,
},
},
],
done,
[],
);
});
it('dispatches requestImport and receiveImportSuccess actions on an unsuccessful request', done => {
it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR on an unsuccessful request', () => {
mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500);
testAction(
return testAction(
fetchImport,
importPayload,
localState,
[],
[
{ type: 'requestImport', payload: importPayload.repo.id },
{ type: 'receiveImportError', payload: { repoId: importPayload.repo.id } },
{ type: REQUEST_IMPORT, payload: importPayload.repo.id },
{ type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id },
],
done,
[],
);
});
});
describe('receiveJobsSuccess', () => {
it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, done => {
testAction(
it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, () => {
return testAction(
receiveJobsSuccess,
repos,
localState,
[{ type: RECEIVE_JOBS_SUCCESS, payload: repos }],
[],
done,
);
});
});
......@@ -293,21 +174,20 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
it('dispatches requestJobs and receiveJobsSuccess actions on a successful request', done => {
it('commits RECEIVE_JOBS_SUCCESS mutation on a successful request', async () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, updatedProjects);
testAction(
await testAction(
fetchJobs,
null,
localState,
[],
[
{
type: 'receiveJobsSuccess',
type: RECEIVE_JOBS_SUCCESS,
payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }),
},
],
done,
[],
);
});
......@@ -316,21 +196,20 @@ describe('import_projects store actions', () => {
localState.filter = 'filter';
});
it('fetches realtime changes with filter applied', done => {
it('fetches realtime changes with filter applied', () => {
mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, updatedProjects);
testAction(
return testAction(
fetchJobs,
null,
localState,
[],
[
{
type: 'receiveJobsSuccess',
type: RECEIVE_JOBS_SUCCESS,
payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }),
},
],
done,
[],
);
});
});
......
......@@ -2,6 +2,7 @@ import {
namespaceSelectOptions,
isImportingAnyRepo,
hasProviderRepos,
hasIncompatibleRepos,
hasImportedProjects,
} from '~/import_projects/store/getters';
import state from '~/import_projects/store/state';
......@@ -80,4 +81,18 @@ describe('import_projects store getters', () => {
expect(hasImportedProjects(localState)).toBe(false);
});
});
describe('hasIncompatibleRepos', () => {
it('returns true if there are any incompatibleProjects', () => {
localState.incompatibleRepos = new Array(1);
expect(hasIncompatibleRepos(localState)).toBe(true);
});
it('returns false if there are no incompatibleProjects', () => {
localState.incompatibleRepos = [];
expect(hasIncompatibleRepos(localState)).toBe(false);
});
});
});
import $ from 'jquery';
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import mountComponent from 'helpers/vue_mount_component_helper';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
const modalComponent = Vue.extend(DeprecatedModal2);
......@@ -86,7 +85,7 @@ describe('DeprecatedModal2', () => {
});
});
it('works with data-toggle="modal"', done => {
it('works with data-toggle="modal"', () => {
setFixtures(`
<button id="modal-button" data-toggle="modal" data-target="#my-modal"></button>
<div id="modal-container"></div>
......@@ -101,9 +100,16 @@ describe('DeprecatedModal2', () => {
},
modalContainer,
);
$(vm.$el).on('shown.bs.modal', () => done());
const modalElement = document.getElementById('my-modal');
modalButton.click();
expect(modalElement).not.toHaveClass('show');
// let the modal fade in
jest.runOnlyPendingTimers();
expect(modalElement).toHaveClass('show');
});
describe('methods', () => {
......@@ -111,7 +117,7 @@ describe('DeprecatedModal2', () => {
beforeEach(() => {
vm = mountComponent(modalComponent, {});
spyOn(vm, '$emit');
jest.spyOn(vm, '$emit').mockImplementation(() => {});
});
describe('emitCancel', () => {
......@@ -149,23 +155,14 @@ describe('DeprecatedModal2', () => {
describe('slots', () => {
const slotContent = 'this should go into the slot';
const modalWithSlot = slotName => {
let template;
if (slotName) {
template = `
<deprecated-modal-2>
<template slot="${slotName}">${slotContent}</template>
</deprecated-modal-2>
`;
} else {
template = `<deprecated-modal-2>${slotContent}</deprecated-modal-2>`;
}
const modalWithSlot = slot => {
return Vue.extend({
components: {
DeprecatedModal2,
},
template,
render: h =>
h('deprecated-modal-2', [slot ? h('template', { slot }, slotContent) : slotContent]),
});
};
......
import $ from 'jquery';
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import mountComponent from 'helpers/vue_mount_component_helper';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
const modalComponent = Vue.extend(DeprecatedModal);
......@@ -47,7 +46,7 @@ describe('DeprecatedModal', () => {
});
});
it('works with data-toggle="modal"', done => {
it('works with data-toggle="modal"', () => {
setFixtures(`
<button id="modal-button" data-toggle="modal" data-target="#my-modal"></button>
<div id="modal-container"></div>
......@@ -63,9 +62,12 @@ describe('DeprecatedModal', () => {
modalContainer,
);
const modalElement = vm.$el.querySelector('#my-modal');
$(modalElement).on('shown.bs.modal', () => done());
expect(modalElement).not.toHaveClass('show');
modalButton.click();
expect(modalElement).toHaveClass('show');
});
});
});
import $ from 'jquery';
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import tooltip from '~/vue_shared/directives/tooltip';
describe('Tooltip directive', () => {
......@@ -13,19 +13,22 @@ describe('Tooltip directive', () => {
describe('with a single tooltip', () => {
beforeEach(() => {
setFixtures('<div id="dummy-element"></div>');
vm = new Vue({
el: '#dummy-element',
directives: {
tooltip,
const wrapper = mount(
{
directives: {
tooltip,
},
data() {
return {
tooltip: 'some text',
};
},
template: '<div v-tooltip :title="tooltip"></div>',
},
data() {
return {
tooltip: 'some text',
};
},
template: '<div v-tooltip :title="tooltip"></div>',
});
{ attachToDocument: true },
);
vm = wrapper.vm;
});
it('should have tooltip plugin applied', () => {
......@@ -34,48 +37,54 @@ describe('Tooltip directive', () => {
it('displays the title as tooltip', () => {
$(vm.$el).tooltip('show');
jest.runOnlyPendingTimers();
const tooltipElement = document.querySelector('.tooltip-inner');
expect(tooltipElement.innerText).toContain('some text');
expect(tooltipElement.textContent).toContain('some text');
});
it('updates a visible tooltip', done => {
it('updates a visible tooltip', () => {
$(vm.$el).tooltip('show');
jest.runOnlyPendingTimers();
const tooltipElement = document.querySelector('.tooltip-inner');
vm.tooltip = 'other text';
Vue.nextTick()
.then(() => {
expect(tooltipElement).toContainText('other text');
done();
})
.catch(done.fail);
jest.runOnlyPendingTimers();
return vm.$nextTick().then(() => {
expect(tooltipElement.textContent).toContain('other text');
});
});
});
describe('with multiple tooltips', () => {
beforeEach(() => {
const SomeComponent = Vue.extend({
directives: {
tooltip,
},
template: `
<div>
<div
v-tooltip
class="js-look-for-tooltip"
title="foo">
</div>
<div
v-tooltip
title="bar">
const wrapper = mount(
{
directives: {
tooltip,
},
template: `
<div>
<div
v-tooltip
class="js-look-for-tooltip"
title="foo">
</div>
<div
v-tooltip
title="bar">
</div>
</div>
</div>
`,
});
`,
},
{ attachToDocument: true },
);
vm = new SomeComponent().$mount();
vm = wrapper.vm;
});
it('should have tooltip plugin applied to all instances', () => {
......
import Vue from 'vue';
import Jed from 'jed';
import { trimText } from 'spec/helpers/text_helper';
import { mount, createLocalVue } from '@vue/test-utils';
import locale from '~/locale';
import Translate from '~/vue_shared/translate';
describe('Vue translate filter', () => {
let el;
const localVue = createLocalVue();
localVue.use(Translate);
describe('Vue translate filter', () => {
const createTranslationMock = (key, ...translations) => {
const fakeLocale = new Jed({
domain: 'app',
locale_data: {
app: {
'': {
domain: 'app',
lang: 'vo',
plural_forms: 'nplurals=2; plural=(n != 1);',
},
[key]: translations,
locale.textdomain('app');
locale.options.locale_data = {
app: {
'': {
domain: 'app',
lang: 'vo',
plural_forms: 'nplurals=2; plural=(n != 1);',
},
[key]: translations,
},
});
// eslint-disable-next-line no-underscore-dangle
locale.__Rewire__('locale', fakeLocale);
};
};
afterEach(() => {
// eslint-disable-next-line no-underscore-dangle
locale.__ResetDependency__('locale');
});
beforeEach(() => {
Vue.use(Translate);
el = document.createElement('div');
document.body.appendChild(el);
});
it('translate singular text (`__`)', done => {
it('translate singular text (`__`)', () => {
const key = 'singular';
const translation = 'singular_translated';
createTranslationMock(key, translation);
const vm = new Vue({
el,
template: `
<span>
{{ __('${key}') }}
</span>
`,
}).$mount();
Vue.nextTick(() => {
expect(trimText(vm.$el.textContent)).toBe(translation);
const wrapper = mount(
{
template: `
<span>
{{ __('${key}') }}
</span>
`,
},
{ localVue },
);
done();
});
expect(wrapper.text()).toBe(translation);
});
it('translate plural text (`n__`) without any substituting text', done => {
it('translate plural text (`n__`) without any substituting text', () => {
const key = 'plural';
const translationPlural = 'plural_multiple translation';
createTranslationMock(key, 'plural_singular translation', translationPlural);
const vm = new Vue({
el,
template: `
<span>
{{ n__('${key}', 'plurals', 2) }}
</span>
`,
}).$mount();
Vue.nextTick(() => {
expect(trimText(vm.$el.textContent)).toBe(translationPlural);
const wrapper = mount(
{
template: `
<span>
{{ n__('${key}', 'plurals', 2) }}
</span>
`,
},
{ localVue },
);
done();
});
expect(wrapper.text()).toBe(translationPlural);
});
describe('translate plural text (`n__`) with substituting %d', () => {
......@@ -89,38 +66,34 @@ describe('Vue translate filter', () => {
createTranslationMock(key, '%d singular translated', '%d plural translated');
});
it('and n === 1', done => {
const vm = new Vue({
el,
template: `
<span>
{{ n__('${key}', '%d days', 1) }}
</span>
`,
}).$mount();
Vue.nextTick(() => {
expect(trimText(vm.$el.textContent)).toBe('1 singular translated');
it('and n === 1', () => {
const wrapper = mount(
{
template: `
<span>
{{ n__('${key}', '%d days', 1) }}
</span>
`,
},
{ localVue },
);
done();
});
expect(wrapper.text()).toBe('1 singular translated');
});
it('and n > 1', done => {
const vm = new Vue({
el,
template: `
<span>
{{ n__('${key}', '%d days', 2) }}
</span>
`,
}).$mount();
Vue.nextTick(() => {
expect(trimText(vm.$el.textContent)).toBe('2 plural translated');
it('and n > 1', () => {
const wrapper = mount(
{
template: `
<span>
{{ n__('${key}', '%d days', 2) }}
</span>
`,
},
{ localVue },
);
done();
});
expect(wrapper.text()).toBe('2 plural translated');
});
});
......@@ -133,119 +106,109 @@ describe('Vue translate filter', () => {
createTranslationMock(key, translation);
});
it('and using two parameters', done => {
const vm = new Vue({
el,
template: `
<span>
{{ s__('Context', 'Foobar') }}
</span>
`,
}).$mount();
Vue.nextTick(() => {
expect(trimText(vm.$el.textContent)).toBe(expectation);
it('and using two parameters', () => {
const wrapper = mount(
{
template: `
<span>
{{ s__('Context', 'Foobar') }}
</span>
`,
},
{ localVue },
);
done();
});
expect(wrapper.text()).toBe(expectation);
});
it('and using the pipe syntax', done => {
const vm = new Vue({
el,
template: `
<span>
{{ s__('${key}') }}
</span>
`,
}).$mount();
Vue.nextTick(() => {
expect(trimText(vm.$el.textContent)).toBe(expectation);
it('and using the pipe syntax', () => {
const wrapper = mount(
{
template: `
<span>
{{ s__('${key}') }}
</span>
`,
},
{ localVue },
);
done();
});
expect(wrapper.text()).toBe(expectation);
});
});
it('translate multi line text', done => {
it('translate multi line text', () => {
const translation = 'multiline string translated';
createTranslationMock('multiline string', translation);
const vm = new Vue({
el,
template: `
<span>
{{ __(\`
multiline
string
\`) }}
</span>
`,
}).$mount();
Vue.nextTick(() => {
expect(trimText(vm.$el.textContent)).toBe(translation);
done();
});
const wrapper = mount(
{
template: `
<span>
{{ __(\`
multiline
string
\`) }}
</span>
`,
},
{ localVue },
);
expect(wrapper.text()).toBe(translation);
});
it('translate pluralized multi line text', done => {
it('translate pluralized multi line text', () => {
const translation = 'multiline string plural';
createTranslationMock('multiline string', 'multiline string singular', translation);
const vm = new Vue({
el,
template: `
<span>
{{ n__(
\`
multiline
string
\`,
\`
multiline
strings
\`,
2
) }}
</span>
`,
}).$mount();
Vue.nextTick(() => {
expect(trimText(vm.$el.textContent)).toBe(translation);
done();
});
const wrapper = mount(
{
template: `
<span>
{{ n__(
\`
multiline
string
\`,
\`
multiline
strings
\`,
2
) }}
</span>
`,
},
{ localVue },
);
expect(wrapper.text()).toBe(translation);
});
it('translate pluralized multi line text with context', done => {
it('translate pluralized multi line text with context', () => {
const translation = 'multiline string with context';
createTranslationMock('Context| multiline string', translation);
const vm = new Vue({
el,
template: `
<span>
{{ s__(
\`
Context|
multiline
string
\`
) }}
</span>
`,
}).$mount();
Vue.nextTick(() => {
expect(trimText(vm.$el.textContent)).toBe(translation);
done();
});
const wrapper = mount(
{
template: `
<span>
{{ s__(
\`
Context|
multiline
string
\`
) }}
</span>
`,
},
{ localVue },
);
expect(wrapper.text()).toBe(translation);
});
});
......@@ -7,7 +7,7 @@ describe GitlabSchema.types['MetricsDashboard'] do
it 'has the expected fields' do
expected_fields = %w[
path annotations
path annotations schema_validation_warnings
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
......@@ -42,7 +42,7 @@ describe PerformanceMonitoring::PrometheusDashboard do
it 'raises error with corresponding messages', :aggregate_failures do
expect { subject }.to raise_error do |error|
expect(error).to be_kind_of(ActiveModel::ValidationError)
expect(error.model.errors.messages).to eql(errors_messages)
expect(error.model.errors.messages).to eq(errors_messages)
end
end
end
......@@ -190,20 +190,51 @@ describe PerformanceMonitoring::PrometheusDashboard do
dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment })
expect(dashboard_instance).to be_instance_of described_class
expect(dashboard_instance.environment).to be environment
expect(dashboard_instance.path).to be path
expect(dashboard_instance.environment).to eq environment
expect(dashboard_instance.path).to eq path
end
end
context 'dashboard has NOT been found' do
it 'returns nil' do
allow(Gitlab::Metrics::Dashboard::Finder).to receive(:find).and_return(status: :error)
allow(Gitlab::Metrics::Dashboard::Finder).to receive(:find).and_return(http_status: :not_found)
dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment })
expect(dashboard_instance).to be_nil
end
end
context 'dashboard has invalid schema', :aggregate_failures do
it 'still returns dashboard object' do
expect(Gitlab::Metrics::Dashboard::Finder).to receive(:find).and_return(http_status: :unprocessable_entity)
dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment })
expect(dashboard_instance).to be_instance_of described_class
expect(dashboard_instance.environment).to eq environment
expect(dashboard_instance.path).to eq path
end
end
end
describe '#schema_validation_warnings' do
context 'when schema is valid' do
it 'returns nil' do
expect(described_class).to receive(:from_json)
expect(described_class.new.schema_validation_warnings).to be_nil
end
end
context 'when schema is invalid' do
it 'returns array with errors messages' do
instance = described_class.new
instance.errors.add(:test, 'test error')
expect(described_class).to receive(:from_json).and_raise(ActiveModel::ValidationError.new(instance))
expect(described_class.new.schema_validation_warnings).to eq ['test: test error']
end
end
end
describe '#to_yaml' do
......
......@@ -3726,7 +3726,7 @@ describe Project do
context 'when feature is private' do
let(:project) { create(:project, :public, :merge_requests_private) }
context 'when user does not has access to the feature' do
context 'when user does not have access to the feature' do
it 'does not return projects with the project feature private' do
is_expected.not_to include(project)
end
......
......@@ -9,25 +9,19 @@ describe 'Getting Metrics Dashboard' do
let(:project) { create(:project) }
let!(:environment) { create(:environment, project: project) }
let(:fields) do
<<~QUERY
#{all_graphql_fields_for('MetricsDashboard'.classify)}
QUERY
end
let(:query) do
%(
query {
project(fullPath:"#{project.full_path}") {
environments(name: "#{environment.name}") {
nodes {
metricsDashboard(path: "#{path}"){
#{fields}
}
}
}
}
}
graphql_query_for(
'project', { 'fullPath' => project.full_path },
query_graphql_field(
:environments, { 'name' => environment.name },
query_graphql_field(
:nodes, nil,
query_graphql_field(
:metricsDashboard, { 'path' => path },
all_graphql_fields_for('MetricsDashboard'.classify)
)
)
)
)
end
......@@ -63,7 +57,29 @@ describe 'Getting Metrics Dashboard' do
it 'returns metrics dashboard' do
dashboard = graphql_data.dig('project', 'environments', 'nodes')[0]['metricsDashboard']
expect(dashboard).to eql("path" => path)
expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => nil)
end
context 'invalid dashboard' do
let(:path) { '.gitlab/dashboards/metrics.yml' }
let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "---\ndasboard: ''" }) }
it 'returns metrics dashboard' do
dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["dashboard: can't be blank", "panel_groups: can't be blank"])
end
end
context 'empty dashboard' do
let(:path) { '.gitlab/dashboards/metrics.yml' }
let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "" }) }
it 'returns metrics dashboard' do
dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard')
expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["dashboard: can't be blank", "panel_groups: can't be blank"])
end
end
end
......@@ -72,7 +88,7 @@ describe 'Getting Metrics Dashboard' do
it_behaves_like 'a working graphql query'
it 'return snil' do
it 'returns nil' do
dashboard = graphql_data.dig('project', 'environments', 'nodes')[0]['metricsDashboard']
expect(dashboard).to be_nil
......
# frozen_string_literal: true
require 'spec_helper'
describe ProviderRepoSerializer do
it 'represents ProviderRepoEntity entities' do
expect(described_class.entity_class).to eq(ProviderRepoEntity)
end
end
......@@ -38,7 +38,8 @@ describe Ci::RetryBuildService do
job_artifacts_codequality job_artifacts_metrics scheduled_at
job_variables waiting_for_resource_at job_artifacts_metrics_referee
job_artifacts_network_referee job_artifacts_dotenv
job_artifacts_cobertura needs job_artifacts_accessibility].freeze
job_artifacts_cobertura needs job_artifacts_accessibility
job_artifacts_requirements].freeze
ignore_accessors =
%i[type lock_version target_url base_tags trace_sections
......
......@@ -787,10 +787,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.137.0.tgz#49fb1f33340cfdf0a47c83b7a613e3c7306dd53c"
integrity sha512-dhyiedyTKYJt/mXV+PjfY2pivAAPh3BAOHpVzNCZj6HmJ9VZFIJDzOAQTTxlxRz4UyPmHPuCiaal63q+JfLzcQ==
"@gitlab/ui@16.2.1":
version "16.2.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-16.2.1.tgz#52d1a7e21b36e8cdb2766a945d928c8815956449"
integrity sha512-JPkJUp9iyhS+u465qEMcVn9ZhZC7LqXjFBsTpF+plY9LuQWxNSEV9LU1WJ6lR1UsyNAQCJJ04qt9VgkxG52S7Q==
"@gitlab/ui@16.3.0":
version "16.3.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-16.3.0.tgz#033864b3e5c3305a2e1663d2d818388fb700dedb"
integrity sha512-iUMKS4AMY7cBP/BHACNWA5dFcOtHL2oFu4jgeCwpI+WtJGELkGcCRKYmmErjSZ+j4nYYMT+FZnyl+Qj7xBn6+g==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册