Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-02-13 15:08:09 +00:00
parent 67cddd762d
commit aadb3204ea
59 changed files with 944 additions and 302 deletions

View File

@ -2,7 +2,6 @@
Rake/Require:
Details: grace period
Exclude:
- 'ee/lib/tasks/gitlab/spdx.rake'
- 'lib/tasks/gitlab/artifacts/migrate.rake'
- 'lib/tasks/gitlab/assets.rake'
- 'lib/tasks/gitlab/backup.rake'

View File

@ -46,7 +46,7 @@ export default {
<template>
<span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center">
<gl-dropdown
v-if="isProjectsImportEnabled && isAvailableForImport"
v-if="isProjectsImportEnabled && (isAvailableForImport || isFinished)"
:text="isFinished ? __('Re-import with projects') : __('Import with projects')"
:disabled="isInvalid"
variant="confirm"
@ -60,7 +60,7 @@ export default {
}}</gl-dropdown-item>
</gl-dropdown>
<gl-button
v-else-if="isAvailableForImport"
v-else-if="isAvailableForImport || isFinished"
:disabled="isInvalid"
variant="confirm"
category="secondary"
@ -70,7 +70,7 @@ export default {
{{ isFinished ? __('Re-import') : __('Import') }}
</gl-button>
<gl-icon
v-if="isAvailableForImport && isFinished"
v-if="isFinished"
v-gl-tooltip
:size="16"
name="information-o"

View File

@ -103,6 +103,7 @@ export default {
perPage: DEFAULT_PAGE_SIZE,
selectedGroupsIds: [],
pendingGroupsIds: [],
reimportRequests: [],
importTargets: {},
unavailableFeaturesAlertVisible: true,
helpUrl: helpPagePath('ee/user/group/import', {
@ -181,9 +182,14 @@ export default {
const importTarget = this.importTargets[group.id];
const status = this.getStatus(group);
const isGroupAvailableForImport = isFinished(group)
? this.reimportRequests.includes(group.id)
: isAvailableForImport(group) && status !== STATUSES.SCHEDULING;
const flags = {
isInvalid: (importTarget.validationErrors ?? []).filter((e) => !e.nonBlocking).length > 0,
isAvailableForImport: isAvailableForImport(group) && status !== STATUSES.SCHEDULING,
isAvailableForImport: isGroupAvailableForImport,
isAllowedForReimport: false,
isFinished: isFinished(group),
};
@ -359,13 +365,9 @@ export default {
this.validateImportTarget(newImportTarget);
},
async importGroups(importRequests) {
async requestGroupsImport(importRequests) {
const newPendingGroupsIds = importRequests.map((request) => request.sourceGroupId);
newPendingGroupsIds.forEach((id) => {
this.importTargets[id].validationErrors = [
{ field: NEW_NAME_FIELD, message: i18n.ERROR_IMPORT_COMPLETED },
];
if (!this.pendingGroupsIds.includes(id)) {
this.pendingGroupsIds.push(id);
}
@ -397,6 +399,26 @@ export default {
}
},
importGroup({ group, extraArgs, index }) {
if (group.flags.isFinished && !this.reimportRequests.includes(group.id)) {
this.validateImportTarget(group.importTarget);
this.reimportRequests.push(group.id);
this.$nextTick(() => {
this.$refs[`importTargetCell-${index}`].focusNewName();
});
} else {
this.reimportRequests = this.reimportRequests.filter((id) => id !== group.id);
this.requestGroupsImport([
{
sourceGroupId: group.id,
targetNamespace: group.importTarget.targetNamespace.fullPath,
newName: group.importTarget.newName,
...extraArgs,
},
]);
}
},
importSelectedGroups(extraArgs = {}) {
const importRequests = this.groupsTableData
.filter((group) => this.selectedGroupsIds.includes(group.id))
@ -407,7 +429,7 @@ export default {
...extraArgs,
}));
this.importGroups(importRequests);
this.requestGroupsImport(importRequests);
},
setPageSize(size) {
@ -768,8 +790,9 @@ export default {
<template #cell(webUrl)="{ item: group }">
<import-source-cell :group="group" />
</template>
<template #cell(importTarget)="{ item: group }">
<template #cell(importTarget)="{ item: group, index }">
<import-target-cell
:ref="`importTargetCell-${index}`"
:group="group"
:group-path-regex="groupPathRegex"
@update-target-namespace="updateImportTarget(group, { targetNamespace: $event })"
@ -779,22 +802,13 @@ export default {
<template #cell(progress)="{ item: group }">
<import-status-cell :status="group.visibleStatus" class="gl-line-height-32" />
</template>
<template #cell(actions)="{ item: group }">
<template #cell(actions)="{ item: group, index }">
<import-actions-cell
:is-projects-import-enabled="isProjectsImportEnabled"
:is-finished="group.flags.isFinished"
:is-available-for-import="group.flags.isAvailableForImport"
:is-invalid="group.flags.isInvalid"
@import-group="
importGroups([
{
sourceGroupId: group.id,
targetNamespace: group.importTarget.targetNamespace.fullPath,
newName: group.importTarget.newName,
...$event,
},
])
"
@import-group="importGroup({ group, extraArgs: $event, index })"
/>
</template>
</gl-table>

View File

@ -38,6 +38,15 @@ export default {
// this will highlight field in green like "passed validation"
return this.group.flags.isInvalid && this.group.flags.isAvailableForImport ? false : null;
},
isPathSelectionAvailable() {
return this.group.flags.isAvailableForImport;
},
},
methods: {
focusNewName() {
this.$refs.newName.$el.focus();
},
},
};
</script>
@ -48,7 +57,7 @@ export default {
<import-group-dropdown
#default="{ namespaces }"
:text="fullPath"
:disabled="!group.flags.isAvailableForImport"
:disabled="!isPathSelectionAvailable"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="gl-h-7 gl-flex-grow-1"
data-qa-selector="target_namespace_selector_dropdown"
@ -76,23 +85,22 @@ export default {
<div
class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
:class="{
'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport,
'gl-border-gray-200': group.flags.isAvailableForImport,
'gl-text-gray-400 gl-border-gray-100': !isPathSelectionAvailable,
'gl-border-gray-200': isPathSelectionAvailable,
}"
>
/
</div>
<div class="gl-flex-grow-1">
<gl-form-input
ref="newName"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:class="{
'gl-inset-border-1-gray-200!':
group.flags.isAvailableForImport && !group.flags.isInvalid,
'gl-inset-border-1-gray-100!':
!group.flags.isAvailableForImport && !group.flags.isInvalid,
'gl-inset-border-1-gray-200!': isPathSelectionAvailable,
'gl-inset-border-1-gray-100!': !isPathSelectionAvailable,
}"
debounce="500"
:disabled="!group.flags.isAvailableForImport"
:disabled="!isPathSelectionAvailable"
:value="group.importTarget.newName"
:aria-label="__('New name')"
:state="validNameState"
@ -101,7 +109,7 @@ export default {
</div>
</div>
<div
v-if="group.flags.isAvailableForImport && (group.flags.isInvalid || validationMessage)"
v-if="isPathSelectionAvailable && (group.flags.isInvalid || validationMessage)"
class="gl-text-red-500 gl-m-0 gl-mt-2"
role="alert"
>

View File

@ -4,6 +4,8 @@ export const STATUS_CLOSED = 'closed';
export const STATUS_OPEN = 'opened';
export const STATUS_REOPENED = 'reopened';
export const TITLE_LENGTH_MAX = 255;
export const IssuableStatusText = {
[STATUS_CLOSED]: __('Closed'),
[STATUS_OPEN]: __('Open'),

View File

@ -185,6 +185,11 @@ export default {
required: false,
default: null,
},
issueIid: {
type: Number,
required: false,
default: null,
},
},
data() {
const store = new Store({
@ -559,6 +564,7 @@ export default {
<component
:is="descriptionComponent"
:issue-id="issueId"
:issue-iid="issueIid"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"

View File

@ -4,6 +4,7 @@ import $ from 'jquery';
import { uniqueId } from 'lodash';
import Sortable from 'sortablejs';
import Vue from 'vue';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
@ -16,22 +17,29 @@ import { __, s__, sprintf } from '~/locale';
import { getSortableDefaultOptions, isDragging } from '~/sortable/utils';
import TaskList from '~/task_list';
import Tracking from '~/tracking';
import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql';
import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import {
sprintfWorkItem,
I18N_WORK_ITEM_ERROR_CREATING,
I18N_WORK_ITEM_ERROR_DELETING,
TRACKING_CATEGORY_SHOW,
TASK_TYPE_NAME,
WIDGET_TYPE_DESCRIPTION,
} from '~/work_items/constants';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import eventHub from '../event_hub';
import animateMixin from '../mixins/animate';
import { convertDescriptionWithDeletedTaskListItem, convertDescriptionWithNewSort } from '../utils';
import {
deleteTaskListItem,
convertDescriptionWithNewSort,
extractTaskTitleAndDescription,
} from '../utils';
import TaskListItemActions from './task_list_item_actions.vue';
Vue.use(GlToast);
@ -49,7 +57,7 @@ export default {
WorkItemDetailModal,
},
mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
inject: ['fullPath'],
inject: ['fullPath', 'hasIterationsFeature'],
props: {
canUpdate: {
type: Boolean,
@ -89,6 +97,11 @@ export default {
required: false,
default: null,
},
issueIid: {
type: Number,
required: false,
default: null,
},
isUpdating: {
type: Boolean,
required: false,
@ -103,6 +116,7 @@ export default {
preAnimation: false,
pulseAnimation: false,
initialUpdate: true,
issueDetails: {},
activeTask: {},
workItemId: isPositiveInteger(workItemId)
? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
@ -111,6 +125,16 @@ export default {
};
},
apollo: {
issueDetails: {
query: getIssueDetailsQuery,
variables() {
return {
fullPath: this.fullPath,
iid: String(this.issueIid),
};
},
update: (data) => data.workspace?.issuable,
},
workItem: {
query: workItemQuery,
variables() {
@ -165,6 +189,7 @@ export default {
},
},
mounted() {
eventHub.$on('convert-task-list-item', this.convertTaskListItem);
eventHub.$on('delete-task-list-item', this.deleteTaskListItem);
this.renderGFM();
@ -178,6 +203,7 @@ export default {
}
},
beforeDestroy() {
eventHub.$off('convert-task-list-item', this.convertTaskListItem);
eventHub.$off('delete-task-list-item', this.deleteTaskListItem);
this.removeAllPointerEventListeners();
@ -319,19 +345,26 @@ export default {
$tasksShort.text('');
}
},
createTaskListItemActions(toggleClass) {
createTaskListItemActions(provide) {
const app = new Vue({
el: document.createElement('div'),
provide: { toggleClass },
provide,
render: (createElement) => createElement(TaskListItemActions),
});
return app.$el;
},
deleteTaskListItem(sourcepos) {
this.$emit(
'saveDescription',
convertDescriptionWithDeletedTaskListItem(this.descriptionText, sourcepos),
convertTaskListItem(sourcepos) {
const oldDescription = this.descriptionText;
const { newDescription, taskDescription, taskTitle } = deleteTaskListItem(
oldDescription,
sourcepos,
);
this.$emit('saveDescription', newDescription);
this.createTask({ taskTitle, taskDescription, oldDescription });
},
deleteTaskListItem(sourcepos) {
const { newDescription } = deleteTaskListItem(this.descriptionText, sourcepos);
this.$emit('saveDescription', newDescription);
},
renderTaskListItemActions() {
if (!this.$el?.querySelectorAll) {
@ -368,8 +401,9 @@ export default {
}
const toggleClass = uniqueId('task-list-item-actions-');
const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate, toggleClass });
this.addPointerEventListeners(item, `.${toggleClass}`);
this.insertNextToTaskListItemText(this.createTaskListItemActions(toggleClass), item);
this.insertNextToTaskListItemText(dropdown, item);
this.hasTaskListItemActions = true;
});
},
@ -423,55 +457,90 @@ export default {
this.workItemId = undefined;
this.updateWorkItemIdUrlQuery(undefined);
},
async handleCreateTask(el) {
this.setActiveTask(el);
async createTask({ taskTitle, taskDescription, oldDescription }) {
try {
const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription);
const iterationInput = {
iterationWidget: {
iterationId: this.issueDetails.iteration?.id ?? null,
},
};
const input = {
confidential: this.issueDetails.confidential,
description,
hierarchyWidget: {
parentId: this.issueGid,
},
...(this.hasIterationsFeature && iterationInput),
milestoneWidget: {
milestoneId: this.issueDetails.milestone?.id ?? null,
},
projectPath: this.fullPath,
title,
workItemTypeId: this.taskWorkItemType,
};
const { data } = await this.$apollo.mutate({
mutation: createWorkItemFromTaskMutation,
variables: {
input: {
id: this.issueGid,
workItemData: {
lockVersion: this.lockVersion,
title: this.activeTask.title,
lineNumberStart: Number(this.activeTask.lineNumberStart),
lineNumberEnd: Number(this.activeTask.lineNumberEnd),
workItemTypeId: this.taskWorkItemType,
},
mutation: createWorkItemMutation,
variables: { input },
});
const { workItem, errors } = data.workItemCreate;
if (errors?.length) {
throw new Error(errors);
}
await this.$apollo.mutate({
mutation: addHierarchyChildMutation,
variables: { id: this.issueGid, workItem },
});
this.$toast.show(s__('WorkItem|Converted to task'), {
action: {
text: s__('WorkItem|Undo'),
onClick: (_, toast) => {
this.undoCreateTask(oldDescription, workItem.id);
toast.hide();
},
},
update(store, { data: { workItemCreateFromTask } }) {
const { newWorkItem } = workItemCreateFromTask;
store.writeQuery({
query: workItemQuery,
variables: {
id: newWorkItem.id,
},
data: {
workItem: newWorkItem,
},
});
},
});
const { workItem, newWorkItem } = data.workItemCreateFromTask;
const updatedDescription = workItem?.widgets?.find(
(widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
)?.descriptionHtml;
this.$emit('updateDescription', updatedDescription);
this.workItemId = newWorkItem.id;
this.openWorkItemDetailModal(el);
} catch (error) {
createAlert({
message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK),
error,
captureError: true,
});
this.showAlert(I18N_WORK_ITEM_ERROR_CREATING, error);
}
},
async undoCreateTask(oldDescription, id) {
this.$emit('saveDescription', oldDescription);
try {
const { data } = await this.$apollo.mutate({
mutation: deleteWorkItemMutation,
variables: { input: { id } },
});
const { errors } = data.workItemDelete;
if (errors?.length) {
throw new Error(errors);
}
await this.$apollo.mutate({
mutation: removeHierarchyChildMutation,
variables: { id: this.issueGid, workItem: { id } },
});
this.$toast.show(s__('WorkItem|Task reverted'));
} catch (error) {
this.showAlert(I18N_WORK_ITEM_ERROR_DELETING, error);
}
},
showAlert(message, error) {
createAlert({
message: sprintfWorkItem(message, workItemTypes.TASK),
error,
captureError: true,
});
},
handleDeleteTask(description) {
this.$emit('updateDescription', description);
this.$toast.show(s__('WorkItem|Task deleted'));

View File

@ -5,6 +5,7 @@ import eventHub from '../event_hub';
export default {
i18n: {
convertToTask: s__('WorkItem|Convert to task'),
delete: __('Delete'),
taskActions: s__('WorkItem|Task actions'),
},
@ -12,8 +13,11 @@ export default {
GlDropdown,
GlDropdownItem,
},
inject: ['toggleClass'],
inject: ['canUpdate', 'toggleClass'],
methods: {
convertToTask() {
eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos);
},
deleteTaskListItem() {
eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos);
},
@ -33,7 +37,10 @@ export default {
text-sr-only
:toggle-class="`task-list-item-actions gl-opacity-0 gl-p-2! ${toggleClass}`"
>
<gl-dropdown-item variant="danger" @click="deleteTaskListItem">
<gl-dropdown-item v-if="canUpdate" @click="convertToTask">
{{ $options.i18n.convertToTask }}
</gl-dropdown-item>
<gl-dropdown-item v-if="canUpdate" variant="danger" @click="deleteTaskListItem">
{{ $options.i18n.delete }}
</gl-dropdown-item>
</gl-dropdown>

View File

@ -94,7 +94,12 @@ export function initIssueApp(issueData, store) {
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
const { canCreateIncident, hasIssueWeightsFeature, ...issueProps } = issueData;
const {
canCreateIncident,
hasIssueWeightsFeature,
hasIterationsFeature,
...issueProps
} = issueData;
return new Vue({
el,
@ -107,6 +112,7 @@ export function initIssueApp(issueData, store) {
registerPath,
signInPath,
hasIssueWeightsFeature,
hasIterationsFeature,
},
computed: {
...mapGetters(['getNoteableData']),
@ -119,6 +125,7 @@ export function initIssueApp(issueData, store) {
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
issueId: this.getNoteableData?.id,
issueIid: this.getNoteableData?.iid,
},
});
},

View File

@ -1,4 +1,6 @@
import { TITLE_LENGTH_MAX } from '~/issues/constants';
import { COLON, HYPHEN, NEWLINE } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
/**
* Returns the start and end `sourcepos` rows, converted to zero-based numbering.
@ -94,8 +96,10 @@ export const convertDescriptionWithNewSort = (description, list) => {
return descriptionLines.join(NEWLINE);
};
const bulletTaskListItemRegex = /^\s*[-*]\s+\[.]/;
const numericalTaskListItemRegex = /^\s*[0-9]\.\s+\[.]/;
const bulletTaskListItemRegex = /^\s*[-*]\s+\[.]\s+/;
const numericalTaskListItemRegex = /^\s*[0-9]\.\s+\[.]\s+/;
const codeMarkdownRegex = /^\s*`.*`\s*$/;
const imageOrLinkMarkdownRegex = /^\s*!?\[.*\)\s*$/;
/**
* Checks whether the line of markdown contains a task list item,
@ -139,12 +143,24 @@ const containsTaskListItem = (line) =>
*
* @param {String} description Description in markdown format
* @param {String} sourcepos Source position in format `23:3-23:14`
* @returns {String} Markdown with the deleted task list item
* @returns {{newDescription: String, taskDescription: String, taskTitle: String}} Object with:
*
* - `newDescription` property that contains markdown with the deleted task list item omitted
* - `taskDescription` property that contains the description of the deleted task list item
* - `taskTitle` property that contains the title of the deleted task list item
*/
export const convertDescriptionWithDeletedTaskListItem = (description, sourcepos) => {
export const deleteTaskListItem = (description, sourcepos) => {
const descriptionLines = description.split(NEWLINE);
const [startIndex, endIndex] = getSourceposRows(sourcepos);
const firstLine = descriptionLines[startIndex];
const firstLineIndentation = firstLine.length - firstLine.trimStart().length;
const taskTitle = firstLine
.replace(bulletTaskListItemRegex, '')
.replace(numericalTaskListItemRegex, '');
const taskDescription = [];
let indentation = 0;
let linesToDelete = 1;
let reduceIndentation = false;
@ -154,17 +170,61 @@ export const convertDescriptionWithDeletedTaskListItem = (description, sourcepos
descriptionLines[i] = descriptionLines[i].slice(indentation);
} else if (containsTaskListItem(descriptionLines[i])) {
reduceIndentation = true;
const firstLine = descriptionLines[startIndex];
const currentLine = descriptionLines[i];
const firstLineIndentation = firstLine.length - firstLine.trimStart().length;
const currentLineIndentation = currentLine.length - currentLine.trimStart().length;
indentation = currentLineIndentation - firstLineIndentation;
descriptionLines[i] = descriptionLines[i].slice(indentation);
} else {
taskDescription.push(descriptionLines[i].trimStart());
linesToDelete += 1;
}
}
descriptionLines.splice(startIndex, linesToDelete);
return descriptionLines.join(NEWLINE);
return {
newDescription: descriptionLines.join(NEWLINE),
taskDescription: taskDescription.join(NEWLINE) || undefined,
taskTitle,
};
};
/**
* Given a title and description for a task:
*
* - Moves characters beyond the 255 character limit from the title to the description
* - Moves a pure markdown title to the description and gives the title the value `Untitled`
*
* @param {String} taskTitle The task title
* @param {String} taskDescription The task description
* @returns {{description: String, title: String}} An object with the formatted task title and description
*/
export const extractTaskTitleAndDescription = (taskTitle, taskDescription) => {
const isTitleOnlyMarkdown =
codeMarkdownRegex.test(taskTitle) || imageOrLinkMarkdownRegex.test(taskTitle);
if (isTitleOnlyMarkdown) {
return {
title: __('Untitled'),
description: taskDescription
? taskTitle.concat(NEWLINE, NEWLINE, taskDescription)
: taskTitle,
};
}
const isTitleTooLong = taskTitle.length > TITLE_LENGTH_MAX;
if (isTitleTooLong) {
return {
title: taskTitle.slice(0, TITLE_LENGTH_MAX),
description: taskDescription
? taskTitle.slice(TITLE_LENGTH_MAX).concat(NEWLINE, NEWLINE, taskDescription)
: taskTitle.slice(TITLE_LENGTH_MAX),
};
}
return {
title: taskTitle,
description: taskDescription,
};
};

View File

@ -23,6 +23,7 @@ module Integrations
].freeze
SECRET_MASK = '************'
CHANNEL_LIMIT_PER_EVENT = 10
attribute :category, default: 'chat'
@ -37,7 +38,8 @@ module Integrations
presence: true,
public_url: true,
if: -> (integration) { integration.activated? && integration.requires_webhook? }
validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true
validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true, if: :activated?
validate :validate_channel_limit, if: :activated?
def initialize_properties
super
@ -300,7 +302,7 @@ module Integrations
channel_names = event_channel_value(event).presence || channel.presence
return [] unless channel_names
channel_names.split(',').map(&:strip)
channel_names.split(',').map(&:strip).uniq
end
def unique_channels
@ -308,6 +310,21 @@ module Integrations
channels_for_event(event)
end.uniq
end
def validate_channel_limit
supported_events.each do |event|
count = channels_for_event(event).count
next unless count > CHANNEL_LIMIT_PER_EVENT
errors.add(
event_channel_name(event).to_sym,
format(
s_('SlackIntegration|cannot have more than %{limit} channels'),
limit: CHANNEL_LIMIT_PER_EVENT
)
)
end
end
end
end

View File

@ -36,14 +36,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
condition(:request_access_enabled) { @subject.request_access_enabled }
condition(:create_projects_disabled, scope: :subject) do
next true if @user.nil?
visibility_evaluation_result = Gitlab::VisibilityLevelChecker
.new(user, Project.new(namespace_id: @subject.id))
.level_restricted?
@subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS ||
visibility_evaluation_result.restricted?
@subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS
end
condition(:developer_maintainer_access, scope: :subject) do

View File

@ -21,4 +21,11 @@
.form-group
= f.gitlab_ui_checkbox_component :user_deactivation_emails_enabled, _('Enable user deactivation emails'), help_text: _('Send emails to users upon account deactivation.')
- if Feature.enabled?(:deactivation_email_additional_text)
.form-group
= f.label :deactivation_email_additional_text, _('Additional text for deactivation email')
= f.text_area :deactivation_email_additional_text, class: 'form-control gl-form-input', rows: 4
.form-text.text-muted
= _('Text added to the body of user deactivation email messages. 1000 character limit.')
= f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' }

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class AddScanResultPolicyIdToApprovalRules < Gitlab::Database::Migration[2.1]
enable_lock_retries!
def change
add_column :approval_project_rules, :scan_result_policy_id, :bigint
add_column :approval_merge_request_rules, :scan_result_policy_id, :bigint
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddMatchOnInclusionToScanResultPolicy < Gitlab::Database::Migration[2.1]
enable_lock_retries!
def change
add_column :scan_result_policies, :match_on_inclusion, :boolean
end
end

View File

@ -14,32 +14,11 @@ class ScheduleVulnerabilitiesFeedbackMigration3 < Gitlab::Database::Migration[2.
restrict_gitlab_migration gitlab_schema: :gitlab_main
def up
# Delete the previous jobs
delete_batched_background_migration(
MIGRATION,
TABLE_NAME,
BATCH_COLUMN,
[]
)
# Reschedule the migration
queue_batched_background_migration(
MIGRATION,
TABLE_NAME,
BATCH_COLUMN,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
max_batch_size: MAX_BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
# replaced by db/post_migrate/20230209171547_schedule_vulnerabilities_feedback_migration4.rb
# no-op
end
def down
delete_batched_background_migration(
MIGRATION,
TABLE_NAME,
BATCH_COLUMN,
[]
)
# no-op
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class AddUniqueSoftwareLicensePoliciesIndexOnProjectAndScanResultPolicy < Gitlab::Database::Migration[2.1]
INDEX_NAME = 'idx_software_license_policies_unique_on_project_and_scan_policy'
disable_ddl_transaction!
def up
add_concurrent_index :software_license_policies,
[:project_id, :software_license_id, :scan_result_policy_id],
unique: true,
name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :software_license_policies, INDEX_NAME
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class AddFkToApprovalRulesOnScanResultPolicyId < Gitlab::Database::Migration[2.1]
disable_ddl_transaction!
def up
add_concurrent_foreign_key :approval_project_rules,
:scan_result_policies,
column: :scan_result_policy_id,
on_delete: :cascade,
reverse_lock_order: true
add_concurrent_foreign_key :approval_merge_request_rules,
:scan_result_policies,
column: :scan_result_policy_id,
on_delete: :cascade,
reverse_lock_order: true
end
def down
remove_foreign_key_if_exists :approval_project_rules, column: :scan_result_policy_id
remove_foreign_key_if_exists :approval_merge_request_rules, column: :scan_result_policy_id
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class RemoveUniqueSoftwareLicensePoliciesIndexOnProject < Gitlab::Database::Migration[2.1]
INDEX_NAME = 'index_software_license_policies_unique_per_project'
disable_ddl_transaction!
def up
remove_concurrent_index_by_name :software_license_policies, INDEX_NAME
end
def down
add_concurrent_index :software_license_policies, [:project_id, :software_license_id], unique: true, name: INDEX_NAME
end
end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
class ScheduleVulnerabilitiesFeedbackMigration4 < Gitlab::Database::Migration[2.1]
MIGRATION = 'MigrateVulnerabilitiesFeedbackToVulnerabilitiesStateTransition'
TABLE_NAME = :vulnerability_feedback
BATCH_COLUMN = :id
JOB_INTERVAL = 2.minutes
BATCH_SIZE = 250
SUB_BATCH_SIZE = 5
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main
def up
# Delete the previous jobs
delete_batched_background_migration(
MIGRATION,
TABLE_NAME,
BATCH_COLUMN,
[]
)
# Reschedule the migration
queue_batched_background_migration(
MIGRATION,
TABLE_NAME,
BATCH_COLUMN,
job_interval: JOB_INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
delete_batched_background_migration(
MIGRATION,
TABLE_NAME,
BATCH_COLUMN,
[]
)
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class PrepareIndexApprovalRulesOnScanResultPolicyId < Gitlab::Database::Migration[2.1]
PROJECT_INDEX_NAME = 'idx_approval_project_rules_on_scan_result_policy_id'
MERGE_REQUEST_INDEX_NAME = 'idx_approval_merge_request_rules_on_scan_result_policy_id'
# TODO: Index to be created synchronously in https://gitlab.com/gitlab-org/gitlab/-/issues/391312
def up
prepare_async_index :approval_project_rules, :scan_result_policy_id, name: PROJECT_INDEX_NAME
prepare_async_index :approval_merge_request_rules, :scan_result_policy_id, name: MERGE_REQUEST_INDEX_NAME
end
def down
unprepare_async_index :approval_project_rules, :scan_result_policy_id, name: PROJECT_INDEX_NAME
unprepare_async_index :approval_merge_request_rules, :scan_result_policy_id, name: MERGE_REQUEST_INDEX_NAME
end
end

View File

@ -0,0 +1 @@
b446c818b57801c3afa26fd4e2c633f04b7956d80f709947cc1be9f87a520fc2

View File

@ -0,0 +1 @@
779501ae368409cfe42bf03151309a07f043834c37d742dc52a062727a9cb9de

View File

@ -0,0 +1 @@
f56cd57c85a852f129099357ae72e94cbed7bc08c3099273842708dc40bc4411

View File

@ -0,0 +1 @@
bcfb07f384564295b4fc359ced37d5fdcde5689a589ab32953fb1d276de692e8

View File

@ -0,0 +1 @@
daaba8ca5c6b9e5eb4ca06d4194208452cb1cf91da8abd80ea228b3887a30c0c

View File

@ -0,0 +1 @@
ec7d8a7d00e5c6a80efa6c859df8de31e8615df4ba586d6b014fee60e0da6644

View File

@ -0,0 +1 @@
00998ed2ff2e1300d4af7f2b1f3817aad6cc3dcec37887704ebc0571963c461d

View File

@ -11840,6 +11840,7 @@ CREATE TABLE approval_merge_request_rules (
severity_levels text[] DEFAULT '{}'::text[] NOT NULL,
vulnerability_states text[] DEFAULT '{newly_detected}'::text[] NOT NULL,
security_orchestration_policy_configuration_id bigint,
scan_result_policy_id bigint,
CONSTRAINT check_6fca5928b2 CHECK ((char_length(section) <= 255))
);
@ -11912,7 +11913,8 @@ CREATE TABLE approval_project_rules (
vulnerability_states text[] DEFAULT '{newly_detected}'::text[] NOT NULL,
orchestration_policy_idx smallint,
applies_to_all_protected_branches boolean DEFAULT false NOT NULL,
security_orchestration_policy_configuration_id bigint
security_orchestration_policy_configuration_id bigint,
scan_result_policy_id bigint
);
CREATE TABLE approval_project_rules_groups (
@ -21726,7 +21728,8 @@ CREATE TABLE scan_result_policies (
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
orchestration_policy_idx smallint NOT NULL,
license_states text[] DEFAULT '{}'::text[]
license_states text[] DEFAULT '{}'::text[],
match_on_inclusion boolean
);
CREATE SEQUENCE scan_result_policies_id_seq
@ -28806,6 +28809,8 @@ CREATE INDEX idx_security_scans_on_scan_type ON security_scans USING btree (scan
CREATE UNIQUE INDEX idx_serverless_domain_cluster_on_clusters_applications_knative ON serverless_domain_cluster USING btree (clusters_applications_knative_id);
CREATE UNIQUE INDEX idx_software_license_policies_unique_on_project_and_scan_policy ON software_license_policies USING btree (project_id, software_license_id, scan_result_policy_id);
CREATE INDEX idx_streaming_headers_on_external_audit_event_destination_id ON audit_events_streaming_headers USING btree (external_audit_event_destination_id);
CREATE INDEX idx_test_reports_on_issue_id_created_at_and_id ON requirements_management_test_reports USING btree (issue_id, created_at, id);
@ -31578,8 +31583,6 @@ CREATE INDEX index_software_license_policies_on_scan_result_policy_id ON softwar
CREATE INDEX index_software_license_policies_on_software_license_id ON software_license_policies USING btree (software_license_id);
CREATE UNIQUE INDEX index_software_license_policies_unique_per_project ON software_license_policies USING btree (project_id, software_license_id);
CREATE INDEX index_software_licenses_on_spdx_identifier ON software_licenses USING btree (spdx_identifier);
CREATE UNIQUE INDEX index_software_licenses_on_unique_name ON software_licenses USING btree (name);
@ -34494,6 +34497,9 @@ ALTER TABLE ONLY protected_branches
ALTER TABLE ONLY issues
ADD CONSTRAINT fk_df75a7c8b8 FOREIGN KEY (promoted_to_epic_id) REFERENCES epics(id) ON DELETE SET NULL;
ALTER TABLE ONLY approval_project_rules
ADD CONSTRAINT fk_e1372c912e FOREIGN KEY (scan_result_policy_id) REFERENCES scan_result_policies(id) ON DELETE CASCADE;
ALTER TABLE ONLY ci_resources
ADD CONSTRAINT fk_e169a8e3d5_p FOREIGN KEY (partition_id, build_id) REFERENCES ci_builds(partition_id, id) ON UPDATE CASCADE ON DELETE SET NULL;
@ -34578,6 +34584,9 @@ ALTER TABLE ONLY boards_epic_list_user_preferences
ALTER TABLE ONLY user_project_callouts
ADD CONSTRAINT fk_f62dd11a33 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY approval_merge_request_rules
ADD CONSTRAINT fk_f726c79756 FOREIGN KEY (scan_result_policy_id) REFERENCES scan_result_policies(id) ON DELETE CASCADE;
ALTER TABLE ONLY cluster_agents
ADD CONSTRAINT fk_f7d43dee13 FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL;

View File

@ -758,26 +758,25 @@ gantt
section Phase 0
Build data partitioning strategy :done, 0_1, 2022-06-01, 90d
section Phase 1
Partition biggest CI tables :1_1, after 0_1, 140d
Biggest table partitioned :milestone, metadata, 2022-12-01, 1min
Partition biggest CI tables :1_1, after 0_1, 200d
Biggest table partitioned :milestone, metadata, 2023-03-01, 1min
Tables larger than 100GB partitioned :milestone, 100gb, after 1_1, 1min
section Phase 2
Add paritioning keys to SQL queries :2_1, after 1_1, 120d
Add paritioning keys to SQL queries :2_1, 2023-01-01, 120d
Emergency partition detachment possible :milestone, detachment, 2023-04-01, 1min
All SQL queries are routed to partitions :milestone, routing, after 2_1, 1min
section Phase 3
Build new data access patterns :3_1, 2023-03-01, 120d
New API endpoint created for inactive data :milestone, api1, 2023-05-01, 1min
Filtering added to existing API endpoints :milestone, api2, 2023-07-01, 1min
Build new data access patterns :3_1, 2023-05-01, 120d
New API endpoint created for inactive data :milestone, api1, 2023-07-01, 1min
Filtering added to existing API endpoints :milestone, api2, 2023-09-01, 1min
section Phase 4
Introduce time-decay mechanisms :4_1, 2023-06-01, 120d
Inactive partitions are not being read :milestone, part1, 2023-08-01, 1min
Performance of the database cluster improves :milestone, part2, 2023-09-01, 1min
Introduce time-decay mechanisms :4_1, 2023-08-01, 120d
Inactive partitions are not being read :milestone, part1, 2023-10-01, 1min
Performance of the database cluster improves :milestone, part2, 2023-11-01, 1min
section Phase 5
Introduce auto-partitioning mechanisms :5_1, 2023-07-01, 120d
New partitions are being created automatically :milestone, part3, 2023-10-01, 1min
Partitioning is made available on self-managed :milestone, part4, 2023-11-01, 1min
```
Introduce auto-partitioning mechanisms :5_1, 2023-09-01, 120d
New partitions are being created automatically :milestone, part3, 2023-12-01, 1min
Partitioning is made available on self-managed :milestone, part4, 2024-01-01, 1min
## Conclusions

View File

@ -46,11 +46,38 @@ runner in supported environments using the existing `gitlab-runner register` com
The remaining concerns become non-issues due to the elimination of the registration token.
### Comparison of current and new runner registration flow
```mermaid
graph TD
subgraph new[<b>New registration flow</b>]
A[<b>GitLab</b>: User creates a runner in GitLab UI and adds the runner configuration] -->|<b>GitLab</b>: creates ci_runners record and returns<br/>new 'glrt-' prefixed authentication token| B
B(<b>Runner</b>: User runs 'gitlab-runner register' command with</br>authentication token to register new runner machine with<br/>the GitLab instance) --> C{<b>Runner</b>: Does a .runner_system_id file exist in<br/>the gitlab-runner configuration directory?}
C -->|Yes| D[<b>Runner</b>: Reads existing system ID] --> F
C -->|No| E[<b>Runner</b>: Generates and persists unique system ID] --> F
F[<b>Runner</b>: Issues 'POST /runner/verify' request<br/>to verify authentication token validity] --> G{<b>GitLab</b>: Is the authentication token valid?}
G -->|Yes| H[<b>GitLab</b>: Creates ci_runner_machine database record if missing] --> J[<b>Runner</b>: Store authentication token in .config.toml]
G -->|No| I(<b>GitLab</b>: Returns '403 Forbidden' error) --> K(gitlab-runner register command fails)
J --> Z(Runner and runner machine are ready for use)
end
subgraph current[<b>Current registration flow</b>]
A'[<b>GitLab</b>: User retrieves runner registration token in GitLab UI] --> B'
B'[<b>Runner</b>: User runs 'gitlab-runner register' command<br/>with registration token to register new runner] -->|<b>Runner</b>: Issues 'POST /runner request' to create<br/>new runner and obtain authentication token| C'{<b>GitLab</b>: Is the registration token valid?}
C' -->|Yes| D'[<b>GitLab</b>: Create ci_runners database record] --> F'
C' -->|No| E'(<b>GitLab</b>: Return '403 Forbidden' error) --> K'(gitlab-runner register command fails)
F'[<b>Runner</b>: Store authentication token<br/>from response in .config.toml] --> Z'(Runner is ready for use)
end
style new fill:#f2ffe6
```
### Using the authentication token in place of the registration token
<!-- vale gitlab.Spelling = NO -->
In this proposal, runners created in the GitLab UI are assigned authentication tokens prefixed with
`glrt-` (**G**it**L**ab **R**unner **T**oken).
In this proposal, runners created in the GitLab UI are assigned
[authentication tokens](../../../security/token_overview.md#runner-authentication-tokens-also-called-runner-tokens)
prefixed with `glrt-` (**G**it**L**ab **R**unner **T**oken).
<!-- vale gitlab.Spelling = YES -->
The prefix allows the existing `register` command to use the authentication token _in lieu_
of the current registration token (`--registration-token`), requiring minimal adjustments in
@ -68,8 +95,8 @@ token in the `--registration-token` argument:
| Token type | Behavior |
| ---------- | -------- |
| Registration token | Leverages the `POST /api/v4/runners` REST endpoint to create a new runner, creating a new entry in `config.toml`. |
| Authentication token | Leverages the `POST /api/v4/runners/verify` REST endpoint to ensure the validity of the authentication token. Creates an entry in `config.toml` file and a `system_id` value in a sidecar file if missing (`.runner_system_id`). |
| [Registration token](../../../security/token_overview.md#runner-authentication-tokens-also-called-runner-tokens) | Leverages the `POST /api/v4/runners` REST endpoint to create a new runner, creating a new entry in `config.toml`. |
| [Authentication token](../../../security/token_overview.md#runner-authentication-tokens-also-called-runner-tokens) | Leverages the `POST /api/v4/runners/verify` REST endpoint to ensure the validity of the authentication token. Creates an entry in `config.toml` file and a `system_id` value in a sidecar file if missing (`.runner_system_id`). |
### Transition period

View File

@ -36,6 +36,8 @@ Display name override is not enabled by default, you need to ask your administra
## Configure GitLab to send notifications to Mattermost
> [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106760) in GitLab 15.9 to limit Mattermost channels to 10 per event.
After the Mattermost instance has an incoming webhook set up, you can set up GitLab
to send the notifications:

View File

@ -28,6 +28,8 @@ to control GitLab from Slack. Slash commands are configured separately.
## Configure GitLab
> [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106760) in GitLab 15.9 to limit Slack channels to 10 per event.
1. On the top bar, select **Main menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Integrations**.
1. Select **Slack notifications**.

View File

@ -22,6 +22,22 @@ To edit an issue:
1. Edit the available fields.
1. Select **Save changes**.
### Remove a task list item
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/377307) in GitLab 15.9 [with a flag](../../../administration/feature_flags.md) named `work_items_mvc_2`. Disabled by default.
Prerequisites:
- You must have at least the Reporter role for the project, or be the author or assignee of the issue.
In an issue description with task list items:
1. Hover over a task list item and select the options menu (**{ellipsis_v}**).
1. Select **Delete**.
The task list item is removed from the issue description.
Any nested task list items are moved up a nested level.
## Bulk edit issues from a project
> - Assigning epic [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210470) in GitLab 13.2.

View File

@ -58,6 +58,22 @@ To create a task:
1. Enter the task title.
1. Select **Create task**.
### Create a task from a task list item
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/377307) in GitLab 15.9 [with a flag](../administration/feature_flags.md) named `work_items_mvc_2`. Disabled by default.
Prerequisites:
- You must have at least the Reporter role for the project.
In an issue description with task list items:
1. Hover over a task list item and select the options menu (**{ellipsis_v}**).
1. Select **Convert to task**.
The task list item is removed from the issue description and a task is created in the tasks widget from its contents.
Any nested task list items are moved up a nested level.
## Add existing tasks to an issue
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/381868) in GitLab 15.6.

View File

@ -6,7 +6,7 @@ module Gitlab
module Bridge
module Common
def label
subject.description
subject.description.presence || super
end
def has_details?

View File

@ -2515,6 +2515,9 @@ msgstr ""
msgid "Additional text"
msgstr ""
msgid "Additional text for deactivation email"
msgstr ""
msgid "Additional text for the sign-in and Help page."
msgstr ""
@ -40071,6 +40074,9 @@ msgstr ""
msgid "SlackIntegration|You may need to reinstall the GitLab for Slack app when we %{linkStart}make updates or change permissions%{linkEnd}."
msgstr ""
msgid "SlackIntegration|cannot have more than %{limit} channels"
msgstr ""
msgid "SlackModal|Are you sure you want to change the project?"
msgstr ""
@ -42439,6 +42445,9 @@ msgstr ""
msgid "Text added to the body of all email messages. %{character_limit} character limit"
msgstr ""
msgid "Text added to the body of user deactivation email messages. 1000 character limit."
msgstr ""
msgid "Text style"
msgstr ""
@ -45545,6 +45554,9 @@ msgstr ""
msgid "Unsupported todo type passed. Supported todo types are: %{todo_types}"
msgstr ""
msgid "Untitled"
msgstr ""
msgid "Unused"
msgstr ""
@ -48298,6 +48310,12 @@ msgstr ""
msgid "WorkItem|Closed"
msgstr ""
msgid "WorkItem|Convert to task"
msgstr ""
msgid "WorkItem|Converted to task"
msgstr ""
msgid "WorkItem|Create %{workItemType}"
msgstr ""
@ -48445,6 +48463,9 @@ msgstr ""
msgid "WorkItem|Task deleted"
msgstr ""
msgid "WorkItem|Task reverted"
msgstr ""
msgid "WorkItem|Tasks"
msgstr ""

View File

@ -57,9 +57,9 @@
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/fonts": "^1.2.0",
"@gitlab/svgs": "3.20.0",
"@gitlab/ui": "55.1.0",
"@gitlab/ui": "55.2.0",
"@gitlab/visual-review-tools": "1.7.3",
"@gitlab/web-ide": "0.0.1-dev-20230120231236",
"@gitlab/web-ide": "0.0.1-dev-20230210211358",
"@rails/actioncable": "6.1.4-7",
"@rails/ujs": "6.1.4-7",
"@sourcegraph/code-host-integration": "0.0.84",

View File

@ -10,7 +10,10 @@ RSpec.describe 'Database schema', feature_category: :database do
let(:columns_name_with_jsonb) { retrieve_columns_name_with_jsonb }
IGNORED_INDEXES_ON_FKS = {
slack_integrations_scopes: %w[slack_api_scope_id]
slack_integrations_scopes: %w[slack_api_scope_id],
# Will be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/391312
approval_project_rules: %w[scan_result_policy_id],
approval_merge_request_rules: %w[scan_result_policy_id]
}.with_indifferent_access.freeze
TABLE_PARTITIONS = %w[ci_builds_metadata].freeze

View File

@ -829,9 +829,36 @@ RSpec.describe 'Admin updates settings', feature_category: :not_owned do
context 'Preferences page' do
before do
stub_feature_flags(deactivation_email_additional_text: deactivation_email_additional_text_feature_flag)
visit preferences_admin_application_settings_path
end
let(:deactivation_email_additional_text_feature_flag) { true }
describe 'Email page' do
context 'when deactivation email additional text feature flag is enabled' do
it 'shows deactivation email additional text field' do
expect(page).to have_field 'Additional text for deactivation email'
page.within('.as-email') do
fill_in 'Additional text for deactivation email', with: 'So long and thanks for all the fish!'
click_button 'Save changes'
end
expect(page).to have_content 'Application settings saved successfully'
expect(current_settings.deactivation_email_additional_text).to eq('So long and thanks for all the fish!')
end
end
context 'when deactivation email additional text feature flag is disabled' do
let(:deactivation_email_additional_text_feature_flag) { false }
it 'does not show deactivation email additional text field' do
expect(page).not_to have_field 'Additional text for deactivation email'
end
end
end
it 'change Help page' do
new_support_url = 'http://example.com/help'
new_documentation_url = 'https://docs.gitlab.com'

View File

@ -510,33 +510,6 @@ RSpec.describe 'Group', feature_category: :subgroups do
end
end
end
context 'when in a private group' do
before do
group.update!(
visibility_level: Gitlab::VisibilityLevel::PRIVATE,
project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS
)
end
context 'when visibility levels have been restricted to private only by an administrator' do
before do
stub_application_setting(
restricted_visibility_levels: [
Gitlab::VisibilityLevel::PRIVATE
]
)
end
it 'does not display the "New project" button' do
visit group_path(group)
page.within '[data-testid="group-buttons"]' do
expect(page).not_to have_link('New project')
end
end
end
end
end
def remove_with_confirm(button_text, confirm_with)

View File

@ -433,7 +433,7 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
issue.save!
end
it 'shows milestone text' do
it 'shows milestone text', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/389287' do
sign_out(:user)
sign_in(guest)

View File

@ -39,7 +39,7 @@ describe('import actions cell', () => {
describe('when group is finished', () => {
beforeEach(() => {
createComponent({ isAvailableForImport: true, isFinished: true });
createComponent({ isAvailableForImport: false, isFinished: true });
});
it('renders re-import button', () => {

View File

@ -38,6 +38,7 @@ describe('import table', () => {
const FAKE_GROUPS = [
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ id: 2, status: STATUSES.FINISHED }),
generateFakeEntry({ id: 3, status: STATUSES.NONE }),
];
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
const FAKE_VERSION_VALIDATION = {
@ -65,8 +66,8 @@ describe('import table', () => {
const triggerSelectAllCheckbox = (checked = true) =>
wrapper.find('thead input[type=checkbox]').setChecked(checked);
const selectRow = (idx) =>
wrapper.findAll('tbody td input[type=checkbox]').at(idx).setChecked(true);
const findRowCheckbox = (idx) => wrapper.findAll('tbody td input[type=checkbox]').at(idx);
const selectRow = (idx) => findRowCheckbox(idx).setChecked(true);
const createComponent = ({
bulkImportSourceGroups,
@ -115,7 +116,7 @@ describe('import table', () => {
beforeEach(() => {
axiosMock = new MockAdapter(axios);
axiosMock.onGet(/.*\/exists$/, () => []).reply(HTTP_STATUS_OK);
axiosMock.onGet(/.*\/exists$/, () => []).reply(HTTP_STATUS_OK, { exists: false });
});
afterEach(() => {
@ -609,6 +610,40 @@ describe('import table', () => {
expect(tooltip.value).toBe('Path of the new group.');
});
describe('re-import', () => {
it('renders finished row as disabled by default', async () => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: [generateFakeEntry({ id: 5, status: STATUSES.FINISHED })],
pageInfo: FAKE_PAGE_INFO,
versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
expect(findRowCheckbox(0).attributes('disabled')).toBeDefined();
});
it('enables row after clicking re-import', async () => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: [generateFakeEntry({ id: 5, status: STATUSES.FINISHED })],
pageInfo: FAKE_PAGE_INFO,
versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
const reimportButton = wrapper
.findAll('tbody td button')
.wrappers.find((w) => w.text().includes('Re-import'));
await reimportButton.trigger('click');
expect(findRowCheckbox(0).attributes('disabled')).toBeUndefined();
});
});
describe('unavailable features warning', () => {
it('renders alert when there are unavailable features', async () => {
createComponent({

View File

@ -2,6 +2,7 @@ import $ from 'jquery';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlModal } from '@gitlab/ui';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import setWindowLocation from 'helpers/set_window_location_helper';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
@ -9,19 +10,22 @@ import { mockTracking } from 'helpers/tracking_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createAlert } from '~/flash';
import Description from '~/issues/show/components/description.vue';
import eventHub from '~/issues/show/event_hub';
import { updateHistory } from '~/lib/utils/url_utility';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import workItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
import TaskList from '~/task_list';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import {
createWorkItemMutationErrorResponse,
createWorkItemMutationResponse,
getIssueDetailsResponse,
projectWorkItemTypesQueryResponse,
createWorkItemFromTaskMutationResponse,
} from 'jest/work_items/mock_data';
import {
descriptionProps as initialProps,
@ -30,6 +34,7 @@ import {
descriptionHtmlWithTask,
} from '../mock_data/mock_data';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
updateHistory: jest.fn(),
@ -45,6 +50,7 @@ const $toast = {
show: jest.fn(),
};
const issueDetailsResponse = getIssueDetailsResponse();
const workItemQueryResponse = {
data: {
workItem: null,
@ -53,9 +59,6 @@ const workItemQueryResponse = {
const queryHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const workItemTypesQueryHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
const createWorkItemFromTaskSuccessHandler = jest
.fn()
.mockResolvedValue(createWorkItemFromTaskMutationResponse);
describe('Description component', () => {
let wrapper;
@ -74,23 +77,27 @@ describe('Description component', () => {
function createComponent({
props = {},
provide,
createWorkItemFromTaskHandler = createWorkItemFromTaskSuccessHandler,
issueDetailsQueryHandler = jest.fn().mockResolvedValue(issueDetailsResponse),
createWorkItemMutationHandler,
...options
} = {}) {
wrapper = shallowMountExtended(Description, {
propsData: {
issueId: 1,
issueIid: 1,
...initialProps,
...props,
},
provide: {
fullPath: 'gitlab-org/gitlab-test',
hasIterationsFeature: true,
...provide,
},
apolloProvider: createMockApollo([
[workItemQuery, queryHandler],
[workItemTypesQuery, workItemTypesQueryHandler],
[createWorkItemFromTaskMutation, createWorkItemFromTaskHandler],
[getIssueDetailsQuery, issueDetailsQueryHandler],
[createWorkItemMutation, createWorkItemMutationHandler],
]),
mocks: {
$toast,
@ -357,6 +364,100 @@ describe('Description component', () => {
});
describe('task list item actions', () => {
describe('converting the task list item to a task', () => {
describe('when successful', () => {
let createWorkItemMutationHandler;
beforeEach(async () => {
createWorkItemMutationHandler = jest
.fn()
.mockResolvedValue(createWorkItemMutationResponse);
const descriptionText = `Tasks
1. [ ] item 1
1. [ ] item 2
paragraph text
1. [ ] item 3
1. [ ] item 4;`;
createComponent({
props: { descriptionText },
provide: { glFeatures: { workItemsMvc2: true } },
createWorkItemMutationHandler,
});
await waitForPromises();
eventHub.$emit('convert-task-list-item', '4:4-8:19');
await waitForPromises();
});
it('emits an event to update the description with the deleted task list item omitted', () => {
const newDescriptionText = `Tasks
1. [ ] item 1
1. [ ] item 3
1. [ ] item 4;`;
expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]);
});
it('calls a mutation to create a task', () => {
const {
confidential,
iteration,
milestone,
} = issueDetailsResponse.data.workspace.issuable;
expect(createWorkItemMutationHandler).toHaveBeenCalledWith({
input: {
confidential,
description: '\nparagraph text\n',
hierarchyWidget: {
parentId: 'gid://gitlab/WorkItem/1',
},
iterationWidget: {
iterationId: IS_EE ? iteration.id : null,
},
milestoneWidget: {
milestoneId: milestone.id,
},
projectPath: 'gitlab-org/gitlab-test',
title: 'item 2',
workItemTypeId: 'gid://gitlab/WorkItems::Type/3',
},
});
});
it('shows a toast to confirm the creation of the task', () => {
expect($toast.show).toHaveBeenCalledWith('Converted to task', expect.any(Object));
});
});
describe('when unsuccessful', () => {
beforeEach(async () => {
createComponent({
props: { descriptionText: 'description' },
provide: { glFeatures: { workItemsMvc2: true } },
createWorkItemMutationHandler: jest
.fn()
.mockResolvedValue(createWorkItemMutationErrorResponse),
});
await waitForPromises();
eventHub.$emit('convert-task-list-item', '1:1-1:11');
await waitForPromises();
});
it('shows an alert with an error message', () => {
expect(createAlert).toHaveBeenCalledWith({
message: 'Something went wrong when creating task. Please try again.',
error: new Error('an error'),
captureError: true,
});
});
});
});
describe('deleting the task list item', () => {
it('emits an event to update the description with the deleted task list item', () => {
const descriptionText = `Tasks

View File

@ -7,7 +7,8 @@ describe('TaskListItemActions component', () => {
let wrapper;
const findGlDropdown = () => wrapper.findComponent(GlDropdown);
const findGlDropdownItem = () => wrapper.findComponent(GlDropdownItem);
const findConvertToTaskItem = () => wrapper.findAllComponents(GlDropdownItem).at(0);
const findDeleteItem = () => wrapper.findAllComponents(GlDropdownItem).at(1);
const mountComponent = () => {
const li = document.createElement('li');
@ -16,7 +17,7 @@ describe('TaskListItemActions component', () => {
document.body.appendChild(li);
wrapper = shallowMount(TaskListItemActions, {
provide: { toggleClass: 'task-list-item-actions' },
provide: { canUpdate: true, toggleClass: 'task-list-item-actions' },
attachTo: document.querySelector('div'),
});
};
@ -35,10 +36,18 @@ describe('TaskListItemActions component', () => {
});
});
it('emits event when `Convert to task` dropdown item is clicked', () => {
jest.spyOn(eventHub, '$emit');
findConvertToTaskItem().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('convert-task-list-item', '3:1-3:10');
});
it('emits event when `Delete` dropdown item is clicked', () => {
jest.spyOn(eventHub, '$emit');
findGlDropdownItem().vm.$emit('click');
findDeleteItem().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('delete-task-list-item', '3:1-3:10');
});

View File

@ -1,6 +1,7 @@
import {
convertDescriptionWithDeletedTaskListItem,
deleteTaskListItem,
convertDescriptionWithNewSort,
extractTaskTitleAndDescription,
} from '~/issues/show/utils';
describe('app/assets/javascripts/issues/show/utils.js', () => {
@ -141,7 +142,7 @@ describe('app/assets/javascripts/issues/show/utils.js', () => {
});
});
describe('convertDescriptionWithDeletedTaskListItem', () => {
describe('deleteTaskListItem', () => {
const description = `Tasks
1. [ ] item 1
@ -226,9 +227,10 @@ describe('app/assets/javascripts/issues/show/utils.js', () => {
1. [ ] item 9
1. [ ] item 10`;
expect(convertDescriptionWithDeletedTaskListItem(description, sourcepos)).toBe(
expect(deleteTaskListItem(description, sourcepos)).toEqual({
newDescription,
);
taskTitle: 'item 2',
});
});
it('deletes deeply nested item with no children', () => {
@ -254,9 +256,10 @@ describe('app/assets/javascripts/issues/show/utils.js', () => {
1. [ ] item 9
1. [ ] item 10`;
expect(convertDescriptionWithDeletedTaskListItem(description, sourcepos)).toBe(
expect(deleteTaskListItem(description, sourcepos)).toEqual({
newDescription,
);
taskTitle: 'item 4',
});
});
it('deletes item with children and moves sub-tasks up a level', () => {
@ -282,9 +285,10 @@ describe('app/assets/javascripts/issues/show/utils.js', () => {
1. [ ] item 9
1. [ ] item 10`;
expect(convertDescriptionWithDeletedTaskListItem(description, sourcepos)).toBe(
expect(deleteTaskListItem(description, sourcepos)).toEqual({
newDescription,
);
taskTitle: 'item 3',
});
});
it('deletes item with associated paragraph text', () => {
@ -306,10 +310,15 @@ describe('app/assets/javascripts/issues/show/utils.js', () => {
1. [ ] item 9
1. [ ] item 10`;
const taskDescription = `
paragraph text
`;
expect(convertDescriptionWithDeletedTaskListItem(description, sourcepos)).toBe(
expect(deleteTaskListItem(description, sourcepos)).toEqual({
newDescription,
);
taskDescription,
taskTitle: 'item 6',
});
});
it('deletes item with associated paragraph text and moves sub-tasks up a level', () => {
@ -331,10 +340,71 @@ describe('app/assets/javascripts/issues/show/utils.js', () => {
1. [ ] item 9
1. [ ] item 10`;
const taskDescription = `
paragraph text
`;
expect(convertDescriptionWithDeletedTaskListItem(description, sourcepos)).toBe(
expect(deleteTaskListItem(description, sourcepos)).toEqual({
newDescription,
);
taskDescription,
taskTitle: 'item 7',
});
});
});
describe('extractTaskTitleAndDescription', () => {
const description = `A multi-line
description`;
describe('when title is pure code block', () => {
const title = '`code block`';
it('moves the title to the description', () => {
expect(extractTaskTitleAndDescription(title)).toEqual({
title: 'Untitled',
description: title,
});
});
it('moves the title to the description and appends the description to it', () => {
expect(extractTaskTitleAndDescription(title, description)).toEqual({
title: 'Untitled',
description: `${title}\n\n${description}`,
});
});
});
describe('when title is too long', () => {
const title =
'Deleniti id facere numquam cum consectetur sint ipsum consequatur. Odit nihil harum consequuntur est nemo adipisci. Incidunt suscipit voluptatem et culpa at voluptatem consequuntur. Rerum aliquam earum quia consequatur ipsam quae ut. Quod molestias ducimus quia ratione nostrum ut adipisci.';
const expectedTitle =
'Deleniti id facere numquam cum consectetur sint ipsum consequatur. Odit nihil harum consequuntur est nemo adipisci. Incidunt suscipit voluptatem et culpa at voluptatem consequuntur. Rerum aliquam earum quia consequatur ipsam quae ut. Quod molestias ducimu';
it('moves the title beyond the character limit to the description', () => {
expect(extractTaskTitleAndDescription(title)).toEqual({
title: expectedTitle,
description: 's quia ratione nostrum ut adipisci.',
});
});
it('moves the title beyond the character limit to the description and appends the description to it', () => {
expect(extractTaskTitleAndDescription(title, description)).toEqual({
title: expectedTitle,
description: `s quia ratione nostrum ut adipisci.\n\n${description}`,
});
});
});
describe('when title is fine', () => {
const title = 'A fine title';
it('uses the title with no modifications', () => {
expect(extractTaskTitleAndDescription(title)).toEqual({ title });
});
it('uses the title and description with no modifications', () => {
expect(extractTaskTitleAndDescription(title, description)).toEqual({ title, description });
});
});
});
});

View File

@ -18,6 +18,7 @@ import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.
import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
getIssueDetailsResponse,
workItemHierarchyResponse,
workItemHierarchyEmptyResponse,
workItemHierarchyNoUpdatePermissionResponse,
@ -28,39 +29,6 @@ import {
Vue.use(VueApollo);
const issueDetailsResponse = (confidential = false) => ({
data: {
workspace: {
id: 'gid://gitlab/Project/1',
issuable: {
id: 'gid://gitlab/Issue/4',
confidential,
iteration: {
id: 'gid://gitlab/Iteration/1124',
title: null,
startDate: '2022-06-22',
dueDate: '2022-07-19',
webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/-/iterations/1124',
iterationCadence: {
id: 'gid://gitlab/Iterations::Cadence/1101',
title: 'Quod voluptates quidem ea eaque eligendi ex corporis.',
__typename: 'IterationCadence',
},
__typename: 'Iteration',
},
milestone: {
dueDate: null,
expired: false,
id: 'gid://gitlab/Milestone/28',
title: 'v2.0',
__typename: 'Milestone',
},
__typename: 'Issue',
},
__typename: 'Project',
},
},
});
const showModal = jest.fn();
describe('WorkItemLinks', () => {
@ -84,7 +52,7 @@ describe('WorkItemLinks', () => {
data = {},
fetchHandler = jest.fn().mockResolvedValue(workItemHierarchyResponse),
mutationHandler = mutationChangeParentHandler,
issueDetailsQueryHandler = jest.fn().mockResolvedValue(issueDetailsResponse()),
issueDetailsQueryHandler = jest.fn().mockResolvedValue(getIssueDetailsResponse()),
hasIterationsFeature = false,
fetchByIid = false,
} = {}) => {
@ -295,7 +263,9 @@ describe('WorkItemLinks', () => {
describe('when parent item is confidential', () => {
it('passes correct confidentiality status to form', async () => {
await createComponent({
issueDetailsQueryHandler: jest.fn().mockResolvedValue(issueDetailsResponse(true)),
issueDetailsQueryHandler: jest
.fn()
.mockResolvedValue(getIssueDetailsResponse({ confidential: true })),
});
findToggleFormDropdown().vm.$emit('click');
findToggleAddFormButton().vm.$emit('click');

View File

@ -471,6 +471,28 @@ export const workItemResponseFactory = ({
},
});
export const getIssueDetailsResponse = ({ confidential = false } = {}) => ({
data: {
workspace: {
id: 'gid://gitlab/Project/1',
issuable: {
id: 'gid://gitlab/Issue/4',
confidential,
iteration: {
id: 'gid://gitlab/Iteration/1124',
__typename: 'Iteration',
},
milestone: {
id: 'gid://gitlab/Milestone/28',
__typename: 'Milestone',
},
__typename: 'Issue',
},
__typename: 'Project',
},
},
});
export const projectWorkItemTypesQueryResponse = {
data: {
workspace: {
@ -528,6 +550,16 @@ export const createWorkItemMutationResponse = {
},
};
export const createWorkItemMutationErrorResponse = {
data: {
workItemCreate: {
__typename: 'WorkItemCreatePayload',
workItem: null,
errors: ['an error'],
},
},
};
export const createWorkItemFromTaskMutationResponse = {
data: {
workItemCreateFromTask: {

View File

@ -73,8 +73,9 @@ RSpec.describe Gitlab::BareRepositoryImport::Importer do
end
it 'does not schedule an import' do
project = Project.find_by_full_path(project_path)
expect(project).not_to receive(:import_schedule)
expect_next_instance_of(Project) do |instance|
expect(instance).not_to receive(:import_schedule)
end
importer.create_project_if_needed
end

View File

@ -27,8 +27,8 @@ RSpec.describe Gitlab::BitbucketImport::ProjectCreator do
end
it 'creates project' do
allow_next_instances_of(Project, 2) do |project|
allow(project).to receive(:add_import_job)
expect_next_instance_of(Project) do |project|
expect(project).to receive(:add_import_job)
end
project_creator = described_class.new(repo, 'vim', namespace, user, access_params)

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Status::Bridge::Common do
RSpec.describe Gitlab::Ci::Status::Bridge::Common, feature_category: :continuous_integration do
let_it_be(:user) { create(:user) }
let_it_be(:bridge) { create(:ci_bridge) }
let_it_be(:downstream_pipeline) { create(:ci_pipeline) }
@ -37,4 +37,35 @@ RSpec.describe Gitlab::Ci::Status::Bridge::Common do
it { expect(subject.details_path).to be_nil }
end
end
describe '#label' do
let(:description) { 'my description' }
let(:bridge) { create(:ci_bridge, description: description) }
subject do
Gitlab::Ci::Status::Created
.new(bridge, user)
.extend(described_class)
end
it 'returns description' do
expect(subject.label).to eq description
end
context 'when description is nil' do
let(:description) { nil }
it 'returns core status label' do
expect(subject.label).to eq('created')
end
end
context 'when description is empty string' do
let(:description) { '' }
it 'returns core status label' do
expect(subject.label).to eq('created')
end
end
end
end

View File

@ -25,7 +25,7 @@ RSpec.describe Gitlab::Ci::Status::Bridge::Factory, feature_category: :continuou
expect(status.text).to eq s_('CiStatusText|created')
expect(status.icon).to eq 'status_created'
expect(status.favicon).to eq 'favicon_status_created'
expect(status.label).to be_nil
expect(status.label).to eq 'created'
expect(status).not_to have_details
expect(status).not_to have_action
end
@ -52,7 +52,7 @@ RSpec.describe Gitlab::Ci::Status::Bridge::Factory, feature_category: :continuou
expect(status.text).to eq s_('CiStatusText|failed')
expect(status.icon).to eq 'status_failed'
expect(status.favicon).to eq 'favicon_status_failed'
expect(status.label).to be_nil
expect(status.label).to eq 'failed'
expect(status.status_tooltip).to eq "#{s_('CiStatusText|failed')} - (unknown failure)"
expect(status).not_to have_details
expect(status).to have_action

View File

@ -24,8 +24,8 @@ RSpec.describe Gitlab::GitlabImport::ProjectCreator do
end
it 'creates project' do
allow_next_instance_of(Project) do |project|
allow(project).to receive(:add_import_job)
expect_next_instance_of(Project) do |project|
expect(project).to receive(:add_import_job)
end
project_creator = described_class.new(repo, namespace, user, access_params)

View File

@ -20,7 +20,7 @@ RSpec.describe Gitlab::LegacyGithubImport::ProjectCreator do
before do
namespace.add_owner(user)
allow_next_instances_of(Project, 2) do |project|
expect_next_instance_of(Project) do |project|
allow(project).to receive(:add_import_job)
end
end

View File

@ -3,7 +3,7 @@
require 'spec_helper'
require_migration!
RSpec.describe ScheduleVulnerabilitiesFeedbackMigration3, feature_category: :vulnerability_management do
RSpec.describe ScheduleVulnerabilitiesFeedbackMigration4, feature_category: :vulnerability_management do
let(:migration) { described_class::MIGRATION }
describe '#up' do
@ -13,9 +13,9 @@ RSpec.describe ScheduleVulnerabilitiesFeedbackMigration3, feature_category: :vul
expect(migration).to have_scheduled_batched_migration(
table_name: :vulnerability_feedback,
column_name: :id,
interval: described_class::DELAY_INTERVAL,
interval: described_class::JOB_INTERVAL,
batch_size: described_class::BATCH_SIZE,
max_batch_size: described_class::MAX_BATCH_SIZE
sub_batch_size: described_class::SUB_BATCH_SIZE
)
end
end

View File

@ -9,13 +9,33 @@ RSpec.describe Integrations::BaseChatNotification, feature_category: :integratio
describe 'validations' do
before do
allow(subject).to receive(:activated?).and_return(true)
subject.active = active
allow(subject).to receive(:default_channel_placeholder).and_return('placeholder')
allow(subject).to receive(:webhook_help).and_return('help')
end
it { is_expected.to validate_presence_of :webhook }
it { is_expected.to validate_inclusion_of(:labels_to_be_notified_behavior).in_array(%w[match_any match_all]).allow_blank }
def build_channel_list(count)
(1..count).map { |i| "##{i}" }.join(',')
end
context 'when active' do
let(:active) { true }
it { is_expected.to validate_presence_of :webhook }
it { is_expected.to validate_inclusion_of(:labels_to_be_notified_behavior).in_array(%w[match_any match_all]).allow_blank }
it { is_expected.to allow_value(build_channel_list(10)).for(:push_channel) }
it { is_expected.not_to allow_value(build_channel_list(11)).for(:push_channel) }
end
context 'when inactive' do
let(:active) { false }
it { is_expected.not_to validate_presence_of :webhook }
it { is_expected.not_to validate_inclusion_of(:labels_to_be_notified_behavior).in_array(%w[match_any match_all]).allow_blank }
it { is_expected.to allow_value(build_channel_list(10)).for(:push_channel) }
it { is_expected.to allow_value(build_channel_list(11)).for(:push_channel) }
end
end
describe '#execute' do
@ -309,6 +329,10 @@ RSpec.describe Integrations::BaseChatNotification, feature_category: :integratio
context 'with multiple channel names with spaces specified' do
it_behaves_like 'with channel specified', 'slack-integration, #slack-test, @UDLP91W0A', ['slack-integration', '#slack-test', '@UDLP91W0A']
end
context 'with duplicate channel names' do
it_behaves_like 'with channel specified', '#slack-test,#slack-test,#slack-test-2', ['#slack-test', '#slack-test-2']
end
end
describe '#default_channel_placeholder' do

View File

@ -667,42 +667,6 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization
it { is_expected.to be_allowed(:create_projects) }
end
context 'when there are no available visibility levels because they have been restricted by an administrator' do
before do
stub_application_setting(
restricted_visibility_levels: [
Gitlab::VisibilityLevel::PUBLIC,
Gitlab::VisibilityLevel::INTERNAL,
Gitlab::VisibilityLevel::PRIVATE
]
)
end
context 'reporter' do
let(:current_user) { reporter }
it { is_expected.to be_disallowed(:create_projects) }
end
context 'developer' do
let(:current_user) { developer }
it { is_expected.to be_disallowed(:create_projects) }
end
context 'maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_disallowed(:create_projects) }
end
context 'owner' do
let(:current_user) { owner }
it { is_expected.to be_disallowed(:create_projects) }
end
end
end
end

View File

@ -1347,10 +1347,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.20.0.tgz#4ee4f2f24304d13ccce58f82c2ecd87e556f35b4"
integrity sha512-nYTF4j5kon4XbBr/sAzuubgxjIne9+RTZLmSrSaL9FL4eyuv9aa7YMCcOrlIbYX5jlSYlcD+ck2F2M1sqXXOBA==
"@gitlab/ui@55.1.0":
version "55.1.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-55.1.0.tgz#a2ea6951365c495df7acdd1351b1660771607b67"
integrity sha512-0E+l76jNsK3BPqQmbuTKAvC4RfjQfpaLvmlShe8wxrnMrS0IKsse43RST0ttV+mhkOVfac0me8pDhN4ijSm7Tw==
"@gitlab/ui@55.2.0":
version "55.2.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-55.2.0.tgz#0cd24310a4ebbd08c1fcf4281b8cc60709fde0da"
integrity sha512-iEbW3OvgyLcT7c0Sd2LcB3eo4kuxIRoqkM4xCZwgIxVLOeGsQfbaBHMCSG9Ekt/OYoesq7B7pX6AqrV9UBuwKw==
dependencies:
"@popperjs/core" "^2.11.2"
bootstrap-vue "2.20.1"
@ -1366,10 +1366,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/visual-review-tools/-/visual-review-tools-1.7.3.tgz#9ea641146436da388ffbad25d7f2abe0df52c235"
integrity sha512-NMV++7Ew1FSBDN1xiZaauU9tfeSfgDHcOLpn+8bGpP+O5orUPm2Eu66R5eC5gkjBPaXosNAxNWtriee+aFk4+g==
"@gitlab/web-ide@0.0.1-dev-20230120231236":
version "0.0.1-dev-20230120231236"
resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20230120231236.tgz#ab80a527b002c3ed4bbb719c8f624b4af4011352"
integrity sha512-1RDZ3h94YjFiPKuoKAV3TMEdaxHQvNTH0RH7AUVb1e5KXR82jXPYAfWyh3QGdfQ0XFu2QVdkRI0BE3zfhL0FIQ==
"@gitlab/web-ide@0.0.1-dev-20230210211358":
version "0.0.1-dev-20230210211358"
resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20230210211358.tgz#1417d4beec86879aec4e6c13a4ba2ffbd3cb8874"
integrity sha512-U5Q9Dmb/rkfWqzb0TOpxzSzs1BJ1v2/IOj+8AwL+16CWaQE3Lh/d5XizWULwk29McLtm6H8Em2OZwjOqWZvouA==
"@graphql-eslint/eslint-plugin@3.12.0":
version "3.12.0"