Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-10-14 09:08:46 +00:00
parent 4a159b9f98
commit 6035fcc36e
61 changed files with 779 additions and 292 deletions

View File

@ -28,6 +28,8 @@
# Help pages are excluded from scan as they are static pages.
# profile/two_factor_auth is excluded from scan to prevent 2FA from being turned on from user profile, which will reduce coverage.
- 'export DAST_AUTH_EXCLUDE_URLS="${DAST_WEBSITE}/help/.*,${DAST_WEBSITE}/profile/two_factor_auth,${DAST_WEBSITE}/users/sign_out"'
# Exclude the automatically generated monitoring project from being tested due to https://gitlab.com/gitlab-org/gitlab/-/issues/260362
- 'DAST_AUTH_EXCLUDE_URLS="${DAST_AUTH_EXCLUDE_URLS},https://.*\.gitlab-review\.app/gitlab-instance-(administrators-)?[a-zA-Z0-9]{8}/.*"'
- enable_rule () { read all_rules; rule=$1; echo $all_rules | sed -r "s/(,)?$rule(,)?/\1-1\2/" ; }
# Sort ids in DAST_RULES ascendingly, which is required when using DAST_RULES as argument to enable_rule
- 'DAST_RULES=$(echo $DAST_RULES | tr "," "\n" | sort -n | paste -sd ",")'

View File

@ -1141,19 +1141,6 @@ Rails/SaveBang:
- 'spec/services/notification_recipients/build_service_spec.rb'
- 'spec/services/notification_service_spec.rb'
- 'spec/services/packages/conan/create_package_file_service_spec.rb'
- 'spec/services/projects/after_rename_service_spec.rb'
- 'spec/services/projects/autocomplete_service_spec.rb'
- 'spec/services/projects/create_service_spec.rb'
- 'spec/services/projects/destroy_service_spec.rb'
- 'spec/services/projects/fork_service_spec.rb'
- 'spec/services/projects/hashed_storage/base_attachment_service_spec.rb'
- 'spec/services/projects/move_access_service_spec.rb'
- 'spec/services/projects/move_project_group_links_service_spec.rb'
- 'spec/services/projects/overwrite_project_service_spec.rb'
- 'spec/services/projects/propagate_service_template_spec.rb'
- 'spec/services/projects/unlink_fork_service_spec.rb'
- 'spec/services/projects/update_pages_service_spec.rb'
- 'spec/services/projects/update_service_spec.rb'
- 'spec/services/reset_project_cache_service_spec.rb'
- 'spec/services/resource_events/change_milestone_service_spec.rb'
- 'spec/services/system_hooks_service_spec.rb'

View File

@ -2,11 +2,24 @@ import { sortBy } from 'lodash';
import ListIssue from 'ee_else_ce/boards/models/issue';
import { ListType } from './constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import boardsStore from '~/boards/stores/boards_store';
export function getMilestone() {
return null;
}
export function formatBoardLists(lists) {
const formattedLists = lists.nodes.map(list =>
boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }),
);
return formattedLists.reduce((map, list) => {
return {
...map,
[list.id]: list,
};
}, {});
}
export function formatIssue(issue) {
return new ListIssue({
...issue,
@ -62,6 +75,13 @@ export function fullBoardId(boardId) {
return `gid://gitlab/Board/${boardId}`;
}
export function fullLabelId(label) {
if (label.project_id !== null) {
return `gid://gitlab/ProjectLabel/${label.id}`;
}
return `gid://gitlab/GroupLabel/${label.id}`;
}
export function moveIssueListHelper(issue, fromList, toList) {
if (toList.type === ListType.label) {
issue.addLabel(toList.label);
@ -85,4 +105,5 @@ export default {
formatIssue,
formatListIssues,
fullBoardId,
fullLabelId,
};

View File

@ -1,5 +1,6 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { sortBy } from 'lodash';
import BoardColumn from 'ee_else_ce/boards/components/board_column.vue';
import { GlAlert } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@ -30,7 +31,9 @@ export default {
...mapState(['boardLists', 'error']),
...mapGetters(['isSwimlanesOn']),
boardListsToUse() {
return this.glFeatures.graphqlBoardLists ? this.boardLists : this.lists;
const lists =
this.glFeatures.graphqlBoardLists || this.isSwimlanesOn ? this.boardLists : this.lists;
return sortBy([...Object.values(lists)], 'position');
},
},
mounted() {
@ -68,7 +71,7 @@ export default {
<template v-else>
<epics-swimlanes
ref="swimlanes"
:lists="boardLists"
:lists="boardListsToUse"
:can-admin-list="canAdminList"
:disabled="disabled"
/>

View File

@ -34,7 +34,7 @@ export default {
referencing a List Model class. Reactivity only applies to plain JS objects
*/
if (this.glFeatures.graphqlBoardLists) {
return this.boardLists.find(({ id }) => id === this.activeId);
return this.boardLists[this.activeId];
}
return boardsStore.state.lists.find(({ id }) => id === this.activeId);
},

View File

@ -6,8 +6,14 @@ import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from '~/flash';
import CreateLabelDropdown from '../../create_label';
import boardsStore from '../stores/boards_store';
import { fullLabelId } from '../boards_util';
import store from '~/boards/stores';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
function shouldCreateListGraphQL(label) {
return store.getters.shouldUseGraphQL && !store.getters.getListByLabelId(fullLabelId(label));
}
$(document)
.off('created.label')
.on('created.label', (e, label, addNewList) => {
@ -15,16 +21,20 @@ $(document)
return;
}
boardsStore.new({
title: label.title,
position: boardsStore.state.lists.length - 2,
list_type: 'label',
label: {
id: label.id,
if (shouldCreateListGraphQL(label)) {
store.dispatch('createList', { labelId: fullLabelId(label) });
} else {
boardsStore.new({
title: label.title,
color: label.color,
},
});
position: boardsStore.state.lists.length - 2,
list_type: 'label',
label: {
id: label.id,
title: label.title,
color: label.color,
},
});
}
});
export default function initNewListDropdown() {
@ -74,7 +84,9 @@ export default function initNewListDropdown() {
const label = options.selectedObj;
e.preventDefault();
if (!boardsStore.findListByLabelId(label.id)) {
if (shouldCreateListGraphQL(label)) {
store.dispatch('createList', { labelId: fullLabelId(label) });
} else if (!boardsStore.findListByLabelId(label.id)) {
boardsStore.new({
title: label.title,
position: boardsStore.state.lists.length - 2,

View File

@ -1,7 +1,21 @@
#import "./board_list.fragment.graphql"
#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean) {
boardListCreate(input: { boardId: $boardId, backlog: $backlog }) {
mutation CreateBoardList(
$boardId: BoardID!
$backlog: Boolean
$labelId: LabelID
$milestoneId: MilestoneID
$assigneeId: UserID
) {
boardListCreate(
input: {
boardId: $boardId
backlog: $backlog
labelId: $labelId
milestoneId: $milestoneId
assigneeId: $assigneeId
}
) {
list {
...BoardListFragment
}

View File

@ -1,13 +1,17 @@
import Cookies from 'js-cookie';
import { sortBy, pick } from 'lodash';
import createFlash from '~/flash';
import { pick } from 'lodash';
import { __ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { BoardType, ListType, inactiveId } from '~/boards/constants';
import * as types from './mutation_types';
import { formatListIssues, fullBoardId, formatListsPageInfo } from '../boards_util';
import {
formatBoardLists,
formatListIssues,
fullBoardId,
formatListsPageInfo,
} from '../boards_util';
import boardStore from '~/boards/stores/boards_store';
import listsIssuesQuery from '../queries/lists_issues.query.graphql';
@ -71,38 +75,29 @@ export default {
variables,
})
.then(({ data }) => {
let { lists } = data[boardType]?.board;
// Temporarily using positioning logic from boardStore
lists = lists.nodes.map(list =>
boardStore.updateListPosition({
...list,
doNotFetchIssues: true,
}),
);
commit(types.RECEIVE_BOARD_LISTS_SUCCESS, sortBy(lists, 'position'));
const { lists } = data[boardType]?.board;
commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists));
// Backlog list needs to be created if it doesn't exist
if (!lists.find(l => l.type === ListType.backlog)) {
if (!lists.nodes.find(l => l.listType === ListType.backlog)) {
dispatch('createList', { backlog: true });
}
dispatch('showWelcomeList');
})
.catch(() => {
createFlash(
__('An error occurred while fetching the board lists. Please reload the page.'),
);
});
.catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE));
},
// This action only supports backlog list creation at this stage
// Future iterations will add the ability to create other list types
createList: ({ state, commit, dispatch }, { backlog = false }) => {
createList: ({ state, commit, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
const { boardId } = state.endpoints;
gqlClient
.mutate({
mutation: createBoardListMutation,
variables: {
boardId: fullBoardId(boardId),
backlog,
labelId,
milestoneId,
assigneeId,
},
})
.then(({ data }) => {
@ -113,16 +108,15 @@ export default {
dispatch('addList', list);
}
})
.catch(() => {
commit(types.CREATE_LIST_FAILURE);
});
.catch(() => commit(types.CREATE_LIST_FAILURE));
},
addList: ({ state, commit }, list) => {
const lists = state.boardLists;
addList: ({ commit }, list) => {
// Temporarily using positioning logic from boardStore
lists.push(boardStore.updateListPosition({ ...list, doNotFetchIssues: true }));
commit(types.RECEIVE_BOARD_LISTS_SUCCESS, sortBy(lists, 'position'));
commit(
types.RECEIVE_ADD_LIST_SUCCESS,
boardStore.updateListPosition({ ...list, doNotFetchIssues: true }),
);
},
showWelcomeList: ({ state, dispatch }) => {
@ -130,7 +124,9 @@ export default {
return;
}
if (
state.boardLists.find(list => list.type !== ListType.backlog && list.type !== ListType.closed)
Object.entries(state.boardLists).find(
([, list]) => list.type !== ListType.backlog && list.type !== ListType.closed,
)
) {
return;
}
@ -152,13 +148,16 @@ export default {
notImplemented();
},
moveList: ({ state, commit, dispatch }, { listId, newIndex, adjustmentValue }) => {
moveList: (
{ state, commit, dispatch },
{ listId, replacedListId, newIndex, adjustmentValue },
) => {
const { boardLists } = state;
const backupList = [...boardLists];
const movedList = boardLists.find(({ id }) => id === listId);
const backupList = { ...boardLists };
const movedList = boardLists[listId];
const newPosition = newIndex - 1;
const listAtNewIndex = boardLists[newIndex];
const listAtNewIndex = boardLists[replacedListId];
movedList.position = newPosition;
listAtNewIndex.position += adjustmentValue;

View File

@ -1,3 +1,4 @@
import { find } from 'lodash';
import { inactiveId } from '../constants';
export default {
@ -22,4 +23,16 @@ export default {
getActiveIssue: state => {
return state.issues[state.activeId] || {};
},
getListByLabelId: state => labelId => {
return find(state.boardLists, l => l.label?.id === labelId);
},
getListByTitle: state => title => {
return find(state.boardLists, l => l.title === title);
},
shouldUseGraphQL: () => {
return gon?.features?.graphqlBoardLists;
},
};

View File

@ -3,6 +3,7 @@ export const SET_FILTERS = 'SET_FILTERS';
export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE';
export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST';
export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST';
export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';

View File

@ -1,5 +1,5 @@
import Vue from 'vue';
import { sortBy, pull, union } from 'lodash';
import { pull, union } from 'lodash';
import { formatIssue, moveIssueListHelper } from '../boards_util';
import * as mutationTypes from './mutation_types';
import { s__ } from '~/locale';
@ -10,16 +10,10 @@ const notImplemented = () => {
throw new Error('Not implemented!');
};
const getListById = ({ state, listId }) => {
const listIndex = state.boardLists.findIndex(l => l.id === listId);
const list = state.boardLists[listIndex];
return { listIndex, list };
};
export const removeIssueFromList = ({ state, listId, issueId }) => {
Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId));
const { listIndex, list } = getListById({ state, listId });
Vue.set(state.boardLists, listIndex, { ...list, issuesSize: list.issuesSize - 1 });
const list = state.boardLists[listId];
Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize - 1 });
};
export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
@ -32,8 +26,8 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter
}
listIssues.splice(newIndex, 0, issueId);
Vue.set(state.issuesByListId, listId, listIssues);
const { listIndex, list } = getListById({ state, listId });
Vue.set(state.boardLists, listIndex, { ...list, issuesSize: list.issuesSize + 1 });
const list = state.boardLists[listId];
Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize + 1 });
};
export default {
@ -49,6 +43,12 @@ export default {
state.boardLists = lists;
},
[mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: state => {
state.error = s__(
'Boards|An error occurred while fetching the board lists. Please reload the page.',
);
},
[mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) {
state.activeId = id;
state.sidebarType = sidebarType;
@ -66,8 +66,8 @@ export default {
notImplemented();
},
[mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: () => {
notImplemented();
[mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: (state, list) => {
Vue.set(state.boardLists, list.id, list);
},
[mutationTypes.RECEIVE_ADD_LIST_ERROR]: () => {
@ -76,10 +76,8 @@ export default {
[mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => {
const { boardLists } = state;
const movedListIndex = state.boardLists.findIndex(l => l.id === movedList.id);
Vue.set(boardLists, movedListIndex, movedList);
Vue.set(boardLists, movedListIndex.position + 1, listAtNewIndex);
Vue.set(state, 'boardLists', sortBy(boardLists, 'position'));
Vue.set(boardLists, movedList.id, movedList);
Vue.set(boardLists, listAtNewIndex.id, listAtNewIndex);
},
[mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => {
@ -156,8 +154,8 @@ export default {
state,
{ originalIssue, fromListId, toListId, moveBeforeId, moveAfterId },
) => {
const fromList = state.boardLists.find(l => l.id === fromListId);
const toList = state.boardLists.find(l => l.id === toListId);
const fromList = state.boardLists[fromListId];
const toList = state.boardLists[toListId];
const issue = moveIssueListHelper(originalIssue, fromList, toList);
Vue.set(state.issues, issue.id, issue);

View File

@ -8,7 +8,7 @@ export default () => ({
isShowingLabels: true,
activeId: inactiveId,
sidebarType: '',
boardLists: [],
boardLists: {},
listsFlags: {},
issuesByListId: {},
pageInfoByListId: {},

View File

@ -1,4 +1,5 @@
import $ from 'jquery';
import { hide } from '~/tooltips';
export const addTooltipToEl = el => {
const textEl = el.querySelector('.js-breadcrumb-item-text');
@ -23,9 +24,11 @@ export default () => {
topLevelLinks.forEach(el => addTooltipToEl(el));
$expander.closest('.dropdown').on('show.bs.dropdown hide.bs.dropdown', e => {
$('.js-breadcrumbs-collapsed-expander', e.currentTarget)
.toggleClass('open')
.tooltip('hide');
const $el = $('.js-breadcrumbs-collapsed-expander', e.currentTarget);
$el.toggleClass('open');
hide($el);
});
}
};

View File

@ -3,6 +3,7 @@ import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui';
import VueDraggable from 'vuedraggable';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, sprintf } from '~/locale';
import { getFilename } from '~/lib/utils/file_upload';
import UploadButton from '../components/upload/button.vue';
import DeleteButton from '../components/delete_button.vue';
import Design from '../components/list/item.vue';
@ -31,7 +32,7 @@ import {
isValidDesignFile,
moveDesignOptimisticResponse,
} from '../utils/design_management_utils';
import { getFilename } from '~/lib/utils/file_upload';
import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
@ -186,6 +187,7 @@ export default {
updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody);
},
onUploadDesignDone(res) {
// display any warnings, if necessary
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
if (skippedWarningMessage) {
@ -196,7 +198,19 @@ export default {
if (!this.isLatestVersion) {
this.$router.push({ name: DESIGNS_ROUTE_NAME });
}
// reset state
this.resetFilesToBeSaved();
this.trackUploadDesign(res);
},
trackUploadDesign(res) {
(res?.data?.designManagementUpload?.designs || []).forEach(design => {
if (design.event === 'CREATION') {
trackDesignCreate();
} else if (design.event === 'MODIFICATION') {
trackDesignUpdate();
}
});
},
onUploadDesignError() {
this.resetFilesToBeSaved();

View File

@ -1,9 +1,16 @@
import Tracking from '~/tracking';
// Tracking Constants
const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0';
const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
const DESIGN_TRACKING_EVENT_NAME = 'view_design';
const DESIGN_TRACKING_CONTEXT_SCHEMAS = {
VIEW_DESIGN_SCHEMA: 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0',
};
const DESIGN_TRACKING_EVENTS = {
VIEW_DESIGN: 'view_design',
CREATE_DESIGN: 'create_design',
UPDATE_DESIGN: 'update_design',
};
export const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
export function trackDesignDetailView(
referer = '',
@ -11,10 +18,11 @@ export function trackDesignDetailView(
designVersion = 1,
latestVersion = false,
) {
Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, {
label: DESIGN_TRACKING_EVENT_NAME,
const eventName = DESIGN_TRACKING_EVENTS.VIEW_DESIGN;
Tracking.event(DESIGN_TRACKING_PAGE_NAME, eventName, {
label: eventName,
context: {
schema: DESIGN_TRACKING_CONTEXT_SCHEMA,
schema: DESIGN_TRACKING_CONTEXT_SCHEMAS.VIEW_DESIGN_SCHEMA,
data: {
'design-version-number': designVersion,
'design-is-current-version': latestVersion,
@ -24,3 +32,11 @@ export function trackDesignDetailView(
},
});
}
export function trackDesignCreate() {
return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.CREATE_DESIGN);
}
export function trackDesignUpdate() {
return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.UPDATE_DESIGN);
}

View File

@ -1,7 +1,5 @@
import Project from './project';
import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation';
document.addEventListener('DOMContentLoaded', () => {
new Project(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
});
new Project(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new

View File

@ -6,7 +6,6 @@
@import '@gitlab/at.js/dist/css/jquery.atwho';
@import 'dropzone/dist/basic';
@import 'select2';
@import 'cropper/dist/cropper';
// GitLab UI framework
@import 'framework';

View File

@ -6,22 +6,22 @@ module Types
class DetailedStatusType < BaseObject
graphql_name 'DetailedStatus'
field :group, GraphQL::STRING_TYPE, null: false,
field :group, GraphQL::STRING_TYPE, null: true,
description: 'Group of the status'
field :icon, GraphQL::STRING_TYPE, null: false,
field :icon, GraphQL::STRING_TYPE, null: true,
description: 'Icon of the status'
field :favicon, GraphQL::STRING_TYPE, null: false,
field :favicon, GraphQL::STRING_TYPE, null: true,
description: 'Favicon of the status'
field :details_path, GraphQL::STRING_TYPE, null: true,
description: 'Path of the details for the status'
field :has_details, GraphQL::BOOLEAN_TYPE, null: false,
field :has_details, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if the status has further details',
method: :has_details?
field :label, GraphQL::STRING_TYPE, null: false,
field :label, GraphQL::STRING_TYPE, null: true,
description: 'Label of the status'
field :text, GraphQL::STRING_TYPE, null: false,
field :text, GraphQL::STRING_TYPE, null: true,
description: 'Text of the status'
field :tooltip, GraphQL::STRING_TYPE, null: false,
field :tooltip, GraphQL::STRING_TYPE, null: true,
description: 'Tooltip associated with the status',
method: :status_tooltip
field :action, Types::Ci::StatusActionType, null: true,

View File

@ -13,6 +13,8 @@ module Types
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the job',
resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
field :scheduled_at, Types::TimeType, null: true,
description: 'Schedule for the build'
end
end
end

View File

@ -7,7 +7,7 @@ class LabelEntity < Grape::Entity
expose :color
expose :description
expose :group_id
expose :project_id
expose :project_id, if: ->(label, _) { !label.is_a?(GlobalLabel) }
expose :template
expose :text_color
expose :created_at

View File

@ -4,6 +4,6 @@ class LabelSerializer < BaseSerializer
entity LabelEntity
def represent_appearance(resource)
represent(resource, { only: [:id, :title, :color, :text_color] })
represent(resource, { only: [:id, :title, :color, :text_color, :project_id] })
end
end

View File

@ -0,0 +1,5 @@
---
title: Schedule adding "Missed SLA" label to issues
merge_request: 44546
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add migration helpers for copying check constraints
merge_request: 44777
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Add product analytics for design created and modified events
merge_request: 44129
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: 'GraphQL: Adds scheduledAt to CiJob'
merge_request: 44054
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: 'GraphQL: Changes fields in detailedStatus to be nullable'
merge_request: 45072
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Fix Rails/SaveBang offenses in spec/services/projects/*
merge_request: 44980
author: matthewbried
type: other

View File

@ -574,6 +574,9 @@ Gitlab.ee do
Settings.cron_jobs['historical_data_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['historical_data_worker']['cron'] ||= '0 12 * * *'
Settings.cron_jobs['historical_data_worker']['job_class'] = 'HistoricalDataWorker'
Settings.cron_jobs['incident_sla_exceeded_check_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['incident_sla_exceeded_check_worker']['cron'] ||= '*/2 * * * *'
Settings.cron_jobs['incident_sla_exceeded_check_worker']['job_class'] = 'IncidentManagement::IncidentSlaExceededCheckWorker'
Settings.cron_jobs['import_software_licenses_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['import_software_licenses_worker']['cron'] ||= '0 3 * * 0'
Settings.cron_jobs['import_software_licenses_worker']['job_class'] = 'ImportSoftwareLicensesWorker'

View File

@ -142,6 +142,8 @@
- 2
- - incident_management
- 2
- - incident_management_apply_incident_sla_exceeded_label
- 1
- - invalid_gpg_signature_update
- 2
- - irker

View File

@ -2127,6 +2127,11 @@ type CiJob {
"""
last: Int
): CiJobConnection
"""
Schedule for the build
"""
scheduledAt: Time
}
"""
@ -5328,37 +5333,37 @@ type DetailedStatus {
"""
Favicon of the status
"""
favicon: String!
favicon: String
"""
Group of the status
"""
group: String!
group: String
"""
Indicates if the status has further details
"""
hasDetails: Boolean!
hasDetails: Boolean
"""
Icon of the status
"""
icon: String!
icon: String
"""
Label of the status
"""
label: String!
label: String
"""
Text of the status
"""
text: String!
text: String
"""
Tooltip associated with the status
"""
tooltip: String!
tooltip: String
}
input DiffImagePositionInput {

View File

@ -5677,6 +5677,20 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "scheduledAt",
"description": "Schedule for the build",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@ -14560,13 +14574,9 @@
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@ -14578,13 +14588,9 @@
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@ -14596,13 +14602,9 @@
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@ -14614,13 +14616,9 @@
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@ -14632,13 +14630,9 @@
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@ -14650,13 +14644,9 @@
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@ -14668,13 +14658,9 @@
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null

View File

@ -334,6 +334,7 @@ Represents the total number of issues and their weights for a particular day.
| ----- | ---- | ----------- |
| `detailedStatus` | DetailedStatus | Detailed status of the job |
| `name` | String | Name of the job |
| `scheduledAt` | Time | Schedule for the build |
### CiStage
@ -855,13 +856,13 @@ Autogenerated return type of DestroySnippet.
| ----- | ---- | ----------- |
| `action` | StatusAction | Action information for the status. This includes method, button title, icon, path, and title |
| `detailsPath` | String | Path of the details for the status |
| `favicon` | String! | Favicon of the status |
| `group` | String! | Group of the status |
| `hasDetails` | Boolean! | Indicates if the status has further details |
| `icon` | String! | Icon of the status |
| `label` | String! | Label of the status |
| `text` | String! | Text of the status |
| `tooltip` | String! | Tooltip associated with the status |
| `favicon` | String | Favicon of the status |
| `group` | String | Group of the status |
| `hasDetails` | Boolean | Indicates if the status has further details |
| `icon` | String | Icon of the status |
| `label` | String | Label of the status |
| `text` | String | Text of the status |
| `tooltip` | String | Tooltip associated with the status |
### DiffPosition

View File

@ -184,3 +184,12 @@ To quickly see the latest updates on an incident, click
un-threaded and ordered chronologically, newest to oldest:
![Timeline view toggle](./img/timeline_view_toggle_v13_5.png)
### Service Level Agreement countdown timer
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/241663) in GitLab 13.5.
After enabling **Incident SLA** in the Incident Management configuration, newly-created
incidents display a SLA (Service Level Agreement) timer showing the time remaining before
the SLA period expires. If the incident is not closed before the SLA period ends, GitLab
adds a `missed::SLA` label to the incident.

View File

@ -115,9 +115,9 @@ Please see the table below for some examples:
| Target version | Your version | Recommended upgrade path | Note |
| --------------------- | ------------ | ------------------------ | ---- |
| `13.2.3` | `11.5.0` | `11.5.0` -> `11.11.8` -> `12.0.12` -> `12.10.14` -> `13.0.12` -> `13.2.3` | Four intermediate versions are required: the final `11.11`, `12.0`, and `12.10` releases, plus `13.0`. |
| `13.0.12` | `11.10.8` | `11.10.5` -> `11.11.8` -> `12.0.12` -> `12.10.14` -> `13.0.12` | Three intermediate versions are required: `11.11`, `12.0`, and `12.10`. |
| `12.10.14` | `11.3.4` | `11.3.4` -> `11.11.8` -> `12.0.12` -> `12.10.14` | Two intermediate versions are required: `11.11` and `12.0` |
| `13.4.3` | `12.9.2` | `12.9.2` -> `12.10.14` -> `13.0.14` -> `13.4.3` | Two intermediate versions are required: the final `12.10` release, plus `13.0`. |
| `13.2.10` | `11.5.0` | `11.5.0` -> `11.11.8` -> `12.0.12` -> `12.10.14` -> `13.0.14` -> `13.2.10` | Four intermediate versions are required: the final `11.11`, `12.0`, and `12.10` releases, plus `13.0`. |
| `12.10.14` | `11.3.4` | `11.3.4` -> `11.11.8` -> `12.0.12` -> `12.10.14` | Two intermediate versions are required: the final `11.11` release and `12.0.12` |
| `12.9.5` | `10.4.5` | `10.4.5` -> `10.8.7` -> `11.11.8` -> `12.0.12` -> `12.9.5` | Three intermediate versions are required: `10.8`, `11.11`, and `12.0`, then `12.9.5` |
| `12.2.5` | `9.2.6` | `9.2.6` -> `9.5.10` -> `10.8.7` -> `11.11.8` -> `12.0.12` -> `12.2.5` | Four intermediate versions are required: `9.5`, `10.8`, `11.11`, `12.0`, then `12.2`. |
| `11.3.4` | `8.13.4` | `8.13.4` -> `8.17.7` -> `9.5.10` -> `10.8.7` -> `11.3.4` | `8.17.7` is the last version in version 8, `9.5.10` is the last version in version 9, `10.8.7` is the last version in version 10. |

View File

@ -13,6 +13,7 @@ module.exports = path => {
'jest-junit',
{
outputName: './junit_jest.xml',
addFileAttribute: 'true',
},
]);
}

View File

@ -1151,6 +1151,64 @@ into similar problems in the future (e.g. when new tables are created).
end
end
# Copies all check constraints for the old column to the new column.
#
# table - The table containing the columns.
# old - The old column.
# new - The new column.
# schema - The schema the table is defined for
# If it is not provided, then the current_schema is used
def copy_check_constraints(table, old, new, schema: nil)
if transaction_open?
raise 'copy_check_constraints can not be run inside a transaction'
end
unless column_exists?(table, old)
raise "Column #{old} does not exist on #{table}"
end
unless column_exists?(table, new)
raise "Column #{new} does not exist on #{table}"
end
table_with_schema = schema.present? ? "#{schema}.#{table}" : table
check_constraints_for(table, old, schema: schema).each do |check_c|
validate = !(check_c["constraint_def"].end_with? "NOT VALID")
# Normalize:
# - Old constraint definitions:
# '(char_length(entity_path) <= 5500)'
# - Definitionss from pg_get_constraintdef(oid):
# 'CHECK ((char_length(entity_path) <= 5500))'
# - Definitions from pg_get_constraintdef(oid, pretty_bool):
# 'CHECK (char_length(entity_path) <= 5500)'
# - Not valid constraints: 'CHECK (...) NOT VALID'
# to a single format that we can use:
# '(char_length(entity_path) <= 5500)'
check_definition = check_c["constraint_def"]
.sub(/^\s*(CHECK)?\s*\({0,2}/, '(')
.sub(/\){0,2}\s*(NOT VALID)?\s*$/, ')')
constraint_name = begin
if check_definition == "(#{old} IS NOT NULL)"
not_null_constraint_name(table_with_schema, new)
elsif check_definition.start_with? "(char_length(#{old}) <="
text_limit_name(table_with_schema, new)
else
check_constraint_name(table_with_schema, new, 'copy_check_constraint')
end
end
add_check_constraint(
table_with_schema,
check_definition.gsub(old.to_s, new.to_s),
constraint_name,
validate: validate
)
end
end
# Migration Helpers for adding limit to text columns
def add_text_limit(table, column, limit, constraint_name: nil, validate: true)
add_check_constraint(
@ -1278,6 +1336,37 @@ into similar problems in the future (e.g. when new tables are created).
end
end
# Returns an ActiveRecord::Result containing the check constraints
# defined for the given column.
#
# If the schema is not provided, then the current_schema is used
def check_constraints_for(table, column, schema: nil)
check_sql = <<~SQL
SELECT
ccu.table_schema as schema_name,
ccu.table_name as table_name,
ccu.column_name as column_name,
con.conname as constraint_name,
pg_get_constraintdef(con.oid) as constraint_def
FROM pg_catalog.pg_constraint con
INNER JOIN pg_catalog.pg_class rel
ON rel.oid = con.conrelid
INNER JOIN pg_catalog.pg_namespace nsp
ON nsp.oid = con.connamespace
INNER JOIN information_schema.constraint_column_usage ccu
ON con.conname = ccu.constraint_name
AND nsp.nspname = ccu.constraint_schema
AND rel.relname = ccu.table_name
WHERE nsp.nspname = #{connection.quote(schema.presence || current_schema)}
AND rel.relname = #{connection.quote(table)}
AND ccu.column_name = #{connection.quote(column)}
AND con.contype = 'c'
ORDER BY constraint_name
SQL
connection.exec_query(check_sql)
end
def statement_timeout_disabled?
# This is a string of the form "100ms" or "0" when disabled
connection.select_value('SHOW statement_timeout') == "0"
@ -1357,6 +1446,7 @@ into similar problems in the future (e.g. when new tables are created).
copy_indexes(table, old, new)
copy_foreign_keys(table, old, new)
copy_check_constraints(table, old, new)
end
def validate_timestamp_column_name!(column_name)

View File

@ -2851,9 +2851,6 @@ msgstr ""
msgid "An error occurred while fetching the Service Desk address."
msgstr ""
msgid "An error occurred while fetching the board lists. Please reload the page."
msgstr ""
msgid "An error occurred while fetching the board lists. Please try again."
msgstr ""
@ -4185,6 +4182,9 @@ msgstr ""
msgid "Boards|An error occurred while fetching the board issues. Please reload the page."
msgstr ""
msgid "Boards|An error occurred while fetching the board lists. Please reload the page."
msgstr ""
msgid "Boards|An error occurred while fetching the board swimlanes. Please reload the page."
msgstr ""

View File

@ -43,7 +43,7 @@
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.5",
"@gitlab/svgs": "1.171.0",
"@gitlab/ui": "21.27.0",
"@gitlab/ui": "21.28.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-3",
"@rails/ujs": "^6.0.3-2",

View File

@ -177,16 +177,26 @@ describe('createList', () => {
describe('moveList', () => {
it('should commit MOVE_LIST mutation and dispatch updateList action', done => {
const initialBoardListsState = {
'gid://gitlab/List/1': mockListsWithModel[0],
'gid://gitlab/List/2': mockListsWithModel[1],
};
const state = {
endpoints: { fullPath: 'gitlab-org', boardId: '1' },
boardType: 'group',
disabled: false,
boardLists: mockListsWithModel,
boardLists: initialBoardListsState,
};
testAction(
actions.moveList,
{ listId: 'gid://gitlab/List/1', newIndex: 1, adjustmentValue: 1 },
{
listId: 'gid://gitlab/List/1',
replacedListId: 'gid://gitlab/List/2',
newIndex: 1,
adjustmentValue: 1,
},
state,
[
{
@ -197,7 +207,11 @@ describe('moveList', () => {
[
{
type: 'updateList',
payload: { listId: 'gid://gitlab/List/1', position: 0, backupList: mockListsWithModel },
payload: {
listId: 'gid://gitlab/List/1',
position: 0,
backupList: initialBoardListsState,
},
},
],
done,

View File

@ -1,6 +1,13 @@
import getters from '~/boards/stores/getters';
import { inactiveId } from '~/boards/constants';
import { mockIssue, mockIssue2, mockIssues, mockIssuesByListId, issues } from '../mock_data';
import {
mockIssue,
mockIssue2,
mockIssues,
mockIssuesByListId,
issues,
mockListsWithModel,
} from '../mock_data';
describe('Boards - Getters', () => {
describe('getLabelToggleState', () => {
@ -130,4 +137,25 @@ describe('Boards - Getters', () => {
);
});
});
const boardsState = {
boardLists: {
'gid://gitlab/List/1': mockListsWithModel[0],
'gid://gitlab/List/2': mockListsWithModel[1],
},
};
describe('getListByLabelId', () => {
it('returns list for a given label id', () => {
expect(getters.getListByLabelId(boardsState)('gid://gitlab/GroupLabel/121')).toEqual(
mockListsWithModel[1],
);
});
});
describe('getListByTitle', () => {
it('returns list for a given list title', () => {
expect(getters.getListByTitle(boardsState)('To Do')).toEqual(mockListsWithModel[1]);
});
});
});

View File

@ -2,8 +2,6 @@ import mutations from '~/boards/stores/mutations';
import * as types from '~/boards/stores/mutation_types';
import defaultState from '~/boards/stores/state';
import {
listObj,
listObjDuplicate,
mockListsWithModel,
mockLists,
rawIssue,
@ -22,6 +20,11 @@ const expectNotImplemented = action => {
describe('Board Store Mutations', () => {
let state;
const initialBoardListsState = {
'gid://gitlab/List/1': mockListsWithModel[0],
'gid://gitlab/List/2': mockListsWithModel[1],
};
beforeEach(() => {
state = defaultState();
});
@ -56,11 +59,19 @@ describe('Board Store Mutations', () => {
describe('RECEIVE_BOARD_LISTS_SUCCESS', () => {
it('Should set boardLists to state', () => {
const lists = [listObj, listObjDuplicate];
mutations[types.RECEIVE_BOARD_LISTS_SUCCESS](state, initialBoardListsState);
mutations[types.RECEIVE_BOARD_LISTS_SUCCESS](state, lists);
expect(state.boardLists).toEqual(initialBoardListsState);
});
});
expect(state.boardLists).toEqual(lists);
describe('RECEIVE_BOARD_LISTS_FAILURE', () => {
it('Should set error in state', () => {
mutations[types.RECEIVE_BOARD_LISTS_FAILURE](state);
expect(state.error).toEqual(
'An error occurred while fetching the board lists. Please reload the page.',
);
});
});
@ -95,7 +106,13 @@ describe('Board Store Mutations', () => {
});
describe('RECEIVE_ADD_LIST_SUCCESS', () => {
expectNotImplemented(mutations.RECEIVE_ADD_LIST_SUCCESS);
it('adds list to boardLists state', () => {
mutations.RECEIVE_ADD_LIST_SUCCESS(state, mockListsWithModel[0]);
expect(state.boardLists).toEqual({
[mockListsWithModel[0].id]: mockListsWithModel[0],
});
});
});
describe('RECEIVE_ADD_LIST_ERROR', () => {
@ -106,7 +123,7 @@ describe('Board Store Mutations', () => {
it('updates boardLists state with reordered lists', () => {
state = {
...state,
boardLists: mockListsWithModel,
boardLists: initialBoardListsState,
};
mutations.MOVE_LIST(state, {
@ -114,7 +131,10 @@ describe('Board Store Mutations', () => {
listAtNewIndex: mockListsWithModel[1],
});
expect(state.boardLists).toEqual([mockListsWithModel[1], mockListsWithModel[0]]);
expect(state.boardLists).toEqual({
'gid://gitlab/List/2': mockListsWithModel[1],
'gid://gitlab/List/1': mockListsWithModel[0],
});
});
});
@ -122,13 +142,16 @@ describe('Board Store Mutations', () => {
it('updates boardLists state with previous order and sets error message', () => {
state = {
...state,
boardLists: [mockListsWithModel[1], mockListsWithModel[0]],
boardLists: {
'gid://gitlab/List/2': mockListsWithModel[1],
'gid://gitlab/List/1': mockListsWithModel[0],
},
error: undefined,
};
mutations.UPDATE_LIST_FAILURE(state, mockListsWithModel);
mutations.UPDATE_LIST_FAILURE(state, initialBoardListsState);
expect(state.boardLists).toEqual(mockListsWithModel);
expect(state.boardLists).toEqual(initialBoardListsState);
expect(state.error).toEqual('An error occurred while updating the list. Please try again.');
});
});
@ -177,7 +200,7 @@ describe('Board Store Mutations', () => {
'gid://gitlab/List/1': [],
},
issues: {},
boardLists: mockListsWithModel,
boardLists: initialBoardListsState,
};
const listPageInfo = {
@ -202,7 +225,7 @@ describe('Board Store Mutations', () => {
it('sets error message', () => {
state = {
...state,
boardLists: mockListsWithModel,
boardLists: initialBoardListsState,
error: undefined,
};
@ -284,7 +307,7 @@ describe('Board Store Mutations', () => {
state = {
...state,
issuesByListId: listIssues,
boardLists: mockListsWithModel,
boardLists: initialBoardListsState,
issues,
};
@ -332,7 +355,7 @@ describe('Board Store Mutations', () => {
state = {
...state,
issuesByListId: listIssues,
boardLists: mockListsWithModel,
boardLists: initialBoardListsState,
};
mutations.MOVE_ISSUE_FAILURE(state, {
@ -400,7 +423,7 @@ describe('Board Store Mutations', () => {
...state,
issuesByListId: listIssues,
issues,
boardLists: mockListsWithModel,
boardLists: initialBoardListsState,
};
mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 });

View File

@ -51,6 +51,34 @@ export const designListQueryResponse = {
},
};
export const designUploadMutationCreatedResponse = {
data: {
designManagementUpload: {
designs: [
{
id: '1',
event: 'CREATION',
filename: 'fox_1.jpg',
},
],
},
},
};
export const designUploadMutationUpdatedResponse = {
data: {
designManagementUpload: {
designs: [
{
id: '1',
event: 'MODIFICATION',
filename: 'fox_1.jpg',
},
],
},
},
};
export const permissionsQueryResponse = {
data: {
project: {

View File

@ -4,6 +4,7 @@ import VueDraggable from 'vuedraggable';
import VueRouter from 'vue-router';
import { GlEmptyState } from '@gitlab/ui';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import Index from '~/design_management/pages/index.vue';
import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql';
import DesignDestroyer from '~/design_management/components/design_destroyer.vue';
@ -21,6 +22,8 @@ import * as utils from '~/design_management/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants';
import {
designListQueryResponse,
designUploadMutationCreatedResponse,
designUploadMutationUpdatedResponse,
permissionsQueryResponse,
moveDesignMutationResponse,
reorderedDesigns,
@ -29,6 +32,7 @@ import {
import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
import { DESIGN_TRACKING_PAGE_NAME } from '~/design_management/utils/tracking';
jest.mock('~/flash.js');
const mockPageEl = {
@ -370,7 +374,7 @@ describe('Design management index page', () => {
createComponent({ stubs: { GlEmptyState } });
wrapper.setData({ filesToBeSaved: [{ name: 'test' }] });
wrapper.vm.onUploadDesignDone();
wrapper.vm.onUploadDesignDone(designUploadMutationCreatedResponse);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.filesToBeSaved).toEqual([]);
expect(wrapper.vm.isSaving).toBeFalsy();
@ -482,6 +486,34 @@ describe('Design management index page', () => {
expect(createFlash).toHaveBeenCalledWith(message);
});
});
describe('tracking', () => {
let trackingSpy;
beforeEach(() => {
trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
createComponent({ stubs: { GlEmptyState } });
});
afterEach(() => {
unmockTracking();
});
it('tracks design creation', () => {
wrapper.vm.onUploadDesignDone(designUploadMutationCreatedResponse);
expect(trackingSpy).toHaveBeenCalledTimes(1);
expect(trackingSpy).toHaveBeenCalledWith(DESIGN_TRACKING_PAGE_NAME, 'create_design');
});
it('tracks design modification', () => {
wrapper.vm.onUploadDesignDone(designUploadMutationUpdatedResponse);
expect(trackingSpy).toHaveBeenCalledTimes(1);
expect(trackingSpy).toHaveBeenCalledWith(DESIGN_TRACKING_PAGE_NAME, 'update_design');
});
});
});
describe('on latest version when has designs', () => {

View File

@ -10,6 +10,7 @@ RSpec.describe Types::Ci::JobType do
name
needs
detailedStatus
scheduledAt
]
expect(described_class).to have_graphql_fields(*expected_fields)

View File

@ -699,6 +699,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:copy_indexes).with(:users, :old, :new)
expect(model).to receive(:copy_foreign_keys).with(:users, :old, :new)
expect(model).to receive(:copy_check_constraints).with(:users, :old, :new)
model.rename_column_concurrently(:users, :old, :new)
end
@ -761,6 +762,9 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:change_column_default)
.with(:users, :new, old_column.default)
expect(model).to receive(:copy_check_constraints)
.with(:users, :old, :new)
model.rename_column_concurrently(:users, :old, :new)
end
end
@ -856,6 +860,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:copy_indexes).with(:users, :new, :old)
expect(model).to receive(:copy_foreign_keys).with(:users, :new, :old)
expect(model).to receive(:copy_check_constraints).with(:users, :new, :old)
model.undo_cleanup_concurrent_column_rename(:users, :old, :new)
end
@ -894,6 +899,9 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model).to receive(:change_column_default)
.with(:users, :old, new_column.default)
expect(model).to receive(:copy_check_constraints)
.with(:users, :new, :old)
model.undo_cleanup_concurrent_column_rename(:users, :old, :new)
end
end
@ -2172,6 +2180,138 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
end
describe '#copy_check_constraints' do
context 'inside a transaction' do
it 'raises an error' do
expect(model).to receive(:transaction_open?).and_return(true)
expect do
model.copy_check_constraints(:test_table, :old_column, :new_column)
end.to raise_error(RuntimeError)
end
end
context 'outside a transaction' do
before do
allow(model).to receive(:transaction_open?).and_return(false)
allow(model).to receive(:column_exists?).and_return(true)
end
let(:old_column_constraints) do
[
{
'schema_name' => 'public',
'table_name' => 'test_table',
'column_name' => 'old_column',
'constraint_name' => 'check_d7d49d475d',
'constraint_def' => 'CHECK ((old_column IS NOT NULL))'
},
{
'schema_name' => 'public',
'table_name' => 'test_table',
'column_name' => 'old_column',
'constraint_name' => 'check_48560e521e',
'constraint_def' => 'CHECK ((char_length(old_column) <= 255))'
},
{
'schema_name' => 'public',
'table_name' => 'test_table',
'column_name' => 'old_column',
'constraint_name' => 'custom_check_constraint',
'constraint_def' => 'CHECK (((old_column IS NOT NULL) AND (another_column IS NULL)))'
},
{
'schema_name' => 'public',
'table_name' => 'test_table',
'column_name' => 'old_column',
'constraint_name' => 'not_valid_check_constraint',
'constraint_def' => 'CHECK ((old_column IS NOT NULL)) NOT VALID'
}
]
end
it 'copies check constraints from one column to another' do
allow(model).to receive(:check_constraints_for)
.with(:test_table, :old_column, schema: nil)
.and_return(old_column_constraints)
allow(model).to receive(:not_null_constraint_name).with(:test_table, :new_column)
.and_return('check_1')
allow(model).to receive(:text_limit_name).with(:test_table, :new_column)
.and_return('check_2')
allow(model).to receive(:check_constraint_name)
.with(:test_table, :new_column, 'copy_check_constraint')
.and_return('check_3')
expect(model).to receive(:add_check_constraint)
.with(
:test_table,
'(new_column IS NOT NULL)',
'check_1',
validate: true
).once
expect(model).to receive(:add_check_constraint)
.with(
:test_table,
'(char_length(new_column) <= 255)',
'check_2',
validate: true
).once
expect(model).to receive(:add_check_constraint)
.with(
:test_table,
'((new_column IS NOT NULL) AND (another_column IS NULL))',
'check_3',
validate: true
).once
expect(model).to receive(:add_check_constraint)
.with(
:test_table,
'(new_column IS NOT NULL)',
'check_1',
validate: false
).once
model.copy_check_constraints(:test_table, :old_column, :new_column)
end
it 'does nothing if there are no constraints defined for the old column' do
allow(model).to receive(:check_constraints_for)
.with(:test_table, :old_column, schema: nil)
.and_return([])
expect(model).not_to receive(:add_check_constraint)
model.copy_check_constraints(:test_table, :old_column, :new_column)
end
it 'raises an error when the orginating column does not exist' do
allow(model).to receive(:column_exists?).with(:test_table, :old_column).and_return(false)
error_message = /Column old_column does not exist on test_table/
expect do
model.copy_check_constraints(:test_table, :old_column, :new_column)
end.to raise_error(RuntimeError, error_message)
end
it 'raises an error when the target column does not exist' do
allow(model).to receive(:column_exists?).with(:test_table, :new_column).and_return(false)
error_message = /Column new_column does not exist on test_table/
expect do
model.copy_check_constraints(:test_table, :old_column, :new_column)
end.to raise_error(RuntimeError, error_message)
end
end
end
describe '#add_text_limit' do
context 'when it is called with the default options' do
it 'calls add_check_constraint with an infered constraint name and validate: true' do

View File

@ -37,11 +37,12 @@ RSpec.describe LabelSerializer do
subject { serializer.represent_appearance(resource) }
it 'serializes only attributes used for appearance' do
expect(subject.keys).to eq([:id, :title, :color, :text_color])
expect(subject.keys).to eq([:id, :title, :color, :project_id, :text_color])
expect(subject[:id]).to eq(resource.id)
expect(subject[:title]).to eq(resource.title)
expect(subject[:color]).to eq(resource.color)
expect(subject[:text_color]).to eq(resource.text_color)
expect(subject[:project_id]).to eq(resource.project_id)
end
end
end

View File

@ -3,65 +3,5 @@
require 'spec_helper'
RSpec.describe IncidentManagement::CreateIncidentLabelService do
let_it_be(:project) { create(:project, :private) }
let_it_be(:user) { User.alert_bot }
let(:service) { described_class.new(project, user) }
subject(:execute) { service.execute }
describe 'execute' do
let(:incident_label_attributes) { attributes_for(:label, :incident) }
let(:title) { incident_label_attributes[:title] }
let(:color) { incident_label_attributes[:color] }
let(:description) { incident_label_attributes[:description] }
shared_examples 'existing label' do
it 'returns the existing label' do
expect { execute }.not_to change(Label, :count)
expect(execute).to be_success
expect(execute.payload).to eq(label: label)
end
end
shared_examples 'new label' do
it 'creates a new label' do
expect { execute }.to change(Label, :count).by(1)
label = project.reload.labels.last
expect(execute).to be_success
expect(execute.payload).to eq(label: label)
expect(label.title).to eq(title)
expect(label.color).to eq(color)
expect(label.description).to eq(description)
end
end
context 'with predefined project label' do
it_behaves_like 'existing label' do
let!(:label) { create(:label, project: project, title: title) }
end
end
context 'with predefined group label' do
let(:project) { create(:project, group: group) }
let(:group) { create(:group) }
it_behaves_like 'existing label' do
let!(:label) { create(:group_label, group: group, title: title) }
end
end
context 'without label' do
context 'when user has permissions to create labels' do
it_behaves_like 'new label'
end
context 'when user has no permissions to create labels' do
let_it_be(:user) { create(:user) }
it_behaves_like 'new label'
end
end
end
it_behaves_like 'incident management label service'
end

View File

@ -243,7 +243,7 @@ RSpec.describe Projects::AfterRenameService do
def service_execute
# AfterRenameService is called by UpdateService after a successful model.update
# the initialization will include before and after paths values
project.update(path: path_after_rename)
project.update!(path: path_after_rename)
described_class.new(project, path_before: path_before_rename, full_path_before: full_path_before_rename).execute
end

View File

@ -123,7 +123,7 @@ RSpec.describe Projects::AutocompleteService do
let!(:subgroup_milestone) { create(:milestone, group: subgroup) }
before do
project.update(namespace: subgroup)
project.update!(namespace: subgroup)
end
it 'includes project milestones and all acestors milestones' do

View File

@ -15,7 +15,7 @@ RSpec.describe Projects::CreateService, '#execute' do
end
it 'creates labels on Project creation if there are templates' do
Label.create(title: "bug", template: true)
Label.create!(title: "bug", template: true)
project = create_project(user, opts)
created_label = project.reload.labels.last

View File

@ -72,7 +72,7 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do
context 'when project has remote mirrors' do
let!(:project) do
create(:project, :repository, namespace: user.namespace).tap do |project|
project.remote_mirrors.create(url: 'http://test.com')
project.remote_mirrors.create!(url: 'http://test.com')
end
end

View File

@ -179,7 +179,7 @@ RSpec.describe Projects::ForkService do
context "when origin has git depth specified" do
before do
@from_project.update(ci_default_git_depth: 42)
@from_project.update!(ci_default_git_depth: 42)
end
it "inherits default_git_depth from the origin project" do
@ -201,7 +201,7 @@ RSpec.describe Projects::ForkService do
context "when project has restricted visibility level" do
context "and only one visibility level is restricted" do
before do
@from_project.update(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
@from_project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::INTERNAL])
end

View File

@ -45,7 +45,7 @@ RSpec.describe Projects::HashedStorage::BaseAttachmentService do
describe '#move_folder!' do
context 'when old_path is not a directory' do
it 'adds information to the logger and returns true' do
Tempfile.create do |old_path|
Tempfile.create do |old_path| # rubocop:disable Rails/SaveBang
new_path = "#{old_path}-new"
expect(subject.send(:move_folder!, old_path, new_path)).to be_truthy

View File

@ -17,9 +17,9 @@ RSpec.describe Projects::MoveAccessService do
project_with_access.add_maintainer(maintainer_user)
project_with_access.add_developer(developer_user)
project_with_access.add_reporter(reporter_user)
project_with_access.project_group_links.create(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
project_with_access.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
project_with_access.project_group_links.create(group: reporter_group, group_access: Gitlab::Access::REPORTER)
project_with_access.project_group_links.create!(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
project_with_access.project_group_links.create!(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
project_with_access.project_group_links.create!(group: reporter_group, group_access: Gitlab::Access::REPORTER)
end
subject { described_class.new(target_project, user) }
@ -97,7 +97,7 @@ RSpec.describe Projects::MoveAccessService do
end
it 'does not remove remaining group links' do
target_project.project_group_links.create(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
target_project.project_group_links.create!(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
subject.execute(project_with_access, options)

View File

@ -14,9 +14,9 @@ RSpec.describe Projects::MoveProjectGroupLinksService do
describe '#execute' do
before do
project_with_groups.project_group_links.create(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
project_with_groups.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
project_with_groups.project_group_links.create(group: reporter_group, group_access: Gitlab::Access::REPORTER)
project_with_groups.project_group_links.create!(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
project_with_groups.project_group_links.create!(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
project_with_groups.project_group_links.create!(group: reporter_group, group_access: Gitlab::Access::REPORTER)
end
it 'moves the group links from one project to another' do
@ -30,8 +30,8 @@ RSpec.describe Projects::MoveProjectGroupLinksService do
end
it 'does not move existent group links in the current project' do
target_project.project_group_links.create(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
target_project.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
target_project.project_group_links.create!(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
target_project.project_group_links.create!(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
expect(project_with_groups.project_group_links.count).to eq 3
expect(target_project.project_group_links.count).to eq 2
@ -55,8 +55,8 @@ RSpec.describe Projects::MoveProjectGroupLinksService do
let(:options) { { remove_remaining_elements: false } }
it 'does not remove remaining project group links' do
target_project.project_group_links.create(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
target_project.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
target_project.project_group_links.create!(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
target_project.project_group_links.create!(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
subject.execute(project_with_groups, options)

View File

@ -111,9 +111,9 @@ RSpec.describe Projects::OverwriteProjectService do
create_list(:deploy_keys_project, 2, project: project_from)
create_list(:notification_setting, 2, source: project_from)
create_list(:users_star_project, 2, project: project_from)
project_from.project_group_links.create(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
project_from.project_group_links.create(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
project_from.project_group_links.create(group: reporter_group, group_access: Gitlab::Access::REPORTER)
project_from.project_group_links.create!(group: maintainer_group, group_access: Gitlab::Access::MAINTAINER)
project_from.project_group_links.create!(group: developer_group, group_access: Gitlab::Access::DEVELOPER)
project_from.project_group_links.create!(group: reporter_group, group_access: Gitlab::Access::REPORTER)
project_from.add_maintainer(maintainer_user)
project_from.add_developer(developer_user)
project_from.add_reporter(reporter_user)

View File

@ -61,7 +61,7 @@ RSpec.describe Projects::UnlinkForkService, :use_clean_rails_memory_store_cachin
context 'when the original project was deleted' do
it 'does not fail when the original project is deleted' do
source = forked_project.forked_from_project
source.destroy
source.destroy!
forked_project.reload
expect { subject.execute }.not_to raise_error

View File

@ -95,14 +95,14 @@ RSpec.describe Projects::UpdatePagesService do
expect(project.pages_deployed?).to be_truthy
expect(Dir.exist?(File.join(project.pages_path))).to be_truthy
project.destroy
project.destroy!
expect(Dir.exist?(File.join(project.pages_path))).to be_falsey
expect(ProjectPagesMetadatum.find_by_project_id(project)).to be_nil
end
it 'fails if sha on branch is not latest' do
build.update(ref: 'feature')
build.update!(ref: 'feature')
expect(execute).not_to eq(:success)
expect(project.pages_metadatum).not_to be_deployed
@ -191,7 +191,7 @@ RSpec.describe Projects::UpdatePagesService do
it 'fails to remove project pages when no pages is deployed' do
expect(PagesWorker).not_to receive(:perform_in)
expect(project.pages_deployed?).to be_falsey
project.destroy
project.destroy!
end
it 'fails if no artifacts' do

View File

@ -141,7 +141,7 @@ RSpec.describe Projects::UpdateService do
let(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel::INTERNAL) }
before do
project.update(namespace: group, visibility_level: group.visibility_level)
project.update!(namespace: group, visibility_level: group.visibility_level)
end
it 'does not update project visibility level' do
@ -256,7 +256,7 @@ RSpec.describe Projects::UpdateService do
end
it 'handles empty project feature attributes' do
project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED)
project.project_feature.update!(wiki_access_level: ProjectFeature::DISABLED)
result = update_project(project, user, { name: 'test1' })
@ -267,7 +267,7 @@ RSpec.describe Projects::UpdateService do
context 'when enabling a wiki' do
it 'creates a wiki' do
project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED)
project.project_feature.update!(wiki_access_level: ProjectFeature::DISABLED)
TestEnv.rm_storage_dir(project.repository_storage, project.wiki.path)
result = update_project(project, user, project_feature_attributes: { wiki_access_level: ProjectFeature::ENABLED })
@ -278,7 +278,7 @@ RSpec.describe Projects::UpdateService do
end
it 'logs an error and creates a metric when wiki can not be created' do
project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED)
project.project_feature.update!(wiki_access_level: ProjectFeature::DISABLED)
expect_any_instance_of(ProjectWiki).to receive(:wiki).and_raise(Wiki::CouldNotCreateWikiError)
expect_any_instance_of(described_class).to receive(:log_error).with("Could not create wiki for #{project.full_name}")

View File

@ -45,3 +45,74 @@ RSpec.shared_examples 'not an incident issue' do
expect(issue.labels).not_to include(have_attributes(label_properties))
end
end
# This shared example is to test the execution of incident management label services
# For example:
# - IncidentManagement::CreateIncidentSlaExceededLabelService
# - IncidentManagement::CreateIncidentLabelService
# It doesn't require any defined variables
RSpec.shared_examples 'incident management label service' do
let_it_be(:project) { create(:project, :private) }
let_it_be(:user) { User.alert_bot }
let(:service) { described_class.new(project, user) }
subject(:execute) { service.execute }
describe 'execute' do
let(:incident_label_attributes) { described_class::LABEL_PROPERTIES }
let(:title) { incident_label_attributes[:title] }
let(:color) { incident_label_attributes[:color] }
let(:description) { incident_label_attributes[:description] }
shared_examples 'existing label' do
it 'returns the existing label' do
expect { execute }.not_to change(Label, :count)
expect(execute).to be_success
expect(execute.payload).to eq(label: label)
end
end
shared_examples 'new label' do
it 'creates a new label' do
expect { execute }.to change(Label, :count).by(1)
label = project.reload.labels.last
expect(execute).to be_success
expect(execute.payload).to eq(label: label)
expect(label.title).to eq(title)
expect(label.color).to eq(color)
expect(label.description).to eq(description)
end
end
context 'with predefined project label' do
it_behaves_like 'existing label' do
let!(:label) { create(:label, project: project, title: title) }
end
end
context 'with predefined group label' do
let(:project) { create(:project, group: group) }
let(:group) { create(:group) }
it_behaves_like 'existing label' do
let!(:label) { create(:group_label, group: group, title: title) }
end
end
context 'without label' do
context 'when user has permissions to create labels' do
it_behaves_like 'new label'
end
context 'when user has no permissions to create labels' do
let_it_be(:user) { create(:user) }
it_behaves_like 'new label'
end
end
end
end

View File

@ -866,10 +866,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.171.0.tgz#abc3092bf804f0898301626130e0f3231834924a"
integrity sha512-TPfdqIxQDda+0CQHhb9XdF50lmqDmADu6yT8R4oZi6BoUtWLdiHbyFt+RnVU6t7EmjIKicNAii7Ga+f2ljCfUA==
"@gitlab/ui@21.27.0":
version "21.27.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.27.0.tgz#4463adc552bb7b7f9a22e0a0281ca761a3daa70a"
integrity sha512-9bMZZebdXWXhPnXbklcragfGosNwZEcqulITWvPSwXcFJwNk2xEHpKy7b/SwQMcErpDjne/eduEnWEGtT+aFNw==
"@gitlab/ui@21.28.0":
version "21.28.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.28.0.tgz#28455d9f53ed34c0b17ea8e1073b670c59617032"
integrity sha512-skhWKaC3hzWpLA6GoDLG5qJqdgRhYNfAtE2W7pONyfi21eUgZuMbzCVSX3dYLm6v2LEBsJRZXbguWmCOT2ZilQ==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"