Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									23c4d0c3e1
								
							
						
					
					
						commit
						f5a72705e4
					
				|  | @ -1,33 +1,32 @@ | |||
| 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_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`; | ||||
| 
 | ||||
| const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => { | ||||
| const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => { | ||||
|   if (valueStreamId) { | ||||
|     return buildApiUrl(PROJECT_VSA_STAGES_PATH) | ||||
|       .replace(':project_path', projectPath) | ||||
|       .replace(':request_path', requestPath) | ||||
|       .replace(':value_stream_id', valueStreamId); | ||||
|   } | ||||
|   return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath); | ||||
|   return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':request_path', requestPath); | ||||
| }; | ||||
| 
 | ||||
| const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) => | ||||
|   buildApiUrl(GROUP_VSA_PATH_BASE) | ||||
|     .replace(':id', groupId) | ||||
| const buildValueStreamStageDataPath = ({ requestPath, valueStreamId = null, stageId = null }) => | ||||
|   buildApiUrl(PROJECT_VSA_STAGE_DATA_PATH) | ||||
|     .replace(':request_path', requestPath) | ||||
|     .replace(':value_stream_id', valueStreamId) | ||||
|     .replace(':stage_id', stageId); | ||||
| 
 | ||||
| export const getProjectValueStreams = (projectPath) => { | ||||
|   const url = buildProjectValueStreamPath(projectPath); | ||||
| export const getProjectValueStreams = (requestPath) => { | ||||
|   const url = buildProjectValueStreamPath(requestPath); | ||||
|   return axios.get(url); | ||||
| }; | ||||
| 
 | ||||
| export const getProjectValueStreamStages = (projectPath, valueStreamId) => { | ||||
|   const url = buildProjectValueStreamPath(projectPath, valueStreamId); | ||||
| export const getProjectValueStreamStages = (requestPath, valueStreamId) => { | ||||
|   const url = buildProjectValueStreamPath(requestPath, valueStreamId); | ||||
|   return axios.get(url); | ||||
| }; | ||||
| 
 | ||||
|  | @ -45,7 +44,15 @@ export const getProjectValueStreamMetrics = (requestPath, params) => | |||
|  * 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 }); | ||||
| export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => { | ||||
|   const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId }); | ||||
|   return axios.get(`${stageBase}/median`, { params }); | ||||
| }; | ||||
| 
 | ||||
| export const getValueStreamStageRecords = ( | ||||
|   { requestPath, valueStreamId, stageId }, | ||||
|   params = {}, | ||||
| ) => { | ||||
|   const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId }); | ||||
|   return axios.get(`${stageBase}/records`, { params }); | ||||
| }; | ||||
|  |  | |||
|  | @ -42,7 +42,7 @@ export default { | |||
|       'selectedStageError', | ||||
|       'stages', | ||||
|       'summary', | ||||
|       'startDate', | ||||
|       'daysInPast', | ||||
|       'permissions', | ||||
|     ]), | ||||
|     ...mapGetters(['pathNavigationData']), | ||||
|  | @ -51,13 +51,15 @@ export default { | |||
|       return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; | ||||
|     }, | ||||
|     displayNotEnoughData() { | ||||
|       return this.selectedStageReady && this.isEmptyStage; | ||||
|       return !this.isLoadingStage && this.isEmptyStage; | ||||
|     }, | ||||
|     displayNoAccess() { | ||||
|       return this.selectedStageReady && !this.isUserAllowed(this.selectedStage.id); | ||||
|       return ( | ||||
|         !this.isLoadingStage && this.selectedStage?.id && !this.isUserAllowed(this.selectedStage.id) | ||||
|       ); | ||||
|     }, | ||||
|     selectedStageReady() { | ||||
|       return !this.isLoadingStage && this.selectedStage; | ||||
|     displayPathNavigation() { | ||||
|       return this.isLoading || (this.selectedStage && this.pathNavigationData.length); | ||||
|     }, | ||||
|     emptyStageTitle() { | ||||
|       if (this.displayNoAccess) { | ||||
|  | @ -83,8 +85,8 @@ export default { | |||
|       'setSelectedStage', | ||||
|       'setDateRange', | ||||
|     ]), | ||||
|     handleDateSelect(startDate) { | ||||
|       this.setDateRange({ startDate }); | ||||
|     handleDateSelect(daysInPast) { | ||||
|       this.setDateRange(daysInPast); | ||||
|     }, | ||||
|     onSelectStage(stage) { | ||||
|       this.setSelectedStage(stage); | ||||
|  | @ -101,15 +103,18 @@ export default { | |||
|   dayRangeOptions: [7, 30, 90], | ||||
|   i18n: { | ||||
|     dropdownText: __('Last %{days} days'), | ||||
|     pageTitle: __('Value Stream Analytics'), | ||||
|     recentActivity: __('Recent Project Activity'), | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
|   <div class="cycle-analytics"> | ||||
|     <h3>{{ $options.i18n.pageTitle }}</h3> | ||||
|     <path-navigation | ||||
|       v-if="selectedStageReady" | ||||
|       v-if="displayPathNavigation" | ||||
|       class="js-path-navigation gl-w-full gl-pb-2" | ||||
|       :loading="isLoading" | ||||
|       :loading="isLoading || isLoadingStage" | ||||
|       :stages="pathNavigationData" | ||||
|       :selected-stage="selectedStage" | ||||
|       :with-stage-counts="false" | ||||
|  | @ -135,7 +140,7 @@ export default { | |||
|               <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> | ||||
|                 <span class="dropdown-label"> | ||||
|                   <gl-sprintf :message="$options.i18n.dropdownText"> | ||||
|                     <template #days>{{ startDate }}</template> | ||||
|                     <template #days>{{ daysInPast }}</template> | ||||
|                   </gl-sprintf> | ||||
|                   <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" /> | ||||
|                 </span> | ||||
|  |  | |||
|  | @ -52,7 +52,7 @@ export default { | |||
|     selectedStage: { | ||||
|       type: Object, | ||||
|       required: false, | ||||
|       default: () => ({ custom: false }), | ||||
|       default: () => ({}), | ||||
|     }, | ||||
|     isLoading: { | ||||
|       type: Boolean, | ||||
|  | @ -102,7 +102,7 @@ export default { | |||
|   }, | ||||
|   computed: { | ||||
|     isEmptyStage() { | ||||
|       return !this.stageEvents.length; | ||||
|       return !this.selectedStage || !this.stageEvents.length; | ||||
|     }, | ||||
|     emptyStateTitleText() { | ||||
|       return this.emptyStateTitle || NOT_ENOUGH_DATA_ERROR; | ||||
|  |  | |||
|  | @ -20,11 +20,9 @@ export default () => { | |||
|   store.dispatch('initializeVsa', { | ||||
|     projectId: parseInt(projectId, 10), | ||||
|     groupPath, | ||||
|     endpoints: { | ||||
|       requestPath, | ||||
|       fullPath, | ||||
|     features: { | ||||
|       cycleAnalyticsForGroups: | ||||
|         (groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,29 +1,28 @@ | |||
| import { | ||||
|   getProjectValueStreamStages, | ||||
|   getProjectValueStreams, | ||||
|   getProjectValueStreamStageData, | ||||
|   getProjectValueStreamMetrics, | ||||
|   getValueStreamStageMedian, | ||||
|   getValueStreamStageRecords, | ||||
| } from '~/api/analytics_api'; | ||||
| import createFlash from '~/flash'; | ||||
| import { __ } from '~/locale'; | ||||
| import { | ||||
|   DEFAULT_DAYS_TO_DISPLAY, | ||||
|   DEFAULT_VALUE_STREAM, | ||||
|   I18N_VSA_ERROR_STAGE_MEDIAN, | ||||
| } from '../constants'; | ||||
| import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants'; | ||||
| import * as types from './mutation_types'; | ||||
| 
 | ||||
| export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => { | ||||
|   commit(types.SET_SELECTED_VALUE_STREAM, valueStream); | ||||
|   return dispatch('fetchValueStreamStages'); | ||||
|   return Promise.all([dispatch('fetchValueStreamStages'), dispatch('fetchCycleAnalyticsData')]); | ||||
| }; | ||||
| 
 | ||||
| export const fetchValueStreamStages = ({ commit, state }) => { | ||||
|   const { fullPath, selectedValueStream } = state; | ||||
|   const { | ||||
|     endpoints: { fullPath }, | ||||
|     selectedValueStream: { id }, | ||||
|   } = state; | ||||
|   commit(types.REQUEST_VALUE_STREAM_STAGES); | ||||
| 
 | ||||
|   return getProjectValueStreamStages(fullPath, selectedValueStream.id) | ||||
|   return getProjectValueStreamStages(fullPath, id) | ||||
|     .then(({ data }) => commit(types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS, data)) | ||||
|     .catch(({ response: { status } }) => { | ||||
|       commit(types.RECEIVE_VALUE_STREAM_STAGES_ERROR, status); | ||||
|  | @ -41,16 +40,11 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => { | |||
| 
 | ||||
| export const fetchValueStreams = ({ commit, dispatch, state }) => { | ||||
|   const { | ||||
|     fullPath, | ||||
|     features: { cycleAnalyticsForGroups }, | ||||
|     endpoints: { fullPath }, | ||||
|   } = state; | ||||
|   commit(types.REQUEST_VALUE_STREAMS); | ||||
| 
 | ||||
|   const stageRequests = ['setSelectedStage']; | ||||
|   if (cycleAnalyticsForGroups) { | ||||
|     stageRequests.push('fetchStageMedians'); | ||||
|   } | ||||
| 
 | ||||
|   const stageRequests = ['setSelectedStage', 'fetchStageMedians']; | ||||
|   return getProjectValueStreams(fullPath) | ||||
|     .then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) | ||||
|     .then(() => Promise.all(stageRequests.map((r) => dispatch(r)))) | ||||
|  | @ -58,9 +52,10 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => { | |||
|       commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| export const fetchCycleAnalyticsData = ({ | ||||
|   state: { requestPath }, | ||||
|   state: { | ||||
|     endpoints: { requestPath }, | ||||
|   }, | ||||
|   getters: { legacyFilterParams }, | ||||
|   commit, | ||||
| }) => { | ||||
|  | @ -76,18 +71,10 @@ export const fetchCycleAnalyticsData = ({ | |||
|     }); | ||||
| }; | ||||
| 
 | ||||
| export const fetchStageData = ({ | ||||
|   state: { requestPath, selectedStage }, | ||||
|   getters: { legacyFilterParams }, | ||||
|   commit, | ||||
| }) => { | ||||
| export const fetchStageData = ({ getters: { requestParams, filterParams }, commit }) => { | ||||
|   commit(types.REQUEST_STAGE_DATA); | ||||
| 
 | ||||
|   return getProjectValueStreamStageData({ | ||||
|     requestPath, | ||||
|     stageId: selectedStage.id, | ||||
|     params: legacyFilterParams, | ||||
|   }) | ||||
|   return getValueStreamStageRecords(requestParams, filterParams) | ||||
|     .then(({ data }) => { | ||||
|       // when there's a query timeout, the request succeeds but the error is encoded in the response data
 | ||||
|       if (data?.error) { | ||||
|  | @ -134,22 +121,32 @@ export const setSelectedStage = ({ dispatch, commit, state: { stages } }, select | |||
|   return dispatch('fetchStageData'); | ||||
| }; | ||||
| 
 | ||||
| const refetchData = (dispatch, commit) => { | ||||
|   commit(types.SET_LOADING, true); | ||||
| export const setLoading = ({ commit }, value) => commit(types.SET_LOADING, value); | ||||
| 
 | ||||
| const refetchStageData = (dispatch) => { | ||||
|   return Promise.resolve() | ||||
|     .then(() => dispatch('fetchValueStreams')) | ||||
|     .then(() => dispatch('fetchCycleAnalyticsData')) | ||||
|     .finally(() => commit(types.SET_LOADING, false)); | ||||
|     .then(() => dispatch('setLoading', true)) | ||||
|     .then(() => | ||||
|       Promise.all([ | ||||
|         dispatch('fetchCycleAnalyticsData'), | ||||
|         dispatch('fetchStageData'), | ||||
|         dispatch('fetchStageMedians'), | ||||
|       ]), | ||||
|     ) | ||||
|     .finally(() => dispatch('setLoading', false)); | ||||
| }; | ||||
| 
 | ||||
| export const setFilters = ({ dispatch, commit }) => refetchData(dispatch, commit); | ||||
| export const setFilters = ({ dispatch }) => refetchStageData(dispatch); | ||||
| 
 | ||||
| export const setDateRange = ({ dispatch, commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => { | ||||
|   commit(types.SET_DATE_RANGE, { startDate }); | ||||
|   return refetchData(dispatch, commit); | ||||
| export const setDateRange = ({ dispatch, commit }, daysInPast) => { | ||||
|   commit(types.SET_DATE_RANGE, daysInPast); | ||||
|   return refetchStageData(dispatch); | ||||
| }; | ||||
| 
 | ||||
| export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { | ||||
|   commit(types.INITIALIZE_VSA, initialData); | ||||
|   return refetchData(dispatch, commit); | ||||
| 
 | ||||
|   return dispatch('setLoading', true) | ||||
|     .then(() => dispatch('fetchValueStreams')) | ||||
|     .finally(() => dispatch('setLoading', false)); | ||||
| }; | ||||
|  |  | |||
|  | @ -13,11 +13,11 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage | |||
| 
 | ||||
| export const requestParams = (state) => { | ||||
|   const { | ||||
|     selectedStage: { id: stageId = null }, | ||||
|     groupPath: groupId, | ||||
|     endpoints: { fullPath }, | ||||
|     selectedValueStream: { id: valueStreamId }, | ||||
|     selectedStage: { id: stageId = null }, | ||||
|   } = state; | ||||
|   return { valueStreamId, groupId, stageId }; | ||||
|   return { requestPath: fullPath, valueStreamId, stageId }; | ||||
| }; | ||||
| 
 | ||||
| const dateRangeParams = ({ createdAfter, createdBefore }) => ({ | ||||
|  | @ -25,15 +25,14 @@ const dateRangeParams = ({ createdAfter, createdBefore }) => ({ | |||
|   created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null, | ||||
| }); | ||||
| 
 | ||||
| export const legacyFilterParams = ({ startDate }) => { | ||||
| export const legacyFilterParams = ({ daysInPast }) => { | ||||
|   return { | ||||
|     'cycle_analytics[start_date]': startDate, | ||||
|     'cycle_analytics[start_date]': daysInPast, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export const filterParams = ({ id, ...rest }) => { | ||||
| export const filterParams = (state) => { | ||||
|   return { | ||||
|     project_ids: [id], | ||||
|     ...dateRangeParams(rest), | ||||
|     ...dateRangeParams(state), | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
|  | @ -4,15 +4,11 @@ import { decorateData, formatMedianValues, calculateFormattedDayInPast } from '. | |||
| import * as types from './mutation_types'; | ||||
| 
 | ||||
| export default { | ||||
|   [types.INITIALIZE_VSA](state, { requestPath, fullPath, groupPath, projectId, features }) { | ||||
|     state.requestPath = requestPath; | ||||
|     state.fullPath = fullPath; | ||||
|     state.groupPath = groupPath; | ||||
|     state.id = projectId; | ||||
|   [types.INITIALIZE_VSA](state, { endpoints }) { | ||||
|     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; | ||||
|  | @ -23,9 +19,9 @@ export default { | |||
|   [types.SET_SELECTED_STAGE](state, stage) { | ||||
|     state.selectedStage = stage; | ||||
|   }, | ||||
|   [types.SET_DATE_RANGE](state, { startDate }) { | ||||
|     state.startDate = startDate; | ||||
|     const { now, past } = calculateFormattedDayInPast(startDate); | ||||
|   [types.SET_DATE_RANGE](state, daysInPast) { | ||||
|     state.daysInPast = daysInPast; | ||||
|     const { now, past } = calculateFormattedDayInPast(daysInPast); | ||||
|     state.createdBefore = now; | ||||
|     state.createdAfter = past; | ||||
|   }, | ||||
|  | @ -50,25 +46,16 @@ 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; | ||||
|     const { summary } = decorateData(data); | ||||
|     state.permissions = data?.permissions || {}; | ||||
|     state.summary = summary; | ||||
|     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; | ||||
|  | @ -76,7 +63,7 @@ export default { | |||
|     state.selectedStageEvents = []; | ||||
|     state.hasError = false; | ||||
|   }, | ||||
|   [types.RECEIVE_STAGE_DATA_SUCCESS](state, { events = [] }) { | ||||
|   [types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) { | ||||
|     state.isLoadingStage = false; | ||||
|     state.isEmptyStage = !events.length; | ||||
|     state.selectedStageEvents = events.map((ev) => | ||||
|  |  | |||
|  | @ -1,11 +1,9 @@ | |||
| import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; | ||||
| 
 | ||||
| export default () => ({ | ||||
|   features: {}, | ||||
|   id: null, | ||||
|   requestPath: '', | ||||
|   fullPath: '', | ||||
|   startDate: DEFAULT_DAYS_TO_DISPLAY, | ||||
|   endpoints: {}, | ||||
|   daysInPast: DEFAULT_DAYS_TO_DISPLAY, | ||||
|   createdAfter: null, | ||||
|   createdBefore: null, | ||||
|   stages: [], | ||||
|  | @ -23,5 +21,4 @@ export default () => ({ | |||
|   isLoadingStage: false, | ||||
|   isEmptyStage: false, | ||||
|   permissions: {}, | ||||
|   parentPath: null, | ||||
| }); | ||||
|  |  | |||
|  | @ -8,13 +8,11 @@ import { parseSeconds } from '~/lib/utils/datetime_utility'; | |||
| import { s__, sprintf } from '../locale'; | ||||
| 
 | ||||
| const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); | ||||
| const mapToMedians = ({ name: id, value }) => ({ id, value }); | ||||
| 
 | ||||
| export const decorateData = (data = {}) => { | ||||
|   const { stats: stages, summary } = data; | ||||
|   const { summary } = data; | ||||
|   return { | ||||
|     summary: summary?.map((item) => mapToSummary(item)) || [], | ||||
|     medians: stages?.map((item) => mapToMedians(item)) || [], | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,8 +3,6 @@ import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert } from '@gitlab/ui' | |||
| import { | ||||
|   CREATE_BRANCH_ERROR_GENERIC, | ||||
|   CREATE_BRANCH_ERROR_WITH_CONTEXT, | ||||
|   CREATE_BRANCH_SUCCESS_ALERT, | ||||
|   I18N_NEW_BRANCH_PAGE_TITLE, | ||||
|   I18N_NEW_BRANCH_LABEL_DROPDOWN, | ||||
|   I18N_NEW_BRANCH_LABEL_BRANCH, | ||||
|   I18N_NEW_BRANCH_LABEL_SOURCE, | ||||
|  | @ -19,8 +17,6 @@ const DEFAULT_ALERT_PARAMS = { | |||
|   title: '', | ||||
|   message: '', | ||||
|   variant: DEFAULT_ALERT_VARIANT, | ||||
|   primaryButtonLink: '', | ||||
|   primaryButtonText: '', | ||||
| }; | ||||
| 
 | ||||
| export default { | ||||
|  | @ -34,13 +30,7 @@ export default { | |||
|     ProjectDropdown, | ||||
|     SourceBranchDropdown, | ||||
|   }, | ||||
|   props: { | ||||
|     initialBranchName: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '', | ||||
|     }, | ||||
|   }, | ||||
|   inject: ['initialBranchName'], | ||||
|   data() { | ||||
|     return { | ||||
|       selectedProject: null, | ||||
|  | @ -111,10 +101,7 @@ export default { | |||
|             message: errors[0], | ||||
|           }); | ||||
|         } else { | ||||
|           this.displayAlert({ | ||||
|             ...CREATE_BRANCH_SUCCESS_ALERT, | ||||
|             variant: 'success', | ||||
|           }); | ||||
|           this.$emit('success'); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         this.onError({ | ||||
|  | @ -126,7 +113,6 @@ export default { | |||
|     }, | ||||
|   }, | ||||
|   i18n: { | ||||
|     I18N_NEW_BRANCH_PAGE_TITLE, | ||||
|     I18N_NEW_BRANCH_LABEL_DROPDOWN, | ||||
|     I18N_NEW_BRANCH_LABEL_BRANCH, | ||||
|     I18N_NEW_BRANCH_LABEL_SOURCE, | ||||
|  | @ -134,15 +120,8 @@ export default { | |||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="gl-border-1 gl-border-b-solid gl-border-gray-100 gl-mb-5 gl-mt-7"> | ||||
|       <h1 class="page-title"> | ||||
|         {{ $options.i18n.I18N_NEW_BRANCH_PAGE_TITLE }} | ||||
|       </h1> | ||||
|     </div> | ||||
| 
 | ||||
|   <gl-form @submit.prevent="onSubmit"> | ||||
|     <gl-alert | ||||
|       v-if="showAlert" | ||||
|       class="gl-mb-5" | ||||
|  | @ -152,12 +131,7 @@ export default { | |||
|     > | ||||
|       {{ alertParams.message }} | ||||
|     </gl-alert> | ||||
| 
 | ||||
|     <gl-form @submit.prevent="onSubmit"> | ||||
|       <gl-form-group | ||||
|         :label="$options.i18n.I18N_NEW_BRANCH_LABEL_DROPDOWN" | ||||
|         label-for="project-select" | ||||
|       > | ||||
|     <gl-form-group :label="$options.i18n.I18N_NEW_BRANCH_LABEL_DROPDOWN" label-for="project-select"> | ||||
|       <project-dropdown | ||||
|         id="project-select" | ||||
|         :selected-project="selectedProject" | ||||
|  | @ -197,5 +171,4 @@ export default { | |||
|       </gl-button> | ||||
|     </div> | ||||
|   </gl-form> | ||||
|   </div> | ||||
| </template> | ||||
|  |  | |||
|  | @ -3,7 +3,6 @@ import { __, s__ } from '~/locale'; | |||
| export const BRANCHES_PER_PAGE = 20; | ||||
| export const PROJECTS_PER_PAGE = 20; | ||||
| 
 | ||||
| export const I18N_NEW_BRANCH_PAGE_TITLE = __('New branch'); | ||||
| export const I18N_NEW_BRANCH_LABEL_DROPDOWN = __('Project'); | ||||
| export const I18N_NEW_BRANCH_LABEL_BRANCH = __('Branch name'); | ||||
| export const I18N_NEW_BRANCH_LABEL_SOURCE = __('Source branch'); | ||||
|  | @ -14,7 +13,13 @@ export const CREATE_BRANCH_ERROR_GENERIC = s__( | |||
| ); | ||||
| export const CREATE_BRANCH_ERROR_WITH_CONTEXT = s__('JiraConnect|Failed to create branch.'); | ||||
| 
 | ||||
| export const CREATE_BRANCH_SUCCESS_ALERT = { | ||||
|   title: s__('JiraConnect|New branch was successfully created.'), | ||||
|   message: s__('JiraConnect|You can now close this window and return to Jira.'), | ||||
| }; | ||||
| export const I18N_PAGE_TITLE_WITH_BRANCH_NAME = s__( | ||||
|   'JiraConnect|Create branch for Jira issue %{jiraIssue}', | ||||
| ); | ||||
| export const I18N_PAGE_TITLE_DEFAULT = __('New branch'); | ||||
| export const I18N_NEW_BRANCH_SUCCESS_TITLE = s__( | ||||
|   'JiraConnect|New branch was successfully created.', | ||||
| ); | ||||
| export const I18N_NEW_BRANCH_SUCCESS_MESSAGE = s__( | ||||
|   'JiraConnect|You can now close this window and return to Jira.', | ||||
| ); | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import Vue from 'vue'; | ||||
| import VueApollo from 'vue-apollo'; | ||||
| import JiraConnectNewBranchForm from '~/jira_connect/branches/components/new_branch_form.vue'; | ||||
| import JiraConnectNewBranchPage from '~/jira_connect/branches/pages/index.vue'; | ||||
| import createDefaultClient from '~/lib/graphql'; | ||||
| 
 | ||||
| Vue.use(VueApollo); | ||||
|  | @ -11,7 +11,7 @@ export default async function initJiraConnectBranches() { | |||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   const { initialBranchName } = el.dataset; | ||||
|   const { initialBranchName, successStateSvgPath } = el.dataset; | ||||
| 
 | ||||
|   const apolloProvider = new VueApollo({ | ||||
|     defaultClient: createDefaultClient( | ||||
|  | @ -25,12 +25,12 @@ export default async function initJiraConnectBranches() { | |||
|   return new Vue({ | ||||
|     el, | ||||
|     apolloProvider, | ||||
|     render(createElement) { | ||||
|       return createElement(JiraConnectNewBranchForm, { | ||||
|         props: { | ||||
|     provide: { | ||||
|       initialBranchName, | ||||
|       successStateSvgPath, | ||||
|     }, | ||||
|       }); | ||||
|     render(createElement) { | ||||
|       return createElement(JiraConnectNewBranchPage); | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,60 @@ | |||
| <script> | ||||
| import { GlEmptyState } from '@gitlab/ui'; | ||||
| import { sprintf } from '~/locale'; | ||||
| import NewBranchForm from '../components/new_branch_form.vue'; | ||||
| import { | ||||
|   I18N_PAGE_TITLE_WITH_BRANCH_NAME, | ||||
|   I18N_PAGE_TITLE_DEFAULT, | ||||
|   I18N_NEW_BRANCH_SUCCESS_TITLE, | ||||
|   I18N_NEW_BRANCH_SUCCESS_MESSAGE, | ||||
| } from '../constants'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     GlEmptyState, | ||||
|     NewBranchForm, | ||||
|   }, | ||||
|   inject: ['initialBranchName', 'successStateSvgPath'], | ||||
|   data() { | ||||
|     return { | ||||
|       showForm: true, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     pageTitle() { | ||||
|       return this.initialBranchName | ||||
|         ? sprintf(this.$options.i18n.I18N_PAGE_TITLE_WITH_BRANCH_NAME, { | ||||
|             jiraIssue: this.initialBranchName, | ||||
|           }) | ||||
|         : this.$options.i18n.I18N_PAGE_TITLE_DEFAULT; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     onNewBranchFormSuccess() { | ||||
|       // light-weight toggle to hide the form and show the success state | ||||
|       this.showForm = false; | ||||
|     }, | ||||
|   }, | ||||
|   i18n: { | ||||
|     I18N_PAGE_TITLE_WITH_BRANCH_NAME, | ||||
|     I18N_PAGE_TITLE_DEFAULT, | ||||
|     I18N_NEW_BRANCH_SUCCESS_TITLE, | ||||
|     I18N_NEW_BRANCH_SUCCESS_MESSAGE, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="gl-border-1 gl-border-b-solid gl-border-gray-100 gl-mb-5 gl-mt-7"> | ||||
|       <h1 data-testid="page-title" class="page-title">{{ pageTitle }}</h1> | ||||
|     </div> | ||||
| 
 | ||||
|     <new-branch-form v-if="showForm" @success="onNewBranchFormSuccess" /> | ||||
|     <gl-empty-state | ||||
|       v-else | ||||
|       :title="$options.i18n.I18N_NEW_BRANCH_SUCCESS_TITLE" | ||||
|       :description="$options.i18n.I18N_NEW_BRANCH_SUCCESS_MESSAGE" | ||||
|       :svg-path="successStateSvgPath" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -2,6 +2,7 @@ | |||
| import { GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlToggle, GlForm } from '@gitlab/ui'; | ||||
| import csrf from '~/lib/utils/csrf'; | ||||
| import { __ } from '~/locale'; | ||||
| import validation from '~/vue_shared/directives/validation'; | ||||
| import { | ||||
|   SECONDARY_OPTIONS_TEXT, | ||||
|   COMMIT_LABEL, | ||||
|  | @ -9,6 +10,13 @@ import { | |||
|   TOGGLE_CREATE_MR_LABEL, | ||||
| } from '../constants'; | ||||
| 
 | ||||
| const initFormField = ({ value, required = true, skipValidation = false }) => ({ | ||||
|   value, | ||||
|   required, | ||||
|   state: skipValidation ? true : null, | ||||
|   feedback: null, | ||||
| }); | ||||
| 
 | ||||
| export default { | ||||
|   csrf, | ||||
|   components: { | ||||
|  | @ -26,6 +34,9 @@ export default { | |||
|     TARGET_BRANCH_LABEL, | ||||
|     TOGGLE_CREATE_MR_LABEL, | ||||
|   }, | ||||
|   directives: { | ||||
|     validation: validation(), | ||||
|   }, | ||||
|   props: { | ||||
|     modalId: { | ||||
|       type: String, | ||||
|  | @ -61,12 +72,20 @@ export default { | |||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     const form = { | ||||
|       state: false, | ||||
|       showValidation: false, | ||||
|       fields: { | ||||
|         // fields key must match case of form name for validation directive to work | ||||
|         commit_message: initFormField({ value: this.commitMessage }), | ||||
|         branch_name: initFormField({ value: this.targetBranch }), | ||||
|       }, | ||||
|     }; | ||||
|     return { | ||||
|       loading: false, | ||||
|       commit: this.commitMessage, | ||||
|       target: this.targetBranch, | ||||
|       createNewMr: true, | ||||
|       error: '', | ||||
|       form, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|  | @ -77,7 +96,7 @@ export default { | |||
|           { | ||||
|             variant: 'danger', | ||||
|             loading: this.loading, | ||||
|             disabled: !this.formCompleted || this.loading, | ||||
|             disabled: this.loading || !this.form.state, | ||||
|           }, | ||||
|         ], | ||||
|       }; | ||||
|  | @ -92,17 +111,26 @@ export default { | |||
|         ], | ||||
|       }; | ||||
|     }, | ||||
|     /* eslint-disable dot-notation */ | ||||
|     showCreateNewMrToggle() { | ||||
|       return this.canPushCode && this.target !== this.originalBranch; | ||||
|       return this.canPushCode && this.form.fields['branch_name'].value !== this.originalBranch; | ||||
|     }, | ||||
|     formCompleted() { | ||||
|       return this.commit && this.target; | ||||
|       return this.form.fields['commit_message'].value && this.form.fields['branch_name'].value; | ||||
|     }, | ||||
|     /* eslint-enable dot-notation */ | ||||
|   }, | ||||
|   methods: { | ||||
|     submitForm(e) { | ||||
|       e.preventDefault(); // Prevent modal from closing | ||||
|       this.form.showValidation = true; | ||||
| 
 | ||||
|       if (!this.form.state) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       this.loading = true; | ||||
|       this.form.showValidation = false; | ||||
|       this.$refs.form.$el.submit(); | ||||
|     }, | ||||
|   }, | ||||
|  | @ -119,7 +147,7 @@ export default { | |||
|     :action-cancel="cancelOptions" | ||||
|     @primary="submitForm" | ||||
|   > | ||||
|     <gl-form ref="form" :action="deletePath" method="post"> | ||||
|     <gl-form ref="form" novalidate :action="deletePath" method="post"> | ||||
|       <input type="hidden" name="_method" value="delete" /> | ||||
|       <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> | ||||
|       <template v-if="emptyRepo"> | ||||
|  | @ -132,15 +160,34 @@ export default { | |||
|         <!-- Once "push to branch" permission is made available, will need to add to conditional | ||||
|           Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335462 --> | ||||
|         <input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" /> | ||||
|         <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message"> | ||||
|           <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" /> | ||||
|         <gl-form-group | ||||
|           :label="$options.i18n.COMMIT_LABEL" | ||||
|           label-for="commit_message" | ||||
|           :invalid-feedback="form.fields['commit_message'].feedback" | ||||
|         > | ||||
|           <gl-form-textarea | ||||
|             v-model="form.fields['commit_message'].value" | ||||
|             v-validation:[form.showValidation] | ||||
|             name="commit_message" | ||||
|             :state="form.fields['commit_message'].state" | ||||
|             :disabled="loading" | ||||
|             required | ||||
|           /> | ||||
|         </gl-form-group> | ||||
|         <gl-form-group | ||||
|           v-if="canPushCode" | ||||
|           :label="$options.i18n.TARGET_BRANCH_LABEL" | ||||
|           label-for="branch_name" | ||||
|           :invalid-feedback="form.fields['branch_name'].feedback" | ||||
|         > | ||||
|           <gl-form-input v-model="target" :disabled="loading" name="branch_name" /> | ||||
|           <gl-form-input | ||||
|             v-model="form.fields['branch_name'].value" | ||||
|             v-validation:[form.showValidation] | ||||
|             :state="form.fields['branch_name'].state" | ||||
|             :disabled="loading" | ||||
|             name="branch_name" | ||||
|             required | ||||
|           /> | ||||
|         </gl-form-group> | ||||
|         <gl-toggle | ||||
|           v-if="showCreateNewMrToggle" | ||||
|  |  | |||
|  | @ -8,12 +8,7 @@ class JiraConnect::BranchesController < ApplicationController | |||
|   feature_category :integrations | ||||
| 
 | ||||
|   def new | ||||
|     return unless params[:issue_key].present? | ||||
| 
 | ||||
|     @branch_name = Issue.to_branch_name( | ||||
|       params[:issue_key], | ||||
|       params[:issue_summary] | ||||
|     ) | ||||
|     @new_branch_data = new_branch_data | ||||
|   end | ||||
| 
 | ||||
|   def self.feature_enabled?(user) | ||||
|  | @ -22,6 +17,22 @@ class JiraConnect::BranchesController < ApplicationController | |||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def initial_branch_name | ||||
|     return unless params[:issue_key].present? | ||||
| 
 | ||||
|     Issue.to_branch_name( | ||||
|       params[:issue_key], | ||||
|       params[:issue_summary] | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
|   def new_branch_data | ||||
|     { | ||||
|       initial_branch_name: initial_branch_name, | ||||
|       success_state_svg_path: ActionController::Base.helpers.image_path('illustrations/merge_requests.svg') | ||||
|     } | ||||
|   end | ||||
| 
 | ||||
|   def feature_enabled! | ||||
|     render_404 unless self.class.feature_enabled?(current_user) | ||||
|   end | ||||
|  |  | |||
|  | @ -8,6 +8,7 @@ module Ci | |||
|     self.limit_scope = :group | ||||
|     self.limit_relation = :recent_runners | ||||
|     self.limit_feature_flag = :ci_runner_limits | ||||
|     self.limit_feature_flag_for_override = :ci_runner_limits_override | ||||
| 
 | ||||
|     belongs_to :runner, inverse_of: :runner_namespaces | ||||
|     belongs_to :namespace, inverse_of: :runner_namespaces, class_name: '::Namespace' | ||||
|  |  | |||
|  | @ -8,6 +8,7 @@ module Ci | |||
|     self.limit_scope = :project | ||||
|     self.limit_relation = :recent_runners | ||||
|     self.limit_feature_flag = :ci_runner_limits | ||||
|     self.limit_feature_flag_for_override = :ci_runner_limits_override | ||||
| 
 | ||||
|     belongs_to :runner, inverse_of: :runner_projects | ||||
|     belongs_to :project, inverse_of: :runner_projects | ||||
|  |  | |||
|  | @ -111,7 +111,7 @@ module Import | |||
|     private | ||||
| 
 | ||||
|     def log_error(exception) | ||||
|       Gitlab::Import::Logger.error( | ||||
|       Gitlab::GithubImport::Logger.error( | ||||
|         message: 'Import failed due to a GitHub error', | ||||
|         status: exception.response_status, | ||||
|         error: exception.response_body | ||||
|  |  | |||
|  | @ -2,4 +2,4 @@ | |||
| - @hide_top_links = true | ||||
| - page_title _('New branch') | ||||
| 
 | ||||
| .js-jira-connect-create-branch{ data: { initial_branch_name: @branch_name } } | ||||
| .js-jira-connect-create-branch{ data: @new_branch_data } | ||||
|  |  | |||
|  | @ -17,10 +17,6 @@ module Gitlab | |||
| 
 | ||||
|         feature_category :importers | ||||
|         worker_has_external_dependencies! | ||||
| 
 | ||||
|         def logger | ||||
|           @logger ||= Gitlab::Import::Logger.build | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       # project - An instance of `Project` to import the data into. | ||||
|  | @ -63,11 +59,11 @@ module Gitlab | |||
|       attr_accessor :github_id | ||||
| 
 | ||||
|       def info(project_id, extra = {}) | ||||
|         logger.info(log_attributes(project_id, extra)) | ||||
|         Logger.info(log_attributes(project_id, extra)) | ||||
|       end | ||||
| 
 | ||||
|       def error(project_id, exception, data = {}) | ||||
|         logger.error( | ||||
|         Logger.error( | ||||
|           log_attributes( | ||||
|             project_id, | ||||
|             message: 'importer failed', | ||||
|  | @ -78,13 +74,12 @@ module Gitlab | |||
| 
 | ||||
|         Gitlab::ErrorTracking.track_and_raise_exception( | ||||
|           exception, | ||||
|           log_attributes(project_id) | ||||
|           log_attributes(project_id, import_source: :github) | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       def log_attributes(project_id, extra = {}) | ||||
|         extra.merge( | ||||
|           import_source: :github, | ||||
|           project_id: project_id, | ||||
|           importer: importer_class.name, | ||||
|           github_id: github_id | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ module Gitlab | |||
|         sidekiq_options dead: false, retry: 5 | ||||
| 
 | ||||
|         sidekiq_retries_exhausted do |msg, e| | ||||
|           Gitlab::Import::Logger.error( | ||||
|           Logger.error( | ||||
|             event: :github_importer_exhausted, | ||||
|             message: msg['error_message'], | ||||
|             class: msg['class'], | ||||
|  |  | |||
|  | @ -37,11 +37,11 @@ module Gitlab | |||
|       private | ||||
| 
 | ||||
|       def info(project_id, extra = {}) | ||||
|         logger.info(log_attributes(project_id, extra)) | ||||
|         Logger.info(log_attributes(project_id, extra)) | ||||
|       end | ||||
| 
 | ||||
|       def error(project_id, exception) | ||||
|         logger.error( | ||||
|         Logger.error( | ||||
|           log_attributes( | ||||
|             project_id, | ||||
|             message: 'stage failed', | ||||
|  | @ -51,21 +51,16 @@ module Gitlab | |||
| 
 | ||||
|         Gitlab::ErrorTracking.track_and_raise_exception( | ||||
|           exception, | ||||
|           log_attributes(project_id) | ||||
|           log_attributes(project_id, import_source: :github) | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       def log_attributes(project_id, extra = {}) | ||||
|         extra.merge( | ||||
|           import_source: :github, | ||||
|           project_id: project_id, | ||||
|           import_stage: self.class.name | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       def logger | ||||
|         @logger ||= Gitlab::Import::Logger.build | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ class MergeRequestMergeabilityCheckWorker | |||
|     merge_request = MergeRequest.find_by_id(merge_request_id) | ||||
| 
 | ||||
|     unless merge_request | ||||
|       logger.error("Failed to find merge request with ID: #{merge_request_id}") | ||||
|       Sidekiq.logger.error(worker: self.class.name, message: "Failed to find merge request", merge_request_id: merge_request_id) | ||||
|       return | ||||
|     end | ||||
| 
 | ||||
|  | @ -23,6 +23,6 @@ class MergeRequestMergeabilityCheckWorker | |||
|         .new(merge_request) | ||||
|         .execute(recheck: false, retry_lease: false) | ||||
| 
 | ||||
|     logger.error("Failed to check mergeability of merge request (#{merge_request_id}): #{result.message}") if result.error? | ||||
|     Sidekiq.logger.error(worker: self.class.name, message: "Failed to check mergeability of merge request: #{result.message}", merge_request_id: merge_request_id) if result.error? | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -0,0 +1,8 @@ | |||
| --- | ||||
| name: ci_runner_limits_override | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67152 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/337224 | ||||
| milestone: '14.2' | ||||
| type: development | ||||
| group: group::runner | ||||
| default_enabled: false | ||||
|  | @ -0,0 +1,11 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddVulnerabilitySeveritiesIntoApprovalProjectRules < ActiveRecord::Migration[6.1] | ||||
|   def up | ||||
|     add_column :approval_project_rules, :severity_levels, :text, array: true, null: false, default: [] | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     remove_column :approval_project_rules, :severity_levels | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,23 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class BackfillIntegrationsTypeNew < ActiveRecord::Migration[6.1] | ||||
|   include Gitlab::Database::MigrationHelpers | ||||
| 
 | ||||
|   MIGRATION = 'BackfillIntegrationsTypeNew' | ||||
|   INTERVAL = 2.minutes | ||||
| 
 | ||||
|   def up | ||||
|     queue_batched_background_migration( | ||||
|       MIGRATION, | ||||
|       :integrations, | ||||
|       :id, | ||||
|       job_interval: INTERVAL | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     Gitlab::Database::BackgroundMigration::BatchedMigration | ||||
|       .for_configuration(MIGRATION, :integrations, :id, []) | ||||
|       .delete_all | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1 @@ | |||
| 378e12c3c7c49e294ab4ab792151af8e3829cc6f38295d5faa0995ad16f3f934 | ||||
|  | @ -0,0 +1 @@ | |||
| 19e23131949e6056ea9837231fac6a2307fb52a8287eb34cc6e89eed11d52849 | ||||
|  | @ -9729,7 +9729,8 @@ CREATE TABLE approval_project_rules ( | |||
|     name character varying NOT NULL, | ||||
|     rule_type smallint DEFAULT 0 NOT NULL, | ||||
|     scanners text[], | ||||
|     vulnerabilities_allowed smallint | ||||
|     vulnerabilities_allowed smallint, | ||||
|     severity_levels text[] DEFAULT '{}'::text[] NOT NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE approval_project_rules_groups ( | ||||
|  |  | |||
|  | @ -617,8 +617,7 @@ In the examples below we set the Registry's port to `5001`. | |||
| ## Disable Container Registry per project | ||||
| 
 | ||||
| If Registry is enabled in your GitLab instance, but you don't need it for your | ||||
| project, you can disable it from your project's settings. Read the user guide | ||||
| on how to achieve that. | ||||
| project, you can [disable it from your project's settings](../../user/project/settings/index.md#sharing-and-permissions).  | ||||
| 
 | ||||
| ## Use an external container registry with GitLab as an auth endpoint | ||||
| 
 | ||||
|  |  | |||
|  | @ -30,6 +30,55 @@ To disable it: | |||
| Feature.disable(:ci_job_token_scope) | ||||
| ``` | ||||
| 
 | ||||
| ## Change the visibility of the Container Registry | ||||
| 
 | ||||
| This controls who can view the Container Registry. | ||||
| 
 | ||||
| ```plaintext | ||||
| PUT /projects/:id/ | ||||
| ``` | ||||
| 
 | ||||
| | Attribute | Type | Required | Description | | ||||
| | --------- | ---- | -------- | ----------- | | ||||
| | `id`      | integer/string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) accessible by the authenticated user. | | ||||
| | `container_registry_access_level` | string | no | The desired visibility of the Container Registry. One of `enabled` (default), `private`, or `disabled`. | | ||||
| 
 | ||||
| Descriptions of the possible values for `container_registry_access_level`: | ||||
| 
 | ||||
| - **enabled** (Default): The Container Registry is visible to everyone with access to the project. | ||||
| If the project is public, the Container Registry is also public. If the project is internal or | ||||
| private, the Container Registry is also internal or private. | ||||
| 
 | ||||
| - **private**: The Container Registry is visible only to project members with Reporter role or | ||||
| higher. This is similar to the behavior of a private project with Container Registry visibility set | ||||
| to **enabled**. | ||||
| 
 | ||||
| - **disabled**: The Container Registry is disabled. | ||||
| 
 | ||||
| See the [Container Registry visibility permissions](../user/packages/container_registry/index.md#container-registry-visibility-permissions) | ||||
| for more details about the permissions that this setting grants to users. | ||||
| 
 | ||||
| ```shell | ||||
| curl --request PUT "https://gitlab.example.com/api/v4/projects/5/" \ | ||||
|      --header 'PRIVATE-TOKEN: <your_access_token>' \ | ||||
|      --header 'Accept: application/json' \ | ||||
|      --header 'Content-Type: application/json' \ | ||||
|      --data-raw '{ | ||||
|          "container_registry_access_level": "private" | ||||
|      }' | ||||
| ``` | ||||
| 
 | ||||
| Example response: | ||||
| 
 | ||||
| ```json | ||||
| { | ||||
|   "id": 5, | ||||
|   "name": "Project 5", | ||||
|   "container_registry_access_level": "private", | ||||
|   ... | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| ## List registry repositories | ||||
| 
 | ||||
| ### Within a project | ||||
|  |  | |||
|  | @ -41,7 +41,7 @@ run only the jobs that match the type of contribution. If your contribution cont | |||
| **only** documentation changes, then only documentation-related jobs run, and | ||||
| the pipeline completes much faster than a code contribution. | ||||
| 
 | ||||
| If you are submitting documentation-only changes to Runner, Omnibus, or Charts, | ||||
| If you are submitting documentation-only changes to Omnibus or Charts, | ||||
| the fast pipeline is not determined automatically. Instead, create branches for | ||||
| docs-only merge requests using the following guide: | ||||
| 
 | ||||
|  |  | |||
|  | @ -237,6 +237,7 @@ The code for this resides in: | |||
| 
 | ||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48512/diffs) in GitLab 13.7. | ||||
| > - Number of imported objects [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64256) in GitLab 14.1. | ||||
| > - `Gitlab::GithubImport::Logger` [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65968) in GitLab 14.2. | ||||
| 
 | ||||
| The import progress can be checked in the `logs/importer.log` file. Each relevant import is logged | ||||
| with `"import_source": "github"` and the `"project_id"`. | ||||
|  |  | |||
|  | @ -54,12 +54,21 @@ billable user, with the following exceptions: | |||
|   [blocked users](../../user/admin_area/moderate_users.md#block-a-user) don't count as billable users in the current subscription. When they are either deactivated or blocked they release a _billable user_ seat. However, they may | ||||
|   count toward overages in the subscribed seat count. | ||||
| - Users who are [pending approval](../../user/admin_area/moderate_users.md#users-pending-approval). | ||||
| - Members with Guest permissions on an Ultimate subscription. | ||||
| - Members with the Guest role on an Ultimate subscription. | ||||
| - Users without project or group memberships on an Ultimate subscription. | ||||
| - GitLab-created service accounts: `Ghost User` and bots | ||||
|   ([`Support Bot`](../../user/project/service_desk.md#support-bot-user), | ||||
|   [`Project bot users`](../../user/project/settings/project_access_tokens.md#project-bot-users), and | ||||
|   so on.) | ||||
| 
 | ||||
| **Billable users** as reported in the `/admin` section is updated once per day. | ||||
| 
 | ||||
| ### Maximum users | ||||
| 
 | ||||
| GitLab shows the highest number of billable users for the current license period. | ||||
| 
 | ||||
| To view this list, on the top bar, select **Menu >** **{admin}** **Admin**. On the left menu, select **Subscription**. In the lower left, the list of **Maximum users** is displayed. | ||||
| 
 | ||||
| ### Tips for managing users and subscription seats | ||||
| 
 | ||||
| Managing the number of users against the number of subscription seats can be a challenge: | ||||
|  |  | |||
|  | @ -745,10 +745,13 @@ You can, however, remove the Container Registry for a project: | |||
| 
 | ||||
| The **Packages & Registries > Container Registry** entry is removed from the project's sidebar. | ||||
| 
 | ||||
| ## Set visibility of the Container Registry | ||||
| ## Change visibility of the Container Registry | ||||
| 
 | ||||
| By default, the Container Registry is visible to everyone with access to the project. | ||||
| You can, however, change the visibility of the Container Registry for a project: | ||||
| You can, however, change the visibility of the Container Registry for a project. | ||||
| 
 | ||||
| See the [Container Registry visibility permissions](#container-registry-visibility-permissions) | ||||
| for more details about the permissions that this setting grants to users. | ||||
| 
 | ||||
| 1. Go to your project's **Settings > General** page. | ||||
| 1. Expand the section **Visibility, project features, permissions**. | ||||
|  | @ -764,6 +767,25 @@ You can, however, change the visibility of the Container Registry for a project: | |||
| 
 | ||||
| 1. Select **Save changes**. | ||||
| 
 | ||||
| ## Container Registry visibility permissions | ||||
| 
 | ||||
| The ability to view the Container Registry and pull images is controlled by the Container Registry's | ||||
| visibility permissions. You can change this through the [visibility setting on the UI](#change-visibility-of-the-container-registry) | ||||
| or the [API](../../../api/container_registry.md#change-the-visibility-of-the-container-registry). | ||||
| [Other permissions](../../permissions.md) | ||||
| such as updating the Container Registry, pushing or deleting images, and so on are not affected by | ||||
| this setting. However, disabling the Container Registry disables all Container Registry operations. | ||||
| 
 | ||||
| |                      |                       | Anonymous<br/>(Everyone on internet) | Guest | Reporter, Developer, Maintainer, Owner | | ||||
| | -------------------- | --------------------- | --------- | ----- | ------------------------------------------ | | ||||
| | Public project with Container Registry visibility <br/> set to **Everyone With Access** (UI) or `enabled` (API)   | View Container Registry <br/> and pull images | Yes       | Yes   | Yes      | | ||||
| | Public project with Container Registry visibility <br/> set to **Only Project Members** (UI) or `private` (API)   | View Container Registry <br/> and pull images | No        | No    | Yes      | | ||||
| | Internal project with Container Registry visibility <br/> set to **Everyone With Access** (UI) or `enabled` (API) | View Container Registry <br/> and pull images | No        | Yes   | Yes      | | ||||
| | Internal project with Container Registry visibility <br/> set to **Only Project Members** (UI) or `private` (API) | View Container Registry <br/> and pull images | No        | No    | Yes      | | ||||
| | Private project with Container Registry visibility <br/> set to **Everyone With Access** (UI) or `enabled` (API)  | View Container Registry <br/> and pull images | No        | No    | Yes      | | ||||
| | Private project with Container Registry visibility <br/> set to **Only Project Members** (UI) or `private` (API)  | View Container Registry <br/> and pull images | No        | No    | Yes      | | ||||
| | Any project with Container Registry `disabled` | All operations on Container Registry | No | No | No | | ||||
| 
 | ||||
| ## Manifest lists and garbage collection | ||||
| 
 | ||||
| Manifest lists are commonly used for creating multi-architecture images. If you rely on manifest | ||||
|  |  | |||
|  | @ -94,7 +94,6 @@ The following table lists project permissions available for each role: | |||
| | Pull [packages](packages/index.md)                | ✓ (*1*) | ✓          | ✓           | ✓        | ✓      | | ||||
| | Reopen [test case](../ci/test_cases/index.md)     |         | ✓          | ✓           | ✓        | ✓      | | ||||
| | See a commit status                               |         | ✓          | ✓           | ✓        | ✓      | | ||||
| | See a container registry                          |         | ✓          | ✓           | ✓        | ✓      | | ||||
| | See a list of merge requests                      |         | ✓          | ✓           | ✓        | ✓      | | ||||
| | See environments                                  |         | ✓          | ✓           | ✓        | ✓      | | ||||
| | [Set issue estimate and record time spent](project/time_tracking.md) | | ✓ | ✓         | ✓        | ✓      | | ||||
|  | @ -260,6 +259,11 @@ Read through the documentation on [permissions for File Locking](project/file_lo | |||
| as well as by guest users that create a confidential issue. To learn more, | ||||
| read through the documentation on [permissions and access to confidential issues](project/issues/confidential_issues.md#permissions-and-access-to-confidential-issues). | ||||
| 
 | ||||
| ### Container Registry visibility permissions | ||||
| 
 | ||||
| Find the visibility permissions for the Container Registry, as described in the | ||||
| [related documentation](packages/container_registry/index.md#container-registry-visibility-permissions). | ||||
| 
 | ||||
| ## Group members permissions | ||||
| 
 | ||||
| NOTE: | ||||
|  |  | |||
|  | @ -3,6 +3,8 @@ | |||
| module Gitlab | ||||
|   module Auth | ||||
|     Result = Struct.new(:actor, :project, :type, :authentication_abilities) do | ||||
|       self::EMPTY = self.new(nil, nil, nil, nil).freeze | ||||
| 
 | ||||
|       def ci?(for_project) | ||||
|         type == :ci && | ||||
|           project && | ||||
|  | @ -29,6 +31,20 @@ module Gitlab | |||
|       def deploy_token | ||||
|         actor.is_a?(DeployToken) ? actor : nil | ||||
|       end | ||||
| 
 | ||||
|       def can?(action) | ||||
|         actor&.can?(action) | ||||
|       end | ||||
| 
 | ||||
|       def can_perform_action_on_project?(action, given_project) | ||||
|         Ability.allowed?(actor, action, given_project) | ||||
|       end | ||||
| 
 | ||||
|       def authentication_abilities_include?(ability) | ||||
|         return false if authentication_abilities.blank? | ||||
| 
 | ||||
|         authentication_abilities.include?(ability) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -0,0 +1,62 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module BackgroundMigration | ||||
|     # Backfills the new `integrations.type_new` column, which contains | ||||
|     # the real class name, rather than the legacy class name in `type` | ||||
|     # which is mapped via `Gitlab::Integrations::StiType`. | ||||
|     class BackfillIntegrationsTypeNew | ||||
|       def perform(start_id, stop_id, *args) | ||||
|         ActiveRecord::Base.connection.execute(<<~SQL) | ||||
|           WITH mapping(old_type, new_type) AS (VALUES | ||||
|             ('AsanaService',                   'Integrations::Asana'), | ||||
|             ('AssemblaService',                'Integrations::Assembla'), | ||||
|             ('BambooService',                  'Integrations::Bamboo'), | ||||
|             ('BugzillaService',                'Integrations::Bugzilla'), | ||||
|             ('BuildkiteService',               'Integrations::Buildkite'), | ||||
|             ('CampfireService',                'Integrations::Campfire'), | ||||
|             ('ConfluenceService',              'Integrations::Confluence'), | ||||
|             ('CustomIssueTrackerService',      'Integrations::CustomIssueTracker'), | ||||
|             ('DatadogService',                 'Integrations::Datadog'), | ||||
|             ('DiscordService',                 'Integrations::Discord'), | ||||
|             ('DroneCiService',                 'Integrations::DroneCi'), | ||||
|             ('EmailsOnPushService',            'Integrations::EmailsOnPush'), | ||||
|             ('EwmService',                     'Integrations::Ewm'), | ||||
|             ('ExternalWikiService',            'Integrations::ExternalWiki'), | ||||
|             ('FlowdockService',                'Integrations::Flowdock'), | ||||
|             ('HangoutsChatService',            'Integrations::HangoutsChat'), | ||||
|             ('IrkerService',                   'Integrations::Irker'), | ||||
|             ('JenkinsService',                 'Integrations::Jenkins'), | ||||
|             ('JiraService',                    'Integrations::Jira'), | ||||
|             ('MattermostService',              'Integrations::Mattermost'), | ||||
|             ('MattermostSlashCommandsService', 'Integrations::MattermostSlashCommands'), | ||||
|             ('MicrosoftTeamsService',          'Integrations::MicrosoftTeams'), | ||||
|             ('MockCiService',                  'Integrations::MockCi'), | ||||
|             ('MockMonitoringService',          'Integrations::MockMonitoring'), | ||||
|             ('PackagistService',               'Integrations::Packagist'), | ||||
|             ('PipelinesEmailService',          'Integrations::PipelinesEmail'), | ||||
|             ('PivotaltrackerService',          'Integrations::Pivotaltracker'), | ||||
|             ('PrometheusService',              'Integrations::Prometheus'), | ||||
|             ('PushoverService',                'Integrations::Pushover'), | ||||
|             ('RedmineService',                 'Integrations::Redmine'), | ||||
|             ('SlackService',                   'Integrations::Slack'), | ||||
|             ('SlackSlashCommandsService',      'Integrations::SlackSlashCommands'), | ||||
|             ('TeamcityService',                'Integrations::Teamcity'), | ||||
|             ('UnifyCircuitService',            'Integrations::UnifyCircuit'), | ||||
|             ('WebexTeamsService',              'Integrations::WebexTeams'), | ||||
|             ('YoutrackService',                'Integrations::Youtrack'), | ||||
| 
 | ||||
|             -- EE-only integrations | ||||
|             ('GithubService',                  'Integrations::Github'), | ||||
|             ('GitlabSlackApplicationService',  'Integrations::GitlabSlackApplication') | ||||
|           ) | ||||
| 
 | ||||
|           UPDATE integrations SET type_new = mapping.new_type | ||||
|           FROM mapping | ||||
|           WHERE integrations.id BETWEEN #{start_id} AND #{stop_id} | ||||
|             AND integrations.type = mapping.old_type | ||||
|         SQL | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,11 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module GithubImport | ||||
|     class Logger < ::Gitlab::Import::Logger | ||||
|       def default_attributes | ||||
|         super.merge(import_source: :github) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -174,11 +174,11 @@ module Gitlab | |||
|       private | ||||
| 
 | ||||
|       def info(project_id, extra = {}) | ||||
|         logger.info(log_attributes(project_id, extra)) | ||||
|         Logger.info(log_attributes(project_id, extra)) | ||||
|       end | ||||
| 
 | ||||
|       def error(project_id, exception) | ||||
|         logger.error( | ||||
|         Logger.error( | ||||
|           log_attributes( | ||||
|             project_id, | ||||
|             message: 'importer failed', | ||||
|  | @ -188,22 +188,17 @@ module Gitlab | |||
| 
 | ||||
|         Gitlab::ErrorTracking.track_exception( | ||||
|           exception, | ||||
|           log_attributes(project_id) | ||||
|           log_attributes(project_id, import_source: :github) | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       def log_attributes(project_id, extra = {}) | ||||
|         extra.merge( | ||||
|           import_source: :github, | ||||
|           project_id: project_id, | ||||
|           importer: importer_class.name, | ||||
|           parallel: parallel? | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       def logger | ||||
|         @logger ||= Gitlab::Import::Logger.build | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -6,6 +6,10 @@ module Gitlab | |||
|       def self.file_name_noext | ||||
|         'importer' | ||||
|       end | ||||
| 
 | ||||
|       def default_attributes | ||||
|         super.merge(feature_category: :importers) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ module Gitlab | |||
|         Asana Assembla Bamboo Bugzilla Buildkite Campfire Confluence CustomIssueTracker Datadog | ||||
|         Discord DroneCi EmailsOnPush Ewm ExternalWiki Flowdock HangoutsChat Irker Jenkins Jira Mattermost | ||||
|         MattermostSlashCommands MicrosoftTeams MockCi MockMonitoring Packagist PipelinesEmail Pivotaltracker | ||||
|         Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit Youtrack WebexTeams | ||||
|         Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack | ||||
|       )).freeze | ||||
| 
 | ||||
|       def self.namespaced_integrations | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ module Gitlab | |||
|     end | ||||
| 
 | ||||
|     def format_message(severity, timestamp, progname, message) | ||||
|       data = {} | ||||
|       data = default_attributes | ||||
|       data[:severity] = severity | ||||
|       data[:time] = timestamp.utc.iso8601(3) | ||||
|       data[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id | ||||
|  | @ -21,5 +21,11 @@ module Gitlab | |||
| 
 | ||||
|       Gitlab::Json.dump(data) + "\n" | ||||
|     end | ||||
| 
 | ||||
|     protected | ||||
| 
 | ||||
|     def default_attributes | ||||
|       {} | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -127,23 +127,25 @@ module Gitlab | |||
| 
 | ||||
|       def project_for_paths(paths, request) | ||||
|         project = Project.where_full_path_in(paths).first | ||||
|         return unless Ability.allowed?(current_user(request, project), :read_project, project) | ||||
| 
 | ||||
|         return unless authentication_result(request, project).can_perform_action_on_project?(:read_project, project) | ||||
| 
 | ||||
|         project | ||||
|       end | ||||
| 
 | ||||
|       def current_user(request, project) | ||||
|         return unless has_basic_credentials?(request) | ||||
|       def authentication_result(request, project) | ||||
|         empty_result = Gitlab::Auth::Result::EMPTY | ||||
|         return empty_result unless has_basic_credentials?(request) | ||||
| 
 | ||||
|         login, password = user_name_and_password(request) | ||||
|         auth_result = Gitlab::Auth.find_for_git_client(login, password, project: project, ip: request.ip) | ||||
|         return unless auth_result.success? | ||||
|         return empty_result unless auth_result.success? | ||||
| 
 | ||||
|         return unless auth_result.actor&.can?(:access_git) | ||||
|         return empty_result unless auth_result.can?(:access_git) | ||||
| 
 | ||||
|         return unless auth_result.authentication_abilities.include?(:read_project) | ||||
|         return empty_result unless auth_result.authentication_abilities_include?(:read_project) | ||||
| 
 | ||||
|         auth_result.actor | ||||
|         auth_result | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  |  | |||
|  | @ -18710,6 +18710,9 @@ msgstr "" | |||
| msgid "Jira-GitLab user mapping template" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "JiraConnect|Create branch for Jira issue %{jiraIssue}" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "JiraConnect|Failed to create branch." | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
|  | @ -247,7 +247,7 @@ function deploy() { | |||
|   gitlab_migrations_image_repository="${IMAGE_REPOSITORY}/gitlab-rails-ee" | ||||
|   gitlab_sidekiq_image_repository="${IMAGE_REPOSITORY}/gitlab-sidekiq-ee" | ||||
|   gitlab_webservice_image_repository="${IMAGE_REPOSITORY}/gitlab-webservice-ee" | ||||
|   gitlab_task_runner_image_repository="${IMAGE_REPOSITORY}/gitlab-task-runner-ee" | ||||
|   gitlab_task_runner_image_repository="${IMAGE_REPOSITORY}/gitlab-toolbox-ee" | ||||
|   gitlab_gitaly_image_repository="${IMAGE_REPOSITORY}/gitaly" | ||||
|   gitaly_image_tag=$(parse_gitaly_image_tag) | ||||
|   gitlab_shell_image_repository="${IMAGE_REPOSITORY}/gitlab-shell" | ||||
|  |  | |||
|  | @ -15,21 +15,24 @@ RSpec.describe JiraConnect::BranchesController do | |||
|         get :new, params: { issue_key: 'ACME-123', issue_summary: 'My Issue !@#$%' } | ||||
| 
 | ||||
|         expect(response).to be_successful | ||||
|         expect(assigns(:branch_name)).to eq('ACME-123-my-issue') | ||||
|         expect(assigns(:new_branch_data)).to include( | ||||
|           initial_branch_name: 'ACME-123-my-issue', | ||||
|           success_state_svg_path: start_with('/assets/illustrations/merge_requests-') | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       it 'ignores missing summary' do | ||||
|         get :new, params: { issue_key: 'ACME-123' } | ||||
| 
 | ||||
|         expect(response).to be_successful | ||||
|         expect(assigns(:branch_name)).to eq('ACME-123') | ||||
|         expect(assigns(:new_branch_data)).to include(initial_branch_name: 'ACME-123') | ||||
|       end | ||||
| 
 | ||||
|       it 'does not set a branch name if key is not passed' do | ||||
|         get :new, params: { issue_summary: 'My issue' } | ||||
| 
 | ||||
|         expect(response).to be_successful | ||||
|         expect(assigns(:branch_name)).to be_nil | ||||
|         expect(assigns(:new_branch_data)).to include('initial_branch_name': nil) | ||||
|       end | ||||
| 
 | ||||
|       context 'when feature flag is disabled' do | ||||
|  |  | |||
|  | @ -46,9 +46,9 @@ RSpec.describe 'Value Stream Analytics', :js do | |||
|         @build = create_cycle(user, project, issue, mr, milestone, pipeline) | ||||
|         deploy_master(user, project) | ||||
| 
 | ||||
|         issue.metrics.update!(first_mentioned_in_commit_at: issue.metrics.first_associated_with_milestone_at + 1.day) | ||||
|         issue.metrics.update!(first_mentioned_in_commit_at: issue.metrics.first_associated_with_milestone_at + 1.hour) | ||||
|         merge_request = issue.merge_requests_closing_issues.first.merge_request | ||||
|         merge_request.update!(created_at: issue.metrics.first_associated_with_milestone_at + 1.day) | ||||
|         merge_request.update!(created_at: issue.metrics.first_associated_with_milestone_at + 1.hour) | ||||
|         merge_request.metrics.update!( | ||||
|           latest_build_started_at: 4.hours.ago, | ||||
|           latest_build_finished_at: 3.hours.ago, | ||||
|  |  | |||
|  | @ -1,3 +0,0 @@ | |||
| // Jest Snapshot v1, https://goo.gl/fbAQLP | ||||
| 
 | ||||
| exports[`Value stream analytics component isLoading = true renders the path navigation component with prop \`loading\` set to true 1`] = `"<path-navigation-stub loading=\\"true\\" stages=\\"\\" selectedstage=\\"[object Object]\\" class=\\"js-path-navigation gl-w-full gl-pb-2\\"></path-navigation-stub>"`; | ||||
|  | @ -8,7 +8,15 @@ import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; | |||
| import StageTable from '~/cycle_analytics/components/stage_table.vue'; | ||||
| import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants'; | ||||
| import initState from '~/cycle_analytics/store/state'; | ||||
| import { selectedStage, issueEvents } from './mock_data'; | ||||
| import { | ||||
|   permissions, | ||||
|   transformedProjectStagePathData, | ||||
|   selectedStage, | ||||
|   issueEvents, | ||||
|   createdBefore, | ||||
|   createdAfter, | ||||
|   currentGroup, | ||||
| } from './mock_data'; | ||||
| 
 | ||||
| const selectedStageEvents = issueEvents.events; | ||||
| const noDataSvgPath = 'path/to/no/data'; | ||||
|  | @ -18,25 +26,31 @@ Vue.use(Vuex); | |||
| 
 | ||||
| let wrapper; | ||||
| 
 | ||||
| function createStore({ initialState = {} }) { | ||||
| const defaultState = { | ||||
|   permissions, | ||||
|   currentGroup, | ||||
|   createdBefore, | ||||
|   createdAfter, | ||||
| }; | ||||
| 
 | ||||
| function createStore({ initialState = {}, initialGetters = {} }) { | ||||
|   return new Vuex.Store({ | ||||
|     state: { | ||||
|       ...initState(), | ||||
|       permissions: { | ||||
|         [selectedStage.id]: true, | ||||
|       }, | ||||
|       ...defaultState, | ||||
|       ...initialState, | ||||
|     }, | ||||
|     getters: { | ||||
|       pathNavigationData: () => [], | ||||
|       pathNavigationData: () => transformedProjectStagePathData, | ||||
|       ...initialGetters, | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| function createComponent({ initialState } = {}) { | ||||
| function createComponent({ initialState, initialGetters } = {}) { | ||||
|   return extendedWrapper( | ||||
|     shallowMount(BaseComponent, { | ||||
|       store: createStore({ initialState }), | ||||
|       store: createStore({ initialState, initialGetters }), | ||||
|       propsData: { | ||||
|         noDataSvgPath, | ||||
|         noAccessSvgPath, | ||||
|  | @ -57,16 +71,7 @@ const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('tit | |||
| 
 | ||||
| describe('Value stream analytics component', () => { | ||||
|   beforeEach(() => { | ||||
|     wrapper = createComponent({ | ||||
|       initialState: { | ||||
|         isLoading: false, | ||||
|         isLoadingStage: false, | ||||
|         isEmptyStage: false, | ||||
|         selectedStageEvents, | ||||
|         selectedStage, | ||||
|         selectedStageError: '', | ||||
|       }, | ||||
|     }); | ||||
|     wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } }); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|  | @ -102,7 +107,7 @@ describe('Value stream analytics component', () => { | |||
|     }); | ||||
| 
 | ||||
|     it('renders the path navigation component with prop `loading` set to true', () => { | ||||
|       expect(findPathNavigation().html()).toMatchSnapshot(); | ||||
|       expect(findPathNavigation().props('loading')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not render the overview metrics', () => { | ||||
|  | @ -130,13 +135,19 @@ describe('Value stream analytics component', () => { | |||
|       expect(tableWrapper.exists()).toBe(true); | ||||
|       expect(tableWrapper.find(GlLoadingIcon).exists()).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('renders the path navigation loading state', () => { | ||||
|       expect(findPathNavigation().props('loading')).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('isEmptyStage = true', () => { | ||||
|     const emptyStageParams = { | ||||
|       isEmptyStage: true, | ||||
|       selectedStage: { ...selectedStage, emptyStageText: 'This stage is empty' }, | ||||
|     }; | ||||
|     beforeEach(() => { | ||||
|       wrapper = createComponent({ | ||||
|         initialState: { selectedStage, isEmptyStage: true }, | ||||
|       }); | ||||
|       wrapper = createComponent({ initialState: emptyStageParams }); | ||||
|     }); | ||||
| 
 | ||||
|     it('renders the empty stage with `Not enough data` message', () => { | ||||
|  | @ -147,8 +158,7 @@ describe('Value stream analytics component', () => { | |||
|       beforeEach(() => { | ||||
|         wrapper = createComponent({ | ||||
|           initialState: { | ||||
|             selectedStage, | ||||
|             isEmptyStage: true, | ||||
|             ...emptyStageParams, | ||||
|             selectedStageError: 'There is too much data to calculate', | ||||
|           }, | ||||
|         }); | ||||
|  | @ -164,7 +174,9 @@ describe('Value stream analytics component', () => { | |||
|     beforeEach(() => { | ||||
|       wrapper = createComponent({ | ||||
|         initialState: { | ||||
|           selectedStage, | ||||
|           permissions: { | ||||
|             ...permissions, | ||||
|             [selectedStage.id]: false, | ||||
|           }, | ||||
|         }, | ||||
|  | @ -179,6 +191,7 @@ describe('Value stream analytics component', () => { | |||
|   describe('without a selected stage', () => { | ||||
|     beforeEach(() => { | ||||
|       wrapper = createComponent({ | ||||
|         initialGetters: { pathNavigationData: () => [] }, | ||||
|         initialState: { selectedStage: null, isEmptyStage: true }, | ||||
|       }); | ||||
|     }); | ||||
|  | @ -187,7 +200,7 @@ describe('Value stream analytics component', () => { | |||
|       expect(findStageTable().exists()).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not render the path navigation component', () => { | ||||
|     it('does not render the path navigation', () => { | ||||
|       expect(findPathNavigation().exists()).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,39 +2,23 @@ import axios from 'axios'; | |||
| import MockAdapter from 'axios-mock-adapter'; | ||||
| import testAction from 'helpers/vuex_action_helper'; | ||||
| import * as actions from '~/cycle_analytics/store/actions'; | ||||
| import * as getters from '~/cycle_analytics/store/getters'; | ||||
| import httpStatusCodes from '~/lib/utils/http_status'; | ||||
| import { allowedStages, selectedStage, selectedValueStream } from '../mock_data'; | ||||
| 
 | ||||
| const mockRequestPath = 'some/cool/path'; | ||||
| const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; | ||||
| const mockStartDate = 30; | ||||
| const mockRequestedDataActions = ['fetchValueStreams', 'fetchCycleAnalyticsData']; | ||||
| const mockInitializeActionCommit = { | ||||
|   payload: { requestPath: mockRequestPath }, | ||||
|   type: 'INITIALIZE_VSA', | ||||
| }; | ||||
| const mockEndpoints = { fullPath: mockFullPath, requestPath: mockRequestPath }; | ||||
| const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' }; | ||||
| const mockRequestedDataMutations = [ | ||||
|   { | ||||
|     payload: true, | ||||
|     type: 'SET_LOADING', | ||||
|   }, | ||||
|   { | ||||
|     payload: false, | ||||
|     type: 'SET_LOADING', | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| const features = { | ||||
|   cycleAnalyticsForGroups: true, | ||||
| }; | ||||
| const defaultState = { ...getters, selectedValueStream }; | ||||
| 
 | ||||
| describe('Project Value Stream Analytics actions', () => { | ||||
|   let state; | ||||
|   let mock; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     state = {}; | ||||
|     mock = new MockAdapter(axios); | ||||
|   }); | ||||
| 
 | ||||
|  | @ -45,28 +29,62 @@ describe('Project Value Stream Analytics actions', () => { | |||
| 
 | ||||
|   const mutationTypes = (arr) => arr.map(({ type }) => type); | ||||
| 
 | ||||
|   const mockFetchStageDataActions = [ | ||||
|     { type: 'setLoading', payload: true }, | ||||
|     { type: 'fetchCycleAnalyticsData' }, | ||||
|     { type: 'fetchStageData' }, | ||||
|     { type: 'fetchStageMedians' }, | ||||
|     { type: 'setLoading', payload: false }, | ||||
|   ]; | ||||
| 
 | ||||
|   describe.each` | ||||
|     action                      | payload                         | expectedActions                                                              | expectedMutations | ||||
|     ${'initializeVsa'}          | ${{ requestPath: mockRequestPath }} | ${mockRequestedDataActions}   | ${[mockInitializeActionCommit, ...mockRequestedDataMutations]} | ||||
|     ${'setDateRange'}           | ${{ startDate: mockStartDate }}     | ${mockRequestedDataActions}   | ${[mockSetDateActionCommit, ...mockRequestedDataMutations]} | ||||
|     ${'setSelectedStage'}       | ${{ selectedStage }}                | ${['fetchStageData']}         | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} | ||||
|     ${'setSelectedValueStream'} | ${{ selectedValueStream }}          | ${['fetchValueStreamStages']} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} | ||||
|     ${'setLoading'}             | ${true}                         | ${[]}                                                                        | ${[{ type: 'SET_LOADING', payload: true }]} | ||||
|     ${'setDateRange'}           | ${{ startDate: mockStartDate }} | ${mockFetchStageDataActions}                                                 | ${[mockSetDateActionCommit]} | ||||
|     ${'setFilters'}             | ${[]}                           | ${mockFetchStageDataActions}                                                 | ${[]} | ||||
|     ${'setSelectedStage'}       | ${{ selectedStage }}            | ${[{ type: 'fetchStageData' }]}                                              | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} | ||||
|     ${'setSelectedValueStream'} | ${{ selectedValueStream }}      | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} | ||||
|   `('$action', ({ action, payload, expectedActions, expectedMutations }) => {
 | ||||
|     const types = mutationTypes(expectedMutations); | ||||
| 
 | ||||
|     it(`will dispatch ${expectedActions} and commit ${types}`, () => | ||||
|       testAction({ | ||||
|         action: actions[action], | ||||
|         state, | ||||
|         payload, | ||||
|         expectedMutations, | ||||
|         expectedActions: expectedActions.map((a) => ({ type: a })), | ||||
|         expectedActions, | ||||
|       })); | ||||
|   }); | ||||
| 
 | ||||
|   describe('initializeVsa', () => { | ||||
|     let mockDispatch; | ||||
|     let mockCommit; | ||||
|     const payload = { endpoints: mockEndpoints }; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       mockDispatch = jest.fn(() => Promise.resolve()); | ||||
|       mockCommit = jest.fn(); | ||||
|     }); | ||||
| 
 | ||||
|     it('will dispatch the setLoading and fetchValueStreams actions and commit INITIALIZE_VSA', async () => { | ||||
|       await actions.initializeVsa( | ||||
|         { | ||||
|           ...state, | ||||
|           dispatch: mockDispatch, | ||||
|           commit: mockCommit, | ||||
|         }, | ||||
|         payload, | ||||
|       ); | ||||
|       expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', { endpoints: mockEndpoints }); | ||||
|       expect(mockDispatch).toHaveBeenCalledWith('setLoading', true); | ||||
|       expect(mockDispatch).toHaveBeenCalledWith('fetchValueStreams'); | ||||
|       expect(mockDispatch).toHaveBeenCalledWith('setLoading', false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('fetchCycleAnalyticsData', () => { | ||||
|     beforeEach(() => { | ||||
|       state = { requestPath: mockRequestPath }; | ||||
|       state = { endpoints: mockEndpoints }; | ||||
|       mock = new MockAdapter(axios); | ||||
|       mock.onGet(mockRequestPath).reply(httpStatusCodes.OK); | ||||
|     }); | ||||
|  | @ -85,7 +103,7 @@ describe('Project Value Stream Analytics actions', () => { | |||
| 
 | ||||
|     describe('with a failing request', () => { | ||||
|       beforeEach(() => { | ||||
|         state = { requestPath: mockRequestPath }; | ||||
|         state = { endpoints: mockEndpoints }; | ||||
|         mock = new MockAdapter(axios); | ||||
|         mock.onGet(mockRequestPath).reply(httpStatusCodes.BAD_REQUEST); | ||||
|       }); | ||||
|  | @ -105,11 +123,12 @@ describe('Project Value Stream Analytics actions', () => { | |||
|   }); | ||||
| 
 | ||||
|   describe('fetchStageData', () => { | ||||
|     const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}`; | ||||
|     const mockStagePath = /value_streams\/\w+\/stages\/\w+\/records/; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       state = { | ||||
|         requestPath: mockRequestPath, | ||||
|         ...defaultState, | ||||
|         endpoints: mockEndpoints, | ||||
|         startDate: mockStartDate, | ||||
|         selectedStage, | ||||
|       }; | ||||
|  | @ -131,7 +150,8 @@ describe('Project Value Stream Analytics actions', () => { | |||
| 
 | ||||
|       beforeEach(() => { | ||||
|         state = { | ||||
|           requestPath: mockRequestPath, | ||||
|           ...defaultState, | ||||
|           endpoints: mockEndpoints, | ||||
|           startDate: mockStartDate, | ||||
|           selectedStage, | ||||
|         }; | ||||
|  | @ -155,7 +175,8 @@ describe('Project Value Stream Analytics actions', () => { | |||
|     describe('with a failing request', () => { | ||||
|       beforeEach(() => { | ||||
|         state = { | ||||
|           requestPath: mockRequestPath, | ||||
|           ...defaultState, | ||||
|           endpoints: mockEndpoints, | ||||
|           startDate: mockStartDate, | ||||
|           selectedStage, | ||||
|         }; | ||||
|  | @ -179,8 +200,7 @@ describe('Project Value Stream Analytics actions', () => { | |||
| 
 | ||||
|     beforeEach(() => { | ||||
|       state = { | ||||
|         features, | ||||
|         fullPath: mockFullPath, | ||||
|         endpoints: mockEndpoints, | ||||
|       }; | ||||
|       mock = new MockAdapter(axios); | ||||
|       mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); | ||||
|  | @ -199,26 +219,6 @@ describe('Project Value Stream Analytics actions', () => { | |||
|         ], | ||||
|       })); | ||||
| 
 | ||||
|     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); | ||||
|  | @ -271,7 +271,7 @@ describe('Project Value Stream Analytics actions', () => { | |||
| 
 | ||||
|     beforeEach(() => { | ||||
|       state = { | ||||
|         fullPath: mockFullPath, | ||||
|         endpoints: mockEndpoints, | ||||
|         selectedValueStream, | ||||
|       }; | ||||
|       mock = new MockAdapter(axios); | ||||
|  |  | |||
|  | @ -21,15 +21,12 @@ const convertedEvents = issueEvents.events; | |||
| const mockRequestPath = 'fake/request/path'; | ||||
| 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 = { features }; | ||||
|     state = {}; | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|  | @ -61,24 +58,44 @@ describe('Project Value Stream Analytics mutations', () => { | |||
|     ${types.REQUEST_STAGE_MEDIANS}                | ${'medians'}             | ${{}} | ||||
|     ${types.RECEIVE_STAGE_MEDIANS_ERROR}          | ${'medians'}             | ${{}} | ||||
|   `('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => {
 | ||||
|     mutations[mutation](state, {}); | ||||
|     mutations[mutation](state); | ||||
| 
 | ||||
|     expect(state).toMatchObject({ [stateKey]: value }); | ||||
|   }); | ||||
| 
 | ||||
|   const mockInitialPayload = { | ||||
|     endpoints: { requestPath: mockRequestPath }, | ||||
|     currentGroup: { title: 'cool-group' }, | ||||
|     id: 1337, | ||||
|   }; | ||||
|   const mockInitializedObj = { | ||||
|     endpoints: { requestPath: mockRequestPath }, | ||||
|     createdAfter: mockCreatedAfter, | ||||
|     createdBefore: mockCreatedBefore, | ||||
|   }; | ||||
| 
 | ||||
|   it.each` | ||||
|     mutation                | stateKey           | value | ||||
|     ${types.INITIALIZE_VSA} | ${'endpoints'}     | ${{ requestPath: mockRequestPath }} | ||||
|     ${types.INITIALIZE_VSA} | ${'createdAfter'}  | ${mockCreatedAfter} | ||||
|     ${types.INITIALIZE_VSA} | ${'createdBefore'} | ${mockCreatedBefore} | ||||
|   `('$mutation will set $stateKey', ({ mutation, stateKey, value }) => {
 | ||||
|     mutations[mutation](state, { ...mockInitialPayload }); | ||||
| 
 | ||||
|     expect(state).toMatchObject({ ...mockInitializedObj, [stateKey]: value }); | ||||
|   }); | ||||
| 
 | ||||
|   it.each` | ||||
|     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_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_VALUE_STREAMS_SUCCESS}        | ${[selectedValueStream]}                  | ${'valueStreams'}        | ${[selectedValueStream]} | ||||
|     ${types.RECEIVE_STAGE_MEDIANS_SUCCESS}        | ${rawStageMedians}                  | ${'medians'}             | ${formattedStageMedians} | ||||
|   `(
 | ||||
|     '$mutation with $payload will set $stateKey to $value', | ||||
|  | @ -98,40 +115,9 @@ describe('Project Value Stream Analytics mutations', () => { | |||
| 
 | ||||
|     it.each` | ||||
|       mutation                            | payload      | stateKey                 | value | ||||
|       ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: [] }}        | ${'isEmptyStage'}        | ${true} | ||||
|       ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'selectedStageEvents'} | ${convertedEvents} | ||||
|       ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'isEmptyStage'}        | ${false} | ||||
|     `(
 | ||||
|       '$mutation with $payload will set $stateKey to $value', | ||||
|       ({ mutation, payload, stateKey, value }) => { | ||||
|         mutations[mutation](state, payload); | ||||
| 
 | ||||
|         expect(state).toMatchObject({ [stateKey]: value }); | ||||
|       }, | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   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'} | ${{}} | ||||
|       ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${[]}        | ${'isEmptyStage'}        | ${true} | ||||
|       ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'selectedStageEvents'} | ${convertedEvents} | ||||
|       ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'isEmptyStage'}        | ${false} | ||||
|     `(
 | ||||
|       '$mutation with $payload will set $stateKey to $value', | ||||
|       ({ mutation, payload, stateKey, value }) => { | ||||
|  |  | |||
|  | @ -9,7 +9,6 @@ import SourceBranchDropdown from '~/jira_connect/branches/components/source_bran | |||
| import { | ||||
|   CREATE_BRANCH_ERROR_GENERIC, | ||||
|   CREATE_BRANCH_ERROR_WITH_CONTEXT, | ||||
|   CREATE_BRANCH_SUCCESS_ALERT, | ||||
| } from '~/jira_connect/branches/constants'; | ||||
| import createBranchMutation from '~/jira_connect/branches/graphql/mutations/create_branch.mutation.graphql'; | ||||
| 
 | ||||
|  | @ -74,10 +73,14 @@ describe('NewBranchForm', () => { | |||
|     return mockApollo; | ||||
|   } | ||||
| 
 | ||||
|   function createComponent({ mockApollo } = {}) { | ||||
|   function createComponent({ mockApollo, provide } = {}) { | ||||
|     wrapper = shallowMount(NewBranchForm, { | ||||
|       localVue, | ||||
|       apolloProvider: mockApollo || createMockApolloProvider(), | ||||
|       provide: { | ||||
|         initialBranchName: '', | ||||
|         ...provide, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|  | @ -139,14 +142,8 @@ describe('NewBranchForm', () => { | |||
|         await waitForPromises(); | ||||
|       }); | ||||
| 
 | ||||
|       it('displays a success message', () => { | ||||
|         const alert = findAlert(); | ||||
|         expect(alert.exists()).toBe(true); | ||||
|         expect(alert.text()).toBe(CREATE_BRANCH_SUCCESS_ALERT.message); | ||||
|         expect(alert.props()).toMatchObject({ | ||||
|           title: CREATE_BRANCH_SUCCESS_ALERT.title, | ||||
|           variant: 'success', | ||||
|         }); | ||||
|       it('emits `success` event', () => { | ||||
|         expect(wrapper.emitted('success')).toBeTruthy(); | ||||
|       }); | ||||
| 
 | ||||
|       it('called `createBranch` mutation correctly', () => { | ||||
|  | @ -195,6 +192,15 @@ describe('NewBranchForm', () => { | |||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when `initialBranchName` is specified', () => { | ||||
|     it('sets value of branch name input to `initialBranchName` by default', () => { | ||||
|       const mockInitialBranchName = 'ap1-test-branch-name'; | ||||
| 
 | ||||
|       createComponent({ provide: { initialBranchName: mockInitialBranchName } }); | ||||
|       expect(findInput().attributes('value')).toBe(mockInitialBranchName); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('error handling', () => { | ||||
|     describe.each` | ||||
|       component               | componentName | ||||
|  |  | |||
|  | @ -0,0 +1,65 @@ | |||
| import { GlEmptyState } from '@gitlab/ui'; | ||||
| import { shallowMount } from '@vue/test-utils'; | ||||
| import NewBranchForm from '~/jira_connect/branches/components/new_branch_form.vue'; | ||||
| import { | ||||
|   I18N_PAGE_TITLE_WITH_BRANCH_NAME, | ||||
|   I18N_PAGE_TITLE_DEFAULT, | ||||
| } from '~/jira_connect/branches/constants'; | ||||
| import JiraConnectNewBranchPage from '~/jira_connect/branches/pages/index.vue'; | ||||
| import { sprintf } from '~/locale'; | ||||
| 
 | ||||
| describe('NewBranchForm', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const findPageTitle = () => wrapper.find('h1'); | ||||
|   const findNewBranchForm = () => wrapper.findComponent(NewBranchForm); | ||||
|   const findEmptyState = () => wrapper.findComponent(GlEmptyState); | ||||
| 
 | ||||
|   function createComponent({ provide } = {}) { | ||||
|     wrapper = shallowMount(JiraConnectNewBranchPage, { | ||||
|       provide: { | ||||
|         initialBranchName: '', | ||||
|         successStateSvgPath: '', | ||||
|         ...provide, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     wrapper.destroy(); | ||||
|   }); | ||||
| 
 | ||||
|   describe('page title', () => { | ||||
|     it.each` | ||||
|       initialBranchName    | pageTitle | ||||
|       ${undefined}         | ${I18N_PAGE_TITLE_DEFAULT} | ||||
|       ${'ap1-test-button'} | ${sprintf(I18N_PAGE_TITLE_WITH_BRANCH_NAME, { jiraIssue: 'ap1-test-button' })} | ||||
|     `(
 | ||||
|       'sets page title to "$pageTitle" when initial branch name is "$initialBranchName"', | ||||
|       ({ initialBranchName, pageTitle }) => { | ||||
|         createComponent({ provide: { initialBranchName } }); | ||||
| 
 | ||||
|         expect(findPageTitle().text()).toBe(pageTitle); | ||||
|       }, | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it('renders NewBranchForm by default', () => { | ||||
|     createComponent(); | ||||
| 
 | ||||
|     expect(findNewBranchForm().exists()).toBe(true); | ||||
|     expect(findEmptyState().exists()).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when `sucesss` event emitted from NewBranchForm', () => { | ||||
|     it('renders the success state', async () => { | ||||
|       createComponent(); | ||||
| 
 | ||||
|       const newBranchForm = findNewBranchForm(); | ||||
|       await newBranchForm.vm.$emit('success'); | ||||
| 
 | ||||
|       expect(findNewBranchForm().exists()).toBe(false); | ||||
|       expect(findEmptyState().exists()).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -37,6 +37,8 @@ describe('DeleteBlobModal', () => { | |||
| 
 | ||||
|   const findModal = () => wrapper.findComponent(GlModal); | ||||
|   const findForm = () => findModal().findComponent(GlForm); | ||||
|   const findCommitTextarea = () => findForm().findComponent(GlFormTextarea); | ||||
|   const findTargetInput = () => findForm().findComponent(GlFormInput); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     wrapper.destroy(); | ||||
|  | @ -65,18 +67,6 @@ describe('DeleteBlobModal', () => { | |||
|       expect(findForm().attributes('action')).toBe(initialProps.deletePath); | ||||
|     }); | ||||
| 
 | ||||
|     it('submits the form', async () => { | ||||
|       createFullComponent(); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       const submitSpy = jest.spyOn(findForm().element, 'submit'); | ||||
|       findModal().vm.$emit('primary', { preventDefault: () => {} }); | ||||
|       await nextTick(); | ||||
| 
 | ||||
|       expect(submitSpy).toHaveBeenCalled(); | ||||
|       submitSpy.mockRestore(); | ||||
|     }); | ||||
| 
 | ||||
|     it.each` | ||||
|       component         | defaultValue                  | canPushCode | targetBranch                 | originalBranch                 | exist | ||||
|       ${GlFormTextarea} | ${initialProps.commitMessage} | ${true}     | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} | ||||
|  | @ -135,4 +125,62 @@ describe('DeleteBlobModal', () => { | |||
|       }, | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   describe('form submission', () => { | ||||
|     let submitSpy; | ||||
| 
 | ||||
|     beforeEach(async () => { | ||||
|       createFullComponent(); | ||||
|       await nextTick(); | ||||
|       submitSpy = jest.spyOn(findForm().element, 'submit'); | ||||
|     }); | ||||
| 
 | ||||
|     afterEach(() => { | ||||
|       submitSpy.mockRestore(); | ||||
|     }); | ||||
| 
 | ||||
|     const fillForm = async (inputValue = {}) => { | ||||
|       const { targetText, commitText } = inputValue; | ||||
| 
 | ||||
|       await findTargetInput().vm.$emit('input', targetText); | ||||
|       await findCommitTextarea().vm.$emit('input', commitText); | ||||
|     }; | ||||
| 
 | ||||
|     describe('invalid form', () => { | ||||
|       beforeEach(async () => { | ||||
|         await fillForm({ targetText: '', commitText: '' }); | ||||
|       }); | ||||
| 
 | ||||
|       it('disables submit button', async () => { | ||||
|         expect(findModal().props('actionPrimary').attributes[0]).toEqual( | ||||
|           expect.objectContaining({ disabled: true }), | ||||
|         ); | ||||
|       }); | ||||
| 
 | ||||
|       it('does not submit form', async () => { | ||||
|         findModal().vm.$emit('primary', { preventDefault: () => {} }); | ||||
|         expect(submitSpy).not.toHaveBeenCalled(); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('valid form', () => { | ||||
|       beforeEach(async () => { | ||||
|         await fillForm({ | ||||
|           targetText: 'some valid target branch', | ||||
|           commitText: 'some valid commit message', | ||||
|         }); | ||||
|       }); | ||||
| 
 | ||||
|       it('enables submit button', async () => { | ||||
|         expect(findModal().props('actionPrimary').attributes[0]).toEqual( | ||||
|           expect.objectContaining({ disabled: false }), | ||||
|         ); | ||||
|       }); | ||||
| 
 | ||||
|       it('submits form', async () => { | ||||
|         findModal().vm.$emit('primary', { preventDefault: () => {} }); | ||||
|         expect(submitSpy).toHaveBeenCalled(); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -97,4 +97,34 @@ RSpec.describe Banzai::Filter::References::ProjectReferenceFilter do | |||
|       expect(filter.send(:projects)).to eq([project.full_path]) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'checking N+1' do | ||||
|     let_it_be(:normal_project)           { create(:project, :public) } | ||||
|     let_it_be(:group)                    { create(:group) } | ||||
|     let_it_be(:group_project)            { create(:project, group: group) } | ||||
|     let_it_be(:nested_group)             { create(:group, :nested) } | ||||
|     let_it_be(:nested_project)           { create(:project, group: nested_group) } | ||||
|     let_it_be(:normal_project_reference) { get_reference(normal_project) } | ||||
|     let_it_be(:group_project_reference)  { get_reference(group_project) } | ||||
|     let_it_be(:nested_project_reference) { get_reference(nested_project) } | ||||
| 
 | ||||
|     it 'does not have N+1 per multiple project references', :use_sql_query_cache do | ||||
|       markdown = "#{normal_project_reference}" | ||||
| 
 | ||||
|       # warm up first | ||||
|       reference_filter(markdown) | ||||
| 
 | ||||
|       max_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do | ||||
|         reference_filter(markdown) | ||||
|       end.count | ||||
| 
 | ||||
|       expect(max_count).to eq 1 | ||||
| 
 | ||||
|       markdown = "#{normal_project_reference} #{invalidate_reference(normal_project_reference)} #{group_project_reference} #{nested_project_reference}" | ||||
| 
 | ||||
|       expect do | ||||
|         reference_filter(markdown) | ||||
|       end.not_to exceed_all_query_limit(max_count) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -3,10 +3,12 @@ | |||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::Auth::Result do | ||||
|   let_it_be(:actor) { create(:user) } | ||||
| 
 | ||||
|   subject { described_class.new(actor, nil, nil, []) } | ||||
| 
 | ||||
|   context 'when actor is User' do | ||||
|     let(:actor) { create(:user) } | ||||
|     let_it_be(:actor) { create(:user) } | ||||
| 
 | ||||
|     it 'returns auth_user' do | ||||
|       expect(subject.auth_user).to eq(actor) | ||||
|  | @ -18,7 +20,7 @@ RSpec.describe Gitlab::Auth::Result do | |||
|   end | ||||
| 
 | ||||
|   context 'when actor is Deploy token' do | ||||
|     let(:actor) { create(:deploy_token) } | ||||
|     let_it_be(:actor) { create(:deploy_token) } | ||||
| 
 | ||||
|     it 'returns deploy token' do | ||||
|       expect(subject.deploy_token).to eq(actor) | ||||
|  | @ -28,4 +30,50 @@ RSpec.describe Gitlab::Auth::Result do | |||
|       expect(subject.auth_user).to be_nil | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#authentication_abilities_include?' do | ||||
|     context 'when authentication abilities are empty' do | ||||
|       it 'returns false' do | ||||
|         expect(subject.authentication_abilities_include?(:read_code)).to be_falsey | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when authentication abilities are not empty' do | ||||
|       subject { described_class.new(actor, nil, nil, [:push_code]) } | ||||
| 
 | ||||
|       it 'returns false when ability is not allowed' do | ||||
|         expect(subject.authentication_abilities_include?(:read_code)).to be_falsey | ||||
|       end | ||||
| 
 | ||||
|       it 'returns true when ability is allowed' do | ||||
|         expect(subject.authentication_abilities_include?(:push_code)).to be_truthy | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#can_perform_action_on_project?' do | ||||
|     let(:project) { double } | ||||
| 
 | ||||
|     it 'returns if actor can do perform given action on given project' do | ||||
|       expect(Ability).to receive(:allowed?).with(actor, :push_code, project).and_return(true) | ||||
|       expect(subject.can_perform_action_on_project?(:push_code, project)).to be_truthy | ||||
|     end | ||||
| 
 | ||||
|     it 'returns if actor cannot do perform given action on given project' do | ||||
|       expect(Ability).to receive(:allowed?).with(actor, :push_code, project).and_return(false) | ||||
|       expect(subject.can_perform_action_on_project?(:push_code, project)).to be_falsey | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#can?' do | ||||
|     it 'returns if actor can do perform given action on given project' do | ||||
|       expect(actor).to receive(:can?).with(:push_code).and_return(true) | ||||
|       expect(subject.can?(:push_code)).to be_truthy | ||||
|     end | ||||
| 
 | ||||
|     it 'returns if actor cannot do perform given action on given project' do | ||||
|       expect(actor).to receive(:can?).with(:push_code).and_return(false) | ||||
|       expect(subject.can?(:push_code)).to be_falsey | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -0,0 +1,36 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::BackgroundMigration::BackfillIntegrationsTypeNew do | ||||
|   let(:integrations) { table(:integrations) } | ||||
|   let(:namespaced_integrations) { Gitlab::Integrations::StiType.namespaced_integrations } | ||||
| 
 | ||||
|   before do | ||||
|     integrations.connection.execute 'ALTER TABLE integrations DISABLE TRIGGER "trigger_type_new_on_insert"' | ||||
| 
 | ||||
|     namespaced_integrations.each_with_index do |type, i| | ||||
|       integrations.create!(id: i + 1, type: "#{type}Service") | ||||
|     end | ||||
|   ensure | ||||
|     integrations.connection.execute 'ALTER TABLE integrations ENABLE TRIGGER "trigger_type_new_on_insert"' | ||||
|   end | ||||
| 
 | ||||
|   it 'backfills `type_new` for the selected records' do | ||||
|     described_class.new.perform(2, 10) | ||||
| 
 | ||||
|     expect(integrations.where(id: 2..10).pluck(:type, :type_new)).to contain_exactly( | ||||
|       ['AssemblaService',           'Integrations::Assembla'], | ||||
|       ['BambooService',             'Integrations::Bamboo'], | ||||
|       ['BugzillaService',           'Integrations::Bugzilla'], | ||||
|       ['BuildkiteService',          'Integrations::Buildkite'], | ||||
|       ['CampfireService',           'Integrations::Campfire'], | ||||
|       ['ConfluenceService',         'Integrations::Confluence'], | ||||
|       ['CustomIssueTrackerService', 'Integrations::CustomIssueTracker'], | ||||
|       ['DatadogService',            'Integrations::Datadog'], | ||||
|       ['DiscordService',            'Integrations::Discord'] | ||||
|     ) | ||||
| 
 | ||||
|     expect(integrations.where.not(id: 2..10)).to all(have_attributes(type_new: nil)) | ||||
|   end | ||||
| end | ||||
|  | @ -61,18 +61,15 @@ RSpec.describe Gitlab::GithubImport::Importer::LfsObjectsImporter do | |||
|             .and_raise(exception) | ||||
|         end | ||||
| 
 | ||||
|         expect_next_instance_of(Gitlab::Import::Logger) do |logger| | ||||
|           expect(logger) | ||||
|         expect(Gitlab::GithubImport::Logger) | ||||
|           .to receive(:error) | ||||
|           .with( | ||||
|             message: 'importer failed', | ||||
|               import_source: :github, | ||||
|             project_id: project.id, | ||||
|             parallel: false, | ||||
|             importer: 'Gitlab::GithubImport::Importer::LfsObjectImporter', | ||||
|             'error.message': 'Invalid Project URL' | ||||
|           ) | ||||
|         end | ||||
| 
 | ||||
|         expect(Gitlab::ErrorTracking) | ||||
|           .to receive(:track_exception) | ||||
|  |  | |||
|  | @ -0,0 +1,41 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::GithubImport::Logger do | ||||
|   subject(:logger) { described_class.new('/dev/null') } | ||||
| 
 | ||||
|   let(:now) { Time.zone.now } | ||||
| 
 | ||||
|   describe '#format_message' do | ||||
|     before do | ||||
|       allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('new-correlation-id') | ||||
|     end | ||||
| 
 | ||||
|     it 'formats strings' do | ||||
|       output = subject.format_message('INFO', now, 'test', 'Hello world') | ||||
| 
 | ||||
|       expect(Gitlab::Json.parse(output)).to eq({ | ||||
|         'severity' => 'INFO', | ||||
|         'time' => now.utc.iso8601(3), | ||||
|         'message' => 'Hello world', | ||||
|         'correlation_id' => 'new-correlation-id', | ||||
|         'feature_category' => 'importers', | ||||
|         'import_source' => 'github' | ||||
|       }) | ||||
|     end | ||||
| 
 | ||||
|     it 'formats hashes' do | ||||
|       output = subject.format_message('INFO', now, 'test', { hello: 1 }) | ||||
| 
 | ||||
|       expect(Gitlab::Json.parse(output)).to eq({ | ||||
|         'severity' => 'INFO', | ||||
|         'time' => now.utc.iso8601(3), | ||||
|         'hello' => 1, | ||||
|         'correlation_id' => 'new-correlation-id', | ||||
|         'feature_category' => 'importers', | ||||
|         'import_source' => 'github' | ||||
|       }) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -79,26 +79,23 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do | |||
|         .to receive(:sequential_import) | ||||
|         .and_return([]) | ||||
| 
 | ||||
|       expect_next_instance_of(Gitlab::Import::Logger) do |logger| | ||||
|         expect(logger) | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           message: 'starting importer', | ||||
|             import_source: :github, | ||||
|           parallel: false, | ||||
|           project_id: project.id, | ||||
|           importer: 'Class' | ||||
|         ) | ||||
|         expect(logger) | ||||
| 
 | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           message: 'importer finished', | ||||
|             import_source: :github, | ||||
|           parallel: false, | ||||
|           project_id: project.id, | ||||
|           importer: 'Class' | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       importer.execute | ||||
|     end | ||||
|  | @ -112,35 +109,32 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do | |||
|         .to receive(:sequential_import) | ||||
|         .and_raise(exception) | ||||
| 
 | ||||
|       expect_next_instance_of(Gitlab::Import::Logger) do |logger| | ||||
|         expect(logger) | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           message: 'starting importer', | ||||
|             import_source: :github, | ||||
|           parallel: false, | ||||
|           project_id: project.id, | ||||
|           importer: 'Class' | ||||
|         ) | ||||
|         expect(logger) | ||||
| 
 | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:error) | ||||
|         .with( | ||||
|           message: 'importer failed', | ||||
|             import_source: :github, | ||||
|           project_id: project.id, | ||||
|           parallel: false, | ||||
|           importer: 'Class', | ||||
|           'error.message': 'some error' | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       expect(Gitlab::ErrorTracking) | ||||
|         .to receive(:track_exception) | ||||
|         .with( | ||||
|           exception, | ||||
|           import_source: :github, | ||||
|           parallel: false, | ||||
|           project_id: project.id, | ||||
|           import_source: :github, | ||||
|           importer: 'Class' | ||||
|         ) | ||||
|         .and_call_original | ||||
|  |  | |||
|  | @ -0,0 +1,39 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::Import::Logger do | ||||
|   subject { described_class.new('/dev/null') } | ||||
| 
 | ||||
|   let(:now) { Time.zone.now } | ||||
| 
 | ||||
|   describe '#format_message' do | ||||
|     before do | ||||
|       allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('new-correlation-id') | ||||
|     end | ||||
| 
 | ||||
|     it 'formats strings' do | ||||
|       output = subject.format_message('INFO', now, 'test', 'Hello world') | ||||
| 
 | ||||
|       expect(Gitlab::Json.parse(output)).to eq({ | ||||
|         'severity' => 'INFO', | ||||
|         'time' => now.utc.iso8601(3), | ||||
|         'message' => 'Hello world', | ||||
|         'correlation_id' => 'new-correlation-id', | ||||
|         'feature_category' => 'importers' | ||||
|       }) | ||||
|     end | ||||
| 
 | ||||
|     it 'formats hashes' do | ||||
|       output = subject.format_message('INFO', now, 'test', { hello: 1 }) | ||||
| 
 | ||||
|       expect(Gitlab::Json.parse(output)).to eq({ | ||||
|         'severity' => 'INFO', | ||||
|         'time' => now.utc.iso8601(3), | ||||
|         'hello' => 1, | ||||
|         'correlation_id' => 'new-correlation-id', | ||||
|         'feature_category' => 'importers' | ||||
|       }) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,38 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| require_migration! | ||||
| 
 | ||||
| RSpec.describe BackfillIntegrationsTypeNew do | ||||
|   let_it_be(:migration) { described_class::MIGRATION } | ||||
|   let_it_be(:integrations) { table(:integrations) } | ||||
| 
 | ||||
|   before do | ||||
|     integrations.create!(id: 1) | ||||
|     integrations.create!(id: 2) | ||||
|     integrations.create!(id: 3) | ||||
|     integrations.create!(id: 4) | ||||
|     integrations.create!(id: 5) | ||||
|   end | ||||
| 
 | ||||
|   describe '#up' do | ||||
|     it 'schedules background jobs for each batch of integrations' do | ||||
|       migrate! | ||||
| 
 | ||||
|       expect(migration).to have_scheduled_batched_migration( | ||||
|         table_name: :integrations, | ||||
|         column_name: :id, | ||||
|         interval: described_class::INTERVAL | ||||
|       ) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#down' do | ||||
|     it 'deletes all batched migration records' do | ||||
|       migrate! | ||||
|       schema_migrate_down! | ||||
| 
 | ||||
|       expect(migration).not_to have_scheduled_batched_migration | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -4,6 +4,12 @@ require 'spec_helper' | |||
| 
 | ||||
| RSpec.describe Ci::RunnerNamespace do | ||||
|   it_behaves_like 'includes Limitable concern' do | ||||
|     before do | ||||
|       skip_default_enabled_yaml_check | ||||
| 
 | ||||
|       stub_feature_flags(ci_runner_limits_override: false) | ||||
|     end | ||||
| 
 | ||||
|     subject { build(:ci_runner_namespace, group: create(:group, :nested), runner: create(:ci_runner, :group)) } | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -4,6 +4,12 @@ require 'spec_helper' | |||
| 
 | ||||
| RSpec.describe Ci::RunnerProject do | ||||
|   it_behaves_like 'includes Limitable concern' do | ||||
|     before do | ||||
|       skip_default_enabled_yaml_check | ||||
| 
 | ||||
|       stub_feature_flags(ci_runner_limits_override: false) | ||||
|     end | ||||
| 
 | ||||
|     subject { build(:ci_runner_project, project: create(:project), runner: create(:ci_runner, :project)) } | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -98,8 +98,14 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do | |||
|             before do | ||||
|               create(:ci_runner, runner_type: :project_type, projects: [project], contacted_at: 1.second.ago) | ||||
|               create(:plan_limits, :default_plan, ci_registered_project_runners: 1) | ||||
| 
 | ||||
|               skip_default_enabled_yaml_check | ||||
|               stub_feature_flags(ci_runner_limits_override: ci_runner_limits_override) | ||||
|             end | ||||
| 
 | ||||
|             context 'with ci_runner_limits_override FF disabled' do | ||||
|               let(:ci_runner_limits_override) { false } | ||||
| 
 | ||||
|               it 'does not create runner' do | ||||
|                 request | ||||
| 
 | ||||
|  | @ -109,10 +115,26 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do | |||
|               end | ||||
|             end | ||||
| 
 | ||||
|             context 'with ci_runner_limits_override FF enabled' do | ||||
|               let(:ci_runner_limits_override) { true } | ||||
| 
 | ||||
|               it 'creates runner' do | ||||
|                 request | ||||
| 
 | ||||
|                 expect(response).to have_gitlab_http_status(:created) | ||||
|                 expect(json_response['message']).to be_nil | ||||
|                 expect(project.runners.reload.size).to eq(2) | ||||
|               end | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           context 'when abandoned runners cause application limits to not be exceeded' do | ||||
|             before do | ||||
|               create(:ci_runner, runner_type: :project_type, projects: [project], created_at: 14.months.ago, contacted_at: 13.months.ago) | ||||
|               create(:plan_limits, :default_plan, ci_registered_project_runners: 1) | ||||
| 
 | ||||
|               skip_default_enabled_yaml_check | ||||
|               stub_feature_flags(ci_runner_limits_override: false) | ||||
|             end | ||||
| 
 | ||||
|             it 'creates runner' do | ||||
|  | @ -182,8 +204,14 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do | |||
|             before do | ||||
|               create(:ci_runner, runner_type: :group_type, groups: [group], contacted_at: nil, created_at: 1.month.ago) | ||||
|               create(:plan_limits, :default_plan, ci_registered_group_runners: 1) | ||||
| 
 | ||||
|               skip_default_enabled_yaml_check | ||||
|               stub_feature_flags(ci_runner_limits_override: ci_runner_limits_override) | ||||
|             end | ||||
| 
 | ||||
|             context 'with ci_runner_limits_override FF disabled' do | ||||
|               let(:ci_runner_limits_override) { false } | ||||
| 
 | ||||
|               it 'does not create runner' do | ||||
|                 request | ||||
| 
 | ||||
|  | @ -193,11 +221,27 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do | |||
|               end | ||||
|             end | ||||
| 
 | ||||
|             context 'with ci_runner_limits_override FF enabled' do | ||||
|               let(:ci_runner_limits_override) { true } | ||||
| 
 | ||||
|               it 'creates runner' do | ||||
|                 request | ||||
| 
 | ||||
|                 expect(response).to have_gitlab_http_status(:created) | ||||
|                 expect(json_response['message']).to be_nil | ||||
|                 expect(group.runners.reload.size).to eq(2) | ||||
|               end | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           context 'when abandoned runners cause application limits to not be exceeded' do | ||||
|             before do | ||||
|               create(:ci_runner, runner_type: :group_type, groups: [group], created_at: 4.months.ago, contacted_at: 3.months.ago) | ||||
|               create(:ci_runner, runner_type: :group_type, groups: [group], contacted_at: nil, created_at: 4.months.ago) | ||||
|               create(:plan_limits, :default_plan, ci_registered_group_runners: 1) | ||||
| 
 | ||||
|               skip_default_enabled_yaml_check | ||||
|               stub_feature_flags(ci_runner_limits_override: false) | ||||
|             end | ||||
| 
 | ||||
|             it 'creates runner' do | ||||
|  |  | |||
|  | @ -1003,8 +1003,14 @@ RSpec.describe API::Ci::Runners do | |||
|           context 'when it exceeds the application limits' do | ||||
|             before do | ||||
|               create(:plan_limits, :default_plan, ci_registered_project_runners: 1) | ||||
| 
 | ||||
|               skip_default_enabled_yaml_check | ||||
|               stub_feature_flags(ci_runner_limits_override: ci_runner_limits_override) | ||||
|             end | ||||
| 
 | ||||
|             context 'with ci_runner_limits_override FF disabled' do | ||||
|               let(:ci_runner_limits_override) { false } | ||||
| 
 | ||||
|               it 'does not enable specific runner' do | ||||
|                 expect do | ||||
|                   post api("/projects/#{project.id}/runners", admin), params: { runner_id: new_project_runner.id } | ||||
|  | @ -1012,6 +1018,18 @@ RSpec.describe API::Ci::Runners do | |||
|                 expect(response).to have_gitlab_http_status(:bad_request) | ||||
|               end | ||||
|             end | ||||
| 
 | ||||
|             context 'with ci_runner_limits_override FF enabled' do | ||||
|               let(:ci_runner_limits_override) { true } | ||||
| 
 | ||||
|               it 'enables specific runner' do | ||||
|                 expect do | ||||
|                   post api("/projects/#{project.id}/runners", admin), params: { runner_id: new_project_runner.id } | ||||
|                 end.to change { project.runners.count } | ||||
|                 expect(response).to have_gitlab_http_status(:created) | ||||
|               end | ||||
|             end | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         it 'enables a instance type runner' do | ||||
|  |  | |||
|  | @ -64,3 +64,33 @@ RSpec::Matchers.define :be_scheduled_migration_with_multiple_args do |*expected| | |||
|     arg.sort == expected.sort | ||||
|   end | ||||
| end | ||||
| 
 | ||||
| RSpec::Matchers.define :have_scheduled_batched_migration do |table_name: nil, column_name: nil, job_arguments: [], **attributes| | ||||
|   define_method :matches? do |migration| | ||||
|     # Default arguments passed by BatchedMigrationWrapper (values don't matter here) | ||||
|     expect(migration).to be_background_migration_with_arguments([ | ||||
|       _start_id = 1, | ||||
|       _stop_id = 2, | ||||
|       table_name, | ||||
|       column_name, | ||||
|       _sub_batch_size = 10, | ||||
|       _pause_ms = 100, | ||||
|       *job_arguments | ||||
|     ]) | ||||
| 
 | ||||
|     batched_migrations = | ||||
|       Gitlab::Database::BackgroundMigration::BatchedMigration | ||||
|         .for_configuration(migration, table_name, column_name, job_arguments) | ||||
| 
 | ||||
|     expect(batched_migrations.count).to be(1) | ||||
|     expect(batched_migrations).to all(have_attributes(attributes)) if attributes.present? | ||||
|   end | ||||
| 
 | ||||
|   define_method :does_not_match? do |migration| | ||||
|     batched_migrations = | ||||
|       Gitlab::Database::BackgroundMigration::BatchedMigration | ||||
|         .where(job_class_name: migration) | ||||
| 
 | ||||
|     expect(batched_migrations.count).to be(0) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -60,26 +60,23 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do | |||
|       expect(importer_instance) | ||||
|         .to receive(:execute) | ||||
| 
 | ||||
|       expect_next_instance_of(Gitlab::Import::Logger) do |logger| | ||||
|         expect(logger) | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           github_id: 1, | ||||
|           message: 'starting importer', | ||||
|             import_source: :github, | ||||
|           project_id: 1, | ||||
|           importer: 'klass_name' | ||||
|         ) | ||||
|         expect(logger) | ||||
| 
 | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           github_id: 1, | ||||
|           message: 'importer finished', | ||||
|             import_source: :github, | ||||
|           project_id: 1, | ||||
|           importer: 'klass_name' | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       worker.import(project, client, { 'number' => 10, 'github_id' => 1 }) | ||||
| 
 | ||||
|  | @ -100,22 +97,20 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do | |||
|         .to receive(:execute) | ||||
|         .and_raise(exception) | ||||
| 
 | ||||
|       expect_next_instance_of(Gitlab::Import::Logger) do |logger| | ||||
|         expect(logger) | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           github_id: 1, | ||||
|           message: 'starting importer', | ||||
|             import_source: :github, | ||||
|           project_id: project.id, | ||||
|           importer: 'klass_name' | ||||
|         ) | ||||
|         expect(logger) | ||||
| 
 | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:error) | ||||
|         .with( | ||||
|           github_id:  1, | ||||
|           message: 'importer failed', | ||||
|             import_source: :github, | ||||
|           project_id: project.id, | ||||
|           importer: 'klass_name', | ||||
|           'error.message': 'some error', | ||||
|  | @ -124,7 +119,6 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do | |||
|             'number' => 10 | ||||
|           } | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       expect(Gitlab::ErrorTracking) | ||||
|         .to receive(:track_and_raise_exception) | ||||
|  | @ -143,13 +137,11 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do | |||
|     it 'logs error when representation does not have a github_id' do | ||||
|       expect(importer_class).not_to receive(:new) | ||||
| 
 | ||||
|       expect_next_instance_of(Gitlab::Import::Logger) do |logger| | ||||
|         expect(logger) | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:error) | ||||
|         .with( | ||||
|           github_id:  nil, | ||||
|           message: 'importer failed', | ||||
|             import_source: :github, | ||||
|           project_id: project.id, | ||||
|           importer: 'klass_name', | ||||
|           'error.message': 'key not found: :github_id', | ||||
|  | @ -157,7 +149,6 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do | |||
|             'number' => 10 | ||||
|           } | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       expect(Gitlab::ErrorTracking) | ||||
|         .to receive(:track_and_raise_exception) | ||||
|  |  | |||
|  | @ -36,24 +36,21 @@ RSpec.describe Gitlab::GithubImport::StageMethods do | |||
|           an_instance_of(Project) | ||||
|         ) | ||||
| 
 | ||||
|       expect_next_instance_of(Gitlab::Import::Logger) do |logger| | ||||
|         expect(logger) | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           message: 'starting stage', | ||||
|             import_source: :github, | ||||
|           project_id: project.id, | ||||
|           import_stage: 'DummyStage' | ||||
|         ) | ||||
|         expect(logger) | ||||
| 
 | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           message: 'stage finished', | ||||
|             import_source: :github, | ||||
|           project_id: project.id, | ||||
|           import_stage: 'DummyStage' | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       worker.perform(project.id) | ||||
|     end | ||||
|  | @ -70,25 +67,22 @@ RSpec.describe Gitlab::GithubImport::StageMethods do | |||
|         .to receive(:try_import) | ||||
|         .and_raise(exception) | ||||
| 
 | ||||
|       expect_next_instance_of(Gitlab::Import::Logger) do |logger| | ||||
|         expect(logger) | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           message: 'starting stage', | ||||
|             import_source: :github, | ||||
|           project_id: project.id, | ||||
|           import_stage: 'DummyStage' | ||||
|         ) | ||||
|         expect(logger) | ||||
| 
 | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:error) | ||||
|         .with( | ||||
|           message: 'stage failed', | ||||
|             import_source: :github, | ||||
|           project_id: project.id, | ||||
|           import_stage: 'DummyStage', | ||||
|           'error.message': 'some error' | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       expect(Gitlab::ErrorTracking) | ||||
|         .to receive(:track_and_raise_exception) | ||||
|  |  | |||
|  | @ -26,13 +26,11 @@ RSpec.describe Gitlab::GithubImport::Stage::FinishImportWorker do | |||
|         .to receive(:increment) | ||||
|         .and_call_original | ||||
| 
 | ||||
|       expect_next_instance_of(Gitlab::Import::Logger) do |logger| | ||||
|         expect(logger) | ||||
|       expect(Gitlab::GithubImport::Logger) | ||||
|         .to receive(:info) | ||||
|         .with( | ||||
|           message: 'GitHub project import finished', | ||||
|           import_stage: 'Gitlab::GithubImport::Stage::FinishImportWorker', | ||||
|             import_source: :github, | ||||
|           object_counts: { | ||||
|             'fetched' => {}, | ||||
|             'imported' => {} | ||||
|  | @ -40,7 +38,6 @@ RSpec.describe Gitlab::GithubImport::Stage::FinishImportWorker do | |||
|           project_id: project.id, | ||||
|           duration_s: a_kind_of(Numeric) | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       worker.report_import_time(project) | ||||
|     end | ||||
|  |  | |||
|  | @ -10,6 +10,12 @@ RSpec.describe MergeRequestMergeabilityCheckWorker do | |||
|       it 'does not execute MergeabilityCheckService' do | ||||
|         expect(MergeRequests::MergeabilityCheckService).not_to receive(:new) | ||||
| 
 | ||||
|         expect(Sidekiq.logger).to receive(:error).once | ||||
|           .with( | ||||
|             merge_request_id: 1, | ||||
|             worker: "MergeRequestMergeabilityCheckWorker", | ||||
|             message: 'Failed to find merge request') | ||||
| 
 | ||||
|         subject.perform(1) | ||||
|       end | ||||
|     end | ||||
|  | @ -24,6 +30,20 @@ RSpec.describe MergeRequestMergeabilityCheckWorker do | |||
| 
 | ||||
|         subject.perform(merge_request.id) | ||||
|       end | ||||
| 
 | ||||
|       it 'structurally logs a failed mergeability check' do | ||||
|         expect_next_instance_of(MergeRequests::MergeabilityCheckService, merge_request) do |service| | ||||
|           expect(service).to receive(:execute).and_return(double(error?: true, message: "solar flares")) | ||||
|         end | ||||
| 
 | ||||
|         expect(Sidekiq.logger).to receive(:error).once | ||||
|           .with( | ||||
|             merge_request_id: merge_request.id, | ||||
|             worker: "MergeRequestMergeabilityCheckWorker", | ||||
|             message: 'Failed to check mergeability of merge request: solar flares') | ||||
| 
 | ||||
|         subject.perform(merge_request.id) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     it_behaves_like 'an idempotent worker' do | ||||
|  |  | |||
|  | @ -3,7 +3,6 @@ package api | |||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
|  | @ -40,8 +39,6 @@ type API struct { | |||
| 	Version string | ||||
| } | ||||
| 
 | ||||
| var ErrNotGeoSecondary = errors.New("this is not a Geo secondary site") | ||||
| 
 | ||||
| var ( | ||||
| 	requestsCounter = promauto.NewCounterVec( | ||||
| 		prometheus.CounterOpts{ | ||||
|  | @ -399,7 +396,6 @@ func validResponseContentType(resp *http.Response) bool { | |||
| 	return helper.IsContentType(ResponseContentType, resp.Header.Get("Content-Type")) | ||||
| } | ||||
| 
 | ||||
| // TODO: Cache the result of the API requests https://gitlab.com/gitlab-org/gitlab/-/issues/329671
 | ||||
| func (api *API) GetGeoProxyURL() (*url.URL, error) { | ||||
| 	geoProxyApiUrl := *api.URL | ||||
| 	geoProxyApiUrl.Path, geoProxyApiUrl.RawPath = joinURLPath(api.URL, geoProxyEndpointPath) | ||||
|  | @ -424,10 +420,6 @@ func (api *API) GetGeoProxyURL() (*url.URL, error) { | |||
| 		return nil, fmt.Errorf("GetGeoProxyURL: decode response: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if response.GeoProxyURL == "" { | ||||
| 		return nil, ErrNotGeoSecondary | ||||
| 	} | ||||
| 
 | ||||
| 	geoProxyURL, err := url.Parse(response.GeoProxyURL) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("GetGeoProxyURL: Could not parse Geo proxy URL: %v, err: %v", response.GeoProxyURL, err) | ||||
|  |  | |||
|  | @ -22,16 +22,14 @@ func TestGetGeoProxyURLWhenGeoSecondary(t *testing.T) { | |||
| 	geoProxyURL, err := getGeoProxyURLGivenResponse(t, `{"geo_proxy_url":"http://primary"}`) | ||||
| 
 | ||||
| 	require.NoError(t, err) | ||||
| 	require.NotNil(t, geoProxyURL) | ||||
| 	require.Equal(t, "http://primary", geoProxyURL.String()) | ||||
| } | ||||
| 
 | ||||
| func TestGetGeoProxyURLWhenGeoPrimaryOrNonGeo(t *testing.T) { | ||||
| 	geoProxyURL, err := getGeoProxyURLGivenResponse(t, "{}") | ||||
| 
 | ||||
| 	require.Error(t, err) | ||||
| 	require.Equal(t, ErrNotGeoSecondary, err) | ||||
| 	require.Nil(t, geoProxyURL) | ||||
| 	require.NoError(t, err) | ||||
| 	require.Equal(t, "", geoProxyURL.String()) | ||||
| } | ||||
| 
 | ||||
| func getGeoProxyURLGivenResponse(t *testing.T, givenInternalApiResponse string) (*url.URL, error) { | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ import ( | |||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
|  | @ -35,6 +36,7 @@ var ( | |||
| 	requestHeaderBlacklist = []string{ | ||||
| 		upload.RewrittenFieldsHeader, | ||||
| 	} | ||||
| 	geoProxyApiPollingInterval = 10 * time.Second | ||||
| ) | ||||
| 
 | ||||
| type upstream struct { | ||||
|  | @ -48,6 +50,7 @@ type upstream struct { | |||
| 	geoLocalRoutes        []routeEntry | ||||
| 	geoProxyCableRoute    routeEntry | ||||
| 	geoProxyRoute         routeEntry | ||||
| 	geoProxyTestChannel   chan struct{} | ||||
| 	accessLogger          *logrus.Logger | ||||
| 	enableGeoProxyFeature bool | ||||
| 	mu                    sync.RWMutex | ||||
|  | @ -61,6 +64,9 @@ func newUpstream(cfg config.Config, accessLogger *logrus.Logger, routesCallback | |||
| 	up := upstream{ | ||||
| 		Config:       cfg, | ||||
| 		accessLogger: accessLogger, | ||||
| 		// Kind of a feature flag. See https://gitlab.com/groups/gitlab-org/-/epics/5914#note_564974130
 | ||||
| 		enableGeoProxyFeature: os.Getenv("GEO_SECONDARY_PROXY") == "1", | ||||
| 		geoProxyBackend:       &url.URL{}, | ||||
| 	} | ||||
| 	if up.Backend == nil { | ||||
| 		up.Backend = DefaultBackend | ||||
|  | @ -79,10 +85,13 @@ func newUpstream(cfg config.Config, accessLogger *logrus.Logger, routesCallback | |||
| 		up.Version, | ||||
| 		up.RoundTripper, | ||||
| 	) | ||||
| 	// Kind of a feature flag. See https://gitlab.com/groups/gitlab-org/-/epics/5914#note_564974130
 | ||||
| 	up.enableGeoProxyFeature = os.Getenv("GEO_SECONDARY_PROXY") == "1" | ||||
| 
 | ||||
| 	routesCallback(&up) | ||||
| 
 | ||||
| 	if up.enableGeoProxyFeature { | ||||
| 		go up.pollGeoProxyAPI() | ||||
| 	} | ||||
| 
 | ||||
| 	var correlationOpts []correlation.InboundHandlerOption | ||||
| 	if cfg.PropagateCorrelationID { | ||||
| 		correlationOpts = append(correlationOpts, correlation.WithPropagation()) | ||||
|  | @ -168,19 +177,14 @@ func (u *upstream) findRoute(cleanedPath string, r *http.Request) *routeEntry { | |||
| } | ||||
| 
 | ||||
| func (u *upstream) findGeoProxyRoute(cleanedPath string, r *http.Request) *routeEntry { | ||||
| 	geoProxyURL, err := u.APIClient.GetGeoProxyURL() | ||||
| 	u.mu.RLock() | ||||
| 	defer u.mu.RUnlock() | ||||
| 
 | ||||
| 	if err == nil { | ||||
| 		u.setGeoProxyRoutes(geoProxyURL) | ||||
| 		return u.matchGeoProxyRoute(cleanedPath, r) | ||||
| 	} else if err != apipkg.ErrNotGeoSecondary { | ||||
| 		log.WithRequest(r).WithError(err).Error("Geo Proxy: Unable to determine Geo Proxy URL. Falling back to normal routing") | ||||
| 	if u.geoProxyBackend.String() == "" { | ||||
| 		log.WithRequest(r).Debug("Geo Proxy: Not a Geo proxy") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *upstream) matchGeoProxyRoute(cleanedPath string, r *http.Request) *routeEntry { | ||||
| 	// Some routes are safe to serve from this GitLab instance
 | ||||
| 	for _, ro := range u.geoLocalRoutes { | ||||
| 		if ro.isMatch(cleanedPath, r) { | ||||
|  | @ -191,8 +195,6 @@ func (u *upstream) matchGeoProxyRoute(cleanedPath string, r *http.Request) *rout | |||
| 
 | ||||
| 	log.WithRequest(r).WithFields(log.Fields{"geoProxyBackend": u.geoProxyBackend}).Debug("Geo Proxy: Forward this request") | ||||
| 
 | ||||
| 	u.mu.RLock() | ||||
| 	defer u.mu.RUnlock() | ||||
| 	if cleanedPath == "/-/cable" { | ||||
| 		return &u.geoProxyCableRoute | ||||
| 	} | ||||
|  | @ -200,15 +202,40 @@ func (u *upstream) matchGeoProxyRoute(cleanedPath string, r *http.Request) *rout | |||
| 	return &u.geoProxyRoute | ||||
| } | ||||
| 
 | ||||
| func (u *upstream) setGeoProxyRoutes(geoProxyURL *url.URL) { | ||||
| func (u *upstream) pollGeoProxyAPI() { | ||||
| 	for { | ||||
| 		u.callGeoProxyAPI() | ||||
| 
 | ||||
| 		// Notify tests when callGeoProxyAPI() finishes
 | ||||
| 		if u.geoProxyTestChannel != nil { | ||||
| 			u.geoProxyTestChannel <- struct{}{} | ||||
| 		} | ||||
| 
 | ||||
| 		time.Sleep(geoProxyApiPollingInterval) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // Calls /api/v4/geo/proxy and sets up routes
 | ||||
| func (u *upstream) callGeoProxyAPI() { | ||||
| 	geoProxyURL, err := u.APIClient.GetGeoProxyURL() | ||||
| 	if err != nil { | ||||
| 		log.WithError(err).WithFields(log.Fields{"geoProxyBackend": u.geoProxyBackend}).Error("Geo Proxy: Unable to determine Geo Proxy URL. Fallback on cached value.") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if u.geoProxyBackend.String() != geoProxyURL.String() { | ||||
| 		log.WithFields(log.Fields{"oldGeoProxyURL": u.geoProxyBackend, "newGeoProxyURL": geoProxyURL}).Info("Geo Proxy: URL changed") | ||||
| 		u.updateGeoProxyFields(geoProxyURL) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (u *upstream) updateGeoProxyFields(geoProxyURL *url.URL) { | ||||
| 	u.mu.Lock() | ||||
| 	defer u.mu.Unlock() | ||||
| 	if u.geoProxyBackend == nil || u.geoProxyBackend.String() != geoProxyURL.String() { | ||||
| 		log.WithFields(log.Fields{"geoProxyURL": geoProxyURL}).Debug("Geo Proxy: Update GeoProxyRoute") | ||||
| 
 | ||||
| 	u.geoProxyBackend = geoProxyURL | ||||
| 	geoProxyRoundTripper := roundtripper.NewBackendRoundTripper(u.geoProxyBackend, "", u.ProxyHeadersTimeout, u.DevelopmentMode) | ||||
| 	geoProxyUpstream := proxypkg.NewProxy(u.geoProxyBackend, u.Version, geoProxyRoundTripper) | ||||
| 	u.geoProxyCableRoute = u.wsRoute(`^/-/cable\z`, geoProxyUpstream) | ||||
| 	u.geoProxyRoute = u.route("", "", geoProxyUpstream) | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -141,7 +141,7 @@ func TestGeoProxyFeatureEnabledOnNonGeoSecondarySite(t *testing.T) { | |||
| 	runTestCases(t, ws, testCases) | ||||
| } | ||||
| 
 | ||||
| func TestGeoProxyWithAPIError(t *testing.T) { | ||||
| func TestGeoProxyFeatureEnabledButWithAPIError(t *testing.T) { | ||||
| 	geoProxyEndpointResponseBody := "Invalid response" | ||||
| 	railsServer, deferredClose := startRailsServer("Local Rails server", geoProxyEndpointResponseBody) | ||||
| 	defer deferredClose() | ||||
|  | @ -214,10 +214,15 @@ func startRailsServer(railsServerName string, geoProxyEndpointResponseBody strin | |||
| } | ||||
| 
 | ||||
| func startWorkhorseServer(railsServerURL string, enableGeoProxyFeature bool) (*httptest.Server, func()) { | ||||
| 	geoProxyTestChannel := make(chan struct{}) | ||||
| 
 | ||||
| 	myConfigureRoutes := func(u *upstream) { | ||||
| 		// Enable environment variable "feature flag"
 | ||||
| 		u.enableGeoProxyFeature = enableGeoProxyFeature | ||||
| 
 | ||||
| 		// An empty message will be sent to this channel after every callGeoProxyAPI()
 | ||||
| 		u.geoProxyTestChannel = geoProxyTestChannel | ||||
| 
 | ||||
| 		// call original
 | ||||
| 		configureRoutes(u) | ||||
| 	} | ||||
|  | @ -226,5 +231,13 @@ func startWorkhorseServer(railsServerURL string, enableGeoProxyFeature bool) (*h | |||
| 	ws := httptest.NewServer(upstreamHandler) | ||||
| 	testhelper.ConfigureSecret() | ||||
| 
 | ||||
| 	if enableGeoProxyFeature { | ||||
| 		// Wait for an empty message from callGeoProxyAPI(). This should be done on
 | ||||
| 		// all tests where enableGeoProxyFeature is true, including the ones where
 | ||||
| 		// we expect geoProxyURL to be nil or error, to ensure the tests do not pass
 | ||||
| 		// by coincidence.
 | ||||
| 		<-geoProxyTestChannel | ||||
| 	} | ||||
| 
 | ||||
| 	return ws, ws.Close | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue