Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-08-04 18:09:57 +00:00
parent 23c4d0c3e1
commit f5a72705e4
76 changed files with 1413 additions and 632 deletions

View File

@ -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 });
};

View File

@ -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>

View File

@ -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;

View File

@ -20,11 +20,9 @@ export default () => {
store.dispatch('initializeVsa', {
projectId: parseInt(projectId, 10),
groupPath,
requestPath,
fullPath,
features: {
cycleAnalyticsForGroups:
(groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false,
endpoints: {
requestPath,
fullPath,
},
});

View File

@ -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));
};

View File

@ -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),
};
};

View File

@ -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) =>

View File

@ -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,
});

View File

@ -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)) || [],
};
};

View File

@ -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,50 +131,44 @@ export default {
>
{{ alertParams.message }}
</gl-alert>
<gl-form-group :label="$options.i18n.I18N_NEW_BRANCH_LABEL_DROPDOWN" label-for="project-select">
<project-dropdown
id="project-select"
:selected-project="selectedProject"
@change="onProjectSelect"
@error="onError"
/>
</gl-form-group>
<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_BRANCH"
label-for="branch-name-input"
>
<gl-form-input id="branch-name-input" v-model="branchName" type="text" required />
</gl-form-group>
<gl-form-group
:label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE"
label-for="source-branch-select"
>
<source-branch-dropdown
id="source-branch-select"
:selected-project="selectedProject"
:selected-branch-name="selectedSourceBranchName"
@change="onSourceBranchSelect"
@error="onError"
/>
</gl-form-group>
<div class="form-actions">
<gl-button
:loading="createBranchLoading"
type="submit"
variant="confirm"
:disabled="disableSubmitButton"
>
<project-dropdown
id="project-select"
:selected-project="selectedProject"
@change="onProjectSelect"
@error="onError"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH"
label-for="branch-name-input"
>
<gl-form-input id="branch-name-input" v-model="branchName" type="text" required />
</gl-form-group>
<gl-form-group
:label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE"
label-for="source-branch-select"
>
<source-branch-dropdown
id="source-branch-select"
:selected-project="selectedProject"
:selected-branch-name="selectedSourceBranchName"
@change="onSourceBranchSelect"
@error="onError"
/>
</gl-form-group>
<div class="form-actions">
<gl-button
:loading="createBranchLoading"
type="submit"
variant="confirm"
:disabled="disableSubmitButton"
>
{{ $options.i18n.I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT }}
</gl-button>
</div>
</gl-form>
</div>
{{ $options.i18n.I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT }}
</gl-button>
</div>
</gl-form>
</template>

View File

@ -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.',
);

View File

@ -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,
provide: {
initialBranchName,
successStateSvgPath,
},
render(createElement) {
return createElement(JiraConnectNewBranchForm, {
props: {
initialBranchName,
},
});
return createElement(JiraConnectNewBranchPage);
},
});
}

View File

@ -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>

View File

@ -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"

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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'],

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
378e12c3c7c49e294ab4ab792151af8e3829cc6f38295d5faa0995ad16f3f934

View File

@ -0,0 +1 @@
19e23131949e6056ea9837231fac6a2307fb52a8287eb34cc6e89eed11d52849

View File

@ -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 (

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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"`.

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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"

View File

@ -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

View File

@ -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,

View File

@ -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>"`;

View File

@ -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);
});

View File

@ -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 } }]}
action | payload | expectedActions | expectedMutations
${'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);

View File

@ -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,25 +58,45 @@ 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 | payload | stateKey | value
${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath}
${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'startDate'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
mutation | 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.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
@ -97,41 +114,10 @@ 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'} | ${{}}
mutation | payload | stateKey | value
${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 }) => {

View File

@ -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

View File

@ -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);
});
});
});

View File

@ -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();
});
});
});
});

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
.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::GithubImport::Logger)
.to receive(:error)
.with(
message: 'importer failed',
project_id: project.id,
parallel: false,
importer: 'Gitlab::GithubImport::Importer::LfsObjectImporter',
'error.message': 'Invalid Project URL'
)
expect(Gitlab::ErrorTracking)
.to receive(:track_exception)

View File

@ -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

View File

@ -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)
.to receive(:info)
.with(
message: 'starting importer',
import_source: :github,
parallel: false,
project_id: project.id,
importer: 'Class'
)
expect(logger)
.to receive(:info)
.with(
message: 'importer finished',
import_source: :github,
parallel: false,
project_id: project.id,
importer: 'Class'
)
end
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
message: 'starting importer',
parallel: false,
project_id: project.id,
importer: 'Class'
)
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
message: 'importer finished',
parallel: false,
project_id: project.id,
importer: 'Class'
)
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)
.to receive(:info)
.with(
message: 'starting importer',
import_source: :github,
parallel: false,
project_id: project.id,
importer: 'Class'
)
expect(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::GithubImport::Logger)
.to receive(:info)
.with(
message: 'starting importer',
parallel: false,
project_id: project.id,
importer: 'Class'
)
expect(Gitlab::GithubImport::Logger)
.to receive(:error)
.with(
message: 'importer failed',
project_id: project.id,
parallel: false,
importer: 'Class',
'error.message': 'some error'
)
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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -98,14 +98,33 @@ 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
it 'does not create runner' do
request
context 'with ci_runner_limits_override FF disabled' do
let(:ci_runner_limits_override) { false }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to include('runner_projects.base' => ['Maximum number of ci registered project runners (1) exceeded'])
expect(project.runners.reload.size).to eq(1)
it 'does not create runner' do
request
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to include('runner_projects.base' => ['Maximum number of ci registered project runners (1) exceeded'])
expect(project.runners.reload.size).to eq(1)
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
@ -113,6 +132,9 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state 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,14 +204,33 @@ 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
it 'does not create runner' do
request
context 'with ci_runner_limits_override FF disabled' do
let(:ci_runner_limits_override) { false }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to include('runner_namespaces.base' => ['Maximum number of ci registered group runners (1) exceeded'])
expect(group.runners.reload.size).to eq(1)
it 'does not create runner' do
request
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to include('runner_namespaces.base' => ['Maximum number of ci registered group runners (1) exceeded'])
expect(group.runners.reload.size).to eq(1)
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
@ -198,6 +239,9 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state 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

View File

@ -1003,13 +1003,31 @@ 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
it 'does not enable specific runner' do
expect do
post api("/projects/#{project.id}/runners", admin), params: { runner_id: new_project_runner.id }
end.not_to change { project.runners.count }
expect(response).to have_gitlab_http_status(:bad_request)
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 }
end.not_to change { project.runners.count }
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

View File

@ -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

View File

@ -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)
.to receive(:info)
.with(
github_id: 1,
message: 'starting importer',
import_source: :github,
project_id: 1,
importer: 'klass_name'
)
expect(logger)
.to receive(:info)
.with(
github_id: 1,
message: 'importer finished',
import_source: :github,
project_id: 1,
importer: 'klass_name'
)
end
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
github_id: 1,
message: 'starting importer',
project_id: 1,
importer: 'klass_name'
)
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
github_id: 1,
message: 'importer finished',
project_id: 1,
importer: 'klass_name'
)
worker.import(project, client, { 'number' => 10, 'github_id' => 1 })
@ -100,31 +97,28 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do
.to receive(:execute)
.and_raise(exception)
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger)
.to receive(:info)
.with(
github_id: 1,
message: 'starting importer',
import_source: :github,
project_id: project.id,
importer: 'klass_name'
)
expect(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',
'github.data': {
'github_id' => 1,
'number' => 10
}
)
end
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
github_id: 1,
message: 'starting importer',
project_id: project.id,
importer: 'klass_name'
)
expect(Gitlab::GithubImport::Logger)
.to receive(:error)
.with(
github_id: 1,
message: 'importer failed',
project_id: project.id,
importer: 'klass_name',
'error.message': 'some error',
'github.data': {
'github_id' => 1,
'number' => 10
}
)
expect(Gitlab::ErrorTracking)
.to receive(:track_and_raise_exception)
@ -143,21 +137,18 @@ 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)
.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',
'github.data': {
'number' => 10
}
)
end
expect(Gitlab::GithubImport::Logger)
.to receive(:error)
.with(
github_id: nil,
message: 'importer failed',
project_id: project.id,
importer: 'klass_name',
'error.message': 'key not found: :github_id',
'github.data': {
'number' => 10
}
)
expect(Gitlab::ErrorTracking)
.to receive(:track_and_raise_exception)

View File

@ -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)
.to receive(:info)
.with(
message: 'starting stage',
import_source: :github,
project_id: project.id,
import_stage: 'DummyStage'
)
expect(logger)
.to receive(:info)
.with(
message: 'stage finished',
import_source: :github,
project_id: project.id,
import_stage: 'DummyStage'
)
end
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
message: 'starting stage',
project_id: project.id,
import_stage: 'DummyStage'
)
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
message: 'stage finished',
project_id: project.id,
import_stage: 'DummyStage'
)
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)
.to receive(:info)
.with(
message: 'starting stage',
import_source: :github,
project_id: project.id,
import_stage: 'DummyStage'
)
expect(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::GithubImport::Logger)
.to receive(:info)
.with(
message: 'starting stage',
project_id: project.id,
import_stage: 'DummyStage'
)
expect(Gitlab::GithubImport::Logger)
.to receive(:error)
.with(
message: 'stage failed',
project_id: project.id,
import_stage: 'DummyStage',
'error.message': 'some error'
)
expect(Gitlab::ErrorTracking)
.to receive(:track_and_raise_exception)

View File

@ -26,21 +26,18 @@ RSpec.describe Gitlab::GithubImport::Stage::FinishImportWorker do
.to receive(:increment)
.and_call_original
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger)
.to receive(:info)
.with(
message: 'GitHub project import finished',
import_stage: 'Gitlab::GithubImport::Stage::FinishImportWorker',
import_source: :github,
object_counts: {
'fetched' => {},
'imported' => {}
},
project_id: project.id,
duration_s: a_kind_of(Numeric)
)
end
expect(Gitlab::GithubImport::Logger)
.to receive(:info)
.with(
message: 'GitHub project import finished',
import_stage: 'Gitlab::GithubImport::Stage::FinishImportWorker',
object_counts: {
'fetched' => {},
'imported' => {}
},
project_id: project.id,
duration_s: a_kind_of(Numeric)
)
worker.report_import_time(project)
end

View File

@ -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

View File

@ -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)

View File

@ -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) {

View File

@ -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) {
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)
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()
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)
}

View File

@ -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
}