Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
b3e13e0dfd
commit
b2180a27bc
64 changed files with 1110 additions and 325 deletions
|
@ -113,7 +113,6 @@ review-stop:
|
|||
.review-qa-base:
|
||||
extends:
|
||||
- .use-docker-in-docker
|
||||
retry: 1 # This is confusing but this means "2 runs at max".
|
||||
image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-qa-alpine-ruby-2.7
|
||||
stage: qa
|
||||
needs: ["review-deploy"]
|
||||
|
@ -165,6 +164,7 @@ review-qa-smoke:
|
|||
extends:
|
||||
- .review-qa-base
|
||||
- .review:rules:review-qa-smoke
|
||||
retry: 1 # This is confusing but this means "2 runs at max".
|
||||
script:
|
||||
- gitlab-qa Test::Instance::Smoke "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}"
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
* Include use cases, benefits, and/or goals for this work.
|
||||
* If adding content: What audience is it intended for? (What roles and scenarios?)
|
||||
For ideas, see personas at https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/ or the persona labels at
|
||||
https://gitlab.com/groups/gitlab-org/-/labels?utf8=%E2%9C%93&subscribed=&search=persona%3A
|
||||
https://gitlab.com/groups/gitlab-org/-/labels?subscribed=&search=persona%3A
|
||||
-->
|
||||
|
||||
### Proposal
|
||||
|
|
|
@ -52,8 +52,8 @@ All reviewers can help ensure accuracy, clarity, completeness, and adherence to
|
|||
- [ ] Technical writer review. If not requested for this MR, must be scheduled post-merge. To request for this MR, assign the writer listed for the applicable [DevOps stage](https://about.gitlab.com/handbook/product/categories/#devops-stages).
|
||||
- [ ] Ensure docs metadata are present and up-to-date.
|
||||
- [ ] Ensure ~"Technical Writing" and ~"documentation" are added.
|
||||
- [ ] Add the corresponding `docs::` [scoped label](https://gitlab.com/groups/gitlab-org/-/labels?utf8=%E2%9C%93&subscribed=&search=docs%3A%3A).
|
||||
- [ ] If working on UI text, add the corresponding `UI Text` [scoped label](https://gitlab.com/groups/gitlab-org/-/labels?utf8=%E2%9C%93&subscribed=&search=ui+text).
|
||||
- [ ] Add the corresponding `docs::` [scoped label](https://gitlab.com/groups/gitlab-org/-/labels?subscribed=&search=docs%3A%3A).
|
||||
- [ ] If working on UI text, add the corresponding `UI Text` [scoped label](https://gitlab.com/groups/gitlab-org/-/labels?subscribed=&search=ui+text).
|
||||
- [ ] Add ~"tw::doing" when starting work on the MR.
|
||||
- [ ] Add ~"tw::finished" if Technical Writing team work on the MR is complete but it remains open.
|
||||
|
||||
|
|
|
@ -722,7 +722,6 @@ RSpec/EmptyLineAfterFinalLetItBe:
|
|||
- spec/services/wiki_pages/event_create_service_spec.rb
|
||||
- spec/support/shared_examples/graphql/design_fields_shared_examples.rb
|
||||
- spec/support/shared_examples/graphql/mutations/set_assignees_shared_examples.rb
|
||||
- spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb
|
||||
- spec/support/shared_examples/quick_actions/issuable/issuable_quick_actions_shared_examples.rb
|
||||
- spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb
|
||||
- spec/support/shared_examples/services/packages_shared_examples.rb
|
||||
|
@ -2917,5 +2916,4 @@ Style/RegexpLiteralMixedPreserve:
|
|||
- 'spec/support/helpers/grafana_api_helpers.rb'
|
||||
- 'spec/support/helpers/query_recorder.rb'
|
||||
- 'spec/support/helpers/require_migration.rb'
|
||||
- 'spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb'
|
||||
- 'spec/views/layouts/_head.html.haml_spec.rb'
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
<script>
|
||||
import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
|
||||
import Cookies from 'js-cookie';
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import { mapActions, mapState, mapGetters } from 'vuex';
|
||||
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
|
||||
import { __ } from '~/locale';
|
||||
import banner from './banner.vue';
|
||||
import stageCodeComponent from './stage_code_component.vue';
|
||||
|
@ -29,6 +30,7 @@ export default {
|
|||
'stage-staging-component': stageStagingComponent,
|
||||
'stage-production-component': stageComponent,
|
||||
'stage-nav-item': stageNavItem,
|
||||
PathNavigation,
|
||||
},
|
||||
props: {
|
||||
noDataSvgPath: {
|
||||
|
@ -56,17 +58,19 @@ export default {
|
|||
'summary',
|
||||
'startDate',
|
||||
]),
|
||||
...mapGetters(['pathNavigationData']),
|
||||
displayStageEvents() {
|
||||
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
|
||||
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
|
||||
},
|
||||
displayNotEnoughData() {
|
||||
const { selectedStage, isEmptyStage, isLoadingStage } = this;
|
||||
return selectedStage && isEmptyStage && !isLoadingStage;
|
||||
return this.selectedStageReady && this.isEmptyStage;
|
||||
},
|
||||
displayNoAccess() {
|
||||
const { selectedStage } = this;
|
||||
return selectedStage && !selectedStage.isUserAllowed;
|
||||
return this.selectedStageReady && !this.selectedStage.isUserAllowed;
|
||||
},
|
||||
selectedStageReady() {
|
||||
return !this.isLoadingStage && this.selectedStage;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -83,8 +87,8 @@ export default {
|
|||
isActiveStage(stage) {
|
||||
return stage.slug === this.selectedStage.slug;
|
||||
},
|
||||
selectStage(stage) {
|
||||
if (this.selectedStage === stage) return;
|
||||
onSelectStage(stage) {
|
||||
if (this.isLoadingStage || this.selectedStage?.slug === stage?.slug) return;
|
||||
|
||||
this.setSelectedStage(stage);
|
||||
if (!stage.isUserAllowed) {
|
||||
|
@ -106,9 +110,23 @@ export default {
|
|||
</script>
|
||||
<template>
|
||||
<div class="cycle-analytics">
|
||||
<path-navigation
|
||||
v-if="selectedStageReady"
|
||||
class="js-path-navigation gl-w-full gl-pb-2"
|
||||
:loading="isLoading"
|
||||
:stages="pathNavigationData"
|
||||
:selected-stage="selectedStage"
|
||||
:with-stage-counts="false"
|
||||
@selected="onSelectStage"
|
||||
/>
|
||||
<gl-loading-icon v-if="isLoading" size="lg" />
|
||||
<div v-else class="wrapper">
|
||||
<div class="card">
|
||||
<!--
|
||||
We wont have access to the stage counts until we move to a default value stream
|
||||
For now we can use the `withStageCounts` flag to ensure we don't display empty stage counts
|
||||
Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/326705
|
||||
-->
|
||||
<div class="card" data-testid="vsa-stage-overview-metrics">
|
||||
<div class="card-header">{{ __('Recent Project Activity') }}</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center">
|
||||
|
@ -139,40 +157,12 @@ export default {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage-panel-container">
|
||||
<div class="card stage-panel">
|
||||
<div class="stage-panel-container" data-testid="vsa-stage-table">
|
||||
<div class="card stage-panel gl-px-5">
|
||||
<div class="card-header border-bottom-0">
|
||||
<nav class="col-headers">
|
||||
<ul>
|
||||
<li class="stage-header pl-5">
|
||||
<span class="stage-name font-weight-bold">{{
|
||||
s__('ProjectLifecycle|Stage')
|
||||
}}</span>
|
||||
<span
|
||||
class="has-tooltip"
|
||||
data-placement="top"
|
||||
:title="__('The phase of the development lifecycle.')"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<gl-icon name="question-o" class="gl-text-gray-500" />
|
||||
</span>
|
||||
</li>
|
||||
<li class="median-header">
|
||||
<span class="stage-name font-weight-bold">{{ __('Median') }}</span>
|
||||
<span
|
||||
class="has-tooltip"
|
||||
data-placement="top"
|
||||
:title="
|
||||
__(
|
||||
'The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.',
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<gl-icon name="question-o" class="gl-text-gray-500" />
|
||||
</span>
|
||||
</li>
|
||||
<li class="event-header pl-3">
|
||||
<ul class="gl-display-flex gl-justify-content-space-between gl-list-style-none">
|
||||
<li>
|
||||
<span v-if="selectedStage" class="stage-name font-weight-bold">{{
|
||||
selectedStage.legend ? __(selectedStage.legend) : __('Related Issues')
|
||||
}}</span>
|
||||
|
@ -187,7 +177,7 @@ export default {
|
|||
<gl-icon name="question-o" class="gl-text-gray-500" />
|
||||
</span>
|
||||
</li>
|
||||
<li class="total-time-header pr-5 text-right">
|
||||
<li>
|
||||
<span class="stage-name font-weight-bold">{{ __('Time') }}</span>
|
||||
<span
|
||||
class="has-tooltip"
|
||||
|
@ -201,45 +191,31 @@ export default {
|
|||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="stage-panel-body">
|
||||
<nav class="stage-nav">
|
||||
<ul>
|
||||
<stage-nav-item
|
||||
v-for="stage in stages"
|
||||
:key="stage.title"
|
||||
:title="stage.title"
|
||||
:is-user-allowed="stage.isUserAllowed"
|
||||
:value="stage.value"
|
||||
:is-active="isActiveStage(stage)"
|
||||
@select="selectStage(stage)"
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
<section class="stage-events overflow-auto">
|
||||
<gl-loading-icon v-show="isLoadingStage" size="lg" />
|
||||
<template v-if="displayNoAccess">
|
||||
<section class="stage-events gl-overflow-auto gl-w-full">
|
||||
<gl-loading-icon v-if="isLoadingStage" size="lg" />
|
||||
<template v-else>
|
||||
<gl-empty-state
|
||||
v-if="displayNoAccess"
|
||||
class="js-empty-state"
|
||||
:title="__('You need permission.')"
|
||||
:svg-path="noAccessSvgPath"
|
||||
:description="__('Want to see the data? Please ask an administrator for access.')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="displayNotEnoughData">
|
||||
<template v-else>
|
||||
<gl-empty-state
|
||||
v-if="displayNotEnoughData"
|
||||
class="js-empty-state"
|
||||
:description="selectedStage.emptyStageText"
|
||||
:svg-path="noDataSvgPath"
|
||||
:title="__('We don\'t have enough data to show this stage.')"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="displayStageEvents">
|
||||
<component
|
||||
:is="selectedStage.component"
|
||||
v-if="displayStageEvents"
|
||||
:stage="selectedStage"
|
||||
:items="selectedStageEvents"
|
||||
data-testid="stage-table-events"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
<script>
|
||||
import {
|
||||
GlPath,
|
||||
GlPopover,
|
||||
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
|
||||
GlSafeHtmlDirective as SafeHtml,
|
||||
} from '@gitlab/ui';
|
||||
import { OVERVIEW_STAGE_ID } from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'PathNavigation',
|
||||
components: {
|
||||
GlPath,
|
||||
GlSkeletonLoading,
|
||||
GlPopover,
|
||||
},
|
||||
directives: {
|
||||
SafeHtml,
|
||||
},
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
stages: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
selectedStage: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => {},
|
||||
},
|
||||
withStageCounts: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
showPopover({ id }) {
|
||||
return id && id !== OVERVIEW_STAGE_ID;
|
||||
},
|
||||
hasStageCount({ stageCount = null }) {
|
||||
return stageCount !== null;
|
||||
},
|
||||
},
|
||||
popoverOptions: {
|
||||
triggers: 'hover',
|
||||
placement: 'bottom',
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<gl-skeleton-loading v-if="loading" :lines="2" class="h-auto pt-2 pb-1" />
|
||||
<gl-path v-else :key="selectedStage.id" :items="stages" @selected="$emit('selected', $event)">
|
||||
<template #default="{ pathItem, pathId }">
|
||||
<gl-popover
|
||||
v-if="showPopover(pathItem)"
|
||||
v-bind="$options.popoverOptions"
|
||||
:target="pathId"
|
||||
:css-classes="['stage-item-popover']"
|
||||
data-testid="stage-item-popover"
|
||||
>
|
||||
<template #title>{{ pathItem.title }}</template>
|
||||
<div class="gl-px-4">
|
||||
<div class="gl-display-flex gl-justify-content-space-between">
|
||||
<div class="gl-pr-4 gl-pb-4">
|
||||
{{ s__('ValueStreamEvent|Stage time (median)') }}
|
||||
</div>
|
||||
<div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="withStageCounts" class="gl-px-4">
|
||||
<div class="gl-display-flex gl-justify-content-space-between">
|
||||
<div class="gl-pr-4 gl-pb-4">
|
||||
{{ s__('ValueStreamEvent|Items in stage') }}
|
||||
</div>
|
||||
<div class="gl-pb-4 gl-font-weight-bold">
|
||||
<template v-if="hasStageCount(pathItem)">{{
|
||||
n__('%d item', '%d items', pathItem.stageCount)
|
||||
}}</template>
|
||||
<template v-else>-</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50">
|
||||
<div
|
||||
v-if="pathItem.startEventHtmlDescription"
|
||||
class="gl-display-flex gl-flex-direction-row"
|
||||
>
|
||||
<div class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-4 metric-label">
|
||||
{{ s__('ValueStreamEvent|Start') }}
|
||||
</div>
|
||||
<div
|
||||
v-safe-html="pathItem.startEventHtmlDescription"
|
||||
class="gl-display-flex gl-flex-direction-column gl-pb-4 stage-event-description"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="pathItem.endEventHtmlDescription"
|
||||
class="gl-display-flex gl-flex-direction-row"
|
||||
>
|
||||
<div class="gl-display-flex gl-flex-direction-column gl-pr-4 metric-label">
|
||||
{{ s__('ValueStreamEvent|Stop') }}
|
||||
</div>
|
||||
<div
|
||||
v-safe-html="pathItem.endEventHtmlDescription"
|
||||
class="gl-display-flex gl-flex-direction-column stage-event-description"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</gl-popover>
|
||||
</template>
|
||||
</gl-path>
|
||||
</template>
|
|
@ -1 +1,2 @@
|
|||
export const DEFAULT_DAYS_TO_DISPLAY = 30;
|
||||
export const OVERVIEW_STAGE_ID = 'overview';
|
||||
|
|
10
app/assets/javascripts/cycle_analytics/store/getters.js
Normal file
10
app/assets/javascripts/cycle_analytics/store/getters.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils';
|
||||
|
||||
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => {
|
||||
return transformStagesForPathNavigation({
|
||||
stages: filterStagesByHiddenStatus(stages, false),
|
||||
medians,
|
||||
stageCounts,
|
||||
selectedStage,
|
||||
});
|
||||
};
|
|
@ -8,6 +8,7 @@
|
|||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import * as actions from './actions';
|
||||
import * as getters from './getters';
|
||||
import mutations from './mutations';
|
||||
import state from './state';
|
||||
|
||||
|
@ -16,6 +17,7 @@ Vue.use(Vuex);
|
|||
export default () =>
|
||||
new Vuex.Store({
|
||||
actions,
|
||||
getters,
|
||||
mutations,
|
||||
state,
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { decorateData, decorateEvents } from '../utils';
|
||||
import { decorateData, decorateEvents, formatMedianValues } from '../utils';
|
||||
import * as types from './mutation_types';
|
||||
|
||||
export default {
|
||||
|
@ -20,9 +20,10 @@ export default {
|
|||
},
|
||||
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
|
||||
state.isLoading = false;
|
||||
const { stages, summary } = decorateData(data);
|
||||
const { stages, summary, medians } = decorateData(data);
|
||||
state.stages = stages;
|
||||
state.summary = summary;
|
||||
state.medians = formatMedianValues(medians);
|
||||
state.hasError = false;
|
||||
},
|
||||
[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import { unescape } from 'lodash';
|
||||
import { sanitize } from '~/lib/dompurify';
|
||||
import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import { parseSeconds } from '~/lib/utils/datetime_utility';
|
||||
import { dasherize } from '~/lib/utils/text_utility';
|
||||
import { __ } from '../locale';
|
||||
import { __, s__, sprintf } from '../locale';
|
||||
import DEFAULT_EVENT_OBJECTS from './default_event_objects';
|
||||
|
||||
const EMPTY_STAGE_TEXTS = {
|
||||
|
@ -40,10 +43,17 @@ const mapToEvent = (event, stage) => {
|
|||
|
||||
export const decorateEvents = (events, stage) => events.map((event) => mapToEvent(event, stage));
|
||||
|
||||
const mapToStage = (permissions, item) => {
|
||||
const slug = dasherize(item.name.toLowerCase());
|
||||
/*
|
||||
* NOTE: We currently use the `name` field since the project level stages are in memory
|
||||
* once we migrate to a default value stream https://gitlab.com/gitlab-org/gitlab/-/issues/326705
|
||||
* we can use the `id` to identify which median we are using
|
||||
*/
|
||||
const mapToStage = (permissions, { name, ...rest }) => {
|
||||
const slug = dasherize(name.toLowerCase());
|
||||
return {
|
||||
...item,
|
||||
...rest,
|
||||
name,
|
||||
id: name,
|
||||
slug,
|
||||
active: false,
|
||||
isUserAllowed: permissions[slug],
|
||||
|
@ -53,11 +63,98 @@ const mapToStage = (permissions, item) => {
|
|||
};
|
||||
|
||||
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
|
||||
const mapToMedians = ({ id, value }) => ({ id, value });
|
||||
|
||||
export const decorateData = (data = {}) => {
|
||||
const { permissions, stats, summary } = data;
|
||||
const stages = stats?.map((item) => mapToStage(permissions, item)) || [];
|
||||
return {
|
||||
stages: stats?.map((item) => mapToStage(permissions, item)) || [],
|
||||
stages,
|
||||
summary: summary?.map((item) => mapToSummary(item)) || [],
|
||||
medians: stages?.map((item) => mapToMedians(item)) || [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes the stages and median data, combined with the selected stage, to build an
|
||||
* array which is formatted to proivde the data required for the path navigation.
|
||||
*
|
||||
* @param {Array} stages - The stages available to the group / project
|
||||
* @param {Object} medians - The median values for the stages available to the group / project
|
||||
* @param {Object} stageCounts - The total item count for the stages available
|
||||
* @param {Object} selectedStage - The currently selected stage
|
||||
* @returns {Array} An array of stages formatted with data required for the path navigation
|
||||
*/
|
||||
export const transformStagesForPathNavigation = ({
|
||||
stages,
|
||||
medians,
|
||||
stageCounts = {},
|
||||
selectedStage,
|
||||
}) => {
|
||||
const formattedStages = stages.map((stage) => {
|
||||
return {
|
||||
metric: medians[stage?.id],
|
||||
selected: stage?.id === selectedStage?.id, // Also could null === null cause an issue here?
|
||||
stageCount: stageCounts && stageCounts[stage?.id],
|
||||
icon: null,
|
||||
...stage,
|
||||
};
|
||||
});
|
||||
|
||||
return formattedStages;
|
||||
};
|
||||
|
||||
export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, weeks, months }) => {
|
||||
if (months) {
|
||||
return sprintf(s__('ValueStreamAnalytics|%{value}M'), {
|
||||
value: roundToNearestHalf(months),
|
||||
});
|
||||
} else if (weeks) {
|
||||
return sprintf(s__('ValueStreamAnalytics|%{value}w'), {
|
||||
value: roundToNearestHalf(weeks),
|
||||
});
|
||||
} else if (days) {
|
||||
return sprintf(s__('ValueStreamAnalytics|%{value}d'), {
|
||||
value: roundToNearestHalf(days),
|
||||
});
|
||||
} else if (hours) {
|
||||
return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours });
|
||||
} else if (minutes) {
|
||||
return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes });
|
||||
} else if (seconds) {
|
||||
return unescape(sanitize(s__('ValueStreamAnalytics|<1m'), { ALLOWED_TAGS: [] }));
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a raw median value in seconds and converts it to a string representation
|
||||
* ie. converts 172800 => 2d (2 days)
|
||||
*
|
||||
* @param {Number} Median - The number of seconds for the median calculation
|
||||
* @returns {String} String representation ie 2w
|
||||
*/
|
||||
export const medianTimeToParsedSeconds = (value) =>
|
||||
timeSummaryForPathNavigation({
|
||||
...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }),
|
||||
seconds: value,
|
||||
});
|
||||
|
||||
/**
|
||||
* Takes the raw median value arrays and converts them into a useful object
|
||||
* containing the string for display in the path navigation
|
||||
* ie. converts [{ id: 'test', value: 172800 }] => { 'test': '2d' }
|
||||
*
|
||||
* @param {Array} Medians - Array of stage median objects, each contains a `id`, `value` and `error`
|
||||
* @returns {Object} Returns key value pair with the stage name and its display median value
|
||||
*/
|
||||
export const formatMedianValues = (medians = []) =>
|
||||
medians.reduce((acc, { id, value = 0 }) => {
|
||||
return {
|
||||
...acc,
|
||||
[id]: value ? medianTimeToParsedSeconds(value) : '-',
|
||||
};
|
||||
}, {});
|
||||
|
||||
export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
|
||||
stages.filter(({ hidden = false }) => hidden === isHidden);
|
||||
|
|
|
@ -191,7 +191,7 @@ export default {
|
|||
},
|
||||
squashIsSelected() {
|
||||
if (this.glFeatures.mergeRequestWidgetGraphql) {
|
||||
return this.squashReadOnly ? this.state.squashOnMerge : this.state.squash;
|
||||
return this.isSquashReadOnly ? this.state.squashOnMerge : this.state.squash;
|
||||
}
|
||||
|
||||
return this.mr.squashIsSelected;
|
||||
|
|
|
@ -30,32 +30,12 @@
|
|||
|
||||
.col-headers {
|
||||
ul {
|
||||
@include clearfix;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
float: left;
|
||||
line-height: 50px;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.stage-header {
|
||||
width: 20.5%;
|
||||
}
|
||||
|
||||
.median-header {
|
||||
width: 19.5%;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.total-time-header {
|
||||
width: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,7 +100,6 @@
|
|||
}
|
||||
|
||||
li {
|
||||
@include clearfix;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
|
@ -169,7 +148,6 @@
|
|||
|
||||
.events-description {
|
||||
line-height: 65px;
|
||||
padding: 0 $gl-padding;
|
||||
}
|
||||
|
||||
.events-info {
|
||||
|
@ -178,7 +156,6 @@
|
|||
}
|
||||
|
||||
.stage-events {
|
||||
width: 60%;
|
||||
min-height: 467px;
|
||||
}
|
||||
|
||||
|
@ -190,8 +167,8 @@
|
|||
.stage-event-item {
|
||||
@include clearfix;
|
||||
list-style-type: none;
|
||||
padding: 0 0 $gl-padding;
|
||||
margin: 0 $gl-padding $gl-padding;
|
||||
padding-bottom: $gl-padding;
|
||||
margin-bottom: $gl-padding;
|
||||
border-bottom: 1px solid var(--gray-50, $gray-50);
|
||||
|
||||
&:last-child {
|
||||
|
|
|
@ -79,11 +79,11 @@ class Projects::CompareController < Projects::ApplicationController
|
|||
private
|
||||
|
||||
def validate_refs!
|
||||
valid = [head_ref, start_ref].map { |ref| valid_ref?(ref) }
|
||||
invalid = [head_ref, start_ref].filter { |ref| !valid_ref?(ref) }
|
||||
|
||||
return if valid.all?
|
||||
return if invalid.empty?
|
||||
|
||||
flash[:alert] = "Invalid branch name"
|
||||
flash[:alert] = "Invalid branch name(s): #{invalid.join(', ')}"
|
||||
redirect_to project_compare_index_path(source_project)
|
||||
end
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module SlackMattermost
|
||||
module Notifier
|
||||
module Integrations
|
||||
module SlackMattermostNotifier
|
||||
private
|
||||
|
||||
def notify(message, opts)
|
|
@ -11,7 +11,8 @@ module IssueAvailableFeatures
|
|||
def available_features_for_issue_types
|
||||
{
|
||||
assignee: %w(issue incident),
|
||||
confidentiality: %(issue incident)
|
||||
confidentiality: %(issue incident),
|
||||
time_tracking: %(issue incident)
|
||||
}.with_indifferent_access
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
module Integrations
|
||||
class Mattermost < BaseChatNotification
|
||||
include SlackMattermost::Notifier
|
||||
include SlackMattermostNotifier
|
||||
include ActionView::Helpers::UrlHelper
|
||||
|
||||
def title
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
module Integrations
|
||||
class Slack < BaseChatNotification
|
||||
include SlackMattermost::Notifier
|
||||
include SlackMattermostNotifier
|
||||
extend ::Gitlab::Utils::Override
|
||||
|
||||
SUPPORTED_EVENTS_FOR_USAGE_LOG = %w[
|
||||
|
|
|
@ -464,6 +464,10 @@ class Issue < ApplicationRecord
|
|||
issue_type_supports?(:assignee)
|
||||
end
|
||||
|
||||
def supports_time_tracking?
|
||||
issue_type_supports?(:time_tracking)
|
||||
end
|
||||
|
||||
def email_participants_emails
|
||||
issue_email_participants.pluck(:email)
|
||||
end
|
||||
|
|
|
@ -8,9 +8,5 @@ class AnalyticsStageEntity < Grape::Entity
|
|||
expose :legend
|
||||
expose :description
|
||||
|
||||
expose :project_median, as: :value do |stage|
|
||||
# median returns a BatchLoader instance which we first have to unwrap by using to_f
|
||||
# we use to_f to make sure results below 1 are presented to the end-user
|
||||
stage.project_median.to_f.nonzero? ? distance_of_time_in_words(stage.project_median) : nil
|
||||
end
|
||||
expose :project_median, as: :value
|
||||
end
|
||||
|
|
|
@ -5,5 +5,5 @@
|
|||
%h3= s_('Integrations|Project integration management')
|
||||
|
||||
- integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path }
|
||||
%p= s_('Integrations|As a GitLab administrator, you can set default configuration parameters for a given integration that all projects can inherit and use. When you set these parameters, your changes update the integration for all projects that are not already using custom settings. Learn more about %{integrations_link_start}Project integration management%{link_end}.').html_safe % { integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe }
|
||||
%p= s_("Integrations|GitLab administrators can set up integrations that all projects inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a group or project if the settings are necessary at that level. Learn more about %{integrations_link_start}project integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe }
|
||||
= render 'shared/integrations/index', integrations: @integrations
|
||||
|
|
|
@ -5,5 +5,5 @@
|
|||
%h3= s_('Integrations|Project integration management')
|
||||
|
||||
- integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path }
|
||||
%p= s_('Integrations|As a GitLab administrator, you can set default configuration parameters for a given integration that all projects can inherit and use. When you set these parameters, your changes update the integration for all projects that are not already using custom settings. Learn more about %{integrations_link_start}Project integration management%{link_end}.').html_safe % { integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe }
|
||||
%p= s_("Integrations|GitLab administrators can set up integrations that all projects inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a group or project if the settings are necessary at that level. Learn more about %{integrations_link_start}project integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe }
|
||||
= render 'shared/integrations/index', integrations: @integrations
|
||||
|
|
|
@ -113,7 +113,7 @@ For source installations the following settings are nested under `artifacts:` an
|
|||
| Setting | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `enabled` | `false` | Enable or disable object storage. |
|
||||
| `remote_directory` | | The bucket name where Artifacts are stored. |
|
||||
| `remote_directory` | | The bucket name where Artifacts are stored. Use the name only, do not include the path. |
|
||||
| `direct_upload` | `false` | Set to `true` to enable direct upload of Artifacts without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. |
|
||||
| `background_upload` | `true` | Set to `false` to disable automatic upload. Option may be removed once upload is direct to S3. |
|
||||
| `proxy_download` | `false` | Set to `true` to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data. |
|
||||
|
|
|
@ -143,25 +143,25 @@ is turned off.
|
|||
|
||||
### Deployments
|
||||
|
||||
Deployments won't go through because pipelines will be unfinished.
|
||||
Deployments don't go through because pipelines are unfinished.
|
||||
|
||||
It is recommended to disable auto deploys during Maintenance Mode, and enable them once it is disabled.
|
||||
|
||||
#### Terraform integration
|
||||
|
||||
Terraform integration depends on running CI pipelines, hence it will be blocked.
|
||||
Terraform integration depends on running CI pipelines, hence it is blocked.
|
||||
|
||||
### Container Registry
|
||||
|
||||
`docker push` will fail with this error: `denied: requested access to the resource is denied`, but `docker pull` will work.
|
||||
`docker push` fails with this error: `denied: requested access to the resource is denied`, but `docker pull` works.
|
||||
|
||||
### Package Registry
|
||||
|
||||
Package Registry will allow you to install but not publish packages.
|
||||
Package Registry allows you to install but not publish packages.
|
||||
|
||||
### Background jobs
|
||||
|
||||
Background jobs (cron jobs, Sidekiq) will continue running as is, because background jobs are not automatically disabled.
|
||||
Background jobs (cron jobs, Sidekiq) continue running as is, because background jobs are not automatically disabled.
|
||||
|
||||
[During a planned Geo failover](../geo/disaster_recovery/planned_failover.md#prevent-updates-to-the-primary-node),
|
||||
it is recommended that you disable all cron jobs except for those related to Geo.
|
||||
|
@ -170,34 +170,34 @@ You can monitor queues and disable jobs in **Admin Area > Monitoring > Backgroun
|
|||
|
||||
### Incident management
|
||||
|
||||
[Incident management](../../operations/incident_management/index.md) functions will be limited. The creation of [alerts](../../operations/incident_management/alerts.md) and [incidents](../../operations/incident_management/incidents.md#incident-creation) will be paused entirely. Notifications and paging on alerts and incidents will therefore be disabled.
|
||||
[Incident management](../../operations/incident_management/index.md) functions are limited. The creation of [alerts](../../operations/incident_management/alerts.md) and [incidents](../../operations/incident_management/incidents.md#incident-creation) are paused entirely. Notifications and paging on alerts and incidents are therefore disabled.
|
||||
|
||||
### Feature flags
|
||||
|
||||
- [Development feature flags](../../development/feature_flags/index.md) cannot be turned on or off through the API, but can be toggled through the Rails console.
|
||||
- [The feature flag service](../../operations/feature_flags.md) will respond to feature flag checks but feature flags cannot be toggled
|
||||
- [The feature flag service](../../operations/feature_flags.md) responds to feature flag checks but feature flags cannot be toggled
|
||||
|
||||
### Geo secondaries
|
||||
|
||||
When primary is in Maintenance Mode, secondary will also automatically go into Maintenance Mode.
|
||||
When primary is in Maintenance Mode, secondary also automatically goes into Maintenance Mode.
|
||||
|
||||
It is important that you do not disable replication before enabling Maintenance Mode.
|
||||
|
||||
Replication and verification will continue to work but proxied Git pushes to primary will not work.
|
||||
Replication and verification continues to work but proxied Git pushes to primary do not work.
|
||||
|
||||
### Secure features
|
||||
|
||||
Features that depend on creating issues or creating or approving Merge Requests, will not work.
|
||||
Features that depend on creating issues or creating or approving Merge Requests, do not work.
|
||||
|
||||
Exporting a vulnerability list from a Vulnerability Report page will not work.
|
||||
Exporting a vulnerability list from a Vulnerability Report page does not work.
|
||||
|
||||
Changing the status on a finding or vulnerability object will not work, even though no error is shown in the UI.
|
||||
Changing the status on a finding or vulnerability object does not work, even though no error is shown in the UI.
|
||||
|
||||
SAST and Secret Detection cannot be initiated because they depend on passing CI jobs to create artifacts.
|
||||
|
||||
## An example use case: a planned failover
|
||||
|
||||
In the use case of [a planned failover](../geo/disaster_recovery/planned_failover.md), a few writes in the primary database are acceptable, since they will be replicated quickly and are not significant in number.
|
||||
In the use case of [a planned failover](../geo/disaster_recovery/planned_failover.md), a few writes in the primary database are acceptable, since they are replicated quickly and are not significant in number.
|
||||
|
||||
For the same reason we don't automatically block background jobs when Maintenance Mode is enabled.
|
||||
|
||||
|
|
|
@ -618,20 +618,20 @@ This can result in some of the following problems:
|
|||
|
||||
- If GitLab is using non-secure HTTP to access the object storage, clients may generate
|
||||
`https->http` downgrade errors and refuse to process the redirect. The solution to this
|
||||
is for GitLab to use HTTPS. LFS, for example, will generate this error:
|
||||
is for GitLab to use HTTPS. LFS, for example, generates this error:
|
||||
|
||||
```plaintext
|
||||
LFS: lfsapi/client: refusing insecure redirect, https->http
|
||||
```
|
||||
|
||||
- Clients will need to trust the certificate authority that issued the object storage
|
||||
- Clients need to trust the certificate authority that issued the object storage
|
||||
certificate, or may return common TLS errors such as:
|
||||
|
||||
```plaintext
|
||||
x509: certificate signed by unknown authority
|
||||
```
|
||||
|
||||
- Clients will need network access to the object storage.
|
||||
- Clients need network access to the object storage.
|
||||
Network firewalls could block access.
|
||||
Errors that might result
|
||||
if this access is not in place include:
|
||||
|
@ -667,7 +667,7 @@ The first option is recommended for MinIO. Otherwise, the
|
|||
is to use the `--compat` parameter on the server.
|
||||
|
||||
Without consolidated object store configuration or instance profiles enabled,
|
||||
GitLab Workhorse will upload files to S3 using pre-signed URLs that do
|
||||
GitLab Workhorse uploads files to S3 using pre-signed URLs that do
|
||||
not have a `Content-MD5` HTTP header computed for them. To ensure data
|
||||
is not corrupted, Workhorse checks that the MD5 hash of the data sent
|
||||
equals the ETag header returned from the S3 server. When encryption is
|
||||
|
@ -683,7 +683,7 @@ eliminates the need to compare ETag headers returned from the S3 server.
|
|||
Instead of supplying AWS access and secret keys in object storage
|
||||
configuration, GitLab can be configured to use IAM roles to set up an
|
||||
[Amazon instance profile](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html).
|
||||
When this is used, GitLab will fetch temporary credentials each time an
|
||||
When this is used, GitLab fetches temporary credentials each time an
|
||||
S3 bucket is accessed, so no hard-coded values are needed in the
|
||||
configuration.
|
||||
|
||||
|
@ -721,7 +721,7 @@ must be fulfilled:
|
|||
- `use_iam_profile` is `true` in the connection settings.
|
||||
- Consolidated object storage settings are in use.
|
||||
|
||||
[ETag mismatch errors](#etag-mismatch) will occur if server side
|
||||
[ETag mismatch errors](#etag-mismatch) occur if server side
|
||||
encryption headers are used without enabling the Workhorse S3 client.
|
||||
|
||||
##### Disabling the feature
|
||||
|
|
|
@ -311,12 +311,12 @@ We automatically add the ~"Accepting merge requests" label to issues
|
|||
that match the [triage policy](https://about.gitlab.com/handbook/engineering/quality/triage-operations/#accepting-merge-requests).
|
||||
|
||||
We recommend people that have never contributed to any open source project to
|
||||
look for issues labeled `~"Accepting merge requests"` with a [weight of 1](https://gitlab.com/groups/gitlab-org/-/issues?state=opened&label_name[]=Accepting+merge+requests&assignee_id=None&sort=weight&weight=1) or the `~"Good for new contributors"` [label](https://gitlab.com/gitlab-org/gitlab/-/issues?scope=all&utf8=%E2%9C%93&state=opened&label_name[]=good%20for%20new%20contributors&assignee_id=None) attached to it.
|
||||
look for issues labeled `~"Accepting merge requests"` with a [weight of 1](https://gitlab.com/groups/gitlab-org/-/issues?state=opened&label_name[]=Accepting+merge+requests&assignee_id=None&sort=weight&weight=1) or the `~"Good for new contributors"` [label](https://gitlab.com/gitlab-org/gitlab/-/issues?scope=all&state=opened&label_name[]=good%20for%20new%20contributors&assignee_id=None) attached to it.
|
||||
More experienced contributors are very welcome to tackle
|
||||
[any of them](https://gitlab.com/groups/gitlab-org/-/issues?state=opened&label_name[]=Accepting+merge+requests&assignee_id=None).
|
||||
|
||||
For more complex features that have a weight of 2 or more and clear scope, we recommend looking at issues
|
||||
with the [label `~"Community Challenge"`](https://gitlab.com/gitlab-org/gitlab/-/issues?scope=all&utf8=%E2%9C%93&state=opened&label_name[]=Accepting%20merge%20requests&label_name[]=Community%20challenge).
|
||||
with the [label `~"Community Challenge"`](https://gitlab.com/gitlab-org/gitlab/-/issues?scope=all&state=opened&label_name[]=Accepting%20merge%20requests&label_name[]=Community%20challenge).
|
||||
If your MR for the `~"Community Challenge"` issue gets merged, you will also have a chance to win a custom
|
||||
GitLab merchandise.
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ In addition to this page, the following resources can help you craft and contrib
|
|||
- [GitLab Handbook style guidelines](https://about.gitlab.com/handbook/communication/#writing-style-guidelines)
|
||||
- [Microsoft Style Guide](https://docs.microsoft.com/en-us/style-guide/welcome/)
|
||||
- [Google Developer Documentation Style Guide](https://developers.google.com/style)
|
||||
- [Recent updates to this guide](https://gitlab.com/dashboard/merge_requests?scope=all&utf8=%E2%9C%93&state=merged&label_name[]=tw-style¬[label_name][]=docs%3A%3Afix)
|
||||
- [Recent updates to this guide](https://gitlab.com/dashboard/merge_requests?scope=all&state=merged&label_name[]=tw-style¬[label_name][]=docs%3A%3Afix)
|
||||
|
||||
## Documentation is the single source of truth (SSOT)
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ Experiments are run as an A/B/n test, and are behind a feature flag to turn the
|
|||
|
||||
## Experiment tracking issue
|
||||
|
||||
Each experiment should have an [Experiment tracking](https://gitlab.com/groups/gitlab-org/-/issues?scope=all&utf8=%E2%9C%93&state=opened&label_name[]=growth%20experiment&search=%22Experiment+tracking%22) issue to track the experiment from roll-out through to cleanup/removal. The tracking issue is similar to a feature flag rollout issue, and is also used to track the status of an experiment. Immediately after an experiment is deployed, the due date of the issue should be set (this depends on the experiment but can be up to a few weeks in the future).
|
||||
Each experiment should have an [Experiment tracking](https://gitlab.com/groups/gitlab-org/-/issues?scope=all&state=opened&label_name[]=growth%20experiment&search=%22Experiment+tracking%22) issue to track the experiment from roll-out through to cleanup/removal. The tracking issue is similar to a feature flag rollout issue, and is also used to track the status of an experiment. Immediately after an experiment is deployed, the due date of the issue should be set (this depends on the experiment but can be up to a few weeks in the future).
|
||||
After the deadline, the issue needs to be resolved and either:
|
||||
|
||||
- It was successful and the experiment becomes the new default.
|
||||
|
|
|
@ -99,7 +99,7 @@ Guidelines:
|
|||
Before toggling any feature flag, check that there are no ongoing
|
||||
significant incidents on GitLab.com. You can do this by checking the
|
||||
`#production` and `#incident-management` Slack channels, or looking for
|
||||
[open incident issues](https://gitlab.com/gitlab-com/gl-infra/production/-/issues/?scope=all&utf8=%E2%9C%93&state=opened&label_name[]=incident)
|
||||
[open incident issues](https://gitlab.com/gitlab-com/gl-infra/production/-/issues/?scope=all&state=opened&label_name[]=incident)
|
||||
(although check the dates and times).
|
||||
|
||||
We do not want to introduce changes during an incident, as it can make
|
||||
|
@ -256,7 +256,7 @@ Any feature flag change that affects GitLab.com (production) via [ChatOps](https
|
|||
is automatically logged in an issue.
|
||||
|
||||
The issue is created in the
|
||||
[gl-infra/feature-flag-log](https://gitlab.com/gitlab-com/gl-infra/feature-flag-log/-/issues?scope=all&utf8=%E2%9C%93&state=closed)
|
||||
[gl-infra/feature-flag-log](https://gitlab.com/gitlab-com/gl-infra/feature-flag-log/-/issues?scope=all&state=closed)
|
||||
project, and it will at minimum log the Slack handle of person enabling
|
||||
a feature flag, the time, and the name of the flag being changed.
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ newly added externalized strings to the community of translators.
|
|||
|
||||
The [GitLab CrowdIn Bot](https://gitlab.com/gitlab-crowdin-bot) also creates merge requests
|
||||
to take newly approved translation submissions and merge them into the `locale/<language>/gitlab.po`
|
||||
files. Check the [merge requests created by `gitlab-crowdin-bot`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests?scope=all&utf8=%E2%9C%93&state=opened&author_username=gitlab-crowdin-bot)
|
||||
files. Check the [merge requests created by `gitlab-crowdin-bot`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests?scope=all&state=opened&author_username=gitlab-crowdin-bot)
|
||||
to see new and merged merge requests.
|
||||
|
||||
## Validation
|
||||
|
@ -53,7 +53,7 @@ We are discussing [automating this entire process](https://gitlab.com/gitlab-org
|
|||
|
||||
CrowdIn creates a new merge request as soon as the old one is closed
|
||||
or merged. But it does not recreate the `master-i18n` branch every
|
||||
time. To force CrowdIn to recreate the branch, close any [open merge requests](https://gitlab.com/gitlab-org/gitlab/-/merge_requests?scope=all&utf8=%E2%9C%93&state=opened&author_username=gitlab-crowdin-bot)
|
||||
time. To force CrowdIn to recreate the branch, close any [open merge requests](https://gitlab.com/gitlab-org/gitlab/-/merge_requests?scope=all&state=opened&author_username=gitlab-crowdin-bot)
|
||||
and delete the [`master-18n`](https://gitlab.com/gitlab-org/gitlab/-/branches/all?utf8=✓&search=master-i18n) branch.
|
||||
|
||||
This might be needed when the merge request contains failures that
|
||||
|
|
|
@ -9,9 +9,9 @@ info: To determine the technical writer assigned to the Stage/Group associated w
|
|||
## Where is Maintenance Mode enforced?
|
||||
|
||||
GitLab Maintenance Mode **only** blocks writes from HTTP and SSH requests at the application level in a few key places within the rails application.
|
||||
[Search the codebase for `maintenance_mode?`.](https://gitlab.com/search?utf8=%E2%9C%93&search=maintenance_mode%3F&group_id=9970&project_id=278964&scope=blobs&search_code=false&snippets=false&repository_ref=)
|
||||
[Search the codebase for `maintenance_mode?`.](https://gitlab.com/search?search=maintenance_mode%3F&group_id=9970&project_id=278964&scope=blobs&search_code=false&snippets=false&repository_ref=)
|
||||
|
||||
- [the read-only database method](https://gitlab.com/gitlab-org/gitlab/blob/2425e9de50c678413ceaad6ee3bf66f42b7e228c/ee/lib/ee/gitlab/database.rb#L13), which toggles special behavior when we are not allowed to write to the database. [Search the codebase for `Gitlab::Database.read_only?`.](https://gitlab.com/search?utf8=%E2%9C%93&search=Gitlab%3A%3ADatabase.read_only%3F&group_id=9970&project_id=278964&scope=blobs&search_code=false&snippets=false&repository_ref=)
|
||||
- [the read-only database method](https://gitlab.com/gitlab-org/gitlab/blob/2425e9de50c678413ceaad6ee3bf66f42b7e228c/ee/lib/ee/gitlab/database.rb#L13), which toggles special behavior when we are not allowed to write to the database. [Search the codebase for `Gitlab::Database.read_only?`.](https://gitlab.com/search?search=Gitlab%3A%3ADatabase.read_only%3F&group_id=9970&project_id=278964&scope=blobs&search_code=false&snippets=false&repository_ref=)
|
||||
- [the read-only middleware](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/ee/gitlab/middleware/read_only/controller.rb), where HTTP requests that cause database writes are blocked, unless explicitly allowed.
|
||||
- [Git push access via SSH is denied](https://gitlab.com/gitlab-org/gitlab/-/blob/2425e9de50c678413ceaad6ee3bf66f42b7e228c/ee/lib/ee/gitlab/git_access.rb#L13) by returning 401 when `gitlab-shell` POSTs to [`/internal/allowed`](internal_api.md) to [check if access is allowed](internal_api.md#git-authentication).
|
||||
- [Container registry authentication service](https://gitlab.com/gitlab-org/gitlab/-/blob/2425e9de50c678413ceaad6ee3bf66f42b7e228c/ee/app/services/ee/auth/container_registry_authentication_service.rb#L12), where updates to the container registry are blocked.
|
||||
|
|
|
@ -186,7 +186,7 @@ stageGroupDashboards.dashboard('source_code')
|
|||
mode='markdown',
|
||||
content=|||
|
||||
Useful link for the Source Code Management group dashboard:
|
||||
- [Issue list](https://gitlab.com/groups/gitlab-org/-/issues?scope=all&utf8=%E2%9C%93&state=opened&label_name%5B%5D=repository)
|
||||
- [Issue list](https://gitlab.com/groups/gitlab-org/-/issues?scope=all&state=opened&label_name%5B%5D=repository)
|
||||
- [Epic list](https://gitlab.com/groups/gitlab-org/-/epics?label_name[]=repository)
|
||||
|||,
|
||||
),
|
||||
|
|
|
@ -502,7 +502,7 @@ for `shared_buffers` is quite high and as such we are looking into adjusting it.
|
|||
More information on this particular change can be found at
|
||||
<https://gitlab.com/gitlab-com/infrastructure/-/issues/1555>. An up to date list
|
||||
of proposed changes can be found at
|
||||
<https://gitlab.com/gitlab-com/infrastructure/-/issues?scope=all&utf8=%E2%9C%93&state=opened&label_name[]=database&label_name[]=change>.
|
||||
<https://gitlab.com/gitlab-com/infrastructure/-/issues?scope=all&state=opened&label_name[]=database&label_name[]=change>.
|
||||
|
||||
## Puma
|
||||
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
Before Width: | Height: | Size: 29 KiB |
Binary file not shown.
Before Width: | Height: | Size: 28 KiB |
|
@ -17,12 +17,13 @@ to perform actions.
|
|||
|
||||
Prerequisite:
|
||||
|
||||
- You must have the [Maintainer role or Owner role](../../permissions.md).
|
||||
- You must have the [Maintainer or Owner role](../../permissions.md).
|
||||
|
||||
To add a user to a project:
|
||||
|
||||
1. Go to your project and select **Members**.
|
||||
1. On the **Invite member** tab, under **GitLab member of Email address**, type the username or email address.
|
||||
1. On the **Invite member** tab, under **GitLab member or Email address**, type the username or email address.
|
||||
In GitLab 13.11 and later, you can [replace this form with a modal window](#add-a-member-modal-window).
|
||||
1. Select a [role](../../permissions.md).
|
||||
1. Optional. Choose an expiration date. On that date, the user can no longer access the project.
|
||||
1. Select **Invite**.
|
||||
|
@ -30,15 +31,24 @@ To add a user to a project:
|
|||
If the user has a GitLab account, they are added to the members list.
|
||||
If you used an email address, the user receives an email.
|
||||
|
||||
If the invitation is not accepted, GitLab sends reminder emails two,
|
||||
five, and ten days later. Unaccepted invites are automatically
|
||||
deleted after 90 days.
|
||||
|
||||
If the user does not have a GitLab account, they are prompted to create an account
|
||||
using the email address the invitation was sent to.
|
||||
|
||||
## Add groups to a project
|
||||
|
||||
When you assign a group to a project, each user in the group gets access to the project,
|
||||
based on the role they're assigned in the group. However, the user's access is also
|
||||
limited by the maximum role you choose when you invite the group.
|
||||
When you add a group to a project, each user in the group gets access to the project.
|
||||
Each user's access is based on:
|
||||
|
||||
- The role they're assigned in the group.
|
||||
- The maximum role you choose when you invite the group.
|
||||
|
||||
Prerequisite:
|
||||
|
||||
- You must have the [Maintainer role or Owner role](../../permissions.md).
|
||||
- You must have the [Maintainer or Owner role](../../permissions.md).
|
||||
|
||||
To add groups to a project:
|
||||
|
||||
|
@ -61,7 +71,7 @@ retain the same permissions as the project you import them from.
|
|||
|
||||
Prerequisite:
|
||||
|
||||
- You must have the [Maintainer role or Owner role](../../permissions.md).
|
||||
- You must have the [Maintainer or Owner role](../../permissions.md).
|
||||
|
||||
To import users:
|
||||
|
||||
|
@ -74,20 +84,39 @@ A success message is displayed and the new members are now displayed in the list
|
|||
|
||||
## Inherited membership
|
||||
|
||||
When your project belongs to a group, group members inherit the membership and permission
|
||||
level for the project from the group.
|
||||
When your project belongs to a group, group members inherit their role
|
||||
from the group.
|
||||
|
||||
![Project members page](img/project_members_v13_9.png)
|
||||
|
||||
From the image above, we can deduce the following things:
|
||||
In this example:
|
||||
|
||||
- There are 3 members that have access to the project.
|
||||
- User0 is a Reporter and has inherited their permissions from group `demo`
|
||||
which contains current project.
|
||||
- User1 is shown as a **Direct member** in the **Source** column, therefore they belong directly
|
||||
to the project we're inspecting.
|
||||
- Administrator is the Owner and member of **all** groups and for that reason,
|
||||
there is an indication of an ancestor group and inherited the [Owner role](../../permissions.md).
|
||||
- Three members have access to the project.
|
||||
- **User 0** is a Reporter and has inherited their role from the **demo** group,
|
||||
which contains the project.
|
||||
- **User 1** belongs directly to the project. In the **Source** column, they are listed
|
||||
as a **Direct member**.
|
||||
- **Administrator** is the [Owner](../../permissions.md) and member of all groups.
|
||||
They have inherited their role from the **demo** group.
|
||||
|
||||
## Remove a member from a project
|
||||
|
||||
If a user is a direct member of a project, you can remove them.
|
||||
If membership is inherited from a parent group, then the member can be removed only from the parent
|
||||
group itself.
|
||||
|
||||
Prerequisite:
|
||||
|
||||
- You must have the [Owner role](../../permissions.md).
|
||||
- Optional. Unassign the member from all issues and merge requests that
|
||||
are assigned to them.
|
||||
|
||||
To remove a member from a project:
|
||||
|
||||
1. Go to your project and select **Members**.
|
||||
1. Next to the project member you want to remove, select **Remove member** **{remove}**.
|
||||
1. Optional. In the confirmation box, select the **Also unassign this user from related issues and merge requests** checkbox.
|
||||
1. Select **Remove member**.
|
||||
|
||||
## Filter and sort members
|
||||
|
||||
|
@ -95,22 +124,21 @@ From the image above, we can deduce the following things:
|
|||
> - [Improved](https://gitlab.com/groups/gitlab-org/-/epics/4901) in GitLab 13.9.
|
||||
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/299954) in GitLab 13.10.
|
||||
|
||||
The following sections illustrate how you can filter and sort members in a project. To view these options,
|
||||
navigate to your desired project, go to **Members**, and include the noted search terms.
|
||||
You can filter and sort members in a project.
|
||||
|
||||
### Membership filter
|
||||
### Display inherited members
|
||||
|
||||
By default, inherited and direct members are displayed. The membership filter can be used to display only inherited or only direct members.
|
||||
|
||||
#### Display inherited members
|
||||
|
||||
To display inherited members, include `Membership` `=` `Inherited` in the search text box.
|
||||
1. Go to your project and select **Members**.
|
||||
1. In the **Filter members** box, select `Membership` `=` `Inherited`.
|
||||
1. Press Enter.
|
||||
|
||||
![Project members filter inherited](img/project_members_filter_inherited_v13_9.png)
|
||||
|
||||
#### Display direct members
|
||||
### Display direct members
|
||||
|
||||
To display direct members, include `Membership` `=` `Direct` in the search text box.
|
||||
1. Go to your project and select **Members**.
|
||||
1. In the **Filter members** box, select `Membership` `=` `Direct`.
|
||||
1. Press Enter.
|
||||
|
||||
![Project members filter direct](img/project_members_filter_direct_v13_9.png)
|
||||
|
||||
|
@ -126,36 +154,41 @@ You can sort members by **Account**, **Access granted**, **Max role**, or **Last
|
|||
|
||||
![Project members sort](img/project_members_sort_v13_9.png)
|
||||
|
||||
## Invite people using their e-mail address
|
||||
## Request access to a project
|
||||
|
||||
NOTE:
|
||||
In GitLab 13.11, you can [replace this form with a modal window](#add-a-member-modal-window).
|
||||
GitLab users can request to become a member of a project.
|
||||
|
||||
If a user you want to give access to doesn't have an account on your GitLab
|
||||
instance, you can invite them just by typing their e-mail address in the
|
||||
user search field.
|
||||
1. Go to the project you'd like to be a member of.
|
||||
1. By the project name, select **Request Access**.
|
||||
|
||||
![Invite user by mail](img/add_user_email_search_v13_8.png)
|
||||
![Request access button](img/request_access_button.png)
|
||||
|
||||
As you can imagine, you can mix inviting multiple people and adding existing
|
||||
GitLab users to the project.
|
||||
An email is sent to the most recently active project maintainers.
|
||||
Up to ten project maintainers are notified.
|
||||
Any project maintainer can approve or decline the request.
|
||||
|
||||
![Invite user by mail ready to submit](img/add_user_email_ready_v13_8.png)
|
||||
If a project does not have any maintainers, the notification is sent to the
|
||||
most recently active owners of the project's group.
|
||||
|
||||
Once done, hit **Add users to project** and watch that there is a new member
|
||||
with the e-mail address we used above. From there on, you can resend the
|
||||
invitation, change their access level, or even delete them.
|
||||
If you change your mind before your request is approved, select
|
||||
**Withdraw Access Request**.
|
||||
|
||||
![Invite user members list](img/add_user_email_accept_v13_9.png)
|
||||
## Prevent users from requesting access to a project
|
||||
|
||||
While unaccepted, the system automatically sends reminder emails on the second, fifth,
|
||||
and tenth day after the invitation was initially sent.
|
||||
You can prevent users from requesting access to a project.
|
||||
|
||||
After the user accepts the invitation, they are prompted to create a new
|
||||
GitLab account using the same e-mail address the invitation was sent to.
|
||||
Prerequisite:
|
||||
|
||||
NOTE:
|
||||
Unaccepted invites are automatically deleted after 90 days.
|
||||
- You must be the project owner.
|
||||
|
||||
1. Go to the project and select **Settings > General**.
|
||||
1. Expand the **Visibility, project features, permissions** section.
|
||||
1. Under **Project visibility**, select **Users can request access**.
|
||||
1. Select **Save changes**.
|
||||
|
||||
## Share a project with a group
|
||||
|
||||
Instead of adding users one by one, you can [share a project with an entire group](share_project_with_groups.md).
|
||||
|
||||
### Add a member modal window
|
||||
|
||||
|
@ -172,10 +205,10 @@ This feature might not be available to you. Check the **version history** note a
|
|||
In GitLab 13.11, you can optionally replace the form to add a member with a modal window.
|
||||
To add a member after enabling this feature:
|
||||
|
||||
1. Go to your project's page.
|
||||
1. In the left sidebar, go to **Members**, and then select **Invite members**.
|
||||
1. Enter an email address, and select a role permission for this user.
|
||||
1. (Optional) Select an **Access expiration date**.
|
||||
1. Go to your project and select **Members**.
|
||||
1. Select **Invite members**.
|
||||
1. Enter an email address and select a role.
|
||||
1. Optional. Select an **Access expiration date**.
|
||||
1. Select **Invite**.
|
||||
|
||||
### Enable or disable modal window **(FREE SELF)**
|
||||
|
@ -196,65 +229,3 @@ To disable it:
|
|||
```ruby
|
||||
Feature.disable(:invite_members_group_modal)
|
||||
```
|
||||
|
||||
## Project membership and requesting access
|
||||
|
||||
Project owners can :
|
||||
|
||||
- Allow non-members to request access to the project.
|
||||
- Prevent non-members from requesting access.
|
||||
|
||||
To configure this, go to the project settings and click on **Allow users to request access**.
|
||||
|
||||
GitLab users can request to become a member of a project. Go to the project you'd
|
||||
like to be a member of and click the **Request Access** button on the right
|
||||
side of your screen.
|
||||
|
||||
![Request access button](img/request_access_button.png)
|
||||
|
||||
After access is requested:
|
||||
|
||||
- Up to ten project maintainers are notified of the request via email.
|
||||
Email is sent to the most recently active project maintainers.
|
||||
- Any project maintainer can approve or decline the request on the members page.
|
||||
|
||||
NOTE:
|
||||
If a project does not have any maintainers, the notification is sent to the
|
||||
most recently active owners of the project's group.
|
||||
|
||||
![Manage access requests](img/access_requests_management_v13_9.png)
|
||||
|
||||
If you change your mind before your request is approved, just click the
|
||||
**Withdraw Access Request** button.
|
||||
|
||||
![Withdraw access request button](img/withdraw_access_request_button.png)
|
||||
|
||||
## Share project with group
|
||||
|
||||
Alternatively, you can [share a project with an entire group](share_project_with_groups.md) instead of adding users one by one.
|
||||
|
||||
## Remove a member from the project
|
||||
|
||||
Only users with permissions of [Owner](../../permissions.md#group-members-permissions) can manage
|
||||
project members.
|
||||
|
||||
You can remove a user from the project if the given member has a direct membership in the project.
|
||||
If membership is inherited from a parent group, then the member can be removed only from the parent
|
||||
group itself.
|
||||
|
||||
When removing a member, you can decide whether to unassign the user from all issues and merge
|
||||
requests they are currently assigned or leave the assignments as they are.
|
||||
|
||||
- **Unassigning the removed member** from all issues and merge requests might be helpful when a user
|
||||
is leaving a private project and you wish to revoke their access to any issues and merge requests
|
||||
they are assigned.
|
||||
- **Keeping the issues and merge requests assigned** might be helpful for projects that accept public
|
||||
contributions where a user doesn't have to be a member to be able to contribute to issues and
|
||||
merge requests.
|
||||
|
||||
To remove a member from a project:
|
||||
|
||||
1. Go to your project and select **Members**.
|
||||
1. Next to the project member you want to remove, select **Remove member** **{remove}**.
|
||||
1. Optional. In the confirmation box, select the **Also unassign this user from related issues and merge requests** checkbox.
|
||||
1. Select **Remove member**.
|
||||
|
|
|
@ -179,7 +179,7 @@ section.
|
|||
You can now change the [Project visibility](../../../public_access/public_access.md).
|
||||
If you set **Project Visibility** to public, you can limit access to some features
|
||||
to **Only Project Members**. In addition, you can select the option to
|
||||
[Allow users to request access](../members/index.md#project-membership-and-requesting-access).
|
||||
[Allow users to request access](../members/index.md#prevent-users-from-requesting-access-to-a-project).
|
||||
|
||||
Use the switches to enable or disable the following features:
|
||||
|
||||
|
|
|
@ -57,13 +57,13 @@ Full details can be found in the [Elasticsearch documentation](https://www.elast
|
|||
here's a quick guide:
|
||||
|
||||
- Searches look for all the words in a query, in any order - e.g.: searching
|
||||
issues for [`display bug`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=issues&repository_ref=&search=display+bug&group_id=9970&project_id=278964) and [`bug display`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=issues&repository_ref=&search=bug+Display&group_id=9970&project_id=278964) will return the same results.
|
||||
- To find the exact phrase (stemming still applies), use double quotes: [`"display bug"`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=issues&repository_ref=&search=%22display+bug%22&group_id=9970&project_id=278964)
|
||||
- To find bugs not mentioning display, use `-`: [`bug -display`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=issues&repository_ref=&search=bug+-display&group_id=9970&project_id=278964)
|
||||
- To find a bug in display or banner, use `|`: [`bug display | banner`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=issues&repository_ref=&search=bug+display+%7C+banner&group_id=9970&project_id=278964)
|
||||
- To group terms together, use parentheses: [`bug | (display +banner)`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=issues&repository_ref=&search=bug+%7C+%28display+%2Bbanner%29&group_id=9970&project_id=278964)
|
||||
- To match a partial word, use `*`. In this example, I want to find bugs with any 500 errors. : [`bug error 50*`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=issues&repository_ref=&search=bug+error+50*&group_id=9970&project_id=278964)
|
||||
- To use one of symbols above literally, escape the symbol with a preceding `\`: [`argument \-last`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=argument+%5C-last&group_id=9970&project_id=278964)
|
||||
issues for [`display bug`](https://gitlab.com/search?snippets=&scope=issues&repository_ref=&search=display+bug&group_id=9970&project_id=278964) and [`bug display`](https://gitlab.com/search?snippets=&scope=issues&repository_ref=&search=bug+Display&group_id=9970&project_id=278964) will return the same results.
|
||||
- To find the exact phrase (stemming still applies), use double quotes: [`"display bug"`](https://gitlab.com/search?snippets=&scope=issues&repository_ref=&search=%22display+bug%22&group_id=9970&project_id=278964)
|
||||
- To find bugs not mentioning display, use `-`: [`bug -display`](https://gitlab.com/search?snippets=&scope=issues&repository_ref=&search=bug+-display&group_id=9970&project_id=278964)
|
||||
- To find a bug in display or banner, use `|`: [`bug display | banner`](https://gitlab.com/search?snippets=&scope=issues&repository_ref=&search=bug+display+%7C+banner&group_id=9970&project_id=278964)
|
||||
- To group terms together, use parentheses: [`bug | (display +banner)`](https://gitlab.com/search?snippets=&scope=issues&repository_ref=&search=bug+%7C+%28display+%2Bbanner%29&group_id=9970&project_id=278964)
|
||||
- To match a partial word, use `*`. In this example, I want to find bugs with any 500 errors. : [`bug error 50*`](https://gitlab.com/search?snippets=&scope=issues&repository_ref=&search=bug+error+50*&group_id=9970&project_id=278964)
|
||||
- To use one of symbols above literally, escape the symbol with a preceding `\`: [`argument \-last`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=argument+%5C-last&group_id=9970&project_id=278964)
|
||||
|
||||
## Syntax search filters
|
||||
|
||||
|
@ -79,15 +79,15 @@ any spaces between the colon (`:`) and the value. When no keyword is provided, a
|
|||
|
||||
Examples:
|
||||
|
||||
- Finding a file with any content named `search_results.rb`: [`* filename:search_results.rb`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=*+filename%3Asearch_results.rb&group_id=9970&project_id=278964)
|
||||
- Finding a file with any content named `search_results.rb`: [`* filename:search_results.rb`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=*+filename%3Asearch_results.rb&group_id=9970&project_id=278964)
|
||||
- The leading asterisk (`*`) can be ignored in the case above: [`filename:search_results.rb`](https://gitlab.com/search?group_id=9970&project_id=278964&scope=blobs&search=filename%3Asearch_results.rb)
|
||||
- Finding a file named `found_blob_spec.rb` with the text `CHANGELOG` inside of it: [`CHANGELOG filename:found_blob_spec.rb`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=CHANGELOG+filename%3Afound_blob_spec.rb&group_id=9970&project_id=278964)
|
||||
- Finding the text `EpicLinks` inside files with the `.rb` extension: [`EpicLinks extension:rb`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=EpicLinks+extension%3Arb&group_id=9970&project_id=278964)
|
||||
- Finding any file with the `.yaml` extension: [`extension:yaml`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=extension%3Ayaml&group_id=9970&project_id=278964)
|
||||
- Finding the text `Sidekiq` in a file, when that file is in a path that includes `elastic`: [`Sidekiq path:elastic`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=Sidekiq+path%3Aelastic&group_id=9970&project_id=278964)
|
||||
- Finding any file in a path that includes `elasticsearch`: [`path:elasticsearch`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=path%3Aelasticsearch&group_id=9970&project_id=278964)
|
||||
- Finding the files represented by the Git object ID `998707b421c89bd9a3063333f9f728ef3e43d101`: [`* blob:998707b421c89bd9a3063333f9f728ef3e43d101`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=false&scope=blobs&repository_ref=&search=*+blob%3A998707b421c89bd9a3063333f9f728ef3e43d101&group_id=9970)
|
||||
- Syntax filters can be combined for complex filtering. Finding any file starting with `search` containing `eventHub` and with the `.js` extension: [`eventHub filename:search* extension:js`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=eventHub+filename%3Asearch*+extension%3Ajs&group_id=9970&project_id=278964)
|
||||
- Finding a file named `found_blob_spec.rb` with the text `CHANGELOG` inside of it: [`CHANGELOG filename:found_blob_spec.rb`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=CHANGELOG+filename%3Afound_blob_spec.rb&group_id=9970&project_id=278964)
|
||||
- Finding the text `EpicLinks` inside files with the `.rb` extension: [`EpicLinks extension:rb`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=EpicLinks+extension%3Arb&group_id=9970&project_id=278964)
|
||||
- Finding any file with the `.yaml` extension: [`extension:yaml`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=extension%3Ayaml&group_id=9970&project_id=278964)
|
||||
- Finding the text `Sidekiq` in a file, when that file is in a path that includes `elastic`: [`Sidekiq path:elastic`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=Sidekiq+path%3Aelastic&group_id=9970&project_id=278964)
|
||||
- Finding any file in a path that includes `elasticsearch`: [`path:elasticsearch`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=path%3Aelasticsearch&group_id=9970&project_id=278964)
|
||||
- Finding the files represented by the Git object ID `998707b421c89bd9a3063333f9f728ef3e43d101`: [`* blob:998707b421c89bd9a3063333f9f728ef3e43d101`](https://gitlab.com/search?snippets=false&scope=blobs&repository_ref=&search=*+blob%3A998707b421c89bd9a3063333f9f728ef3e43d101&group_id=9970)
|
||||
- Syntax filters can be combined for complex filtering. Finding any file starting with `search` containing `eventHub` and with the `.js` extension: [`eventHub filename:search* extension:js`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=eventHub+filename%3Asearch*+extension%3Ajs&group_id=9970&project_id=278964)
|
||||
|
||||
### Excluding filters
|
||||
|
||||
|
@ -102,14 +102,14 @@ Filters can be inverted to **filter out** results from the result set, by prefix
|
|||
|
||||
Examples:
|
||||
|
||||
- Finding `rails` in all files but `Gemfile.lock`: [`rails -filename:Gemfile.lock`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=rails+-filename%3AGemfile.lock&group_id=9970&project_id=278964)
|
||||
- Finding `success` in all files excluding `.po|pot` files: [`success -filename:*.po*`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=success+-filename%3A*.po*&group_id=9970&project_id=278964)
|
||||
- Finding `import` excluding minified JavaScript (`.min.js`) files: [`import -extension:min.js`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=import+-extension%3Amin.js&group_id=9970&project_id=278964)
|
||||
- Finding `docs` for all files outside the `docs/` folder: [`docs -path:docs/`](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=blobs&repository_ref=&search=docs+-path%3Adocs%2F&group_id=9970&project_id=278964)
|
||||
- Finding `rails` in all files but `Gemfile.lock`: [`rails -filename:Gemfile.lock`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=rails+-filename%3AGemfile.lock&group_id=9970&project_id=278964)
|
||||
- Finding `success` in all files excluding `.po|pot` files: [`success -filename:*.po*`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=success+-filename%3A*.po*&group_id=9970&project_id=278964)
|
||||
- Finding `import` excluding minified JavaScript (`.min.js`) files: [`import -extension:min.js`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=import+-extension%3Amin.js&group_id=9970&project_id=278964)
|
||||
- Finding `docs` for all files outside the `docs/` folder: [`docs -path:docs/`](https://gitlab.com/search?snippets=&scope=blobs&repository_ref=&search=docs+-path%3Adocs%2F&group_id=9970&project_id=278964)
|
||||
|
||||
## Search by issue or merge request ID
|
||||
|
||||
You can search a specific issue or merge request by its ID with a special prefix.
|
||||
|
||||
- To search by issue ID, use prefix `#` followed by issue ID. For example, [#23456](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=issues&repository_ref=&search=%2323456&group_id=9970&project_id=278964)
|
||||
- To search by merge request ID, use prefix `!` followed by merge request ID. For example [!23456](https://gitlab.com/search?utf8=%E2%9C%93&snippets=&scope=merge_requests&repository_ref=&search=%2123456&group_id=9970&project_id=278964)
|
||||
- To search by issue ID, use prefix `#` followed by issue ID. For example, [#23456](https://gitlab.com/search?snippets=&scope=issues&repository_ref=&search=%2323456&group_id=9970&project_id=278964)
|
||||
- To search by merge request ID, use prefix `!` followed by merge request ID. For example [!23456](https://gitlab.com/search?snippets=&scope=merge_requests&repository_ref=&search=%2123456&group_id=9970&project_id=278964)
|
||||
|
|
|
@ -40,4 +40,5 @@ container_scanning:
|
|||
rules:
|
||||
- if: $CONTAINER_SCANNING_DISABLED
|
||||
when: never
|
||||
- if: $GITLAB_FEATURES =~ /\bcontainer_scanning\b/
|
||||
- if: $CI_COMMIT_BRANCH &&
|
||||
$GITLAB_FEATURES =~ /\bcontainer_scanning\b/
|
||||
|
|
|
@ -155,6 +155,7 @@ module Gitlab
|
|||
params '<1w 3d 2h 14m>'
|
||||
types Issue, MergeRequest
|
||||
condition do
|
||||
quick_action_target.supports_time_tracking? &&
|
||||
current_user.can?(:"admin_#{quick_action_target.to_ability_name}", project)
|
||||
end
|
||||
parse_params do |raw_duration|
|
||||
|
@ -177,6 +178,7 @@ module Gitlab
|
|||
params '<time(1h30m | -1h30m)> <date(YYYY-MM-DD)>'
|
||||
types Issue, MergeRequest
|
||||
condition do
|
||||
quick_action_target.supports_time_tracking? &&
|
||||
current_user.can?(:"admin_#{quick_action_target.to_ability_name}", quick_action_target)
|
||||
end
|
||||
parse_params do |raw_time_date|
|
||||
|
|
|
@ -52,7 +52,7 @@ module SystemCheck
|
|||
def load_config
|
||||
erb = ERB.new(File.read(mail_room_config_path))
|
||||
erb.filename = mail_room_config_path
|
||||
config_file = YAML.safe_load(erb.result)
|
||||
config_file = YAML.safe_load(erb.result, permitted_classes: [Symbol])
|
||||
|
||||
config_file[:mailboxes]
|
||||
end
|
||||
|
|
|
@ -13087,6 +13087,9 @@ msgstr ""
|
|||
msgid "EscalationPolicies|A schedule is required for adding an escalation policy."
|
||||
msgstr ""
|
||||
|
||||
msgid "EscalationPolicies|A schedule is required for adding an escalation policy. Please create an on-call schedule first."
|
||||
msgstr ""
|
||||
|
||||
msgid "EscalationPolicies|Add an escalation policy"
|
||||
msgstr ""
|
||||
|
||||
|
@ -17749,9 +17752,6 @@ msgstr ""
|
|||
msgid "Integrations|All projects inheriting these settings will also be reset."
|
||||
msgstr ""
|
||||
|
||||
msgid "Integrations|As a GitLab administrator, you can set default configuration parameters for a given integration that all projects can inherit and use. When you set these parameters, your changes update the integration for all projects that are not already using custom settings. Learn more about %{integrations_link_start}Project integration management%{link_end}."
|
||||
msgstr ""
|
||||
|
||||
msgid "Integrations|Browser limitations"
|
||||
msgstr ""
|
||||
|
||||
|
@ -17791,6 +17791,9 @@ msgstr ""
|
|||
msgid "Integrations|Failed to unlink namespace. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Integrations|GitLab administrators can set up integrations that all projects inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a group or project if the settings are necessary at that level. Learn more about %{integrations_link_start}project integration management%{link_end}."
|
||||
msgstr ""
|
||||
|
||||
msgid "Integrations|Includes Standard, plus the entire commit message, commit hash, and issue IDs"
|
||||
msgstr ""
|
||||
|
||||
|
@ -18556,12 +18559,21 @@ msgstr ""
|
|||
msgid "Iterations|Cadence name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Couldn't find iteration cadence"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Create cadence"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Duration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Edit cadence"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Edit iteration cadence"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Error loading iteration cadences."
|
||||
msgstr ""
|
||||
|
||||
|
@ -18583,6 +18595,9 @@ msgstr ""
|
|||
msgid "Iterations|Number of future iterations you would like to have scheduled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Save cadence"
|
||||
msgstr ""
|
||||
|
||||
msgid "Iterations|Select duration"
|
||||
msgstr ""
|
||||
|
||||
|
@ -20462,9 +20477,6 @@ msgstr ""
|
|||
msgid "Measured in bytes of code. Excludes generated and vendored code."
|
||||
msgstr ""
|
||||
|
||||
msgid "Median"
|
||||
msgstr ""
|
||||
|
||||
msgid "Medium Timeout Period"
|
||||
msgstr ""
|
||||
|
||||
|
@ -25667,9 +25679,6 @@ msgstr ""
|
|||
msgid "ProjectLastActivity|Never"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectLifecycle|Stage"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectOverview|Fork"
|
||||
msgstr ""
|
||||
|
||||
|
@ -32667,9 +32676,6 @@ msgstr ""
|
|||
msgid "The password for your GitLab account on %{link_to_gitlab} has successfully been changed."
|
||||
msgstr ""
|
||||
|
||||
msgid "The phase of the development lifecycle."
|
||||
msgstr ""
|
||||
|
||||
msgid "The pipeline has been deleted"
|
||||
msgstr ""
|
||||
|
||||
|
@ -32814,9 +32820,6 @@ msgstr ""
|
|||
msgid "The username for the Jenkins server."
|
||||
msgstr ""
|
||||
|
||||
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
|
||||
msgstr ""
|
||||
|
||||
msgid "The value of the provided variable exceeds the %{count} character limit"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -115,7 +115,7 @@
|
|||
"deckar01-task_list": "^2.3.1",
|
||||
"diff": "^3.4.0",
|
||||
"document-register-element": "1.14.3",
|
||||
"dompurify": "^2.2.8",
|
||||
"dompurify": "^2.2.9",
|
||||
"dropzone": "^4.2.0",
|
||||
"editorconfig": "^0.15.3",
|
||||
"emoji-regex": "^7.0.3",
|
||||
|
|
|
@ -156,7 +156,7 @@ RSpec.describe Projects::CompareController do
|
|||
it 'shows a flash message and redirects' do
|
||||
show_request
|
||||
|
||||
expect(flash[:alert]).to eq('Invalid branch name')
|
||||
expect(flash[:alert]).to eq("Invalid branch name(s): master%' AND 2554=4423 AND '%'='")
|
||||
expect(response).to have_gitlab_http_status(:found)
|
||||
end
|
||||
end
|
||||
|
@ -169,7 +169,20 @@ RSpec.describe Projects::CompareController do
|
|||
it 'shows a flash message and redirects' do
|
||||
show_request
|
||||
|
||||
expect(flash[:alert]).to eq('Invalid branch name')
|
||||
expect(flash[:alert]).to eq("Invalid branch name(s): master%' AND 2554=4423 AND '%'='")
|
||||
expect(response).to have_gitlab_http_status(:found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the both refs are invalid' do
|
||||
let(:from_project_id) { nil }
|
||||
let(:from_ref) { "master%' AND 2554=4423 AND '%'='" }
|
||||
let(:to_ref) { "improve%' =,awesome" }
|
||||
|
||||
it 'shows a flash message and redirects' do
|
||||
show_request
|
||||
|
||||
expect(flash[:alert]).to eq("Invalid branch name(s): improve%' =,awesome, master%' AND 2554=4423 AND '%'='")
|
||||
expect(response).to have_gitlab_http_status(:found)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -32,7 +32,7 @@ RSpec.describe 'Value Stream Analytics', :js do
|
|||
end
|
||||
|
||||
it 'shows active stage with empty message' do
|
||||
expect(page).to have_selector('.stage-nav-item.active', text: 'Issue')
|
||||
expect(page).to have_selector('.gl-path-active-item-indigo', text: 'Issue')
|
||||
expect(page).to have_content("We don't have enough data to show this stage.")
|
||||
end
|
||||
end
|
||||
|
@ -171,7 +171,7 @@ RSpec.describe 'Value Stream Analytics', :js do
|
|||
end
|
||||
|
||||
def click_stage(stage_name)
|
||||
find('.stage-nav li', text: stage_name).click
|
||||
find('.gl-path-nav-list-item', text: stage_name).click
|
||||
wait_for_requests
|
||||
end
|
||||
end
|
||||
|
|
|
@ -24,7 +24,7 @@ RSpec.describe 'User searches group settings', :js do
|
|||
visit group_settings_integrations_path(group)
|
||||
end
|
||||
|
||||
it_behaves_like 'can highlight results', 'set default configuration'
|
||||
it_behaves_like 'can highlight results', 'Project integration management'
|
||||
end
|
||||
|
||||
context 'in Repository page' do
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Value stream analytics component isEmptyStage = true renders the empty stage with \`Not enough data\` message 1`] = `"<gl-empty-state-stub title=\\"We don't have enough data to show this stage.\\" svgpath=\\"path/to/no/data\\" description=\\"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
|
||||
|
||||
exports[`Value stream analytics component isLoading = true renders the path navigation component with prop \`loading\` set to true 1`] = `"<path-navigation-stub loading=\\"true\\" stages=\\"\\" selectedstage=\\"[object Object]\\" class=\\"js-path-navigation gl-w-full gl-pb-2\\"></path-navigation-stub>"`;
|
||||
|
||||
exports[`Value stream analytics component without enough permissions renders the empty stage with \`You need permission\` message 1`] = `"<gl-empty-state-stub title=\\"You need permission.\\" svgpath=\\"path/to/no/access\\" description=\\"Want to see the data? Please ask an administrator for access.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
|
173
spec/frontend/cycle_analytics/base_spec.js
Normal file
173
spec/frontend/cycle_analytics/base_spec.js
Normal file
|
@ -0,0 +1,173 @@
|
|||
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import BaseComponent from '~/cycle_analytics/components/base.vue';
|
||||
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
|
||||
import initState from '~/cycle_analytics/store/state';
|
||||
import { selectedStage, convertedEvents as selectedStageEvents } from './mock_data';
|
||||
|
||||
const noDataSvgPath = 'path/to/no/data';
|
||||
const noAccessSvgPath = 'path/to/no/access';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
let wrapper;
|
||||
|
||||
function createStore({ initialState = {} }) {
|
||||
return new Vuex.Store({
|
||||
state: {
|
||||
...initState(),
|
||||
...initialState,
|
||||
},
|
||||
getters: {
|
||||
pathNavigationData: () => [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createComponent({ initialState } = {}) {
|
||||
return extendedWrapper(
|
||||
shallowMount(BaseComponent, {
|
||||
store: createStore({ initialState }),
|
||||
propsData: {
|
||||
noDataSvgPath,
|
||||
noAccessSvgPath,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
|
||||
const findPathNavigation = () => wrapper.findComponent(PathNavigation);
|
||||
const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics');
|
||||
const findStageTable = () => wrapper.findByTestId('vsa-stage-table');
|
||||
const findEmptyStage = () => wrapper.findComponent(GlEmptyState);
|
||||
const findStageEvents = () => wrapper.findByTestId('stage-table-events');
|
||||
|
||||
describe('Value stream analytics component', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
initialState: {
|
||||
isLoading: false,
|
||||
isLoadingStage: false,
|
||||
isEmptyStage: false,
|
||||
selectedStageEvents,
|
||||
selectedStage,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
it('renders the path navigation component', () => {
|
||||
expect(findPathNavigation().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the overview metrics', () => {
|
||||
expect(findOverviewMetrics().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the stage table', () => {
|
||||
expect(findStageTable().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the stage table events', () => {
|
||||
expect(findEmptyStage().exists()).toBe(false);
|
||||
expect(findStageEvents().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render the loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('isLoading = true', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
initialState: { isLoading: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the path navigation component with prop `loading` set to true', () => {
|
||||
expect(findPathNavigation().html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not render the overview metrics', () => {
|
||||
expect(findOverviewMetrics().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render the stage table', () => {
|
||||
expect(findStageTable().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLoadingStage = true', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
initialState: { isLoadingStage: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the stage table with a loading icon', () => {
|
||||
const tableWrapper = findStageTable();
|
||||
expect(tableWrapper.exists()).toBe(true);
|
||||
expect(tableWrapper.find(GlLoadingIcon).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmptyStage = true', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
initialState: { selectedStage, isEmptyStage: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the empty stage with `Not enough data` message', () => {
|
||||
expect(findEmptyStage().html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('without enough permissions', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
initialState: { selectedStage: { ...selectedStage, isUserAllowed: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the empty stage with `You need permission` message', () => {
|
||||
expect(findEmptyStage().html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a selected stage', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
initialState: { selectedStage: null, isEmptyStage: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the stage table', () => {
|
||||
expect(findStageTable().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render the path navigation component', () => {
|
||||
expect(findPathNavigation().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render the stage table events', () => {
|
||||
expect(findStageEvents().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render the loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,10 @@
|
|||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
|
||||
export const getStageByTitle = (stages, title) =>
|
||||
stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {};
|
||||
|
||||
export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging'];
|
||||
|
||||
export const summary = [
|
||||
{ value: '20', title: 'New Issues' },
|
||||
{ value: null, title: 'Commits' },
|
||||
|
@ -8,6 +13,7 @@ export const summary = [
|
|||
];
|
||||
|
||||
const issueStage = {
|
||||
id: 'issue',
|
||||
title: 'Issue',
|
||||
name: 'issue',
|
||||
legend: '',
|
||||
|
@ -16,30 +22,34 @@ const issueStage = {
|
|||
};
|
||||
|
||||
const planStage = {
|
||||
id: 'plan',
|
||||
title: 'Plan',
|
||||
name: 'plan',
|
||||
legend: '',
|
||||
description: 'Time before an issue starts implementation',
|
||||
value: 'about 21 hours',
|
||||
value: 75600,
|
||||
};
|
||||
|
||||
const codeStage = {
|
||||
id: 'code',
|
||||
title: 'Code',
|
||||
name: 'code',
|
||||
legend: '',
|
||||
description: 'Time until first merge request',
|
||||
value: '2 days',
|
||||
value: 172800,
|
||||
};
|
||||
|
||||
const testStage = {
|
||||
id: 'test',
|
||||
title: 'Test',
|
||||
name: 'test',
|
||||
legend: '',
|
||||
description: 'Total test time for all commits/merges',
|
||||
value: 'about 5 hours',
|
||||
value: 17550,
|
||||
};
|
||||
|
||||
const reviewStage = {
|
||||
id: 'review',
|
||||
title: 'Review',
|
||||
name: 'review',
|
||||
legend: '',
|
||||
|
@ -48,11 +58,12 @@ const reviewStage = {
|
|||
};
|
||||
|
||||
const stagingStage = {
|
||||
id: 'staging',
|
||||
title: 'Staging',
|
||||
name: 'staging',
|
||||
legend: '',
|
||||
description: 'From merge request merge until deploy to production',
|
||||
value: '2 days',
|
||||
value: 172800,
|
||||
};
|
||||
|
||||
export const selectedStage = {
|
||||
|
@ -184,3 +195,64 @@ export const rawEvents = [
|
|||
export const convertedEvents = rawEvents.map((ev) =>
|
||||
convertObjectPropsToCamelCase(ev, { deep: true }),
|
||||
);
|
||||
|
||||
export const pathNavIssueMetric = 172800;
|
||||
|
||||
export const rawStageMedians = [
|
||||
{ id: 'issue', value: 172800 },
|
||||
{ id: 'plan', value: 86400 },
|
||||
{ id: 'review', value: 1036800 },
|
||||
{ id: 'code', value: 129600 },
|
||||
{ id: 'test', value: 259200 },
|
||||
{ id: 'staging', value: 388800 },
|
||||
];
|
||||
|
||||
export const stageMedians = {
|
||||
issue: 172800,
|
||||
plan: 86400,
|
||||
review: 1036800,
|
||||
code: 129600,
|
||||
test: 259200,
|
||||
staging: 388800,
|
||||
};
|
||||
|
||||
export const allowedStages = [issueStage, planStage, codeStage];
|
||||
|
||||
export const transformedProjectStagePathData = [
|
||||
{
|
||||
metric: 172800,
|
||||
selected: true,
|
||||
stageCount: undefined,
|
||||
icon: null,
|
||||
id: 'issue',
|
||||
title: 'Issue',
|
||||
name: 'issue',
|
||||
legend: '',
|
||||
description: 'Time before an issue gets scheduled',
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
metric: 86400,
|
||||
selected: false,
|
||||
stageCount: undefined,
|
||||
icon: null,
|
||||
id: 'plan',
|
||||
title: 'Plan',
|
||||
name: 'plan',
|
||||
legend: '',
|
||||
description: 'Time before an issue starts implementation',
|
||||
value: 75600,
|
||||
},
|
||||
{
|
||||
metric: 129600,
|
||||
selected: false,
|
||||
stageCount: undefined,
|
||||
icon: null,
|
||||
id: 'code',
|
||||
title: 'Code',
|
||||
name: 'code',
|
||||
legend: '',
|
||||
description: 'Time until first merge request',
|
||||
value: 172800,
|
||||
},
|
||||
];
|
||||
|
|
136
spec/frontend/cycle_analytics/path_navigation_spec.js
Normal file
136
spec/frontend/cycle_analytics/path_navigation_spec.js
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { GlPath, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import Component from '~/cycle_analytics/components/path_navigation.vue';
|
||||
import { transformedProjectStagePathData, selectedStage } from './mock_data';
|
||||
|
||||
describe('Project PathNavigation', () => {
|
||||
let wrapper = null;
|
||||
|
||||
const createComponent = (props) => {
|
||||
return extendedWrapper(
|
||||
mount(Component, {
|
||||
propsData: {
|
||||
stages: transformedProjectStagePathData,
|
||||
selectedStage,
|
||||
loading: false,
|
||||
...props,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const findPathNavigation = () => {
|
||||
return wrapper.findByTestId('gl-path-nav');
|
||||
};
|
||||
|
||||
const findPathNavigationItems = () => {
|
||||
return findPathNavigation().findAll('li');
|
||||
};
|
||||
|
||||
const findPathNavigationTitles = () => {
|
||||
return findPathNavigation()
|
||||
.findAll('li button')
|
||||
.wrappers.map((w) => w.html());
|
||||
};
|
||||
|
||||
const clickItemAt = (index) => {
|
||||
findPathNavigationItems().at(index).find('button').trigger('click');
|
||||
};
|
||||
|
||||
const pathItemContent = () => findPathNavigationItems().wrappers.map(extendedWrapper);
|
||||
const firstPopover = () => wrapper.findAllByTestId('stage-item-popover').at(0);
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
describe('displays correctly', () => {
|
||||
it('has the correct props', () => {
|
||||
expect(wrapper.find(GlPath).props('items')).toMatchObject(transformedProjectStagePathData);
|
||||
});
|
||||
|
||||
it('contains all the expected stages', () => {
|
||||
const stageContent = findPathNavigationTitles();
|
||||
transformedProjectStagePathData.forEach((stage, index) => {
|
||||
expect(stageContent[index]).toContain(stage.title);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading', () => {
|
||||
describe('is false', () => {
|
||||
it('displays the gl-path component', () => {
|
||||
expect(wrapper.find(GlPath).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the gl-skeleton-loading component', () => {
|
||||
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders each stage', () => {
|
||||
const result = findPathNavigationTitles();
|
||||
expect(result.length).toBe(transformedProjectStagePathData.length);
|
||||
});
|
||||
|
||||
it('renders each stage with its median', () => {
|
||||
const result = findPathNavigationTitles();
|
||||
transformedProjectStagePathData.forEach(({ title, metric }, index) => {
|
||||
expect(result[index]).toContain(title);
|
||||
expect(result[index]).toContain(metric);
|
||||
});
|
||||
});
|
||||
|
||||
describe('popovers', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ stages: transformedProjectStagePathData });
|
||||
});
|
||||
|
||||
it('renders popovers for all stages', () => {
|
||||
pathItemContent().forEach((stage) => {
|
||||
expect(stage.findByTestId('stage-item-popover').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the median stage time for the first stage item', () => {
|
||||
expect(firstPopover().text()).toContain('Stage time (median)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('is true', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ loading: true });
|
||||
});
|
||||
|
||||
it('hides the gl-path component', () => {
|
||||
expect(wrapper.find(GlPath).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('displays the gl-skeleton-loading component', () => {
|
||||
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('event handling', () => {
|
||||
it('emits the selected event', () => {
|
||||
expect(wrapper.emitted('selected')).toBeUndefined();
|
||||
|
||||
clickItemAt(0);
|
||||
clickItemAt(1);
|
||||
clickItemAt(2);
|
||||
|
||||
expect(wrapper.emitted().selected).toEqual([
|
||||
[transformedProjectStagePathData[0]],
|
||||
[transformedProjectStagePathData[1]],
|
||||
[transformedProjectStagePathData[2]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
16
spec/frontend/cycle_analytics/store/getters_spec.js
Normal file
16
spec/frontend/cycle_analytics/store/getters_spec.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import * as getters from '~/cycle_analytics/store/getters';
|
||||
import {
|
||||
allowedStages,
|
||||
stageMedians,
|
||||
transformedProjectStagePathData,
|
||||
selectedStage,
|
||||
} from '../mock_data';
|
||||
|
||||
describe('Value stream analytics getters', () => {
|
||||
describe('pathNavigationData', () => {
|
||||
it('returns the transformed data', () => {
|
||||
const state = { stages: allowedStages, medians: stageMedians, selectedStage };
|
||||
expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,22 @@
|
|||
import { decorateEvents, decorateData } from '~/cycle_analytics/utils';
|
||||
import { selectedStage, rawData, convertedData, rawEvents } from './mock_data';
|
||||
import {
|
||||
decorateEvents,
|
||||
decorateData,
|
||||
transformStagesForPathNavigation,
|
||||
timeSummaryForPathNavigation,
|
||||
medianTimeToParsedSeconds,
|
||||
formatMedianValues,
|
||||
filterStagesByHiddenStatus,
|
||||
} from '~/cycle_analytics/utils';
|
||||
import {
|
||||
selectedStage,
|
||||
rawData,
|
||||
convertedData,
|
||||
rawEvents,
|
||||
allowedStages,
|
||||
stageMedians,
|
||||
pathNavIssueMetric,
|
||||
rawStageMedians,
|
||||
} from './mock_data';
|
||||
|
||||
describe('Value stream analytics utils', () => {
|
||||
describe('decorateEvents', () => {
|
||||
|
@ -74,4 +91,91 @@ describe('Value stream analytics utils', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformStagesForPathNavigation', () => {
|
||||
const stages = allowedStages;
|
||||
const response = transformStagesForPathNavigation({
|
||||
stages,
|
||||
medians: stageMedians,
|
||||
selectedStage,
|
||||
});
|
||||
|
||||
describe('transforms the data as expected', () => {
|
||||
it('returns an array of stages', () => {
|
||||
expect(Array.isArray(response)).toBe(true);
|
||||
expect(response.length).toBe(stages.length);
|
||||
});
|
||||
|
||||
it('selects the correct stage', () => {
|
||||
const selected = response.filter((stage) => stage.selected === true)[0];
|
||||
|
||||
expect(selected.title).toBe(selectedStage.title);
|
||||
});
|
||||
|
||||
it('includes the correct metric for the associated stage', () => {
|
||||
const issue = response.filter((stage) => stage.name === 'issue')[0];
|
||||
|
||||
expect(issue.metric).toBe(pathNavIssueMetric);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeSummaryForPathNavigation', () => {
|
||||
it.each`
|
||||
unit | value | result
|
||||
${'months'} | ${1.5} | ${'1.5M'}
|
||||
${'weeks'} | ${1.25} | ${'1.5w'}
|
||||
${'days'} | ${2} | ${'2d'}
|
||||
${'hours'} | ${10} | ${'10h'}
|
||||
${'minutes'} | ${20} | ${'20m'}
|
||||
${'seconds'} | ${10} | ${'<1m'}
|
||||
${'seconds'} | ${0} | ${'-'}
|
||||
`('will format $value $unit to $result', ({ unit, value, result }) => {
|
||||
expect(timeSummaryForPathNavigation({ [unit]: value })).toBe(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('medianTimeToParsedSeconds', () => {
|
||||
it.each`
|
||||
value | result
|
||||
${1036800} | ${'1w'}
|
||||
${259200} | ${'3d'}
|
||||
${172800} | ${'2d'}
|
||||
${86400} | ${'1d'}
|
||||
${1000} | ${'16m'}
|
||||
${61} | ${'1m'}
|
||||
${59} | ${'<1m'}
|
||||
${0} | ${'-'}
|
||||
`('will correctly parse $value seconds into $result', ({ value, result }) => {
|
||||
expect(medianTimeToParsedSeconds(value)).toBe(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMedianValues', () => {
|
||||
const calculatedMedians = formatMedianValues(rawStageMedians);
|
||||
|
||||
it('returns an object with each stage and their median formatted for display', () => {
|
||||
rawStageMedians.forEach(({ id, value }) => {
|
||||
expect(calculatedMedians).toMatchObject({ [id]: medianTimeToParsedSeconds(value) });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterStagesByHiddenStatus', () => {
|
||||
const hiddenStages = [{ title: 'three', hidden: true }];
|
||||
const visibleStages = [
|
||||
{ title: 'one', hidden: false },
|
||||
{ title: 'two', hidden: false },
|
||||
];
|
||||
const mockStages = [...visibleStages, ...hiddenStages];
|
||||
|
||||
it.each`
|
||||
isHidden | result
|
||||
${false} | ${visibleStages}
|
||||
${undefined} | ${hiddenStages}
|
||||
${true} | ${hiddenStages}
|
||||
`('with isHidden=$isHidden returns matching stages', ({ isHidden, result }) => {
|
||||
expect(filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -59,12 +59,17 @@ const createTestService = () => ({
|
|||
});
|
||||
|
||||
let wrapper;
|
||||
const createComponent = (customConfig = {}) => {
|
||||
const createComponent = (customConfig = {}, mergeRequestWidgetGraphql = false) => {
|
||||
wrapper = shallowMount(ReadyToMerge, {
|
||||
propsData: {
|
||||
mr: createTestMr(customConfig),
|
||||
service: createTestService(),
|
||||
},
|
||||
provide: {
|
||||
glFeatures: {
|
||||
mergeRequestWidgetGraphql,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -673,6 +678,34 @@ describe('ReadyToMerge', () => {
|
|||
expect(findCommitEditElements().length).toBe(2);
|
||||
});
|
||||
|
||||
it('should have two edit components when squash is enabled and there is more than 1 commit and mergeRequestWidgetGraphql is enabled', async () => {
|
||||
createComponent(
|
||||
{
|
||||
mr: {
|
||||
commitsCount: 2,
|
||||
squashIsSelected: true,
|
||||
enableSquashBeforeMerge: true,
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
wrapper.setData({
|
||||
loading: false,
|
||||
state: {
|
||||
...createTestMr({}),
|
||||
userPermissions: {},
|
||||
squash: true,
|
||||
mergeable: true,
|
||||
commitCount: 2,
|
||||
commitsWithoutMergeCommits: {},
|
||||
},
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(findCommitEditElements().length).toBe(2);
|
||||
});
|
||||
|
||||
it('should have one edit components when squash is enabled and there is 1 commit only', () => {
|
||||
createComponent({
|
||||
mr: {
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'fast_spec_helper'
|
||||
|
||||
MAIL_ROOM_CONFIG_ENABLED_SAMPLE =
|
||||
":mailboxes:\n"\
|
||||
" \n"\
|
||||
" -\n"\
|
||||
" :host: \"gitlab.example.com\"\n"\
|
||||
" :port: 143\n"\
|
||||
""
|
||||
|
||||
RSpec.describe SystemCheck::IncomingEmail::ImapAuthenticationCheck do
|
||||
subject(:system_check) { described_class.new }
|
||||
|
||||
describe '#load_config' do
|
||||
subject { system_check.send(:load_config) }
|
||||
|
||||
context 'returns no mailbox configurations with mailroom default configuration' do
|
||||
it { is_expected.to be_nil }
|
||||
end
|
||||
|
||||
context 'returns an array of mailbox configurations with mailroom configured' do
|
||||
before do
|
||||
allow(File).to receive(:read).and_return(MAIL_ROOM_CONFIG_ENABLED_SAMPLE)
|
||||
end
|
||||
|
||||
it { is_expected.to eq([{ host: "gitlab.example.com", port: 143 }]) }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -33,6 +33,6 @@ RSpec.describe CycleAnalytics::ProjectLevelStageAdapter, type: :model do
|
|||
end
|
||||
|
||||
it 'presents the data as json' do
|
||||
expect(subject.as_json).to include({ title: 'Review', value: 'about 1 hour' })
|
||||
expect(subject.as_json).to include({ title: 'Review', value: 1.hour })
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,5 +3,5 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Integrations::Mattermost do
|
||||
it_behaves_like "slack or mattermost notifications", "Mattermost"
|
||||
it_behaves_like Integrations::SlackMattermostNotifier, "Mattermost"
|
||||
end
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Integrations::Slack do
|
||||
it_behaves_like "slack or mattermost notifications", 'Slack'
|
||||
it_behaves_like Integrations::SlackMattermostNotifier, "Slack"
|
||||
|
||||
describe '#execute' do
|
||||
before do
|
||||
|
|
|
@ -5,6 +5,8 @@ require 'spec_helper'
|
|||
RSpec.describe Issue do
|
||||
include ExternalAuthorizationServiceHelpers
|
||||
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:reusable_project) { create(:project) }
|
||||
|
||||
|
@ -1333,6 +1335,26 @@ RSpec.describe Issue do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#supports_time_tracking?' do
|
||||
let_it_be(:project) { create(:project) }
|
||||
let_it_be_with_refind(:issue) { create(:incident, project: project) }
|
||||
|
||||
where(:issue_type, :supports_time_tracking) do
|
||||
:issue | true
|
||||
:incident | true
|
||||
end
|
||||
|
||||
with_them do
|
||||
before do
|
||||
issue.update!(issue_type: issue_type)
|
||||
end
|
||||
|
||||
it do
|
||||
expect(issue.supports_time_tracking?).to eq(supports_time_tracking)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#email_participants_emails' do
|
||||
let_it_be(:issue) { create(:issue) }
|
||||
|
||||
|
|
|
@ -130,6 +130,17 @@ RSpec.describe Notes::QuickActionsService do
|
|||
end
|
||||
end
|
||||
|
||||
describe '/estimate' do
|
||||
let(:note_text) { '/estimate 1h' }
|
||||
|
||||
it 'adds time estimate to noteable' do
|
||||
content = execute(note)
|
||||
|
||||
expect(content).to be_empty
|
||||
expect(note.noteable.time_estimate).to eq(3600)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'note with command & text' do
|
||||
describe '/close, /label, /assign & /milestone' do
|
||||
let(:note_text) do
|
||||
|
@ -301,6 +312,11 @@ RSpec.describe Notes::QuickActionsService do
|
|||
let(:note) { build(:note_on_issue, project: project, noteable: issue) }
|
||||
end
|
||||
|
||||
it_behaves_like 'note on noteable that supports quick actions' do
|
||||
let_it_be(:incident, reload: true) { create(:incident, project: project) }
|
||||
let(:note) { build(:note_on_issue, project: project, noteable: incident) }
|
||||
end
|
||||
|
||||
it_behaves_like 'note on noteable that supports quick actions' do
|
||||
let(:merge_request) { create(:merge_request, source_project: project) }
|
||||
let(:note) { build(:note_on_merge_request, project: project, noteable: merge_request) }
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
|
||||
RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name|
|
||||
include StubRequests
|
||||
|
||||
let(:chat_service) { described_class.new }
|
||||
let(:webhook_url) { 'https://example.gitlab.com' }
|
||||
|
||||
def execute_with_options(options)
|
||||
receive(:new).with(webhook_url, options.merge(http_client: SlackMattermost::Notifier::HTTPClient))
|
||||
receive(:new).with(webhook_url, options.merge(http_client: Integrations::SlackMattermostNotifier::HTTPClient))
|
||||
.and_return(double(:slack_service).as_null_object)
|
||||
end
|
||||
|
||||
|
@ -128,6 +128,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
|
|||
|
||||
context 'issue events' do
|
||||
let_it_be(:issue) { create(:issue) }
|
||||
|
||||
let(:data) { issue.to_hook_data(user) }
|
||||
|
||||
it_behaves_like 'calls the service API with the event message', /Issue (.*?) opened by/
|
||||
|
@ -167,6 +168,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
|
|||
|
||||
context 'merge request events' do
|
||||
let_it_be(:merge_request) { create(:merge_request) }
|
||||
|
||||
let(:data) { merge_request.to_hook_data(user) }
|
||||
|
||||
it_behaves_like 'calls the service API with the event message', /opened merge request/
|
||||
|
@ -184,9 +186,10 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
|
|||
|
||||
context 'wiki page events' do
|
||||
let_it_be(:wiki_page) { create(:wiki_page, wiki: project.wiki, message: 'user created page: Awesome wiki_page') }
|
||||
|
||||
let(:data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') }
|
||||
|
||||
it_behaves_like 'calls the service API with the event message', / created (.*?)wikis\/(.*?)|wiki page> in/
|
||||
it_behaves_like 'calls the service API with the event message', %r{ created (.*?)wikis/(.*?)|wiki page> in}
|
||||
|
||||
context 'with event channel' do
|
||||
let(:chat_service_params) { { wiki_page_channel: 'random' } }
|
||||
|
@ -201,6 +204,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
|
|||
|
||||
context 'deployment events' do
|
||||
let_it_be(:deployment) { create(:deployment) }
|
||||
|
||||
let(:data) { Gitlab::DataBuilder::Deployment.build(deployment, Time.current) }
|
||||
|
||||
it_behaves_like 'calls the service API with the event message', /Deploy to (.*?) created/
|
||||
|
@ -208,6 +212,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
|
|||
|
||||
context 'note event' do
|
||||
let_it_be(:issue_note) { create(:note_on_issue, project: project, note: "issue note") }
|
||||
|
||||
let(:data) { Gitlab::DataBuilder::Note.build(issue_note, user) }
|
||||
|
||||
it_behaves_like 'calls the service API with the event message', /commented on issue/
|
|
@ -4523,10 +4523,10 @@ domhandler@^4.0.0, domhandler@^4.2.0:
|
|||
dependencies:
|
||||
domelementtype "^2.2.0"
|
||||
|
||||
dompurify@^2.2.8:
|
||||
version "2.2.8"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.2.8.tgz#ce88e395f6d00b6dc53f80d6b2a6fdf5446873c6"
|
||||
integrity sha512-9H0UL59EkDLgY3dUFjLV6IEUaHm5qp3mxSqWw7Yyx4Zhk2Jn2cmLe+CNPP3xy13zl8Bqg+0NehQzkdMoVhGRww==
|
||||
dompurify@^2.2.8, dompurify@^2.2.9:
|
||||
version "2.2.9"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.2.9.tgz#4b42e244238032d9286a0d2c87b51313581d9624"
|
||||
integrity sha512-+9MqacuigMIZ+1+EwoEltogyWGFTJZWU3258Rupxs+2CGs4H914G9er6pZbsme/bvb5L67o2rade9n21e4RW/w==
|
||||
|
||||
domutils@^1.5.1:
|
||||
version "1.7.0"
|
||||
|
|
Loading…
Reference in a new issue