Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-07-03 12:11:56 +00:00
parent 960b02f579
commit 79c469c065
80 changed files with 1260 additions and 508 deletions

View File

@ -1041,7 +1041,7 @@
rules:
- if: '$GITLABCOM_DATABASE_TESTING_TRIGGER_TOKEN == null'
when: never
- <<: *if-fork-merge-request
- if: '$CI_MERGE_REQUEST_SOURCE_PROJECT_PATH !~ /^gitlab(-org|-cn)?\//'
when: never
- <<: *if-merge-request
changes: *db-patterns

View File

@ -1 +1 @@
398249273423b358d1c226d6379391f5bf0c927c
60cb736d21dff98e753dd49b873840cf665d8c91

View File

@ -1 +1 @@
2e27a8a56fb51a7359742453a6102e0cf20711de
42fc100c92311d4989681df8c62b91cd18edb886

View File

@ -82,7 +82,6 @@ export default {
);
},
},
colorScheme: gon?.user_color_scheme,
};
</script>
@ -103,8 +102,7 @@ export default {
<pre
v-if="hover.language"
ref="code-output"
:class="$options.colorScheme"
class="border-0 bg-transparent m-0 code highlight text-wrap"
class="border-0 bg-transparent m-0 code code-syntax-highlight-theme highlight text-wrap"
><doc-line v-for="(tokens, tokenIndex) in hover.tokens" :key="tokenIndex" :language="hover.language" :tokens="tokens" /></pre>
<markdown v-else ref="doc-output" class="gl-p-3" :markdown="hover.value" />
</div>

View File

@ -59,9 +59,6 @@ export default {
};
},
computed: {
themeClass() {
return window.gon?.user_color_scheme;
},
isCodeSuggestion() {
return (
this.node.attrs.isCodeSuggestion &&
@ -72,7 +69,7 @@ export default {
classList() {
return this.isCodeSuggestion
? '!gl-p-0 suggestion-added-input'
: `gl-p-3 code highlight ${this.$options.userColorScheme}`;
: `gl-p-3 code highlight code-syntax-highlight-theme`;
},
lineOffset() {
return langParamsToLineOffset(this.node.attrs.langParams);
@ -160,7 +157,6 @@ export default {
.run();
},
},
userColorScheme: gon.user_color_scheme,
};
</script>
<template>
@ -260,7 +256,10 @@ export default {
</div>
</div>
<div class="suggestion-deleted code" :class="themeClass" data-testid="suggestion-deleted">
<div
class="suggestion-deleted code code-syntax-highlight-theme"
data-testid="suggestion-deleted"
>
<code
v-for="(line, i) in deletedLines"
:key="i"
@ -272,8 +271,7 @@ export default {
>
</div>
<div
class="suggestion-added code gl-absolute"
:class="themeClass"
class="suggestion-added code code-syntax-highlight-theme gl-absolute"
data-testid="suggestion-added"
>
<code
@ -292,8 +290,7 @@ export default {
as="code"
class="gl-relative gl-z-1 !gl-break-words"
:class="{
'line_content new code': isCodeSuggestion,
[themeClass]: isCodeSuggestion,
'line_content new code code-syntax-highlight-theme': isCodeSuggestion,
}"
:spellcheck="false"
data-testid="suggestion-field"

View File

@ -140,7 +140,7 @@ export default CodeBlockLowlight.extend({
'pre',
{
...mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
class: `content-editor-code-block ${gon.user_color_scheme} ${HTMLAttributes.class}`,
class: `content-editor-code-block code-syntax-highlight-theme ${HTMLAttributes.class}`,
},
['code', {}, 0],
];

View File

@ -188,18 +188,14 @@ export default {
return this.lineDrafts(line, side).length > 0;
},
},
userColorScheme: window.gon.user_color_scheme,
};
</script>
<template>
<div
:class="[
$options.userColorScheme,
{ 'inline-diff-view': inline, 'with-inline-findings': hasInlineFindingsChanges },
]"
:class="[{ 'inline-diff-view': inline, 'with-inline-findings': hasInlineFindingsChanges }]"
:data-commit-id="commitId"
class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file"
class="diff-grid diff-table code code-syntax-highlight-theme diff-wrap-lines js-syntax-highlight text-file"
@mousedown="handleParallelLineMouseDown"
>
<template v-for="(line, index) in diffLines">

View File

@ -74,7 +74,6 @@ export default {
return line[1] ?? line.line;
},
},
userColorScheme: window.gon.user_color_scheme,
};
</script>
@ -122,7 +121,7 @@ export default {
</div>
</div>
<table v-if="isExpanded" :class="$options.userColorScheme" class="code js-syntax-highlight">
<table v-if="isExpanded" class="code code-syntax-highlight-theme js-syntax-highlight">
<tbody>
<tr v-for="(line, index) in lines" :key="`stacktrace-line-${index}`" class="line_holder">
<td class="diff-line-num" :class="{ old: isHighlighted(lineNum(line)) }">

View File

@ -53,8 +53,7 @@ export default {
action: null,
},
// eslint-disable-next-line @gitlab/require-i18n-strings
preClasses: `code highlight ${gon.user_color_scheme}`,
preClasses: 'code highlight code-syntax-highlight-theme',
};
},
computed: {

View File

@ -93,10 +93,15 @@ import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
import {
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
BASE_ALLOWED_CREATE_TYPES,
DETAIL_VIEW_QUERY_PARAM_NAME,
INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION,
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
WORK_ITEM_TYPE_NAME_ISSUE,
WORK_ITEM_TYPE_NAME_KEY_RESULT,
WORK_ITEM_TYPE_NAME_OBJECTIVE,
} from '~/work_items/constants';
import CreateWorkItemModal from '~/work_items/components/create_work_item_modal.vue';
import WorkItemDrawer from '~/work_items/components/work_item_drawer.vue';
import { makeDrawerUrlParam } from '~/work_items/utils';
import {
@ -148,7 +153,9 @@ export default {
ISSUES_VIEW_TYPE_KEY,
ISSUES_GRID_VIEW_KEY,
ISSUES_LIST_VIEW_KEY,
WORK_ITEM_TYPE_NAME_ISSUE,
components: {
CreateWorkItemModal,
CsvImportExportButtons,
GlDisclosureDropdown,
GlDisclosureDropdownGroup,
@ -295,6 +302,16 @@ export default {
},
},
computed: {
allowedWorkItemTypes() {
if (this.glFeatures.okrsMvc && this.hasOkrsFeature) {
return BASE_ALLOWED_CREATE_TYPES.concat(
WORK_ITEM_TYPE_NAME_KEY_RESULT,
WORK_ITEM_TYPE_NAME_OBJECTIVE,
);
}
return BASE_ALLOWED_CREATE_TYPES;
},
dropdownTooltip() {
return !this.showTooltip ? this.$options.i18n.actionsLabel : '';
},
@ -1106,23 +1123,35 @@ export default {
>
{{ __('Bulk edit') }}
</gl-button>
<slot name="new-issuable-button">
<gl-button
v-if="showNewIssueLink"
:href="newIssuePath"
variant="confirm"
class="gl-grow"
>
{{ __('New issue') }}
</gl-button>
</slot>
<new-resource-dropdown
v-if="showNewIssueDropdown"
:query="$options.searchProjectsQuery"
:query-variables="newIssueDropdownQueryVariables"
:extract-projects="extractProjects"
:group-id="groupId"
<create-work-item-modal
v-if="glFeatures.issuesListCreateModal"
:allowed-work-item-types="allowedWorkItemTypes"
always-show-work-item-type-select
:full-path="fullPath"
:is-group="!isProject"
:preselected-work-item-type="$options.WORK_ITEM_TYPE_NAME_ISSUE"
:show-project-selector="!isProject"
@workItemCreated="refetchIssuables"
/>
<template v-else>
<slot name="new-issuable-button">
<gl-button
v-if="showNewIssueLink"
:href="newIssuePath"
variant="confirm"
class="gl-grow"
>
{{ __('New issue') }}
</gl-button>
</slot>
<new-resource-dropdown
v-if="showNewIssueDropdown"
:query="$options.searchProjectsQuery"
:query-variables="newIssueDropdownQueryVariables"
:extract-projects="extractProjects"
:group-id="groupId"
/>
</template>
<gl-disclosure-dropdown
v-gl-tooltip
category="tertiary"
@ -1166,10 +1195,6 @@ export default {
<empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" />
</template>
<template #list-body>
<slot name="list-body"></slot>
</template>
<template #custom-status="{ issuable = {} }">
<slot name="custom-status" v-bind="{ issuable }"></slot>
</template>

View File

@ -129,7 +129,6 @@ export default {
return line.replace(FIRST_CHAR_REGEX, '');
},
},
userColorSchemeClass: window.gon.user_color_scheme,
};
</script>
@ -144,7 +143,7 @@ export default {
:expanded="!isCollapsed"
/>
<div v-if="isTextFile" class="diff-content">
<table class="code js-syntax-highlight" :class="$options.userColorSchemeClass">
<table class="code js-syntax-highlight code-syntax-highlight-theme">
<template v-if="!isFileDiscussion">
<template v-if="hasTruncatedDiffLines">
<tr

View File

@ -16,6 +16,7 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import CompactCodeDropdown from 'ee_else_ce/repository/components/code_dropdown/compact_code_dropdown.vue';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import apolloProvider from '~/repository/graphql';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { initHomePanel } from '../home_panel';
// Project show page loads different overview content based on user preferences
@ -125,8 +126,16 @@ const initEmptyProjectTabs = () => {
new EmptyProject(); // eslint-disable-line no-new
};
const initWikiContent = () => {
const el = document.querySelector('.js-wiki-content');
if (!el) return;
renderGFM(el);
};
initCodeDropdown();
initSourceCodeDropdowns();
initFindFileShortcut();
initEmptyProjectTabs();
initWebIdeLink({ el: document.getElementById('js-tree-web-ide-link') });
initWikiContent();

View File

@ -2,11 +2,6 @@
import { s__ } from '~/locale';
export default {
computed: {
themeClass() {
return window.gon?.user_color_scheme;
},
},
i18n: {
previewLabel: s__('Preferences|Preview'),
},
@ -16,7 +11,7 @@ export default {
<div class="form-group">
<label>{{ $options.i18n.previewLabel }}</label>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
<table :class="themeClass" class="code">
<table class="code code-syntax-highlight-theme">
<tbody>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">

View File

@ -58,11 +58,6 @@ export default {
})),
};
},
computed: {
codeTheme() {
return gon.user_color_scheme || 'white';
},
},
mounted() {
this.chunk.lines.forEach(this.processLine);
},
@ -97,8 +92,7 @@ export default {
<template>
<div
id="search-blob-content"
class="file-content code gl-rounded-none !gl-border-0 !gl-border-transparent"
:class="codeTheme"
class="file-content code code-syntax-highlight-theme gl-rounded-none !gl-border-0 !gl-border-transparent"
>
<div class="blob-content">
<div

View File

@ -20,7 +20,7 @@ export default function syntaxHighlight($els = null) {
if (el.classList.contains('js-syntax-highlight')) {
// Given the element itself, apply highlighting
return el.classList.add(gon.user_color_scheme);
return el.classList.add('code-syntax-highlight-theme');
}
// Given a parent element, recurse to any of its applicable children
const children = el.querySelectorAll('.js-syntax-highlight');

View File

@ -100,7 +100,6 @@ export default {
}
});
},
userColorScheme: window.gon.user_color_scheme,
};
</script>
@ -122,8 +121,7 @@ export default {
</p>
<pre
:class="[
$options.userColorScheme,
'code highlight js-syntax-highlight gl-rounded-base',
'code code-syntax-highlight-theme highlight js-syntax-highlight gl-rounded-base',
{ 'gl-rounded-b-none': reviewingDocsPath },
]"
data-testid="how-to-merge-instructions"
@ -169,7 +167,6 @@ export default {
{{ $options.i18n.steps.step4.help }}
</p>
<pre
:class="$options.userColorScheme"
class="code highlight js-syntax-highlight language-shell gl-rounded-base"
data-testid="how-to-merge-instructions"
>{{ mergeInfo2 }}</pre

View File

@ -140,12 +140,11 @@ export default {
}
},
},
userColorScheme: window.gon.user_color_scheme,
};
</script>
<template>
<div>
<div class="file-content code js-syntax-highlight gl-flex" :class="$options.userColorScheme">
<div class="file-content code code-syntax-highlight-theme js-syntax-highlight gl-flex">
<blame v-if="showBlame && blameInfoForRange.length" :blame-info="blameInfoForRange" />
<div class="line-numbers !gl-px-0">
<div v-for="line in lineNumbers" :key="line" class="diff-line-num line-links gl-flex">

View File

@ -25,13 +25,11 @@ export default {
return isScrollable ? scrollableStyles : null;
},
},
userColorScheme: window.gon.user_color_scheme,
};
</script>
<template>
<pre
class="code-block rounded code"
:class="$options.userColorScheme"
class="code-block rounded code code-syntax-highlight-theme"
:style="styleObject"
><slot><code class="gl-block">{{ code }}</code></slot></pre>
</template>

View File

@ -139,7 +139,6 @@ export default {
}
},
},
userColorSchemeClass: window.gon.user_color_scheme,
};
</script>
@ -259,8 +258,7 @@ export default {
class="gl-my-2 gl-mr-5 gl-overflow-hidden gl-overflow-visible gl-rounded-small gl-border-1 gl-border-solid gl-border-strong gl-pl-0"
>
<table
:class="$options.userColorSchemeClass"
class="code js-syntax-highlight"
class="code code-syntax-highlight-theme js-syntax-highlight"
data-testid="outdated-lines"
>
<tr v-for="line in lines" v-once :key="line.line_code" class="line_holder">

View File

@ -136,7 +136,6 @@ export default {
this.lineHighlighter.highlightHash(this.$route.hash);
},
},
userColorScheme: window.gon.user_color_scheme,
};
</script>
@ -145,8 +144,7 @@ export default {
<blame v-if="showBlame && blameInfo.length" :blame-info="blameInfo" />
<div
class="file-content code js-syntax-highlight blob-content blob-viewer gl-flex gl-w-full gl-flex-col gl-overflow-auto"
:class="$options.userColorScheme"
class="file-content code code-syntax-highlight-theme js-syntax-highlight blob-content blob-viewer gl-flex gl-w-full gl-flex-col gl-overflow-auto"
data-type="simple"
:data-path="blob.path"
data-testid="blob-viewer-file-content"

View File

@ -300,6 +300,7 @@ export default {
workItemType: workItemType.name,
workItemTypeId: workItemType.id,
workItemTypeIconName: workItemType.iconName,
relatedItemId: this.relatedItemId,
workItemTitle,
workItemDescription,
confidential: this.isConfidential,
@ -311,6 +312,7 @@ export default {
if (selectedWorkItemType) {
updateDraftWorkItemType({
fullPath: this.selectedProjectFullPath,
relatedItemId: this.relatedItemId,
workItemType: {
id: selectedWorkItemType.id,
name: selectedWorkItemType.name,
@ -354,6 +356,9 @@ export default {
hasWidgets() {
return this.workItem?.widgets?.length > 0;
},
relatedItemId() {
return this.relatedItem?.id;
},
relatedItemReference() {
return getDisplayReference(this.selectedProjectFullPath, this.relatedItem.reference);
},
@ -540,7 +545,7 @@ export default {
return (
this.isWidgetSupported(WIDGET_TYPE_LINKED_ITEMS) &&
this.isRelatedToItem &&
this.relatedItem?.id
this.relatedItemId
);
},
resolvingMRDiscussionLink() {
@ -706,10 +711,12 @@ export default {
workItemType: this.selectedWorkItemTypeName,
workItemTypeId: this.selectedWorkItemTypeId,
workItemTypeIconName: this.selectedWorkItemTypeIconName,
relatedItemId: this.relatedItemId,
});
updateDraftWorkItemType({
fullPath: this.selectedProjectFullPath,
relatedItemId: this.relatedItemId,
workItemType: {
id: this.selectedWorkItemTypeId,
name: this.selectedWorkItemTypeName,
@ -731,6 +738,7 @@ export default {
input: {
fullPath: this.selectedProjectFullPath,
workItemType: this.selectedWorkItemTypeName,
relatedItemId: this.relatedItemId,
[type]: value,
},
},
@ -929,6 +937,22 @@ export default {
this.loading = false;
}
},
async handleUpdateWidgetDraft(input) {
try {
await this.$apollo.mutate({
mutation: updateNewWorkItemMutation,
variables: {
input: {
...input,
relatedItemId: this.relatedItemId,
},
},
});
} catch (e) {
this.error = this.createErrorText;
Sentry.captureException(e);
}
},
handleCancelClick() {
/*
If any form field is filled or has a non-default value, ask user to confirm
@ -955,6 +979,7 @@ export default {
workItemType: this.selectedWorkItemTypeName,
workItemTypeId: this.selectedWorkItemTypeId,
workItemTypeIconName: this.selectedWorkItemTypeIconName,
relatedItemId: this.relatedItemId,
});
},
onParentMilestone(parentMilestone) {
@ -1108,6 +1133,7 @@ export default {
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:work-item-type="selectedWorkItemTypeName"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
<work-item-assignees
@ -1122,6 +1148,7 @@ export default {
:allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
:work-item-type="selectedWorkItemTypeName"
:can-invite-members="workItemAssignees.canInviteMembers"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
<work-item-labels
@ -1133,6 +1160,7 @@ export default {
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:work-item-type="selectedWorkItemTypeName"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
<work-item-parent
@ -1146,6 +1174,7 @@ export default {
:parent="workItemParent"
:is-group="isGroup"
:allowed-parent-types-for-new-work-item="allowedParentTypesForSelectedType"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
@parentMilestone="onParentMilestone"
/>
@ -1158,6 +1187,7 @@ export default {
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:work-item-type="selectedWorkItemTypeName"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
<work-item-milestone
@ -1170,6 +1200,7 @@ export default {
:work-item-milestone="workItemMilestone.milestone || selectedParentMilestone"
:work-item-type="selectedWorkItemTypeName"
:can-update="canUpdate"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
@parentMilestone="onParentMilestone"
/>
@ -1183,6 +1214,7 @@ export default {
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:work-item-type="selectedWorkItemTypeName"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
<work-item-dates
@ -1196,6 +1228,7 @@ export default {
:should-roll-up="shouldDatesRollup"
:work-item-type="selectedWorkItemTypeName"
:work-item="workItem"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
<work-item-health-status
@ -1205,6 +1238,7 @@ export default {
:work-item-iid="workItemIid"
:work-item-type="selectedWorkItemTypeName"
:full-path="selectedProjectFullPath"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
<work-item-color
@ -1213,6 +1247,7 @@ export default {
:work-item="workItem"
:full-path="selectedProjectFullPath"
:can-update="canUpdate"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
<work-item-custom-fields
@ -1222,6 +1257,7 @@ export default {
:custom-fields="workItemCustomFields"
:full-path="selectedProjectFullPath"
:can-update="canUpdate"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
<work-item-crm-contacts
@ -1231,6 +1267,7 @@ export default {
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:work-item-type="selectedWorkItemTypeName"
@updateWidgetDraft="handleUpdateWidgetDraft"
@error="$emit('error', $event)"
/>
</aside>

View File

@ -99,7 +99,10 @@ export default {
},
},
data() {
const draftWorkItemType = getDraftWorkItemType({ fullPath: this.fullPath })?.name;
const draftWorkItemType = getDraftWorkItemType({
fullPath: this.fullPath,
relatedItemId: this.relatedItem?.id,
})?.name;
return {
isCreateModalVisible: false,
@ -175,6 +178,11 @@ export default {
this.isCreateModalVisible = false;
},
showCreateModal(event) {
if (!gon?.current_user_id) {
// If user is signed out, don't show modal, but allow them to click on the button to sign in
return;
}
if (Boolean(event) && isMetaClick(event)) {
// opening in a new tab
return;

View File

@ -108,7 +108,6 @@ export default {
safeHtmlConfig: {
ADD_TAGS: ['use'], // to support icon SVGs
},
userColorSchemeClass: window.gon.user_color_scheme,
};
</script>

View File

@ -13,7 +13,6 @@ import { s__, sprintf, __ } from '~/locale';
import Tracking from '~/tracking';
import { ISSUE_MR_CHANGE_ASSIGNEE } from '~/behaviors/shortcuts/keybindings';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
export default {
@ -268,15 +267,10 @@ export default {
const { localAssigneeIds } = this;
if (this.workItemId === newWorkItemId(this.workItemType)) {
this.$apollo.mutate({
mutation: updateNewWorkItemMutation,
variables: {
input: {
workItemType: this.workItemType,
fullPath: this.fullPath,
assignees: this.localAssignees,
},
},
this.$emit('updateWidgetDraft', {
workItemType: this.workItemType,
fullPath: this.fullPath,
assignees: this.localAssignees,
});
this.updateInProgress = false;

View File

@ -1,4 +1,5 @@
<script>
import { camelCase } from 'lodash';
import { GlForm } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@ -98,6 +99,9 @@ export default {
shouldUseGraphQLBulkEdit() {
return this.isEpicsList || this.glFeatures.workItemsBulkEdit;
},
isEditableUnlessEpicList() {
return !this.shouldUseGraphQLBulkEdit || (this.shouldUseGraphQLBulkEdit && !this.isEpicsList);
},
},
methods: {
async handleFormSubmitted() {
@ -121,16 +125,36 @@ export default {
}
},
performBulkEdit() {
let assigneeIds;
if (this.assigneeId === BULK_UPDATE_UNASSIGNED) {
assigneeIds = [null];
} else if (this.assigneeId) {
assigneeIds = [this.assigneeId];
}
const hasLabelsToUpdate = this.addLabelIds.length > 0 || this.removeLabelIds.length > 0;
return this.$apollo.mutate({
mutation: workItemBulkUpdateMutation,
variables: {
input: {
parentId: this.parentId,
ids: this.checkedItems.map((item) => item.id),
labelsWidget: {
addLabelIds: this.addLabelIds,
removeLabelIds: this.removeLabelIds,
},
labelsWidget: hasLabelsToUpdate
? {
addLabelIds: this.addLabelIds,
removeLabelIds: this.removeLabelIds,
}
: undefined,
assigneesWidget: assigneeIds
? {
assigneeIds,
}
: undefined,
confidential: this.confidentiality ? this.confidentiality === 'true' : undefined,
healthStatusWidget: this.healthStatus
? {
healthStatus: camelCase(this.healthStatus),
}
: undefined,
},
},
});
@ -171,7 +195,7 @@ export default {
data-testid="bulk-edit-state"
/>
<work-item-bulk-edit-assignee
v-if="!shouldUseGraphQLBulkEdit"
v-if="isEditableUnlessEpicList"
v-model="assigneeId"
:full-path="fullPath"
:is-group="isGroup"
@ -192,7 +216,7 @@ export default {
@select="removeLabelIds = $event"
/>
<work-item-bulk-edit-dropdown
v-if="!shouldUseGraphQLBulkEdit && hasIssuableHealthStatusFeature"
v-if="hasIssuableHealthStatusFeature && isEditableUnlessEpicList"
v-model="healthStatus"
:header-text="__('Select health status')"
:items="$options.healthStatusItems"
@ -208,7 +232,7 @@ export default {
data-testid="bulk-edit-subscription"
/>
<work-item-bulk-edit-dropdown
v-if="!shouldUseGraphQLBulkEdit"
v-if="isEditableUnlessEpicList"
v-model="confidentiality"
:header-text="__('Select confidentiality')"
:items="$options.confidentialityItems"

View File

@ -7,7 +7,6 @@ import Tracking from '~/tracking';
import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
import { findCrmContactsWidget, newWorkItemFullPath, newWorkItemId } from '../utils';
@ -211,17 +210,11 @@ export default {
}
if (this.createFlow) {
this.$apollo.mutate({
mutation: updateNewWorkItemMutation,
variables: {
input: {
workItemType: this.workItemType,
fullPath: this.fullPath,
crmContacts: newSelectedItems,
},
},
this.$emit('updateWidgetDraft', {
workItemType: this.workItemType,
fullPath: this.fullPath,
crmContacts: newSelectedItems,
});
this.updateInProgress = false;
return;
}

View File

@ -12,7 +12,6 @@ import {
WIDGET_TYPE_START_AND_DUE_DATE,
} from '../constants';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql';
import WorkItemSidebarWidget from './shared/work_item_sidebar_widget.vue';
const nullObjectDate = new Date(0);
@ -175,19 +174,14 @@ export default {
this.rollupType = ROLLUP_TYPE_FIXED;
if (this.workItemId === newWorkItemId(this.workItemType)) {
this.$apollo.mutate({
mutation: updateNewWorkItemMutation,
variables: {
input: {
workItemType: this.workItemType,
fullPath: this.fullPath,
rolledUpDates: {
isFixed: true,
dueDate: this.localDueDate ? toISODateFormat(this.localDueDate) : null,
startDate: this.localStartDate ? toISODateFormat(this.localStartDate) : null,
rollUp: this.shouldRollUp,
},
},
this.$emit('updateWidgetDraft', {
workItemType: this.workItemType,
fullPath: this.fullPath,
rolledUpDates: {
isFixed: true,
dueDate: this.localDueDate ? toISODateFormat(this.localDueDate) : null,
startDate: this.localStartDate ? toISODateFormat(this.localStartDate) : null,
rollUp: this.shouldRollUp,
},
});

View File

@ -13,7 +13,6 @@ import Tracking from '~/tracking';
import { ISSUABLE_CHANGE_LABEL } from '~/behaviors/shortcuts/keybindings';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WORK_ITEM_TYPE_NAME_EPIC } from '../constants';
import {
findLabelsWidget,
@ -257,16 +256,11 @@ export default {
await this.updateLabels({ addLabelIds, removeLabelIds });
}
},
async updateDraftCache() {
await this.$apollo.mutate({
mutation: updateNewWorkItemMutation,
variables: {
input: {
workItemType: this.workItemType,
fullPath: this.fullPath,
labels: this.labelsCache.filter(({ id }) => this.selectedLabelsIds.includes(id)),
},
},
updateDraftCache() {
this.$emit('updateWidgetDraft', {
workItemType: this.workItemType,
fullPath: this.fullPath,
labels: this.labelsCache.filter(({ id }) => this.selectedLabelsIds.includes(id)),
});
},
async updateLabels({ addLabelIds = [], removeLabelIds = [] }) {

View File

@ -11,7 +11,6 @@ import { ISSUE_MR_CHANGE_MILESTONE } from '~/behaviors/shortcuts/keybindings';
import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
import groupMilestonesQuery from '~/sidebar/queries/group_milestones.query.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateNewWorkItemMutation from '~/work_items/graphql/update_new_work_item.mutation.graphql';
import {
I18N_WORK_ITEM_ERROR_UPDATING,
NAME_TO_TEXT_LOWERCASE_MAP,
@ -169,32 +168,21 @@ export default {
this.updateInProgress = true;
if (this.workItemId === newWorkItemId(this.workItemType)) {
this.$apollo
.mutate({
mutation: updateNewWorkItemMutation,
variables: {
input: {
fullPath: this.fullPath,
milestone: this.localMilestone
? {
...this.localMilestone,
webPath: this.localMilestone.webUrl,
startDate: '',
projectMilestone: false,
}
: null,
workItemType: this.workItemType,
},
},
})
.catch((error) => {
Sentry.captureException(error);
})
.finally(() => {
this.updateInProgress = false;
this.searchTerm = '';
this.shouldFetch = false;
});
this.$emit('updateWidgetDraft', {
fullPath: this.fullPath,
milestone: this.localMilestone
? {
...this.localMilestone,
webPath: this.localMilestone.webUrl,
startDate: '',
projectMilestone: false,
}
: null,
workItemType: this.workItemType,
});
this.updateInProgress = false;
this.searchTerm = '';
this.shouldFetch = false;
return;
}

View File

@ -7,7 +7,6 @@ import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_i
import updateParentMutation from '~/work_items/graphql/update_parent.mutation.graphql';
import { isValidURL } from '~/lib/utils/url_utility';
import updateNewWorkItemMutation from '~/work_items/graphql/update_new_work_item.mutation.graphql';
import {
findMilestoneWidget,
findHierarchyWidgetDefinition,
@ -237,35 +236,19 @@ export default {
this.updateInProgress = true;
if (this.workItemId === newWorkItemId(this.workItemType)) {
this.$apollo
.mutate({
mutation: updateNewWorkItemMutation,
variables: {
input: {
fullPath: this.fullPath,
parent: this.isSelectedParentAvailable
? {
...this.visibleWorkItems.find(({ id }) => id === this.localSelectedItem),
webUrl: this.parentWebUrl ?? null,
}
: null,
workItemType: this.workItemType,
},
},
})
.catch((error) => {
this.$emit(
'error',
sprintf(I18N_WORK_ITEM_ERROR_UPDATING, {
workItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.workItemType],
}),
);
Sentry.captureException(error);
})
.finally(() => {
this.searchStarted = false;
this.updateInProgress = false;
});
this.$emit('updateWidgetDraft', {
fullPath: this.fullPath,
parent: this.isSelectedParentAvailable
? {
...this.visibleWorkItems.find(({ id }) => id === this.localSelectedItem),
webUrl: this.parentWebUrl ?? null,
}
: null,
workItemType: this.workItemType,
});
this.searchStarted = false;
this.updateInProgress = false;
return;
}

View File

@ -321,11 +321,12 @@ export const getNewWorkItemSharedCache = ({
widgetDefinitions,
fullPath,
workItemType,
relatedItemId,
isValidWorkItemDescription,
workItemDescription = '',
}) => {
const widgetsAutosaveKey = getNewWorkItemWidgetsAutoSaveKey({ fullPath });
const fullDraftAutosaveKey = getNewWorkItemAutoSaveKey({ fullPath, workItemType });
const widgetsAutosaveKey = getNewWorkItemWidgetsAutoSaveKey({ fullPath, relatedItemId });
const fullDraftAutosaveKey = getNewWorkItemAutoSaveKey({ fullPath, workItemType, relatedItemId });
const workItemTypeSpecificWidgets =
getWorkItemWidgets(JSON.parse(getDraft(fullDraftAutosaveKey))) || {};
const sharedCacheWidgets = JSON.parse(getDraft(widgetsAutosaveKey)) || {};
@ -627,6 +628,7 @@ export const setNewWorkItemCache = async ({
workItemType,
workItemTypeId,
workItemTypeIconName,
relatedItemId,
workItemTitle = '',
workItemDescription = '',
confidential = false,
@ -661,7 +663,7 @@ export const setNewWorkItemCache = async ({
const isValidWorkItemTitle = workItemTitle.trim().length > 0;
const isValidWorkItemDescription = workItemDescription.trim().length > 0;
const autosaveKey = getNewWorkItemAutoSaveKey({ fullPath, workItemType });
const autosaveKey = getNewWorkItemAutoSaveKey({ fullPath, workItemType, relatedItemId });
const getStorageDraftString = getDraft(autosaveKey);
const draftData = JSON.parse(getDraft(autosaveKey));
@ -678,6 +680,7 @@ export const setNewWorkItemCache = async ({
workItemType,
isValidWorkItemDescription,
workItemDescription,
relatedItemId,
});
draftTitle = sharedCache.draftTitle;

View File

@ -95,6 +95,7 @@ const updateCustomFieldsWidget = (sourceData, draftData, customField) => {
export const updateNewWorkItemCache = (input, cache) => {
const {
relatedItemId,
healthStatus,
fullPath,
workItemType,
@ -198,14 +199,14 @@ export const updateNewWorkItemCache = (input, cache) => {
const newData = cache.readQuery({ query, variables });
const autosaveKey = getNewWorkItemAutoSaveKey({ fullPath, workItemType });
const autosaveKey = getNewWorkItemAutoSaveKey({ fullPath, workItemType, relatedItemId });
const isQueryDataValid = !isEmpty(newData) && newData?.workspace?.workItem;
if (isQueryDataValid && autosaveKey) {
updateDraft(autosaveKey, JSON.stringify(newData));
updateDraft(
getNewWorkItemWidgetsAutoSaveKey({ fullPath }),
getNewWorkItemWidgetsAutoSaveKey({ fullPath, relatedItemId }),
JSON.stringify(getWorkItemWidgets(newData)),
);
}

View File

@ -12,6 +12,8 @@ import {
WORK_ITEM_TYPE_NAME_INCIDENT,
WORK_ITEM_TYPE_NAME_ISSUE,
WORK_ITEM_TYPE_NAME_TASK,
WORK_ITEM_TYPE_ROUTE_WORK_ITEM,
WORK_ITEM_TYPE_ROUTE_ISSUE,
} from '../constants';
import workItemRelatedItemQuery from '../graphql/work_item_related_item.query.graphql';
import { convertTypeEnumToName } from '../utils';
@ -122,14 +124,15 @@ export default {
const isWorkItemRoute = this.$route.params?.type === 'work_items';
const isGroupWorkItemRoute = isWorkItemRoute && this.$router.history.base.includes('groups');
/*
If the route is epics, issues or work items on the group level
(because work items on the project level is not yet available)
we redirect to the list page when the user clicks on cancel,
otherwise we go back to the previous page.
*/
if (Boolean(listPath) && (!isWorkItemRoute || isGroupWorkItemRoute)) {
/**
* If the route is epics, issues or work items on the group level
* (because work items on the project level is not yet available)
* we redirect to the list page when the user clicks on cancel,
* otherwise we go back to the previous page.
*/
if (Boolean(listPath) && isWorkItemRoute && isGroupWorkItemRoute) {
visitUrl(listPath.replaceAll(WORK_ITEM_TYPE_ROUTE_WORK_ITEM, WORK_ITEM_TYPE_ROUTE_ISSUE));
} else if (Boolean(listPath) && (!isWorkItemRoute || isGroupWorkItemRoute)) {
visitUrl(listPath);
} else {
this.$router.go(-1);

View File

@ -36,6 +36,7 @@ import {
WIDGET_TYPE_TIME_TRACKING,
WIDGET_TYPE_VULNERABILITIES,
WIDGET_TYPE_WEIGHT,
WORK_ITEM_TYPE_NAME_ISSUE,
WORK_ITEM_TYPE_ROUTE_WORK_ITEM,
} from './constants';
@ -284,7 +285,12 @@ export const markdownPreviewPath = ({ fullPath, iid, isGroup = false }) => {
export const newWorkItemPath = ({ fullPath, isGroup = false, workItemType, query = '' }) => {
const domain = gon.relative_url_root || '';
const basePath = isGroup ? `groups/${fullPath}` : fullPath;
const type = NAME_TO_ROUTE_MAP[workItemType] || WORK_ITEM_TYPE_ROUTE_WORK_ITEM;
// We have a special case to redirect to /groups/my-group/-/work_items/new
// instead of /groups/my-group/-/issues/new
const type =
isGroup && workItemType === WORK_ITEM_TYPE_NAME_ISSUE
? WORK_ITEM_TYPE_ROUTE_WORK_ITEM
: NAME_TO_ROUTE_MAP[workItemType] || WORK_ITEM_TYPE_ROUTE_WORK_ITEM;
return `${domain}/${basePath}/-/${type}/new${query}`;
};
@ -419,26 +425,41 @@ export const getAutosaveKeyQueryParamString = () => {
return queryParams.toString();
};
export const getNewWorkItemAutoSaveKey = ({ fullPath, workItemType }) => {
export const getNewWorkItemAutoSaveKey = ({ fullPath, workItemType, relatedItemId }) => {
if (!workItemType || !fullPath) return '';
const relatedId = getIdFromGraphQLId(relatedItemId);
const queryParamString = getAutosaveKeyQueryParamString();
let initialKey = `new-${fullPath}-${workItemType.toLowerCase()}`;
if (relatedId) {
initialKey = `${initialKey}-related-${relatedId}`;
}
if (queryParamString) {
return `new-${fullPath}-${workItemType.toLowerCase()}-${queryParamString}-draft`;
initialKey = `${initialKey}-${queryParamString}`;
}
return `new-${fullPath}-${workItemType.toLowerCase()}-draft`;
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${initialKey}-draft`;
};
export const getNewWorkItemWidgetsAutoSaveKey = ({ fullPath }) => {
export const getNewWorkItemWidgetsAutoSaveKey = ({ fullPath, relatedItemId }) => {
if (!fullPath) return '';
const relatedId = getIdFromGraphQLId(relatedItemId);
const queryParamString = getAutosaveKeyQueryParamString();
let initialKey = `new-${fullPath}`;
if (relatedId) {
initialKey = `${initialKey}-related-${relatedId}`;
}
if (queryParamString) {
return `new-${fullPath}-widgets-${queryParamString}-draft`;
initialKey = `${initialKey}-${queryParamString}`;
}
return `new-${fullPath}-widgets-draft`;
return `${initialKey}-widgets-draft`;
};
export const getWorkItemWidgets = (draftData) => {
@ -456,18 +477,20 @@ export const getWorkItemWidgets = (draftData) => {
return widgets;
};
export const updateDraftWorkItemType = ({ fullPath, workItemType }) => {
export const updateDraftWorkItemType = ({ fullPath, workItemType, relatedItemId }) => {
const widgetsAutosaveKey = getNewWorkItemWidgetsAutoSaveKey({
fullPath,
relatedItemId,
});
const sharedCacheWidgets = JSON.parse(getDraft(widgetsAutosaveKey)) || {};
sharedCacheWidgets.TYPE = workItemType;
updateDraft(widgetsAutosaveKey, JSON.stringify(sharedCacheWidgets));
};
export const getDraftWorkItemType = ({ fullPath }) => {
export const getDraftWorkItemType = ({ fullPath, relatedItemId }) => {
const widgetsAutosaveKey = getNewWorkItemWidgetsAutoSaveKey({
fullPath,
relatedItemId,
});
const sharedCacheWidgets = JSON.parse(getDraft(widgetsAutosaveKey)) || {};
return sharedCacheWidgets.TYPE;

View File

@ -127,7 +127,8 @@ $dark-il: #de935f;
--diff-deletion-color: #{$dark-old-bg};
}
.code.dark {
.code.dark,
.code.code-syntax-highlight-theme {
--code-dark-theme: 1;
--code-border-lightness-adjust: 0.1;
--diff-expansion-background-color: #{$gl-color-neutral-600};

View File

@ -98,7 +98,8 @@ $monokai-gh: #75715e;
--diff-deletion-color: #{$monokai-old-bg};
}
.code.monokai {
.code.monokai,
.code.code-syntax-highlight-theme {
--code-dark-theme: 1;
--code-border-lightness-adjust: 0.1;
--diff-expansion-background-color: #{$gl-color-neutral-600};

View File

@ -18,7 +18,8 @@ $none-code-mark: #d3e3f4;
--diff-deletion-color: #{$gl-color-neutral-50};
}
.code.none {
.code.none,
.code.code-syntax-highlight-theme {
--code-light-theme: 1;
--diff-expansion-background-color: #{$gl-color-neutral-100};

View File

@ -101,7 +101,8 @@ $solarized-dark-il: #2aa198;
--diff-deletion-color: #{$solarized-dark-old-bg};
}
.code.solarized-dark {
.code.solarized-dark,
.code.code-syntax-highlight-theme {
--code-dark-theme: 1;
--code-border-lightness-adjust: 0.1;
--diff-expansion-background-color: #{lighten($solarized-dark-pre-bg, 10%)};

View File

@ -108,7 +108,8 @@ $solarized-light-il: #2aa198;
background: $solarized-light-matchline-bg;
}
.code.solarized-light {
.code.solarized-light,
.code.code-syntax-highlight-theme {
--code-light-theme: 1;
--diff-expansion-background-color: #{$gl-color-neutral-100};

View File

@ -1,6 +1,7 @@
@import '../white_base';
.code.white {
.code.white,
.code.code-syntax-highlight-theme {
@include white-base;
@include conflict-colors('white');

View File

@ -26,7 +26,7 @@
.rd-app-content-header{ data: { hidden_files_warning: true } }
- if empty_diff? && !lazy?
= render RapidDiffs::EmptyStateComponent.new
.rd-app-code-theme.code{ class: helpers.user_color_scheme }
.rd-app-code-theme.code.code-syntax-highlight-theme
.rd-app-diffs-list
-# performance optimization: using a sibling element to cover diffs list is faster than changing opacity on the parent
.rd-app-diffs-list-loading-overlay{ data: { diffs_overlay: true } }

View File

@ -37,6 +37,7 @@ class GroupsController < Groups::ApplicationController
push_force_frontend_feature_flag(:work_items_beta, group.work_items_beta_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_alpha, group.work_items_alpha_feature_flag_enabled?)
push_frontend_feature_flag(:issues_grid_view)
push_frontend_feature_flag(:issues_list_create_modal, group)
push_frontend_feature_flag(:issues_list_drawer, group)
push_frontend_feature_flag(:work_item_status_feature_flag, group&.root_ancestor)
end

View File

@ -50,6 +50,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:preserve_markdown, project)
push_frontend_feature_flag(:issues_grid_view)
push_frontend_feature_flag(:service_desk_ticket)
push_frontend_feature_flag(:issues_list_create_modal, project)
push_frontend_feature_flag(:issues_list_drawer, project)
push_frontend_feature_flag(:notifications_todos_buttons, current_user)
push_frontend_feature_flag(:work_item_planning_view, project&.group)

View File

@ -4,8 +4,6 @@ module Ci
# The purpose of this class is to store Build related data that can be disposed.
# Data that should be persisted forever, should be stored with Ci::Build model.
class BuildMetadata < Ci::ApplicationRecord
BuildTimeout = Struct.new(:value, :source)
include Ci::Partitionable
include Presentable
include ChronicDurationAttribute
@ -51,8 +49,7 @@ module Ci
}
def update_timeout_state
timeout = timeout_with_highest_precedence
timeout = ::Ci::Builds::TimeoutCalculator.new(build).applicable_timeout
return unless timeout
update(timeout: timeout.value, timeout_source: timeout.source)
@ -69,37 +66,5 @@ module Ci
def set_build_project
self.project_id ||= build.project_id
end
def timeout_with_highest_precedence
[(job_timeout || project_timeout), runner_timeout].compact.min_by(&:value)
end
def project_timeout
strong_memoize(:project_timeout) do
BuildTimeout.new(project&.build_timeout, :project_timeout_source)
end
end
def job_timeout
return unless build.options
strong_memoize(:job_timeout) do
if timeout_from_options = build.options[:job_timeout]
BuildTimeout.new(timeout_from_options, :job_timeout_source)
end
end
end
def runner_timeout
return unless runner_timeout_set?
strong_memoize(:runner_timeout) do
BuildTimeout.new(build.runner.maximum_timeout, :runner_timeout_source)
end
end
def runner_timeout_set?
build.runner&.maximum_timeout.to_i > 0
end
end
end

View File

@ -36,7 +36,7 @@ module WebHooks
included do
delegate :auto_disabling_enabled?, to: :class
ignore_column :backoff_count, remove_with: '18.1', remove_after: '2025-05-20'
ignore_column :backoff_count, remove_with: '18.3', remove_after: '2025-07-20'
# A webhook is disabled if:
#

View File

@ -21,7 +21,7 @@
},
"project_security_status": {
"type": [
"array",
"object",
"null"
],
"description": "Data for rendering the project grades summary in PDF reports"

View File

@ -1,6 +1,6 @@
- if @wiki_home.present?
%div{ class: container_class }
.md.gl-mt-3.gl-mb-3
.md.gl-mt-3.gl-mb-3.js-wiki-content
= render_wiki_content(@wiki_home)
- else
- can_create_wiki = can?(current_user, :create_wiki, @project)

View File

@ -1,6 +1,6 @@
- current_line = @blame.first_line
.file-content.blame.code{ class: user_color_scheme }
.file-content.blame.code.code-syntax-highlight-theme
- groups_length = @blame.groups.size - 1
- @blame.groups.each_with_index do |blame_group, index|
- commit_data = @blame.commit_data(blame_group[:commit])

View File

@ -0,0 +1,10 @@
---
name: issues_list_create_modal
description:
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/514577
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/190721
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/551355
milestone: '18.2'
group: group::project management
type: beta
default_enabled: false

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class RemoveWebHooksBackoffCountColumn < Gitlab::Database::Migration[2.3]
milestone '18.2'
TABLE_NAME = :web_hooks
COLUMN_NAME = :backoff_count
def up
remove_column TABLE_NAME, COLUMN_NAME
end
def down
add_column TABLE_NAME, COLUMN_NAME, :smallint, default: 0, null: false
end
end

View File

@ -0,0 +1 @@
80357a3afe319e27bd583abd215cc23494b00ab6ef789d590e36171d95309c9d

View File

@ -25996,7 +25996,6 @@ CREATE TABLE web_hooks (
member_events boolean DEFAULT false NOT NULL,
subgroup_events boolean DEFAULT false NOT NULL,
recent_failures smallint DEFAULT 0 NOT NULL,
backoff_count smallint DEFAULT 0 NOT NULL,
disabled_until timestamp with time zone,
encrypted_url_variables bytea,
encrypted_url_variables_iv bytea,

View File

@ -23,6 +23,7 @@ Auditor users:
- Cannot view the Admin area or perform any administration actions.
- Cannot access group or projects settings.
- Cannot view job logs when [debug logging](../ci/variables/variables_troubleshooting.md#enable-debug-logging) is enabled.
- Cannot access areas designed for editing, including the [pipeline editor](../ci/pipeline_editor/_index.md).
Auditor users are sometimes used in situations where:

View File

@ -20,9 +20,8 @@ title: Rate limits on Git HTTP
{{< /history >}}
If you use Git HTTP in your repository, common Git operations can generate many Git HTTP requests.
Some of these Git HTTP requests do not contain authentication parameters, and are considered
unauthenticated requests. Enforcing rate limits on Git HTTP requests can improve the security and
durability of your web application.
GitLab can enforce rate limits on both authenticated and unauthenticated Git HTTP requests to improve
the security and durability of your web application.
{{< alert type="note" >}}
@ -30,10 +29,12 @@ durability of your web application.
{{< /alert >}}
## Configure Git HTTP rate limits
## Configure unauthenticated Git HTTP rate limits
GitLab disables rate limits on Git HTTP requests by default. If you enable and configure these limits,
GitLab applies them to Git HTTP requests:
GitLab disables rate limits on unauthenticated Git HTTP requests by default.
To apply rate limits to Git HTTP requests that do not contain authentication
parameters, enable and configure these limits:
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > Network**.
@ -43,6 +44,34 @@ GitLab applies them to Git HTTP requests:
1. Enter a value for **Unauthenticated Git HTTP rate limit period in seconds**.
1. Select **Save changes**.
## Configure authenticated Git HTTP rate limits
{{< history >}}
- Authenticated Git HTTP rate limits [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/191552) in GitLab 18.1 [with a flag](../../administration/feature_flags/_index.md) named `git_authenticated_http_limit`. Disabled by default.
{{< /history >}}
{{< alert type="flag" >}}
The availability of this feature is controlled by a feature flag.
For more information, see the history.
{{< /alert >}}
GitLab disables rate limits on authenticated Git HTTP requests by default.
To apply rate limits to Git HTTP requests that contain authentication
parameters, enable and configure these limits:
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **Settings > Network**.
1. Expand **Git HTTP rate limits**.
1. Select **Enable authenticated Git HTTP request rate limit**.
1. Enter a value for **Max authenticated Git HTTP requests per period per user**.
1. Enter a value for **Authenticated Git HTTP rate limit period in seconds**.
1. Select **Save changes**.
## Related topics
- [Rate limiting](../../security/rate_limits.md)

View File

@ -930,7 +930,7 @@ Example response:
{{< alert type="warning" >}}
This endpoint is scheduled for removal in GitLab 18.5.
This endpoint is scheduled for removal in GitLab 18.3 (August 11th, 2025).
Use [`GET /groups/:id/saml_users`](#list-all-saml-users) and [`GET /groups/:id/service_accounts`](group_service_accounts.md#list-all-service-account-users) instead.
{{< /alert >}}

View File

@ -1355,6 +1355,7 @@ To emphasize an area in a screenshot, use an arrow.
For an MR added to 11.1's milestone, a valid name for an illustration is `devops_diagram_v11_1.png`.
- Place images in a separate directory named `img/` in the same directory where
the `.md` document that you're working on is located.
- Do not link to externally-hosted images. Download a copy and store it in the appropriate `img` directory within the docs directory.
- Consider PNG images instead of JPEG.
- Compress GIFs with <https://ezgif.com/optimize> or similar tool.

View File

@ -16,6 +16,7 @@ GitLab can check your application for security vulnerabilities and that it meets
| [Create a compliance pipeline](compliance_pipeline/_index.md) | Learn how to create compliance pipelines for your groups. | {{< icon name="star" >}} |
| [Set up a merge request approval policy](scan_result_policy/_index.md) | Learn how to configure a merge request approval policy that takes action based on scan results. | {{< icon name="star" >}} |
| [Set up a scan execution policy](scan_execution_policy/_index.md) | Learn how to create a scan execution policy to enforce security scanning of your project. | {{< icon name="star" >}} |
| [Set up a pipeline execution policy](pipeline_execution_policy/_index.md) | Learn how to create a pipeline execution policy to enforce security scanning across projects as part of the pipeline. | {{< icon name="star" >}} |
| [Scan a Docker container for vulnerabilities](container_scanning/_index.md) | Learn how to use container scanning templates to add container scanning to your projects. | {{< icon name="star" >}} |
| [Protect your project with secret push protection](../user/application_security/secret_detection/push_protection_tutorial.md) | Enable secret push protection in your project. | {{< icon name="star" >}} |
| [Remove a secret from your commits](../user/application_security/secret_detection/remove_secrets_tutorial.md) | Learn how to remove a secret from your commit history. | {{< icon name="star" >}} |

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
module Ci
module Builds
Timeout = Struct.new(:value, :source)
class TimeoutCalculator
def self.timeout_sources
Ci::BuildMetadata.timeout_sources
end
def initialize(build)
@build = build
end
def applicable_timeout
[job_timeout || project_timeout, runner_timeout].compact.min_by(&:value)
end
private
attr_reader :build
def job_timeout
value = build.options[:job_timeout]
return unless value
Ci::Builds::Timeout.new(value, fetch_source(:job_timeout_source))
end
def project_timeout
value = build.project&.build_timeout
return unless value
Ci::Builds::Timeout.new(value, fetch_source(:project_timeout_source))
end
def runner_timeout
value = build.runner&.maximum_timeout.to_i
return unless value > 0
Ci::Builds::Timeout.new(value, fetch_source(:runner_timeout_source))
end
def fetch_source(source)
self.class.timeout_sources.fetch(source)
end
end
end
end

View File

@ -0,0 +1,263 @@
# frozen_string_literal: true
require "prawn"
require "prawn-svg"
module Gitlab
module PDF
module Security
class GroupVulnerabilitiesProjectsGrades
include Prawn::View
DEFAULT_COUNTS = '0 projects'
GRADES_DISPLAY_INFO = {
a: { color: '#16a34a', severities: [] },
b: { color: '#f97316', severities: %w[low] },
c: { color: '#ea580c', severities: %w[medium] },
d: { color: '#b91c1c', severities: %w[high unknown] },
f: { color: '#991b1b', severities: %w[critical] }
}.freeze
SVG_STYLES = <<~SVG_STYLES.freeze
<defs>
<style>
.header-text { font-family: sans-serif; font-size: 18px; font-weight: bold; fill: #1f2937; }
.subheader { font-family: sans-serif; font-size: 13px; fill: #6b7280; }
.grade-letter { font-family: sans-serif; font-size: 16px; font-weight: bold; }
.project-count { font-family: sans-serif; font-size: 14px; fill: #1f2937; }
.description { font-family: sans-serif; font-size: 12px; fill: #6b7280; }
.severity-count { font-family: sans-serif; font-size: 11px; }
.grade-f { fill: #{GRADES_DISPLAY_INFO.dig(:f, :color)}; }
.grade-d { fill: #{GRADES_DISPLAY_INFO.dig(:d, :color)}; }
.grade-c { fill: #{GRADES_DISPLAY_INFO.dig(:c, :color)}; }
.grade-b { fill: #{GRADES_DISPLAY_INFO.dig(:b, :color)}; }
.grade-a { fill: #{GRADES_DISPLAY_INFO.dig(:a, :color)}; }
</style>
</defs>
SVG_STYLES
def self.render(pdf, data: {})
new(pdf, data).render
end
def initialize(pdf, data)
@pdf = pdf
@grades = process_raw(data)
@expanded_grade = data&.fetch(:expanded_grade, 'F')
@gitlab_host_url = Rails.application.routes.url_helpers.root_url.chomp('/')
@width = 500
@height = 700
@y = pdf.cursor
end
def render
return :noop if @grades.blank?
@pdf.bounding_box([0, @y], width: @pdf.bounds.right, height: @height) do
@pdf.save_graphics_state
@pdf.fill_color "F9F9F9"
@pdf.fill_rectangle [0, @pdf.bounds.top], @pdf.bounds.right, @height
@pdf.restore_graphics_state
@pdf.text_box(
s_('Project security status'),
at: [0, @pdf.bounds.top - 10],
width: @pdf.bounds.right,
align: :center,
style: :bold,
size: 16
)
@pdf.text_box(
s_('Projects are graded based on the highest severity vulnerability present'),
at: [0, @pdf.bounds.top - 40],
width: @pdf.bounds.right,
align: :center,
size: 12
)
svg = build_base_svg
@pdf.svg svg, at: [0, @pdf.cursor]
render_project_names
end
end
private
def build_base_svg
svg = <<~SVG
<svg width="#{@pdf.bounds.width}" height="700" xmlns="http://www.w3.org/2000/svg">
#{SVG_STYLES}
#{svg_background_layers}
#{svg_headers(title_y: 35, description_y: 55)}
SVG
current_svg_y = 80
@project_text_positions = [] # Store positions for later text rendering
@grades.each do |grade|
if grade[:letter] == @expanded_grade
expanded_drawer_height = (grade[:projects].count * 45) + 80
if grade[:projects]
y_position = current_svg_y + 70
grade[:projects].each_with_index do |project, index|
@project_text_positions << {
project: project,
y: y_position + (index * 45),
grade: grade[:letter]
}
end
end
svg += expanded_grade_svg(grade, current_svg_y, expanded_drawer_height)
current_svg_y += expanded_drawer_height
else
svg += collapsed_grade_svg(grade, current_svg_y)
current_svg_y += 40 # collapsed row height
end
end
svg += '</svg>'
end
def svg_background_layers
<<~SVG
<rect x="0" y="0" width="#{@pdf.bounds.width}" height="#{@height}" fill="#ffffff"/>
<rect x=" 0" y="0" width="#{@pdf.bounds.width}" height="80" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
SVG
end
def svg_headers(title_y:, description_y:)
title = s_("Project security status")
description = s_("Projects are graded based on the highest severity vulnerability present")
<<~SVG
<text x="20" y="#{title_y}" class="header-text">#{title}</text>
<text x="20" y="#{description_y}" class="subheader">#{description}</text>
SVG
end
def expanded_grade_svg(grade, current_svg_y, drawer_height)
letter_grade = grade[:letter]
projects = grade[:projects]
<<~SVG
<g transform="translate(0, #{current_svg_y})">
<rect x="0" y="0" width="#{@pdf.bounds.width}" height="#{drawer_height}" fill="#ffffff" stroke="#e5e7eb" stroke-width="1"/>
<rect x="0" y="0" width="#{@pdf.bounds.width}" height="40" fill="#f3f4f6"/>
<text x="20" y="25" class="grade-letter grade-#{@expanded_grade.downcase}">#{letter_grade}</text>
<text x="50" y="25" class="project-count">#{grade[:count]}</text>
<text x="20" y="60" class="description">#{grade[:description]}</text>
<g transform="translate(20, 80)">
#{severity_counts_svg(projects, letter_grade)}
</g>
</g>
SVG
end
def severity_counts_svg(projects, letter_grade)
severities_included_in_grade = GRADES_DISPLAY_INFO[letter_grade.downcase.to_sym][:severities]
y = 0
projects.map do |project|
y_offset = 15 # Start below where the project name would be
svg = ""
severities_included_in_grade.each do |severity|
count = project['vulnerabilitySeveritiesCount'][severity]
next if count == 0
count_text = "#{count} #{severity}"
severity_css = "severity-count grade-#{letter_grade.downcase}"
svg += "<text x=\"0\" y=\"#{y + y_offset}\" class=\"#{severity_css}\">#{count_text}</text>"
y_offset += 15
end
y += 45 # Move to next project position
svg
end.join
end
def collapsed_grade_svg(grade, current_svg_y)
count = grade[:count]
letter_grade = grade[:letter]
<<~SVG
<g transform="translate(0, #{current_svg_y})">
<rect x="0" y="0" width="#{@pdf.bounds.width}" height="40" fill="#ffffff" stroke="#e5e7eb" stroke-width="1"/>
<rect x="0" y="0" width="#{@pdf.bounds.width}" height="40" fill="#f9fafb"/>
<text x="20" y="25" class="grade-letter grade-#{letter_grade.downcase}">#{letter_grade}</text>
<text x="50" y="25" class="project-count">#{count}</text>
</g>
SVG
end
def render_project_names
@project_text_positions.each do |pos|
project_name = pos[:project]['nameWithNamespace']
dashboard_link = @gitlab_host_url + pos[:project]['securityDashboardPath']
@pdf.formatted_text_box(
[{ text: project_name, color: '2563eb', link: dashboard_link }],
at: [20, @pdf.bounds.top - pos[:y]],
width: @pdf.bounds.width - 40,
height: 15,
overflow: :ellipsis,
single_line: true,
size: 12
)
end
end
def process_raw(data)
grades = data.present? ? data[:vulnerability_grades] : []
return if grades.blank?
grade_order = %w[F D C B A]
grades = grades.sort_by { |g| g[:grade] }.reverse!
grade_order.map! do |letter_grade|
if letter_grade == grades.first&.fetch(:grade)
grade = grades.shift
{
letter: grade['grade'],
count: "#{grade['count']} projects",
projects: sort_projects(grade.dig('projects', 'nodes'))
}
else
{ letter: letter_grade, count: DEFAULT_COUNTS, projects: [] }
end
end
grade_order
end
# TODO: Once the below issue is resolved, we can likely delete
# this sorting as the projects should arrive to us sorted:
# https://gitlab.com/gitlab-org/gitlab/-/issues/545479
def sort_projects(projects)
return projects if projects.blank?
projects.sort_by do |project|
severities = project["vulnerabilitySeveritiesCount"]
[
-severities["critical"],
-severities["high"],
-severities["medium"],
-severities["low"],
-severities["info"],
-severities["unknown"]
]
end.first(5)
end
def severity_css(letter_grade)
"severity-count grade-#{letter_grade.downcase}"
end
end
end
end
end

View File

@ -48217,6 +48217,9 @@ msgstr ""
msgid "Project ID"
msgstr ""
msgid "Project Security Status"
msgstr ""
msgid "Project Status"
msgstr ""
@ -68655,6 +68658,9 @@ msgstr ""
msgid "Vulnerabilities"
msgstr ""
msgid "Vulnerabilities Over Time"
msgstr ""
msgid "Vulnerabilities over time"
msgstr ""

View File

@ -16,7 +16,7 @@ gem 'rest-client', '~> 2.1.0'
gem 'rspec_junit_formatter', '~> 0.6.0'
gem 'faker', '~> 3.5', '>= 3.5.2'
gem 'knapsack', '~> 4.0'
gem 'parallel_tests', '~> 5.1'
gem 'parallel_tests', '~> 5.3'
gem 'rotp', '~> 6.3.0'
gem 'parallel', '~> 1.27'
gem 'rainbow', '~> 3.1.1'
@ -32,7 +32,7 @@ gem 'fog-google', '~> 1.25', require: false
gem "warning", "~> 1.5"
# dependencies for jenkins client
gem 'nokogiri', '~> 1.18', '>= 1.18.1'
gem 'nokogiri', '~> 1.18', '>= 1.18.8'
gem 'deprecation_toolkit', '~> 2.2.3', require: false

View File

@ -231,7 +231,7 @@ GEM
net-http (0.4.1)
uri
netrc (0.11.0)
nokogiri (1.18.1)
nokogiri (1.18.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
octokit (9.2.0)
@ -239,7 +239,7 @@ GEM
sawyer (~> 0.9)
os (1.1.4)
parallel (1.27.0)
parallel_tests (5.1.0)
parallel_tests (5.3.0)
parallel
parser (3.3.7.0)
ast (~> 2.4.1)
@ -390,10 +390,10 @@ DEPENDENCIES
influxdb-client (~> 3.2)
junit_merge (~> 0.1.2)
knapsack (~> 4.0)
nokogiri (~> 1.18, >= 1.18.1)
nokogiri (~> 1.18, >= 1.18.8)
octokit (~> 9.2.0)
parallel (~> 1.27)
parallel_tests (~> 5.1)
parallel_tests (~> 5.3)
pry-byebug (~> 3.11.0)
rainbow (~> 3.1.1)
rake (~> 13, >= 13.3.0)

View File

@ -2,7 +2,7 @@
require "spec_helper"
RSpec.describe "User creates issue", feature_category: :team_planning do
RSpec.describe "User creates issue", :js, feature_category: :team_planning do
include DropzoneHelper
include ListboxHelpers
@ -23,16 +23,12 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
sign_out(:user)
end
it "redirects to signin then back to new issue after signin", :js do
it "redirects to signin then back to new issue after signin" do
create(:issue, project: project)
visit project_issues_path(project)
wait_for_all_requests
page.within ".nav-controls" do
click_link "New issue"
end
click_link 'New item'
expect(page).to have_current_path new_user_session_path, ignore_query: true
@ -42,7 +38,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
context "when signed in as guest", :js do
context "when signed in as guest" do
before do
project.add_guest(user)
sign_in(user)
@ -114,7 +110,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
context 'with due date', :js do
context 'with due date' do
it 'saves with due date' do
visit(new_project_issue_path(project))
@ -134,7 +130,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
context 'dropzone upload file', :js do
context 'dropzone upload file' do
before do
visit new_project_issue_path(project)
end
@ -208,7 +204,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
context 'suggestions', :js do
context 'suggestions' do
it 'displays list of related issues' do
visit(new_project_issue_path(project))
@ -224,7 +220,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
it 'clears local storage after creating a new issue', :js do
it 'clears local storage after creating a new issue' do
2.times do
visit new_project_issue_path(project)
@ -238,7 +234,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
it 'clears local storage after cancelling a new issue creation', :js do
it 'clears local storage after cancelling a new issue creation' do
2.times do
visit new_project_issue_path(project)
@ -254,7 +250,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
context 'when signed in as reporter', :js do
context 'when signed in as reporter' do
let_it_be(:project) { create(:project) }
before_all do
@ -279,7 +275,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
end
end
context 'when signed in as a maintainer', :js do
context 'when signed in as a maintainer' do
let_it_be(:project) { create(:project) }
before_all do
@ -310,7 +306,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do
visit(new_project_issue_path(project))
end
it "will correctly escape user names with an apostrophe when clicking 'Assign to me'", :js do
it "will correctly escape user names with an apostrophe when clicking 'Assign to me'" do
click_button 'assign yourself'
expect(page).to have_content(user_special.name)

View File

@ -67,6 +67,23 @@ RSpec.describe 'Sandboxed Mermaid rendering', :js, feature_category: :markdown d
end
end
context 'in a project home page' do
let!(:wiki) { create(:project_wiki, project: project) }
let!(:wiki_page) { create(:wiki_page, wiki: wiki, title: 'home', content: description) }
before do
project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
end
it 'includes mermaid frame correctly' do
visit(project_path(project))
wait_for_requests
expect(page.html).to include(expected)
end
end
context 'in a group milestone' do
let(:group_milestone) { create(:group_milestone, description: description) }

View File

@ -23,7 +23,7 @@ exports[`Code navigation popover component renders popover 1`] = `
>
<div>
<pre
class="bg-transparent border-0 code highlight m-0 text-wrap"
class="bg-transparent border-0 code code-syntax-highlight-theme highlight m-0 text-wrap"
>
<span
class="line"

View File

@ -40,6 +40,7 @@ import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_wit
import IssuesListApp from '~/issues/list/components/issues_list_app.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
import CreateWorkItemModal from '~/work_items/components/create_work_item_modal.vue';
import WorkItemDrawer from '~/work_items/components/work_item_drawer.vue';
import {
CREATED_DESC,
@ -171,6 +172,7 @@ describe('CE IssuesListApp component', () => {
const mockIssuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse);
const mockIssuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse);
const findCreateWorkItemModal = () => wrapper.findComponent(CreateWorkItemModal);
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
@ -178,7 +180,7 @@ describe('CE IssuesListApp component', () => {
const findGlButtons = () => wrapper.findAllComponents(GlButton);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findListViewTypeBtn = () => wrapper.findByTestId('list-view-type');
const findGridtViewTypeBtn = () => wrapper.findByTestId('grid-view-type');
const findGridViewTypeBtn = () => wrapper.findByTestId('grid-view-type');
const findViewTypeLocalStorageSync = () => wrapper.findAllComponents(LocalStorageSync).at(0);
const findNewResourceDropdown = () => wrapper.findComponent(NewResourceDropdown);
const findCalendarButton = () => wrapper.findByTestId('subscribe-calendar');
@ -376,6 +378,17 @@ describe('CE IssuesListApp component', () => {
});
});
describe('create modal', () => {
it.each([true, false])(
'renders depending on whether issuesListCreateModal=%s',
(issuesListCreateModal) => {
wrapper = mountComponent({ provide: { glFeatures: { issuesListCreateModal } } });
expect(findCreateWorkItemModal().exists()).toBe(issuesListCreateModal);
},
);
});
describe('new issue button', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: true }, mountFn: mount });
@ -389,6 +402,12 @@ describe('CE IssuesListApp component', () => {
expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0);
});
it('does not render when `issuesListCreateModal` is enabled', () => {
wrapper = mountComponent({ provide: { glFeatures: { issuesListCreateModal: true } } });
expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0);
});
});
describe('new issue split dropdown', () => {
@ -403,6 +422,14 @@ describe('CE IssuesListApp component', () => {
expect(findNewResourceDropdown().exists()).toBe(true);
});
it('does not render when `issuesListCreateModal` is enabled', () => {
wrapper = mountComponent({
provide: { isProject: false, glFeatures: { issuesListCreateModal: true } },
});
expect(findNewResourceDropdown().exists()).toBe(false);
});
});
});
@ -424,7 +451,7 @@ describe('CE IssuesListApp component', () => {
});
it('switch between list and grid', async () => {
findGridtViewTypeBtn().vm.$emit('click');
findGridViewTypeBtn().vm.$emit('click');
await nextTick();
expect(findIssuableList().props('isGridView')).toBe(true);

View File

@ -8,7 +8,7 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = `
Preview
</label>
<table
class="code"
class="code code-syntax-highlight-theme"
>
<tbody>
<tr

View File

@ -3,10 +3,6 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import syntaxHighlight from '~/syntax_highlight';
describe('Syntax Highlighter', () => {
const stubUserColorScheme = (value) => {
window.gon.user_color_scheme = value;
};
// We have to bind `document.querySelectorAll` to `document` to not mess up the fn's context
describe.each`
desc | fn
@ -24,10 +20,9 @@ describe('Syntax Highlighter', () => {
});
it('applies syntax highlighting', () => {
stubUserColorScheme('monokai');
syntaxHighlight(fn('.js-syntax-highlight'));
expect(fn('.js-syntax-highlight')).toHaveClass('monokai');
expect(fn('.js-syntax-highlight')).toHaveClass('code-syntax-highlight-theme');
});
});
@ -43,13 +38,12 @@ describe('Syntax Highlighter', () => {
});
it('applies highlighting to all applicable children', () => {
stubUserColorScheme('monokai');
syntaxHighlight(fn('.parent'));
expect(fn('.parent')).not.toHaveClass('monokai');
expect(fn('.foo')).not.toHaveClass('monokai');
expect(fn('.parent')).not.toHaveClass('code-syntax-highlight-theme');
expect(fn('.foo')).not.toHaveClass('code-syntax-highlight-theme');
expect(document.querySelectorAll('.monokai').length).toBe(2);
expect(document.querySelectorAll('.code-syntax-highlight-theme').length).toBe(2);
});
it('prevents an infinite loop when no matches exist', () => {

View File

@ -3,7 +3,7 @@
exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = `
<div>
<div
class="code file-content gl-flex js-syntax-highlight"
class="code code-syntax-highlight-theme file-content gl-flex js-syntax-highlight"
>
<div
class="!gl-px-0 line-numbers"

View File

@ -17,58 +17,58 @@ describe('Code Block', () => {
createComponent({}, { default: 'DEFAULT SLOT' });
expect(wrapper.element).toMatchInlineSnapshot(`
<pre
class="code code-block rounded"
>
DEFAULT SLOT
</pre>
`);
<pre
class="code code-block code-syntax-highlight-theme rounded"
>
DEFAULT SLOT
</pre>
`);
});
it('renders with empty code prop', () => {
createComponent({});
expect(wrapper.element).toMatchInlineSnapshot(`
<pre
class="code code-block rounded"
>
<code
class="gl-block"
/>
</pre>
`);
<pre
class="code code-block code-syntax-highlight-theme rounded"
>
<code
class="gl-block"
/>
</pre>
`);
});
it('renders code prop when provided', () => {
createComponent({ code });
expect(wrapper.element).toMatchInlineSnapshot(`
<pre
class="code code-block rounded"
>
<code
class="gl-block"
>
test-code
</code>
</pre>
`);
<pre
class="code code-block code-syntax-highlight-theme rounded"
>
<code
class="gl-block"
>
test-code
</code>
</pre>
`);
});
it('sets maxHeight properly when provided', () => {
createComponent({ code, maxHeight: '200px' });
expect(wrapper.element).toMatchInlineSnapshot(`
<pre
class="code code-block rounded"
style="max-height: 200px; overflow-y: auto;"
>
<code
class="gl-block"
>
test-code
</code>
</pre>
`);
<pre
class="code code-block code-syntax-highlight-theme rounded"
style="max-height: 200px; overflow-y: auto;"
>
<code
class="gl-block"
>
test-code
</code>
</pre>
`);
});
});

View File

@ -78,6 +78,10 @@ describe('CreateWorkItemModal', () => {
});
};
beforeEach(() => {
gon.current_user_id = 1;
});
afterEach(() => {
localStorage.clear();
});
@ -95,6 +99,26 @@ describe('CreateWorkItemModal', () => {
expect(findForm().props('preselectedWorkItemType')).toBe(WORK_ITEM_TYPE_NAME_ISSUE);
});
it('renders create-work-item component with preselectedWorkItemType prop set from localStorage draft with related item id', async () => {
localStorage.setItem(
'autosave/new-full-path-related-22-widgets-draft',
JSON.stringify({ TYPE: { name: WORK_ITEM_TYPE_NAME_ISSUE } }),
);
createComponent({
relatedItem: {
id: 'gid://gitlab/WorkItem/22',
type: 'Issue',
reference: 'full-path#22',
webUrl: '/full-path/-/issues/22',
},
});
await waitForPromises();
expect(findForm().props('preselectedWorkItemType')).toBe(WORK_ITEM_TYPE_NAME_ISSUE);
});
it('shows toast on workItemCreated', async () => {
createComponent();
@ -134,6 +158,19 @@ describe('CreateWorkItemModal', () => {
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
it('does not open modal or prevent link default when user is signed out', async () => {
window.gon = { current_user_id: undefined };
createComponent();
await waitForPromises();
const mockEvent = { preventDefault: jest.fn(), ctrlKey: true };
findTrigger().vm.$emit('click', mockEvent);
await nextTick();
expect(findCreateModal().props('visible')).toBe(false);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
it('does not render when hideButton=true', () => {
createComponent({ hideButton: true });

View File

@ -35,6 +35,7 @@ import { setNewWorkItemCache } from '~/work_items/graphql/cache_utils';
import { updateDraftWorkItemType } from '~/work_items/utils';
import namespaceWorkItemTypesQuery from '~/work_items/graphql/namespace_work_item_types.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import updateNewWorkItemMutation from '~/work_items/graphql/update_new_work_item.mutation.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import { resolvers } from '~/graphql_shared/issuable_client';
import setWindowLocation from 'helpers/set_window_location_helper';
@ -73,6 +74,12 @@ describe('Create work item component', () => {
const namespaceWorkItemTypes =
namespaceWorkItemTypesQueryResponse.data.workspace.workItemTypes.nodes;
const { webUrl: namespaceWebUrl } = namespaceWorkItemTypesQueryResponse.data.workspace;
const mockRelatedItem = {
id: 'gid://gitlab/WorkItem/22',
type: 'Issue',
reference: 'full-path#22',
webUrl: '/full-path/-/issues/22',
};
const findFormTitle = () => wrapper.find('h1');
const findAlert = () => wrapper.findComponent(GlAlert);
@ -174,7 +181,11 @@ describe('Create work item component', () => {
describe('Default', () => {
beforeEach(async () => {
createComponent();
createComponent({
props: {
relatedItem: mockRelatedItem,
},
});
await waitForPromises();
});
@ -183,6 +194,35 @@ describe('Create work item component', () => {
expect(findAlert().exists()).toBe(false);
});
it('calls `updateNewWorkItemMutation` mutation when any widget emits `updateWidgetDraft` event', () => {
jest.spyOn(mockApollo.defaultClient, 'mutate');
const mockInput = {
workItemType: 'Issue',
fullPath: 'full-path',
assignees: [
{
__typename: 'CurrentUser',
id: 'gid://gitlab/User/1',
name: 'Administrator',
username: 'root',
webUrl: 'http://127.0.0.1:3000/root',
webPath: '/root',
},
],
};
findAssigneesWidget().vm.$emit('updateWidgetDraft', mockInput);
expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({
mutation: updateNewWorkItemMutation,
variables: {
input: {
...mockInput,
relatedItemId: mockRelatedItem.id,
},
},
});
});
it('emits "confirmCancel" event on Cancel button click if form is filled', async () => {
await updateWorkItemTitle();
findCancelButton().vm.$emit('click');
@ -233,7 +273,12 @@ describe('Create work item component', () => {
const expectedWorkItemTypeData = namespaceWorkItemTypes.find(
({ name }) => name === workItemType,
);
createComponent({ props: { preselectedWorkItemType: workItemType } });
createComponent({
props: {
preselectedWorkItemType: workItemType,
relatedItem: mockRelatedItem,
},
});
await waitForPromises();
findCancelButton().vm.$emit('click');
@ -245,6 +290,7 @@ describe('Create work item component', () => {
workItemType: expectedWorkItemTypeData.name,
workItemTypeId: expectedWorkItemTypeData.id,
workItemTypeIconName: expectedWorkItemTypeData.iconName,
relatedItemId: mockRelatedItem.id,
});
},
);
@ -367,7 +413,7 @@ describe('Create work item component', () => {
});
it('sets new work item cache and emits changeType on select', async () => {
createComponent({ props: { preselectedWorkItemType: null } });
createComponent({ props: { preselectedWorkItemType: null, relatedItem: mockRelatedItem } });
await waitForPromises();
const mockId = 'Issue';
@ -380,13 +426,14 @@ describe('Create work item component', () => {
workItemType: mockId,
workItemTypeId: 'gid://gitlab/WorkItems::Type/1',
workItemTypeIconName: 'issue-type-issue',
relatedItemId: mockRelatedItem.id,
});
expect(wrapper.emitted('changeType')).toBeDefined();
});
it('sets selected work item type in localStorage draft', async () => {
createComponent({ props: { preselectedWorkItemType: null } });
createComponent({ props: { preselectedWorkItemType: null, relatedItem: mockRelatedItem } });
await waitForPromises();
const mockId = 'Issue';
@ -395,6 +442,7 @@ describe('Create work item component', () => {
expect(updateDraftWorkItemType).toHaveBeenCalledWith({
fullPath: 'full-path',
relatedItemId: mockRelatedItem.id,
workItemType: {
id: 'gid://gitlab/WorkItems::Type/1',
name: mockId,
@ -902,7 +950,11 @@ describe('Create work item component', () => {
</div>
</div>`);
createComponent();
createComponent({
props: {
relatedItem: mockRelatedItem,
},
});
await waitForPromises();
expect(setNewWorkItemCache).toHaveBeenCalledWith({
@ -917,6 +969,7 @@ describe('Create work item component', () => {
a
description!`,
confidential: false,
relatedItemId: mockRelatedItem.id,
});
});
});

View File

@ -124,67 +124,130 @@ describe('WorkItemBulkEditSidebar component', () => {
});
describe('when not epics list', () => {
it('makes POST request to bulk edit', async () => {
const issuable_ids = '11,22'; // eslint-disable-line camelcase
const add_label_ids = [1, 2, 3]; // eslint-disable-line camelcase
const assignee_ids = [5]; // eslint-disable-line camelcase
const confidential = 'true';
const health_status = 'on_track'; // eslint-disable-line camelcase
const remove_label_ids = [4, 5, 6]; // eslint-disable-line camelcase
const state_event = 'reopen'; // eslint-disable-line camelcase
const subscription_event = 'subscribe'; // eslint-disable-line camelcase
axiosMock.onPost().replyOnce(HTTP_STATUS_OK);
createComponent({
provide: { hasIssuableHealthStatusFeature: true },
props: { isEpicsList: false },
describe('when work_items_bulk_edit is enabled', () => {
it('calls mutation to bulk edit', async () => {
const addLabelIds = ['gid://gitlab/Label/1'];
const removeLabelIds = ['gid://gitlab/Label/2'];
createComponent({
provide: {
hasIssuableHealthStatusFeature: true,
glFeatures: { workItemsBulkEdit: true },
},
props: { isEpicsList: false },
});
await waitForPromises();
findAssigneeComponent().vm.$emit('input', 'gid://gitlab/User/5');
findAddLabelsComponent().vm.$emit('select', addLabelIds);
findRemoveLabelsComponent().vm.$emit('select', removeLabelIds);
findHealthStatusComponent().vm.$emit('input', 'on_track');
findConfidentialityComponent().vm.$emit('input', 'true');
findForm().vm.$emit('submit', { preventDefault: () => {} });
expect(workItemBulkUpdateHandler).toHaveBeenCalledWith({
input: {
parentId: 'gid://gitlab/Group/1',
ids: ['gid://gitlab/WorkItem/11', 'gid://gitlab/WorkItem/22'],
labelsWidget: {
addLabelIds,
removeLabelIds,
},
assigneesWidget: {
assigneeIds: ['gid://gitlab/User/5'],
},
confidential: true,
healthStatusWidget: {
healthStatus: 'onTrack',
},
},
});
expect(findAddLabelsComponent().props('selectedLabelsIds')).toEqual([]);
expect(findRemoveLabelsComponent().props('selectedLabelsIds')).toEqual([]);
});
findStateComponent().vm.$emit('input', state_event);
findAssigneeComponent().vm.$emit('input', 'gid://gitlab/User/5');
findAddLabelsComponent().vm.$emit('select', [
'gid://gitlab/Label/1',
'gid://gitlab/Label/2',
'gid://gitlab/Label/3',
]);
findRemoveLabelsComponent().vm.$emit('select', [
'gid://gitlab/Label/4',
'gid://gitlab/Label/5',
'gid://gitlab/Label/6',
]);
findHealthStatusComponent().vm.$emit('input', health_status);
findSubscriptionComponent().vm.$emit('input', subscription_event);
findConfidentialityComponent().vm.$emit('input', confidential);
findForm().vm.$emit('submit', { preventDefault: () => {} });
await waitForPromises();
it('renders error when there is a mutation error', async () => {
createComponent({
props: { isEpicsList: true },
mutationHandler: jest.fn().mockRejectedValue(new Error('oh no')),
});
expect(axiosMock.history.post[0].url).toBe('/group/project/-/issues/bulk_update');
expect(axiosMock.history.post[0].data).toBe(
JSON.stringify({
update: {
add_label_ids,
assignee_ids,
confidential,
health_status,
issuable_ids,
remove_label_ids,
state_event,
subscription_event,
},
}),
);
findForm().vm.$emit('submit', { preventDefault: () => {} });
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({
captureError: true,
error: new Error('oh no'),
message: 'Something went wrong while bulk editing.',
});
});
});
it('renders error when there is a response error', async () => {
axiosMock.onPost().replyOnce(HTTP_STATUS_BAD_REQUEST);
createComponent({ props: { isEpicsList: false } });
describe('when work_items_bulk_edit is disabled', () => {
it('makes POST request to bulk edit', async () => {
const issuable_ids = '11,22'; // eslint-disable-line camelcase
const add_label_ids = [1, 2, 3]; // eslint-disable-line camelcase
const assignee_ids = [5]; // eslint-disable-line camelcase
const confidential = 'true';
const health_status = 'on_track'; // eslint-disable-line camelcase
const remove_label_ids = [4, 5, 6]; // eslint-disable-line camelcase
const state_event = 'reopen'; // eslint-disable-line camelcase
const subscription_event = 'subscribe'; // eslint-disable-line camelcase
axiosMock.onPost().replyOnce(HTTP_STATUS_OK);
createComponent({
provide: {
hasIssuableHealthStatusFeature: true,
glFeatures: { workItemsBulkEdit: false },
},
props: { isEpicsList: false },
});
findForm().vm.$emit('submit', { preventDefault: () => {} });
await waitForPromises();
findStateComponent().vm.$emit('input', state_event);
findAssigneeComponent().vm.$emit('input', 'gid://gitlab/User/5');
findAddLabelsComponent().vm.$emit('select', [
'gid://gitlab/Label/1',
'gid://gitlab/Label/2',
'gid://gitlab/Label/3',
]);
findRemoveLabelsComponent().vm.$emit('select', [
'gid://gitlab/Label/4',
'gid://gitlab/Label/5',
'gid://gitlab/Label/6',
]);
findHealthStatusComponent().vm.$emit('input', health_status);
findSubscriptionComponent().vm.$emit('input', subscription_event);
findConfidentialityComponent().vm.$emit('input', confidential);
findForm().vm.$emit('submit', { preventDefault: () => {} });
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({
captureError: true,
error: new Error('Request failed with status code 400'),
message: 'Something went wrong while bulk editing.',
expect(axiosMock.history.post[0].url).toBe('/group/project/-/issues/bulk_update');
expect(axiosMock.history.post[0].data).toBe(
JSON.stringify({
update: {
add_label_ids,
assignee_ids,
confidential,
health_status,
issuable_ids,
remove_label_ids,
state_event,
subscription_event,
},
}),
);
});
it('renders error when there is a response error', async () => {
axiosMock.onPost().replyOnce(HTTP_STATUS_BAD_REQUEST);
createComponent({ props: { isEpicsList: false } });
findForm().vm.$emit('submit', { preventDefault: () => {} });
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({
captureError: true,
error: new Error('Request failed with status code 400'),
message: 'Something went wrong while bulk editing.',
});
});
});
});

View File

@ -316,7 +316,7 @@ describe('Create work item page component', () => {
expect(visitUrl).toHaveBeenCalledWith('/gitlab-org/gitlab-test/-/issues');
});
it('confirmation modal closes when user clicks "Discard changes" and redirects to list page when on group `work_items/new` route', async () => {
it('confirmation modal closes when user clicks "Discard changes" and redirects to issues list page when on group `work_items/new` route', async () => {
const historyMock = {
base: '/groups/gitlab-org/-',
current: {
@ -342,7 +342,7 @@ describe('Create work item page component', () => {
await nextTick();
expect(findCancelConfirmationModal().props('isVisible')).toBe(false);
expect(visitUrl).toHaveBeenCalledWith('/groups/gitlab-org/-/work_items');
expect(visitUrl).toHaveBeenCalledWith('/groups/gitlab-org/-/issues');
});
it('confirmation modal closes when user clicks "Discard changes" and redirects to list page when on group `epics/new` route', async () => {

View File

@ -47,6 +47,7 @@ import {
getNewWorkItemWidgetsAutoSaveKey,
getWorkItemWidgets,
updateDraftWorkItemType,
getDraftWorkItemType,
} from '~/work_items/utils';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { TYPE_EPIC } from '~/issues/constants';
@ -264,6 +265,16 @@ describe('newWorkItemPath', () => {
'/foobar/project/-/work_items/new?foo=bar',
);
});
it('returns `work_items` path for group issues', () => {
expect(
newWorkItemPath({
fullPath: 'my-group',
isGroup: true,
workItemType: WORK_ITEM_TYPE_NAME_ISSUE,
}),
).toBe('/foobar/groups/my-group/-/work_items/new');
});
});
describe('convertTypeEnumToName', () => {
@ -451,6 +462,16 @@ describe('getNewWorkItemAutoSaveKey', () => {
expect(autosaveKey).toEqual(expectedAutosaveKey);
},
);
it('returns autosave key for new related item', () => {
const autosaveKey = getNewWorkItemAutoSaveKey({
fullPath: 'gitlab-org/gitlab',
workItemType: 'issue',
relatedItemId: 'gid://gitlab/WorkItem/22',
});
expect(autosaveKey).toEqual('new-gitlab-org/gitlab-issue-related-22-draft');
});
});
describe('getNewWorkItemWidgetsAutoSaveKey', () => {
@ -460,6 +481,15 @@ describe('getNewWorkItemWidgetsAutoSaveKey', () => {
});
expect(autosaveKey).toEqual('new-gitlab-org/gitlab-widgets-draft');
});
it('returns autosave key for new related item', () => {
const autosaveKey = getNewWorkItemWidgetsAutoSaveKey({
fullPath: 'gitlab-org/gitlab',
relatedItemId: 'gid://gitlab/WorkItem/22',
});
expect(autosaveKey).toEqual('new-gitlab-org/gitlab-related-22-widgets-draft');
});
});
describe('getWorkItemWidgets', () => {
@ -520,6 +550,53 @@ describe('updateDraftWorkItemType', () => {
JSON.stringify({ TITLE: 'Some work item', TYPE: workItemType }),
);
});
it('updates `TYPE` with workItemType to localStorage widgets for related item drafts key when it already exists', () => {
const workItemWidgetsKey = 'autosave/new-gitlab-org/gitlab-related-22-widgets-draft';
localStorage.setItem(workItemWidgetsKey, JSON.stringify({ TITLE: 'Some work item' }));
updateDraftWorkItemType({
fullPath: 'gitlab-org/gitlab',
relatedItemId: 'gid://gitlab/WorkItem/22',
workItemType,
});
expect(localStorage.setItem).toHaveBeenCalledWith(
workItemWidgetsKey,
JSON.stringify({ TITLE: 'Some work item', TYPE: workItemType }),
);
});
});
describe('getDraftWorkItemType', () => {
afterEach(() => {
localStorage.clear();
});
it('gets `TYPE` from localStorage widgets draft when it exists', () => {
localStorage.setItem(
'autosave/new-gitlab-org/gitlab-widgets-draft',
JSON.stringify({ TYPE: 'Issue' }),
);
const workItemType = getDraftWorkItemType({
fullPath: 'gitlab-org/gitlab',
});
expect(workItemType).toBe('Issue');
});
it('gets `TYPE` from localStorage widgets for related item draft when it exists', () => {
localStorage.setItem(
'autosave/new-gitlab-org/gitlab-related-22-widgets-draft',
JSON.stringify({ TYPE: 'Issue' }),
);
const workItemType = getDraftWorkItemType({
fullPath: 'gitlab-org/gitlab',
relatedItemId: 'gid://gitlab/WorkItem/22',
});
expect(workItemType).toBe('Issue');
});
});
describe('`getItems`', () => {

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::Builds::TimeoutCalculator, feature_category: :continuous_integration do
using RSpec::Parameterized::TableSyntax
let_it_be(:project) { FactoryBot.build(:project) }
let_it_be(:runner) { FactoryBot.build(:ci_runner) }
let_it_be(:build) { FactoryBot.build(:ci_build, project: project, runner: runner) }
let(:calculator) { described_class.new(build) }
describe '#applicable_timeout' do
where(:job_timeout, :project_timeout, :runner_timeout, :result_value, :result_source) do
100 | 200 | 300 | 100 | :job_timeout_source
100 | nil | 300 | 100 | :job_timeout_source
100 | 50 | 300 | 100 | :job_timeout_source
100 | 50 | nil | 100 | :job_timeout_source
nil | 200 | 300 | 200 | :project_timeout_source
nil | 200 | nil | 200 | :project_timeout_source
100 | 200 | 50 | 50 | :runner_timeout_source
nil | 200 | 100 | 100 | :runner_timeout_source
nil | nil | 100 | 100 | :runner_timeout_source
nil | nil | nil | nil | nil
end
with_them do
before do
allow(build).to receive(:options).and_return({ job_timeout: job_timeout })
allow(project).to receive(:build_timeout).and_return(project_timeout)
allow(runner).to receive(:maximum_timeout).and_return(runner_timeout)
end
it 'calculates correct timeout' do
result = calculator.applicable_timeout
if result_value.nil? && result_source.nil?
expect(result).to be_nil
else
expect(result).to be_a(Ci::Builds::Timeout)
expect(result.value).to eq(result_value)
expect(result.source).to eq(described_class.timeout_sources.fetch(result_source))
end
end
end
end
end

View File

@ -0,0 +1,131 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::PDF::Security::GroupVulnerabilitiesProjectsGrades, feature_category: :vulnerability_management do
let(:pdf) { Prawn::Document.new }
let(:data) do
{
vulnerability_grades: [
{
grade: "F",
count: 296,
projects: {
nodes: [
{
name: "Oxeye Rulez",
nameWithNamespace: "Gitlab Org / Oxeye Rulez",
securityDashboardPath: "/gitlab-org/oxeye-rulez/-/security/dashboard",
vulnerabilitySeveritiesCount: severities_data(critical: 295, high: 1070)
},
{
name: "Security Reports",
nameWithNamespace: "Gitlab Org / Security Reports",
securityDashboardPath: "/gitlab-org/security-reports/-/security/dashboard",
vulnerabilitySeveritiesCount: severities_data(critical: 1)
}
]
}
},
{
grade: "D",
count: 10,
projects: {
nodes: [
{
name: "Cwe 78 Cwe 89 Tests",
nameWithNamespace: "Gitlab Org / Cwe 78 Cwe 89 Tests",
securityDashboardPath: "/gitlab-org/cwe-78-cwe-89-tests/-/security/dashboard",
vulnerabilitySeveritiesCount: severities_data(high: 10)
},
{ name: "Project 2", nameWithNamespace: "Gitlab Org / Project 2",
vulnerabilitySeveritiesCount: severities_data },
{ name: "Project 3", nameWithNamespace: "Gitlab Org / Project 3",
vulnerabilitySeveritiesCount: severities_data },
{ name: "Project 4", nameWithNamespace: "Gitlab Org / Project 4",
vulnerabilitySeveritiesCount: severities_data },
{ name: "Project 5", nameWithNamespace: "Gitlab Org / Project 5",
vulnerabilitySeveritiesCount: severities_data },
{ name: "Project 6", nameWithNamespace: "Gitlab Org / Project 6",
vulnerabilitySeveritiesCount: severities_data }
]
}
}
],
expanded_grade: "F"
}.with_indifferent_access
end
def severities_data(severities = {})
{ critical: 0, high: 0, info: 0, low: 0, medium: 0, unknown: 0 }
.merge(severities)
end
describe '.render' do
subject(:render) { described_class.render(pdf, data: data) }
let(:mock_instance) { instance_double(described_class) }
before do
allow(mock_instance).to receive(:render)
allow(described_class).to receive(:new).and_return(mock_instance)
end
it 'creates a new instance and calls render on it' do
render
expect(described_class).to have_received(:new).with(pdf, data).once
expect(mock_instance).to have_received(:render).exactly(:once)
end
end
describe '#render' do
subject(:render_grades) { described_class.render(pdf, data: data) }
before do
allow(pdf).to receive(:text_box).and_call_original
allow(pdf).to receive(:svg).and_call_original
end
let(:expected_labels) do
[
s_('Project security status'),
s_('Projects are graded based on the highest severity vulnerability present')
]
end
it 'includes expected text elements' do
render_grades
expected_labels.each do |label|
expect(pdf).to have_received(:text_box).with(label, any_args).once
end
end
it 'renders the SVG table layout' do
render_grades
expect(pdf).to have_received(:svg).with(%r{<svg.*</svg>}m, any_args).at_least(:once)
end
context 'when data is nil' do
let(:data) { nil }
it 'returns :noop without rendering anything' do
expect(render_grades).to eq(:noop)
expect(pdf).not_to have_received(:svg)
expect(pdf).not_to have_received(:text_box)
end
end
context 'when data is blank' do
let(:data) { {} }
it 'returns :noop without rendering anything' do
expect(render_grades).to eq(:noop)
expect(pdf).not_to have_received(:svg)
expect(pdf).not_to have_received(:text_box)
end
end
end
end

View File

@ -29,120 +29,36 @@ RSpec.describe Ci::BuildMetadata, feature_category: :continuous_integration do
describe '#update_timeout_state' do
subject { metadata }
shared_examples 'sets timeout' do |source, timeout|
it 'sets project_timeout_source' do
expect { subject.update_timeout_state }.to change { subject.reload.timeout_source }.to(source)
end
let(:calculator) { instance_double(::Ci::Builds::TimeoutCalculator) }
it 'sets project timeout' do
expect { subject.update_timeout_state }.to change { subject.reload.timeout }.to(timeout)
end
before do
allow(::Ci::Builds::TimeoutCalculator).to receive(:new).with(job).and_return(calculator)
end
context 'when job, project and runner timeouts are set' do
context 'when job timeout is lower then runner timeout' do
before do
runner.update!(maximum_timeout: 4000)
job.update!(options: { job_timeout: 3000 })
end
it_behaves_like 'sets timeout', 'job_timeout_source', 3000
end
context 'when runner timeout is lower then job timeout' do
before do
runner.update!(maximum_timeout: 2000)
job.update!(options: { job_timeout: 3000 })
end
it_behaves_like 'sets timeout', 'runner_timeout_source', 2000
end
end
context 'when job, project timeout values are set and runner is assigned' do
context 'when runner has no timeout set' do
before do
runner.update!(maximum_timeout: nil)
job.update!(options: { job_timeout: 3000 })
end
it_behaves_like 'sets timeout', 'job_timeout_source', 3000
end
end
context 'when only job and project timeouts are defined' do
context 'when job timeout is lower then project timeout' do
before do
job.update!(options: { job_timeout: 1000 })
end
it_behaves_like 'sets timeout', 'job_timeout_source', 1000
end
context 'when project timeout is lower then job timeout' do
before do
job.update!(options: { job_timeout: 3000 })
end
it_behaves_like 'sets timeout', 'job_timeout_source', 3000
end
end
context 'when only project and runner timeouts are defined' do
context 'when no timeouts defined anywhere' do
before do
runner.update!(maximum_timeout: 1900)
allow(calculator).to receive(:applicable_timeout).and_return(nil)
end
context 'when runner timeout is lower then project timeout' do
it_behaves_like 'sets timeout', 'runner_timeout_source', 1900
end
context 'when project timeout is lower then runner timeout' do
before do
runner.update!(maximum_timeout: 2100)
end
it_behaves_like 'sets timeout', 'project_timeout_source', 2000
it 'does not change anything' do
expect { subject.update_timeout_state }
.to not_change { subject.reload.timeout_source }
.and not_change { subject.reload.timeout }
end
end
context 'when only job and runner timeouts are defined' do
context 'when runner timeout is lower them job timeout' do
before do
job.update!(options: { job_timeout: 2000 })
runner.update!(maximum_timeout: 1900)
end
it_behaves_like 'sets timeout', 'runner_timeout_source', 1900
end
context 'when job timeout is lower them runner timeout' do
before do
job.update!(options: { job_timeout: 1000 })
runner.update!(maximum_timeout: 1900)
end
it_behaves_like 'sets timeout', 'job_timeout_source', 1000
end
end
context 'when only job timeout is defined and runner is assigned, but has no timeout set' do
context 'when at least a timeout is defined' do
before do
job.update!(options: { job_timeout: 1000 })
runner.update!(maximum_timeout: nil)
allow(calculator)
.to receive(:applicable_timeout)
.and_return(
::Ci::Builds::Timeout.new(25, ::Ci::BuildMetadata.timeout_sources.fetch(:job_timeout_source)))
end
it_behaves_like 'sets timeout', 'job_timeout_source', 1000
end
context 'when only one timeout value is defined' do
context 'when only project timeout value is defined' do
before do
job.update!(options: { job_timeout: nil })
runner.update!(maximum_timeout: nil)
end
it_behaves_like 'sets timeout', 'project_timeout_source', 2000
it 'sets the timeout' do
expect { subject.update_timeout_state }
.to change { subject.reload.timeout_source }.to('job_timeout_source')
.and change { subject.reload.timeout }.to(25)
end
end
end

View File

@ -4,10 +4,9 @@ RSpec.shared_examples 'code highlight' do
include PreferencesHelper
let_it_be(:current_user) { user }
let_it_be(:scheme_class) { user_color_scheme }
it 'has highlighted code', :js do
wait_for_requests
expect(subject).to have_selector(".js-syntax-highlight.#{scheme_class}")
expect(subject).to have_selector(".js-syntax-highlight")
end
end

View File

@ -19,13 +19,13 @@ RSpec.shared_examples 'rich text editor - code blocks' do
end
it 'applies theme classes to code blocks' do
expect(page).not_to have_css('.content-editor-code-block.code.highlight.dark')
expect(page).not_to have_css('.content-editor-code-block.code.highlight.code-syntax-highlight-theme')
type_in_content_editor [:enter, :enter]
type_in_content_editor '```js ' # trigger input rule
type_in_content_editor 'var a = 0'
expect(page).to have_css('.content-editor-code-block.code.highlight.dark')
expect(page).to have_css('.content-editor-code-block.code.highlight.code-syntax-highlight-theme')
end
end