Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-08-16 12:09:17 +00:00
parent 78e911431f
commit 09dff3eec7
74 changed files with 1676 additions and 511 deletions

View file

@ -2,10 +2,17 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { buildApiUrl } from './api_utils';
const PROJECT_VSA_METRICS_BASE = '/:request_path/-/analytics/value_stream_analytics';
const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics/value_streams';
const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`;
const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`;
export const METRIC_TYPE_SUMMARY = 'summary';
export const METRIC_TYPE_TIME_SUMMARY = 'time_summary';
const buildProjectMetricsPath = (requestPath) =>
buildApiUrl(PROJECT_VSA_METRICS_BASE).replace(':request_path', requestPath);
const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => {
if (valueStreamId) {
return buildApiUrl(PROJECT_VSA_STAGES_PATH)
@ -40,9 +47,7 @@ export const getProjectValueStreamMetrics = (requestPath, params) =>
axios.get(requestPath, { params });
/**
* Shared group VSA paths
* We share some endpoints across and group and project level VSA
* When used for project level VSA, requests should include the `project_id` in the params object
* Dedicated project VSA paths
*/
export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => {
@ -62,3 +67,17 @@ export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId
const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'count'), { params });
};
export const getValueStreamMetrics = ({
endpoint = METRIC_TYPE_SUMMARY,
requestPath,
params = {},
}) => {
const metricBase = buildProjectMetricsPath(requestPath);
return axios.get(joinPaths(metricBase, endpoint), { params });
};
export const getValueStreamSummaryMetrics = (requestPath, params = {}) => {
const metricBase = buildProjectMetricsPath(requestPath);
return axios.get(joinPaths(metricBase, 'summary'), { params });
};

View file

@ -1,6 +1,7 @@
import $ from 'jquery';
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import SourceEditor from '~/editor/source_editor';
import { getBlobLanguage } from '~/editor/utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
@ -16,16 +17,7 @@ export default class EditBlob {
this.configureMonacoEditor();
if (this.options.isMarkdown) {
import('~/editor/extensions/source_editor_markdown_ext')
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
this.editor.use(new MarkdownExtension());
addEditorMarkdownListeners(this.editor);
})
.catch((e) =>
createFlash({
message: `${BLOB_EDITOR_ERROR}: ${e}`,
}),
);
this.fetchMarkdownExtension();
}
this.initModePanesAndLinks();
@ -34,12 +26,30 @@ export default class EditBlob {
this.editor.focus();
}
fetchMarkdownExtension() {
import('~/editor/extensions/source_editor_markdown_ext')
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
this.editor.use(
new MarkdownExtension({ instance: this.editor, projectPath: this.options.projectPath }),
);
this.hasMarkdownExtension = true;
addEditorMarkdownListeners(this.editor);
})
.catch((e) =>
createFlash({
message: `${BLOB_EDITOR_ERROR}: ${e}`,
}),
);
}
configureMonacoEditor() {
const editorEl = document.getElementById('editor');
const fileNameEl = document.getElementById('file_path') || document.getElementById('file_name');
const fileContentEl = document.getElementById('file-content');
const form = document.querySelector('.js-edit-blob-form');
this.hasMarkdownExtension = false;
const rootEditor = new SourceEditor();
this.editor = rootEditor.createInstance({
@ -51,6 +61,12 @@ export default class EditBlob {
fileNameEl.addEventListener('change', () => {
this.editor.updateModelLanguage(fileNameEl.value);
const newLang = getBlobLanguage(fileNameEl.value);
if (newLang === 'markdown') {
if (!this.hasMarkdownExtension) {
this.fetchMarkdownExtension();
}
}
});
form.addEventListener('submit', () => {

View file

@ -4,7 +4,9 @@ import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { __ } from '~/locale';
import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
@ -16,6 +18,7 @@ export default {
GlSprintf,
PathNavigation,
StageTable,
ValueStreamMetrics,
},
props: {
noDataSvgPath: {
@ -45,8 +48,10 @@ export default {
'daysInPast',
'permissions',
'stageCounts',
'endpoints',
'features',
]),
...mapGetters(['pathNavigationData']),
...mapGetters(['pathNavigationData', 'filterParams']),
displayStageEvents() {
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
@ -88,6 +93,9 @@ export default {
}
return 0;
},
metricsRequests() {
return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
},
},
methods: {
...mapActions([
@ -122,62 +130,54 @@ export default {
<template>
<div class="cycle-analytics">
<h3>{{ $options.i18n.pageTitle }}</h3>
<path-navigation
v-if="displayPathNavigation"
class="js-path-navigation gl-w-full gl-pb-2"
:loading="isLoading || isLoadingStage"
:stages="pathNavigationData"
:selected-stage="selectedStage"
@selected="onSelectStage"
/>
<gl-loading-icon v-if="isLoading" size="lg" />
<div v-else class="wrapper">
<!--
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">
<h3 class="header">{{ item.value }}</h3>
<p class="text">{{ item.title }}</p>
</div>
<div class="flex-grow align-self-center text-center">
<div class="js-ca-dropdown dropdown inline">
<!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
<span class="dropdown-label">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ daysInPast }}</template>
</gl-sprintf>
<gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
</span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
<a href="#" @click.prevent="handleDateSelect(days)">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ days }}</template>
</gl-sprintf>
</a>
</li>
</ul>
</div>
</div>
<div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row">
<path-navigation
v-if="displayPathNavigation"
class="js-path-navigation gl-w-full gl-pb-2"
:loading="isLoading || isLoadingStage"
:stages="pathNavigationData"
:selected-stage="selectedStage"
@selected="onSelectStage"
/>
<div class="gl-flex-grow gl-align-self-end">
<div class="js-ca-dropdown dropdown inline">
<!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
<span class="dropdown-label">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ daysInPast }}</template>
</gl-sprintf>
<gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
</span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
<a href="#" @click.prevent="handleDateSelect(days)">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ days }}</template>
</gl-sprintf>
</a>
</li>
</ul>
</div>
</div>
<stage-table
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
:selected-stage="selectedStage"
:stage-count="selectedStageCount"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
/>
</div>
<value-stream-metrics
:request-path="endpoints.fullPath"
:request-params="filterParams"
:requests="metricsRequests"
/>
<gl-loading-icon v-if="isLoading" size="lg" />
<stage-table
v-else
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
:selected-stage="selectedStage"
:stage-count="selectedStageCount"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
/>
</div>
</template>

View file

@ -0,0 +1,107 @@
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { flatten } from 'lodash';
import createFlash from '~/flash';
import { sprintf, s__ } from '~/locale';
import { METRICS_POPOVER_CONTENT } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils';
const requestData = ({ request, endpoint, path, params, name }) => {
return request({ endpoint, params, requestPath: path })
.then(({ data }) => data)
.catch(() => {
const message = sprintf(
s__(
'ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data.',
),
{ requestTypeName: name },
);
createFlash({ message });
});
};
const fetchMetricsData = (reqs = [], path, params) => {
const promises = reqs.map((r) => requestData({ ...r, path, params }));
return Promise.all(promises).then((responses) =>
prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
);
};
export default {
name: 'ValueStreamMetrics',
components: {
GlPopover,
GlSingleStat,
GlSkeletonLoading,
},
props: {
requestPath: {
type: String,
required: true,
},
requestParams: {
type: Object,
required: true,
},
requests: {
type: Array,
required: true,
},
},
data() {
return {
metrics: [],
isLoading: false,
};
},
watch: {
requestParams() {
this.fetchData();
},
},
mounted() {
this.fetchData();
},
methods: {
fetchData() {
removeFlash();
this.isLoading = true;
return fetchMetricsData(this.requests, this.requestPath, this.requestParams)
.then((data) => {
this.metrics = data;
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
});
},
},
};
</script>
<template>
<div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics">
<div v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6">
<gl-skeleton-loading />
</div>
<template v-else>
<div v-for="metric in metrics" :key="metric.key" class="gl-my-6 gl-pr-9">
<gl-single-stat
:id="metric.key"
:value="`${metric.value}`"
:title="metric.label"
:unit="metric.unit || ''"
:should-animate="true"
:animation-decimal-places="1"
tabindex="0"
/>
<gl-popover :target="metric.key" placement="bottom">
<template #title>
<span class="gl-display-block gl-text-left">{{ metric.label }}</span>
</template>
<span v-if="metric.description">{{ metric.description }}</span>
</gl-popover>
</div>
</template>
</div>
</template>

View file

@ -1,3 +1,8 @@
import {
getValueStreamMetrics,
METRIC_TYPE_SUMMARY,
METRIC_TYPE_TIME_SUMMARY,
} from '~/api/analytics_api';
import { __, s__ } from '~/locale';
export const DEFAULT_DAYS_IN_PAST = 30;
@ -30,3 +35,37 @@ export const I18N_VSA_ERROR_STAGE_MEDIAN = __('There was an error fetching media
export const I18N_VSA_ERROR_SELECTED_STAGE = __(
'There was an error fetching data for the selected stage',
);
export const OVERVIEW_METRICS = {
TIME_SUMMARY: 'TIME_SUMMARY',
RECENT_ACTIVITY: 'RECENT_ACTIVITY',
};
export const METRICS_POPOVER_CONTENT = {
'lead-time': {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
},
'cycle-time': {
description: s__(
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
),
},
'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
'deployment-frequency': {
description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
},
commits: {
description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
},
};
export const SUMMARY_METRICS_REQUEST = [
{ endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics },
];
export const METRICS_REQUESTS = [
{ endpoint: METRIC_TYPE_TIME_SUMMARY, name: __('time summary'), request: getValueStreamMetrics },
...SUMMARY_METRICS_REQUEST,
];

View file

@ -24,6 +24,9 @@ export default () => {
requestPath,
fullPath,
},
features: {
cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
},
});
// eslint-disable-next-line no-new

View file

@ -1,14 +1,15 @@
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
import { decorateData, formatMedianValues, calculateFormattedDayInPast } from '../utils';
import { formatMedianValues, calculateFormattedDayInPast } from '../utils';
import * as types from './mutation_types';
export default {
[types.INITIALIZE_VSA](state, { endpoints }) {
[types.INITIALIZE_VSA](state, { endpoints, features }) {
state.endpoints = endpoints;
const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
state.createdBefore = now;
state.createdAfter = past;
state.features = features;
},
[types.SET_LOADING](state, loadingState) {
state.isLoading = loadingState;
@ -48,9 +49,7 @@ export default {
state.hasError = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
const { summary } = decorateData(data);
state.permissions = data?.permissions || {};
state.summary = summary;
state.hasError = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {

View file

@ -2,6 +2,7 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
export default () => ({
id: null,
features: {},
endpoints: {},
daysInPast: DEFAULT_DAYS_TO_DISPLAY,
createdAfter: null,

View file

@ -1,19 +1,19 @@
import dateFormat from 'dateformat';
import { unescape } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants';
import { hideFlash } from '~/flash';
import { sanitize } from '~/lib/dompurify';
import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { slugify } from '~/lib/utils/text_utility';
import { s__, sprintf } from '../locale';
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
export const decorateData = (data = {}) => {
const { summary } = data;
return {
summary: summary?.map((item) => mapToSummary(item)) || [],
};
export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
if (flashEl) {
hideFlash(flashEl);
}
};
/**
@ -116,3 +116,36 @@ export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => {
past: toIsoFormat(getDateInPast(today, daysInPast)),
};
};
/**
* @typedef {Object} MetricData
* @property {String} title - Title of the metric measured
* @property {String} value - String representing the decimal point value, e.g '1.5'
* @property {String} [unit] - String representing the decimal point value, e.g '1.5'
*
* @typedef {Object} TransformedMetricData
* @property {String} label - Title of the metric measured
* @property {String} value - String representing the decimal point value, e.g '1.5'
* @property {String} key - Slugified string based on the 'title'
* @property {String} description - String to display for a description
* @property {String} unit - String representing the decimal point value, e.g '1.5'
*/
/**
* Prepares metric data to be rendered in the metric_card component
*
* @param {MetricData[]} data - The metric data to be rendered
* @param {Object} popoverContent - Key value pair of data to display in the popover
* @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card
*/
export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
data.map(({ title: label, ...rest }) => {
const key = slugify(label);
return {
...rest,
label,
key,
description: popoverContent[key]?.description || '',
};
});

View file

@ -4,13 +4,16 @@ import { ApolloMutation } from 'vue-apollo';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
import allVersionsMixin from '../../mixins/all_versions';
import { hasErrors } from '../../utils/cache_update';
import { extractDesign } from '../../utils/design_management_utils';
import { ADD_DISCUSSION_COMMENT_ERROR } from '../../utils/error_messages';
import DesignNote from './design_note.vue';
import DesignReplyForm from './design_reply_form.vue';
@ -161,6 +164,19 @@ export default {
},
toggleResolvedStatus() {
this.isResolving = true;
/**
* Get previous todo count
*/
const { defaultClient: client } = this.$apollo.provider.clients;
const sourceData = client.readQuery({
query: getDesignQuery,
variables: this.designVariables,
});
const design = extractDesign(sourceData);
const prevTodoCount = design.currentUserTodos?.nodes?.length || 0;
this.$apollo
.mutate({
mutation: toggleResolveDiscussionMutation,
@ -170,6 +186,10 @@ export default {
if (data.errors?.length > 0) {
this.$emit('resolve-discussion-error', data.errors[0]);
}
const newTodoCount =
data?.discussionToggleResolve?.discussion?.noteable?.currentUserTodos?.nodes?.length ||
0;
updateGlobalTodoCount(newTodoCount - prevTodoCount);
})
.catch((err) => {
this.$emit('resolve-discussion-error', err);

View file

@ -1,10 +1,11 @@
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import { defaultDataIdFromObject, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import produce from 'immer';
import { uniqueId } from 'lodash';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import introspectionQueryResultData from './graphql/fragmentTypes.json';
import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
import getDesignQuery from './graphql/queries/get_design.query.graphql';
import typeDefs from './graphql/typedefs.graphql';
@ -12,6 +13,10 @@ import { addPendingTodoToStore } from './utils/cache_update';
import { extractTodoIdFromDeletePath, createPendingTodo } from './utils/design_management_utils';
import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages';
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
});
Vue.use(VueApollo);
const resolvers = {
@ -80,6 +85,7 @@ const defaultClient = createDefaultClient(
}
return defaultDataIdFromObject(object);
},
fragmentMatcher,
},
typeDefs,
assumeImmutableResults: true,

View file

@ -0,0 +1 @@
{"__schema":{"types":[{"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]},{"kind":"UNION","name":"NoteableType","possibleTypes":[{"name":"Design"},{"name":"Issue"},{"name":"MergeRequest"}]}]}}

View file

@ -0,0 +1,11 @@
fragment DesignTodoItem on Design {
id
image
__typename
currentUserTodos(state: pending) {
nodes {
id
__typename
}
}
}

View file

@ -1,4 +1,5 @@
#import "../fragments/design_note.fragment.graphql"
#import "../fragments/design_todo_item.fragment.graphql"
mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
createImageDiffNote(input: $input) {
@ -7,6 +8,11 @@ mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
discussion {
id
replyId
noteable {
... on Design {
...DesignTodoItem
}
}
notes {
nodes {
...DesignNote

View file

@ -1,11 +1,17 @@
#import "../fragments/design_note.fragment.graphql"
#import "../fragments/discussion_resolved_status.fragment.graphql"
#import "../fragments/design_todo_item.fragment.graphql"
mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) {
discussionToggleResolve(input: { id: $id, resolve: $resolve }) {
discussion {
id
...ResolvedStatus
noteable {
... on Design {
...DesignTodoItem
}
}
notes {
nodes {
...DesignNote

View file

@ -1,10 +1,12 @@
<script>
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { isNull } from 'lodash';
import Mousetrap from 'mousetrap';
import { ApolloMutation } from 'vue-apollo';
import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DesignDestroyer from '../../components/design_destroyer.vue';
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
@ -93,6 +95,7 @@ export default {
errorMessage: '',
scale: DEFAULT_SCALE,
resolvedDiscussionsExpanded: false,
prevCurrentUserTodos: null,
};
},
apollo: {
@ -163,6 +166,13 @@ export default {
resolvedDiscussions() {
return this.discussions.filter((discussion) => discussion.resolved);
},
currentUserTodos() {
if (!this.design || !this.design.currentUserTodos) {
return null;
}
return this.design.currentUserTodos?.nodes?.length;
},
},
watch: {
resolvedDiscussions(val) {
@ -170,6 +180,9 @@ export default {
this.resolvedDiscussionsExpanded = false;
}
},
currentUserTodos(_, prevCurrentUserTodos) {
this.prevCurrentUserTodos = prevCurrentUserTodos;
},
},
mounted() {
Mousetrap.bind(keysFor(ISSUE_CLOSE_DESIGN), this.closeDesign);
@ -272,9 +285,14 @@ export default {
this.$refs.newDiscussionForm.focusInput();
}
},
closeCommentForm() {
closeCommentForm(data) {
this.comment = '';
this.annotationCoordinates = null;
if (data?.data && !isNull(this.prevCurrentUserTodos)) {
updateGlobalTodoCount(this.currentUserTodos - this.prevCurrentUserTodos);
this.prevCurrentUserTodos = this.currentUserTodos;
}
},
closeDesign() {
this.$router.push({

View file

@ -28,3 +28,8 @@ export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
// '*.gitlab-ci.yml' regardless of project configuration.
// https://gitlab.com/gitlab-org/gitlab/-/issues/293641
export const EXTENSION_CI_SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml';
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS = 'md';
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS = 'source-editor-preview';
export const EXTENSION_MARKDOWN_PREVIEW_ACTION_ID = 'markdown-preview';
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width

View file

@ -1,6 +1,149 @@
import { debounce } from 'lodash';
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
import createFlash from '~/flash';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import syntaxHighlight from '~/syntax_highlight';
import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
} from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base';
const getPreview = (text, projectPath = '') => {
let url;
if (projectPath) {
url = `/${projectPath}/preview_markdown`;
} else {
const { group, project } = document.body.dataset;
url = `/${group}/${project}/preview_markdown`;
}
return axios
.post(url, {
text,
})
.then(({ data }) => {
return data.body;
});
};
const setupDomElement = ({ injectToEl = null } = {}) => {
const previewEl = document.createElement('div');
previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS);
previewEl.style.display = 'none';
if (injectToEl) {
injectToEl.appendChild(previewEl);
}
return previewEl;
};
export class EditorMarkdownExtension extends SourceEditorExtension {
constructor({ instance, projectPath, ...args } = {}) {
super({ instance, ...args });
Object.assign(instance, {
projectPath,
preview: {
el: undefined,
action: undefined,
shown: false,
},
});
this.setupPreviewAction.call(instance);
}
static togglePreviewLayout() {
const { width, height } = this.getLayoutInfo();
const newWidth = this.preview.shown
? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
this.layout({ width: newWidth, height });
}
static togglePreviewPanel() {
const parentEl = this.getDomNode().parentElement;
const { el: previewEl } = this.preview;
parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
if (previewEl.style.display === 'none') {
// Show the preview panel
this.fetchPreview();
} else {
// Hide the preview panel
previewEl.style.display = 'none';
}
}
cleanup() {
this.preview.action.dispose();
if (this.preview.shown) {
EditorMarkdownExtension.togglePreviewPanel.call(this);
EditorMarkdownExtension.togglePreviewLayout.call(this);
}
this.preview.shown = false;
}
fetchPreview() {
const { el: previewEl } = this.preview;
getPreview(this.getValue(), this.projectPath)
.then((data) => {
previewEl.innerHTML = sanitize(data);
syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
previewEl.style.display = 'block';
})
.catch(() => createFlash(BLOB_PREVIEW_ERROR));
}
setupPreviewAction() {
if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
this.preview.action = this.addAction({
id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
label: __('Preview Markdown'),
keybindings: [
// eslint-disable-next-line no-bitwise,no-undef
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.5,
// Method that will be executed when the action is triggered.
// @param ed The editor instance is passed in as a convenience
run(instance) {
instance.togglePreview();
},
});
}
togglePreview() {
if (!this.preview?.el) {
this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement });
}
EditorMarkdownExtension.togglePreviewLayout.call(this);
EditorMarkdownExtension.togglePreviewPanel.call(this);
if (!this.preview?.shown) {
this.modelChangeListener = this.onDidChangeModelContent(
debounce(this.fetchPreview.bind(this), 250),
);
} else {
this.modelChangeListener.dispose();
}
this.preview.shown = !this.preview?.shown;
this.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
this.setupPreviewAction();
} else {
this.cleanup();
}
});
}
getSelectedText(selection = this.getSelection()) {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const valArray = this.getValue().split('\n');

View file

@ -1,5 +1,5 @@
<script>
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { capitalize } from 'lodash';
import { __ } from '~/locale';
import { IssuableTypes } from '../../constants';
@ -15,6 +15,7 @@ export default {
IssuableTypes,
components: {
GlFormGroup,
GlIcon,
GlDropdown,
GlDropdownItem,
},
@ -72,6 +73,7 @@ export default {
is-check-item
@click="updateIssueType(type.value)"
>
<gl-icon :name="type.icon" />
{{ type.text }}
</gl-dropdown-item>
</gl-dropdown>

View file

@ -28,8 +28,8 @@ export const STATUS_PAGE_PUBLISHED = __('Published on status page');
export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
export const IssuableTypes = [
{ value: 'issue', text: __('Issue') },
{ value: 'incident', text: __('Incident') },
{ value: 'issue', text: __('Issue'), icon: 'issue-type-issue' },
{ value: 'incident', text: __('Incident'), icon: 'issue-type-incident' },
];
export const IssueTypePath = 'issues';

View file

@ -174,7 +174,7 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi
parsedLines[currentHeader.index].line.section_duration = line.section_duration;
isPreviousLineHeader = false;
currentHeader = null;
} else {
} else if (currentHeader?.isHeader) {
currentHeader.line.section_duration = line.section_duration;
if (previousSection && previousSection?.index) {
@ -185,6 +185,11 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi
}
currentHeader = previousSection;
} else {
// On older job logs, there's no `section_header: true` response, it's just an object
// with the `section_duration` and `section` props, so we just parse it
// as a standard line
parsedLines.push(parseLine(line, currentLineCount));
}
} else {
parsedLines.push(parseLine(line, currentLineCount));

View file

@ -1,7 +1,7 @@
<script>
import { GlBanner } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { parseBoolean, setCookie, getCookie } from '~/lib/utils/common_utils';
import { setCookie } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
export default {
@ -16,50 +16,36 @@ export default {
components: {
GlBanner,
},
props: {
projectId: {
type: Number,
required: true,
},
},
inject: ['terraformImagePath', 'bannerDismissedKey'],
data() {
return {
isVisible: true,
};
},
computed: {
bannerDissmisedKey() {
return `terraform_notification_dismissed_for_project_${this.projectId}`;
},
docsUrl() {
return helpPagePath('user/infrastructure/terraform_state');
},
},
created() {
if (parseBoolean(getCookie(this.bannerDissmisedKey))) {
this.isVisible = false;
}
},
methods: {
handleClose() {
setCookie(this.bannerDissmisedKey, true);
setCookie(this.bannerDismissedKey, true);
this.isVisible = false;
},
},
};
</script>
<template>
<div v-if="isVisible">
<div class="gl-py-5">
<gl-banner
:title="$options.i18n.title"
:button-text="$options.i18n.buttonText"
:button-link="docsUrl"
variant="introduction"
@close="handleClose"
>
<p>{{ $options.i18n.description }}</p>
</gl-banner>
</div>
<div v-if="isVisible" class="gl-py-5">
<gl-banner
:title="$options.i18n.title"
:button-text="$options.i18n.buttonText"
:button-link="docsUrl"
:svg-path="terraformImagePath"
variant="promotion"
@close="handleClose"
>
<p>{{ $options.i18n.description }}</p>
</gl-banner>
</div>
</template>

View file

@ -1,18 +1,23 @@
import Vue from 'vue';
import { parseBoolean, getCookie } from '~/lib/utils/common_utils';
import TerraformNotification from './components/terraform_notification.vue';
export default () => {
const el = document.querySelector('.js-terraform-notification');
const bannerDismissedKey = 'terraform_notification_dismissed';
if (!el) {
if (!el || parseBoolean(getCookie(bannerDismissedKey))) {
return false;
}
const { projectId } = el.dataset;
const { terraformImagePath } = el.dataset;
return new Vue({
el,
render: (createElement) =>
createElement(TerraformNotification, { props: { projectId: Number(projectId) } }),
provide: {
terraformImagePath,
bannerDismissedKey,
},
render: (createElement) => createElement(TerraformNotification),
});
};

View file

@ -1,6 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
import { todoLabel } from './utils';
import { todoLabel, updateGlobalTodoCount } from './utils';
export default {
components: {
@ -19,23 +19,11 @@ export default {
},
},
methods: {
updateGlobalTodoCount(additionalTodoCount) {
const countContainer = document.querySelector('.js-todos-count');
if (countContainer === null) return;
const currentCount = parseInt(countContainer.innerText, 10);
const todoToggleEvent = new CustomEvent('todo:toggle', {
detail: {
count: Math.max(currentCount + additionalTodoCount, 0),
},
});
document.dispatchEvent(todoToggleEvent);
},
incrementGlobalTodoCount() {
this.updateGlobalTodoCount(1);
updateGlobalTodoCount(1);
},
decrementGlobalTodoCount() {
this.updateGlobalTodoCount(-1);
updateGlobalTodoCount(-1);
},
onToggle(event) {
if (this.isTodo) {

View file

@ -3,3 +3,19 @@ import { __ } from '~/locale';
export const todoLabel = (hasTodo) => {
return hasTodo ? __('Mark as done') : __('Add a to do');
};
export const updateGlobalTodoCount = (additionalTodoCount) => {
const countContainer = document.querySelector('.js-todos-count');
if (countContainer === null) return;
const currentCount = parseInt(countContainer.innerText, 10);
const todoToggleEvent = new CustomEvent('todo:toggle', {
detail: {
count: Math.max(currentCount + additionalTodoCount, 0),
},
});
document.dispatchEvent(todoToggleEvent);
};

View file

@ -25,6 +25,17 @@
height: 500px;
}
.source-editor-preview {
@include gl-display-flex;
.md {
@include gl-overflow-scroll;
@include gl-px-6;
@include gl-py-4;
@include gl-w-full;
}
}
.monaco-editor.gl-source-editor {
.margin-view-overlays {
.line-numbers {

View file

@ -48,6 +48,14 @@ module IssuesHelper
end
end
def work_item_type_icon(issue_type)
if WorkItem::Type.base_types.include?(issue_type)
"issue-type-#{issue_type.to_s.dasherize}"
else
'issue-type-issue'
end
end
def confidential_icon(issue)
sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential?
end

View file

@ -128,8 +128,7 @@ module CounterAttribute
end
def counter_attribute_enabled?(attribute)
Feature.enabled?(:efficient_counter_attribute, project) &&
self.class.counter_attributes.include?(attribute)
self.class.counter_attributes.include?(attribute)
end
private

View file

@ -14,6 +14,7 @@ class InstanceConfiguration
host: host,
gitlab_pages: gitlab_pages,
gitlab_ci: gitlab_ci,
package_file_size_limits: package_file_size_limits,
rate_limits: rate_limits }.deep_symbolize_keys
end
end
@ -44,6 +45,22 @@ class InstanceConfiguration
default: 100.megabytes })
end
def package_file_size_limits
Plan.all.to_h { |plan| [plan.name.capitalize, plan_file_size_limits(plan)] }
end
def plan_file_size_limits(plan)
{
conan: plan.actual_limits[:conan_max_file_size],
maven: plan.actual_limits[:maven_max_file_size],
npm: plan.actual_limits[:npm_max_file_size],
nuget: plan.actual_limits[:nuget_max_file_size],
pypi: plan.actual_limits[:pypi_max_file_size],
terraform_module: plan.actual_limits[:terraform_module_max_file_size],
generic: plan.actual_limits[:generic_packages_max_file_size]
}
end
def rate_limits
{
unauthenticated: {

View file

@ -49,10 +49,7 @@ class ProjectFeature < ApplicationRecord
end
end
# Default scopes force us to unscope here since a service may need to check
# permissions for a project in pending_delete
# http://stackoverflow.com/questions/1540645/how-to-disable-default-scope-for-a-belongs-to
belongs_to :project, -> { unscope(where: :pending_delete) }
belongs_to :project
validates :project, presence: true

View file

@ -294,8 +294,6 @@ module Projects
end
def pages_file_entries_limit
return 0 unless Feature.enabled?(:pages_limit_entries_count, project, default_enabled: :yaml)
project.actual_limits.pages_file_entries
end
end

View file

@ -8,6 +8,7 @@
= render 'help/instance_configuration/ssh_info'
= render 'help/instance_configuration/gitlab_pages'
= render 'help/instance_configuration/gitlab_ci'
= render 'help/instance_configuration/package_registry'
= render 'help/instance_configuration/rate_limits'
%p
%strong= _("Table of contents")

View file

@ -0,0 +1,48 @@
- package_file_size_limits = @instance_configuration.settings[:package_file_size_limits]
- content_for :table_content do
- if package_file_size_limits.present?
%li= link_to _('Package Registry'), '#package-registry'
- content_for :settings_content do
- if package_file_size_limits.present?
%h2#package-registry
= _('Package Registry')
%p
= _('There are several file size limits in place for the Package Registry.')
.table-responsive
%table
%thead
%tr
%th= _('Package type')
- package_file_size_limits.each_key do |title|
%th= title
%tbody
%tr
%td= 'Conan'
- package_file_size_limits.each_value do |limits|
%td= instance_configuration_human_size_cell(limits[:conan])
%tr
%td= 'Maven'
- package_file_size_limits.each_value do |limits|
%td= instance_configuration_human_size_cell(limits[:maven])
%tr
%td= 'npm'
- package_file_size_limits.each_value do |limits|
%td= instance_configuration_human_size_cell(limits[:npm])
%tr
%td= 'NuGet'
- package_file_size_limits.each_value do |limits|
%td= instance_configuration_human_size_cell(limits[:nuget])
%tr
%td= 'PyPI'
- package_file_size_limits.each_value do |limits|
%td= instance_configuration_human_size_cell(limits[:pypi])
%tr
%td= 'Terraform Module'
- package_file_size_limits.each_value do |limits|
%td= instance_configuration_human_size_cell(limits[:terraform_module])
%tr
%td= _('Generic')
- package_file_size_limits.each_value do |limits|
%td= instance_configuration_human_size_cell(limits[:generic])

View file

@ -2,4 +2,4 @@
- if show_terraform_banner?(project)
.container-fluid{ class: @content_class }
.js-terraform-notification{ data: { project_id: project.id } }
.js-terraform-notification{ data: { terraform_image_path: image_path('illustrations/third-party-logos/ci_cd-template-logos/terraform.svg') } }

View file

@ -16,14 +16,14 @@
= _("Select type")
%button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') }
= sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon')
.dropdown-content
.dropdown-content{ data: { testid: 'issue-type-select-dropdown' } }
%ul
%li.js-filter-issuable-type
= link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
= _("Issue")
#{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_("Issue")}
%li.js-filter-issuable-type{ data: { track: { event: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
= link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
= _("Incident")
#{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_("Incident")}
#js-type-popover

View file

@ -1,8 +0,0 @@
---
name: efficient_counter_attribute
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35878
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238535
milestone: '13.3'
type: development
group: group::pipeline execution
default_enabled: false

View file

@ -1,8 +0,0 @@
---
name: pages_limit_entries_count
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64925/diffs
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334765
milestone: '14.1'
type: development
group: group::release
default_enabled: false

View file

@ -1,7 +1,7 @@
---
data_category: optional
key_path: redis_hll_counters.ecosystem.ecosystem_total_unique_counts_monthly
description: Number of users performing actions on Jira issues by month
description: Number of users for Jira and Slack by month
product_section: dev
product_stage: ecosystem
product_group: group::integrations

View file

@ -131,3 +131,32 @@ To workaround this issue, make sure to apply one of the following conditions:
1. The `terraform-user` creates all subgroup resources.
1. Grant Maintainer or Owner role to the `terraform-user` user on `subgroup-B`.
1. The `terraform-user` inherited access to `subgroup-B` and `subgroup-B` contains at least one project.
### Invalid CI/CD syntax error when using the "latest" base template
On GitLab 14.2 and later, you might get a CI/CD syntax error when using the
`latest` Base Terraform template:
```yaml
include:
- template: Terraform/Base.latest.gitlab-ci.yml
my-Terraform-job:
extends: .init
```
The base template's [jobs were renamed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67719/)
with better Terraform-specific names. To resolve the syntax error, you can:
- Use the stable `Terraform/Base.gitlab-ci.yml` template, which has not changed.
- Update your pipeline configuration to use the new job names in
`https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml`.
For example:
```yaml
include:
- template: Terraform/Base.latest.gitlab-ci.yml
my-Terraform-job:
extends: .terraform:init # The updated name.
```

View file

@ -4,7 +4,7 @@
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Terraform.gitlab-ci.yml
include:
- template: Terraform/Base.latest.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml
- template: Terraform/Base.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml
stages:
- init

View file

@ -14,20 +14,22 @@ stages:
- cleanup
init:
extends: .init
extends: .terraform:init
validate:
extends: .validate
extends: .terraform:validate
build:
extends: .build
extends: .terraform:build
deploy:
extends: .deploy
extends: .terraform:deploy
dependencies:
- build
environment:
name: $TF_STATE_NAME
cleanup:
extends: .destroy
extends: .terraform:destroy
dependencies:
- deploy

View file

@ -0,0 +1,64 @@
# Terraform/Base.latest
#
# The purpose of this template is to provide flexibility to the user so
# they are able to only include the jobs that they find interesting.
#
# Therefore, this template is not supposed to run any jobs. The idea is to only
# create hidden jobs. See: https://docs.gitlab.com/ee/ci/yaml/#hide-jobs
#
# There is a more opinionated template which we suggest the users to abide,
# which is the lib/gitlab/ci/templates/Terraform.latest.gitlab-ci.yml
image:
name: registry.gitlab.com/gitlab-org/terraform-images/releases/terraform:1.0.3
variables:
TF_ROOT: ${CI_PROJECT_DIR} # The relative path to the root directory of the Terraform project
TF_STATE_NAME: ${TF_STATE_NAME:-default} # The name of the state file used by the GitLab Managed Terraform state backend
cache:
key: "${TF_ROOT}"
paths:
- ${TF_ROOT}/.terraform/
- ${TF_ROOT}/.terraform.lock.hcl
.init: &init
stage: init
script:
- cd ${TF_ROOT}
- gitlab-terraform init
.validate: &validate
stage: validate
script:
- cd ${TF_ROOT}
- gitlab-terraform validate
.build: &build
stage: build
script:
- cd ${TF_ROOT}
- gitlab-terraform plan
- gitlab-terraform plan-json
artifacts:
paths:
- ${TF_ROOT}/plan.cache
reports:
terraform: ${TF_ROOT}/plan.json
.deploy: &deploy
stage: deploy
script:
- cd ${TF_ROOT}
- gitlab-terraform apply
when: manual
only:
variables:
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
.destroy: &destroy
stage: cleanup
script:
- cd ${TF_ROOT}
- gitlab-terraform destroy
when: manual

View file

@ -13,7 +13,8 @@ image:
name: registry.gitlab.com/gitlab-org/terraform-images/stable:latest
variables:
TF_ROOT: ${CI_PROJECT_DIR}
TF_ROOT: ${CI_PROJECT_DIR} # The relative path to the root directory of the Terraform project
TF_STATE_NAME: ${TF_STATE_NAME:-default} # The name of the state file used by the GitLab Managed Terraform state backend
cache:
key: "${TF_ROOT}"
@ -21,43 +22,46 @@ cache:
- ${TF_ROOT}/.terraform/
- ${TF_ROOT}/.terraform.lock.hcl
.init: &init
.terraform:init: &terraform_init
stage: init
script:
- cd ${TF_ROOT}
- gitlab-terraform init
.validate: &validate
.terraform:validate: &terraform_validate
stage: validate
script:
- cd ${TF_ROOT}
- gitlab-terraform validate
.build: &build
.terraform:build: &terraform_build
stage: build
script:
- cd ${TF_ROOT}
- gitlab-terraform plan
- gitlab-terraform plan-json
resource_group: ${TF_STATE_NAME}
artifacts:
paths:
- ${TF_ROOT}/plan.cache
reports:
terraform: ${TF_ROOT}/plan.json
.deploy: &deploy
.terraform:deploy: &terraform_deploy
stage: deploy
script:
- cd ${TF_ROOT}
- gitlab-terraform apply
resource_group: ${TF_STATE_NAME}
when: manual
only:
variables:
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
.destroy: &destroy
.terraform:destroy: &terraform_destroy
stage: cleanup
script:
- cd ${TF_ROOT}
- gitlab-terraform destroy
resource_group: ${TF_STATE_NAME}
when: manual

View file

@ -57,68 +57,5 @@ namespace :gitlab do
post.puts "<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->"
end
end
desc 'GitLab | Docs | Clean up old redirects'
task :clean_redirects do
#
# Calculate new path from the redirect URL.
#
# If the redirect is not a full URL:
# 1. Create a new Pathname of the file
# 2. Use dirname to get all but the last component of the path
# 3. Join with the redirect_to entry
# 4. Substitute:
# - '.md' => '.html'
# - 'doc/' => '/ee/'
#
# If the redirect URL is a full URL pointing to the Docs site
# (cross-linking among the 4 products), remove the FQDN prefix:
#
# From : https://docs.gitlab.com/ee/install/requirements.html
# To : /ee/install/requirements.html
#
def new_path(redirect, filename)
if !redirect.start_with?('http')
Pathname.new(filename).dirname.join(redirect).to_s.gsub(%r(\.md), '.html').gsub(%r(doc/), '/ee/')
elsif redirect.start_with?('https://docs.gitlab.com')
redirect.gsub('https://docs.gitlab.com', '')
else
redirect
end
end
today = Time.now.utc.to_date
#
# Find the files to be deleted.
# Exclude 'doc/development/documentation/index.md' because it
# contains an example of the YAML front matter.
#
files_to_be_deleted = `grep -Ir 'remove_date:' doc | grep -v doc/development/documentation/index.md | cut -d ":" -f 1`.split("\n")
#
# Iterate over the files to be deleted and print the needed
# YAML entries for the Docs site redirects.
#
files_to_be_deleted.each do |filename|
frontmatter = YAML.safe_load(File.read(filename))
remove_date = Date.parse(frontmatter['remove_date'])
old_path = filename.gsub(%r(\.md), '.html').gsub(%r(doc/), '/ee/')
#
# Check if the removal date is before today, and delete the file and
# print the content to be pasted in
# https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/content/_data/redirects.yaml.
# The remove_date of redirects.yaml should be nine months in the future.
# To not be confused with the remove_date of the Markdown page.
#
next unless remove_date < today
File.delete(filename) if File.exist?(filename)
puts " - from: #{old_path}"
puts " to: #{new_path(frontmatter['redirect_to'], filename)}"
puts " remove_date: #{remove_date >> 9}"
end
end
end
end

View file

@ -14705,6 +14705,9 @@ msgstr ""
msgid "Generate site and private keys at"
msgstr ""
msgid "Generic"
msgstr ""
msgid "Generic package file size in bytes"
msgstr ""
@ -23652,6 +23655,9 @@ msgstr ""
msgid "Package recipe already exists"
msgstr ""
msgid "Package type"
msgstr ""
msgid "Package type must be Conan"
msgstr ""
@ -33562,6 +33568,9 @@ msgstr ""
msgid "There are running deployments on the environment. Please retry later."
msgstr ""
msgid "There are several file size limits in place for the Package Registry."
msgstr ""
msgid "There are several rate limits in place to protect the system."
msgstr ""
@ -33793,9 +33802,6 @@ msgstr ""
msgid "There was an error while fetching the table data. Please refresh the page to try again."
msgstr ""
msgid "There was an error while fetching value stream analytics %{requestTypeName} data."
msgstr ""
msgid "There was an error while fetching value stream analytics data."
msgstr ""
@ -36631,9 +36637,15 @@ msgstr ""
msgid "ValueStreamAnalytics|Median time from issue first merge request created to issue closed."
msgstr ""
msgid "ValueStreamAnalytics|Number of commits pushed to the default branch"
msgstr ""
msgid "ValueStreamAnalytics|Number of new issues created."
msgstr ""
msgid "ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data."
msgstr ""
msgid "ValueStreamAnalytics|Total number of deploys to production."
msgstr ""

View file

@ -101,3 +101,5 @@ module QA
end
end
end
QA::Resource::GroupBase.prepend_mod_with('Resource::GroupBase', namespace: QA)

View file

@ -2,7 +2,7 @@
module QA
RSpec.describe 'Manage', :requires_admin do
describe 'Bulk group import via api' do
describe 'Bulk group import' do
let!(:staging?) { Runtime::Scenario.gitlab_address.include?('staging.gitlab.com') }
let(:admin_api_client) { Runtime::API::Client.as_admin }

View file

@ -57,10 +57,7 @@ module QA
# Non blocking issues:
# https://gitlab.com/gitlab-org/gitlab/-/issues/331252
# https://gitlab.com/gitlab-org/gitlab/-/issues/333678 <- can cause 500 when creating user and group back to back
it(
'imports group with subgroups and labels',
testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1785'
) do
it 'imports group from UI', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1785' do
Page::Group::BulkImport.perform do |import_page|
import_page.import_group(imported_group.path, imported_group.sandbox.path)

View file

@ -7,6 +7,7 @@ RSpec.describe 'Value Stream Analytics', :js do
let_it_be(:guest) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:stage_table_selector) { '[data-testid="vsa-stage-table"]' }
let_it_be(:metrics_selector) { "[data-testid='vsa-time-metrics']" }
let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
let(:milestone) { create(:milestone, project: project) }
@ -26,11 +27,13 @@ RSpec.describe 'Value Stream Analytics', :js do
wait_for_requests
end
it 'shows pipeline summary' do
expect(new_issues_counter).to have_content('-')
expect(commits_counter).to have_content('-')
expect(deploys_counter).to have_content('-')
expect(deployment_frequency_counter).to have_content('-')
it 'displays metrics' do
aggregate_failures 'with relevant values' do
expect(new_issues_counter).to have_content('-')
expect(commits_counter).to have_content('-')
expect(deploys_counter).to have_content('-')
expect(deployment_frequency_counter).to have_content('-')
end
end
it 'shows active stage with empty message' do
@ -60,11 +63,15 @@ RSpec.describe 'Value Stream Analytics', :js do
visit project_cycle_analytics_path(project)
end
it 'shows pipeline summary' do
expect(new_issues_counter).to have_content('1')
expect(commits_counter).to have_content('2')
expect(deploys_counter).to have_content('1')
expect(deployment_frequency_counter).to have_content('0')
it 'displays metrics' do
metrics_tiles = page.find(metrics_selector)
aggregate_failures 'with relevant values' do
expect(metrics_tiles).to have_content('Commit')
expect(metrics_tiles).to have_content('Deploy')
expect(metrics_tiles).to have_content('Deployment Frequency')
expect(metrics_tiles).to have_content('New Issue')
end
end
it 'shows data on each stage', :sidekiq_might_not_need_inline do
@ -96,7 +103,7 @@ RSpec.describe 'Value Stream Analytics', :js do
end
it 'shows only relevant data' do
expect(new_issues_counter).to have_content('1')
expect(new_issue_counter).to have_content('1')
end
end
end
@ -116,7 +123,7 @@ RSpec.describe 'Value Stream Analytics', :js do
end
it 'does not show the commit stats' do
expect(page).to have_no_selector(:xpath, commits_counter_selector)
expect(page.find(metrics_selector)).not_to have_selector("#commits")
end
it 'needs permissions to see restricted stages' do
@ -130,28 +137,29 @@ RSpec.describe 'Value Stream Analytics', :js do
end
end
def new_issues_counter
find(:xpath, "//p[contains(text(),'New Issue')]/preceding-sibling::h3")
def find_metric_tile(sel)
page.find("#{metrics_selector} #{sel}")
end
def commits_counter_selector
"//p[contains(text(),'Commits')]/preceding-sibling::h3"
# When now use proper pluralization for the metric names, which affects the id
def new_issue_counter
find_metric_tile("#new-issue")
end
def new_issues_counter
find_metric_tile("#new-issues")
end
def commits_counter
find(:xpath, commits_counter_selector)
find_metric_tile("#commits")
end
def deploys_counter
find(:xpath, "//p[contains(text(),'Deploy')]/preceding-sibling::h3", match: :first)
end
def deployment_frequency_counter_selector
"//p[contains(text(),'Deployment Frequency')]/preceding-sibling::h3"
find_metric_tile("#deploys")
end
def deployment_frequency_counter
find(:xpath, deployment_frequency_counter_selector)
find_metric_tile("#deployment-frequency")
end
def expect_issue_to_be_present

View file

@ -6,13 +6,13 @@ RSpec.describe 'New/edit issue', :js do
include ActionView::Helpers::JavaScriptHelper
include FormHelper
let!(:project) { create(:project) }
let!(:user) { create(:user)}
let!(:user2) { create(:user)}
let!(:milestone) { create(:milestone, project: project) }
let!(:label) { create(:label, project: project) }
let!(:label2) { create(:label, project: project) }
let!(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) }
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user)}
let_it_be(:user2) { create(:user)}
let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:label) { create(:label, project: project) }
let_it_be(:label2) { create(:label, project: project) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) }
before do
stub_licensed_features(multiple_issue_assignees: false, issue_weights: false)
@ -234,6 +234,28 @@ RSpec.describe 'New/edit issue', :js do
expect(page).to have_selector('.atwho-view')
end
describe 'displays issue type options in the dropdown' do
before do
page.within('.issue-form') do
click_button 'Issue'
end
end
it 'correctly displays the Issue type option with an icon', :aggregate_failures do
page.within('[data-testid="issue-type-select-dropdown"]') do
expect(page).to have_selector('[data-testid="issue-type-issue-icon"]')
expect(page).to have_content('Issue')
end
end
it 'correctly displays the Incident type option with an icon', :aggregate_failures do
page.within('[data-testid="issue-type-select-dropdown"]') do
expect(page).to have_selector('[data-testid="issue-type-incident-icon"]')
expect(page).to have_content('Incident')
end
end
end
describe 'milestone' do
let!(:milestone) { create(:milestone, title: '">&lt;img src=x onerror=alert(document.domain)&gt;', project: project) }

View file

@ -6,6 +6,7 @@ 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 StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants';
import initState from '~/cycle_analytics/store/state';
import {
@ -23,6 +24,7 @@ const selectedStageEvents = issueEvents.events;
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
const selectedStageCount = stageCounts[selectedStage.id];
const fullPath = 'full/path/to/foo';
Vue.use(Vuex);
@ -34,6 +36,7 @@ const defaultState = {
createdBefore,
createdAfter,
stageCounts,
endpoints: { fullPath },
};
function createStore({ initialState = {}, initialGetters = {} }) {
@ -45,6 +48,10 @@ function createStore({ initialState = {}, initialGetters = {} }) {
},
getters: {
pathNavigationData: () => transformedProjectStagePathData,
filterParams: () => ({
created_after: createdAfter,
created_before: createdBefore,
}),
...initialGetters,
},
});
@ -67,11 +74,17 @@ function createComponent({ initialState, initialGetters } = {}) {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPathNavigation = () => wrapper.findComponent(PathNavigation);
const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics');
const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics);
const findStageTable = () => wrapper.findComponent(StageTable);
const findStageEvents = () => findStageTable().props('stageEvents');
const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title');
const hasMetricsRequests = (reqs) => {
const foundReqs = findOverviewMetrics().props('requests');
expect(foundReqs.length).toEqual(reqs.length);
expect(foundReqs.map(({ name }) => name)).toEqual(reqs);
};
describe('Value stream analytics component', () => {
beforeEach(() => {
wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } });
@ -94,6 +107,10 @@ describe('Value stream analytics component', () => {
expect(findOverviewMetrics().exists()).toBe(true);
});
it('passes requests prop to the metrics component', () => {
hasMetricsRequests(['recent activity']);
});
it('renders the stage table', () => {
expect(findStageTable().exists()).toBe(true);
});
@ -110,6 +127,16 @@ describe('Value stream analytics component', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
describe('with `cycleAnalyticsForGroups=true` license', () => {
beforeEach(() => {
wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } });
});
it('passes requests prop to the metrics component', () => {
hasMetricsRequests(['time summary', 'recent activity']);
});
});
describe('isLoading = true', () => {
beforeEach(() => {
wrapper = createComponent({
@ -121,14 +148,14 @@ describe('Value stream analytics component', () => {
expect(findPathNavigation().props('loading')).toBe(true);
});
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 overview metrics', () => {
expect(findOverviewMetrics().exists()).toBe(true);
});
it('renders the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});

View file

@ -15,8 +15,11 @@ export const getStageByTitle = (stages, title) =>
const fixtureEndpoints = {
customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages',
stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}`,
metricsData: 'projects/analytics/value_stream_analytics/summary',
};
export const metricsData = getJSONFixture(fixtureEndpoints.metricsData);
export const customizableStagesAndEvents = getJSONFixture(
fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents,
);

View file

@ -6,8 +6,6 @@ import {
selectedStage,
rawIssueEvents,
issueEvents,
rawData,
convertedData,
selectedValueStream,
rawValueStreamStages,
valueStreamStages,
@ -90,18 +88,17 @@ describe('Project Value Stream Analytics mutations', () => {
});
it.each`
mutation | payload | stateKey | value
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
mutation | payload | stateKey | value
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {

View file

@ -1,40 +1,24 @@
import { useFakeDate } from 'helpers/fake_date';
import {
decorateData,
transformStagesForPathNavigation,
timeSummaryForPathNavigation,
medianTimeToParsedSeconds,
formatMedianValues,
filterStagesByHiddenStatus,
calculateFormattedDayInPast,
prepareTimeMetricsData,
} from '~/cycle_analytics/utils';
import { slugify } from '~/lib/utils/text_utility';
import {
selectedStage,
rawData,
convertedData,
allowedStages,
stageMedians,
pathNavIssueMetric,
rawStageMedians,
metricsData,
} from './mock_data';
describe('Value stream analytics utils', () => {
describe('decorateData', () => {
const result = decorateData(rawData);
it('returns the summary data', () => {
expect(result.summary).toEqual(convertedData.summary);
});
it('returns `-` for summary data that has no value', () => {
const singleSummaryResult = decorateData({
stats: [],
permissions: { issue: true },
summary: [{ value: null, title: 'Commits' }],
});
expect(singleSummaryResult.summary).toEqual([{ value: '-', title: 'Commits' }]);
});
});
describe('transformStagesForPathNavigation', () => {
const stages = allowedStages;
const response = transformStagesForPathNavigation({
@ -129,4 +113,32 @@ describe('Value stream analytics utils', () => {
expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' });
});
});
describe('prepareTimeMetricsData', () => {
let prepared;
const [first, second] = metricsData;
const firstKey = slugify(first.title);
const secondKey = slugify(second.title);
beforeEach(() => {
prepared = prepareTimeMetricsData([first, second], {
[firstKey]: { description: 'Is a value that is good' },
});
});
it('will add a `key` based on the title', () => {
expect(prepared).toMatchObject([{ key: firstKey }, { key: secondKey }]);
});
it('will add a `label` key', () => {
expect(prepared).toMatchObject([{ label: 'New Issues' }, { label: 'Commits' }]);
});
it('will add a popover description using the key if it is provided', () => {
expect(prepared).toMatchObject([
{ description: 'Is a value that is good' },
{ description: '' },
]);
});
});
});

View file

@ -0,0 +1,128 @@
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import createFlash from '~/flash';
import { group, metricsData } from './mock_data';
jest.mock('~/flash');
describe('ValueStreamMetrics', () => {
let wrapper;
let mockGetValueStreamSummaryMetrics;
const { full_path: requestPath } = group;
const fakeReqName = 'Mock metrics';
const metricsRequestFactory = () => ({
request: mockGetValueStreamSummaryMetrics,
endpoint: METRIC_TYPE_SUMMARY,
name: fakeReqName,
});
const createComponent = ({ requestParams = {} } = {}) => {
return shallowMount(ValueStreamMetrics, {
propsData: {
requestPath,
requestParams,
requests: [metricsRequestFactory()],
},
});
};
const findMetrics = () => wrapper.findAllComponents(GlSingleStat);
const expectToHaveRequest = (fields) => {
expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({
endpoint: METRIC_TYPE_SUMMARY,
requestPath,
...fields,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with successful requests', () => {
beforeEach(() => {
mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
wrapper = createComponent();
});
it('will display a loader with pending requests', async () => {
await wrapper.vm.$nextTick();
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
});
describe('with data loaded', () => {
beforeEach(async () => {
await waitForPromises();
});
it('fetches data from the value stream analytics endpoint', () => {
expectToHaveRequest({ params: {} });
});
it.each`
index | value | title | unit
${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit}
${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit}
${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit}
${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit}
`(
'renders a single stat component for the $title with value and unit',
({ index, value, title, unit }) => {
const metric = findMetrics().at(index);
expect(metric.props()).toMatchObject({ value, title, unit: unit ?? '' });
},
);
it('will not display a loading icon', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
describe('with additional params', () => {
beforeEach(async () => {
wrapper = createComponent({
requestParams: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
await waitForPromises();
});
it('fetches data for the `getValueStreamSummaryMetrics` request', () => {
expectToHaveRequest({
params: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
});
});
});
});
describe('with a request failing', () => {
beforeEach(async () => {
mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue();
wrapper = createComponent();
await waitForPromises();
});
it('it should render an error message', () => {
expect(createFlash).toHaveBeenCalledWith({
message: `There was an error while fetching value stream analytics ${fakeReqName} data.`,
});
});
});
});

View file

@ -17,6 +17,8 @@ const defaultMockDiscussion = {
notes,
};
const DEFAULT_TODO_COUNT = 2;
describe('Design discussions component', () => {
let wrapper;
@ -41,8 +43,14 @@ describe('Design discussions component', () => {
},
};
const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } });
const readQuery = jest.fn().mockReturnValue({
project: {
issue: { designCollection: { designs: { nodes: [{ currentUserTodos: { nodes: [] } }] } } },
},
});
const $apollo = {
mutate,
provider: { clients: { defaultClient: { readQuery } } },
};
function createComponent(props = {}, data = {}) {
@ -69,6 +77,12 @@ describe('Design discussions component', () => {
$apollo,
$route: {
hash: '#note_1',
params: {
id: 1,
},
query: {
version: null,
},
},
},
});
@ -138,7 +152,13 @@ describe('Design discussions component', () => {
});
describe('when discussion is resolved', () => {
let dispatchEventSpy;
beforeEach(() => {
dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
jest.spyOn(document, 'querySelector').mockReturnValue({
innerText: DEFAULT_TODO_COUNT,
});
createComponent({
discussion: {
...defaultMockDiscussion,
@ -174,6 +194,24 @@ describe('Design discussions component', () => {
expect(findResolveIcon().props('name')).toBe('check-circle-filled');
});
it('emit todo:toggle when discussion is resolved', async () => {
createComponent(
{ discussionWithOpenForm: defaultMockDiscussion.id },
{ discussionComment: 'test', isFormRendered: true },
);
findResolveButton().trigger('click');
findReplyForm().vm.$emit('submitForm');
await mutate();
await wrapper.vm.$nextTick();
const dispatchedEvent = dispatchEventSpy.mock.calls[0][0];
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
expect(dispatchedEvent.detail).toEqual({ count: DEFAULT_TODO_COUNT });
expect(dispatchedEvent.type).toBe('todo:toggle');
});
describe('when replies are expanded', () => {
beforeEach(() => {
findRepliesWidget().vm.$emit('toggle');

View file

@ -172,3 +172,40 @@ export const moveDesignMutationResponseWithErrors = {
},
},
};
export const resolveCommentMutationResponse = {
discussionToggleResolve: {
discussion: {
noteable: {
id: 'gid://gitlab/DesignManagement::Design/1',
currentUserTodos: {
nodes: [],
__typename: 'TodoConnection',
},
__typename: 'Design',
},
__typename: 'Discussion',
},
errors: [],
__typename: 'DiscussionToggleResolvePayload',
},
};
export const getDesignQueryResponse = {
project: {
issue: {
designCollection: {
designs: {
nodes: [
{
id: 'gid://gitlab/DesignManagement::Design/1',
currentUserTodos: {
nodes: [{ id: 'gid://gitlab/Todo::1' }],
},
},
],
},
},
},
},
};

View file

@ -1,16 +1,34 @@
import MockAdapter from 'axios-mock-adapter';
import { Range, Position } from 'monaco-editor';
import waitForPromises from 'helpers/wait_for_promises';
import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
} from '~/editor/constants';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import SourceEditor from '~/editor/source_editor';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import syntaxHighlight from '~/syntax_highlight';
jest.mock('~/syntax_highlight');
jest.mock('~/flash');
describe('Markdown Extension for Source Editor', () => {
let editor;
let instance;
let editorEl;
let panelSpy;
let mockAxios;
const projectPath = 'fooGroup/barProj';
const firstLine = 'This is a';
const secondLine = 'multiline';
const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
const filePath = 'foo.md';
const responseData = '<div>FooBar</div>';
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn);
@ -22,7 +40,13 @@ describe('Markdown Extension for Source Editor', () => {
const selectionToString = () => instance.getSelection().toString();
const positionToString = () => instance.getPosition().toString();
const togglePreview = async () => {
instance.togglePreview();
await waitForPromises();
};
beforeEach(() => {
mockAxios = new MockAdapter(axios);
setFixtures('<div id="editor" data-editor-loading></div>');
editorEl = document.getElementById('editor');
editor = new SourceEditor();
@ -31,12 +55,313 @@ describe('Markdown Extension for Source Editor', () => {
blobPath: filePath,
blobContent: text,
});
editor.use(new EditorMarkdownExtension());
editor.use(new EditorMarkdownExtension({ instance, projectPath }));
panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
});
afterEach(() => {
instance.dispose();
editorEl.remove();
mockAxios.restore();
});
it('sets up the instance', () => {
expect(instance.preview).toEqual({
el: undefined,
action: expect.any(Object),
shown: false,
});
expect(instance.projectPath).toBe(projectPath);
});
describe('cleanup', () => {
beforeEach(async () => {
mockAxios.onPost().reply(200, { body: responseData });
await togglePreview();
});
it('removes the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
instance.cleanup();
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null);
});
it('toggles the `shown` flag', () => {
expect(instance.preview.shown).toBe(true);
instance.cleanup();
expect(instance.preview.shown).toBe(false);
});
it('toggles the panel only if the preview is visible', () => {
const { el: previewEl } = instance.preview;
const parentEl = previewEl.parentElement;
expect(previewEl).toBeVisible();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true);
instance.cleanup();
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
instance.cleanup();
expect(previewEl).toBeHidden();
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
});
it('toggles the layout only if the preview is visible', () => {
const { width } = instance.getLayoutInfo();
expect(instance.preview.shown).toBe(true);
instance.cleanup();
const { width: newWidth } = instance.getLayoutInfo();
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
instance.cleanup();
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
});
});
describe('fetchPreview', () => {
const group = 'foo';
const project = 'bar';
const setData = (path, g, p) => {
instance.projectPath = path;
document.body.setAttribute('data-group', g);
document.body.setAttribute('data-project', p);
};
const fetchPreview = async () => {
instance.fetchPreview();
await waitForPromises();
};
beforeEach(() => {
mockAxios.onPost().reply(200, { body: responseData });
});
it('correctly fetches preview based on projectPath', async () => {
setData(projectPath, group, project);
await fetchPreview();
expect(mockAxios.history.post[0].url).toBe(`/${projectPath}/preview_markdown`);
expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text }));
});
it('correctly fetches preview based on group and project data attributes', async () => {
setData(undefined, group, project);
await fetchPreview();
expect(mockAxios.history.post[0].url).toBe(`/${group}/${project}/preview_markdown`);
expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text }));
});
it('puts the fetched content into the preview DOM element', async () => {
instance.preview.el = editorEl.parentElement;
await fetchPreview();
expect(instance.preview.el.innerHTML).toEqual(responseData);
});
it('applies syntax highlighting to the preview content', async () => {
instance.preview.el = editorEl.parentElement;
await fetchPreview();
expect(syntaxHighlight).toHaveBeenCalled();
});
it('catches the errors when fetching the preview', async () => {
mockAxios.onPost().reply(500);
await fetchPreview();
expect(createFlash).toHaveBeenCalled();
});
});
describe('setupPreviewAction', () => {
it('adds the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
});
it('does not set up action if one already exists', () => {
jest.spyOn(instance, 'addAction').mockImplementation();
instance.setupPreviewAction();
expect(instance.addAction).not.toHaveBeenCalled();
});
it('toggles preview when the action is triggered', () => {
jest.spyOn(instance, 'togglePreview').mockImplementation();
expect(instance.togglePreview).not.toHaveBeenCalled();
const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID);
action.run();
expect(instance.togglePreview).toHaveBeenCalled();
});
});
describe('togglePreview', () => {
beforeEach(() => {
mockAxios.onPost().reply(200, { body: responseData });
});
it('toggles preview flag on instance', () => {
expect(instance.preview.shown).toBe(false);
instance.togglePreview();
expect(instance.preview.shown).toBe(true);
instance.togglePreview();
expect(instance.preview.shown).toBe(false);
});
describe('model language changes', () => {
const plaintextPath = 'foo.txt';
const markdownPath = 'foo.md';
let cleanupSpy;
let actionSpy;
beforeEach(() => {
cleanupSpy = jest.spyOn(instance, 'cleanup');
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
instance.togglePreview();
});
it('cleans up when switching away from markdown', async () => {
expect(instance.cleanup).not.toHaveBeenCalled();
expect(instance.setupPreviewAction).not.toHaveBeenCalled();
instance.updateModelLanguage(plaintextPath);
expect(cleanupSpy).toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
it('re-enables the action when switching back to markdown', () => {
instance.updateModelLanguage(plaintextPath);
jest.clearAllMocks();
instance.updateModelLanguage(markdownPath);
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).toHaveBeenCalled();
});
it('does not re-enable the action if we do not change the language', () => {
instance.updateModelLanguage(markdownPath);
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
});
describe('panel DOM element set up', () => {
it('sets up an element to contain the preview and stores it on instance', () => {
expect(instance.preview.el).toBeUndefined();
instance.togglePreview();
expect(instance.preview.el).toBeDefined();
expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe(
true,
);
});
it('re-uses existing preview DOM element on repeated calls', () => {
instance.togglePreview();
const origPreviewEl = instance.preview.el;
instance.togglePreview();
expect(instance.preview.el).toBe(origPreviewEl);
});
it('hides the preview DOM element by default', () => {
panelSpy.mockImplementation();
instance.togglePreview();
expect(instance.preview.el.style.display).toBe('none');
});
});
describe('preview layout setup', () => {
it('sets correct preview layout', () => {
jest.spyOn(instance, 'layout');
const { width, height } = instance.getLayoutInfo();
instance.togglePreview();
expect(instance.layout).toHaveBeenCalledWith({
width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
height,
});
});
});
describe('preview panel', () => {
it('toggles preview CSS class on the editor', () => {
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
instance.togglePreview();
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
true,
);
instance.togglePreview();
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
false,
);
});
it('toggles visibility of the preview DOM element', async () => {
await togglePreview();
expect(instance.preview.el.style.display).toBe('block');
await togglePreview();
expect(instance.preview.el.style.display).toBe('none');
});
describe('hidden preview DOM element', () => {
it('listens to model changes and re-fetches preview', async () => {
expect(mockAxios.history.post).toHaveLength(0);
await togglePreview();
expect(mockAxios.history.post).toHaveLength(1);
instance.setValue('New Value');
await waitForPromises();
expect(mockAxios.history.post).toHaveLength(2);
});
it('stores disposable listener for model changes', async () => {
expect(instance.modelChangeListener).toBeUndefined();
await togglePreview();
expect(instance.modelChangeListener).toBeDefined();
});
});
describe('already visible preview', () => {
beforeEach(async () => {
await togglePreview();
mockAxios.resetHistory();
});
it('does not re-fetch the preview', () => {
instance.togglePreview();
expect(mockAxios.history.post).toHaveLength(0);
});
it('disposes the model change event listener', () => {
const disposeSpy = jest.fn();
instance.modelChangeListener = {
dispose: disposeSpy,
};
instance.togglePreview();
expect(disposeSpy).toHaveBeenCalled();
});
});
});
});
describe('getSelectedText', () => {

View file

@ -51,4 +51,21 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
end
end
end
describe Projects::Analytics::CycleAnalytics::SummaryController, type: :controller do
render_views
let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } }
before do
project.add_developer(user)
sign_in(user)
end
it "projects/analytics/value_stream_analytics/summary" do
get(:show, params: params, format: :json)
expect(response).to be_successful
end
end
end

View file

@ -1,4 +1,4 @@
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@ -35,6 +35,9 @@ describe('Issue type field component', () => {
const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup);
const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown);
const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findTypeFromDropDownItemAt = (at) => findTypeFromDropDownItems().at(at);
const findTypeFromDropDownItemIconAt = (at) =>
findTypeFromDropDownItems().at(at).findComponent(GlIcon);
const createComponent = ({ data } = {}) => {
fakeApollo = createMockApollo([], mockResolvers);
@ -60,6 +63,15 @@ describe('Issue type field component', () => {
wrapper.destroy();
});
it.each`
at | text | icon
${0} | ${IssuableTypes[0].text} | ${IssuableTypes[0].icon}
${1} | ${IssuableTypes[1].text} | ${IssuableTypes[1].icon}
`(`renders the issue type $text with an icon in the dropdown`, ({ at, text, icon }) => {
expect(findTypeFromDropDownItemIconAt(at).attributes('name')).toBe(icon);
expect(findTypeFromDropDownItemAt(at).text()).toBe(text);
});
it('renders a form group with the correct label', () => {
expect(findTypeFromGroup().attributes('label')).toBe(i18n.label);
});

View file

@ -123,6 +123,15 @@ export const multipleCollapsibleSectionsMockData = [
},
];
export const backwardsCompatibilityTrace = [
{
offset: 2365,
content: [],
section: 'download-artifacts',
section_duration: '00:01',
},
];
export const originalTrace = [
{
offset: 1,

View file

@ -19,6 +19,7 @@ import {
collapsibleTrace,
collapsibleTraceIncremental,
multipleCollapsibleSectionsMockData,
backwardsCompatibilityTrace,
} from '../components/log/mock_data';
describe('Jobs Store Utils', () => {
@ -297,6 +298,21 @@ describe('Jobs Store Utils', () => {
expect(result.parsedLines[1].lines).toEqual(expect.arrayContaining(innerSection));
});
});
describe('backwards compatibility', () => {
beforeEach(() => {
result = logLinesParser(backwardsCompatibilityTrace);
});
it('should return an object with a parsedLines prop', () => {
expect(result).toEqual(
expect.objectContaining({
parsedLines: expect.any(Array),
}),
);
expect(result.parsedLines).toHaveLength(1);
});
});
});
describe('findOffsetAndRemove', () => {

View file

@ -5,19 +5,21 @@ import TerraformNotification from '~/projects/terraform_notification/components/
jest.mock('~/lib/utils/common_utils');
const bannerDissmisedKey = 'terraform_notification_dismissed_for_project_1';
const terraformImagePath = '/path/to/image';
const bannerDismissedKey = 'terraform_notification_dismissed';
describe('TerraformNotificationBanner', () => {
let wrapper;
const propsData = {
projectId: 1,
const provideData = {
terraformImagePath,
bannerDismissedKey,
};
const findBanner = () => wrapper.findComponent(GlBanner);
beforeEach(() => {
wrapper = shallowMount(TerraformNotification, {
propsData,
provide: provideData,
stubs: { GlBanner },
});
});
@ -27,19 +29,6 @@ describe('TerraformNotificationBanner', () => {
parseBoolean.mockReturnValue(false);
});
describe('when the dismiss cookie is set', () => {
beforeEach(() => {
parseBoolean.mockReturnValue(true);
wrapper = shallowMount(TerraformNotification, {
propsData,
});
});
it('should not render the banner', () => {
expect(findBanner().exists()).toBe(false);
});
});
describe('when the dismiss cookie is not set', () => {
it('should render the banner', () => {
expect(findBanner().exists()).toBe(true);
@ -51,8 +40,8 @@ describe('TerraformNotificationBanner', () => {
await findBanner().vm.$emit('close');
});
it('should set the cookie with the bannerDissmisedKey', () => {
expect(setCookie).toHaveBeenCalledWith(bannerDissmisedKey, true);
it('should set the cookie with the bannerDismissedKey', () => {
expect(setCookie).toHaveBeenCalledWith(bannerDismissedKey, true);
});
it('should remove the banner', () => {

View file

@ -1,14 +1,26 @@
# frozen_string_literal: true
require "spec_helper"
require 'spec_helper'
RSpec.describe IssuesHelper do
let(:project) { create(:project) }
let(:issue) { create :issue, project: project }
let(:ext_project) { create :redmine_project }
describe '#work_item_type_icon' do
it 'returns icon of all standard base types' do
WorkItem::Type.base_types.each do |type|
expect(work_item_type_icon(type[0])).to eq "issue-type-#{type[0].to_s.dasherize}"
end
end
it 'defaults to issue icon if type is unknown' do
expect(work_item_type_icon('invalid')).to eq 'issue-type-issue'
end
end
describe '#award_user_list' do
it "returns a comma-separated list of the first X users" do
it 'returns a comma-separated list of the first X users' do
user = build_stubbed(:user, name: 'Joe')
awards = Array.new(3, build_stubbed(:award_emoji, user: user))
@ -24,7 +36,7 @@ RSpec.describe IssuesHelper do
expect(award_user_list([award], nil)).to eq 'Joe'
end
it "truncates lists" do
it 'truncates lists' do
user = build_stubbed(:user, name: 'Jane')
awards = Array.new(5, build_stubbed(:award_emoji, user: user))
@ -32,14 +44,14 @@ RSpec.describe IssuesHelper do
.to eq('Jane, Jane, Jane, and 2 more.')
end
it "displays the current user in front of other users" do
it 'displays the current user in front of other users' do
current_user = build_stubbed(:user)
my_award = build_stubbed(:award_emoji, user: current_user)
award = build_stubbed(:award_emoji, user: build_stubbed(:user, name: 'Jane'))
awards = Array.new(5, award).push(my_award)
expect(award_user_list(awards, current_user, limit: 2))
.to eq("You, Jane, and 4 more.")
.to eq('You, Jane, and 4 more.')
end
end
@ -54,19 +66,19 @@ RSpec.describe IssuesHelper do
end
end
it "returns disabled string for unauthenticated user" do
expect(helper.award_state_class(awardable, AwardEmoji.all, nil)).to eq("disabled")
it 'returns disabled string for unauthenticated user' do
expect(helper.award_state_class(awardable, AwardEmoji.all, nil)).to eq('disabled')
end
it "returns disabled for a user that does not have access to the awardable" do
expect(helper.award_state_class(awardable, AwardEmoji.all, build(:user))).to eq("disabled")
it 'returns disabled for a user that does not have access to the awardable' do
expect(helper.award_state_class(awardable, AwardEmoji.all, build(:user))).to eq('disabled')
end
it "returns active string for author" do
expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq("active")
it 'returns active string for author' do
expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq('active')
end
it "is blank for a user that has access to the awardable" do
it 'is blank for a user that has access to the awardable' do
user = build(:user)
expect(helper).to receive(:can?).with(user, :award_emoji, awardable).and_return(true)
@ -74,40 +86,40 @@ RSpec.describe IssuesHelper do
end
end
describe "awards_sort" do
it "sorts a hash so thumbsup and thumbsdown are always on top" do
data = { "thumbsdown" => "some value", "lifter" => "some value", "thumbsup" => "some value" }
describe 'awards_sort' do
it 'sorts a hash so thumbsup and thumbsdown are always on top' do
data = { 'thumbsdown' => 'some value', 'lifter' => 'some value', 'thumbsup' => 'some value' }
expect(awards_sort(data).keys).to eq(%w(thumbsup thumbsdown lifter))
end
end
describe "#link_to_discussions_to_resolve" do
describe "passing only a merge request" do
describe '#link_to_discussions_to_resolve' do
describe 'passing only a merge request' do
let(:merge_request) { create(:merge_request) }
it "links just the merge request" do
it 'links just the merge request' do
expected_path = project_merge_request_path(merge_request.project, merge_request)
expect(link_to_discussions_to_resolve(merge_request, nil)).to include(expected_path)
end
it "contains the reference to the merge request" do
it 'contains the reference to the merge request' do
expect(link_to_discussions_to_resolve(merge_request, nil)).to include(merge_request.to_reference)
end
end
describe "when passing a discussion" do
describe 'when passing a discussion' do
let(:diff_note) { create(:diff_note_on_merge_request) }
let(:merge_request) { diff_note.noteable }
let(:discussion) { diff_note.to_discussion }
it "links to the merge request with first note if a single discussion was passed" do
it 'links to the merge request with first note if a single discussion was passed' do
expected_path = Gitlab::UrlBuilder.build(diff_note)
expect(link_to_discussions_to_resolve(merge_request, discussion)).to include(expected_path)
end
it "contains both the reference to the merge request and a mention of the discussion" do
it 'contains both the reference to the merge request and a mention of the discussion' do
expect(link_to_discussions_to_resolve(merge_request, discussion)).to include("#{merge_request.to_reference} (discussion #{diff_note.id})")
end
end
@ -235,13 +247,13 @@ RSpec.describe IssuesHelper do
end
describe '#use_startup_call' do
it "returns false when a query param is present" do
it 'returns false when a query param is present' do
allow(controller.request).to receive(:query_parameters).and_return({ foo: 'bar' })
expect(helper.use_startup_call?).to eq(false)
end
it "returns false when user has stored sort preference" do
it 'returns false when user has stored sort preference' do
controller.instance_variable_set(:@sort, 'updated_asc')
expect(helper.use_startup_call?).to eq(false)
@ -265,13 +277,13 @@ RSpec.describe IssuesHelper do
it 'returns expected result' do
expected = {
can_create_issue: "true",
can_reopen_issue: "true",
can_report_spam: "false",
can_update_issue: "true",
can_create_issue: 'true',
can_reopen_issue: 'true',
can_report_spam: 'false',
can_update_issue: 'true',
iid: issue.iid,
is_issue_author: "false",
issue_type: "issue",
is_issue_author: 'false',
issue_type: 'issue',
new_issue_path: new_project_issue_path(project),
project_path: project.full_path,
report_abuse_path: new_abuse_report_path(user_id: issue.author.id, ref_url: issue_url(issue)),
@ -345,7 +357,7 @@ RSpec.describe IssuesHelper do
end
it 'returns manual ordering class' do
expect(helper.issue_manual_ordering_class).to eq("manual-ordering")
expect(helper.issue_manual_ordering_class).to eq('manual-ordering')
end
context 'when manual sorting disabled' do

View file

@ -2,8 +2,8 @@
require 'spec_helper'
RSpec.describe 'Terraform/Base.latest.gitlab-ci.yml' do
subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform/Base.latest') }
RSpec.describe 'Terraform/Base.gitlab-ci.yml' do
subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform/Base') }
describe 'the created pipeline' do
let(:default_branch) { 'master' }

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Terraform/Base.latest.gitlab-ci.yml' do
subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform/Base.latest') }
describe 'the created pipeline' do
let(:default_branch) { 'master' }
let(:pipeline_branch) { default_branch }
let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) }
let(:user) { project.owner }
let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) }
let(:pipeline) { service.execute!(:push).payload }
let(:build_names) { pipeline.builds.pluck(:name) }
before do
stub_ci_pipeline_yaml_file(template.content)
allow(project).to receive(:default_branch).and_return(default_branch)
end
it 'does not create any jobs' do
expect(build_names).to be_empty
end
end
end

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Terraform.gitlab-ci.yml' do
before do
allow(Gitlab::Template::GitlabCiYmlTemplate).to receive(:excluded_patterns).and_return([])
end
subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform') }
describe 'the created pipeline' do
let(:default_branch) { project.default_branch_or_main }
let(:pipeline_branch) { default_branch }
let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) }
let(:user) { project.owner }
let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) }
let(:pipeline) { service.execute!(:push).payload }
let(:build_names) { pipeline.builds.pluck(:name) }
before do
stub_ci_pipeline_yaml_file(template.content)
allow(project).to receive(:default_branch).and_return(default_branch)
end
context 'on master branch' do
it 'creates init, validate and build jobs', :aggregate_failures do
expect(pipeline.errors).to be_empty
expect(build_names).to include('init', 'validate', 'build', 'deploy')
end
end
context 'outside the master branch' do
let(:pipeline_branch) { 'patch-1' }
before do
project.repository.create_branch(pipeline_branch, default_branch)
end
it 'does not creates a deploy and a test job', :aggregate_failures do
expect(pipeline.errors).to be_empty
expect(build_names).not_to include('deploy')
end
end
end
end

View file

@ -25,7 +25,8 @@ RSpec.describe 'Terraform.latest.gitlab-ci.yml' do
end
context 'on master branch' do
it 'creates init, validate and build jobs' do
it 'creates init, validate and build jobs', :aggregate_failures do
expect(pipeline.errors).to be_empty
expect(build_names).to include('init', 'validate', 'build', 'deploy')
end
end
@ -37,7 +38,8 @@ RSpec.describe 'Terraform.latest.gitlab-ci.yml' do
project.repository.create_branch(pipeline_branch, default_branch)
end
it 'does not creates a deploy and a test job' do
it 'does not creates a deploy and a test job', :aggregate_failures do
expect(pipeline.errors).to be_empty
expect(build_names).not_to include('deploy')
end
end

View file

@ -97,6 +97,41 @@ RSpec.describe InstanceConfiguration do
end
end
describe '#package_file_size_limits' do
let_it_be(:plan1) { create(:plan, name: 'plan1', title: 'Plan 1') }
let_it_be(:plan2) { create(:plan, name: 'plan2', title: 'Plan 2') }
before do
create(:plan_limits,
plan: plan1,
conan_max_file_size: 1001,
maven_max_file_size: 1002,
npm_max_file_size: 1003,
nuget_max_file_size: 1004,
pypi_max_file_size: 1005,
terraform_module_max_file_size: 1006,
generic_packages_max_file_size: 1007
)
create(:plan_limits,
plan: plan2,
conan_max_file_size: 1101,
maven_max_file_size: 1102,
npm_max_file_size: 1103,
nuget_max_file_size: 1104,
pypi_max_file_size: 1105,
terraform_module_max_file_size: 1106,
generic_packages_max_file_size: 1107
)
end
it 'returns package file size limits' do
file_size_limits = subject.settings[:package_file_size_limits]
expect(file_size_limits[:Plan1]).to eq({ conan: 1001, maven: 1002, npm: 1003, nuget: 1004, pypi: 1005, terraform_module: 1006, generic: 1007 })
expect(file_size_limits[:Plan2]).to eq({ conan: 1101, maven: 1102, npm: 1103, nuget: 1104, pypi: 1105, terraform_module: 1106, generic: 1107 })
end
end
describe '#rate_limits' do
before do
Gitlab::CurrentSettings.current_application_settings.update!(

View file

@ -8,6 +8,8 @@ RSpec.describe ProjectFeature do
let(:project) { create(:project) }
let(:user) { create(:user) }
it { is_expected.to belong_to(:project) }
describe 'PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT' do
it 'has higher level than that of PRIVATE_FEATURES_MIN_ACCESS_LEVEL' do
described_class::PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT.each do |feature, level|

View file

@ -401,12 +401,6 @@ RSpec.describe ProjectStatistics do
let(:stat) { :build_artifacts_size }
it_behaves_like 'a statistic that increases storage_size asynchronously'
it_behaves_like 'a statistic that increases storage_size' do
before do
stub_feature_flags(efficient_counter_attribute: false)
end
end
end
context 'when adjusting :pipeline_artifacts_size' do

View file

@ -156,13 +156,6 @@ RSpec.describe Projects::UpdatePagesService do
expect(GenericCommitStatus.last.description).to eq("pages site contains 3 file entries, while limit is set to 2")
end
it 'does not limit pages file count if feature is disabled' do
stub_feature_flags(pages_limit_entries_count: false)
create(:plan_limits, :default_plan, pages_file_entries: 2)
expect(execute).to eq(:success)
end
it 'removes pages after destroy' do
expect(PagesWorker).to receive(:perform_in)
expect(project.pages_deployed?).to be_falsey

View file

@ -62,26 +62,6 @@ RSpec.shared_examples_for CounterAttribute do |counter_attributes|
.to raise_error(ActiveModel::MissingAttributeError)
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(efficient_counter_attribute: false)
end
it 'delegates to ActiveRecord update!' do
expect { subject }
.to change { model.reset.read_attribute(attribute) }.by(increment)
end
it 'does not increment the counter in Redis' do
subject
Gitlab::Redis::SharedState.with do |redis|
counter = redis.get(model.counter_key(attribute))
expect(counter).to be_nil
end
end
end
end
end
end

View file

@ -22,116 +22,6 @@ RSpec.shared_examples 'UpdateProjectStatistics' do |with_counter_attribute|
it { is_expected.to be_new_record }
context 'when feature flag efficient_counter_attribute is disabled' do
before do
stub_feature_flags(efficient_counter_attribute: false)
end
context 'when creating' do
it 'updates the project statistics' do
delta0 = reload_stat
subject.save!
delta1 = reload_stat
expect(delta1).to eq(delta0 + read_attribute)
expect(delta1).to be > delta0
end
it 'schedules a namespace statistics worker' do
expect(Namespaces::ScheduleAggregationWorker)
.to receive(:perform_async).once
subject.save!
end
end
context 'when updating' do
let(:delta) { 42 }
before do
subject.save!
end
it 'updates project statistics' do
expect(ProjectStatistics)
.to receive(:increment_statistic)
.and_call_original
subject.write_attribute(statistic_attribute, read_attribute + delta)
expect { subject.save! }
.to change { reload_stat }
.by(delta)
end
it 'schedules a namespace statistics worker' do
expect(Namespaces::ScheduleAggregationWorker)
.to receive(:perform_async).once
subject.write_attribute(statistic_attribute, read_attribute + delta)
subject.save!
end
it 'avoids N + 1 queries' do
subject.write_attribute(statistic_attribute, read_attribute + delta)
control_count = ActiveRecord::QueryRecorder.new do
subject.save!
end
subject.write_attribute(statistic_attribute, read_attribute + delta)
expect do
subject.save!
end.not_to exceed_query_limit(control_count)
end
end
context 'when destroying' do
before do
subject.save!
end
it 'updates the project statistics' do
delta0 = reload_stat
subject.destroy!
delta1 = reload_stat
expect(delta1).to eq(delta0 - read_attribute)
expect(delta1).to be < delta0
end
it 'schedules a namespace statistics worker' do
expect(Namespaces::ScheduleAggregationWorker)
.to receive(:perform_async).once
subject.destroy!
end
context 'when it is destroyed from the project level' do
it 'does not update the project statistics' do
expect(ProjectStatistics)
.not_to receive(:increment_statistic)
project.update!(pending_delete: true)
project.destroy!
end
it 'does not schedule a namespace statistics worker' do
expect(Namespaces::ScheduleAggregationWorker)
.not_to receive(:perform_async)
project.update!(pending_delete: true)
project.destroy!
end
end
end
end
def expect_flush_counter_increments_worker_performed
expect(FlushCounterIncrementsWorker)
.to receive(:perform_in)