diff options
47 files changed, 599 insertions, 248 deletions
diff --git a/.gitlab/ci/review.gitlab-ci.yml b/.gitlab/ci/review.gitlab-ci.yml index 235d02efa49..38d59af5aed 100644 --- a/.gitlab/ci/review.gitlab-ci.yml +++ b/.gitlab/ci/review.gitlab-ci.yml @@ -2,7 +2,7 @@ review-cleanup: extends: - .default-retry - .review:rules:review-cleanup - image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-helm3-kubectl1.14 + image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-helm3.5-kubectl1.17 stage: prepare environment: name: review/auto-cleanup diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 7cd6346ac01..76ae4aa70a8 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -ebabe4399c781ea1f4a1a43774b14489446f1f68 +b56e0680f4d85505205945c39c01aa641d37bd9b diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js index 58494c5a2b8..fd9b0160b0d 100644 --- a/app/assets/javascripts/api/analytics_api.js +++ b/app/assets/javascripts/api/analytics_api.js @@ -1,6 +1,8 @@ import axios from '~/lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; +const GROUP_VSA_PATH_BASE = + '/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id'; const PROJECT_VSA_PATH_BASE = '/:project_path/-/analytics/value_stream_analytics/value_streams'; const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`; @@ -13,6 +15,12 @@ const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => { return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath); }; +const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) => + buildApiUrl(GROUP_VSA_PATH_BASE) + .replace(':id', groupId) + .replace(':value_stream_id', valueStreamId) + .replace(':stage_id', stageId); + export const getProjectValueStreams = (projectPath) => { const url = buildProjectValueStreamPath(projectPath); return axios.get(url); @@ -30,3 +38,14 @@ export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) 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 + */ + +export const getValueStreamStageMedian = ({ groupId, valueStreamId, stageId }, params = {}) => { + const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId }); + return axios.get(`${stageBase}/median`, { params }); +}; diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 57cb220d9c9..615f96c3860 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -8,11 +8,24 @@ Vue.use(Translate); export default () => { const store = createStore(); const el = document.querySelector('#js-cycle-analytics'); - const { noAccessSvgPath, noDataSvgPath, requestPath, fullPath } = el.dataset; + const { + noAccessSvgPath, + noDataSvgPath, + requestPath, + fullPath, + projectId, + groupPath, + } = el.dataset; store.dispatch('initializeVsa', { + projectId: parseInt(projectId, 10), + groupPath, requestPath, fullPath, + features: { + cycleAnalyticsForGroups: + (groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false, + }, }); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index ab852cbbb2d..955f0c7271e 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -3,6 +3,7 @@ import { getProjectValueStreams, getProjectValueStreamStageData, getProjectValueStreamMetrics, + getValueStreamStageMedian, } from '~/api/analytics_api'; import createFlash from '~/flash'; import { __ } from '~/locale'; @@ -35,21 +36,33 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => { }; export const fetchValueStreams = ({ commit, dispatch, state }) => { - const { fullPath } = state; + const { + fullPath, + features: { cycleAnalyticsForGroups }, + } = state; commit(types.REQUEST_VALUE_STREAMS); + const stageRequests = ['setSelectedStage']; + if (cycleAnalyticsForGroups) { + stageRequests.push('fetchStageMedians'); + } + return getProjectValueStreams(fullPath) .then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) - .then(() => dispatch('setSelectedStage')) + .then(() => Promise.all(stageRequests.map((r) => dispatch(r)))) .catch(({ response: { status } }) => { commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); }); }; -export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, commit }) => { +export const fetchCycleAnalyticsData = ({ + state: { requestPath }, + getters: { legacyFilterParams }, + commit, +}) => { commit(types.REQUEST_CYCLE_ANALYTICS_DATA); - return getProjectValueStreamMetrics(requestPath, { 'cycle_analytics[start_date]': startDate }) + return getProjectValueStreamMetrics(requestPath, legacyFilterParams) .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data)) .catch(() => { commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR); @@ -59,13 +72,17 @@ export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, com }); }; -export const fetchStageData = ({ state: { requestPath, selectedStage, startDate }, commit }) => { +export const fetchStageData = ({ + state: { requestPath, selectedStage }, + getters: { legacyFilterParams }, + commit, +}) => { commit(types.REQUEST_STAGE_DATA); return getProjectValueStreamStageData({ requestPath, stageId: selectedStage.id, - params: { 'cycle_analytics[start_date]': startDate }, + params: legacyFilterParams, }) .then(({ data }) => { // when there's a query timeout, the request succeeds but the error is encoded in the response data @@ -78,6 +95,37 @@ export const fetchStageData = ({ state: { requestPath, selectedStage, startDate .catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR)); }; +const getStageMedians = ({ stageId, vsaParams, filterParams = {} }) => { + return getValueStreamStageMedian({ ...vsaParams, stageId }, filterParams).then(({ data }) => ({ + id: stageId, + value: data?.value || null, + })); +}; + +export const fetchStageMedians = ({ + state: { stages }, + getters: { requestParams: vsaParams, filterParams }, + commit, +}) => { + commit(types.REQUEST_STAGE_MEDIANS); + return Promise.all( + stages.map(({ id: stageId }) => + getStageMedians({ + vsaParams, + stageId, + filterParams, + }), + ), + ) + .then((data) => commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data)) + .catch((error) => { + commit(types.RECEIVE_STAGE_MEDIANS_ERROR, error); + createFlash({ + message: __('There was an error fetching median data for stages'), + }); + }); +}; + export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => { const stage = selectedStage || stages[0]; commit(types.SET_SELECTED_STAGE, stage); diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js index c60a70ef147..66971ea8a2e 100644 --- a/app/assets/javascripts/cycle_analytics/store/getters.js +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -1,3 +1,5 @@ +import dateFormat from 'dateformat'; +import { dateFormats } from '~/analytics/shared/constants'; import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils'; export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => { @@ -8,3 +10,30 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage selectedStage, }); }; + +export const requestParams = (state) => { + const { + selectedStage: { id: stageId = null }, + groupPath: groupId, + selectedValueStream: { id: valueStreamId }, + } = state; + return { valueStreamId, groupId, stageId }; +}; + +const dateRangeParams = ({ createdAfter, createdBefore }) => ({ + created_after: createdAfter ? dateFormat(createdAfter, dateFormats.isoDate) : null, + created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null, +}); + +export const legacyFilterParams = ({ startDate }) => { + return { + 'cycle_analytics[start_date]': startDate, + }; +}; + +export const filterParams = ({ id, ...rest }) => { + return { + project_ids: [id], + ...dateRangeParams(rest), + }; +}; diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js index 4f3d430ec9f..11ed62a4081 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js @@ -20,3 +20,7 @@ export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA'; export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS'; export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR'; + +export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS'; +export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS'; +export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR'; diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js index 0ae80116cd2..a8b7a607b66 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -1,11 +1,23 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { decorateData, decorateEvents, formatMedianValues } from '../utils'; +import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; +import { + decorateData, + decorateEvents, + formatMedianValues, + calculateFormattedDayInPast, +} from '../utils'; import * as types from './mutation_types'; export default { - [types.INITIALIZE_VSA](state, { requestPath, fullPath }) { + [types.INITIALIZE_VSA](state, { requestPath, fullPath, groupPath, projectId, features }) { state.requestPath = requestPath; state.fullPath = fullPath; + state.groupPath = groupPath; + state.id = projectId; + 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; @@ -18,6 +30,9 @@ export default { }, [types.SET_DATE_RANGE](state, { startDate }) { state.startDate = startDate; + const { now, past } = calculateFormattedDayInPast(startDate); + state.createdBefore = now; + state.createdAfter = past; }, [types.REQUEST_VALUE_STREAMS](state) { state.valueStreams = []; @@ -46,17 +61,25 @@ export default { [types.REQUEST_CYCLE_ANALYTICS_DATA](state) { state.isLoading = true; state.hasError = false; + if (!state.features.cycleAnalyticsForGroups) { + state.medians = {}; + } }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { const { summary, medians } = decorateData(data); + if (!state.features.cycleAnalyticsForGroups) { + state.medians = formatMedianValues(medians); + } state.permissions = data.permissions; state.summary = summary; - state.medians = formatMedianValues(medians); state.hasError = false; }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { state.isLoading = false; state.hasError = true; + if (!state.features.cycleAnalyticsForGroups) { + state.medians = {}; + } }, [types.REQUEST_STAGE_DATA](state) { state.isLoadingStage = true; @@ -78,4 +101,13 @@ export default { state.hasError = true; state.selectedStageError = error; }, + [types.REQUEST_STAGE_MEDIANS](state) { + state.medians = {}; + }, + [types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians) { + state.medians = formatMedianValues(medians); + }, + [types.RECEIVE_STAGE_MEDIANS_ERROR](state) { + state.medians = {}; + }, }; diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js index 02f953d9517..4d61077fb99 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/cycle_analytics/store/state.js @@ -1,9 +1,13 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; export default () => ({ + features: {}, + id: null, requestPath: '', fullPath: '', startDate: DEFAULT_DAYS_TO_DISPLAY, + createdAfter: null, + createdBefore: null, stages: [], summary: [], analytics: [], @@ -19,4 +23,5 @@ export default () => ({ isLoadingStage: false, isEmptyStage: false, permissions: {}, + parentPath: null, }); diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index 40ad7d8b2fc..a1690dd1513 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -1,6 +1,9 @@ +import dateFormat from 'dateformat'; import { unescape } from 'lodash'; +import { dateFormats } from '~/analytics/shared/constants'; import { sanitize } from '~/lib/dompurify'; import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility'; import { parseSeconds } from '~/lib/utils/datetime_utility'; import { s__, sprintf } from '../locale'; import DEFAULT_EVENT_OBJECTS from './default_event_objects'; @@ -115,3 +118,20 @@ export const formatMedianValues = (medians = []) => export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => stages.filter(({ hidden = false }) => hidden === isHidden); + +const toIsoFormat = (d) => dateFormat(d, dateFormats.isoDate); + +/** + * Takes an integer specifying the number of days to subtract + * from the date specified will return the 2 dates, formatted as ISO dates + * + * @param {Number} daysInPast - Number of days in the past to subtract + * @param {Date} [today=new Date] - Date to subtract days from, defaults to today + * @returns {Object} Returns 'now' and the 'past' date formatted as ISO dates + */ +export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => { + return { + now: toIsoFormat(today), + past: toIsoFormat(getDateInPast(today, daysInPast)), + }; +}; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index eaf396a7a59..5ee00464a8b 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -421,3 +421,61 @@ export const isValidSha1Hash = (str) => { export function insertFinalNewline(content, endOfLine = '\n') { return content.slice(-endOfLine.length) !== endOfLine ? `${content}${endOfLine}` : content; } + +export const markdownConfig = { + // allowedTags from GitLab's inline HTML guidelines + // https://docs.gitlab.com/ee/user/markdown.html#inline-html + ALLOWED_TAGS: [ + 'a', + 'abbr', + 'b', + 'blockquote', + 'br', + 'code', + 'dd', + 'del', + 'div', + 'dl', + 'dt', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'i', + 'img', + 'ins', + 'kbd', + 'li', + 'ol', + 'p', + 'pre', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'span', + 'strike', + 'strong', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + 'tt', + 'ul', + 'var', + ], + ALLOWED_ATTR: ['class', 'style', 'href', 'src'], + ALLOW_DATA_ATTR: false, +}; diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index a7fcce02ab3..0f4cec67ce8 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -3,7 +3,7 @@ import katex from 'katex'; import marked from 'marked'; import { sanitize } from '~/lib/dompurify'; -import { hasContent } from '~/lib/utils/text_utility'; +import { hasContent, markdownConfig } from '~/lib/utils/text_utility'; import Prompt from './prompt.vue'; const renderer = new marked.Renderer(); @@ -140,63 +140,7 @@ export default { markdown() { renderer.attachments = this.cell.attachments; - return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), { - // allowedTags from GitLab's inline HTML guidelines - // https://docs.gitlab.com/ee/user/markdown.html#inline-html - ALLOWED_TAGS: [ - 'a', - 'abbr', - 'b', - 'blockquote', - 'br', - 'code', - 'dd', - 'del', - 'div', - 'dl', - 'dt', - 'em', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'hr', - 'i', - 'img', - 'ins', - 'kbd', - 'li', - 'ol', - 'p', - 'pre', - 'q', - 'rp', - 'rt', - 'ruby', - 's', - 'samp', - 'span', - 'strike', - 'strong', - 'sub', - 'summary', - 'sup', - 'table', - 'tbody', - 'td', - 'tfoot', - 'th', - 'thead', - 'tr', - 'tt', - 'ul', - 'var', - ], - ALLOWED_ATTR: ['class', 'style', 'href', 'src'], - ALLOW_DATA_ATTR: false, - }); + return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), markdownConfig); }, }, }; diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index c91ae1ed14e..5ea431224ce 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -13,6 +13,7 @@ import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_ import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; +import { renderMarkdown } from '../utils'; import { getStartLineNumber, getEndLineNumber, @@ -300,7 +301,7 @@ export default { this.isRequesting = true; this.oldContent = this.note.note_html; // eslint-disable-next-line vue/no-mutating-props - this.note.note_html = escape(noteText); + this.note.note_html = renderMarkdown(noteText); this.updateNote(data) .then(() => { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 7bd8d28282c..6a4a3263e4a 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -312,25 +312,23 @@ export const saveNote = ({ commit, dispatch }, noteData) => { $('.notes-form .flash-container').hide(); // hide previous flash notification commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders - if (replyId) { - if (hasQuickActions) { - placeholderText = utils.stripQuickActions(placeholderText); - } + if (hasQuickActions) { + placeholderText = utils.stripQuickActions(placeholderText); + } - if (placeholderText.length) { - commit(types.SHOW_PLACEHOLDER_NOTE, { - noteBody: placeholderText, - replyId, - }); - } + if (placeholderText.length) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + noteBody: placeholderText, + replyId, + }); + } - if (hasQuickActions) { - commit(types.SHOW_PLACEHOLDER_NOTE, { - isSystemNote: true, - noteBody: utils.getQuickActionText(note), - replyId, - }); - } + if (hasQuickActions) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + isSystemNote: true, + noteBody: utils.getQuickActionText(note), + replyId, + }); } const processQuickActions = (res) => { @@ -400,9 +398,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }; const removePlaceholder = (res) => { - if (replyId) { - commit(types.REMOVE_PLACEHOLDER_NOTES); - } + commit(types.REMOVE_PLACEHOLDER_NOTES); return res; }; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index b04b1d28ffa..956221d69ae 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -279,7 +279,7 @@ export const getDiscussion = (state) => (discussionId) => export const commentsDisabled = (state) => state.commentsDisabled; export const suggestionsCount = (state, getters) => - Object.values(getters.notesById).filter((n) => n.suggestions.length).length; + Object.values(getters.notesById).filter((n) => n.suggestions?.length).length; export const hasDrafts = (state, getters, rootState, rootGetters) => Boolean(rootGetters['batchComments/hasDrafts']); diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js index 7966a884eab..ec18a570960 100644 --- a/app/assets/javascripts/notes/utils.js +++ b/app/assets/javascripts/notes/utils.js @@ -1,4 +1,7 @@ /* eslint-disable @gitlab/require-i18n-strings */ +import marked from 'marked'; +import { sanitize } from '~/lib/dompurify'; +import { markdownConfig } from '~/lib/utils/text_utility'; /** * Tracks snowplow event when User toggles timeline view @@ -10,3 +13,7 @@ export const trackToggleTimelineView = (enabled) => ({ label: 'Status', property: enabled, }); + +export const renderMarkdown = (rawMarkdown) => { + return sanitize(marked(rawMarkdown), markdownConfig); +}; diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 69afd711797..d6501a37a35 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -16,12 +16,15 @@ * :note="{body: 'This is a note'}" * /> */ +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import { renderMarkdown } from '~/notes/utils'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import userAvatarLink from '../user_avatar/user_avatar_link.vue'; export default { name: 'PlaceholderNote', + directives: { SafeHtml }, components: { userAvatarLink, TimelineEntryItem, @@ -34,6 +37,9 @@ export default { }, computed: { ...mapGetters(['getUserData']), + renderedNote() { + return renderMarkdown(this.note.body); + }, }, }; </script> @@ -57,9 +63,7 @@ export default { </div> </div> <div class="note-body"> - <div class="note-text md"> - <p>{{ note.body }}</p> - </div> + <div v-safe-html="renderedNote" class="note-text md"></div> </div> </div> </timeline-entry-item> diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index d1d27286c68..3142ef0f404 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -13,6 +13,10 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController feature_category :planning_analytics + before_action do + push_licensed_feature(:cycle_analytics_for_groups) if project.licensed_feature_available?(:cycle_analytics_for_groups) + end + def show @cycle_analytics = Analytics::CycleAnalytics::ProjectLevel.new(project: @project, options: options(cycle_analytics_project_params)) diff --git a/app/graphql/types/user_callout_type.rb b/app/graphql/types/user_callout_type.rb index 12f4fdea878..0ff32d68400 100644 --- a/app/graphql/types/user_callout_type.rb +++ b/app/graphql/types/user_callout_type.rb @@ -4,7 +4,7 @@ module Types class UserCalloutType < BaseObject # rubocop:disable Graphql/AuthorizeTypes graphql_name 'UserCallout' - field :feature_name, UserCalloutFeatureNameEnum, null: false, + field :feature_name, UserCalloutFeatureNameEnum, null: true, description: 'Name of the feature that the callout is for.' field :dismissed_at, Types::TimeType, null: true, description: 'Date when the callout was dismissed.' diff --git a/app/models/wiki.rb b/app/models/wiki.rb index a58994f8c86..e114e30d589 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -87,7 +87,8 @@ class Wiki end def create_wiki_repository - change_head_to_default_branch if repository.create_if_not_exists + repository.create_if_not_exists + change_head_to_default_branch raise CouldNotCreateWikiError unless repository_exists? rescue StandardError => err @@ -249,7 +250,7 @@ class Wiki override :default_branch def default_branch - super || wiki.class.default_ref(container) + super || Gitlab::Git::Wiki.default_ref(container) end def wiki_base_path @@ -323,6 +324,12 @@ class Wiki end def change_head_to_default_branch + # If the wiki has commits in the 'HEAD' branch means that the current + # HEAD is pointing to the right branch. If not, it could mean that either + # the repo has just been created or that 'HEAD' is pointing + # to the wrong branch and we need to rewrite it + return if repository.raw_repository.commit_count('HEAD') != 0 + repository.raw_repository.write_ref('HEAD', "refs/heads/#{default_branch}") end end diff --git a/app/services/wikis/create_attachment_service.rb b/app/services/wikis/create_attachment_service.rb index 82179459345..88a593cce48 100644 --- a/app/services/wikis/create_attachment_service.rb +++ b/app/services/wikis/create_attachment_service.rb @@ -21,7 +21,11 @@ module Wikis end def create_commit! + wiki.create_wiki_repository + commit_result(create_transformed_commit(@file_content)) + rescue Wiki::CouldNotCreateWikiError + raise_error("Error creating the wiki repository") end private diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index c1f6cfc40c3..3c9762e200a 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,6 +1,6 @@ - page_title _("Value Stream Analytics") - add_page_specific_style 'page_bundles/cycle_analytics' - svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") } -- initial_data = { request_path: project_cycle_analytics_path(@project), full_path: @project.full_path }.merge!(svgs) +- initial_data = { project_id: @project.id, group_path: @project.group&.path, request_path: project_cycle_analytics_path(@project), full_path: @project.full_path }.merge!(svgs) #js-cycle-analytics{ data: initial_data } diff --git a/config/feature_flags/development/trace_memory_allocations.yml b/config/feature_flags/development/trace_memory_allocations.yml deleted file mode 100644 index 3760ceded43..00000000000 --- a/config/feature_flags/development/trace_memory_allocations.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: trace_memory_allocations -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52306 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/299524 -milestone: '13.9' -type: development -group: group::memory -default_enabled: true diff --git a/doc/administration/lfs/index.md b/doc/administration/lfs/index.md index 793edf8322a..edf0e324a5c 100644 --- a/doc/administration/lfs/index.md +++ b/doc/administration/lfs/index.md @@ -321,6 +321,29 @@ end See more information in [!19581](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/19581) +### LFS commands fail on TLS v1.3 server + +If you configure GitLab to [disable TLS v1.2](https://docs.gitlab.com/omnibus/settings/nginx.md) +and only enable TLS v1.3 connections, LFS operations require a +[Git LFS client](https://git-lfs.github.com) version 2.11.0 or later. If you use +a Git LFS client earlier than version 2.11.0, GitLab displays an error: + +```plaintext +batch response: Post https://username:***@gitlab.example.com/tool/releases.git/info/lfs/objects/batch: remote error: tls: protocol version not supported +error: failed to fetch some objects from 'https://username:[MASKED]@gitlab.example.com/tool/releases.git/info/lfs' +``` + +When using GitLab CI over a TLS v1.3 configured GitLab server, you must +[upgrade to GitLab Runner](https://docs.gitlab.com/runner/install/index.md) 13.2.0 +or later to receive an updated Git LFS client version via +the included [GitLab Runner Helper image](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#helper-image). + +To check an installed Git LFS client's version, run this command: + +```shell +git lfs version +``` + ## Known limitations - Support for removing unreferenced LFS objects was added in 8.14 onward. diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index de07d601c4f..f452639d254 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -13389,7 +13389,7 @@ Represents a recorded measurement (object count) for the Admins. | Name | Type | Description | | ---- | ---- | ----------- | | <a id="usercalloutdismissedat"></a>`dismissedAt` | [`Time`](#time) | Date when the callout was dismissed. | -| <a id="usercalloutfeaturename"></a>`featureName` | [`UserCalloutFeatureNameEnum!`](#usercalloutfeaturenameenum) | Name of the feature that the callout is for. | +| <a id="usercalloutfeaturename"></a>`featureName` | [`UserCalloutFeatureNameEnum`](#usercalloutfeaturenameenum) | Name of the feature that the callout is for. | ### `UserCore` diff --git a/doc/development/fe_guide/frontend_faq.md b/doc/development/fe_guide/frontend_faq.md index 6b9d5ace4e6..1e8f7f5fb81 100644 --- a/doc/development/fe_guide/frontend_faq.md +++ b/doc/development/fe_guide/frontend_faq.md @@ -157,7 +157,7 @@ export const fetchFoos = ({ state }) => { Sometimes it's necessary to test locally what the frontend production build would produce, to do so the steps are: 1. Stop webpack: `gdk stop webpack`. -1. Open `gitlab.yaml` located in your `gitlab` installation folder, scroll down to the `webpack` section and change `dev_server` to `enabled: false`. +1. Open `gitlab.yaml` located in `gitlab/config` folder, scroll down to the `webpack` section, and change `dev_server` to `enabled: false`. 1. Run `yarn webpack-prod && gdk restart rails-web`. The production build takes a few minutes to be completed. Any code changes at this point are diff --git a/doc/development/multi_version_compatibility.md b/doc/development/multi_version_compatibility.md index 8e49afda579..a12c01c734a 100644 --- a/doc/development/multi_version_compatibility.md +++ b/doc/development/multi_version_compatibility.md @@ -115,12 +115,24 @@ For major or minor version updates of Rails or Puma: ### Feature flags -One way to handle this is to use a feature flag that is disabled by -default. The feature flag can be enabled when the deployment is in a -consistent state. However, this method of synchronization **does not -guarantee** that customers with on-premise instances can [update with -zero downtime](https://docs.gitlab.com/omnibus/update/#zero-downtime-updates) -because point releases bundle many changes together. +[Feature flags](feature_flags/index.md) are a tool, not a strategy, for handling backward compatibility problems. + +For example, it is safe to add a new feature with frontend and API changes, if both +frontend and API changes are disabled by default. This can be done with multiple +merge requests, merged in any order. After all the changes are deployed to +GitLab.com, the feature can be enabled in ChatOps and validated on GitLab.com. + +**However, it is not necessarily safe to enable the feature by default.** If the +feature flag is removed, or the default is flipped to enabled, in the same release +where the code was merged, then customers performing [zero-downtime updates](https://docs.gitlab.com/omnibus/update/#zero-downtime-updates) +will end up running the new frontend code against the previous release's API. + +If you're not sure whether it's safe to enable all the changes at once, then one +option is to enable the API in the **current** release and enable the frontend +change in the **next** release. This is an example of the [Expand and contract pattern](#expand-and-contract-pattern). + +Or you may be able to avoid delaying by a release by modifying the frontend to +[degrade gracefully](#graceful-degradation) against the previous release's API. ### Graceful degradation diff --git a/doc/user/clusters/agent/ci_cd_tunnel.md b/doc/user/clusters/agent/ci_cd_tunnel.md index 3cafdabf909..67024f0a816 100644 --- a/doc/user/clusters/agent/ci_cd_tunnel.md +++ b/doc/user/clusters/agent/ci_cd_tunnel.md @@ -6,7 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w # CI/CD Tunnel -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/327409) in GitLab 14.0. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/327409) in GitLab 14.1. The CI/CD Tunnel enables users to access Kubernetes clusters from GitLab CI/CD jobs even if there is no network connectivity between GitLab Runner and a cluster. In the current iteration, only CI/CD jobs in the Configuration project @@ -21,7 +21,7 @@ Prerequisistes: To create the Tunnel: -1. In your `.gitlab-ci.yml` add a section that creates a `kubectl` compatible configuration file and use it in one +1. In your `.gitlab-ci.yml` add a section that creates a `kubectl` compatible configuration file (`kubecontext`) and use it in one or more jobs: ```yaml diff --git a/doc/user/clusters/integrations.md b/doc/user/clusters/integrations.md index dc8e75edf68..d2861ce2946 100644 --- a/doc/user/clusters/integrations.md +++ b/doc/user/clusters/integrations.md @@ -119,7 +119,7 @@ wget https://gitlab.com/gitlab-org/project-templates/cluster-management/-/raw/ma helm repo add gitlab https://charts.gitlab.io # Install Elastic Stack -helm install prometheus gitlab/elastic-stack -n gitlab-managed-apps --values values.yaml +helm install elastic-stack gitlab/elastic-stack -n gitlab-managed-apps --values values.yaml ``` ### Enable Elastic Stack integration for your cluster @@ -134,6 +134,6 @@ To enable the Elastic Stack integration for your cluster: - For an [instance-level cluster](../instance/clusters/index.md), navigate to your instance's **Kubernetes** page. 1. Select the **Integrations** tab. -1. Check the **Enable Prometheus integration** checkbox. +1. Check the **Enable Elastic Stack integration** checkbox. 1. Click **Save changes**. 1. Go to the **Health** tab to see your cluster's metrics. diff --git a/doc/user/project/import/fogbugz.md b/doc/user/project/import/fogbugz.md index d3d77f16200..982bc6d90e8 100644 --- a/doc/user/project/import/fogbugz.md +++ b/doc/user/project/import/fogbugz.md @@ -7,31 +7,24 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Import your project from FogBugz to GitLab **(FREE)** -It only takes a few simple steps to import your project from FogBugz. -The importer imports all your cases and comments with original case -numbers and timestamps. You can also map FogBugz users to GitLab users. +Using the importer, you can import your FogBugz project to GitLab.com +or to your self-managed GitLab instance. -Follow these steps to import your project from FogBugz: +The importer imports all of your cases and comments with the original +case numbers and timestamps. You can also map FogBugz users to GitLab +users. -1. From your GitLab dashboard, select **New project**. +To import your project from FogBugz: +1. From your GitLab dashboard, select **New project**. 1. Select the **FogBugz** button. - - ![FogBugz](img/fogbugz_import_select_fogbogz.png) - + ![FogBugz](img/fogbugz_import_select_fogbogz.png) 1. Enter your FogBugz URL, email address, and password. - - ![Login](img/fogbugz_import_login.png) - -1. Create mapping from FogBugz users to GitLab users. - - ![User Map](img/fogbugz_import_user_map.png) - -1. Select the projects you wish to import by selecting the **Import** buttons. - - ![Import Project](img/fogbugz_import_select_project.png) - -1. Once the import finishes, click the link to go to the project + ![Login](img/fogbugz_import_login.png) +1. Create a mapping from FogBugz users to GitLab users. + ![User Map](img/fogbugz_import_user_map.png) +1. Select **Import** for the projects you want to import. + ![Import Project](img/fogbugz_import_select_project.png) +1. After the import finishes, click the link to go to the project dashboard. Follow the directions to push your existing repository. - - ![Finished](img/fogbugz_import_finished.png) + ![Finished](img/fogbugz_import_finished.png) diff --git a/lib/api/ci/runners.rb b/lib/api/ci/runners.rb index d46b3a27e72..7f755b1a4d4 100644 --- a/lib/api/ci/runners.rb +++ b/lib/api/ci/runners.rb @@ -7,7 +7,7 @@ module API before { authenticate! } - feature_category :continuous_integration + feature_category :runner resource :runners do desc 'Get runners available for user' do diff --git a/lib/gitlab/memory/instrumentation.rb b/lib/gitlab/memory/instrumentation.rb index e800fe14cf1..4e93d6a9ece 100644 --- a/lib/gitlab/memory/instrumentation.rb +++ b/lib/gitlab/memory/instrumentation.rb @@ -21,24 +21,13 @@ module Gitlab Thread.current.respond_to?(:memory_allocations) end - # This method changes a global state - def self.ensure_feature_flag! + def self.start_thread_memory_allocations return unless available? - enabled = Feature.enabled?(:trace_memory_allocations, default_enabled: true) - return if enabled == Thread.trace_memory_allocations - MUTEX.synchronize do - # This enables or disables feature dynamically - # based on a feature flag - Thread.trace_memory_allocations = enabled + # This method changes a global state + Thread.trace_memory_allocations = true end - end - - def self.start_thread_memory_allocations - return unless available? - - ensure_feature_flag! # it will return `nil` if disabled Thread.current.memory_allocations diff --git a/lib/gitlab/usage_data_counters/hll_redis_counter.rb b/lib/gitlab/usage_data_counters/hll_redis_counter.rb index 2a231f8fce0..597df9936ea 100644 --- a/lib/gitlab/usage_data_counters/hll_redis_counter.rb +++ b/lib/gitlab/usage_data_counters/hll_redis_counter.rb @@ -117,7 +117,7 @@ module Gitlab private def track(values, event_name, context: '', time: Time.zone.now) - return unless Gitlab::CurrentSettings.usage_ping_enabled? + return unless usage_ping_enabled? event = event_for(event_name) Gitlab::ErrorTracking.track_and_raise_for_dev_exception(UnknownEvent.new("Unknown event #{event_name}")) unless event.present? @@ -131,6 +131,10 @@ module Gitlab Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) end + def usage_ping_enabled? + Gitlab::CurrentSettings.usage_ping_enabled? + end + # The array of valid context on which we allow tracking def valid_context_list Plan.all_plans diff --git a/package.json b/package.json index 3238a8b1f4b..b8773f4329a 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@gitlab/favicon-overlay": "2.0.0", "@gitlab/svgs": "1.202.0", "@gitlab/tributejs": "1.0.0", - "@gitlab/ui": "31.3.0", + "@gitlab/ui": "31.5.0", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "6.1.3-2", "@rails/ujs": "6.1.3-2", diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb index 1fcb40df491..c646698219b 100644 --- a/spec/features/merge_request/batch_comments_spec.rb +++ b/spec/features/merge_request/batch_comments_spec.rb @@ -13,6 +13,8 @@ RSpec.describe 'Merge request > Batch comments', :js do end before do + stub_feature_flags(paginated_notes: false) + project.add_maintainer(user) sign_in(user) diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js index 9b6385dc523..4e6471d5f7b 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -174,6 +174,15 @@ export const stageMedians = { staging: 388800, }; +export const formattedStageMedians = { + issue: '2d', + plan: '1d', + review: '1w', + code: '1d', + test: '3d', + staging: '4d', +}; + export const allowedStages = [issueStage, planStage, codeStage]; export const transformedProjectStagePathData = [ diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 4f37e1266fb..8a8dd374f8e 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/cycle_analytics/store/actions'; import httpStatusCodes from '~/lib/utils/http_status'; -import { selectedStage, selectedValueStream } from '../mock_data'; +import { allowedStages, selectedStage, selectedValueStream } from '../mock_data'; const mockRequestPath = 'some/cool/path'; const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; @@ -25,6 +25,10 @@ const mockRequestedDataMutations = [ }, ]; +const features = { + cycleAnalyticsForGroups: true, +}; + describe('Project Value Stream Analytics actions', () => { let state; let mock; @@ -175,6 +179,7 @@ describe('Project Value Stream Analytics actions', () => { beforeEach(() => { state = { + features, fullPath: mockFullPath, }; mock = new MockAdapter(axios); @@ -187,9 +192,33 @@ describe('Project Value Stream Analytics actions', () => { state, payload: {}, expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }], - expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }], + expectedActions: [ + { type: 'receiveValueStreamsSuccess' }, + { type: 'setSelectedStage' }, + { type: 'fetchStageMedians' }, + ], })); + describe('with cycleAnalyticsForGroups=false', () => { + beforeEach(() => { + state = { + features: { cycleAnalyticsForGroups: false }, + fullPath: mockFullPath, + }; + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); + }); + + it("does not dispatch the 'fetchStageMedians' request", () => + testAction({ + action: actions.fetchValueStreams, + state, + payload: {}, + expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }], + expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }], + })); + }); + describe('with a failing request', () => { beforeEach(() => { mock = new MockAdapter(axios); @@ -280,4 +309,59 @@ describe('Project Value Stream Analytics actions', () => { })); }); }); + + describe('fetchStageMedians', () => { + const mockValueStreamPath = /median/; + + const stageMediansPayload = [ + { id: 'issue', value: null }, + { id: 'plan', value: null }, + { id: 'code', value: null }, + ]; + + const stageMedianError = new Error( + `Request failed with status code ${httpStatusCodes.BAD_REQUEST}`, + ); + + beforeEach(() => { + state = { + fullPath: mockFullPath, + selectedValueStream, + stages: allowedStages, + }; + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); + }); + + it(`commits the 'REQUEST_STAGE_MEDIANS' and 'RECEIVE_STAGE_MEDIANS_SUCCESS' mutations`, () => + testAction({ + action: actions.fetchStageMedians, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_STAGE_MEDIANS' }, + { type: 'RECEIVE_STAGE_MEDIANS_SUCCESS', payload: stageMediansPayload }, + ], + expectedActions: [], + })); + + describe('with a failing request', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST); + }); + + it(`commits the 'RECEIVE_VALUE_STREAM_STAGES_ERROR' mutation`, () => + testAction({ + action: actions.fetchStageMedians, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_STAGE_MEDIANS' }, + { type: 'RECEIVE_STAGE_MEDIANS_ERROR', payload: stageMedianError }, + ], + expectedActions: [], + })); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 88e1a13f506..77b19280517 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -1,3 +1,5 @@ +import { useFakeDate } from 'helpers/fake_date'; +import { DEFAULT_DAYS_TO_DISPLAY } from '~/cycle_analytics/constants'; import * as types from '~/cycle_analytics/store/mutation_types'; import mutations from '~/cycle_analytics/store/mutations'; import { @@ -9,15 +11,23 @@ import { selectedValueStream, rawValueStreamStages, valueStreamStages, + rawStageMedians, + formattedStageMedians, } from '../mock_data'; let state; const mockRequestPath = 'fake/request/path'; -const mockStartData = '2021-04-20'; +const mockCreatedAfter = '2020-06-18'; +const mockCreatedBefore = '2020-07-18'; +const features = { + cycleAnalyticsForGroups: true, +}; describe('Project Value Stream Analytics mutations', () => { + useFakeDate(2020, 6, 18); + beforeEach(() => { - state = {}; + state = { features }; }); afterEach(() => { @@ -46,6 +56,8 @@ describe('Project Value Stream Analytics mutations', () => { ${types.RECEIVE_STAGE_DATA_ERROR} | ${'selectedStageEvents'} | ${[]} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'hasError'} | ${true} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} + ${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}} + ${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}} `('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => { mutations[mutation](state, {}); @@ -53,15 +65,19 @@ describe('Project Value Stream Analytics mutations', () => { }); it.each` - mutation | payload | stateKey | value - ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} - ${types.SET_DATE_RANGE} | ${{ startDate: mockStartData }} | ${'startDate'} | ${mockStartData} - ${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} + mutation | payload | stateKey | value + ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} + ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'startDate'} | ${DEFAULT_DAYS_TO_DISPLAY} + ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdAfter'} | ${mockCreatedAfter} + ${types.SET_DATE_RANGE} | ${{ startDate: 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_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} + ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} `( '$mutation with $payload will set $stateKey to $value', ({ mutation, payload, stateKey, value }) => { @@ -92,4 +108,35 @@ describe('Project Value Stream Analytics mutations', () => { }, ); }); + + describe('with cycleAnalyticsForGroups=false', () => { + useFakeDate(2020, 6, 18); + + beforeEach(() => { + state = { features: { cycleAnalyticsForGroups: false } }; + }); + + const formattedMedians = { + code: '2d', + issue: '-', + plan: '21h', + review: '-', + staging: '2d', + test: '4h', + }; + + it.each` + mutation | payload | stateKey | value + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'medians'} | ${formattedMedians} + ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${{}} | ${'medians'} | ${{}} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${{}} | ${'medians'} | ${{}} + `( + '$mutation with $payload will set $stateKey to $value', + ({ mutation, payload, stateKey, value }) => { + mutations[mutation](state, payload); + + expect(state).toMatchObject({ [stateKey]: value }); + }, + ); + }); }); diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js index 15137bb0571..1fecdfc0539 100644 --- a/spec/frontend/cycle_analytics/utils_spec.js +++ b/spec/frontend/cycle_analytics/utils_spec.js @@ -1,3 +1,4 @@ +import { useFakeDate } from 'helpers/fake_date'; import { decorateEvents, decorateData, @@ -6,6 +7,7 @@ import { medianTimeToParsedSeconds, formatMedianValues, filterStagesByHiddenStatus, + calculateFormattedDayInPast, } from '~/cycle_analytics/utils'; import { selectedStage, @@ -149,4 +151,12 @@ describe('Value stream analytics utils', () => { expect(filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result); }); }); + + describe('calculateFormattedDayInPast', () => { + useFakeDate(1815, 11, 10); + + it('will return 2 dates, now and past', () => { + expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' }); + }); + }); }); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index 7444c441e06..f217dfd2e48 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -1,5 +1,4 @@ import { mount } from '@vue/test-utils'; -import { escape } from 'lodash'; import Vue from 'vue'; import Vuex from 'vuex'; @@ -263,7 +262,9 @@ describe('issue_note', () => { await waitForPromises(); expect(alertSpy).not.toHaveBeenCalled(); - expect(wrapper.vm.note.note_html).toBe(escape(noteBody)); + expect(wrapper.vm.note.note_html).toBe( + '<p><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"></p>\n', + ); }); }); @@ -291,7 +292,7 @@ describe('issue_note', () => { await wrapper.vm.$nextTick(); let noteBodyProps = noteBody.props(); - expect(noteBodyProps.note.note_html).toBe(updatedText); + expect(noteBodyProps.note.note_html).toBe(`<p>${updatedText}</p>\n`); noteBody.vm.$emit('cancelForm'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap index f3ce03796f9..5e956d66b6a 100644 --- a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap @@ -55,6 +55,8 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` <p> Foo </p> + + </div> </div> </div> diff --git a/spec/lib/gitlab/instrumentation_helper_spec.rb b/spec/lib/gitlab/instrumentation_helper_spec.rb index e6cc0174642..48fcc9f93db 100644 --- a/spec/lib/gitlab/instrumentation_helper_spec.rb +++ b/spec/lib/gitlab/instrumentation_helper_spec.rb @@ -99,23 +99,6 @@ RSpec.describe Gitlab::InstrumentationHelper do :mem_mallocs ) end - - context 'when trace_memory_allocations is disabled' do - before do - stub_feature_flags(trace_memory_allocations: false) - Gitlab::Memory::Instrumentation.ensure_feature_flag! - end - - it 'does not log memory usage metrics' do - subject - - expect(payload).not_to include( - :mem_objects, - :mem_bytes, - :mem_mallocs - ) - end - end end context 'when load balancing is enabled' do diff --git a/spec/lib/gitlab/memory/instrumentation_spec.rb b/spec/lib/gitlab/memory/instrumentation_spec.rb index 0dbe9a8e275..069c45da18a 100644 --- a/spec/lib/gitlab/memory/instrumentation_spec.rb +++ b/spec/lib/gitlab/memory/instrumentation_spec.rb @@ -18,24 +18,8 @@ RSpec.describe Gitlab::Memory::Instrumentation do describe '.start_thread_memory_allocations' do subject { described_class.start_thread_memory_allocations } - context 'when feature flag trace_memory_allocations is enabled' do - before do - stub_feature_flags(trace_memory_allocations: true) - end - - it 'a hash is returned' do - is_expected.not_to be_empty - end - end - - context 'when feature flag trace_memory_allocations is disabled' do - before do - stub_feature_flags(trace_memory_allocations: false) - end - - it 'a nil is returned' do - is_expected.to be_nil - end + it 'a hash is returned' do + is_expected.to be_a(Hash) end context 'when feature is unavailable' do @@ -63,30 +47,14 @@ RSpec.describe Gitlab::Memory::Instrumentation do expect(described_class).to receive(:measure_thread_memory_allocations).and_call_original end - context 'when feature flag trace_memory_allocations is enabled' do - before do - stub_feature_flags(trace_memory_allocations: true) - end - - it 'a hash is returned' do - result = subject - expect(result).to include( - mem_objects: be > 1000, - mem_mallocs: be > 1000, - mem_bytes: be > 100_000, # 100 items * 100 bytes each - mem_total_bytes: eq(result[:mem_bytes] + 40 * result[:mem_objects]) - ) - end - end - - context 'when feature flag trace_memory_allocations is disabled' do - before do - stub_feature_flags(trace_memory_allocations: false) - end - - it 'a nil is returned' do - is_expected.to be_nil - end + it 'a hash is returned' do + result = subject + expect(result).to include( + mem_objects: be > 1000, + mem_mallocs: be > 1000, + mem_bytes: be > 100_000, # 100 items * 100 bytes each + mem_total_bytes: eq(result[:mem_bytes] + 40 * result[:mem_objects]) + ) end context 'when feature is unavailable' do diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb index 17b2c7b38e1..6c356f685ec 100644 --- a/spec/services/event_create_service_spec.rb +++ b/spec/services/event_create_service_spec.rb @@ -318,7 +318,8 @@ RSpec.describe EventCreateService do specify { expect { result }.to change { Event.count }.by(8) } - specify { expect { result }.not_to exceed_query_limit(1) } + # An addditional query due to event tracking + specify { expect { result }.not_to exceed_query_limit(2) } it 'creates 3 created design events' do ids = result.pluck('id') @@ -347,7 +348,8 @@ RSpec.describe EventCreateService do specify { expect { result }.to change { Event.count }.by(5) } - specify { expect { result }.not_to exceed_query_limit(1) } + # An addditional query due to event tracking + specify { expect { result }.not_to exceed_query_limit(2) } it 'creates 5 destroyed design events' do ids = result.pluck('id') diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb index 06bab24bb0e..bc5956e3eec 100644 --- a/spec/support/shared_examples/models/wiki_shared_examples.rb +++ b/spec/support/shared_examples/models/wiki_shared_examples.rb @@ -575,18 +575,21 @@ RSpec.shared_examples 'wiki model' do end describe '#create_wiki_repository' do + let(:head_path) { Rails.root.join(TestEnv.repos_path, "#{wiki.disk_path}.git", 'HEAD') } + let(:default_branch) { 'foo' } + + before do + allow(Gitlab::CurrentSettings).to receive(:default_branch_name).and_return(default_branch) + end + subject { wiki.create_wiki_repository } context 'when repository is not created' do let(:wiki_container) { wiki_container_without_repo } - let(:head_path) { Rails.root.join(TestEnv.repos_path, "#{wiki.disk_path}.git", 'HEAD') } - let(:default_branch) { 'foo' } it 'changes the HEAD reference to the default branch' do expect(wiki.empty?).to eq true - allow(Gitlab::CurrentSettings).to receive(:default_branch_name).and_return(default_branch) - subject expect(File.read(head_path).squish).to eq "ref: refs/heads/#{default_branch}" @@ -596,23 +599,36 @@ RSpec.shared_examples 'wiki model' do context 'when repository is empty' do let(:wiki_container) { wiki_container_without_repo } - it 'does nothing' do + it 'changes the HEAD reference to the default branch' do wiki.repository.create_if_not_exists - - expect(wiki).not_to receive(:change_head_to_default_branch) + wiki.repository.raw_repository.write_ref('HEAD', 'refs/heads/bar') subject + + expect(File.read(head_path).squish).to eq "ref: refs/heads/#{default_branch}" end end context 'when repository is not empty' do - it 'does nothing' do + before do wiki.create_page('index', 'test content') + end - expect(wiki).not_to receive(:change_head_to_default_branch) + it 'does nothing when HEAD points to the right branch' do + expect(wiki.repository.raw_repository).not_to receive(:write_ref) subject end + + context 'when HEAD points to the wrong branch' do + it 'rewrites HEAD with the right branch' do + wiki.repository.raw_repository.write_ref('HEAD', 'refs/heads/bar') + + subject + + expect(File.read(head_path).squish).to eq "ref: refs/heads/#{default_branch}" + end + end end end end diff --git a/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb b/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb index 555a6d5eed0..1646c18a0ed 100644 --- a/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb +++ b/spec/support/shared_examples/services/wikis/create_attachment_service_shared_examples.rb @@ -25,6 +25,25 @@ RSpec.shared_examples 'Wikis::CreateAttachmentService#execute' do |container_typ container.add_developer(user) end + context 'creates wiki repository if it does not exist' do + let(:container) { create(container_type) } # rubocop:disable Rails/SaveBang + + it 'creates wiki repository' do + expect { service.execute }.to change { container.wiki.repository.exists? }.to(true) + end + + context 'if an error is raised creating the repository' do + it 'catches error and return gracefully' do + allow(container.wiki).to receive(:repository_exists?).and_return(false) + + result = service.execute + + expect(result[:status]).to eq :error + expect(result[:message]).to eq 'Error creating the wiki repository' + end + end + end + context 'creates branch if it does not exists' do let(:branch_name) { 'new_branch' } let(:opts) { file_opts.merge(branch_name: branch_name) } diff --git a/yarn.lock b/yarn.lock index 93d06635536..c316b7d9d00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -908,10 +908,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8" integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw== -"@gitlab/ui@31.3.0": - version "31.3.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-31.3.0.tgz#90619e2b52e68100323488042fcbd1b4c1226b54" - integrity sha512-FsClTQHQZqPQ8cYod1wAx7T3Ogs77PIQ+4EnD2HGZxXTnQHmM3JRivRcp15F+tOW6vJbeNJmHNLT/d03W8G4kg== +"@gitlab/ui@31.5.0": + version "31.5.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-31.5.0.tgz#45b7866b790e7d5a1b67b39000c047991036b437" + integrity sha512-pIJXbO0zfpgD0CmZTjKMMCu6l1AMLWiGPdPqDhqgGskuKGOU1UxX7ApDLesgRsSCievMTD/zsVvRHrG6AH7LiQ== dependencies: "@babel/standalone" "^7.0.0" bootstrap-vue "2.18.1" |