Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-10-22 15:13:59 +00:00
parent 4f27fd0e82
commit 0e48c192ad
66 changed files with 1005 additions and 275 deletions

View File

@ -82,7 +82,7 @@ workflow:
QA_ALLOW_LOCAL_REQUESTS: "true"
QA_SUITE_STATUS_ENV_FILE: $CI_PROJECT_DIR/suite_status.env
CNG_FORCE_COLOR: "true"
QA_RUN_IN_PARALLEL: "true"
QA_RUN_IN_PARALLEL: "false" # TODO: consider re-enabling after https://gitlab.com/groups/gitlab-org/quality/quality-engineering/-/epics/99 is completed
QA_PARALLEL_PROCESSES: 4
before_script:
- echo "SUITE_RAN=true" > "$QA_SUITE_STATUS_ENV_FILE"

View File

@ -1235,3 +1235,10 @@ Gitlab/NoFindInWorkers:
Include:
- app/workers/**/*
- ee/app/workers/**/*
Tailwind/StringInterpolation:
Enabled: true
Include:
- '{,ee/,jh/}app/helpers/**/*.rb'
- '{,ee/,jh/}app/components/**/*.{haml,rb}'
- '{,ee/,jh/}app/views/**/*.haml'

View File

@ -5,7 +5,6 @@ Layout/ExtraSpacing:
- 'ee/app/graphql/mutations/geo/registries/update.rb'
- 'ee/app/models/epic_issue.rb'
- 'ee/app/models/merge_request/predictions.rb'
- 'ee/app/models/search/namespace_index_assignment.rb'
- 'ee/app/services/dependencies/export_serializers/group_dependencies_service.rb'
- 'ee/app/workers/pull_mirrors/reenable_configuration_worker.rb'
- 'ee/lib/ee/gitlab/ci/pipeline/quota/size.rb'

View File

@ -1647,7 +1647,6 @@ Layout/LineLength:
- 'ee/spec/models/merge_requests/compliance_violation_spec.rb'
- 'ee/spec/models/merge_requests/external_status_check_spec.rb'
- 'ee/spec/models/namespace_setting_spec.rb'
- 'ee/spec/models/note_spec.rb'
- 'ee/spec/models/packages/package_file_spec.rb'
- 'ee/spec/models/project_import_data_spec.rb'
- 'ee/spec/models/project_import_state_spec.rb'

View File

@ -1345,7 +1345,6 @@ Style/InlineDisableAnnotation:
- 'ee/app/models/protected_environments/approval_rules/summarizable.rb'
- 'ee/app/models/protected_environments/authorizable.rb'
- 'ee/app/models/requirements_management/requirement.rb'
- 'ee/app/models/search/namespace_index_assignment.rb'
- 'ee/app/models/security/training.rb'
- 'ee/app/models/vulnerabilities/finding.rb'
- 'ee/app/policies/merge_request_diff_policy.rb'

View File

@ -13,7 +13,6 @@ Style/RedundantParentheses:
- 'ee/app/controllers/groups/billings_controller.rb'
- 'ee/app/helpers/ee/projects_helper.rb'
- 'ee/app/models/push_rule.rb'
- 'ee/app/models/search/index.rb'
- 'ee/app/services/concerns/approval_rules/updater.rb'
- 'ee/app/services/merge_trains/refresh_service.rb'
- 'ee/lib/ee/gitlab/git_access.rb'

View File

@ -202,7 +202,6 @@ Style/RedundantSelf:
- 'ee/lib/ee/gitlab/auth/ldap/sync/proxy.rb'
- 'ee/lib/ee/gitlab/auth/ldap/sync/users.rb'
- 'ee/lib/ee/gitlab/background_migration/delete_invalid_epic_issues.rb'
- 'ee/lib/elastic/instance_proxy_util.rb'
- 'ee/lib/elastic/latest/commit_config.rb'
- 'ee/lib/elastic/latest/issue_config.rb'
- 'ee/lib/elastic/latest/note_config.rb'

View File

@ -439,7 +439,7 @@
{"name":"numerizer","version":"0.2.0","platform":"ruby","checksum":"e58076d5ee5370417b7e52d9cb25836d62acd1b8d9a194c308707986c1705d7b"},
{"name":"oauth","version":"0.5.6","platform":"ruby","checksum":"4085fe28e0c5e2434135e00a6555294fd2a4ff96a98d1bdecdcd619fc6368dff"},
{"name":"oauth2","version":"2.0.9","platform":"ruby","checksum":"b21f9defcf52dc1610e0dfab4c868342173dcd707fd15c777d9f4f04e153f7fb"},
{"name":"octokit","version":"9.1.0","platform":"ruby","checksum":"7849a659d2722c629181f48d1d7e567c9539f1a85c9676144dbdbfc6ce288253"},
{"name":"octokit","version":"9.2.0","platform":"ruby","checksum":"4fa47ff35ce654127edf2c836ab9269bcc8829f5542dc1e86871f697ce7f4316"},
{"name":"ohai","version":"18.1.3","platform":"ruby","checksum":"980cfd6a6597f897e157532ba2168d29afb83a8f5e125f682ec3248c3407df95"},
{"name":"oj","version":"3.13.23","platform":"ruby","checksum":"206dfdc4020ad9974705037f269cfba211d61b7662a58c717cce771829ccef51"},
{"name":"oj-introspect","version":"0.7.2","platform":"ruby","checksum":"c415a44567ed2870d8e963a69421d9322128e194fab7867e37e54d5a25d5333d"},

View File

@ -1202,7 +1202,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
octokit (9.1.0)
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
ohai (18.1.3)

View File

@ -444,7 +444,7 @@
{"name":"numerizer","version":"0.2.0","platform":"ruby","checksum":"e58076d5ee5370417b7e52d9cb25836d62acd1b8d9a194c308707986c1705d7b"},
{"name":"oauth","version":"0.5.6","platform":"ruby","checksum":"4085fe28e0c5e2434135e00a6555294fd2a4ff96a98d1bdecdcd619fc6368dff"},
{"name":"oauth2","version":"2.0.9","platform":"ruby","checksum":"b21f9defcf52dc1610e0dfab4c868342173dcd707fd15c777d9f4f04e153f7fb"},
{"name":"octokit","version":"9.1.0","platform":"ruby","checksum":"7849a659d2722c629181f48d1d7e567c9539f1a85c9676144dbdbfc6ce288253"},
{"name":"octokit","version":"9.2.0","platform":"ruby","checksum":"4fa47ff35ce654127edf2c836ab9269bcc8829f5542dc1e86871f697ce7f4316"},
{"name":"ohai","version":"18.1.3","platform":"ruby","checksum":"980cfd6a6597f897e157532ba2168d29afb83a8f5e125f682ec3248c3407df95"},
{"name":"oj","version":"3.13.23","platform":"ruby","checksum":"206dfdc4020ad9974705037f269cfba211d61b7662a58c717cce771829ccef51"},
{"name":"oj-introspect","version":"0.7.2","platform":"ruby","checksum":"c415a44567ed2870d8e963a69421d9322128e194fab7867e37e54d5a25d5333d"},

View File

@ -1217,7 +1217,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
octokit (9.1.0)
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
ohai (18.1.3)

View File

@ -3,6 +3,7 @@ import isEmpty from 'lodash/isEmpty';
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { __ } from '~/locale';
import { sanitize } from '~/lib/dompurify';
export const VARIANT_SUCCESS = 'success';
export const VARIANT_WARNING = 'warning';
@ -76,6 +77,7 @@ export const createAlert = ({
error = null,
messageLinks = null,
dismissible = true,
renderMessageHTML = false,
}) => {
if (captureError && error) Sentry.captureException(error);
@ -90,6 +92,19 @@ export const createAlert = ({
}
const createMessageNodes = (h) => {
if (renderMessageHTML) {
return [
h('div', {
domProps: {
innerHTML: sanitize(message, {
ALLOWED_TAGS: ['a'],
ALLOWED_ATTR: ['href', 'rel', 'target'],
}),
},
}),
];
}
if (isEmpty(messageLinks)) {
return message;
}

View File

@ -4,7 +4,7 @@ import { uniqueId } from 'lodash';
import { sprintf, __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRefMixin from '../mixins/get_ref';
import DeleteBlobModal from './delete_blob_modal.vue';
import CommitChangesModal from './commit_changes_modal.vue';
import UploadBlobModal from './upload_blob_modal.vue';
const REPLACE_BLOB_MODAL_ID = 'modal-replace-blob';
@ -19,7 +19,7 @@ export default {
GlButtonGroup,
GlButton,
UploadBlobModal,
DeleteBlobModal,
CommitChangesModal,
LockButton: () => import('ee_component/repository/components/lock_button.vue'),
},
mixins: [getRefMixin, glFeatureFlagMixin()],
@ -89,7 +89,7 @@ export default {
deleteModalId() {
return uniqueId('delete-modal');
},
deleteModalTitle() {
deleteModalCommitMessage() {
return sprintf(__('Delete %{name}'), { name: this.name });
},
lockBtnTestId() {
@ -141,12 +141,11 @@ export default {
:replace-path="replacePath"
:primary-btn-text="$options.i18n.replacePrimaryBtnText"
/>
<delete-blob-modal
<commit-changes-modal
:ref="deleteModalId"
:modal-id="deleteModalId"
:modal-title="deleteModalTitle"
:delete-path="deletePath"
:commit-message="deleteModalTitle"
:commit-message="deleteModalCommitMessage"
:target-branch="targetBranch || ref"
:original-branch="originalBranch || ref"
:can-push-code="canPushCode"

View File

@ -1,47 +1,51 @@
<script>
import {
GlModal,
GlFormCheckbox,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlToggle,
GlFormRadio,
GlFormRadioGroup,
GlForm,
GlSprintf,
GlLink,
} from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { __, s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
import validation, { initFormField } from '~/vue_shared/directives/validation';
import { helpPagePath } from '~/helpers/help_page_helper';
import {
SECONDARY_OPTIONS_TEXT,
COMMIT_LABEL,
TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL,
COMMIT_MESSAGE_SUBJECT_MAX_LENGTH,
COMMIT_MESSAGE_BODY_MAX_LENGTH,
} from '../constants';
const initFormField = ({ value, required = true, skipValidation = false }) => ({
value,
required,
state: skipValidation ? true : null,
feedback: null,
});
export default {
csrf,
components: {
GlModal,
GlFormCheckbox,
GlFormGroup,
GlFormInput,
GlFormRadio,
GlFormRadioGroup,
GlFormTextarea,
GlToggle,
GlForm,
GlSprintf,
GlLink,
},
i18n: {
BRANCH: __('Branch'),
CURRENT_BRANCH_LABEL: __('Commit to the current %{branchName} branch'),
COMMIT_CHANGES: __('Commit changes'),
COMMIT_LABEL,
COMMIT_MESSAGE_HINT: __(
'Try to keep the first line under 52 characters and the others under 72.',
),
NEW_BRANCH_LABEl: __('Commit to a new branch'),
CREATE_MR_LABEL: __('Create a merge request for this change'),
LFS_WARNING_TITLE: __("The file you're about to delete is tracked by LFS"),
LFS_WARNING_PRIMARY_CONTENT: s__(
'BlobViewer|If you delete the file, it will be removed from the branch %{branch}.',
@ -51,14 +55,7 @@ export default {
),
LFS_CONTINUE_TEXT: __('Continue…'),
LFS_CANCEL_TEXT: __('Cancel'),
PRIMARY_OPTIONS_TEXT: __('Delete file'),
SECONDARY_OPTIONS_TEXT,
COMMIT_LABEL,
TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL,
COMMIT_MESSAGE_HINT: __(
'Try to keep the first line under 52 characters and the others under 72.',
),
},
directives: {
validation: validation(),
@ -68,10 +65,6 @@ export default {
type: String,
required: true,
},
modalTitle: {
type: String,
required: true,
},
deletePath: {
type: String,
required: true,
@ -113,23 +106,29 @@ export default {
fields: {
// fields key must match case of form name for validation directive to work
commit_message: initFormField({ value: this.commitMessage }),
branch_name: initFormField({ value: this.targetBranch, skipValidation: !this.canPushCode }),
branch_name: initFormField({
value: this.canPushToBranch ? this.originalBranch : '',
// Branch name is pre-filled with the current branch name in two scenarios and therefore doesn't need validation:
// 1. When the user doesn't have permission to push to the repo (e.g., guest user)
// 2. When the user can push directly to the current branch
skipValidation: !this.canPushCode || this.canPushToBranch,
}),
},
};
return {
lfsWarningDismissed: false,
loading: false,
createNewBranch: false,
createNewMr: true,
error: '',
form,
};
},
computed: {
primaryOptions() {
const defaultOptions = {
text: this.$options.i18n.PRIMARY_OPTIONS_TEXT,
text: this.$options.i18n.COMMIT_CHANGES,
attributes: {
variant: 'danger',
variant: 'confirm',
loading: this.loading,
disabled: this.loading || !this.form.state,
},
@ -150,12 +149,6 @@ export default {
},
};
},
showCreateNewMrToggle() {
return this.canPushCode && this.form.fields.branch_name.value !== this.originalBranch;
},
formCompleted() {
return this.form.fields.commit_message.value && this.form.fields.branch_name.value;
},
showHint() {
const splitCommitMessageByLineBreak = this.form.fields.commit_message.value
.trim()
@ -177,12 +170,29 @@ export default {
return this.isUsingLfs && !this.lfsWarningDismissed;
},
title() {
return this.showLfsWarning ? this.$options.i18n.LFS_WARNING_TITLE : this.modalTitle;
return this.showLfsWarning
? this.$options.i18n.LFS_WARNING_TITLE
: this.$options.i18n.COMMIT_CHANGES;
},
showDeleteForm() {
return !this.isUsingLfs || (this.isUsingLfs && this.lfsWarningDismissed);
},
},
watch: {
createNewBranch: {
handler(newValue) {
if (newValue) {
this.form.fields.branch_name.value = '';
} else {
this.form.fields.branch_name = {
...this.form.fields.branch_name,
value: this.originalBranch,
state: true,
};
}
},
},
},
methods: {
show() {
this.$refs[this.modalId].show();
@ -259,12 +269,7 @@ export default {
</template>
<template v-else>
<input type="hidden" name="original_branch" :value="originalBranch" />
<input
v-if="createNewMr || !canPushToBranch"
type="hidden"
name="create_merge_request"
value="1"
/>
<input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" />
<gl-form-group
:label="$options.i18n.COMMIT_LABEL"
label-for="commit_message"
@ -288,26 +293,66 @@ export default {
</gl-form-group>
<gl-form-group
v-if="canPushCode"
:label="$options.i18n.TARGET_BRANCH_LABEL"
label-for="branch_name"
:invalid-feedback="form.fields['branch_name'].feedback"
:label="$options.i18n.BRANCH"
label-for="branch_selection"
>
<gl-form-input
id="branch_name"
v-model="form.fields['branch_name'].value"
v-validation:[form.showValidation]
:state="form.fields['branch_name'].state"
:disabled="loading"
name="branch_name"
required
/>
<template v-if="canPushToBranch">
<gl-form-radio-group
v-model="createNewBranch"
name="branch_selection"
:label="$options.i18n.BRANCH"
>
<gl-form-radio :value="false">
<gl-sprintf :message="$options.i18n.CURRENT_BRANCH_LABEL">
<template #branchName
><code>{{ originalBranch }}</code>
</template>
</gl-sprintf>
</gl-form-radio>
<gl-form-radio :value="true">
{{ $options.i18n.NEW_BRANCH_LABEl }}
</gl-form-radio>
</gl-form-radio-group>
<div v-if="createNewBranch" class="gl-ml-6">
<gl-form-input
v-model="form.fields['branch_name'].value"
v-validation:[form.showValidation]
:state="form.fields['branch_name'].state"
:disabled="loading"
name="branch_name"
required
:placeholder="__('example-branch-name')"
class="gl-mt-2"
/>
<gl-form-checkbox v-if="createNewBranch" v-model="createNewMr" class="gl-mt-4">
<span>
{{ $options.i18n.CREATE_MR_LABEL }}
</span>
</gl-form-checkbox>
</div>
</template>
<template v-else>
<span>
{{ $options.i18n.NEW_BRANCH_LABEl }}
</span>
<gl-form-input
v-model="form.fields['branch_name'].value"
v-validation:[form.showValidation]
:state="form.fields['branch_name'].state"
:disabled="loading"
name="branch_name"
required
:placeholder="__('example-branch-name')"
class="gl-mt-2"
/>
<gl-form-checkbox v-model="createNewMr" class="gl-mt-4">
<span>
{{ $options.i18n.CREATE_MR_LABEL }}
</span>
</gl-form-checkbox>
</template>
</gl-form-group>
<gl-toggle
v-if="showCreateNewMrToggle"
v-model="createNewMr"
:disabled="loading"
:label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
/>
</template>
</gl-form>
</div>

View File

@ -154,7 +154,7 @@ export default function initValidation(customFeedbackMap = {}) {
* @param {*} fieldValues
* @returns formObject
*/
const initFormField = ({ value, required = true, skipValidation = false }) => ({
export const initFormField = ({ value, required = true, skipValidation = false }) => ({
value,
required,
state: skipValidation ? true : null,

View File

@ -53,6 +53,9 @@ export default {
i18n: {
enableConfidentiality: s__('WorkItem|Turn on confidentiality'),
disableConfidentiality: s__('WorkItem|Turn off confidentiality'),
confidentialParentTooltip: s__(
'WorkItem|Child items of a confidential parent must be confidential. Turn off confidentiality on the parent item first.',
),
notifications: s__('WorkItem|Notifications'),
notificationOn: s__('WorkItem|Notifications turned on.'),
notificationOff: s__('WorkItem|Notifications turned off.'),
@ -236,10 +239,18 @@ export default {
canPromoteToObjective() {
return this.canUpdate && this.workItemType === WORK_ITEM_TYPE_VALUE_KEY_RESULT;
},
confidentialItemText() {
return this.isConfidential
? this.$options.i18n.disableConfidentiality
: this.$options.i18n.enableConfidentiality;
confidentialItem() {
return {
text: this.isConfidential
? this.$options.i18n.disableConfidentiality
: this.$options.i18n.enableConfidentiality,
extraAttrs: {
disabled: this.isParentConfidential,
},
};
},
confidentialTooltip() {
return this.isParentConfidential ? this.$options.i18n.confidentialParentTooltip : '';
},
lockDiscussionText() {
return this.isDiscussionLocked ? __('Unlock discussion') : __('Lock discussion');
@ -474,12 +485,12 @@ export default {
</gl-disclosure-dropdown-item>
<gl-disclosure-dropdown-item
v-if="canUpdate && !isParentConfidential"
v-if="canUpdate"
v-gl-tooltip.left.viewport.d0="confidentialTooltip"
:item="confidentialItem"
:data-testid="$options.confidentialityTestId"
@action="handleToggleWorkItemConfidentiality"
>
<template #list-item>{{ confidentialItemText }}</template>
</gl-disclosure-dropdown-item>
/>
<gl-disclosure-dropdown-item
:data-testid="$options.copyReferenceTestId"

View File

@ -169,6 +169,8 @@ export default {
'.pika-single',
'.atwho-container',
'.tippy-content .gl-new-dropdown-panel',
'#blocked-by-issues-modal',
'#open-children-warning-modal',
],
};
</script>

View File

@ -13,10 +13,11 @@ import {
LINKED_CATEGORIES_MAP,
i18n,
} from '../constants';
import { findLinkedItemsWidget } from '../utils';
import { findHierarchyWidgets, findLinkedItemsWidget } from '../utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import workItemLinkedItemsQuery from '../graphql/work_item_linked_items.query.graphql';
import workItemOpenChildCountQuery from '../graphql/open_child_count.query.graphql';
export default {
components: {
@ -63,6 +64,7 @@ export default {
return {
updateInProgress: false,
blockerItems: [],
openChildItemsCount: 0,
};
},
apollo: {
@ -101,7 +103,7 @@ export default {
update({ workspace }) {
if (!workspace?.workItem) return [];
const linkedWorkItems = findLinkedItemsWidget(workspace.workItem).linkedItems?.nodes || [];
const linkedWorkItems = findLinkedItemsWidget(workspace.workItem)?.linkedItems?.nodes || [];
return linkedWorkItems.filter((item) => {
return item.linkType === LINKED_CATEGORIES_MAP.IS_BLOCKED_BY;
@ -113,6 +115,32 @@ export default {
Sentry.captureException(new Error(msg));
},
},
openChildItemsCount: {
query: workItemOpenChildCountQuery,
variables() {
return {
fullPath: this.fullPath,
iid: this.workItemIid,
};
},
skip() {
return !this.workItemIid;
},
update({ namespace }) {
if (!namespace?.workItem) return 0;
/** @type {Array<{countsByState: { opened : number }}> } */
const countsByType = findHierarchyWidgets(namespace.workItem.widgets)?.rolledUpCountsByType;
if (!countsByType) {
return 0;
}
const total = countsByType.reduce((acc, curr) => acc + curr.countsByState.opened, 0);
return total;
},
},
},
computed: {
isWorkItemOpen() {
@ -146,24 +174,46 @@ export default {
isBlocked() {
return this.blockerItems.length > 0;
},
hasOpenChildren() {
return this.openChildItemsCount > 0;
},
action() {
if (this.isBlocked && this.isWorkItemOpen) {
return () => this.$refs.blockedByIssuesModal.show();
if (this.isWorkItemOpen) {
if (this.isBlocked) {
return () => this.$refs.blockedByIssuesModal.show();
}
if (this.hasOpenChildren) {
return () => this.$refs.openChildrenWarningModal.show();
}
}
return this.updateWorkItem;
},
modalTitle() {
blockedByModalTitle() {
return sprintfWorkItem(
s__('WorkItem|Are you sure you want to close this blocked %{workItemType}?'),
this.workItemType,
);
},
modalBody() {
blockedByModalBody() {
return sprintfWorkItem(
s__('WorkItem|This %{workItemType} is currently blocked by the following items:'),
this.workItemType,
);
},
openChildrenModalTitle() {
return sprintfWorkItem(
s__('WorkItem|Are you sure you want to close this %{workItemType}?'),
this.workItemType,
);
},
openChildrenModalBody() {
return sprintfWorkItem(
s__(
'WorkItem|This %{workItemType} has open child items. If you close this %{workItemType}, they will remain open.',
),
this.workItemType,
);
},
modalActionCancel() {
return {
text: __('Cancel'),
@ -235,17 +285,30 @@ export default {
<gl-modal
ref="blockedByIssuesModal"
modal-id="blocked-by-issues-modal"
data-testid="blocked-by-issues-modal"
:action-cancel="modalActionCancel"
:action-primary="modalActionPrimary"
:title="modalTitle"
:title="blockedByModalTitle"
@primary="updateWorkItem"
>
<p>{{ modalBody }}</p>
<p>{{ blockedByModalBody }}</p>
<ul>
<li v-for="issue in blockerItems" :key="issue.workItem.iid">
<gl-link :href="issue.workItem.webUrl">#{{ issue.workItem.iid }}</gl-link>
</li>
</ul>
</gl-modal>
<gl-modal
ref="openChildrenWarningModal"
modal-id="open-children-warning-modal"
data-testid="open-children-warning-modal"
:action-cancel="modalActionCancel"
:action-primary="modalActionPrimary"
:title="openChildrenModalTitle"
@primary="updateWorkItem"
>
<p>{{ openChildrenModalBody }}</p>
</gl-modal>
</span>
</template>

View File

@ -0,0 +1,25 @@
query openChildItemCount($fullPath: ID!, $iid: String!) {
namespace(fullPath: $fullPath) {
id
workItem(iid: $iid) {
id
widgets {
... on WorkItemWidgetHierarchy {
type
rolledUpCountsByType {
countsByState {
opened
all
closed
}
workItemType {
id
name
iconName
}
}
}
}
}
}
}

View File

@ -32,6 +32,7 @@ fragment WorkItemHierarchy on WorkItem {
}
rolledUpCountsByType {
countsByState {
opened
all
closed
}

View File

@ -52,6 +52,7 @@ fragment WorkItemWidgets on WorkItemWidget {
hasParent
rolledUpCountsByType {
countsByState {
opened
all
closed
}

View File

@ -71,10 +71,6 @@
}
}
@mixin btn-white {
@include btn-color($white, $gray-200, $gray-50, $gray-200, $gray-100, $gray-300, $gl-text-color);
}
@mixin btn-with-margin {
margin-left: $gl-spacing-scale-3;
float: left;
@ -86,7 +82,7 @@
.btn {
@include btn-default;
@include btn-white;
@include btn-color($white, $gray-200, $gray-50, $gray-200, $gray-100, $gray-300, $gl-text-color);
color: $gl-text-color;
white-space: nowrap;

View File

@ -29,7 +29,7 @@ module Pajamas
end
def base_class
classes = ["gl-alert-#{@variant}"]
classes = ["gl-alert-#{@variant}"] # rubocop:disable Tailwind/StringInterpolation -- Not a CSS utility class
classes.push('gl-alert-not-dismissible') unless @dismissible
classes.push('gl-alert-no-icon') unless @show_icon
classes.push('gl-alert-has-title') if @title
@ -48,7 +48,7 @@ module Pajamas
end
def icon_classes
"gl-alert-icon#{' gl-alert-icon-no-title' if @title.nil?}"
"gl-alert-icon#{' gl-alert-icon-no-title' if @title.nil?}" # rubocop:disable Tailwind/StringInterpolation -- Not a CSS utility class
end
def dismissible_button_options

View File

@ -27,7 +27,7 @@ module Pajamas
private
def avatar_classes
classes = ["gl-avatar", "gl-avatar-s#{@size}", @class]
classes = ["gl-avatar", "gl-avatar-s#{@size}", @class] # rubocop:disable Tailwind/StringInterpolation -- Not a CSS utility class
if @item.is_a?(User) || @item.is_a?(AvatarEmail)
classes.push("gl-avatar-circle")
else
@ -36,7 +36,7 @@ module Pajamas
unless src
classes.push("gl-avatar-identicon")
classes.push("gl-avatar-identicon-bg#{((@item.id || 0) % 7) + 1}")
classes.push("gl-avatar-identicon-bg#{((@item.id || 0) % 7) + 1}") # rubocop:disable Tailwind/StringInterpolation -- Not a CSS utility class
end
classes.join(' ')

View File

@ -20,7 +20,9 @@ module Pajamas
private
def spinner_class
# rubocop:disable Tailwind/StringInterpolation -- Not a CSS utility class
["gl-spinner", "gl-spinner-#{@size}", "gl-spinner-#{@color} !gl-align-text-bottom"]
# rubocop:enable Tailwind/StringInterpolation
end
def html_options

View File

@ -414,7 +414,7 @@ module ApplicationHelper
end
def client_class_list
"gl-browser-#{browser_id} gl-platform-#{platform_id}"
"gl-browser-#{browser_id} gl-platform-#{platform_id}" # rubocop:disable Tailwind/StringInterpolation -- Not a CSS utility class
end
def client_js_flags

View File

@ -84,7 +84,7 @@ module IconsHelper
end
def external_snippet_icon(name)
content_tag(:span, "", class: "gl-snippet-icon gl-snippet-icon-#{name}")
content_tag(:span, "", class: "gl-snippet-icon gl-snippet-icon-#{name}") # rubocop:disable Tailwind/StringInterpolation -- Not a CSS utility class
end
def audit_icon(name, css_class: nil)

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
module Search
class Cache
DEFAULT_EXPIRES_IN = 1.minute
def self.lookup(...)
new(...).lookup { yield }
end
attr_reader :cache_key, :expires_in, :enabled
def initialize(resource:, action:, expires_in: DEFAULT_EXPIRES_IN, cache_key: nil, enabled: true)
@cache_key = cache_key || generate_cache_key(resource, action)
@expires_in = expires_in
@enabled = enabled
end
def lookup
return yield unless enabled
Rails.cache.fetch(cache_key, expires_in: expires_in) { yield }
end
private
def generate_cache_key(resource, action)
"search_#{resource.class.name.downcase}_#{resource.id}_#{action}"
end
end
end

View File

@ -0,0 +1,9 @@
---
name: prompt_migration_merge_request_reader
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/499593
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/169942
rollout_issue_url: https://gitlab.com/gitlab-org/modelops/applied-ml/code-suggestions/ai-assist/-/issues/595
milestone: '17.6'
group: group::custom models
type: experiment
default_enabled: false

View File

@ -1,8 +1,9 @@
---
name: search_index_partitioning_notes
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112402
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/392376
milestone: '15.10'
type: development
name: search_cache_authorizations
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/472011
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/169630
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/499721
milestone: '17.6'
group: group::global search
type: gitlab_com_derisk
default_enabled: false

View File

@ -4,6 +4,32 @@
breaking_change: true
reporter: hsutor
body: | # Do not modify this line, instead modify the lines below.
Whether your existing project access tokens have expiry dates automatically applied depends on what GitLab offering you have, and when you upgraded to GitLab 16.0 or later:
- On GitLab.com, during the 16.0 milestone, existing project access tokens without an expiry date were automatically given an expiry date of 365 days later than the current date.
- On GitLab self-managed, if you upgraded from GitLab 15.11 or earlier to GitLab 16.0 or later:
- On or before July 23, 2024, existing project access tokens without an expiry date were automatically given an expiry date of 365 days later than the current date.
This change is a breaking change.
- On or after July 24, 2024, existing project access tokens without an expiry date did not have an expiry date set.
On GitLab self-managed, if you do a new install of one of the following GitLab versions, your existing project access tokens do not have expiry dates automatically applied:
- 16.0.9
- 16.1.7
- 16.2.10
- 16.3.8
- 16.4.6
- 16.5.9
- 16.6.9
- 16.7.9
- 16.8.9
- 16.9.10
- 16.10.9
- 16.11.7
- 17.0.5
- 17.1.3
- 17.2.1
Access tokens that have no expiration date are valid indefinitely, which presents a security risk if the access token
is divulged. Because access tokens that have an expiration date are better, from GitLab 15.3 we
[populate a default expiration date](https://gitlab.com/gitlab-org/gitlab/-/issues/348660).

View File

@ -9,4 +9,4 @@ description: Represents an Advanced Search index
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113612/
milestone: '15.11'
gitlab_schema: gitlab_main_cell
exempt_from_sharding: true # table scheduled for deletion https://gitlab.com/gitlab-org/gitlab/-/issues/473381
exempt_from_sharding: true

View File

@ -506,6 +506,7 @@ kanban
kanbans
kaniko
Karma
Kata
KCachegrind
Kerberos
Keycloak
@ -1009,6 +1010,7 @@ swimlane
swimlanes
syncable
Sysbench
Sysbox
syscall
syscalls
syslog

View File

@ -11,7 +11,7 @@ DETAILS:
**Offering:** Self-managed
Job logs are sent by a runner while it's processing a job. You can see
logs in job pages, pipelines, email notifications, and so on.
logs in places like job pages, pipelines, and email notifications.
## Data flow
@ -147,6 +147,12 @@ job logs are automatically migrated to it along with the other job artifacts.
See "Phase 3: uploading" in [Data flow](#data-flow) to learn about the process.
## Maximum log file size
The job log file size limit in GitLab is 100 megabytes by default.
Any job that exceeds the limit is marked as failed, and dropped by the runner.
For more details, see [Maximum file size for job logs](../../administration/instance_limits.md#maximum-file-size-for-job-logs).
## Prevent local disk usage
If you want to avoid any local disk usage for job logs,
@ -158,11 +164,14 @@ you can do so using one of the following options:
## How to remove job logs
There isn't a way to automatically expire old job logs, but it's safe to remove
There isn't a way to automatically expire old job logs. However, it's safe to remove
them if they're taking up too much space. If you remove the logs manually, the
job output in the UI is empty.
For example, to delete all job logs older than 60 days, run the following
For details on how to delete job logs by using GitLab CLI,
see [Delete job logs](../../user/storage_management_automation.md#delete-job-logs).
Alternatively, you can delete job logs with shell commands. For example, to delete all job logs older than 60 days, run the following
command from a shell in your GitLab instance.
NOTE:

View File

@ -13,7 +13,9 @@ a base64-encoded ASCII text file with a `.gitlab-license` extension.
The first time you sign in to your GitLab instance, a note with a
link to the **Add license** page should be displayed.
Otherwise, to add your license:
Otherwise, add your license in the Admin area.
## Add license in the Admin area
1. Sign in to GitLab as an administrator.
1. On the left sidebar, at the bottom, select **Admin**.

View File

@ -26387,8 +26387,9 @@ Represents a member role.
| <a id="memberroleeditpath"></a>`editPath` **{warning-solid}** | [`String!`](#string) | **Introduced** in GitLab 16.11. **Status**: Experiment. Web UI path to edit the custom role. |
| <a id="memberroleenabledpermissions"></a>`enabledPermissions` **{warning-solid}** | [`CustomizablePermissionConnection!`](#customizablepermissionconnection) | **Introduced** in GitLab 16.5. **Status**: Experiment. Array of all permissions enabled for the custom role. |
| <a id="memberroleid"></a>`id` | [`MemberRoleID!`](#memberroleid) | ID of the member role. |
| <a id="memberrolememberscount"></a>`membersCount` **{warning-solid}** | [`Int!`](#int) | **Introduced** in GitLab 16.7. **Status**: Experiment. Total number of members with the custom role. |
| <a id="memberrolememberscount"></a>`membersCount` **{warning-solid}** | [`Int`](#int) | **Introduced** in GitLab 16.7. **Status**: Experiment. Number of times the role has been directly assigned to a group or project member. |
| <a id="memberrolename"></a>`name` | [`String!`](#string) | Name of the member role. |
| <a id="memberroleuserscount"></a>`usersCount` **{warning-solid}** | [`Int`](#int) | **Introduced** in GitLab 17.5. **Status**: Experiment. Number of users who have been directly assigned the role in at least one group or project. |
### `MergeAccessLevel`
@ -33902,8 +33903,9 @@ Represents a standard role.
| ---- | ---- | ----------- |
| <a id="standardroleaccesslevel"></a>`accessLevel` | [`Int!`](#int) | Access level as a number. |
| <a id="standardroledetailspath"></a>`detailsPath` **{warning-solid}** | [`String!`](#string) | **Introduced** in GitLab 17.4. **Status**: Experiment. URL path to the role details webpage. |
| <a id="standardrolememberscount"></a>`membersCount` **{warning-solid}** | [`Int!`](#int) | **Introduced** in GitLab 17.3. **Status**: Experiment. Total number of members with the standard role. |
| <a id="standardrolememberscount"></a>`membersCount` **{warning-solid}** | [`Int`](#int) | **Introduced** in GitLab 17.3. **Status**: Experiment. Number of times the role has been directly assigned to a group or project member. |
| <a id="standardrolename"></a>`name` | [`String!`](#string) | Access level as a string. |
| <a id="standardroleuserscount"></a>`usersCount` **{warning-solid}** | [`Int`](#int) | **Introduced** in GitLab 17.5. **Status**: Experiment. Number of users who have been directly assigned the role in at least one group or project. |
### `StandardsAdherenceChecksStatus`

View File

@ -310,6 +310,8 @@ You can view the contents of a job log in full screen mode by clicking **Show fu
To use full screen mode, your web browser must also support it. If your web browser does not support full screen mode, then the option is not available.
Job logs divided into sections can be collapsed or expanded. For more details, see [Expand and collapse job log sections](../yaml/script.md#expand-and-collapse-job-log-sections).
## Deployment jobs
Deployment jobs are a specific kind of CI job in that they deploy code to

View File

@ -112,9 +112,9 @@ steps:
### The result
1. ![Step 1](img/pipeline_wizard_sample_step1_v15_1.png)
1. ![Step 2](img/pipeline_wizard_sample_step2_v15_1.png)
1. ![Step 3](img/pipeline_wizard_sample_step3_v15_1.png)
1. ![Step 1 of the pipeline wizard, where you select a Docker image for building the image.](img/pipeline_wizard_sample_step1_v15_1.png)
1. ![Step 2 of the pipeline wizard, where you define commands for setting up a local build environment.](img/pipeline_wizard_sample_step2_v15_1.png)
1. ![Last step of the pipeline wizard, where you enter a commit message and commit the pipeline configuration.](img/pipeline_wizard_sample_step3_v15_1.png)
### The commit step

View File

@ -163,18 +163,18 @@ A user is not counted as a billable user if:
- They are a [blocked user](../../administration/moderate_users.md#block-a-user).
- The account is a GitLab-created service account:
- [Ghost User](../../user/profile/account/delete_account.md#associated-records).
- Bots such as:
- Bots:
- [Support Bot](../../user/project/service_desk/configure.md#support-bot-user).
- [Bot users for projects](../../user/project/settings/project_access_tokens.md#bot-users-for-projects).
- [Bot users for groups](../../user/group/settings/group_access_tokens.md#bot-users-for-groups).
Seat usage is reviewed [quarterly or annually](../quarterly_reconciliation.md).
If a user goes to a different top-level group (one they have created themselves, for example)
and that group does not have a paid subscription, they would not see any of the paid features.
If a user views or selects a different top-level group (one they have created themselves, for example)
and that group does not have a paid subscription, the user does not see any of the paid features.
It is also possible for users to belong to two different top-level groups with different subscriptions.
In this case, they would see only the features available to that subscription.
A user can belong to two different top-level groups with different subscriptions.
In this case, the user sees only the features available to that subscription.
### Free Guest users
@ -185,11 +185,11 @@ DETAILS:
In the **Ultimate** tier, users who are assigned the Guest role do not consume a seat.
The user must not be assigned any other role, anywhere in the instance or in the namespace for GitLab.com.
- If your project is private or internal, a user with the Guest role has
[a set of permissions](../../user/permissions.md#project-members-permissions).
- If your project is public, all users, including those with the Guest role
can access your project.
- For GitLab.com, if a user with the Guest role creates a project in their personal namespace, it does not consume a seat. The project is under the user's personal namespace and does not relate to the group with the Ultimate subscription.
- If your project is:
- Private or internal, a user with the Guest role has [a set of permissions](../../user/permissions.md#project-members-permissions).
- Public, all users, including those with the Guest role, can access your project.
- For GitLab.com, if a user with the Guest role creates a project in their personal namespace, the user does not consume a seat.
The project is under the user's personal namespace and does not relate to the group with the Ultimate subscription.
### Seats owed
@ -240,16 +240,18 @@ To view a list of seats being used:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Settings > Usage Quotas**.
1. On the **Seats** tab, view usage information.
1. Select the **Seats** tab.
For each user, a list shows groups and projects where the user is a direct member.
- **Group invite** indicates the user is a member of a [group shared with a group](../../user/project/members/sharing_projects_groups.md#invite-a-group-to-a-group).
- **Project invite** indicates the user is a member of a [group shared with a project](../../user/project/members/sharing_projects_groups.md#invite-a-group-to-a-project).
- **Group invite** indicates the user is a member of a [group invited to a group](../../user/project/members/sharing_projects_groups.md#invite-a-group-to-a-group).
- **Project invite** indicates the user is a member of a [group invited to a project](../../user/project/members/sharing_projects_groups.md#invite-a-group-to-a-project).
The data in seat usage listing, **Seats in use**, and **Seats in subscription** are updated live.
The counts for **Max seats used** and **Seats owed** are updated once per day.
### View billing information
To view your subscription information and a summary of seat counts:
1. On the left sidebar, select **Search or go to** and find your group.
@ -262,14 +264,13 @@ To view your subscription information and a summary of seat counts:
## Search seat usage
To search billable users:
To search seat usage:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Settings > Usage Quotas**.
1. On the **Seats tab**, enter a string in the search field. A minimum of 3
characters are required.
1. On the **Seats tab**, enter a string in the search field. A minimum of 3 characters are required.
The search returns those users whose first name, last name, or username contain the search string.
The search returns users whose first name, last name, or username contain the search string.
For example:
@ -318,10 +319,7 @@ To add seats to a subscription:
1. Check the **I accept the Privacy Statement and Terms of Service** checkbox.
1. Select **Purchase seats**.
The following is emailed to you:
- A payment receipt. You can also access this information in the Customers Portal under
[**Invoices**](https://customers.gitlab.com/invoices).
You receive the payment receipt by email. You can also access the receipt in the Customers Portal under [**Invoices**](https://customers.gitlab.com/invoices).
## Remove users from subscription
@ -335,8 +333,8 @@ To remove a billable user from your GitLab.com subscription:
If you add a member to a group by using the [share a group with another group](../../user/project/members/sharing_projects_groups.md#invite-a-group-to-a-group) feature, you can't remove the member by using this method. Instead, you can either:
- Remove the member from the shared group. You must be a group owner to do this.
- From the group's membership page, remove access from the entire shared group.
- [Remove the member from the shared group](../../user/group/index.md#remove-a-member-from-the-group).
- [Remove the invited group](../../user/project/members/sharing_projects_groups.md#remove-an-invited-group).
To remove a user from your self-managed instance, see [Deactivate a user](../../administration/moderate_users.md#deactivate-a-user).
@ -413,7 +411,7 @@ You can [disable automatic subscription renewal](../self_managed/index.md#enable
1. Sign in to GitLab.com.
1. From either your personal homepage or the group's page, go to **Settings > Usage Quotas**.
1. Select **Storage** tab.
1. Select the **Storage** tab.
1. For each read-only project, total by how much its **Usage** exceeds the free quota and purchased
storage. You must purchase the storage increment that exceeds this total.
1. Select **Buy storage**. You are taken to the Customers Portal.
@ -438,7 +436,7 @@ To purchase additional storage for your group on GitLab.com:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Settings > Usage Quotas**.
1. Select **Storage** tab.
1. Select the **Storage** tab.
1. Select **Buy storage**. You are taken to the Customers Portal.
1. In the **Subscription details** section, enter the desired quantity of storage packs.
1. In the **Customer information** section, verify your address.
@ -448,8 +446,7 @@ To purchase additional storage for your group on GitLab.com:
After your payment is processed, the extra storage is available for your group namespace.
To confirm the available storage, go to your group, and then select
**Settings > Usage Quotas** and select the **Storage** tab.
To confirm the available storage, follow the first three steps listed above.
The **Purchased storage available** total is incremented by the amount purchased. All locked
projects are unlocked and their excess usage is deducted from the additional storage.

View File

@ -0,0 +1,76 @@
---
stage: Create
group: Source Code
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"
description: "Introduction to Git rebase and force push, methods to resolve merge conflicts through the command line."
---
# Advanced Git operations
Advanced Git operations help you perform tasks to maintain and manage your code.
They are more complex actions that go beyond [basic Git operations](basics.md).
These operations enable you to:
- Rewrite commit history.
- Revert and undo changes.
- Manage remote repository connections.
They provide you with the following benefits:
- Code quality: Maintain a clean, linear project history.
- Problem solving: Provide tools to fix mistakes or adjust your repository's state.
- Workflow optimization: Streamline complex development processes.
- Collaboration: Facilitate smoother teamwork in large or complex projects.
To use Git operations effectively, it's important to understand key concepts such as
repositories, branches, commits, and merge requests.
For more information, see [Get started learning Git](get_started.md).
## Best practices
When you use advanced Git operations, you should:
- Create a backup or work on a [separate branch](branch.md).
- Communicate with your team before when you use operations that affect shared branch history.
- Use descriptive [commit messages](../../tutorials/update_commit_messages/index.md)
when you rewrite history.
- Update your knowledge of Git to stay current with best practices and new features.
For more information, see the [Git documentation](https://git-scm.com/docs).
- Practice advanced operations in a test repository.
## Rebase and resolve conflicts
The `git rebase` command updates your branch with the contents of another branch.
It confirms that changes in your branch don't conflict with changes in the target branch.
If you have a [merge conflict](../../user/project/merge_requests/conflicts.md),
you can rebase to fix it.
For more information, see [Rebase to address merge conflicts](git_rebase.md).
## Revert and undo changes
The following Git commands help you to revert and undo changes:
- `git revert`: Creates a new commit that undoes the changes made in a previous commit.
This helps you to undo a mistake or a change that you no longer need.
- `git reset`: Resets and undoes changes that are not yet committed.
- `git restore`: Restores changes that are lost or deleted.
For more information, see [Revert changes](undo.md).
## Update Git remote URLs
The `git remote set-url` command updates the URL of the remote repository.
Use this if:
- You imported an existing project from another Git repository host.
- Your organization moved your projects to a new GitLab instance with a new domain name.
- The project was renamed to a new path in the same GitLab instance.
For more information, see [Update Git remote URLs](../../tutorials/update_git_remote_url/index.md).
## Related topics
- [Getting started](get_started.md)
- [Basic Git operations](basics.md)
- [Common Git commands](commands.md)

View File

@ -4175,6 +4175,32 @@ However, since certificate-based integration with Kubernetes clusters is depreca
</div>
Whether your existing project access tokens have expiry dates automatically applied depends on what GitLab offering you have, and when you upgraded to GitLab 16.0 or later:
- On GitLab.com, during the 16.0 milestone, existing project access tokens without an expiry date were automatically given an expiry date of 365 days later than the current date.
- On GitLab self-managed, if you upgraded from GitLab 15.11 or earlier to GitLab 16.0 or later:
- On or before July 23, 2024, existing project access tokens without an expiry date were automatically given an expiry date of 365 days later than the current date.
This change is a breaking change.
- On or after July 24, 2024, existing project access tokens without an expiry date did not have an expiry date set.
On GitLab self-managed, if you do a new install of one of the following GitLab versions, your existing project access tokens do not have expiry dates automatically applied:
- 16.0.9
- 16.1.7
- 16.2.10
- 16.3.8
- 16.4.6
- 16.5.9
- 16.6.9
- 16.7.9
- 16.8.9
- 16.9.10
- 16.10.9
- 16.11.7
- 17.0.5
- 17.1.3
- 17.2.1
Access tokens that have no expiration date are valid indefinitely, which presents a security risk if the access token
is divulged. Because access tokens that have an expiration date are better, from GitLab 15.3 we
[populate a default expiration date](https://gitlab.com/gitlab-org/gitlab/-/issues/348660).

View File

@ -10,11 +10,8 @@ DETAILS:
**Tier:** Ultimate
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/13266) in GitLab 17.2 [with a flag](../../../administration/feature_flags.md) named `pipeline_execution_policy_type`. Enabled by default. [Feature flag removed in GitLab 17.3](https://gitlab.com/gitlab-org/gitlab/-/issues/454278).
FLAG:
The availability of this feature is controlled by a feature flag.
For more information, see the history.
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/13266) in GitLab 17.2 [with a flag](../../../administration/feature_flags.md) named `pipeline_execution_policy_type`. Enabled by default.
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/454278) in GitLab 17.3. Feature flag `pipeline_execution_policy_type` removed.
Use Pipeline execution policies to enforce CI/CD jobs for all applicable projects.

View File

@ -19,6 +19,8 @@ Before making changes, ensure either the SAML response includes the `groups` att
and the `AttributeValue` value matches the **SAML Group Name** in GitLab,
or that all groups are removed from GitLab to disable Group Sync.
SAML group sync allows users to be assigned to pre-existing GitLab groups with specific permissions based on the user's group assignment in the SAML identity provider (IdP). This feature allows you to create a many-to-many mapping between SAML IdP groups and GitLab groups. For example, if the user `@amelia` is assigned to the `security` group in the SAML IdP, SAML group sync allows you to assign `@amelia` to the `security-gitlab` and `vulnerability` GitLab groups with `maintainer` and `reporter` permissions, respectively. SAML group sync does not create groups. You [create groups separately](../index.md#create-a-group), and then create the mapping.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For a demo of Group Sync using Azure, see [Demo: SAML Group Sync](https://youtu.be/Iqvo2tJfXjg).
@ -34,8 +36,7 @@ Prerequisites:
When SAML is enabled, users with the Owner role see a new menu
item in group **Settings > SAML Group Links**.
- You can configure one or more **SAML Group Links** to map a SAML identity
provider (IdP) group name to a GitLab role.
- You can configure one or more **SAML Group Links** to map a SAML IdP group name to a GitLab role.
- Members of the SAML IdP group are added as members of the GitLab
group on their next SAML sign-in.
- Group membership is evaluated each time a user signs in using SAML.

View File

@ -33,7 +33,8 @@ To set up infrastructure for workspaces:
1. Install an Ingress controller of your choice (for example, `ingress-nginx`).
1. [Install](../clusters/agent/install/index.md) and [configure](gitlab_agent_configuration.md) the GitLab agent.
1. Point [`dns_zone`](gitlab_agent_configuration.md#dns_zone) and `*.<dns_zone>`
to the load balancer exposed by the Ingress controller. This load balancer must support WebSockets.
to the load balancer exposed by the Ingress controller.
This load balancer must support WebSockets.
1. [Set up the GitLab workspaces proxy](set_up_workspaces_proxy.md).
1. Optional. [Configure sudo access for a workspace](#configure-sudo-access-for-a-workspace).
@ -93,39 +94,40 @@ You can configure secure sudo access for a workspace with:
### With Sysbox
[Sysbox](https://github.com/nestybox/sysbox) is a container runtime that enhances containers to
improves container isolation and enables containers to run same workloads as VMs.
[Sysbox](https://github.com/nestybox/sysbox) is a container runtime that improves container isolation
and enables containers to run the same workloads as virtual machines.
To configure your workspace to use Sysbox:
To configure sudo access for a workspace with Sysbox:
1. [Install Sysbox in the Kubernetes cluster](https://github.com/nestybox/sysbox#installation).
1. Configure the following settings in the GitLab agent for workspaces:
- Set [`default_runtime_class`](gitlab_agent_configuration.md#default_runtime_class) to the runtime class set up by Sysbox. For example, `sysbox-runc`.
1. In the Kubernetes cluster, [install Sysbox](https://github.com/nestybox/sysbox#installation).
1. In the GitLab agent for workspaces:
- Set [`default_runtime_class`](gitlab_agent_configuration.md#default_runtime_class) to the runtime class
of Sysbox (for example, `sysbox-runc`).
- Set [`allow_privilege_escalation`](gitlab_agent_configuration.md#allow_privilege_escalation) to `true`.
### With Kata Containers
[Kata Containers](https://github.com/kata-containers/kata-containers) is a standard implementation of lightweight virtual machines (VMs)
that feel and perform like containers, but provide the workload isolation and security advantages of VMs.
[Kata Containers](https://github.com/kata-containers/kata-containers) is a standard implementation of lightweight
virtual machines that perform like containers but provide the workload isolation and security of virtual machines.
To configure your workspace to use Kata Containers:
To configure sudo access for a workspace with Kata Containers:
1. [Install Kata Containers in the Kubernetes cluster](https://github.com/kata-containers/kata-containers/tree/main/docs/install).
1. Configure the following settings in the GitLab agent for workspaces:
- Set [`default_runtime_class`](gitlab_agent_configuration.md#default_runtime_class) to one of the runtime classes set up by Kata Containers. For example, `kata-qemu`.
1. In the Kubernetes cluster, [install Kata Containers](https://github.com/kata-containers/kata-containers/tree/main/docs/install).
1. In the GitLab agent for workspaces:
- Set [`default_runtime_class`](gitlab_agent_configuration.md#default_runtime_class) to one of the runtime classes
of Kata Containers (for example, `kata-qemu`).
- Set [`allow_privilege_escalation`](gitlab_agent_configuration.md#allow_privilege_escalation) to `true`.
### With user namespaces
User namespaces isolate the user running inside the container from the user in the host.
In Kubernetes 1.30, this feature is in beta.
[User namespaces](https://kubernetes.io/docs/concepts/workloads/pods/user-namespaces/) isolate the user
running inside the container from the user on the host.
To configure your workspace to use the user namespaces feature in Kubernetes:
To configure sudo access for a workspace with user namespaces:
1. [Configure Kubernetes cluster with User namespaces](https://kubernetes.io/blog/2024/04/22/userns-beta/).
1. Configure the following settings in GitLab agent for workspaces:
- Set [`use_kubernetes_user_namespaces`](gitlab_agent_configuration.md#use_kubernetes_user_namespaces) to `true`.
- Set [`allow_privilege_escalation`](gitlab_agent_configuration.md#allow_privilege_escalation) to `true`.
1. In the Kubernetes cluster, [configure user namespaces](https://kubernetes.io/blog/2024/04/22/userns-beta/).
1. In the GitLab agent for workspaces, set [`use_kubernetes_user_namespaces`](gitlab_agent_configuration.md#use_kubernetes_user_namespaces)
and [`allow_privilege_escalation`](gitlab_agent_configuration.md#allow_privilege_escalation) to `true`.
## Connect to a workspace with SSH

View File

@ -337,8 +337,8 @@ remote_development:
Use this setting to specify whether to use the user namespaces feature in Kubernetes.
User namespaces isolate the user running inside the container from the user in the host.
In Kubernetes 1.30, this feature is in beta.
[User namespaces](https://kubernetes.io/docs/concepts/workloads/pods/user-namespaces/) isolate the user
running inside the container from the user on the host.
The default value is `false`. Before you set the value to `true`, ensure your Kubernetes cluster supports user namespaces.

View File

@ -116,8 +116,8 @@ regardless of the machine or platform you use.
- `parent`, `projects`, and `starterProjects` are not supported.
- For `variables`, keys must not start with `gl-`, `gl_`, `GL-`, or `GL_`.
- For `attributes`:
- `pod-overrides` must not be set, either at the root level or within `components`.
- `container-overrides` must not be set within `components`.
- `pod-overrides` must not be set at the root level or in `components`.
- `container-overrides` must not be set in `components`.
### `container` component type

View File

@ -105,6 +105,13 @@ module Gitlab
Gitlab::Metrics.counter(name, comment)
end
def self.duplicate_job_name_errors_counter
name = :gitlab_ci_duplicate_job_name_errors_counter
comment = 'Counter of duplicate job name errors'
Gitlab::Metrics.counter(name, comment)
end
def ci_minutes_exceeded_builds_counter
name = :ci_minutes_exceeded_builds_counter
comment = 'Count of builds dropped due to compute minutes exceeded'

View File

@ -13849,6 +13849,12 @@ msgstr ""
msgid "Commit statistics for %{ref} %{start_time} - %{end_time}"
msgstr ""
msgid "Commit to a new branch"
msgstr ""
msgid "Commit to the current %{branchName} branch"
msgstr ""
msgid "CommitBoxTitle|Commit"
msgstr ""
@ -16204,6 +16210,9 @@ msgstr ""
msgid "Create a merge request branch target."
msgstr ""
msgid "Create a merge request for this change"
msgstr ""
msgid "Create a merge request, or edit your criteria and try again."
msgstr ""
@ -18231,9 +18240,6 @@ msgstr ""
msgid "Delete epic"
msgstr ""
msgid "Delete file"
msgstr ""
msgid "Delete group"
msgstr ""
@ -23560,7 +23566,7 @@ msgstr ""
msgid "Filter users"
msgstr ""
msgid "Filter: %{filter_name})"
msgid "Filter: %{filter_name}"
msgstr ""
msgid "Finalizing"
@ -62193,6 +62199,9 @@ msgstr ""
msgid "WorkItem|Are you sure you want to cancel editing?"
msgstr ""
msgid "WorkItem|Are you sure you want to close this %{workItemType}?"
msgstr ""
msgid "WorkItem|Are you sure you want to close this blocked %{workItemType}?"
msgstr ""
@ -62214,6 +62223,9 @@ msgstr ""
msgid "WorkItem|Child items"
msgstr ""
msgid "WorkItem|Child items of a confidential parent must be confidential. Turn off confidentiality on the parent item first."
msgstr ""
msgid "WorkItem|Child removal reverted"
msgstr ""
@ -62574,6 +62586,9 @@ msgstr ""
msgid "WorkItem|The current task"
msgstr ""
msgid "WorkItem|This %{workItemType} has open child items. If you close this %{workItemType}, they will remain open."
msgstr ""
msgid "WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access"
msgstr ""
@ -64976,6 +64991,9 @@ msgstr ""
msgid "event"
msgstr ""
msgid "example-branch-name"
msgstr ""
msgid "example.com"
msgstr ""

View File

@ -3,7 +3,7 @@
source 'https://rubygems.org'
gem 'gitlab-qa', '~> 14', '>= 14.17.0', require: 'gitlab/qa'
gem 'gitlab_quality-test_tooling', '~> 1.39.0', require: false
gem 'gitlab_quality-test_tooling', '~> 2.0.0', require: false
gem 'gitlab-utils', path: '../gems/gitlab-utils'
gem 'activesupport', '~> 7.0.8.4' # This should stay in sync with the root's Gemfile
gem 'allure-rspec', '~> 2.25.0'

View File

@ -47,6 +47,7 @@ GEM
tins (~> 1.0)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.8)
binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1)
builder (3.3.0)
@ -125,11 +126,11 @@ GEM
rainbow (>= 3, < 4)
table_print (= 1.5.7)
zeitwerk (>= 2, < 3)
gitlab_quality-test_tooling (1.39.0)
gitlab_quality-test_tooling (2.0.0)
activesupport (>= 7.0, < 7.2)
amatch (~> 0.4.1)
fog-google (~> 1.24, >= 1.24.1)
gitlab (~> 4.19)
gitlab (>= 4.19, < 6.0)
http (~> 5.0)
influxdb-client (~> 3.1)
nokogiri (~> 1.10)
@ -208,8 +209,7 @@ GEM
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.23.1)
mize (0.4.1)
protocol (~> 2.0)
mize (0.6.1)
multi_json (1.15.0)
multi_xml (0.6.0)
net-http (0.4.1)
@ -234,8 +234,6 @@ GEM
coderay
parser
unparser
protocol (2.0.0)
ruby_parser (~> 3.0)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
@ -296,9 +294,6 @@ GEM
rspec-core (>= 2, < 4, != 2.12.0)
ruby-debug-ide (0.7.3)
rake (>= 0.8.1)
ruby_parser (3.21.0)
racc (~> 1.5)
sexp_processor (~> 4.16)
rubyzip (2.3.2)
sawyer (0.9.2)
addressable (>= 2.3.5)
@ -309,7 +304,6 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sexp_processor (4.17.1)
signet (0.19.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
@ -321,7 +315,8 @@ GEM
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.1)
tins (1.32.1)
tins (1.37.0)
bigdecimal
sync
trailblazer-option (0.1.2)
tty-color (0.6.0)
@ -369,7 +364,7 @@ DEPENDENCIES
gitlab-cng!
gitlab-qa (~> 14, >= 14.17.0)
gitlab-utils!
gitlab_quality-test_tooling (~> 1.39.0)
gitlab_quality-test_tooling (~> 2.0.0)
googleauth (~> 1.9.0)
influxdb-client (~> 3.1)
junit_merge (~> 0.1.2)

View File

@ -10,7 +10,7 @@ module QA
def self.included(base)
super
base.view 'app/assets/javascripts/repository/components/delete_blob_modal.vue' do
base.view 'app/assets/javascripts/repository/components/commit_changes_modal.vue' do
element 'commit-message-field'
end

View File

@ -35,8 +35,8 @@ module QA
click_element('question-icon')
end
def click_delete_file
click_on 'Delete file'
def click_commit_changes
click_on 'Commit changes'
end
end
end

View File

@ -10,7 +10,7 @@ module QA
end
context 'when imported via api' do
it 'imports project', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347670' do
it 'imports project', :blocking, testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347670' do
expect_project_import_finished_successfully
aggregate_failures do

View File

@ -16,7 +16,7 @@ module QA
Page::File::Show.perform do |file|
file.click_delete
file.add_commit_message(commit_message_for_delete)
file.click_delete_file
file.click_commit_changes
end
Page::Project::Show.perform do |project|

View File

@ -32,7 +32,7 @@ RSpec.describe 'Projects > Files > User deletes files', :js, feature_category: :
click_on('Delete')
fill_in(:commit_message, with: 'New commit message', visible: true)
click_button('Delete file')
click_button('Commit changes')
expect(page).to have_current_path(project_tree_path(project, 'master/'), ignore_query: true)
expect(page).not_to have_content('.gitignore')
@ -62,7 +62,7 @@ RSpec.describe 'Projects > Files > User deletes files', :js, feature_category: :
click_on('Delete')
fill_in(:commit_message, with: 'New commit message', visible: true)
click_button('Delete file')
click_button('Commit changes')
fork = user.fork_of(project2.reload)

View File

@ -1,7 +1,11 @@
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { sanitize } from '~/lib/dompurify';
import { createAlert, VARIANT_WARNING } from '~/alert';
jest.mock('~/lib/dompurify', () => ({
sanitize: jest.fn((val) => val),
}));
jest.mock('~/sentry/sentry_browser_wrapper');
describe('Flash', () => {
@ -359,6 +363,36 @@ describe('Flash', () => {
expect(messageLinks).toHaveLength(0);
});
});
describe('when rendered as HTML', () => {
const findMessageHTML = () => document.querySelector('.gl-alert-body div').innerHTML;
const message =
'error: <a href="https://documentation.com/further-information">learn more</a>';
it('renders the given message as HTML', () => {
alert = createAlert({
message,
renderMessageHTML: true,
});
expect(findMessageHTML()).toBe(message);
});
it('sanitizes the given message', () => {
expect(sanitize).not.toHaveBeenCalled();
createAlert({
message,
renderMessageHTML: true,
});
expect(sanitize).toHaveBeenCalledTimes(1);
expect(sanitize).toHaveBeenCalledWith(message, {
ALLOWED_TAGS: ['a'],
ALLOWED_ATTR: ['href', 'rel', 'target'],
});
});
});
});
});
});

View File

@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import BlobButtonGroup from '~/repository/components/blob_button_group.vue';
import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue';
import CommitChangesModal from '~/repository/components/commit_changes_modal.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
const DEFAULT_PROPS = {
@ -40,7 +40,7 @@ describe('BlobButtonGroup component', () => {
show: showUploadBlobModalMock,
},
});
const DeleteBlobModalStub = stubComponent(DeleteBlobModal, {
const DeleteBlobModalStub = stubComponent(CommitChangesModal, {
methods: {
show: showDeleteBlobModalMock,
},
@ -56,12 +56,12 @@ describe('BlobButtonGroup component', () => {
},
stubs: {
UploadBlobModal: UploadBlobModalStub,
DeleteBlobModal: DeleteBlobModalStub,
CommitChangesModal: DeleteBlobModalStub,
},
});
};
const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal);
const findDeleteBlobModal = () => wrapper.findComponent(CommitChangesModal);
const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal);
const findDeleteButton = () => wrapper.findByTestId('delete');
const findReplaceButton = () => wrapper.findByTestId('replace');
@ -92,13 +92,13 @@ describe('BlobButtonGroup component', () => {
});
it('triggers the UploadBlobModal from the replace button', () => {
findReplaceButton().trigger('click');
findReplaceButton().vm.$emit('click');
expect(showUploadBlobModalMock).toHaveBeenCalled();
});
it('triggers the DeleteBlobModal from the delete button', () => {
findDeleteButton().trigger('click');
it('triggers the CommitChangesModal from the delete button', () => {
findDeleteButton().vm.$emit('click');
expect(showDeleteBlobModalMock).toHaveBeenCalled();
});
@ -109,14 +109,14 @@ describe('BlobButtonGroup component', () => {
});
it('does not trigger the UploadBlobModal from the replace button', () => {
findReplaceButton().trigger('click');
findReplaceButton().vm.$emit('click');
expect(showUploadBlobModalMock).not.toHaveBeenCalled();
expect(wrapper.emitted().fork).toHaveLength(1);
});
it('does not trigger the DeleteBlobModal from the delete button', () => {
findDeleteButton().trigger('click');
findDeleteButton().vm.$emit('click');
expect(showDeleteBlobModalMock).not.toHaveBeenCalled();
expect(wrapper.emitted().fork).toHaveLength(1);
@ -143,7 +143,7 @@ describe('BlobButtonGroup component', () => {
});
});
it('renders DeleteBlobModel', () => {
it('renders CommitChangesModal for delete', () => {
createComponent();
const { targetBranch, originalBranch } = DEFAULT_INJECT;
@ -151,7 +151,6 @@ describe('BlobButtonGroup component', () => {
const title = `Delete ${name}`;
expect(findDeleteBlobModal().props()).toMatchObject({
modalTitle: title,
commitMessage: title,
targetBranch,
originalBranch,

View File

@ -1,16 +1,24 @@
import { GlFormTextarea, GlModal, GlFormInput, GlToggle, GlForm, GlSprintf } from '@gitlab/ui';
import {
GlFormTextarea,
GlModal,
GlFormCheckbox,
GlFormInput,
GlFormRadioGroup,
GlForm,
GlSprintf,
GlFormRadio,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { RENDER_ALL_SLOTS_TEMPLATE, stubComponent } from 'helpers/stub_component';
import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue';
import CommitChangesModal from '~/repository/components/commit_changes_modal.vue';
import { sprintf } from '~/locale';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
const initialProps = {
modalId: 'Delete-blob',
modalTitle: 'Delete File',
deletePath: 'some/path',
commitMessage: 'Delete File',
targetBranch: 'some-target-branch',
@ -20,15 +28,15 @@ const initialProps = {
emptyRepo: false,
};
const { i18n } = DeleteBlobModal;
const { i18n } = CommitChangesModal;
describe('DeleteBlobModal', () => {
describe('CommitChangesModal', () => {
let wrapper;
const createComponentFactory =
(mountFn) =>
(props = {}) => {
wrapper = mountFn(DeleteBlobModal, {
wrapper = mountFn(CommitChangesModal, {
propsData: {
...initialProps,
...props,
@ -50,6 +58,11 @@ describe('DeleteBlobModal', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findForm = () => findModal().findComponent(GlForm);
const findCommitTextarea = () => findForm().findComponent(GlFormTextarea);
const findFormRadioGroup = () => findForm().findComponent(GlFormRadioGroup);
const findRadioGroup = () => findForm().findAllComponents(GlFormRadio);
const findCurrentBranchRadioOption = () => findRadioGroup().at(0);
const findNewBranchRadioOption = () => findRadioGroup().at(1);
const findCreateMrCheckbox = () => findForm().findComponent(GlFormCheckbox);
const findTargetInput = () => findForm().findComponent(GlFormInput);
const findCommitHint = () => wrapper.find('[data-testid="hint"]');
@ -92,13 +105,10 @@ describe('DeleteBlobModal', () => {
it('renders Modal component', () => {
createComponent();
const { modalTitle: title } = initialProps;
expect(findModal().props()).toMatchObject({
title,
size: 'md',
actionPrimary: {
text: 'Delete file',
text: 'Commit changes',
},
actionCancel: {
text: 'Cancel',
@ -112,32 +122,53 @@ describe('DeleteBlobModal', () => {
expect(findForm().attributes('action')).toBe(initialProps.deletePath);
});
it.each`
component | defaultValue | canPushCode | targetBranch | originalBranch | exist
${GlFormTextarea} | ${initialProps.commitMessage} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true}
${GlFormInput} | ${initialProps.targetBranch} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true}
${GlFormInput} | ${undefined} | ${false} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${false}
${GlToggle} | ${'true'} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true}
${GlToggle} | ${undefined} | ${true} | ${'same-branch'} | ${'same-branch'} | ${false}
`(
'has the correct form fields',
({ component, defaultValue, canPushCode, targetBranch, originalBranch, exist }) => {
createComponent({
canPushCode,
targetBranch,
originalBranch,
});
const formField = wrapper.findComponent(component);
it('shows the correct form fields when commit to current branch', () => {
createComponent();
expect(findCommitTextarea().exists()).toBe(true);
expect(findRadioGroup()).toHaveLength(2);
expect(findCurrentBranchRadioOption().text()).toContain(initialProps.originalBranch);
expect(findNewBranchRadioOption().text()).toBe('Commit to a new branch');
});
if (!exist) {
expect(formField.exists()).toBe(false);
return;
}
it('shows the correct form fields when commit to new branch', async () => {
createComponent();
expect(findTargetInput().exists()).toBe(false);
expect(formField.exists()).toBe(true);
expect(formField.attributes('value')).toBe(defaultValue);
},
);
findFormRadioGroup().vm.$emit('input', true);
await nextTick();
expect(findTargetInput().exists()).toBe(true);
expect(findCreateMrCheckbox().text()).toBe('Create a merge request for this change');
});
it('shows the correct form fields when `canPushToBranch` is `false`', () => {
createComponent({ canPushToBranch: false, canPushCode: true });
expect(wrapper.vm.$data.form.fields.branch_name.value).toBe('');
expect(findCommitTextarea().exists()).toBe(true);
expect(findRadioGroup().exists()).toBe(false);
expect(findTargetInput().exists()).toBe(true);
expect(findCreateMrCheckbox().text()).toBe('Create a merge request for this change');
});
it('clear branch name when new branch option is selected', async () => {
createComponent();
expect(wrapper.vm.$data.form.fields.branch_name).toEqual({
feedback: null,
required: true,
state: true,
value: 'main',
});
findFormRadioGroup().vm.$emit('input', true);
await nextTick();
expect(wrapper.vm.$data.form.fields.branch_name).toEqual({
feedback: null,
required: true,
state: true,
value: '',
});
});
it.each`
input | value | emptyRepo | canPushCode | canPushToBranch | exist
@ -181,6 +212,7 @@ describe('DeleteBlobModal', () => {
beforeEach(async () => {
createFullComponent();
findFormRadioGroup().vm.$emit('input', true);
await nextTick();
});
@ -219,6 +251,9 @@ describe('DeleteBlobModal', () => {
describe('invalid form', () => {
beforeEach(async () => {
findFormRadioGroup().vm.$emit('input', true);
await nextTick();
await fillForm({ targetText: '', commitText: '' });
});
@ -236,6 +271,8 @@ describe('DeleteBlobModal', () => {
describe('valid form', () => {
beforeEach(async () => {
findFormRadioGroup().vm.$emit('input', true);
await nextTick();
await fillForm({
targetText: 'some valid target branch',
commitText: 'some valid commit message',

View File

@ -321,15 +321,17 @@ describe('WorkItemActions component', () => {
expect(wrapper.emitted('toggleWorkItemConfidentiality')[0]).toEqual([true]);
});
it.each`
props | propName | value
${{ isParentConfidential: true }} | ${'isParentConfidential'} | ${true}
${{ canUpdate: false }} | ${'canUpdate'} | ${false}
`('does not render when $propName is $value', ({ props }) => {
createComponent(props);
it('does not render when canUpdate is false', () => {
createComponent({ canUpdate: false });
expect(findConfidentialityToggleButton().exists()).toBe(false);
});
it('is disabled when item has confidential parent', () => {
createComponent({ isParentConfidential: true });
expect(findConfidentialityToggleButton().props('item')).toMatchObject({
extraAttrs: { disabled: true },
});
});
});
describe('delete action', () => {

View File

@ -1,10 +1,10 @@
import { GlButton, GlModal, GlLink } from '@gitlab/ui';
import { GlButton, GlLink } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import WorkItemStateToggle from '~/work_items/components/work_item_state_toggle.vue';
import {
STATE_OPEN,
@ -16,11 +16,15 @@ import {
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import workItemLinkedItemsQuery from '~/work_items/graphql/work_item_linked_items.query.graphql';
import workItemOpenChildCountQuery from '~/work_items/graphql/open_child_count.query.graphql';
import {
updateWorkItemMutationResponse,
mockBlockedByLinkedItem,
workItemByIidResponseFactory,
workItemBlockedByLinkedItemsResponse,
workItemNoBlockedByLinkedItemsResponse,
mockOpenChildrenCount,
mockNoOpenChildrenCount,
} from '../mock_data';
describe('Work Item State toggle button component', () => {
@ -34,27 +38,32 @@ describe('Work Item State toggle button component', () => {
const querySuccessHander = jest.fn().mockResolvedValue(workItemQueryResponse);
const workItemBlockedByItemsSuccessHandler = jest
.fn()
.mockResolvedValue(workItemBlockedByLinkedItemsResponse);
.mockResolvedValue(workItemNoBlockedByLinkedItemsResponse);
const openChildCountSuccessHandler = jest.fn().mockResolvedValue(mockNoOpenChildrenCount);
const findStateToggleButton = () => wrapper.findComponent(GlButton);
const findModal = () => wrapper.findComponent(GlModal);
const findModalLinkAt = (index) => findModal().findAllComponents(GlLink).at(index);
const findBlockedByModal = () => wrapper.findByTestId('blocked-by-issues-modal');
const findBlockedByModalLinkAt = (index) =>
findBlockedByModal().findAllComponents(GlLink).at(index);
const findOpenChildrenModal = () => wrapper.findByTestId('open-children-warning-modal');
const { id, iid } = workItemQueryResponse.data.workspace.workItem;
const createComponent = ({
mutationHandler = mutationSuccessHandler,
workItemLinkedItemsHandler = workItemBlockedByItemsSuccessHandler,
workItemOpenChildCountHandler = openChildCountSuccessHandler,
canUpdate = true,
workItemState = STATE_OPEN,
workItemType = 'Task',
hasComment = false,
} = {}) => {
wrapper = shallowMount(WorkItemStateToggle, {
wrapper = shallowMountExtended(WorkItemStateToggle, {
apolloProvider: createMockApollo([
[updateWorkItemMutation, mutationHandler],
[workItemByIidQuery, querySuccessHander],
[workItemLinkedItemsQuery, workItemLinkedItemsHandler],
[workItemOpenChildCountQuery, workItemOpenChildCountHandler],
]),
propsData: {
workItemId: id,
@ -173,18 +182,22 @@ describe('Work Item State toggle button component', () => {
const blockers = mockBlockedByLinkedItem.linkedItems.nodes;
beforeEach(async () => {
createComponent();
createComponent({
workItemLinkedItemsHandler: jest
.fn()
.mockResolvedValue(workItemBlockedByLinkedItemsResponse),
});
await waitForPromises();
});
it('has title text', () => {
expect(findModal().attributes('title')).toBe(
expect(findBlockedByModal().attributes('title')).toBe(
'Are you sure you want to close this blocked task?',
);
});
it('has body text', () => {
expect(findModal().text()).toContain(
expect(findBlockedByModal().text()).toContain(
'This task is currently blocked by the following items:',
);
});
@ -195,12 +208,36 @@ describe('Work Item State toggle button component', () => {
${'second'} | ${1}
`('$ordinal blocked-by issue link', ({ index }) => {
it('has link text', () => {
expect(findModalLinkAt(index).text()).toBe(`#${blockers[index].workItem.iid}`);
expect(findBlockedByModalLinkAt(index).text()).toBe(`#${blockers[index].workItem.iid}`);
});
it('has url', () => {
expect(findModalLinkAt(index).attributes('href')).toBe(blockers[index].workItem.webUrl);
expect(findBlockedByModalLinkAt(index).attributes('href')).toBe(
blockers[index].workItem.webUrl,
);
});
});
});
describe('with open child items', () => {
beforeEach(async () => {
createComponent({
workItemOpenChildCountHandler: jest.fn().mockResolvedValue(mockOpenChildrenCount),
workItemType: 'Epic',
});
await waitForPromises();
});
it('has title text', () => {
expect(findOpenChildrenModal().attributes('title')).toBe(
'Are you sure you want to close this epic?',
);
});
it('has body text', () => {
expect(findOpenChildrenModal().text()).toContain(
'This epic has open child items. If you close this epic, they will remain open.',
);
});
});
});

View File

@ -699,6 +699,13 @@ export const mockBlockedByLinkedItem = {
__typename: 'WorkItemWidgetLinkedItems',
};
export const mockNoLinkedItems = {
type: WIDGET_TYPE_LINKED_ITEMS,
linkedItems: {
nodes: [],
},
};
export const mockLinkedItems = {
type: WIDGET_TYPE_LINKED_ITEMS,
linkedItems: {
@ -886,6 +893,20 @@ export const workItemSingleLinkedItemResponse = {
},
};
export const workItemNoBlockedByLinkedItemsResponse = {
data: {
workspace: {
__typename: 'Namespace',
id: 'gid://gitlab/Group/1',
workItem: {
id: 'gid://gitlab/WorkItem/2',
widgets: [mockNoLinkedItems],
__typename: 'WorkItem',
},
},
},
};
export const workItemBlockedByLinkedItemsResponse = {
data: {
workspace: {
@ -2295,6 +2316,7 @@ export const mockDepthLimitReachedByType = [
export const mockRolledUpCountsByType = [
{
countsByState: {
opened: 0,
all: 3,
closed: 0,
__typename: 'WorkItemStateCountsType',
@ -2309,6 +2331,7 @@ export const mockRolledUpCountsByType = [
},
{
countsByState: {
opened: 0,
all: 5,
closed: 2,
__typename: 'WorkItemStateCountsType',
@ -2323,6 +2346,7 @@ export const mockRolledUpCountsByType = [
},
{
countsByState: {
opened: 0,
all: 2,
closed: 1,
__typename: 'WorkItemStateCountsType',
@ -2358,6 +2382,138 @@ export const mockHierarchyWidget = {
__typename: 'WorkItemWidgetHierarchy',
};
export const mockOpenChildrenCount = {
data: {
namespace: {
id: 'gid://gitlab/Group/33',
workItem: {
id: 'gid://gitlab/WorkItem/843',
widgets: [
{
type: 'HIERARCHY',
rolledUpCountsByType: [
{
countsByState: {
opened: 0,
all: 0,
closed: 0,
__typename: 'WorkItemStateCountsType',
},
workItemType: {
id: 'gid://gitlab/WorkItems::Type/8',
name: 'Epic',
iconName: 'issue-type-epic',
__typename: 'WorkItemType',
},
__typename: 'WorkItemTypeCountsByState',
},
{
countsByState: {
opened: 1,
all: 1,
closed: 0,
__typename: 'WorkItemStateCountsType',
},
workItemType: {
id: 'gid://gitlab/WorkItems::Type/1',
name: 'Issue',
iconName: 'issue-type-issue',
__typename: 'WorkItemType',
},
__typename: 'WorkItemTypeCountsByState',
},
{
countsByState: {
opened: 0,
all: 0,
closed: 0,
__typename: 'WorkItemStateCountsType',
},
workItemType: {
id: 'gid://gitlab/WorkItems::Type/5',
name: 'Task',
iconName: 'issue-type-task',
__typename: 'WorkItemType',
},
__typename: 'WorkItemTypeCountsByState',
},
],
__typename: 'WorkItemWidgetHierarchy',
},
],
__typename: 'WorkItem',
},
__typename: 'Namespace',
},
},
};
export const mockNoOpenChildrenCount = {
data: {
namespace: {
id: 'gid://gitlab/Group/33',
workItem: {
id: 'gid://gitlab/WorkItem/843',
widgets: [
{
type: 'HIERARCHY',
rolledUpCountsByType: [
{
countsByState: {
opened: 0,
all: 0,
closed: 0,
__typename: 'WorkItemStateCountsType',
},
workItemType: {
id: 'gid://gitlab/WorkItems::Type/8',
name: 'Epic',
iconName: 'issue-type-epic',
__typename: 'WorkItemType',
},
__typename: 'WorkItemTypeCountsByState',
},
{
countsByState: {
opened: 0,
all: 0,
closed: 0,
__typename: 'WorkItemStateCountsType',
},
workItemType: {
id: 'gid://gitlab/WorkItems::Type/1',
name: 'Issue',
iconName: 'issue-type-issue',
__typename: 'WorkItemType',
},
__typename: 'WorkItemTypeCountsByState',
},
{
countsByState: {
opened: 0,
all: 0,
closed: 0,
__typename: 'WorkItemStateCountsType',
},
workItemType: {
id: 'gid://gitlab/WorkItems::Type/5',
name: 'Task',
iconName: 'issue-type-task',
__typename: 'WorkItemType',
},
__typename: 'WorkItemTypeCountsByState',
},
],
__typename: 'WorkItemWidgetHierarchy',
},
],
__typename: 'WorkItem',
},
__typename: 'Namespace',
},
},
};
export const workItemHierarchyTreeResponse = {
data: {
workItem: {

View File

@ -14,15 +14,15 @@ RSpec.describe Gitlab::Fp::Result, feature_category: :shared do
context 'when checked with .ok? and .err?' do
it 'works with ok result' do
result = Gitlab::Fp::Result.ok(:success)
expect(result.ok?).to eq(true)
expect(result.err?).to eq(false)
expect(result.ok?).to be(true)
expect(result.err?).to be(false)
expect(result.unwrap).to eq(:success)
end
it 'works with error result' do
result = Gitlab::Fp::Result.err(:failure)
expect(result.err?).to eq(true)
expect(result.ok?).to eq(false)
expect(result.err?).to be(true)
expect(result.ok?).to be(false)
expect(result.unwrap_err).to eq(:failure)
end
end
@ -97,7 +97,7 @@ RSpec.describe Gitlab::Fp::Result, feature_category: :shared do
.and_then(->(value) { Gitlab::Fp::Result.ok(value + 1) })
.and_then(->(value) { Gitlab::Fp::Result.ok(value + 1) })
expect(final_result.ok?).to eq(true)
expect(final_result.ok?).to be(true)
expect(final_result.unwrap).to eq(3)
end
@ -108,7 +108,7 @@ RSpec.describe Gitlab::Fp::Result, feature_category: :shared do
.and_then(->(value) { Gitlab::Fp::Result.err("invalid: #{value}") })
.and_then(->(value) { Gitlab::Fp::Result.ok(value + 1) })
expect(final_result.err?).to eq(true)
expect(final_result.err?).to be(true)
expect(final_result.unwrap_err).to eq('invalid: 1')
end
end
@ -137,7 +137,7 @@ RSpec.describe Gitlab::Fp::Result, feature_category: :shared do
.and_then(::MyModuleUsingResult.method(:double))
.and_then(::MyModuleUsingResult::MyClassUsingResult.method(:triple))
expect(final_result.ok?).to eq(true)
expect(final_result.ok?).to be(true)
expect(final_result.unwrap).to eq(6)
end
@ -150,7 +150,7 @@ RSpec.describe Gitlab::Fp::Result, feature_category: :shared do
.and_then(::MyModuleUsingResult.method(:return_err))
.and_then(::MyModuleUsingResult.method(:double))
expect(final_result.err?).to eq(true)
expect(final_result.err?).to be(true)
expect(final_result.unwrap_err).to eq('invalid: 6')
end
end
@ -195,7 +195,7 @@ RSpec.describe Gitlab::Fp::Result, feature_category: :shared do
.map(->(value) { value + 1 })
.map(->(value) { value + 1 })
expect(final_result.ok?).to eq(true)
expect(final_result.ok?).to be(true)
expect(final_result.unwrap).to eq(3)
end
@ -206,7 +206,7 @@ RSpec.describe Gitlab::Fp::Result, feature_category: :shared do
.and_then(->(value) { Gitlab::Fp::Result.err("invalid: #{value}") })
.map(->(value) { value + 1 })
expect(final_result.err?).to eq(true)
expect(final_result.err?).to be(true)
expect(final_result.unwrap_err).to eq('invalid: 1')
end
end
@ -231,7 +231,7 @@ RSpec.describe Gitlab::Fp::Result, feature_category: :shared do
.map(::MyModuleNotUsingResult.method(:double))
.map(::MyModuleNotUsingResult::MyClassNotUsingResult.method(:triple))
expect(final_result.ok?).to eq(true)
expect(final_result.ok?).to be(true)
expect(final_result.unwrap).to eq(6)
end
@ -243,7 +243,7 @@ RSpec.describe Gitlab::Fp::Result, feature_category: :shared do
.and_then(->(value) { Gitlab::Fp::Result.err("invalid: #{value}") })
.map(::MyModuleUsingResult.method(:double))
expect(final_result.err?).to eq(true)
expect(final_result.err?).to be(true)
expect(final_result.unwrap_err).to eq('invalid: 2')
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Search::Cache, feature_category: :global_search do
let_it_be(:resource) { create(:user) }
let(:action) { 'test' }
let(:cache_key) { "search_user_#{resource.id}_#{action}" }
describe '.lookup' do
it 'uses Rails.cache to fetch the value' do
expect(Rails.cache).to receive(:fetch)
.with(cache_key, expires_in: described_class::DEFAULT_EXPIRES_IN)
.and_call_original
described_class.lookup(resource: resource, action: action) { 'cached_value' }
end
context 'when caching is disabled' do
it 'does not use the cache' do
expect(Rails.cache).not_to receive(:fetch)
result = described_class.lookup(resource: resource, action: action, enabled: false) { 'uncached_value' }
expect(result).to eq('uncached_value')
end
end
context 'with a custom cache key' do
let(:custom_cache_key) { 'my_cache_key' }
it 'uses the cache key' do
expect(Rails.cache).to receive(:fetch)
.with(custom_cache_key, expires_in: described_class::DEFAULT_EXPIRES_IN)
.and_call_original
described_class.lookup(resource: resource, action: action, cache_key: custom_cache_key) { 'cached_value' }
end
end
context 'with a custom expiration' do
let(:custom_expiration) { 5.minutes }
it 'uses the expiration' do
expect(Rails.cache).to receive(:fetch)
.with(cache_key, expires_in: custom_expiration)
.and_call_original
described_class.lookup(resource: resource, action: action, expires_in: custom_expiration) { 'cached_value' }
end
end
end
end

View File

@ -1363,7 +1363,6 @@
- './ee/spec/models/namespaces/free_user_cap_spec.rb'
- './ee/spec/models/namespaces/storage/root_excess_size_spec.rb'
- './ee/spec/models/namespaces/storage/root_size_spec.rb'
- './ee/spec/models/note_spec.rb'
- './ee/spec/models/packages/package_file_spec.rb'
- './ee/spec/models/path_lock_spec.rb'
- './ee/spec/models/plan_spec.rb'

View File

@ -422,6 +422,8 @@ RSpec.shared_examples 'work items notifications' do
click_button(class: 'gl-toggle')
wait_for_requests
expect(page).to have_button(class: 'gl-toggle is-checked')
end
end

View File

@ -46,3 +46,51 @@ RSpec.shared_examples 'merge request approval policy editor' do
expect(page).to have_current_path(project_merge_request_path(policy_management_project, 1))
end
end
# Requires the url to the policy editor:
# - path_to_merge_request_approval_policy_editor
# - path_to_merge_request_approval_policy_editor
RSpec.shared_examples 'merge request approval policy invalid policy properties' do
let(:merge_request_approval_policy_with_exceeding_number_of_rules) do
fixture_file('security_orchestration/merge_request_approval_policy_with_exceeding_number_of_rules.yml', dir: 'ee')
end
it "fails to create a policy without name" do
click_button _('Configure with a merge request')
expect(page).to have_content('Empty policy name')
expect(page).to have_current_path(path_to_merge_request_approval_policy_editor)
end
it "fails to create a policy without approvers" do
fill_in _('Name'), with: 'Missing approvers'
click_button _('Configure with a merge request')
expect(page).to have_content('Required approvals exceed eligible approvers.')
expect(page).to have_current_path(path_to_merge_request_approval_policy_editor)
end
it "fails to create a policy without rules" do
fill_in _('Name'), with: 'Missing rules'
page.within(find_by_testid('actions-section')) do
select_from_listbox 'Roles', from: 'Choose approver type'
select_from_listbox 'Owner', from: 'Choose specific role'
end
click_button _('Configure with a merge request')
expect(page).to have_content("Invalid policy YAML")
expect(page).to have_current_path(path_to_merge_request_approval_policy_editor)
end
it "fails to create policy with exceeding number of rules" do
click_button _('.yaml mode')
editor_set_value(merge_request_approval_policy_with_exceeding_number_of_rules.to_s)
click_button _('Configure with a merge request')
expect(page).to have_content("Invalid policy YAML")
expect(page).to have_current_path(path_to_merge_request_approval_policy_editor)
end
end