From 0e48c192ad06f15047e529c1bbdfd42a44666ca7 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Tue, 22 Oct 2024 15:13:59 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .gitlab/ci/test-on-cng/main.gitlab-ci.yml | 2 +- .rubocop.yml | 7 + .rubocop_todo/layout/extra_spacing.yml | 1 - .rubocop_todo/layout/line_length.yml | 1 - .../style/inline_disable_annotation.yml | 1 - .rubocop_todo/style/redundant_parentheses.yml | 1 - .rubocop_todo/style/redundant_self.yml | 1 - Gemfile.checksum | 2 +- Gemfile.lock | 2 +- Gemfile.next.checksum | 2 +- Gemfile.next.lock | 2 +- app/assets/javascripts/alert.js | 15 ++ .../components/blob_button_group.vue | 11 +- ...lob_modal.vue => commit_changes_modal.vue} | 161 +++++++++++------- .../vue_shared/directives/validation.js | 2 +- .../components/work_item_actions.vue | 27 ++- .../components/work_item_drawer.vue | 2 + .../components/work_item_state_toggle.vue | 79 ++++++++- .../graphql/open_child_count.query.graphql | 25 +++ .../work_item_hierarchy.fragment.graphql | 1 + .../work_item_widgets.fragment.graphql | 1 + app/assets/stylesheets/framework/buttons.scss | 6 +- app/components/pajamas/alert_component.rb | 4 +- app/components/pajamas/avatar_component.rb | 4 +- app/components/pajamas/spinner_component.rb | 2 + app/helpers/application_helper.rb | 2 +- app/helpers/icons_helper.rb | 2 +- app/services/concerns/search/cache.rb | 31 ++++ .../prompt_migration_merge_request_reader.yml | 9 + .../search_cache_authorizations.yml} | 11 +- .../15-4-non-expiring-access-tokens.yml | 26 +++ db/docs/search_indices.yml | 2 +- doc/.vale/gitlab_base/spelling-exceptions.txt | 2 + doc/administration/cicd/job_logs.md | 15 +- doc/administration/license_file.md | 4 +- doc/api/graphql/reference/index.md | 6 +- doc/ci/jobs/index.md | 2 + doc/development/cicd/pipeline_wizard.md | 6 +- doc/subscriptions/gitlab_com/index.md | 51 +++--- doc/topics/git/advanced.md | 76 +++++++++ doc/update/deprecations.md | 26 +++ .../policies/pipeline_execution_policies.md | 7 +- doc/user/group/saml_sso/group_sync.md | 5 +- doc/user/workspace/configuration.md | 42 ++--- .../workspace/gitlab_agent_configuration.md | 4 +- doc/user/workspace/index.md | 4 +- lib/gitlab/ci/pipeline/metrics.rb | 7 + locale/gitlab.pot | 26 ++- qa/Gemfile | 2 +- qa/Gemfile.lock | 19 +-- qa/qa/page/file/shared/commit_message.rb | 2 +- qa/qa/page/file/show.rb | 4 +- .../import/import_github_repo_spec.rb | 2 +- .../file/delete_file_via_web_spec.rb | 2 +- .../projects/files/user_deletes_files_spec.rb | 4 +- spec/frontend/alert_spec.js | 34 ++++ .../components/blob_button_group_spec.js | 21 ++- ...l_spec.js => commit_changes_modal_spec.js} | 105 ++++++++---- .../components/work_item_actions_spec.js | 16 +- .../components/work_item_state_toggle_spec.js | 59 +++++-- spec/frontend/work_items/mock_data.js | 156 +++++++++++++++++ spec/lib/gitlab/fp/result_spec.rb | 24 +-- spec/services/concerns/search/cache_spec.rb | 51 ++++++ spec/support/rspec_order_todo.yml | 1 - .../features/work_items_shared_examples.rb | 2 + .../security/policies_shared_examples.rb | 48 ++++++ 66 files changed, 1005 insertions(+), 275 deletions(-) rename app/assets/javascripts/repository/components/{delete_blob_modal.vue => commit_changes_modal.vue} (63%) create mode 100644 app/assets/javascripts/work_items/graphql/open_child_count.query.graphql create mode 100644 app/services/concerns/search/cache.rb create mode 100644 config/feature_flags/experiment/prompt_migration_merge_request_reader.yml rename config/feature_flags/{development/search_index_partitioning_notes.yml => gitlab_com_derisk/search_cache_authorizations.yml} (51%) create mode 100644 doc/topics/git/advanced.md rename spec/frontend/repository/components/{delete_blob_modal_spec.js => commit_changes_modal_spec.js} (73%) create mode 100644 spec/services/concerns/search/cache_spec.rb diff --git a/.gitlab/ci/test-on-cng/main.gitlab-ci.yml b/.gitlab/ci/test-on-cng/main.gitlab-ci.yml index cd6dbca85c1..736bc2adc49 100644 --- a/.gitlab/ci/test-on-cng/main.gitlab-ci.yml +++ b/.gitlab/ci/test-on-cng/main.gitlab-ci.yml @@ -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" diff --git a/.rubocop.yml b/.rubocop.yml index df99fcb807f..68ba3aa7fff 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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' diff --git a/.rubocop_todo/layout/extra_spacing.yml b/.rubocop_todo/layout/extra_spacing.yml index 2b352fdfd97..76ca09f9f14 100644 --- a/.rubocop_todo/layout/extra_spacing.yml +++ b/.rubocop_todo/layout/extra_spacing.yml @@ -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' diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index b5f347f23a6..d06c7f5c375 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -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' diff --git a/.rubocop_todo/style/inline_disable_annotation.yml b/.rubocop_todo/style/inline_disable_annotation.yml index e5e5dadea0e..799c899f729 100644 --- a/.rubocop_todo/style/inline_disable_annotation.yml +++ b/.rubocop_todo/style/inline_disable_annotation.yml @@ -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' diff --git a/.rubocop_todo/style/redundant_parentheses.yml b/.rubocop_todo/style/redundant_parentheses.yml index 3bb507c1074..b483df815ac 100644 --- a/.rubocop_todo/style/redundant_parentheses.yml +++ b/.rubocop_todo/style/redundant_parentheses.yml @@ -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' diff --git a/.rubocop_todo/style/redundant_self.yml b/.rubocop_todo/style/redundant_self.yml index 6ffd55f942b..20ddd099200 100644 --- a/.rubocop_todo/style/redundant_self.yml +++ b/.rubocop_todo/style/redundant_self.yml @@ -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' diff --git a/Gemfile.checksum b/Gemfile.checksum index ceb529e2db6..2e5fe03f480 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -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"}, diff --git a/Gemfile.lock b/Gemfile.lock index a39332b7346..ac2a4f34424 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/Gemfile.next.checksum b/Gemfile.next.checksum index edd812f7b95..7957750680a 100644 --- a/Gemfile.next.checksum +++ b/Gemfile.next.checksum @@ -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"}, diff --git a/Gemfile.next.lock b/Gemfile.next.lock index fb00ba3b0ed..3ba2ca489b7 100644 --- a/Gemfile.next.lock +++ b/Gemfile.next.lock @@ -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) diff --git a/app/assets/javascripts/alert.js b/app/assets/javascripts/alert.js index fd060c91810..1af4315487e 100644 --- a/app/assets/javascripts/alert.js +++ b/app/assets/javascripts/alert.js @@ -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; } diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index ed7212eb9a6..3aec35ee960 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -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" /> - 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 { diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js index c129aff9236..2aeaacc2ab1 100644 --- a/app/assets/javascripts/vue_shared/directives/validation.js +++ b/app/assets/javascripts/vue_shared/directives/validation.js @@ -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, diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 7f52730a6cf..9210a4c41b4 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -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 { - - + /> diff --git a/app/assets/javascripts/work_items/components/work_item_state_toggle.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue index 4a5afb230da..3b3841ba3fa 100644 --- a/app/assets/javascripts/work_items/components/work_item_state_toggle.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue @@ -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 { -

{{ modalBody }}

+

{{ blockedByModalBody }}

  • #{{ issue.workItem.iid }}
+ + +

{{ openChildrenModalBody }}

+
diff --git a/app/assets/javascripts/work_items/graphql/open_child_count.query.graphql b/app/assets/javascripts/work_items/graphql/open_child_count.query.graphql new file mode 100644 index 00000000000..2cd6a335938 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/open_child_count.query.graphql @@ -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 + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_hierarchy.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_hierarchy.fragment.graphql index 6a3fbc217d0..7c8538a3ea0 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_hierarchy.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_hierarchy.fragment.graphql @@ -32,6 +32,7 @@ fragment WorkItemHierarchy on WorkItem { } rolledUpCountsByType { countsByState { + opened all closed } diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index fc94c898cf3..8e6b90895e3 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -52,6 +52,7 @@ fragment WorkItemWidgets on WorkItemWidget { hasParent rolledUpCountsByType { countsByState { + opened all closed } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index c1f8c42bf6e..3756410a0ce 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -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; diff --git a/app/components/pajamas/alert_component.rb b/app/components/pajamas/alert_component.rb index dfe29c57a9d..d63321fa967 100644 --- a/app/components/pajamas/alert_component.rb +++ b/app/components/pajamas/alert_component.rb @@ -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 diff --git a/app/components/pajamas/avatar_component.rb b/app/components/pajamas/avatar_component.rb index 1be6042278c..91aef8a98b2 100644 --- a/app/components/pajamas/avatar_component.rb +++ b/app/components/pajamas/avatar_component.rb @@ -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(' ') diff --git a/app/components/pajamas/spinner_component.rb b/app/components/pajamas/spinner_component.rb index 025594caf8d..1c81301f4ce 100644 --- a/app/components/pajamas/spinner_component.rb +++ b/app/components/pajamas/spinner_component.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 582c45193f5..aae79a6cc3b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 06dc99981f7..77467f0e451 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -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) diff --git a/app/services/concerns/search/cache.rb b/app/services/concerns/search/cache.rb new file mode 100644 index 00000000000..ca92ca330b1 --- /dev/null +++ b/app/services/concerns/search/cache.rb @@ -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 diff --git a/config/feature_flags/experiment/prompt_migration_merge_request_reader.yml b/config/feature_flags/experiment/prompt_migration_merge_request_reader.yml new file mode 100644 index 00000000000..62607c71f40 --- /dev/null +++ b/config/feature_flags/experiment/prompt_migration_merge_request_reader.yml @@ -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 diff --git a/config/feature_flags/development/search_index_partitioning_notes.yml b/config/feature_flags/gitlab_com_derisk/search_cache_authorizations.yml similarity index 51% rename from config/feature_flags/development/search_index_partitioning_notes.yml rename to config/feature_flags/gitlab_com_derisk/search_cache_authorizations.yml index 1abd3be35c5..66a2558c49d 100644 --- a/config/feature_flags/development/search_index_partitioning_notes.yml +++ b/config/feature_flags/gitlab_com_derisk/search_cache_authorizations.yml @@ -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 diff --git a/data/deprecations/15-4-non-expiring-access-tokens.yml b/data/deprecations/15-4-non-expiring-access-tokens.yml index db3170dec52..3c8e1baf312 100644 --- a/data/deprecations/15-4-non-expiring-access-tokens.yml +++ b/data/deprecations/15-4-non-expiring-access-tokens.yml @@ -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). diff --git a/db/docs/search_indices.yml b/db/docs/search_indices.yml index 4bb470c6f02..0260e6158d1 100644 --- a/db/docs/search_indices.yml +++ b/db/docs/search_indices.yml @@ -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 diff --git a/doc/.vale/gitlab_base/spelling-exceptions.txt b/doc/.vale/gitlab_base/spelling-exceptions.txt index e09584c12ab..f652726c853 100644 --- a/doc/.vale/gitlab_base/spelling-exceptions.txt +++ b/doc/.vale/gitlab_base/spelling-exceptions.txt @@ -506,6 +506,7 @@ kanban kanbans kaniko Karma +Kata KCachegrind Kerberos Keycloak @@ -1009,6 +1010,7 @@ swimlane swimlanes syncable Sysbench +Sysbox syscall syscalls syslog diff --git a/doc/administration/cicd/job_logs.md b/doc/administration/cicd/job_logs.md index 5386c095421..acfdb466b48 100644 --- a/doc/administration/cicd/job_logs.md +++ b/doc/administration/cicd/job_logs.md @@ -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: diff --git a/doc/administration/license_file.md b/doc/administration/license_file.md index 3e55ea00e4a..2418bf543e7 100644 --- a/doc/administration/license_file.md +++ b/doc/administration/license_file.md @@ -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**. diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index e06b580e2b3..75bdd491f00 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -26387,8 +26387,9 @@ Represents a member role. | `editPath` **{warning-solid}** | [`String!`](#string) | **Introduced** in GitLab 16.11. **Status**: Experiment. Web UI path to edit the custom role. | | `enabledPermissions` **{warning-solid}** | [`CustomizablePermissionConnection!`](#customizablepermissionconnection) | **Introduced** in GitLab 16.5. **Status**: Experiment. Array of all permissions enabled for the custom role. | | `id` | [`MemberRoleID!`](#memberroleid) | ID of the member role. | -| `membersCount` **{warning-solid}** | [`Int!`](#int) | **Introduced** in GitLab 16.7. **Status**: Experiment. Total number of members with the custom role. | +| `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. | | `name` | [`String!`](#string) | Name of the member role. | +| `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. | ---- | ---- | ----------- | | `accessLevel` | [`Int!`](#int) | Access level as a number. | | `detailsPath` **{warning-solid}** | [`String!`](#string) | **Introduced** in GitLab 17.4. **Status**: Experiment. URL path to the role details webpage. | -| `membersCount` **{warning-solid}** | [`Int!`](#int) | **Introduced** in GitLab 17.3. **Status**: Experiment. Total number of members with the standard role. | +| `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. | | `name` | [`String!`](#string) | Access level as a string. | +| `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` diff --git a/doc/ci/jobs/index.md b/doc/ci/jobs/index.md index 2b6978af14f..fac8fdde8f4 100644 --- a/doc/ci/jobs/index.md +++ b/doc/ci/jobs/index.md @@ -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 diff --git a/doc/development/cicd/pipeline_wizard.md b/doc/development/cicd/pipeline_wizard.md index 41aac74119f..38512f3d2ac 100644 --- a/doc/development/cicd/pipeline_wizard.md +++ b/doc/development/cicd/pipeline_wizard.md @@ -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 diff --git a/doc/subscriptions/gitlab_com/index.md b/doc/subscriptions/gitlab_com/index.md index d1b5e75aa44..9c72b86341b 100644 --- a/doc/subscriptions/gitlab_com/index.md +++ b/doc/subscriptions/gitlab_com/index.md @@ -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. diff --git a/doc/topics/git/advanced.md b/doc/topics/git/advanced.md new file mode 100644 index 00000000000..3cd01d899c1 --- /dev/null +++ b/doc/topics/git/advanced.md @@ -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) diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md index abf6b28f848..867641ec1af 100644 --- a/doc/update/deprecations.md +++ b/doc/update/deprecations.md @@ -4175,6 +4175,32 @@ However, since certificate-based integration with Kubernetes clusters is depreca +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). diff --git a/doc/user/application_security/policies/pipeline_execution_policies.md b/doc/user/application_security/policies/pipeline_execution_policies.md index cc4938bdf41..4867e28d0de 100644 --- a/doc/user/application_security/policies/pipeline_execution_policies.md +++ b/doc/user/application_security/policies/pipeline_execution_policies.md @@ -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. diff --git a/doc/user/group/saml_sso/group_sync.md b/doc/user/group/saml_sso/group_sync.md index 6b21a6cc868..2c6ef4a5645 100644 --- a/doc/user/group/saml_sso/group_sync.md +++ b/doc/user/group/saml_sso/group_sync.md @@ -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. + 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. diff --git a/doc/user/workspace/configuration.md b/doc/user/workspace/configuration.md index 4bfcc099231..a0a72073a32 100644 --- a/doc/user/workspace/configuration.md +++ b/doc/user/workspace/configuration.md @@ -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 `*.` - 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 diff --git a/doc/user/workspace/gitlab_agent_configuration.md b/doc/user/workspace/gitlab_agent_configuration.md index f77691213ac..f8a8a385e1b 100644 --- a/doc/user/workspace/gitlab_agent_configuration.md +++ b/doc/user/workspace/gitlab_agent_configuration.md @@ -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. diff --git a/doc/user/workspace/index.md b/doc/user/workspace/index.md index e70732c3a30..5d395ececc8 100644 --- a/doc/user/workspace/index.md +++ b/doc/user/workspace/index.md @@ -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 diff --git a/lib/gitlab/ci/pipeline/metrics.rb b/lib/gitlab/ci/pipeline/metrics.rb index 07cf0942937..9a232bb7513 100644 --- a/lib/gitlab/ci/pipeline/metrics.rb +++ b/lib/gitlab/ci/pipeline/metrics.rb @@ -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' diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e749fd007c6..ec3156b4bd2 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/qa/Gemfile b/qa/Gemfile index 06c7659a76f..9ebf4a9c851 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -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' diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index cccbb31c5ed..bc11b7d424f 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -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) diff --git a/qa/qa/page/file/shared/commit_message.rb b/qa/qa/page/file/shared/commit_message.rb index 32e1abf5590..3372c67bd11 100644 --- a/qa/qa/page/file/shared/commit_message.rb +++ b/qa/qa/page/file/shared/commit_message.rb @@ -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 diff --git a/qa/qa/page/file/show.rb b/qa/qa/page/file/show.rb index 5e1593ac117..4637cedad41 100644 --- a/qa/qa/page/file/show.rb +++ b/qa/qa/page/file/show.rb @@ -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 diff --git a/qa/qa/specs/features/api/1_manage/import/import_github_repo_spec.rb b/qa/qa/specs/features/api/1_manage/import/import_github_repo_spec.rb index ccca396ead2..f4fb86a213a 100644 --- a/qa/qa/specs/features/api/1_manage/import/import_github_repo_spec.rb +++ b/qa/qa/specs/features/api/1_manage/import/import_github_repo_spec.rb @@ -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 diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/file/delete_file_via_web_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/file/delete_file_via_web_spec.rb index 305056f2277..b00a34de343 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/file/delete_file_via_web_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/file/delete_file_via_web_spec.rb @@ -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| diff --git a/spec/features/projects/files/user_deletes_files_spec.rb b/spec/features/projects/files/user_deletes_files_spec.rb index 68a996451f6..b34b8e8d566 100644 --- a/spec/features/projects/files/user_deletes_files_spec.rb +++ b/spec/features/projects/files/user_deletes_files_spec.rb @@ -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) diff --git a/spec/frontend/alert_spec.js b/spec/frontend/alert_spec.js index 6a0d5843d9e..c8749f66395 100644 --- a/spec/frontend/alert_spec.js +++ b/spec/frontend/alert_spec.js @@ -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: learn more'; + + 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'], + }); + }); + }); }); }); }); diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index 1a077028704..42aed016f40 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -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, diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/commit_changes_modal_spec.js similarity index 73% rename from spec/frontend/repository/components/delete_blob_modal_spec.js rename to spec/frontend/repository/components/commit_changes_modal_spec.js index d3af39425db..e945f2cc37f 100644 --- a/spec/frontend/repository/components/delete_blob_modal_spec.js +++ b/spec/frontend/repository/components/commit_changes_modal_spec.js @@ -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', diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js index 1716da4b2c4..e56f0bb1cf9 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -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', () => { diff --git a/spec/frontend/work_items/components/work_item_state_toggle_spec.js b/spec/frontend/work_items/components/work_item_state_toggle_spec.js index 72130ddd061..b56360a0ac2 100644 --- a/spec/frontend/work_items/components/work_item_state_toggle_spec.js +++ b/spec/frontend/work_items/components/work_item_state_toggle_spec.js @@ -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.', + ); + }); + }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index a43e90ade34..e8eb4e5b821 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -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: { diff --git a/spec/lib/gitlab/fp/result_spec.rb b/spec/lib/gitlab/fp/result_spec.rb index 3a4877e2069..a695565346e 100644 --- a/spec/lib/gitlab/fp/result_spec.rb +++ b/spec/lib/gitlab/fp/result_spec.rb @@ -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 diff --git a/spec/services/concerns/search/cache_spec.rb b/spec/services/concerns/search/cache_spec.rb new file mode 100644 index 00000000000..0e5ec9ae2ff --- /dev/null +++ b/spec/services/concerns/search/cache_spec.rb @@ -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 diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml index 9008f1b18e6..51737925156 100644 --- a/spec/support/rspec_order_todo.yml +++ b/spec/support/rspec_order_todo.yml @@ -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' diff --git a/spec/support/shared_examples/features/work_items_shared_examples.rb b/spec/support/shared_examples/features/work_items_shared_examples.rb index d8e53e28135..8712eefe2c7 100644 --- a/spec/support/shared_examples/features/work_items_shared_examples.rb +++ b/spec/support/shared_examples/features/work_items_shared_examples.rb @@ -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 diff --git a/spec/support/shared_examples/security/policies_shared_examples.rb b/spec/support/shared_examples/security/policies_shared_examples.rb index acc943c61a6..17cb1b54399 100644 --- a/spec/support/shared_examples/security/policies_shared_examples.rb +++ b/spec/support/shared_examples/security/policies_shared_examples.rb @@ -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