Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-04-09 21:14:45 +00:00
parent ed79d7cc5b
commit ef69661413
43 changed files with 967 additions and 222 deletions

View File

@ -292,7 +292,6 @@ export default {
'app/assets/javascripts/work_items/components/work_item_detail_modal.vue',
'app/assets/javascripts/work_items/components/work_item_development/work_item_create_branch_merge_request_modal.vue',
'app/assets/javascripts/work_items/components/work_item_development/work_item_development_mr_item.vue',
'app/assets/javascripts/work_items/components/work_item_labels.vue',
'app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue',
'app/assets/javascripts/work_items/components/work_item_links/work_item_groups_listbox.vue',
'app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue',

View File

@ -401,7 +401,7 @@ gem 'sentry-sidekiq', '~> 5.22.0', feature_category: :observability
# PostgreSQL query parsing
#
gem 'pg_query', '~> 6.0.0', feature_category: :database
gem 'pg_query', '~> 6.1.0', feature_category: :database
gem 'gitlab-schema-validation', path: 'gems/gitlab-schema-validation', feature_category: :shared
gem 'gitlab-http', path: 'gems/gitlab-http', feature_category: :shared
@ -521,7 +521,7 @@ group :development, :test do
# Prevent occasions where minitest is not bundled in packaged versions of ruby (see #3826)
gem 'minitest', '~> 5.11.0', feature_category: :shared
gem 'spring', '~> 4.1.0', feature_category: :shared
gem 'spring', '~> 4.3.0', feature_category: :shared
gem 'spring-commands-rspec', '~> 1.0.4', feature_category: :shared
gem 'gitlab-styles', '~> 13.1.0', feature_category: :tooling, require: false

View File

@ -513,7 +513,7 @@
{"name":"pg","version":"1.5.9","platform":"x64-mingw-ucrt","checksum":"9d9d6a4fcc25251312065b61f94eb56c5266007c0e747606704641d47b92c5eb"},
{"name":"pg","version":"1.5.9","platform":"x64-mingw32","checksum":"02a682056d3db6677e0ed5b233e69383d20641785ba0123cdf56a5eb8286a013"},
{"name":"pg","version":"1.5.9","platform":"x86-mingw32","checksum":"f32a3cde1018a16f0b3392f654f763fd7ef3f6af4fee008312ad7d3575b3c0ab"},
{"name":"pg_query","version":"6.0.0","platform":"ruby","checksum":"fbf09a4e900cee1d61e2bbfda1fefdbc35bc83c5f1c7ae1be1c6ffc5ae0f5c04"},
{"name":"pg_query","version":"6.1.0","platform":"ruby","checksum":"8b005229e209f12c5887c34c60d0eb2a241953b9475b53a9840d24578532481e"},
{"name":"plist","version":"3.7.0","platform":"ruby","checksum":"703ca90a7cb00e8263edd03da2266627f6741d280c910abbbac07c95ffb2f073"},
{"name":"png_quantizator","version":"0.2.1","platform":"ruby","checksum":"6023d4d064125c3a7e02929c95b7320ed6ac0d7341f9e8de0c9ea6576ef3106b"},
{"name":"premailer","version":"1.23.0","platform":"ruby","checksum":"f0d7f6ba299559c96ddf982aa5263f85e5617c86437c8d8ffff120813b2d7efb"},
@ -701,7 +701,7 @@
{"name":"solargraph","version":"0.47.2","platform":"ruby","checksum":"87ca4b799b9155c2c31c15954c483e952fdacd800f52d6709b901dd447bcac6a"},
{"name":"sorbet-runtime","version":"0.5.11647","platform":"ruby","checksum":"64b65112f2e6a5323310ca9ac0d7d9a6be63aade5a62a6225fe066042ff4fdb6"},
{"name":"spamcheck","version":"1.3.3","platform":"ruby","checksum":"3a29ba9dfcd59543d88054d38c657f79e0a6cf44d763df08ad47680abed50ec7"},
{"name":"spring","version":"4.1.0","platform":"ruby","checksum":"f17f080fb0df558d663c897a6229ed3d5cc54819ab51876ea6eef49a67f0a3cb"},
{"name":"spring","version":"4.3.0","platform":"ruby","checksum":"0aaaf3bcce38e8528275854881d1922660d76cbd19a9a3af4a419d95b7fe7122"},
{"name":"spring-commands-rspec","version":"1.0.4","platform":"ruby","checksum":"6202e54fa4767452e3641461a83347645af478bf45dddcca9737b43af0dd1a2c"},
{"name":"sprite-factory","version":"1.7.1","platform":"ruby","checksum":"5586524a1aec003241f1abc6852b61433e988aba5ee2b55f906387bf49b01ba2"},
{"name":"sprockets","version":"3.7.2","platform":"ruby","checksum":"5ea1d7facd09203c1aa196afd6178208cd25abdbcc2a9978810a2f0754e152a0"},

View File

@ -1451,7 +1451,7 @@ GEM
peek (1.1.0)
railties (>= 4.0.0)
pg (1.5.9)
pg_query (6.0.0)
pg_query (6.1.0)
google-protobuf (>= 3.25.3)
plist (3.7.0)
png_quantizator (0.2.1)
@ -1806,7 +1806,7 @@ GEM
sorbet-runtime (0.5.11647)
spamcheck (1.3.3)
grpc (~> 1.63)
spring (4.1.0)
spring (4.3.0)
spring-commands-rspec (1.0.4)
spring (>= 0.9.1)
sprite-factory (1.7.1)
@ -2262,7 +2262,7 @@ DEPENDENCIES
parslet (~> 1.8)
peek (~> 1.1)
pg (~> 1.5.6)
pg_query (~> 6.0.0)
pg_query (~> 6.1.0)
png_quantizator (~> 0.2.1)
premailer-rails (~> 1.12.0)
prometheus-client-mmap (~> 1.2.8)
@ -2328,7 +2328,7 @@ DEPENDENCIES
snowplow-tracker (~> 0.8.0)
solargraph (~> 0.47.2)
spamcheck (~> 1.3.0)
spring (~> 4.1.0)
spring (~> 4.3.0)
spring-commands-rspec (~> 1.0.4)
sprite-factory (~> 1.7)
sprockets (~> 3.7.0)

View File

@ -516,7 +516,7 @@
{"name":"pg","version":"1.5.9","platform":"x64-mingw-ucrt","checksum":"9d9d6a4fcc25251312065b61f94eb56c5266007c0e747606704641d47b92c5eb"},
{"name":"pg","version":"1.5.9","platform":"x64-mingw32","checksum":"02a682056d3db6677e0ed5b233e69383d20641785ba0123cdf56a5eb8286a013"},
{"name":"pg","version":"1.5.9","platform":"x86-mingw32","checksum":"f32a3cde1018a16f0b3392f654f763fd7ef3f6af4fee008312ad7d3575b3c0ab"},
{"name":"pg_query","version":"6.0.0","platform":"ruby","checksum":"fbf09a4e900cee1d61e2bbfda1fefdbc35bc83c5f1c7ae1be1c6ffc5ae0f5c04"},
{"name":"pg_query","version":"6.1.0","platform":"ruby","checksum":"8b005229e209f12c5887c34c60d0eb2a241953b9475b53a9840d24578532481e"},
{"name":"plist","version":"3.7.0","platform":"ruby","checksum":"703ca90a7cb00e8263edd03da2266627f6741d280c910abbbac07c95ffb2f073"},
{"name":"png_quantizator","version":"0.2.1","platform":"ruby","checksum":"6023d4d064125c3a7e02929c95b7320ed6ac0d7341f9e8de0c9ea6576ef3106b"},
{"name":"pp","version":"0.6.2","platform":"ruby","checksum":"947ec3120c6f92195f8ee8aa25a7b2c5297bb106d83b41baa02983686577b6ff"},
@ -712,7 +712,7 @@
{"name":"solargraph","version":"0.47.2","platform":"ruby","checksum":"87ca4b799b9155c2c31c15954c483e952fdacd800f52d6709b901dd447bcac6a"},
{"name":"sorbet-runtime","version":"0.5.11647","platform":"ruby","checksum":"64b65112f2e6a5323310ca9ac0d7d9a6be63aade5a62a6225fe066042ff4fdb6"},
{"name":"spamcheck","version":"1.3.3","platform":"ruby","checksum":"3a29ba9dfcd59543d88054d38c657f79e0a6cf44d763df08ad47680abed50ec7"},
{"name":"spring","version":"4.1.0","platform":"ruby","checksum":"f17f080fb0df558d663c897a6229ed3d5cc54819ab51876ea6eef49a67f0a3cb"},
{"name":"spring","version":"4.3.0","platform":"ruby","checksum":"0aaaf3bcce38e8528275854881d1922660d76cbd19a9a3af4a419d95b7fe7122"},
{"name":"spring-commands-rspec","version":"1.0.4","platform":"ruby","checksum":"6202e54fa4767452e3641461a83347645af478bf45dddcca9737b43af0dd1a2c"},
{"name":"sprite-factory","version":"1.7.1","platform":"ruby","checksum":"5586524a1aec003241f1abc6852b61433e988aba5ee2b55f906387bf49b01ba2"},
{"name":"sprockets","version":"3.7.2","platform":"ruby","checksum":"5ea1d7facd09203c1aa196afd6178208cd25abdbcc2a9978810a2f0754e152a0"},

View File

@ -1468,7 +1468,7 @@ GEM
peek (1.1.0)
railties (>= 4.0.0)
pg (1.5.9)
pg_query (6.0.0)
pg_query (6.1.0)
google-protobuf (>= 3.25.3)
plist (3.7.0)
png_quantizator (0.2.1)
@ -1839,7 +1839,7 @@ GEM
sorbet-runtime (0.5.11647)
spamcheck (1.3.3)
grpc (~> 1.63)
spring (4.1.0)
spring (4.3.0)
spring-commands-rspec (1.0.4)
spring (>= 0.9.1)
sprite-factory (1.7.1)
@ -2296,7 +2296,7 @@ DEPENDENCIES
parslet (~> 1.8)
peek (~> 1.1)
pg (~> 1.5.6)
pg_query (~> 6.0.0)
pg_query (~> 6.1.0)
png_quantizator (~> 0.2.1)
premailer-rails (~> 1.12.0)
prometheus-client-mmap (~> 1.2.8)
@ -2362,7 +2362,7 @@ DEPENDENCIES
snowplow-tracker (~> 0.8.0)
solargraph (~> 0.47.2)
spamcheck (~> 1.3.0)
spring (~> 4.1.0)
spring (~> 4.3.0)
spring-commands-rspec (~> 1.0.4)
sprite-factory (~> 1.7)
sprockets (~> 3.7.0)

View File

@ -300,7 +300,7 @@ export default {
/>
<div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div>
<div class="gl-flex gl-min-w-0 gl-flex-1 gl-flex-col">
<span class="gl-truncate" data-testid="downstream-title-content">
<span class="gl-whitespace-normal" data-testid="downstream-title-content">
{{ downstreamTitle }}
</span>
<div class="gl-truncate">

View File

@ -7,6 +7,12 @@ const markdownToAst = (markdown) => {
return unified().use(remarkParse).parse(markdown);
};
export const transformQuickActions = (markdown) => {
// ensure 3 newlines after all quick actions so that
// any reference style links after it get correctly parsed
return markdown.replace(/^\/(.+?)\n/gm, '/$1\n\n\n');
};
/**
* Extracts link reference definitions from a markdown string.
* This is useful for preserving reference definitions when
@ -44,21 +50,22 @@ export default ({ render }) => {
* @returns {{ document: ProseMirror.Node }}
*/
deserialize: async ({ schema, markdown }) => {
const html = markdown ? (await render(markdown)).body : '<p></p>';
const transformedMarkdown = transformQuickActions(markdown);
const html = markdown ? (await render(transformedMarkdown)).body : '<p></p>';
const parser = new DOMParser();
const { body } = parser.parseFromString(`<body>${html}</body>`, 'text/html');
replaceCommentsWith(body, 'comment');
// append original source as a comment that nodes can access
body.append(document.createComment(markdown));
body.append(document.createComment(transformedMarkdown));
const doc = ProseMirror.DOMParser.fromSchema(schema).parse(body);
if (preserveMarkdown())
doc.attrs = {
source: markdown,
referenceDefinitions: extractReferenceDefinitions(markdown),
source: transformedMarkdown,
referenceDefinitions: extractReferenceDefinitions(transformedMarkdown),
};
return { document: doc };

View File

@ -1,9 +1,18 @@
<script>
import { GlIcon, GlBadge, GlFormInput, GlButton, GlLink, GlTooltip, GlSprintf } from '@gitlab/ui';
import {
GlIcon,
GlBadge,
GlFormInput,
GlButton,
GlLink,
GlTooltip,
GlSprintf,
GlModal,
} from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import { __ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __, s__ } from '~/locale';
import HelpPageLink from '~/vue_shared/components/help_page_link/help_page_link.vue';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import ImportTargetDropdown from '../../components/import_target_dropdown.vue';
import ImportStatus from '../../components/import_status.vue';
@ -13,6 +22,7 @@ import { isProjectImportable, isImporting, isIncompatible, getImportStatus } fro
export default {
name: 'ProviderRepoTableRow',
components: {
HelpPageLink,
HelpPopover,
ImportStatus,
ImportTargetDropdown,
@ -23,6 +33,7 @@ export default {
GlLink,
GlTooltip,
GlSprintf,
GlModal,
},
inject: {
userNamespace: {
@ -48,6 +59,7 @@ export default {
data() {
return {
isSelectedForReimport: false,
showMembershipsModal: false,
};
},
@ -61,7 +73,7 @@ export default {
showMembershipsWarning() {
const userNamespaceSelected = this.importTarget.targetNamespace === this.userNamespace;
return this.isImportNotStarted && userNamespaceSelected;
return (this.isImportNotStarted || this.isSelectedForReimport) && userNamespaceSelected;
},
isFinished() {
@ -143,15 +155,20 @@ export default {
}
},
onImportClick() {
if (this.showMembershipsWarning) {
this.showMembershipsModal = true;
} else {
this.handleImportRepo();
}
},
onSelect(value) {
this.updateImportTarget({ targetNamespace: value });
},
},
helpPath: helpPagePath('/user/project/import/github'),
membershipsHelpPath: helpPagePath('user/project/import/_index', {
anchor: 'user-contribution-and-membership-mapping',
}),
actionPrimary: { text: s__('ImportProjects|Continue import') },
actionCancel: { text: __('Cancel') },
};
</script>
@ -223,7 +240,8 @@ export default {
'ImportProjects|Imported files will be kept. You can import this repository again later.',
)
}}
<gl-link :href="$options.helpPath" target="_blank">{{ __('Learn more.') }}</gl-link>
<help-page-link href="/user/project/import/github">{{ __('Learn more') }}</help-page-link
>.
</div>
</gl-tooltip>
<gl-button
@ -239,10 +257,33 @@ export default {
v-if="isImportNotStarted || isFinished"
type="button"
data-testid="import-button"
@click="handleImportRepo()"
@click="onImportClick"
>
{{ importButtonText }}
</gl-button>
<gl-modal
v-if="showMembershipsWarning"
v-model="showMembershipsModal"
:title="
s__('ImportProjects|Are you sure you want to import the project to a personal namespace?')
"
:action-primary="$options.actionPrimary"
:action-cancel="$options.actionCancel"
@primary="handleImportRepo"
>
<p>
{{
s__(
'ImportProjects|Importing a project into a personal namespace results in all contributions being mapped to the same bot user and they cannot be reassigned. To map contributions to actual users, import the project to a group instead.',
)
}}
<help-page-link
href="/user/project/import/_index"
anchor="user-contribution-and-membership-mapping"
>{{ __('Learn more') }}</help-page-link
>.
</p>
</gl-modal>
<span class="gl-ml-3 gl-inline-flex gl-gap-3">
<help-popover
v-show="showMembershipsWarning"
@ -252,12 +293,14 @@ export default {
>
{{
s__(
'ImportProjects|Importing a project into a personal namespace results in all contributions being mapped to the same bot user. To map contributions to real users, import projects into a group instead.',
'ImportProjects|Importing a project into a personal namespace results in all contributions being mapped to the same bot user and they cannot be reassigned. To map contributions to actual users, import the project to a group instead.',
)
}}
<gl-link :href="$options.membershipsHelpPath" target="_blank">{{
__('Learn more.')
}}</gl-link>
<help-page-link
href="/user/project/import/_index"
anchor="user-contribution-and-membership-mapping"
>{{ __('Learn more') }}</help-page-link
>.
</help-popover>
<help-popover v-if="isFinished" icon="information-o">

View File

@ -27,8 +27,6 @@ export default {
Suggestions,
DuoCodeReviewFeedback: () =>
import('ee_component/notes/components/duo_code_review_feedback.vue'),
AmazonQFixButton: () =>
import('ee_component/merge_requests/components/amazon_q/amazon_q_fix_button.vue'),
},
directives: {
SafeHtml,
@ -246,7 +244,6 @@ export default {
@handleFormUpdate="handleFormUpdate"
@cancelForm="formCancelHandler"
/>
<amazon-q-fix-button :note="note" class="gl-mt-3" />
<!-- eslint-disable vue/no-mutating-props -->
<textarea
v-if="canEdit"

View File

@ -1,6 +1,5 @@
<script>
import { GlButton, GlDisclosureDropdown, GlLabel } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { difference, unionBy } from 'lodash';
import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { __, n__, s__ } from '~/locale';
@ -64,27 +63,30 @@ export default {
},
data() {
return {
searchLabels: [],
searchTerm: '',
searchStarted: false,
showLabelForm: false,
updateInProgress: false,
workItem: {},
createdLabelId: undefined,
removeLabelIds: [],
addLabelIds: [],
labelsCache: [],
labelsToShowAtTopOfTheListbox: [],
labelsToShowAtTopOfListbox: [],
shortcut: ISSUABLE_CHANGE_LABEL,
};
},
computed: {
createFlow() {
isCreateFlow() {
return this.workItemId === newWorkItemId(this.workItemType);
},
workItemFullPath() {
return this.createFlow
return this.isCreateFlow
? newWorkItemFullPath(this.fullPath, this.workItemType)
: this.fullPath;
},
// eslint-disable-next-line vue/no-unused-properties
tracking() {
return {
category: TRACKING_CATEGORY_SHOW,
@ -92,55 +94,38 @@ export default {
property: `type_${this.workItemType}`,
};
},
areLabelsSelected() {
return this.addLabelIds.length > 0 || this.itemValues.length > 0;
},
selectedLabelCount() {
return this.addLabelIds.length + this.itemValues.length - this.removeLabelIds.length;
},
dropDownLabelText() {
return n__('%d label', '%d labels', this.selectedLabelCount);
},
dropdownText() {
return this.areLabelsSelected ? `${this.dropDownLabelText}` : __('No labels');
const selectedLabelsCount =
this.addLabelIds.length + this.widgetLabelsIds.length - this.removeLabelIds.length;
return this.addLabelIds.length > 0 || this.widgetLabelsIds.length > 0
? n__('%d label', '%d labels', selectedLabelsCount)
: __('No labels');
},
isLoadingLabels() {
return this.$apollo.queries.searchLabels.loading;
},
visibleLabels() {
if (this.searchTerm) {
return fuzzaldrinPlus.filter(this.searchLabels, this.searchTerm, {
key: ['title'],
});
listboxItems() {
const formattedSearchLabels = this.searchLabels.map(formatLabelForListbox);
if (this.searchTerm || this.widgetLabelsIds.length === 0) {
return formattedSearchLabels;
}
return this.searchLabels;
},
labelsList() {
const visibleLabels = this.visibleLabels?.map(formatLabelForListbox) || [];
if (this.searchTerm || this.itemValues.length === 0) {
return visibleLabels;
}
const selectedLabels = this.labelsToShowAtTopOfTheListbox.map(formatLabelForListbox) || [];
const unselectedLabels = visibleLabels.filter(
({ value }) => !this.labelsToShowAtTopOfTheListbox.find((l) => l.id === value),
);
const selectedLabels = this.labelsToShowAtTopOfListbox.map(formatLabelForListbox);
return [
{ options: selectedLabels, text: __('Selected') },
{ options: unselectedLabels, text: __('All'), textSrOnly: true },
{ text: __('Selected'), options: selectedLabels },
{ text: __('All'), textSrOnly: true, options: formattedSearchLabels },
];
},
labelsWidget() {
return findLabelsWidget(this.workItem);
},
localLabels() {
widgetLabels() {
return this.labelsWidget?.labels?.nodes || [];
},
itemValues() {
return this.localLabels.map(({ id }) => id);
widgetLabelsIds() {
return this.widgetLabels.map(({ id }) => id);
},
allowsScopedLabels() {
return this.labelsWidget?.allowsScopedLabels;
@ -158,21 +143,20 @@ export default {
watch: {
searchTerm(newVal, oldVal) {
if (newVal === '' && oldVal !== '') {
const selectedIds = [...this.itemValues, ...this.addLabelIds].filter(
const selectedIds = [...this.widgetLabelsIds, ...this.addLabelIds].filter(
(x) => !this.removeLabelIds.includes(x),
);
this.labelsToShowAtTopOfTheListbox = this.labelsCache.filter(({ id }) =>
this.labelsToShowAtTopOfListbox = this.labelsCache.filter(({ id }) =>
selectedIds.includes(id),
);
}
},
localLabels(newVal) {
this.labelsToShowAtTopOfTheListbox = newVal;
widgetLabels(newVal) {
this.labelsToShowAtTopOfListbox = newVal;
},
},
apollo: {
// eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties
workItem: {
query: workItemByIidQuery,
variables() {
@ -195,7 +179,6 @@ export default {
this.$emit('error', i18n.fetchError);
},
},
// eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties
searchLabels: {
query() {
return this.isGroup ? groupLabelsQuery : projectLabelsQuery;
@ -210,7 +193,7 @@ export default {
return !this.searchStarted;
},
update(data) {
return data.workspace?.labels?.nodes;
return data.workspace?.labels?.nodes ?? [];
},
result({ data }) {
const labels = data?.workspace?.labels?.nodes || [];
@ -231,30 +214,33 @@ export default {
},
search(searchTerm) {
this.searchTerm = searchTerm;
this.searchStarted = true;
},
removeLabel({ id }) {
this.removeLabelIds.push(id);
this.updateLabels();
},
updateLabel(labels) {
this.removeLabelIds = difference(this.itemValues, labels);
this.addLabelIds = difference(labels, this.itemValues);
this.removeLabelIds = difference(this.widgetLabelsIds, labels);
this.addLabelIds = difference(labels, this.widgetLabelsIds);
},
async updateLabels(labels) {
this.updateInProgress = true;
if (labels?.length === 0) {
this.removeLabelIds = this.itemValues;
this.removeLabelIds = this.widgetLabelsIds;
this.addLabelIds = [];
}
if (this.createFlow) {
const selectedIds = [...this.itemValues, ...this.addLabelIds].filter(
if (!this.addLabelIds.length && !this.removeLabelIds.length) {
return;
}
this.updateInProgress = true;
if (this.isCreateFlow) {
const selectedIds = [...this.widgetLabelsIds, ...this.addLabelIds].filter(
(x) => !this.removeLabelIds.includes(x),
);
this.$apollo.mutate({
await this.$apollo.mutate({
mutation: updateNewWorkItemMutation,
variables: {
input: {
@ -307,9 +293,6 @@ export default {
scopedLabel(label) {
return this.allowsScopedLabels && isScopedLabel(label);
},
isSelected(id) {
return this.itemValues.includes(id) || this.addLabelIds.includes(id);
},
labelFilterUrl(label) {
return `${this.issuesListPath}?label_name[]=${encodeURIComponent(label.title)}`;
},
@ -329,8 +312,8 @@ export default {
:created-label-id="createdLabelId"
dropdown-name="label"
:loading="isLoadingLabels"
:list-items="labelsList"
:item-value="itemValues"
:list-items="listboxItems"
:item-value="widgetLabelsIds"
:update-in-progress="updateInProgress"
:toggle-dropdown-text="dropdownText"
:header-text="__('Select labels')"
@ -357,7 +340,7 @@ export default {
<template #readonly>
<div class="gl-mt-1 gl-flex gl-flex-wrap gl-gap-2" data-testid="selected-label-content">
<gl-label
v-for="label in localLabels"
v-for="label in widgetLabels"
:key="label.id"
:title="label.title"
:description="label.description"

View File

@ -22,7 +22,7 @@ class GroupsController < Groups::ApplicationController
# Authorize
before_action :authorize_admin_group!, only: [:update, :projects, :transfer, :export, :download_export]
before_action :authorize_view_edit_page!, only: :edit
before_action :authorize_remove_group!, only: :destroy
before_action :authorize_remove_group!, only: [:destroy, :restore]
before_action :authorize_create_group!, only: [:new]
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
@ -55,7 +55,7 @@ class GroupsController < Groups::ApplicationController
feature_category :groups_and_projects, [
:index, :new, :create, :show, :edit, :update,
:destroy, :details, :transfer, :activity
:destroy, :details, :transfer, :activity, :restore
]
feature_category :team_planning, [:issues, :issues_calendar, :preview_markdown]
feature_category :code_review_workflow, [:merge_requests, :unfoldered_environment_names]
@ -180,18 +180,51 @@ class GroupsController < Groups::ApplicationController
end
def destroy
Groups::DestroyService.new(@group, current_user).async_execute
message = format(_("Group '%{group_name}' is being deleted."), group_name: @group.full_name)
return destroy_immediately unless group.adjourned_deletion?
return destroy_immediately if group.marked_for_deletion? && ::Gitlab::Utils.to_boolean(params[:permanently_remove])
respond_to do |format|
format.html do
flash[:toast] = message
redirect_to root_path, status: :found
end
result = ::Groups::MarkForDeletionService.new(group, current_user).execute
format.json do
render json: { message: message }
if result[:status] == :success
respond_to do |format|
format.html do
redirect_to group_path(group), status: :found
end
format.json do
render json: {
message: format(
_("'%{group_name}' has been scheduled for deletion and will be deleted on %{date}."),
group_name: group.name,
# FIXME: Replace `group.marked_for_deletion_on` with `group` after https://gitlab.com/gitlab-org/gitlab/-/work_items/527085
date: helpers.permanent_deletion_date_formatted(group.marked_for_deletion_on)
)
}
end
end
else
respond_to do |format|
format.html do
redirect_to edit_group_path(group), status: :found, alert: result[:message]
end
format.json do
render json: { message: result[:message] }, status: :unprocessable_entity
end
end
end
end
def restore
return render_404 unless group.marked_for_deletion?
result = ::Groups::RestoreService.new(group, current_user).execute
if result[:status] == :success
redirect_to edit_group_path(group),
notice: format(_("Group '%{group_name}' has been successfully restored."), group_name: group.full_name)
else
redirect_to edit_group_path(group), alert: result[:message]
end
end
@ -368,6 +401,22 @@ class GroupsController < Groups::ApplicationController
def has_project_list?
%w[details show index].include?(action_name)
end
def destroy_immediately
Groups::DestroyService.new(@group, current_user).async_execute
message = format(_("Group '%{group_name}' is being deleted."), group_name: @group.full_name)
respond_to do |format|
format.html do
flash[:toast] = message
redirect_to root_path, status: :found
end
format.json do
render json: { message: message }
end
end
end
end
GroupsController.prepend_mod

View File

@ -2,6 +2,8 @@
module Emails
module Projects
include NamespacesHelper
def project_was_moved_email(project_id, user_id, old_path_with_namespace)
@current_user = @user = User.find user_id
@project = Project.find project_id
@ -30,6 +32,18 @@ module Emails
)
end
def project_scheduled_for_deletion(recipient_id, project_id)
@project = ::Project.find(project_id)
@user = ::User.find(recipient_id)
@deletion_due_in_days = ::Gitlab::CurrentSettings.deletion_adjourned_period.days
@deletion_date = permanent_deletion_date_formatted(@project.marked_for_deletion_on, format: '%B %-d, %Y')
email_with_layout(
to: @user.email,
subject: subject('Project scheduled for deletion')
)
end
def repository_cleanup_success_email(project, user)
@project = project
@user = user

View File

@ -435,6 +435,14 @@ class NotifyPreview < ActionMailer::Preview
Notify.repository_rewrite_history_failure_email(project, user, 'Error message')
end
def project_scheduled_for_deletion
cleanup do
project.update!(marked_for_deletion_at: Time.current)
::Notify.project_scheduled_for_deletion(user.id, project.id).message
end
end
private
def project

View File

@ -21,5 +21,3 @@ class ProjectNoteEntity < NoteEntity
project_note_path(note.project, note)
end
end
ProjectNoteEntity.prepend_mod_with('ProjectNoteEntity')

View File

@ -788,6 +788,19 @@ class NotificationService
mailer.new_achievement_email(user, achievement)
end
def project_scheduled_for_deletion(project)
return if project.emails_disabled?
recipients = owners_without_invites(project)
recipients.each do |recipient|
mailer.project_scheduled_for_deletion(
recipient.id,
project.id
).deliver_later
end
end
protected
def new_resource_email(target, current_user, method)
@ -869,6 +882,16 @@ class NotificationService
private
def owners_without_invites(project)
recipients = project.members.active_without_invites_and_requests.owners
if recipients.empty? && project.group
recipients = project.group.members.active_without_invites_and_requests.owners
end
recipients.map(&:user)
end
def log_info(message_text, user)
Gitlab::AppLogger.info(
message: message_text,

View File

@ -16,6 +16,7 @@ module Projects
if result[:status] == :success
log_event
send_project_deletion_notification
## Trigger root statistics refresh, to skip project_statistics of
## projects marked for deletion
@ -27,6 +28,14 @@ module Projects
private
def send_project_deletion_notification
return unless ::Feature.enabled?(:project_deletion_notification_email, project) &&
project.adjourned_deletion? &&
project.marked_for_deletion?
::NotificationService.new.project_scheduled_for_deletion(project)
end
def log_event
log_info("User #{current_user.id} marked project #{project.full_path} for deletion")
end

View File

@ -0,0 +1,7 @@
%p
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
= _('Your project %{project_name} has been marked for deletion and will be removed in %{days}.').html_safe % { project_name: link_to(@project.full_name, project_url(@project)), days: pluralize((@deletion_due_in_days / 1.day).to_i, _('day')) }
%p
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: inactive_dashboard_projects_url }
= _('If this was a mistake, you can %{link_start}retain the project%{link_end} before %{deletion_date}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe, deletion_date: @deletion_date }

View File

@ -0,0 +1,7 @@
<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
<%= _('Your project %{project_name} has been marked for deletion and will be removed in %{days}.') % { project_name: @project.full_name, days: pluralize((@deletion_due_in_days / 1.day).to_i, _('day')) } %>
<%= _('View your project: %{project_url}') % { project_url: project_url(@project) } %>
<%= _('If this was a mistake, you can retain the project before %{deletion_date}: %{retention_url}') % { retention_url: inactive_dashboard_projects_url, deletion_date: @deletion_date } %>

View File

@ -0,0 +1,10 @@
---
name: project_deletion_notification_email
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/522883
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/184026
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/525979
milestone: '17.11'
group: group::authorization
type: gitlab_com_derisk
default_enabled: false

View File

@ -181,7 +181,6 @@ InitializerConnections.raise_if_new_database_connection do
draw :gitlab_subscriptions
draw :phone_verification
draw :arkose
draw :amazon_q
scope '/from_secondary/:geo_node_id' do
draw :git_http

View File

@ -182,6 +182,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :work_items, only: [:index, :show], param: :iid
post :preview_markdown
post '/restore' => '/groups#restore', as: :restore
end
scope(

View File

@ -37,6 +37,7 @@ The following Rake tasks are available for use with GitLab:
| [Incoming email](incoming_email.md) | Incoming email-related tasks. |
| [Integrity checks](check.md) | Check the integrity of repositories, files, LDAP, and more. |
| [LDAP maintenance](ldap.md) | [LDAP](../../administration/auth/ldap/_index.md)-related tasks. |
| [Password](password.md) | Password management tasks. |
| [Praefect Rake tasks](praefect.md) | [Praefect](../../administration/gitaly/praefect.md)-related tasks. |
| [Project import/export](project_import_export.md) | Prepare for [project exports and imports](../../user/project/settings/import_export.md). |
| [Sidekiq job migration](../sidekiq/sidekiq_job_migration.md) | Migrate Sidekiq jobs scheduled for future dates to a new queue. |

View File

@ -0,0 +1,34 @@
---
stage: Systems
group: Distribution
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
title: Maintenance Rake tasks
---
{{< details >}}
- Tier: Free, Premium, Ultimate
- Offering: GitLab Self-Managed
{{< /details >}}
GitLab provides Rake tasks for managing passwords.
## Reset passwords
To reset a password using a Rake task, see [reset user passwords](../../security/reset_user_password.md#use-a-rake-task).
## Check password salt length
Starting with GitLab 17.11, the salts of password hashes on FIPS instances
are increased when a user signs in.
You can check how many users need this migration:
```shell
# omnibus-gitlab
sudo gitlab-rake gitlab:password:fips_check_salts:[true]
# installation from source
bundle exec rake gitlab:password:fips_check_salts:[true] RAILS_ENV=production
```

View File

@ -6,7 +6,7 @@ title: Foreign keys and associations
---
When adding an association to a model you must also add a foreign key. When
adding a foreign key you must always add an [index](#indexes).
adding a foreign key you must always add an [index](#indexes) first.
If the [index must be created async](adding_database_indexes.md#create-indexes-asynchronously)
due to duration reasons, you must avoid adding the foreign key until the index
@ -158,10 +158,10 @@ this should be set to `CASCADE`.
When adding a foreign key in PostgreSQL the column is not indexed automatically,
thus you must also add a concurrent index. Indexes are required for all foreign
keys and they must be added in the same or earlier migration than the migration
adding the foreign key. Conversely, foreign keys must be removed in
the same or earlier migration than the migration
removing indexes supporting these foreign keys.
keys and they must be added before the foreign key. This can mean that they are
an earlier step in the same migration or they are added in an earlier migration
than the migration adding the foreign key. For the same reasons, foreign keys
must be removed before removing indexes supporting these foreign keys.
Without an index on the foreign key it forces Postgres to do a full table scan
every time a record is deleted from the referenced table. In the past this has

View File

@ -413,10 +413,13 @@ For this tool to automatically remove the usages of the feature flag in your cod
For example you can create a patch file for `config/feature_flags/beta/my_feature_flag.yml` using the following steps:
1. Edit the code locally to remove the feature flag `my_feature_flag` usage assuming that the feature flag is already enabled and we are rolling forward
1. Run `git diff > config/feature_flags/beta/my_feature_flag.patch`
1. Undo the changes to the files where you removed the feature flag usage
1. Commit this file `config/feature_flags/beta/my_feature_flag.patch` file to the branch where you are adding the feature flag
1. Ensure you have a clean Git working directory.
1. Delete `config/feature_flags/beta/my_feature_flag.yml`.
1. Edit the code locally to remove any usage of `my_feature_flag` as though that the feature flag is already enabled and the feature is moving forward.
1. Run `git diff > config/feature_flags/beta/my_feature_flag.patch`. If your feature flag is not a `beta` flag, ensure your patch file in the same directory as the YAML file that defines your feature flag.
1. Undo the deletion of `config/feature_flags/beta/my_feature_flag.yml`
1. Undo the changes to the files you ended to remove the feature flag usage
1. Commit the patch file to the branch where you are adding the feature flag
Then in future the `gitlab-housekeeper` will automatically clean up your
feature flag for you by applying this patch.

View File

@ -269,6 +269,8 @@ Learn how to [opt out of all emails from GitLab](#opt-out-of-all-gitlab-emails).
The following table presents the events that generate notifications for issues, merge requests, and
epics:
<!-- For issue due timing source, see 'issue_due_scheduler_worker' in https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/initializers/1_settings.rb -->
| Type | Event | Sent to |
|------|-------|---------|
| Epic | Closed | Subscribers and participants. |
@ -276,7 +278,6 @@ epics:
| Epic | New note | Participants, Watchers, Subscribers, and Custom notification level with this event selected. Also anyone mentioned by username in the comment, with notification level "Mention" or higher. |
| Epic | Reopened | Subscribers and participants. |
| Issue | Closed | Subscribers and participants. |
<!-- For issue due timing source, see 'issue_due_scheduler_worker' in https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/initializers/1_settings.rb -->
| Issue | Due tomorrow. The notification is sent at 00:50 in the server's time zone (for GitLab.com this is UTC) for open issues with a due date of the next calendar day. | Participants and Custom notification level with this event selected. |
| Issue | Milestone changed | Subscribers and participants. |
| Issue | Milestone removed | Subscribers and participants. |

View File

@ -81,6 +81,7 @@ To create a custom field:
- In **Use on**, select the work item types where you want this field to be available.
- In **Options** (on single-select and multi-select fields), enter the possible select options.
A single-select or multi-select field can have at most 50 select options.
- Reorder options by dragging the grip icon ({{< icon name="grip" >}}) to the left of each option.
1. Select **Save**.
### Edit a custom field

View File

@ -489,7 +489,8 @@ module Gitlab
def unavailable_scopes_for_resource(resource)
unavailable_ai_features_scopes +
unavailable_observability_scopes_for_resource(resource)
unavailable_observability_scopes_for_resource(resource) +
unavailable_virtual_registry_scopes_for_resource(resource)
end
def unavailable_ai_features_scopes
@ -503,6 +504,12 @@ module Gitlab
OBSERVABILITY_SCOPES
end
def unavailable_virtual_registry_scopes_for_resource(resource)
return VIRTUAL_REGISTRY_SCOPES if resource.is_a?(Project)
[]
end
def non_admin_available_scopes
API_SCOPES + REPOSITORY_SCOPES + registry_scopes + virtual_registry_scopes + OBSERVABILITY_SCOPES + AI_FEATURES_SCOPES
end

View File

@ -28,5 +28,40 @@ namespace :gitlab do
puts "Password successfully updated for user with username #{username}."
end
desc "GitLab | Password | Check status of password salts on FIPS systems"
task :fips_check_salts, [:print_usernames] => :environment do |_, args|
abort Rainbow('This command is only available on FIPS instances').red unless Gitlab::FIPS.enabled?
message = "Active users with unmigrated salts:"
batch_size = 50
min_salt_len = 64
count_total = 0
count_unmigrated = 0
puts Rainbow(message) if args.print_usernames
User.active.each_batch(of: batch_size) do |user_batch|
user_batch.each do |user|
count_total += 1
begin
hash = user.encrypted_password
salt_len = Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512
.split_digest(hash)[:salt].length
rescue StandardError => e
puts("Error getting salt for user #{user.username}: #{e.message}")
next
end
if salt_len < min_salt_len
puts user.username if args.print_usernames
count_unmigrated += 1
end
end
end
puts Rainbow("#{message} #{count_unmigrated} out of #{count_total} total users")
end
end
end

View File

@ -6418,9 +6418,6 @@ msgstr ""
msgid "AmazonQ|Amazon Q will be turned off for all groups, subgroups, and projects, even if they have previously enabled it."
msgstr ""
msgid "AmazonQ|An error occurred. Please try again later."
msgstr ""
msgid "AmazonQ|An unexpected error occurred while disconnecting Amazon Q. Please see the browser console log for more details."
msgstr ""
@ -6433,9 +6430,6 @@ msgstr ""
msgid "AmazonQ|Are you sure? Removing the ARN will disconnect Amazon Q from GitLab and all related features will stop working."
msgstr ""
msgid "AmazonQ|Ask GitLab Duo with Amazon Q to suggest a solution for this issue"
msgstr ""
msgid "AmazonQ|Audience"
msgstr ""
@ -6463,9 +6457,6 @@ msgstr ""
msgid "AmazonQ|Create an identity provider for this GitLab instance within AWS using the following values. %{helpStart}Learn more%{helpEnd}."
msgstr ""
msgid "AmazonQ|Create fixes for review findings"
msgstr ""
msgid "AmazonQ|Create unit tests for selected lines of code in Java or Python files"
msgstr ""
@ -6514,9 +6505,6 @@ msgstr ""
msgid "AmazonQ|I'm creating unit tests for this merge request. I'll update this comment when I'm done."
msgstr ""
msgid "AmazonQ|I'm generating a fix for this review finding. I'll update this comment when I'm done."
msgstr ""
msgid "AmazonQ|I'm generating code for this issue. I'll update this comment and open a merge request when I'm done."
msgstr ""
@ -6586,9 +6574,6 @@ msgstr ""
msgid "AmazonQ|Status"
msgstr ""
msgid "AmazonQ|Suggest a fix"
msgstr ""
msgid "AmazonQ|This field is required"
msgstr ""
@ -6628,9 +6613,6 @@ msgstr ""
msgid "AmazonQ|dev"
msgstr ""
msgid "AmazonQ|fix"
msgstr ""
msgid "AmazonQ|review"
msgstr ""
@ -31526,6 +31508,9 @@ msgstr ""
msgid "ImportProjects|All organizations"
msgstr ""
msgid "ImportProjects|Are you sure you want to import the project to a personal namespace?"
msgstr ""
msgid "ImportProjects|Blocked import URL: %{message}"
msgstr ""
@ -31541,6 +31526,9 @@ msgstr ""
msgid "ImportProjects|Collaborated"
msgstr ""
msgid "ImportProjects|Continue import"
msgstr ""
msgid "ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}"
msgstr ""
@ -31550,7 +31538,7 @@ msgstr ""
msgid "ImportProjects|Imported files will be kept. You can import this repository again later."
msgstr ""
msgid "ImportProjects|Importing a project into a personal namespace results in all contributions being mapped to the same bot user. To map contributions to real users, import projects into a group instead."
msgid "ImportProjects|Importing a project into a personal namespace results in all contributions being mapped to the same bot user and they cannot be reassigned. To map contributions to actual users, import the project to a group instead."
msgstr ""
msgid "ImportProjects|Importing the project failed"
@ -64474,7 +64462,7 @@ msgstr ""
msgid "UsageQuota|Code packages and container images."
msgstr ""
msgid "UsageQuota|Compute units usage is calculated based on instance runners duration with cost factors applied."
msgid "UsageQuota|Compute minutes usage displays the hosted runner usage against the total available compute minutes."
msgstr ""
msgid "UsageQuota|Compute usage"

View File

@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe GroupsController, :with_current_organization, factory_default: :keep, feature_category: :code_review_workflow do
include ExternalAuthorizationServiceHelpers
include AdminModeHelper
include NamespacesHelper
let_it_be(:group_organization) { current_organization }
let_it_be_with_refind(:group) { create_default(:group, :public, organization: group_organization) }
@ -24,7 +25,6 @@ RSpec.describe GroupsController, :with_current_organization, factory_default: :k
before do
enable_admin_mode!(admin_with_admin_mode)
stub_feature_flags(downtier_delayed_deletion: false)
end
shared_examples 'member with ability to create subgroups' do
@ -527,42 +527,249 @@ RSpec.describe GroupsController, :with_current_organization, factory_default: :k
end
describe 'DELETE #destroy' do
context 'as another user' do
it 'returns 404' do
sign_in(create(:user))
let(:format) { :html }
let(:params) { {} }
delete :destroy, params: { id: group.to_param }
subject { delete :destroy, format: format, params: { id: group.to_param, **params } }
context 'when authenticated user can admin the group' do
let_it_be(:user) { owner }
before do
sign_in(user)
end
context 'delayed deletion feature is available' do
context 'success' do
it 'marks the group for delayed deletion' do
expect { subject }.to change { group.reload.marked_for_deletion? }.from(false).to(true)
end
it 'does not immediately delete the group' do
Sidekiq::Testing.fake! do
expect { subject }.not_to change { GroupDestroyWorker.jobs.size }
end
end
context 'for a html request' do
it 'redirects to group path' do
subject
expect(response).to redirect_to(group_path(group))
end
end
context 'for a json request', :freeze_time do
let(:format) { :json }
it 'returns json with message' do
subject
# FIXME: Replace `group.marked_for_deletion_on` with `group` after https://gitlab.com/gitlab-org/gitlab/-/work_items/527085
expect(json_response['message'])
.to eq(
"'#{group.name}' has been scheduled for deletion and will be deleted on " \
"#{permanent_deletion_date_formatted(group.marked_for_deletion_on)}.")
end
end
end
context 'failure' do
before do
allow(::Groups::MarkForDeletionService).to receive_message_chain(:new, :execute).and_return({ status: :error, message: 'error' })
end
it 'does not mark the group for deletion' do
expect { subject }.not_to change { group.reload.marked_for_deletion? }.from(false)
end
context 'for a html request' do
it 'redirects to group edit page' do
subject
expect(response).to redirect_to(edit_group_path(group))
expect(flash[:alert]).to include 'error'
end
end
context 'for a json request' do
let(:format) { :json }
it 'returns json with message' do
subject
expect(json_response['message']).to eq("error")
end
end
end
context 'when group is already marked for deletion' do
before do
create(:group_deletion_schedule, group: group, marked_for_deletion_on: Date.current)
end
context 'when permanently_remove param is set' do
let(:params) { { permanently_remove: true } }
context 'for a html request' do
it 'deletes the group immediately and redirects to root path' do
expect(GroupDestroyWorker).to receive(:perform_async)
subject
expect(response).to redirect_to(root_path)
expect(flash[:toast]).to include "Group '#{group.name}' is being deleted."
end
end
context 'for a json request' do
let(:format) { :json }
it 'deletes the group immediately and returns json with message' do
expect(GroupDestroyWorker).to receive(:perform_async)
subject
expect(json_response['message']).to eq("Group '#{group.name}' is being deleted.")
end
end
end
context 'when permanently_remove param is not set' do
context 'for a html request' do
it 'redirects to edit path with error' do
subject
expect(response).to redirect_to(edit_group_path(group))
expect(flash[:alert]).to include "Group has been already marked for deletion"
end
end
context 'for a json request' do
let(:format) { :json }
it 'returns json with message' do
subject
expect(json_response['message']).to eq("Group has been already marked for deletion")
end
end
end
end
end
context 'delayed deletion feature is not available', :sidekiq_inline do
before do
stub_feature_flags(downtier_delayed_deletion: false)
end
context 'for a html request' do
it 'immediately schedules a group destroy and redirects to root page with alert about immediate deletion' do
Sidekiq::Testing.fake! do
expect { subject }.to change { GroupDestroyWorker.jobs.size }.by(1)
end
expect(response).to redirect_to(root_path)
expect(flash[:toast]).to include "Group '#{group.name}' is being deleted."
end
end
context 'for a json request' do
let(:format) { :json }
it 'immediately schedules a group destroy and returns json with message' do
Sidekiq::Testing.fake! do
expect { subject }.to change { GroupDestroyWorker.jobs.size }.by(1)
end
expect(json_response['message']).to eq("Group '#{group.name}' is being deleted.")
end
end
end
end
context 'when authenticated user cannot admin the group' do
before do
sign_in(create(:user))
end
it 'returns 404' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'as the group owner' do
let(:user) { create(:user) }
let(:group) { create(:group) }
describe 'POST #restore' do
let_it_be(:group) do
create(:group_with_deletion_schedule,
marked_for_deletion_on: 1.day.ago,
deleting_user: user)
end
subject { post :restore, params: { group_id: group.to_param } }
context 'when authenticated user can admin the group' do
before do
group.add_owner(user)
sign_in(user)
end
context 'for a html request' do
it 'schedules a group destroy and redirects to the root path' do
Sidekiq::Testing.fake! do
expect { delete :destroy, params: { id: group.to_param } }.to change(GroupDestroyWorker.jobs, :size).by(1)
context 'when the delayed deletion feature is available' do
context 'when the restore succeeds' do
it 'restores the group' do
expect { subject }.to change { group.reload.marked_for_deletion? }.from(true).to(false)
end
it 'renders success notice upon restoring' do
subject
expect(response).to redirect_to(edit_group_path(group))
expect(flash[:notice]).to include "Group '#{group.name}' has been successfully restored."
end
end
context 'when the restore fails' do
before do
allow(::Groups::RestoreService).to receive_message_chain(:new, :execute).and_return({ status: :error, message: 'error' })
end
it 'does not restore the group' do
expect { subject }.not_to change { group.reload.marked_for_deletion? }.from(true)
end
it 'redirects to group edit page' do
subject
expect(response).to redirect_to(edit_group_path(group))
expect(flash[:alert]).to include 'error'
end
expect(flash[:toast]).to eq(format(_("Group '%{group_name}' is being deleted."), group_name: group.full_name))
expect(response).to redirect_to(root_path)
end
end
context 'for a json request' do
it 'schedules a group destroy and returns message' do
Sidekiq::Testing.fake! do
expect { delete :destroy, format: :json, params: { id: group.to_param } }.to change(GroupDestroyWorker.jobs, :size).by(1)
end
expect(Gitlab::Json.parse(response.body)).to eq({ 'message' => "Group '#{group.full_name}' is being deleted." })
context 'when delayed deletion feature is not available' do
before do
stub_feature_flags(downtier_delayed_deletion: false)
end
it 'returns 404' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'when authenticated user cannot admin the group' do
before do
sign_in(create(:user))
end
it 'returns 404' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
end

View File

@ -242,11 +242,10 @@ RSpec.describe 'Project issue boards sidebar labels', :js, feature_category: :po
page.within(labels_widget) do
click_button 'Edit'
wait_for_requests
expect(page).to have_selector('.gl-new-dropdown-item-check-icon', count: 2)
expect(page).to have_content(development.title)
expect(page).to have_content(stretch.title)
# Selected labels are shown twice - once in a "Selected" section and once in the "All" section below
expect(page).to have_selector('.gl-new-dropdown-item-check-icon', count: 4)
expect(page).to have_content(development.title, count: 2)
expect(page).to have_content(stretch.title, count: 2)
end
end

View File

@ -34,6 +34,7 @@ RSpec.describe 'Import multiple repositories by uploading a manifest file', :js,
page.within(second_row) do
click_on 'Import'
end
click_on 'Continue import'
wait_for_requests

View File

@ -176,12 +176,6 @@
"path": {
"type": "string"
},
"amazon_q_quick_actions_path": {
"type": [
"string",
"null"
]
},
"commands_changes": {
"type": "object",
"additionalProperties": true

View File

@ -1,4 +1,6 @@
import createMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer';
import createMarkdownDeserializer, {
transformQuickActions,
} from '~/content_editor/services/gl_api_markdown_deserializer';
import MarkdownSerializer from '~/content_editor/services/markdown_serializer';
import { builders, tiptapEditor, doc, text } from '../serialization_utils';
@ -22,6 +24,14 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => {
renderMarkdown = jest.fn();
});
describe('transformQuickActions', () => {
it('ensures at least 3 newlines after quick actions so that reference style links after the quick action are correctly parsed', () => {
expect(
transformQuickActions('Link to [GitLab][link]\n/confidential\n[link]: https://gitlab.com'),
).toBe('Link to [GitLab][link]\n/confidential\n\n\n[link]: https://gitlab.com');
});
});
describe('when deserializing', () => {
let deserializer;
let result;

View File

@ -1,4 +1,4 @@
import { GlBadge, GlButton } from '@gitlab/ui';
import { GlBadge, GlButton, GlModal } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
@ -45,6 +45,7 @@ describe('ProviderRepoTableRow', () => {
const findImportStatus = () => wrapper.findComponent(ImportStatus);
const findProviderLink = () => wrapper.findByTestId('provider-link');
const findMembershipsWarning = () => wrapper.findByTestId('memberships-warning');
const findGlModal = () => wrapper.findComponent(GlModal);
const findCancelButton = () => {
const buttons = wrapper
@ -107,12 +108,44 @@ describe('ProviderRepoTableRow', () => {
it('shows memberships warning', () => {
expect(findMembershipsWarning().isVisible()).toBe(true);
});
it('shows modal with warning message when import button is clicked', async () => {
findImportButton().vm.$emit('click');
await nextTick();
const modal = findGlModal();
expect(modal.props('title')).toBe(
'Are you sure you want to import the project to a personal namespace?',
);
expect(modal.text()).toContain(
'Importing a project into a personal namespace results in all contributions being mapped to the same bot user and they cannot be reassigned. To map contributions to actual users, import the project to a group instead.',
);
});
it('triggers import when clicking modal primary button', async () => {
findImportButton().vm.$emit('click');
await nextTick();
findGlModal().vm.$emit('primary');
expect(fetchImport).toHaveBeenCalledWith(expect.anything(), {
repoId: repo.importSource.id,
optionalStages: {},
});
});
});
describe('when group namespace is selected as import target', () => {
it('does not show memberships warning', () => {
expect(findMembershipsWarning().isVisible()).toBe(false);
});
it('does not show modal when import button is clicked', async () => {
findImportButton().vm.$emit('click');
await nextTick();
expect(findGlModal().exists()).toBe(false);
});
});
it('renders import button', () => {

View File

@ -214,22 +214,6 @@ describe('WorkItemLabels component', () => {
expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(false);
});
it('filters search results by title in frontend', async () => {
createComponent({
searchQueryHandler: jest.fn().mockResolvedValue(getProjectLabelsResponse(mockLabels)),
});
showDropdown();
await findWorkItemSidebarDropdownWidget().vm.$emit('searchStarted', mockLabels[0].title);
expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(true);
await waitForPromises();
expect(findWorkItemSidebarDropdownWidget().props('listItems')).toHaveLength(1);
expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(false);
});
it('emits error event if search query fails', async () => {
createComponent({ searchQueryHandler: errorHandler });
showDropdown();
@ -336,25 +320,17 @@ describe('WorkItemLabels component', () => {
it('shows selected labels at top of list', async () => {
const [label1, label2, label3] = mockLabels;
const label999 = {
__typename: 'Label',
id: 'gid://gitlab/Label/999',
title: 'Label 999',
description: 'Label not in the label query result',
color: '#fff',
textColor: '#000',
};
createComponent({
workItemQueryHandler: workItemQuerySuccess,
updateWorkItemMutationHandler: jest.fn().mockResolvedValue(
updateWorkItemMutationResponseFactory({
labels: [label1, label999],
labels: [label1, label3],
}),
),
});
updateLabels([label1Id, label999.id]);
updateLabels([label1Id, label3Id]);
showDropdown();
@ -362,10 +338,11 @@ describe('WorkItemLabels component', () => {
const selected = [
{ color: label1.color, text: label1.title, value: label1.id },
{ color: label999.color, text: label999.title, value: label999.id },
{ color: label3.color, text: label3.title, value: label3.id },
];
const unselected = [
{ color: label1.color, text: label1.title, value: label1.id },
{ color: label2.color, text: label2.title, value: label2.id },
{ color: label3.color, text: label3.title, value: label3.id },
];
@ -376,6 +353,21 @@ describe('WorkItemLabels component', () => {
]);
});
it('does not update labels when no labels were added or removed', async () => {
createComponent({
workItemQueryHandler: workItemQueryWithLabelsHandler,
updateWorkItemMutationHandler: successRemoveAllLabelWorkItemMutationHandler,
});
await waitForPromises();
showDropdown();
findWorkItemSidebarDropdownWidget().vm.$emit('updateSelected', [label2Id, label3Id]);
findWorkItemSidebarDropdownWidget().vm.$emit('updateSelected', [label1Id, label2Id, label3Id]);
findWorkItemSidebarDropdownWidget().vm.$emit('updateValue', [label1Id, label2Id, label3Id]);
expect(successRemoveAllLabelWorkItemMutationHandler).not.toHaveBeenCalled();
});
describe('tracking', () => {
let trackingSpy;

View File

@ -251,12 +251,28 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching, feature_cate
end
context 'when dependency proxy is enabled' do
let(:virtual_registry_scopes) { %i[read_virtual_registry write_virtual_registry] }
before do
stub_config(dependency_proxy: { enabled: true })
end
it 'contains all virtual registry related scopes' do
expect(subject.virtual_registry_scopes).to eq %i[read_virtual_registry write_virtual_registry]
expect(subject.virtual_registry_scopes).to eq virtual_registry_scopes
end
context 'for a Project' do
it 'does not include virtual registry scopes' do
expect(subject.available_scopes_for(build_stubbed(:project))).to not_include(*virtual_registry_scopes)
end
end
%i[user group].each do |resource_type|
context "for a #{resource_type}" do
it 'includes the virtual registry scopes' do
expect(subject.available_scopes_for(build_stubbed(resource_type))).to include(*virtual_registry_scopes)
end
end
end
end
end

View File

@ -283,4 +283,29 @@ RSpec.describe Emails::Projects do
is_expected.to have_body_text("#{project.name} | Project export error")
end
end
describe '#project_scheduled_for_deletion' do
let_it_be(:user) { create(:user) }
let_it_be(:frozen_time) { Time.new(2023, 10, 15, 12, 0, 0) }
let_it_be(:project) { create(:project, marked_for_deletion_on: frozen_time) }
let(:deletion_adjourned_period) { 7 }
let(:deletion_date) { (frozen_time.to_date + deletion_adjourned_period.days).strftime('%B %-d, %Y') }
before do
stub_application_setting(deletion_adjourned_period: deletion_adjourned_period)
allow_next_instance_of(Project) do |instance|
allow(instance).to receive(:marked_for_deletion_on).and_return(frozen_time)
end
end
subject { Notify.project_scheduled_for_deletion(user.id, project.id) }
it 'has expected content', :aggregate_failures do
is_expected.to have_subject("#{project.name} | Project scheduled for deletion")
is_expected.to have_body_text(project.full_name)
is_expected.to have_body_text(deletion_adjourned_period.to_s)
is_expected.to have_body_text(deletion_date)
end
end
end

View File

@ -4551,6 +4551,76 @@ RSpec.describe NotificationService, :mailer, feature_category: :team_planning do
end
end
describe 'project scheduled for deletion' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
context 'when project emails are disabled' do
before do
allow(project).to receive(:emails_disabled?).and_return(true)
end
it 'does not send any emails' do
expect(Notify).not_to receive(:project_scheduled_for_deletion)
subject.project_scheduled_for_deletion(project)
end
end
context 'when project emails are enabled' do
before do
allow(project).to receive(:emails_disabled?).and_return(false)
end
context 'when user is owner' do
it 'sends email' do
expect(Notify).to receive(:project_scheduled_for_deletion).with(project.first_owner.id, project.id).and_call_original
subject.project_scheduled_for_deletion(project)
end
context 'when owner is blocked' do
it 'does not send email' do
project.owner.block!
expect(Notify).not_to receive(:project_scheduled_for_deletion)
subject.project_scheduled_for_deletion(project)
end
end
end
context 'when project has multiple owners' do
it 'sends email to all owners' do
project.add_owner(user)
expect(Notify).to receive(:project_scheduled_for_deletion).with(project.first_owner.id, project.id).and_call_original
expect(Notify).to receive(:project_scheduled_for_deletion).with(user.id, project.id).and_call_original
subject.project_scheduled_for_deletion(project)
end
end
context 'when project has no direct owners but belongs to a group with owners' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:group_owner) { create(:user) }
before do
group.add_owner(group_owner)
# Ensure project has no direct owners
project.members.owners.delete_all if project.members.owners.any?
end
it 'sends email to group owners' do
expect(Notify).to receive(:project_scheduled_for_deletion).with(group_owner.id, project.id).and_call_original
subject.project_scheduled_for_deletion(project)
end
end
end
end
def build_team(project)
@u_watcher = create_global_setting_for(create(:user), :watch)
@u_participating = create_global_setting_for(create(:user), :participating)

View File

@ -11,8 +11,9 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
let(:original_project_path) { project.path }
let(:original_project_name) { project.name }
let(:licensed) { false }
let(:service) { described_class.new(project, user) }
subject(:result) { described_class.new(project, user).execute(licensed: licensed) }
subject(:result) { service.execute(licensed: licensed) }
context 'with downtier_delayed_deletion feature flag enabled' do
context 'when marking project for deletion' do
@ -47,6 +48,12 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
result
end
it 'sends project deletion notification' do
expect(service).to receive(:send_project_deletion_notification)
result
end
end
context 'when marking project for deletion once again' do
@ -58,6 +65,22 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
expect(result[:status]).to eq(:success)
expect(project.marked_for_deletion_at).to eq(marked_for_deletion_at.to_date)
end
it 'does not send project deletion notification' do
project.update!(marked_for_deletion_at: marked_for_deletion_at)
expect(service).not_to receive(:send_project_deletion_notification)
result
end
it 'does not send notification email' do
stub_feature_flags(project_deletion_notification_email: true)
expect(NotificationService).not_to receive(:new)
result
end
end
end
@ -66,7 +89,8 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
stub_feature_flags(downtier_delayed_deletion: false)
end
it 'returns an error response' do
it 'returns an error response and does not send notification' do
expect(service).not_to receive(:send_project_deletion_notification)
expect(result).to eq(status: :error, message: 'Cannot mark project for deletion: feature not supported')
end
@ -76,6 +100,88 @@ RSpec.describe Projects::MarkForDeletionService, feature_category: :groups_and_p
it 'is successful' do
expect(result[:status]).to eq(:success)
end
it 'sends project deletion notification' do
expect(service).to receive(:send_project_deletion_notification)
result
end
end
end
describe '#send_project_deletion_notification' do
context 'when all conditions are met' do
before do
stub_feature_flags(project_deletion_notification_email: true)
allow(project).to receive_messages(adjourned_deletion?: true, marked_for_deletion?: true)
end
it 'sends a notification email' do
expect_next_instance_of(NotificationService) do |service|
expect(service).to receive(:project_scheduled_for_deletion).with(project)
end
execute_send_project_deletion_notification
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(project_deletion_notification_email: false)
allow(project).to receive_messages(adjourned_deletion?: true, marked_for_deletion?: true)
end
it 'does not send a notification email' do
expect(NotificationService).not_to receive(:new)
execute_send_project_deletion_notification
end
end
context 'when feature flag is enabled for specific project' do
before do
stub_feature_flags(project_deletion_notification_email: project)
allow(project).to receive_messages(adjourned_deletion?: true, marked_for_deletion?: true)
end
it 'sends a notification email' do
expect_next_instance_of(NotificationService) do |service|
expect(service).to receive(:project_scheduled_for_deletion).with(project)
end
execute_send_project_deletion_notification
end
end
context 'when adjourned deletion is disabled' do
before do
stub_feature_flags(project_deletion_notification_email: true)
allow(project).to receive_messages(adjourned_deletion?: false, marked_for_deletion?: true)
end
it 'does not send a notification email' do
expect(NotificationService).not_to receive(:new)
execute_send_project_deletion_notification
end
end
context 'when project is not marked for deletion' do
before do
stub_feature_flags(project_deletion_notification_email: true)
allow(project).to receive_messages(adjourned_deletion?: true, marked_for_deletion?: false)
end
it 'does not send a notification email' do
expect(NotificationService).not_to receive(:new)
execute_send_project_deletion_notification
end
end
end
end
def execute_send_project_deletion_notification
service = described_class.new(project, user)
service.send(:send_project_deletion_notification)
end

View File

@ -3,27 +3,29 @@
require 'spec_helper'
RSpec.describe 'gitlab:password rake tasks', :silence_stdout do
let!(:user_1) { create(:user, username: 'foobar', password: User.random_password, password_automatically_set: true) }
let(:password) { User.random_password }
def stub_username(username)
allow(Gitlab::TaskHelpers).to receive(:prompt).with('Enter username: ').and_return(username)
end
def stub_password(password, confirmation = nil)
confirmation ||= password
allow(Gitlab::TaskHelpers).to receive(:prompt_for_password).and_return(password)
allow(Gitlab::TaskHelpers).to receive(:prompt_for_password).with('Confirm password: ').and_return(confirmation)
end
before do
before(:all) do
Rake.application.rake_require 'tasks/gitlab/password'
stub_username('foobar')
stub_password(password)
end
describe ':reset' do
let!(:user_1) { create(:user, username: 'foobar', password: User.random_password, password_automatically_set: true) }
let(:password) { User.random_password }
def stub_username(username)
allow(Gitlab::TaskHelpers).to receive(:prompt).with('Enter username: ').and_return(username)
end
def stub_password(password, confirmation = nil)
confirmation ||= password
allow(Gitlab::TaskHelpers).to receive(:prompt_for_password).and_return(password)
allow(Gitlab::TaskHelpers).to receive(:prompt_for_password).with('Confirm password: ').and_return(confirmation)
end
before do
stub_username('foobar')
stub_password(password)
end
context 'when all inputs are correct' do
it 'updates the password properly' do
expect(user_1.password_automatically_set?).to eq(true)
@ -80,4 +82,69 @@ RSpec.describe 'gitlab:password rake tasks', :silence_stdout do
end
end
end
describe ":fips_check_salts" do
subject(:run_rake) { run_rake_task('gitlab:password:fips_check_salts') }
context 'without fips mode' do
it 'aborts' do
expect { run_rake }.to abort_execution
end
end
context 'in fips mode', :fips_mode do
def hash(salt_len)
Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.digest(
User.random_password, 20_000, Devise.friendly_token(salt_len))
end
let!(:unmigrated_users) do
create(:user, username: 'user1', encrypted_password: hash(16))
create(:user, username: 'user2', encrypted_password: hash(16))
end
let!(:migrated_users) do
create(:user, username: 'user3', encrypted_password: hash(64))
create(:user, username: 'user4', encrypted_password: hash(64))
end
context 'with no extra argument' do
it 'only prints the user count' do
expect { run_rake }
.to output("Active users with unmigrated salts: 2 out of 4 total users\n")
.to_stdout
end
end
context 'with an error while inspecting a salt' do
before do
allow(Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512)
.to receive(:split_digest)
.and_raise(StandardError.new('test error'))
end
it 'prints an error message' do
expect { run_rake }
.to output(/Error getting salt for user user1: test error/)
.to_stdout
end
end
context 'with user printing enabled' do
subject(:run_rake) { run_rake_task('gitlab:password:fips_check_salts', "true") }
it 'prints the user names and user count' do
expect { run_rake }
.to output(
<<~MSG
Active users with unmigrated salts:
user1
user2
Active users with unmigrated salts: 2 out of 4 total users
MSG
).to_stdout
end
end
end
end
end