Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/mailers/in_product_marketing/admin_verify-0.pngbin30421 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/create-0.pngbin10275 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/create-1.pngbin39565 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/create-2.pngbin15793 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/experience-0.pngbin66492 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/team-0.pngbin42448 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/team-1.pngbin62019 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/team.png (renamed from app/assets/images/mailers/in_product_marketing/team-2.png)bin54468 -> 54468 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/trial-0.pngbin50665 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/trial-1.pngbin8676 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/trial-2.pngbin47411 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/verify-0.pngbin15366 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/verify-1.pngbin60722 -> 0 bytes
-rw-r--r--app/assets/images/mailers/in_product_marketing/verify.png (renamed from app/assets/images/mailers/in_product_marketing/verify-2.png)bin57506 -> 57506 bytes
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/user_details.vue36
-rw-r--r--app/assets/javascripts/admin/abuse_report/constants.js6
-rw-r--r--app/assets/javascripts/api/alert_management_alerts_api.js4
-rw-r--r--app/assets/javascripts/badges/components/badge_list.vue15
-rw-r--r--app/assets/javascripts/behaviors/secret_values.js47
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue4
-rw-r--r--app/assets/javascripts/blob/openapi/index.js6
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_card_move_to_position.vue15
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue9
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue26
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue7
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue10
-rw-r--r--app/assets/javascripts/boards/constants.js6
-rw-r--r--app/assets/javascripts/boards/index.js25
-rw-r--r--app/assets/javascripts/boards/stores/actions.js925
-rw-r--r--app/assets/javascripts/boards/stores/getters.js67
-rw-r--r--app/assets/javascripts/boards/stores/index.js20
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js47
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js319
-rw-r--r--app/assets/javascripts/boards/stores/state.js44
-rw-r--r--app/assets/javascripts/branches/components/graph_bar.vue2
-rw-r--r--app/assets/javascripts/breadcrumb.js2
-rw-r--r--app/assets/javascripts/captcha/captcha_modal.vue44
-rw-r--r--app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue4
-rw-r--r--app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue6
-rw-r--r--app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue2
-rw-r--r--app/assets/javascripts/ci/catalog/components/list/catalog_header.vue3
-rw-r--r--app/assets/javascripts/ci/catalog/components/list/catalog_search.vue26
-rw-r--r--app/assets/javascripts/ci/catalog/components/list/catalog_tabs.vue67
-rw-r--r--app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue6
-rw-r--r--app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue75
-rw-r--r--app/assets/javascripts/ci/catalog/constants.js6
-rw-r--r--app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql4
-rw-r--r--app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_create.mutation.graphql (renamed from app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_destroy.mutation.graphql (renamed from app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql2
-rw-r--r--app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql2
-rw-r--r--app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql3
-rw-r--r--app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources_count.query.graphql8
-rw-r--r--app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql (renamed from app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/catalog/graphql/settings.js2
-rw-r--r--app/assets/javascripts/ci/ci_environments_dropdown/ci_environments_dropdown.vue (renamed from app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue)37
-rw-r--r--app/assets/javascripts/ci/ci_environments_dropdown/constants.js14
-rw-r--r--app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/group_environments.query.graphql (renamed from app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/project_environments.query.graphql (renamed from app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/ci_environments_dropdown/utils.js (renamed from app/assets/javascripts/ci/ci_variable_list/utils.js)13
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue38
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue6
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js9
-rw-r--r--app/assets/javascripts/ci/common/private/ci_environments_dropdown.js9
-rw-r--r--app/assets/javascripts/ci/pipeline_details/constants.js2
-rw-r--r--app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js12
-rw-r--r--app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue23
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue4
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue9
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue6
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue2
-rw-r--r--app/assets/javascripts/comment_templates/components/form.vue46
-rw-r--r--app/assets/javascripts/commit/components/signature_badge.vue2
-rw-r--r--app/assets/javascripts/commit/constants.js17
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/image.vue13
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/playable.vue62
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue24
-rw-r--r--app/assets/javascripts/content_editor/extensions/copy_paste.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js7
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_cell.js11
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_header.js36
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_item.js32
-rw-r--r--app/assets/javascripts/content_editor/extensions/video.js1
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js6
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js30
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js10
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js6
-rw-r--r--app/assets/javascripts/deploy_keys/components/action_btn.vue23
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue280
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue110
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue10
-rw-r--r--app/assets/javascripts/deploy_keys/graphql/resolvers.js35
-rw-r--r--app/assets/javascripts/deploy_keys/index.js32
-rw-r--r--app/assets/javascripts/deploy_keys/service/index.js19
-rw-r--r--app/assets/javascripts/deploy_keys/store/index.js9
-rw-r--r--app/assets/javascripts/diffs/components/app.vue30
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue15
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue34
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js18
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue54
-rw-r--r--app/assets/javascripts/diffs/index.js1
-rw-r--r--app/assets/javascripts/diffs/store/actions.js103
-rw-r--r--app/assets/javascripts/diffs/store/getters.js15
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js21
-rw-r--r--app/assets/javascripts/diffs/store/utils.js28
-rw-r--r--app/assets/javascripts/dropzone_input.js3
-rw-r--r--app/assets/javascripts/editor/schema/ci.json63
-rw-r--r--app/assets/javascripts/entrypoints/analytics.js4
-rw-r--r--app/assets/javascripts/entrypoints/sandboxed_swagger.js1
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_status_bar.vue17
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_tabs.vue54
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue1
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue8
-rw-r--r--app/assets/javascripts/environments/constants.js3
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js13
-rw-r--r--app/assets/javascripts/environments/graphql/client.js4
-rw-r--r--app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql1
-rw-r--r--app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql1
-rw-r--r--app/assets/javascripts/environments/helpers/k8s_integration_helper.js63
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details_info.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue11
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js29
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js184
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json2
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js8
-rw-r--r--app/assets/javascripts/groups/components/app.vue2
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue4
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue18
-rw-r--r--app/assets/javascripts/groups/components/overview_tabs.vue27
-rw-r--r--app/assets/javascripts/groups/init_overview_tabs.js2
-rw-r--r--app/assets/javascripts/groups/settings/api/access_dropdown_api.js7
-rw-r--r--app/assets/javascripts/groups/settings/components/access_dropdown.vue6
-rw-r--r--app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue17
-rw-r--r--app/assets/javascripts/ide/components/file_alert.vue26
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue33
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/lib/alerts/environments.vue33
-rw-r--r--app/assets/javascripts/ide/lib/alerts/index.js21
-rw-r--r--app/assets/javascripts/ide/services/index.js16
-rw-r--r--app/assets/javascripts/ide/stores/actions.js1
-rw-r--r--app/assets/javascripts/ide/stores/actions/alert.js18
-rw-r--r--app/assets/javascripts/ide/stores/getters.js2
-rw-r--r--app/assets/javascripts/ide/stores/getters/alert.js3
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js5
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js2
-rw-r--r--app/assets/javascripts/ide/stores/mutations/alert.js21
-rw-r--r--app/assets/javascripts/ide/stores/state.js2
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_status.vue7
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue17
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue1
-rw-r--r--app/assets/javascripts/invite_members/utils/member_utils.js4
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue3
-rw-r--r--app/assets/javascripts/issues/list/index.js1
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue12
-rw-r--r--app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue76
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue7
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue19
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue9
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/constants.js44
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/graphql/client.js92
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js56
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_cron_jobs.query.graphql18
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_jobs.query.graphql18
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_services.query.graphql17
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js130
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js30
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/pages/cron_jobs_page.vue84
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/pages/jobs_page.vue80
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/pages/services_page.vue69
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/router/constants.js6
-rw-r--r--app/assets/javascripts/kubernetes_dashboard/router/routes.js34
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/constants.js5
-rw-r--r--app/assets/javascripts/lib/utils/headers.js2
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js45
-rw-r--r--app/assets/javascripts/lib/utils/secret_detection.js8
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js2
-rw-r--r--app/assets/javascripts/logo.js2
-rw-r--r--app/assets/javascripts/merge_requests/components/compare_app.vue6
-rw-r--r--app/assets/javascripts/merge_requests/components/compare_dropdown.vue6
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue3
-rw-r--r--app/assets/javascripts/ml/model_registry/apps/index.js3
-rw-r--r--app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue34
-rw-r--r--app/assets/javascripts/ml/model_registry/apps/new_ml_model.vue127
-rw-r--r--app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue15
-rw-r--r--app/assets/javascripts/ml/model_registry/components/actions_dropdown.vue34
-rw-r--r--app/assets/javascripts/ml/model_registry/components/candidate_list.vue79
-rw-r--r--app/assets/javascripts/ml/model_registry/components/model_version_list.vue76
-rw-r--r--app/assets/javascripts/ml/model_registry/components/searchable_list.vue79
-rw-r--r--app/assets/javascripts/ml/model_registry/graphql/mutations/create_model.mutation.graphql11
-rw-r--r--app/assets/javascripts/ml/model_registry/translations.js9
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue6
-rw-r--r--app/assets/javascripts/observability/client.js21
-rw-r--r--app/assets/javascripts/organizations/groups_and_projects/components/app.vue2
-rw-r--r--app/assets/javascripts/organizations/mock_data.js6
-rw-r--r--app/assets/javascripts/organizations/new/components/app.vue10
-rw-r--r--app/assets/javascripts/organizations/new/index.js5
-rw-r--r--app/assets/javascripts/organizations/settings/general/components/organization_settings.vue29
-rw-r--r--app/assets/javascripts/organizations/settings/general/index.js10
-rw-r--r--app/assets/javascripts/organizations/shared/components/groups_view.vue12
-rw-r--r--app/assets/javascripts/organizations/shared/components/new_edit_form.vue82
-rw-r--r--app/assets/javascripts/organizations/shared/components/organization_url_field.vue4
-rw-r--r--app/assets/javascripts/organizations/shared/components/projects_view.vue12
-rw-r--r--app/assets/javascripts/organizations/shared/constants.js2
-rw-r--r--app/assets/javascripts/organizations/show/components/app.vue4
-rw-r--r--app/assets/javascripts/organizations/show/components/organization_avatar.vue1
-rw-r--r--app/assets/javascripts/organizations/show/components/organization_description.vue24
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue115
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql13
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue21
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/page.js11
-rw-r--r--app/assets/javascripts/pages/projects/ml/models/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/ml/models/new/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue6
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/index.js3
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js4
-rw-r--r--app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js4
-rw-r--r--app/assets/javascripts/pages/shared/mount_badge_settings.js2
-rw-r--r--app/assets/javascripts/pages/shared/mount_runner_instructions.js27
-rw-r--r--app/assets/javascripts/pages/users/index.js2
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js2
-rw-r--r--app/assets/javascripts/performance_bar/components/request_warning.vue1
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js2
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue7
-rw-r--r--app/assets/javascripts/profile/preferences/profile_preferences_bundle.js3
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_comments_button.vue42
-rw-r--r--app/assets/javascripts/projects/commit/index.js2
-rw-r--r--app/assets/javascripts/projects/commit/init_commit_comments_button.js18
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue2
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/refs_list.vue8
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue162
-rw-r--r--app/assets/javascripts/projects/project_new.js3
-rw-r--r--app/assets/javascripts/projects/settings/api/access_dropdown_api.js3
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue9
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue125
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/constants.js10
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/graphql/mutations/create_branch_rule.mutation.graphql8
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js1
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js1
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js1
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.vue1
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue3
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue56
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue3
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js15
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js12
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js1
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue4
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue1
-rw-r--r--app/assets/javascripts/search/store/actions.js51
-rw-r--r--app/assets/javascripts/security_configuration/constants.js208
-rw-r--r--app/assets/javascripts/security_configuration/index.js6
-rw-r--r--app/assets/javascripts/security_configuration/utils.js36
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue50
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue16
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_color_picker.vue75
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue33
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue6
-rw-r--r--app/assets/javascripts/sidebar/constants.js6
-rw-r--r--app/assets/javascripts/sidebar/queries/constants.js6
-rw-r--r--app/assets/javascripts/super_sidebar/components/pinned_section.vue6
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue54
-rw-r--r--app/assets/javascripts/tracking/constants.js2
-rw-r--r--app/assets/javascripts/tracking/internal_events.js8
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue67
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/storage_usage_overview_card.vue51
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue33
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/daterange_token.vue158
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_countdown.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue107
-rw-r--r--app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.stories.js35
-rw-r--r--app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue84
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql10
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue112
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue16
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue29
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue4
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue2
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue2
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue194
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_token_input.vue108
-rw-r--r--app/assets/javascripts/work_items/components/widget_wrapper.vue21
-rw-r--r--app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue77
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue58
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description_rendered.vue26
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue100
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue5
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone_inline.vue (renamed from app/assets/javascripts/work_items/components/work_item_milestone.vue)0
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue203
-rw-r--r--app/assets/javascripts/work_items/components/work_item_parent_inline.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_state_toggle.vue17
-rw-r--r--app/assets/javascripts/work_items/components/work_item_title_with_edit.vue45
-rw-r--r--app/assets/javascripts/work_items/constants.js15
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_items.query.graphql7
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_items_by_references.query.graphql10
-rw-r--r--app/assets/javascripts/work_items/utils.js11
-rw-r--r--app/assets/stylesheets/application.scss10
-rw-r--r--app/assets/stylesheets/components/_index.scss11
-rw-r--r--app/assets/stylesheets/components/content_editor.scss9
-rw-r--r--app/assets/stylesheets/emoji_sprites.scss4
-rw-r--r--app/assets/stylesheets/fonts.scss8
-rw-r--r--app/assets/stylesheets/framework.scss3
-rw-r--r--app/assets/stylesheets/framework/badges.scss53
-rw-r--r--app/assets/stylesheets/framework/breadcrumbs.scss13
-rw-r--r--app/assets/stylesheets/framework/diffs.scss27
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss38
-rw-r--r--app/assets/stylesheets/framework/files.scss2
-rw-r--r--app/assets/stylesheets/framework/flash.scss8
-rw-r--r--app/assets/stylesheets/framework/header.scss124
-rw-r--r--app/assets/stylesheets/framework/labels.scss56
-rw-r--r--app/assets/stylesheets/framework/layout.scss2
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss1
-rw-r--r--app/assets/stylesheets/framework/source_editor.scss2
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss177
-rw-r--r--app/assets/stylesheets/framework/top_bar.scss20
-rw-r--r--app/assets/stylesheets/framework/typography.scss16
-rw-r--r--app/assets/stylesheets/highlight/common.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/issuable.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/issuable_list.scss19
-rw-r--r--app/assets/stylesheets/page_bundles/labels.scss49
-rw-r--r--app/assets/stylesheets/page_bundles/login.scss125
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss15
-rw-r--r--app/assets/stylesheets/page_bundles/profile.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/project.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/search.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/wiki.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss9
-rw-r--r--app/assets/stylesheets/pages/colors.scss49
-rw-r--r--app/assets/stylesheets/pages/issues.scss2
-rw-r--r--app/assets/stylesheets/pages/notes.scss21
-rw-r--r--app/assets/stylesheets/print.scss162
-rw-r--r--app/assets/stylesheets/snippets.scss3
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_blue.scss14
-rw-r--r--app/assets/stylesheets/themes/theme_gray.scss14
-rw-r--r--app/assets/stylesheets/themes/theme_green.scss14
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss36
-rw-r--r--app/assets/stylesheets/themes/theme_indigo.scss14
-rw-r--r--app/assets/stylesheets/themes/theme_light_blue.scss14
-rw-r--r--app/assets/stylesheets/themes/theme_light_gray.scss2
-rw-r--r--app/assets/stylesheets/themes/theme_light_green.scss14
-rw-r--r--app/assets/stylesheets/themes/theme_light_indigo.scss14
-rw-r--r--app/assets/stylesheets/themes/theme_light_red.scss14
-rw-r--r--app/assets/stylesheets/themes/theme_red.scss14
-rw-r--r--app/assets/stylesheets/utilities.scss10
-rw-r--r--app/assets/stylesheets/vendors/_index.scss1
-rw-r--r--app/components/pajamas/avatar_component.rb29
-rw-r--r--app/components/projects/ml/models_index_component.rb26
-rw-r--r--app/components/projects/ml/show_ml_model_component.rb3
-rw-r--r--app/controllers/admin/ci/variables_controller.rb2
-rw-r--r--app/controllers/admin/users_controller.rb4
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/concerns/confirm_email_warning.rb2
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb14
-rw-r--r--app/controllers/concerns/integrations/params.rb3
-rw-r--r--app/controllers/concerns/preview_markdown.rb1
-rw-r--r--app/controllers/explore/catalog_controller.rb2
-rw-r--r--app/controllers/graphql_controller.rb3
-rw-r--r--app/controllers/groups/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb3
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb3
-rw-r--r--app/controllers/import/bulk_imports_controller.rb8
-rw-r--r--app/controllers/import/fogbugz_controller.rb3
-rw-r--r--app/controllers/import/github_controller.rb3
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb3
-rw-r--r--app/controllers/import/manifest_controller.rb3
-rw-r--r--app/controllers/jwks_controller.rb4
-rw-r--r--app/controllers/ldap/omniauth_callbacks_controller.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb12
-rw-r--r--app/controllers/organizations/organizations_controller.rb4
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb10
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/projects/boards_controller.rb1
-rw-r--r--app/controllers/projects/commit_controller.rb1
-rw-r--r--app/controllers/projects/environments_controller.rb2
-rw-r--r--app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb6
-rw-r--r--app/controllers/projects/google_cloud/configuration_controller.rb2
-rw-r--r--app/controllers/projects/google_cloud/databases_controller.rb6
-rw-r--r--app/controllers/projects/google_cloud/deployments_controller.rb6
-rw-r--r--app/controllers/projects/google_cloud/gcp_regions_controller.rb2
-rw-r--r--app/controllers/projects/google_cloud/service_accounts_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb1
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb12
-rw-r--r--app/controllers/projects/merge_requests_controller.rb11
-rw-r--r--app/controllers/projects/ml/models_controller.rb4
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/security/configuration_controller.rb10
-rw-r--r--app/controllers/projects/settings/packages_and_registries_controller.rb5
-rw-r--r--app/controllers/projects/settings/repository_controller.rb4
-rw-r--r--app/controllers/projects/variables_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb16
-rw-r--r--app/controllers/search_controller.rb2
-rw-r--r--app/controllers/uploads_controller.rb5
-rw-r--r--app/controllers/users_controller.rb3
-rw-r--r--app/events/ci/job_artifacts_deleted_event.rb4
-rw-r--r--app/events/project_authorizations/authorizations_added_event.rb16
-rw-r--r--app/events/projects/release_published_event.rb15
-rw-r--r--app/experiments/in_product_guidance_environments_webide_experiment.rb13
-rw-r--r--app/finders/ci/catalog/resources/versions_finder.rb7
-rw-r--r--app/finders/ci/runner_jobs_finder.rb8
-rw-r--r--app/finders/ci/runner_managers_finder.rb31
-rw-r--r--app/finders/ci/runners_finder.rb149
-rw-r--r--app/finders/groups/accepting_project_shares_finder.rb7
-rw-r--r--app/finders/issuable_finder/params.rb11
-rw-r--r--app/finders/packages/terraform_module/packages_finder.rb32
-rw-r--r--app/finders/projects/ml/experiment_finder.rb46
-rw-r--r--app/finders/projects_finder.rb13
-rw-r--r--app/finders/users_finder.rb15
-rw-r--r--app/graphql/graphql_triggers.rb6
-rw-r--r--app/graphql/mutations/branch_rules/create.rb56
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb4
-rw-r--r--app/graphql/mutations/issues/set_assignees.rb2
-rw-r--r--app/graphql/mutations/ml/models/base.rb20
-rw-r--r--app/graphql/mutations/ml/models/create.rb32
-rw-r--r--app/graphql/mutations/namespace/package_settings/update.rb10
-rw-r--r--app/graphql/mutations/work_items/create.rb10
-rw-r--r--app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb10
-rw-r--r--app/graphql/resolvers/ci/runner_owner_project_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/runner_projects_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/runner_resolver.rb27
-rw-r--r--app/graphql/resolvers/ci/runners_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/resolves_groups.rb28
-rw-r--r--app/graphql/resolvers/container_repository_tags_resolver.rb8
-rw-r--r--app/graphql/resolvers/full_path_resolver.rb6
-rw-r--r--app/graphql/resolvers/group_resolver.rb6
-rw-r--r--app/graphql/resolvers/ml/find_models_resolver.rb35
-rw-r--r--app/graphql/resolvers/namespace_resolver.rb6
-rw-r--r--app/graphql/resolvers/organizations/organizations_resolver.rb19
-rw-r--r--app/graphql/resolvers/organizations/projects_resolver.rb19
-rw-r--r--app/graphql/resolvers/project_resolver.rb6
-rw-r--r--app/graphql/resolvers/projects/fork_targets_resolver.rb11
-rw-r--r--app/graphql/resolvers/users_resolver.rb26
-rw-r--r--app/graphql/types/ci/catalog/resources/component_type.rb3
-rw-r--r--app/graphql/types/ci/catalog/resources/version_type.rb26
-rw-r--r--app/graphql/types/ci/ci_cd_setting_type.rb2
-rw-r--r--app/graphql/types/ci/inherited_ci_variable_type.rb4
-rw-r--r--app/graphql/types/ci/instance_variable_type.rb4
-rw-r--r--app/graphql/types/commit_signatures/verification_status_enum.rb6
-rw-r--r--app/graphql/types/container_repository_referrer_type.rb16
-rw-r--r--app/graphql/types/container_repository_tag_type.rb2
-rw-r--r--app/graphql/types/group_type.rb36
-rw-r--r--app/graphql/types/merge_request_type.rb12
-rw-r--r--app/graphql/types/ml/model_links_type.rb17
-rw-r--r--app/graphql/types/ml/model_type.rb15
-rw-r--r--app/graphql/types/ml/model_version_links_type.rb3
-rw-r--r--app/graphql/types/ml/models_order_by_enum.rb15
-rw-r--r--app/graphql/types/mutation_type.rb2
-rw-r--r--app/graphql/types/namespace/package_settings_type.rb6
-rw-r--r--app/graphql/types/namespace_type.rb2
-rw-r--r--app/graphql/types/organizations/organization_type.rb4
-rw-r--r--app/graphql/types/permission_types/issue.rb2
-rw-r--r--app/graphql/types/project_type.rb18
-rw-r--r--app/graphql/types/query_type.rb8
-rw-r--r--app/graphql/types/subscription_type.rb4
-rw-r--r--app/graphql/types/work_items/widgets/notes_input_type.rb15
-rw-r--r--app/graphql/types/work_items/widgets/notes_type.rb4
-rw-r--r--app/helpers/appearances_helper.rb2
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/helpers/application_settings_helper.rb9
-rw-r--r--app/helpers/avatars_helper.rb2
-rw-r--r--app/helpers/breadcrumbs_helper.rb2
-rw-r--r--app/helpers/button_helper.rb10
-rw-r--r--app/helpers/ci/builds_helper.rb7
-rw-r--r--app/helpers/ci/status_helper.rb9
-rw-r--r--app/helpers/ci/variables_helper.rb15
-rw-r--r--app/helpers/count_helper.rb2
-rw-r--r--app/helpers/diff_helper.rb2
-rw-r--r--app/helpers/environment_helper.rb8
-rw-r--r--app/helpers/environments_helper.rb11
-rw-r--r--app/helpers/form_helper.rb2
-rw-r--r--app/helpers/groups/group_members_helper.rb2
-rw-r--r--app/helpers/groups_helper.rb12
-rw-r--r--app/helpers/ide_helper.rb9
-rw-r--r--app/helpers/import_helper.rb2
-rw-r--r--app/helpers/listbox_helper.rb2
-rw-r--r--app/helpers/merge_requests_helper.rb13
-rw-r--r--app/helpers/mirror_helper.rb2
-rw-r--r--app/helpers/nav_helper.rb7
-rw-r--r--app/helpers/organizations/organization_helper.rb23
-rw-r--r--app/helpers/preferences_helper.rb4
-rw-r--r--app/helpers/projects/project_members_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb12
-rw-r--r--app/helpers/registrations_helper.rb4
-rw-r--r--app/helpers/reminder_emails_helper.rb2
-rw-r--r--app/helpers/safe_format_helper.rb4
-rw-r--r--app/helpers/search_helper.rb6
-rw-r--r--app/helpers/sessions_helper.rb33
-rw-r--r--app/helpers/sidebars_helper.rb36
-rw-r--r--app/helpers/time_zone_helper.rb13
-rw-r--r--app/helpers/users_helper.rb2
-rw-r--r--app/helpers/vite_helper.rb6
-rw-r--r--app/helpers/wiki_page_version_helper.rb2
-rw-r--r--app/mailers/emails/issues.rb6
-rw-r--r--app/mailers/emails/merge_requests.rb6
-rw-r--r--app/models/abuse_report.rb5
-rw-r--r--app/models/ai/service_access_token.rb3
-rw-r--r--app/models/analytics/cycle_analytics/aggregation.rb19
-rw-r--r--app/models/analytics/cycle_analytics/stage.rb17
-rw-r--r--app/models/application_setting.rb256
-rw-r--r--app/models/application_setting_implementation.rb5
-rw-r--r--app/models/bulk_imports/entity.rb6
-rw-r--r--app/models/bulk_imports/failure.rb8
-rw-r--r--app/models/ci/build.rb25
-rw-r--r--app/models/ci/catalog/resources/version.rb9
-rw-r--r--app/models/ci/instance_variable.rb1
-rw-r--r--app/models/ci/namespace_mirror.rb1
-rw-r--r--app/models/ci/pipeline.rb11
-rw-r--r--app/models/ci/pipeline_artifact.rb3
-rw-r--r--app/models/ci/pipeline_chat_data.rb7
-rw-r--r--app/models/ci/pipeline_config.rb4
-rw-r--r--app/models/ci/pipeline_metadata.rb7
-rw-r--r--app/models/ci/pipeline_variable.rb3
-rw-r--r--app/models/ci/processable.rb4
-rw-r--r--app/models/ci/project_mirror.rb2
-rw-r--r--app/models/ci/runner.rb52
-rw-r--r--app/models/ci/runner_manager.rb37
-rw-r--r--app/models/ci/stage.rb3
-rw-r--r--app/models/commit.rb2
-rw-r--r--app/models/commit_status.rb2
-rw-r--r--app/models/compare.rb4
-rw-r--r--app/models/concerns/analytics/cycle_analytics/parentable.rb11
-rw-r--r--app/models/concerns/atomic_internal_id.rb4
-rw-r--r--app/models/concerns/cache_markdown_field.rb2
-rw-r--r--app/models/concerns/ci/has_runner_status.rb50
-rw-r--r--app/models/concerns/ci/partitionable/testing.rb4
-rw-r--r--app/models/concerns/commit_signature.rb12
-rw-r--r--app/models/concerns/database_event_tracking.rb52
-rw-r--r--app/models/concerns/enums/commit_signature.rb24
-rw-r--r--app/models/concerns/integrations/enable_ssl_verification.rb3
-rw-r--r--app/models/concerns/integrations/has_issue_tracker_fields.rb13
-rw-r--r--app/models/concerns/integrations/slack_mattermost_fields.rb18
-rw-r--r--app/models/concerns/partitioned_table.rb3
-rw-r--r--app/models/concerns/restricted_signup.rb8
-rw-r--r--app/models/concerns/routable.rb36
-rw-r--r--app/models/container_registry/protection/rule.rb17
-rw-r--r--app/models/container_repository.rb16
-rw-r--r--app/models/deployment.rb3
-rw-r--r--app/models/group.rb34
-rw-r--r--app/models/integration.rb8
-rw-r--r--app/models/integrations/apple_app_store.rb2
-rw-r--r--app/models/integrations/bamboo.rb12
-rw-r--r--app/models/integrations/campfire.rb11
-rw-r--r--app/models/integrations/clickup.rb4
-rw-r--r--app/models/integrations/confluence.rb3
-rw-r--r--app/models/integrations/diffblue_cover.rb126
-rw-r--r--app/models/integrations/discord.rb5
-rw-r--r--app/models/integrations/external_wiki.rb1
-rw-r--r--app/models/integrations/google_play.rb18
-rw-r--r--app/models/integrations/harbor.rb4
-rw-r--r--app/models/integrations/mattermost.rb2
-rw-r--r--app/models/integrations/mattermost_slash_commands.rb2
-rw-r--r--app/models/integrations/slack.rb2
-rw-r--r--app/models/integrations/squash_tm.rb2
-rw-r--r--app/models/integrations/youtrack.rb4
-rw-r--r--app/models/issue_email_participant.rb3
-rw-r--r--app/models/jira_connect_subscription.rb2
-rw-r--r--app/models/label.rb5
-rw-r--r--app/models/member.rb67
-rw-r--r--app/models/members/group_member.rb53
-rw-r--r--app/models/members/project_member.rb44
-rw-r--r--app/models/merge_request.rb23
-rw-r--r--app/models/merge_request/metrics.rb30
-rw-r--r--app/models/merge_request_diff.rb26
-rw-r--r--app/models/ml/experiment.rb3
-rw-r--r--app/models/ml/model_metadata.rb2
-rw-r--r--app/models/ml/model_version.rb13
-rw-r--r--app/models/ml/model_version_metadata.rb14
-rw-r--r--app/models/namespace.rb27
-rw-r--r--app/models/namespace/package_setting.rb10
-rw-r--r--app/models/namespace_setting.rb4
-rw-r--r--app/models/namespaces/descendants.rb30
-rw-r--r--app/models/namespaces/traversal/cached.rb34
-rw-r--r--app/models/onboarding/completion.rb16
-rw-r--r--app/models/onboarding/progress.rb3
-rw-r--r--app/models/organizations/organization.rb10
-rw-r--r--app/models/organizations/organization_detail.rb2
-rw-r--r--app/models/organizations/organization_user.rb12
-rw-r--r--app/models/pages/project_settings.rb25
-rw-r--r--app/models/pages_deployment.rb8
-rw-r--r--app/models/project.rb29
-rw-r--r--app/models/project_authorizations/changes.rb48
-rw-r--r--app/models/project_statistics.rb5
-rw-r--r--app/models/project_team.rb7
-rw-r--r--app/models/projects/project_topic.rb2
-rw-r--r--app/models/projects/repository_storage_move.rb6
-rw-r--r--app/models/projects/topic.rb9
-rw-r--r--app/models/release.rb6
-rw-r--r--app/models/resource_label_event.rb6
-rw-r--r--app/models/resource_milestone_event.rb2
-rw-r--r--app/models/route.rb44
-rw-r--r--app/models/service_desk/custom_email_credential.rb5
-rw-r--r--app/models/snippets/repository_storage_move.rb6
-rw-r--r--app/models/ssh_host_key.rb5
-rw-r--r--app/models/time_tracking/timelog_category.rb2
-rw-r--r--app/models/timelog.rb1
-rw-r--r--app/models/tree.rb11
-rw-r--r--app/models/user.rb56
-rw-r--r--app/models/users/callout.rb4
-rw-r--r--app/models/users/credit_card_validation.rb2
-rw-r--r--app/models/users/in_product_marketing_email.rb75
-rw-r--r--app/models/users/phone_number_validation.rb35
-rw-r--r--app/models/work_item.rb2
-rw-r--r--app/models/work_items/hierarchy_restriction.rb9
-rw-r--r--app/models/work_items/widget_definition.rb4
-rw-r--r--app/models/work_items/widgets/notes.rb10
-rw-r--r--app/policies/container_registry/referrer_policy.rb7
-rw-r--r--app/policies/global_policy.rb4
-rw-r--r--app/policies/organizations/organization_policy.rb8
-rw-r--r--app/policies/project_policy.rb1
-rw-r--r--app/presenters/blob_presenter.rb5
-rw-r--r--app/presenters/projects/security/configuration_presenter.rb3
-rw-r--r--app/serializers/activity_pub/activity_serializer.rb40
-rw-r--r--app/serializers/activity_pub/activity_streams_serializer.rb90
-rw-r--r--app/serializers/activity_pub/actor_serializer.rb39
-rw-r--r--app/serializers/activity_pub/collection_serializer.rb68
-rw-r--r--app/serializers/activity_pub/object_serializer.rb35
-rw-r--r--app/serializers/activity_pub/publish_release_activity_serializer.rb7
-rw-r--r--app/serializers/activity_pub/releases_actor_serializer.rb2
-rw-r--r--app/serializers/activity_pub/releases_outbox_serializer.rb4
-rw-r--r--app/serializers/admin/abuse_report_details_entity.rb17
-rw-r--r--app/serializers/build_details_entity.rb4
-rw-r--r--app/serializers/ci/basic_variable_entity.rb1
-rw-r--r--app/serializers/diffs_metadata_entity.rb4
-rw-r--r--app/services/auth/container_registry_authentication_service.rb2
-rw-r--r--app/services/boards/base_item_move_service.rb11
-rw-r--r--app/services/bulk_imports/file_download_service.rb6
-rw-r--r--app/services/ci/cancel_pipeline_service.rb27
-rw-r--r--app/services/ci/catalog/resources/versions/create_service.rb6
-rw-r--r--app/services/ci/create_pipeline_service.rb8
-rw-r--r--app/services/ci/create_web_ide_terminal_service.rb2
-rw-r--r--app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb73
-rw-r--r--app/services/ci/runners/unregister_runner_manager_service.rb2
-rw-r--r--app/services/ci/unlock_pipeline_service.rb12
-rw-r--r--app/services/click_house/sync_strategies/base_sync_strategy.rb124
-rw-r--r--app/services/click_house/sync_strategies/event_sync_strategy.rb62
-rw-r--r--app/services/cloud_seed/google_cloud/base_service.rb67
-rw-r--r--app/services/cloud_seed/google_cloud/create_cloudsql_instance_service.rb80
-rw-r--r--app/services/cloud_seed/google_cloud/create_service_accounts_service.rb42
-rw-r--r--app/services/cloud_seed/google_cloud/enable_cloud_run_service.rb23
-rw-r--r--app/services/cloud_seed/google_cloud/enable_cloudsql_service.rb27
-rw-r--r--app/services/cloud_seed/google_cloud/enable_vision_ai_service.rb21
-rw-r--r--app/services/cloud_seed/google_cloud/fetch_google_ip_list_service.rb93
-rw-r--r--app/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service.rb25
-rw-r--r--app/services/cloud_seed/google_cloud/generate_pipeline_service.rb100
-rw-r--r--app/services/cloud_seed/google_cloud/get_cloudsql_instances_service.rb20
-rw-r--r--app/services/cloud_seed/google_cloud/service_accounts_service.rb53
-rw-r--r--app/services/cloud_seed/google_cloud/setup_cloudsql_instance_service.rb120
-rw-r--r--app/services/clusters/agents/authorizations/user_access/refresh_service.rb4
-rw-r--r--app/services/concerns/users/participable_service.rb29
-rw-r--r--app/services/draft_notes/destroy_service.rb8
-rw-r--r--app/services/event_create_service.rb6
-rw-r--r--app/services/google_cloud/base_service.rb65
-rw-r--r--app/services/google_cloud/create_cloudsql_instance_service.rb78
-rw-r--r--app/services/google_cloud/create_service_accounts_service.rb40
-rw-r--r--app/services/google_cloud/enable_cloud_run_service.rb21
-rw-r--r--app/services/google_cloud/enable_cloudsql_service.rb25
-rw-r--r--app/services/google_cloud/enable_vision_ai_service.rb19
-rw-r--r--app/services/google_cloud/fetch_google_ip_list_service.rb91
-rw-r--r--app/services/google_cloud/gcp_region_add_or_replace_service.rb23
-rw-r--r--app/services/google_cloud/generate_pipeline_service.rb98
-rw-r--r--app/services/google_cloud/get_cloudsql_instances_service.rb18
-rw-r--r--app/services/google_cloud/service_accounts_service.rb51
-rw-r--r--app/services/google_cloud/setup_cloudsql_instance_service.rb118
-rw-r--r--app/services/google_cloud_platform/artifact_registry/list_docker_images_service.rb46
-rw-r--r--app/services/groups/create_service.rb23
-rw-r--r--app/services/groups/participants_service.rb4
-rw-r--r--app/services/groups/transfer_service.rb2
-rw-r--r--app/services/groups/update_service.rb24
-rw-r--r--app/services/import/bitbucket_server_service.rb5
-rw-r--r--app/services/import/fogbugz_service.rb5
-rw-r--r--app/services/import/github_service.rb29
-rw-r--r--app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb48
-rw-r--r--app/services/issuable/common_system_notes_service.rb15
-rw-r--r--app/services/issuable_base_service.rb14
-rw-r--r--app/services/issue_email_participants/base_service.rb45
-rw-r--r--app/services/issue_email_participants/create_service.rb42
-rw-r--r--app/services/issue_email_participants/destroy_service.rb44
-rw-r--r--app/services/issues/base_service.rb22
-rw-r--r--app/services/jira/requests/base.rb18
-rw-r--r--app/services/merge_requests/approval_service.rb1
-rw-r--r--app/services/merge_requests/conflicts/list_service.rb4
-rw-r--r--app/services/merge_requests/remove_approval_service.rb1
-rw-r--r--app/services/merge_requests/request_review_service.rb5
-rw-r--r--app/services/milestones/destroy_service.rb2
-rw-r--r--app/services/milestones/promote_service.rb7
-rw-r--r--app/services/ml/create_model_service.rb20
-rw-r--r--app/services/ml/create_model_version_service.rb3
-rw-r--r--app/services/namespaces/package_settings/update_service.rb2
-rw-r--r--app/services/notification_service.rb64
-rw-r--r--app/services/organizations/create_service.rb12
-rw-r--r--app/services/organizations/update_service.rb4
-rw-r--r--app/services/packages/npm/create_package_service.rb25
-rw-r--r--app/services/packages/terraform_module/create_package_service.rb35
-rw-r--r--app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb18
-rw-r--r--app/services/projects/destroy_service.rb7
-rw-r--r--app/services/projects/participants_service.rb8
-rw-r--r--app/services/projects/unlink_fork_service.rb4
-rw-r--r--app/services/projects/update_statistics_service.rb8
-rw-r--r--app/services/routes/rename_descendants_service.rb135
-rw-r--r--app/services/spam/spam_verdict_service.rb3
-rw-r--r--app/services/system_note_service.rb8
-rw-r--r--app/services/system_notes/issuables_service.rb8
-rw-r--r--app/services/system_notes/time_tracking_service.rb23
-rw-r--r--app/services/todo_service.rb10
-rw-r--r--app/services/work_items/callbacks/assignees.rb36
-rw-r--r--app/services/work_items/callbacks/current_user_todos.rb35
-rw-r--r--app/services/work_items/callbacks/description.rb17
-rw-r--r--app/services/work_items/callbacks/notifications.rb24
-rw-r--r--app/services/work_items/callbacks/start_and_due_date.rb16
-rw-r--r--app/services/work_items/create_service.rb2
-rw-r--r--app/services/work_items/widgets/assignees_service/update_service.rb38
-rw-r--r--app/services/work_items/widgets/current_user_todos_service/update_service.rb37
-rw-r--r--app/services/work_items/widgets/description_service/update_service.rb19
-rw-r--r--app/services/work_items/widgets/notifications_service/update_service.rb26
-rw-r--r--app/services/work_items/widgets/start_and_due_date_service/update_service.rb18
-rw-r--r--app/validators/json_schemas/application_setting_rate_limits.json13
-rw-r--r--app/validators/json_schemas/build_metadata_secrets.json73
-rw-r--r--app/validators/json_schemas/cloud_connector_access.json12
-rw-r--r--app/validators/json_schemas/scan_result_policy_project_approval_settings.json24
-rw-r--r--app/views/admin/application_settings/_members_api_limits.html.haml21
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml2
-rw-r--r--app/views/admin/application_settings/_signin.html.haml3
-rw-r--r--app/views/admin/application_settings/_user_restrictions.html.haml1
-rw-r--r--app/views/admin/application_settings/appearances/_form.html.haml105
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml11
-rw-r--r--app/views/admin/application_settings/general.html.haml5
-rw-r--r--app/views/admin/application_settings/network.html.haml2
-rw-r--r--app/views/admin/dashboard/_stats_users_table.html.haml2
-rw-r--r--app/views/admin/dashboard/stats.html.haml2
-rw-r--r--app/views/admin/sessions/new.html.haml4
-rw-r--r--app/views/admin/sessions/two_factor.html.haml3
-rw-r--r--app/views/admin/users/show.html.haml2
-rw-r--r--app/views/ci/runner/_how_to_setup_runner.html.haml23
-rw-r--r--app/views/ci/variables/_attributes.html.haml13
-rw-r--r--app/views/ci/variables/_index.html.haml11
-rw-r--r--app/views/dashboard/todos/index.html.haml5
-rw-r--r--app/views/devise/confirmations/new.html.haml3
-rw-r--r--app/views/devise/passwords/new.html.haml6
-rw-r--r--app/views/devise/registrations/new.html.haml3
-rw-r--r--app/views/devise/sessions/new.html.haml33
-rw-r--r--app/views/devise/sessions/two_factor.html.haml3
-rw-r--r--app/views/devise/shared/_footer.html.haml3
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml19
-rw-r--r--app/views/devise/shared/_omniauth_provider_button.haml7
-rw-r--r--app/views/devise/shared/_signup_box.html.haml5
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_button.haml14
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml24
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers.haml3
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml2
-rw-r--r--app/views/devise/shared/_terms_of_service_notice.html.haml18
-rw-r--r--app/views/devise/unlocks/new.html.haml7
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml4
-rw-r--r--app/views/groups/settings/_export.html.haml2
-rw-r--r--app/views/groups/settings/_permissions.html.haml9
-rw-r--r--app/views/import/github/status.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml1
-rw-r--r--app/views/layouts/_page.html.haml15
-rw-r--r--app/views/layouts/_snowplow.html.haml5
-rw-r--r--app/views/layouts/application.html.haml4
-rw-r--r--app/views/layouts/devise.html.haml78
-rw-r--r--app/views/layouts/devise_empty.html.haml2
-rw-r--r--app/views/layouts/group.html.haml1
-rw-r--r--app/views/layouts/header/_super_sidebar_logged_out.haml2
-rw-r--r--app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml4
-rw-r--r--app/views/layouts/project.html.haml1
-rw-r--r--app/views/layouts/signup_onboarding.html.haml2
-rw-r--r--app/views/notify/_reassigned_issuable_email.html.haml17
-rw-r--r--app/views/notify/new_review_email.html.haml59
-rw-r--r--app/views/notify/reassigned_issue_email.html.haml2
-rw-r--r--app/views/notify/reassigned_issue_email.text.erb13
-rw-r--r--app/views/notify/reassigned_merge_request_email.html.haml2
-rw-r--r--app/views/notify/reassigned_merge_request_email.text.erb13
-rw-r--r--app/views/profiles/accounts/_providers.html.haml41
-rw-r--r--app/views/profiles/emails/index.html.haml10
-rw-r--r--app/views/profiles/gpg_keys/_key_table.html.haml2
-rw-r--r--app/views/profiles/preferences/show.html.haml4
-rw-r--r--app/views/profiles/show.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml4
-rw-r--r--app/views/projects/_import_project_pane.html.haml18
-rw-r--r--app/views/projects/_readme.html.haml2
-rw-r--r--app/views/projects/_sidebar.html.haml69
-rw-r--r--app/views/projects/buttons/_code.html.haml12
-rw-r--r--app/views/projects/buttons/_download_menu_items.html.haml4
-rw-r--r--app/views/projects/commit/_commit_box.html.haml3
-rw-r--r--app/views/projects/edit.html.haml7
-rw-r--r--app/views/projects/empty.html.haml2
-rw-r--r--app/views/projects/environments/folder.html.haml2
-rw-r--r--app/views/projects/forks/index.html.haml2
-rw-r--r--app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml4
-rw-r--r--app/views/projects/merge_requests/_code_dropdown.html.haml6
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml5
-rw-r--r--app/views/projects/merge_requests/_page.html.haml1
-rw-r--r--app/views/projects/merge_requests/creations/new.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos_list.html.haml2
-rw-r--r--app/views/projects/ml/models/index.html.haml2
-rw-r--r--app/views/projects/ml/models/new.html.haml5
-rw-r--r--app/views/projects/pages_domains/_dns.html.haml4
-rw-r--r--app/views/search/results/_blob_data.html.haml6
-rw-r--r--app/views/search/results/_blob_highlight.html.haml2
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml7
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml5
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml2
-rw-r--r--app/views/shared/_file_highlight.html.haml2
-rw-r--r--app/views/shared/_visibility_radios.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_index.html.haml7
-rw-r--r--app/views/shared/deploy_tokens/_new_deploy_token.html.haml24
-rw-r--r--app/views/shared/groups/_group.html.haml7
-rw-r--r--app/views/shared/groups/_list.html.haml1
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/members/_member.html.haml4
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml2
-rw-r--r--app/views/shared/users/_user.html.haml2
-rw-r--r--app/views/shared/web_hooks/_index.html.haml2
-rw-r--r--app/views/shared/wikis/_sidebar.html.haml2
-rw-r--r--app/views/shared/wikis/git_error.html.haml4
-rw-r--r--app/views/shared/wikis/show.html.haml7
-rw-r--r--app/views/user_settings/passwords/edit.html.haml8
-rw-r--r--app/views/user_settings/passwords/new.html.haml8
-rw-r--r--app/views/users/show.html.haml6
-rw-r--r--app/workers/all_queues.yml29
-rw-r--r--app/workers/bulk_imports/export_request_worker.rb17
-rw-r--r--app/workers/ci/unlock_pipelines_in_queue_worker.rb3
-rw-r--r--app/workers/click_house/event_authors_consistency_cron_worker.rb121
-rw-r--r--app/workers/click_house/events_sync_worker.rb135
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb16
-rw-r--r--app/workers/concerns/gitlab/github_import/queue.rb6
-rw-r--r--app/workers/concerns/gitlab/github_import/rescheduling_methods.rb2
-rw-r--r--app/workers/concerns/gitlab/github_import/stage_methods.rb26
-rw-r--r--app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb4
-rw-r--r--app/workers/gitlab/github_gists_import/start_import_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/advance_stage_worker.rb20
-rw-r--r--app/workers/gitlab/github_import/import_issue_event_worker.rb10
-rw-r--r--app/workers/gitlab/github_import/replay_events_worker.rb27
-rw-r--r--app/workers/gitlab/github_import/stage/import_collaborators_worker.rb8
-rw-r--r--app/workers/gitlab/github_import/stage/import_issue_events_worker.rb17
-rw-r--r--app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb10
-rw-r--r--app/workers/google_cloud/create_cloudsql_instance_worker.rb2
-rw-r--r--app/workers/google_cloud/fetch_google_ip_list_worker.rb2
-rw-r--r--app/workers/new_issue_worker.rb5
-rw-r--r--app/workers/releases/publish_event_worker.rb32
882 files changed, 10846 insertions, 7173 deletions
diff --git a/app/assets/images/mailers/in_product_marketing/admin_verify-0.png b/app/assets/images/mailers/in_product_marketing/admin_verify-0.png
deleted file mode 100644
index c6d3e55afc1..00000000000
--- a/app/assets/images/mailers/in_product_marketing/admin_verify-0.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/create-0.png b/app/assets/images/mailers/in_product_marketing/create-0.png
deleted file mode 100644
index 7fc992f14f2..00000000000
--- a/app/assets/images/mailers/in_product_marketing/create-0.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/create-1.png b/app/assets/images/mailers/in_product_marketing/create-1.png
deleted file mode 100644
index 0315ffefb31..00000000000
--- a/app/assets/images/mailers/in_product_marketing/create-1.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/create-2.png b/app/assets/images/mailers/in_product_marketing/create-2.png
deleted file mode 100644
index 619f9fcd659..00000000000
--- a/app/assets/images/mailers/in_product_marketing/create-2.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/experience-0.png b/app/assets/images/mailers/in_product_marketing/experience-0.png
deleted file mode 100644
index 346204d1db1..00000000000
--- a/app/assets/images/mailers/in_product_marketing/experience-0.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/team-0.png b/app/assets/images/mailers/in_product_marketing/team-0.png
deleted file mode 100644
index f10ae998efa..00000000000
--- a/app/assets/images/mailers/in_product_marketing/team-0.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/team-1.png b/app/assets/images/mailers/in_product_marketing/team-1.png
deleted file mode 100644
index cd68464e6e8..00000000000
--- a/app/assets/images/mailers/in_product_marketing/team-1.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/team-2.png b/app/assets/images/mailers/in_product_marketing/team.png
index b199c659943..b199c659943 100644
--- a/app/assets/images/mailers/in_product_marketing/team-2.png
+++ b/app/assets/images/mailers/in_product_marketing/team.png
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/trial-0.png b/app/assets/images/mailers/in_product_marketing/trial-0.png
deleted file mode 100644
index 3b0d7a8ecd8..00000000000
--- a/app/assets/images/mailers/in_product_marketing/trial-0.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/trial-1.png b/app/assets/images/mailers/in_product_marketing/trial-1.png
deleted file mode 100644
index 3a30b2acaee..00000000000
--- a/app/assets/images/mailers/in_product_marketing/trial-1.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/trial-2.png b/app/assets/images/mailers/in_product_marketing/trial-2.png
deleted file mode 100644
index 95bd965b49f..00000000000
--- a/app/assets/images/mailers/in_product_marketing/trial-2.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/verify-0.png b/app/assets/images/mailers/in_product_marketing/verify-0.png
deleted file mode 100644
index 04b6f172b37..00000000000
--- a/app/assets/images/mailers/in_product_marketing/verify-0.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/verify-1.png b/app/assets/images/mailers/in_product_marketing/verify-1.png
deleted file mode 100644
index 8997e8ba575..00000000000
--- a/app/assets/images/mailers/in_product_marketing/verify-1.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mailers/in_product_marketing/verify-2.png b/app/assets/images/mailers/in_product_marketing/verify.png
index 93c99dee246..93c99dee246 100644
--- a/app/assets/images/mailers/in_product_marketing/verify-2.png
+++ b/app/assets/images/mailers/in_product_marketing/verify.png
Binary files differ
diff --git a/app/assets/javascripts/admin/abuse_report/components/user_details.vue b/app/assets/javascripts/admin/abuse_report/components/user_details.vue
index 0c32341652b..0e946fed8ac 100644
--- a/app/assets/javascripts/admin/abuse_report/components/user_details.vue
+++ b/app/assets/javascripts/admin/abuse_report/components/user_details.vue
@@ -26,12 +26,18 @@ export default {
.map(([k]) => this.$options.i18n.verificationMethods[k])
.join(', ');
},
- showSimilarRecords() {
+ showCreditCardSimilarRecords() {
return this.user.creditCard.similarRecordsCount > 1;
},
- similarRecordsCount() {
+ creditCardSimilarRecordsCount() {
return formatNumber(this.user.creditCard.similarRecordsCount);
},
+ showPhoneNumberSimilarRecords() {
+ return this.user.phoneNumber.similarRecordsCount > 1;
+ },
+ phoneNumberSimilarRecordsCount() {
+ return formatNumber(this.user.phoneNumber.similarRecordsCount);
+ },
},
i18n: USER_DETAILS_I18N,
};
@@ -60,11 +66,33 @@ export default {
data-testid="credit-card-verification"
:label="$options.i18n.creditCard"
>
- <gl-sprintf v-if="showSimilarRecords" :message="$options.i18n.similarRecords">
+ <gl-sprintf
+ v-if="showCreditCardSimilarRecords"
+ :message="$options.i18n.creditCardSimilarRecords"
+ >
<template #cardMatchesLink="{ content }">
<gl-link :href="user.creditCard.cardMatchesLink">
<gl-sprintf :message="content">
- <template #count>{{ similarRecordsCount }}</template>
+ <template #count>{{ creditCardSimilarRecordsCount }}</template>
+ </gl-sprintf>
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </user-detail>
+
+ <user-detail
+ v-if="user.phoneNumber"
+ data-testid="phone-number-verification"
+ :label="$options.i18n.phoneNumber"
+ >
+ <gl-sprintf
+ v-if="showPhoneNumberSimilarRecords"
+ :message="$options.i18n.phoneNumberSimilarRecords"
+ >
+ <template #phoneMatchesLink="{ content }">
+ <gl-link :href="user.phoneNumber.phoneMatchesLink">
+ <gl-sprintf :message="content">
+ <template #count>{{ phoneNumberSimilarRecordsCount }}</template>
</gl-sprintf>
</gl-link>
</template>
diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js
index c56ea678b1d..69bcdebad61 100644
--- a/app/assets/javascripts/admin/abuse_report/constants.js
+++ b/app/assets/javascripts/admin/abuse_report/constants.js
@@ -61,6 +61,7 @@ export const USER_DETAILS_I18N = {
plan: s__('AbuseReport|Tier'),
verification: s__('AbuseReport|Verification'),
creditCard: s__('AbuseReport|Credit card'),
+ phoneNumber: s__('AbuseReport|Phone number'),
pastReports: s__('AbuseReport|Past abuse reports'),
normalLocation: s__('AbuseReport|Normal location'),
lastSignInIp: s__('AbuseReport|Last login'),
@@ -78,9 +79,12 @@ export const USER_DETAILS_I18N = {
reportedFor: s__(
'AbuseReport|%{reportLinkStart}Reported%{reportLinkEnd} for %{category} %{timeAgo}.',
),
- similarRecords: s__(
+ creditCardSimilarRecords: s__(
'AbuseReport|Card matches %{cardMatchesLinkStart}%{count} accounts%{cardMatchesLinkEnd}',
),
+ phoneNumberSimilarRecords: s__(
+ 'AbuseReport|Phone matches %{phoneMatchesLinkStart}%{count} accounts%{phoneMatchesLinkEnd}',
+ ),
};
export const REPORTED_CONTENT_I18N = {
diff --git a/app/assets/javascripts/api/alert_management_alerts_api.js b/app/assets/javascripts/api/alert_management_alerts_api.js
index fa66ca5b3dd..6f595cb7cb0 100644
--- a/app/assets/javascripts/api/alert_management_alerts_api.js
+++ b/app/assets/javascripts/api/alert_management_alerts_api.js
@@ -1,6 +1,6 @@
import axios from '~/lib/utils/axios_utils';
import { buildApiUrl } from '~/api/api_utils';
-import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
+import { contentTypeMultipartFormData } from '~/lib/utils/headers';
const ALERT_METRIC_IMAGES_PATH =
'/api/:version/projects/:id/alert_management_alerts/:alert_iid/metric_images';
@@ -16,7 +16,7 @@ export function fetchAlertMetricImages({ alertIid, id }) {
}
export function uploadAlertMetricImage({ alertIid, id, file, url = null, urlText = null }) {
- const options = { headers: { ...ContentTypeMultipartFormData } };
+ const options = { headers: { ...contentTypeMultipartFormData } };
const metricImagesUrl = buildApiUrl(ALERT_METRIC_IMAGES_PATH)
.replace(':id', encodeURIComponent(id))
.replace(':alert_iid', encodeURIComponent(alertIid));
diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue
index a4f88067fa9..3f6435d66ce 100644
--- a/app/assets/javascripts/badges/components/badge_list.vue
+++ b/app/assets/javascripts/badges/components/badge_list.vue
@@ -3,6 +3,7 @@ import {
GlBadge,
GlLoadingIcon,
GlTable,
+ GlTooltipDirective,
GlPagination,
GlButton,
GlModalDirective,
@@ -27,6 +28,7 @@ export default {
},
directives: {
GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
i18n: {
emptyGroupMessage: s__('Badges|This group has no badges. Add an existing badge or create one.'),
@@ -107,19 +109,26 @@ export default {
:current-page="currentPage"
stacked="md"
show-empty
+ class="b-table-fixed"
data-testid="badge-list"
>
<template #cell(name)="{ item }">
- <label class="label-bold str-truncated mb-0">{{ item.name }}</label>
+ <label v-gl-tooltip class="label-bold str-truncated mb-0" :title="item.name">{{
+ item.name
+ }}</label>
<gl-badge size="sm">{{ badgeKindText(item) }}</gl-badge>
</template>
<template #cell(badge)="{ item }">
- <badge :image-url="item.renderedImageUrl" :link-url="item.renderedLinkUrl" />
+ <div class="overflow-hidden">
+ <badge :image-url="item.renderedImageUrl" :link-url="item.renderedLinkUrl" />
+ </div>
</template>
<template #cell(url)="{ item }">
- {{ item.linkUrl }}
+ <span v-gl-tooltip :title="item.linkUrl" class="str-truncated">
+ {{ item.linkUrl }}
+ </span>
</template>
<template #cell(actions)="{ item }">
diff --git a/app/assets/javascripts/behaviors/secret_values.js b/app/assets/javascripts/behaviors/secret_values.js
deleted file mode 100644
index b6ed14611cd..00000000000
--- a/app/assets/javascripts/behaviors/secret_values.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { n__ } from '~/locale';
-
-export default class SecretValues {
- constructor({
- container,
- valueSelector = '.js-secret-value',
- placeholderSelector = '.js-secret-value-placeholder',
- }) {
- this.container = container;
- this.valueSelector = valueSelector;
- this.placeholderSelector = placeholderSelector;
- }
-
- init() {
- this.revealButton = this.container.querySelector('.js-secret-value-reveal-button');
-
- if (this.revealButton) {
- const isRevealed = parseBoolean(this.revealButton.dataset.secretRevealStatus);
- this.updateDom(isRevealed);
-
- this.revealButton.addEventListener('click', this.onRevealButtonClicked.bind(this));
- }
- }
-
- onRevealButtonClicked() {
- const previousIsRevealed = parseBoolean(this.revealButton.dataset.secretRevealStatus);
- this.updateDom(!previousIsRevealed);
- }
-
- updateDom(isRevealed) {
- const values = this.container.querySelectorAll(this.valueSelector);
- values.forEach((value) => {
- value.classList.toggle('hide', !isRevealed);
- });
-
- const placeholders = this.container.querySelectorAll(this.placeholderSelector);
- placeholders.forEach((placeholder) => {
- placeholder.classList.toggle('hide', isRevealed);
- });
-
- this.revealButton.textContent = isRevealed
- ? n__('Hide value', 'Hide values', values.length)
- : n__('Reveal value', 'Reveal values', values.length);
- this.revealButton.dataset.secretRevealStatus = isRevealed;
- }
-}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
index e81ceae57c0..f8b2331befa 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
@@ -79,9 +79,7 @@ export default {
"
>
<template #link="{ content }">
- <gl-link :href="absoluteUserPreferencesPath">
- {{ content }}
- </gl-link>
+ <gl-link :href="absoluteUserPreferencesPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</span>
diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js
index 9c22d960bf5..e2715d89b4e 100644
--- a/app/assets/javascripts/blob/openapi/index.js
+++ b/app/assets/javascripts/blob/openapi/index.js
@@ -1,5 +1,5 @@
+import SwaggerClient from 'swagger-client';
import { setAttributes } from '~/lib/utils/dom_utils';
-import axios from '~/lib/utils/axios_utils';
import {
getBaseURL,
relativePathToAbsolute,
@@ -42,11 +42,11 @@ export default async (el = document.getElementById('js-openapi-viewer')) => {
const wrapperEl = el;
const sandboxEl = createSandbox();
- const { data } = await axios.get(wrapperEl.dataset.endpoint);
+ const { spec } = await SwaggerClient.resolve({ url: wrapperEl.dataset.endpoint });
wrapperEl.appendChild(sandboxEl);
sandboxEl.addEventListener('load', () => {
- sandboxEl.contentWindow.postMessage(data, '*');
+ if (spec) sandboxEl.contentWindow.postMessage(JSON.stringify(spec), '*');
});
};
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index 2c8aa1cbe21..b1b3e7f7022 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -155,7 +155,7 @@ export default {
:is-swimlanes-on="isSwimlanesOn"
:filter-params="formattedFilterParams"
:board-lists="boardLists"
- :apollo-error="error"
+ :error="error"
:list-query-variables="listQueryVariables"
@setActiveList="setActiveId"
@setAddColumnFormVisibility="addColumnFormVisible = $event"
diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
index 9173503c888..398dcc494f7 100644
--- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue
+++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
@@ -1,6 +1,7 @@
<script>
-import { GlDisclosureDropdown } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui';
import Tracking from '~/tracking';
+import { s__ } from '~/locale';
import {
BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS,
BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION,
@@ -12,6 +13,9 @@ export default {
components: {
GlDisclosureDropdown,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [Tracking.mixin()],
props: {
item: {
@@ -87,6 +91,9 @@ export default {
}
},
},
+ i18n: {
+ moveCardText: s__('Boards|Card options'),
+ },
};
</script>
@@ -94,12 +101,16 @@ export default {
<gl-disclosure-dropdown
ref="dropdown"
:key="itemIdentifier"
+ v-gl-tooltip.hover.focus.top="{
+ title: $options.i18n.moveCardText,
+ boundary: 'viewport',
+ }"
class="move-to-position gl-display-block gl-mb-2 gl-ml-auto gl-mt-n3 gl-mr-n3 js-no-trigger"
category="tertiary"
:items="$options.BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS"
icon="ellipsis_v"
:tabindex="index"
- :toggle-text="s__('Boards|Move card')"
+ :aria-label="$options.i18n.moveCardText"
:text-sr-only="true"
no-caret
data-testid="board-move-to-position"
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 2b9c5d52d5e..dbdfe314ae0 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -46,7 +46,7 @@ export default {
required: false,
default: () => {},
},
- apolloError: {
+ error: {
type: String,
required: false,
default: null,
@@ -97,9 +97,6 @@ export default {
return this.canDragColumns ? options : {};
},
- errorToDisplay() {
- return this.apolloError || null;
- },
},
methods: {
afterFormEnters() {
@@ -195,8 +192,8 @@ export default {
data-testid="boards-list"
class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-min-h-0"
>
- <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="dismissError">
- {{ errorToDisplay }}
+ <gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="dismissError">
+ {{ error }}
</gl-alert>
<component
:is="boardColumnWrapper"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 8a5c6882e56..58c20c0da91 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -3,6 +3,7 @@ import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { STATUS_CLOSED } from '~/issues/constants';
import { sprintf, __, s__ } from '~/locale';
+import { ESC_KEY_CODE } from '~/lib/utils/keycodes';
import { defaultSortableOptions, DRAG_DELAY } from '~/sortable/constants';
import { sortableStart, sortableEnd } from '~/sortable/utils';
import Tracking from '~/tracking';
@@ -82,6 +83,7 @@ export default {
toList: {},
addItemToListInProgress: false,
updateIssueOrderInProgress: false,
+ dragCancelled: false,
};
},
apollo: {
@@ -307,6 +309,11 @@ export default {
return;
}
+ // Reset dragCancelled flag
+ this.dragCancelled = false;
+ // Attach listener to detect `ESC` key press to cancel drag.
+ document.addEventListener('keyup', this.handleKeyUp.bind(this));
+
sortableStart();
this.track('drag_card', { label: 'board' });
},
@@ -323,6 +330,11 @@ export default {
return;
}
+ // Detach listener as soon as drag ends.
+ document.removeEventListener('keyup', this.handleKeyUp.bind(this));
+ // Drag was cancelled, prevent reordering.
+ if (this.dragCancelled) return;
+
sortableEnd();
let newIndex = originalNewIndex;
let { children } = to;
@@ -375,6 +387,20 @@ export default {
this.updateIssueOrderInProgress = false;
});
},
+ /**
+ * This implementation is needed to support `Esc` key press to cancel drag.
+ * It matches with what we already shipped in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/119311
+ */
+ handleKeyUp(e) {
+ if (e.keyCode === ESC_KEY_CODE) {
+ this.dragCancelled = true;
+ // Sortable.js internally listens for `mouseup` event on document
+ // to register drop event, see https://github.com/SortableJS/Sortable/blob/master/src/Sortable.js#L625
+ // We need to manually trigger it to simulate cancel behaviour as VueDraggable doesn't
+ // natively support it, see https://github.com/SortableJS/Vue.Draggable/issues/968.
+ document.dispatchEvent(new Event('mouseup'));
+ }
+ },
isItemInTheList(itemIid) {
const items = this.toList?.[`${this.issuableType}s`]?.nodes || [];
return items.some((item) => item.iid === itemIid);
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
index 69e6cc870d2..f4d4222e41a 100644
--- a/app/assets/javascripts/boards/components/config_toggle.vue
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -1,7 +1,5 @@
<script>
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
-// eslint-disable-next-line no-restricted-imports
-import { mapGetters } from 'vuex';
import { formType } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import { s__, __ } from '~/locale';
@@ -25,12 +23,11 @@ export default {
},
},
computed: {
- ...mapGetters(['hasScope']),
buttonText() {
return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope');
},
tooltipTitle() {
- return this.hasScope || this.boardHasScope ? __("This board's scope is reduced") : '';
+ return this.boardHasScope ? __("This board's scope is reduced") : '';
},
},
methods: {
@@ -48,7 +45,7 @@ export default {
v-gl-modal-directive="'board-config-modal'"
v-gl-tooltip
:title="tooltipTitle"
- :class="{ 'dot-highlight': hasScope || boardHasScope }"
+ :class="{ 'dot-highlight': boardHasScope }"
data-testid="boards-config-button"
@click.prevent="showPage"
>
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 7bbc444701a..9c5dd633092 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -77,10 +77,12 @@ export default {
},
activeGroupProjects() {
return (
- this.projects?.nodes?.map((project) => ({
- value: project.id,
- text: project.nameWithNamespace,
- })) || []
+ this.projects?.nodes
+ ?.filter((p) => !p.archived)
+ .map((project) => ({
+ value: project.id,
+ text: project.nameWithNamespace,
+ })) || []
);
},
selectedProjectName() {
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index acf01a8c528..a3983f11c86 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -51,12 +51,6 @@ export const toggleFormEventPrefix = {
issue: 'toggle-issue-form-',
};
-export const active = 'active';
-
-export const inactiveId = 0;
-
-export const ISSUABLE = 'issuable';
-export const LIST = 'list';
export const INCIDENT = 'INCIDENT';
export const flashAnimationDuration = 2000;
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 72b8aef31a4..0d8882cf57e 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -3,10 +3,9 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import BoardApp from '~/boards/components/board_app.vue';
import '~/boards/filters/due_date_filters';
-import store from '~/boards/stores';
import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import {
- NavigationType,
+ navigationType,
isLoggedIn,
parseBoolean,
convertObjectPropsToCamelCase,
@@ -24,7 +23,6 @@ const apolloProvider = new VueApollo({
function mountBoardApp(el) {
const { boardId, groupId, fullPath, rootPath } = el.dataset;
- const isApolloBoard = true;
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
@@ -34,31 +32,12 @@ function mountBoardApp(el) {
const boardType = el.dataset.parent;
- if (!isApolloBoard) {
- store.dispatch('fetchBoard', {
- fullPath,
- fullBoardId: fullBoardId(boardId),
- boardType,
- });
-
- store.dispatch('setInitialBoardData', {
- boardId,
- fullBoardId: fullBoardId(boardId),
- fullPath,
- boardType,
- disabled: parseBoolean(el.dataset.disabled) || true,
- issuableType: TYPE_ISSUE,
- });
- }
-
// eslint-disable-next-line no-new
new Vue({
el,
name: 'BoardAppRoot',
- store,
apolloProvider,
provide: {
- isApolloBoard,
initialBoardId: fullBoardId(boardId),
disabled: parseBoolean(el.dataset.disabled),
groupId: Number(groupId),
@@ -114,7 +93,7 @@ export default () => {
// check for browser back and trigger a hard reload to circumvent browser caching.
window.addEventListener('pageshow', (event) => {
const isNavTypeBackForward =
- window.performance && window.performance.navigation.type === NavigationType.TYPE_BACK_FORWARD;
+ window.performance && window.performance.navigation.type === navigationType.TYPE_BACK_FORWARD;
if (event.persisted || isNavTypeBackForward) {
window.location.reload();
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
deleted file mode 100644
index 97e40c8cc39..00000000000
--- a/app/assets/javascripts/boards/stores/actions.js
+++ /dev/null
@@ -1,925 +0,0 @@
-import { sortBy } from 'lodash';
-import * as Sentry from '~/sentry/sentry_browser_wrapper';
-import {
- ListType,
- inactiveId,
- flashAnimationDuration,
- ISSUABLE,
- titleQueries,
- subscriptionQueries,
- deleteListQueries,
- listsQuery,
- updateListQueries,
- FilterFields,
- ListTypeTitles,
- DraggableItemTypes,
- DEFAULT_BOARD_LIST_ITEMS_SIZE,
-} from 'ee_else_ce/boards/constants';
-import {
- formatIssueInput,
- formatBoardLists,
- formatListIssues,
- formatListsPageInfo,
- formatIssue,
- updateListPosition,
- moveItemListHelper,
- getMoveData,
- FiltersInfo,
- filterVariables,
-} from 'ee_else_ce/boards/boards_util';
-import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
-import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
-import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
-import { fetchPolicies } from '~/lib/graphql';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client';
-import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { queryToObject } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
-import eventHub from '../eventhub';
-import projectBoardQuery from '../graphql/project_board.query.graphql';
-import groupBoardQuery from '../graphql/group_board.query.graphql';
-import boardLabelsQuery from '../graphql/board_labels.query.graphql';
-import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
-import groupProjectsQuery from '../graphql/group_projects.query.graphql';
-import issueCreateMutation from '../graphql/issue_create.mutation.graphql';
-import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
-import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql';
-
-import * as types from './mutation_types';
-
-export default {
- fetchBoard: ({ commit, dispatch }, { fullPath, fullBoardId, boardType }) => {
- commit(types.REQUEST_CURRENT_BOARD);
-
- const variables = {
- fullPath,
- boardId: fullBoardId,
- };
-
- return gqlClient
- .query({
- query: boardType === WORKSPACE_GROUP ? groupBoardQuery : projectBoardQuery,
- variables,
- })
- .then(({ data }) => {
- if (data.workspace?.errors) {
- commit(types.RECEIVE_BOARD_FAILURE);
- } else {
- const board = data.workspace?.board;
- dispatch('setBoard', board);
- }
- })
- .catch(() => commit(types.RECEIVE_BOARD_FAILURE));
- },
-
- setInitialBoardData: ({ commit }, data) => {
- commit(types.SET_INITIAL_BOARD_DATA, data);
- },
-
- setBoardConfig: ({ commit }, board) => {
- const config = {
- milestoneId: board.milestone?.id || null,
- milestoneTitle: board.milestone?.title || null,
- iterationId: board.iteration?.id || null,
- iterationTitle: board.iteration?.title || null,
- iterationCadenceId: board.iterationCadence?.id || null,
- assigneeId: board.assignee?.id || null,
- assigneeUsername: board.assignee?.username || null,
- labels: board.labels?.nodes || [],
- labelIds: board.labels?.nodes?.map((label) => label.id) || [],
- weight: board.weight,
- };
- commit(types.SET_BOARD_CONFIG, config);
- },
-
- setBoard: async ({ commit, dispatch }, board) => {
- commit(types.RECEIVE_BOARD_SUCCESS, board);
- await dispatch('setBoardConfig', board);
- dispatch('performSearch', { resetLists: true });
- eventHub.$emit('updateTokens');
- },
-
- setActiveId({ commit }, { id, sidebarType }) {
- commit(types.SET_ACTIVE_ID, { id, sidebarType });
- },
-
- unsetActiveId({ dispatch }) {
- dispatch('setActiveId', { id: inactiveId, sidebarType: '' });
- },
-
- setFilters: ({ commit, state: { issuableType } }, filters) => {
- commit(
- types.SET_FILTERS,
- filterVariables({
- filters,
- issuableType,
- filterInfo: FiltersInfo,
- filterFields: FilterFields,
- }),
- );
- },
-
- performSearch({ dispatch }, { resetLists = false } = {}) {
- dispatch(
- 'setFilters',
- convertObjectPropsToCamelCase(queryToObject(window.location.search, { gatherArrays: true })),
- );
- dispatch('fetchLists', { resetLists });
- dispatch('resetIssues');
- },
-
- fetchLists: ({ commit, state, dispatch }, { resetLists = false } = {}) => {
- const { boardType, filterParams, fullPath, fullBoardId, issuableType } = state;
-
- const variables = {
- fullPath,
- boardId: fullBoardId,
- filters: filterParams,
- ...(issuableType === TYPE_ISSUE && {
- isGroup: boardType === WORKSPACE_GROUP,
- isProject: boardType === WORKSPACE_PROJECT,
- }),
- };
-
- return gqlClient
- .query({
- query: listsQuery[issuableType].query,
- variables,
- ...(resetLists ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}),
- })
- .then(({ data }) => {
- const { lists, hideBacklogList } = data[boardType].board;
- commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists));
- // Backlog list needs to be created if it doesn't exist and it's not hidden
- if (!lists.nodes.find((l) => l.listType === ListType.backlog) && !hideBacklogList) {
- dispatch('createList', { backlog: true });
- }
- })
- .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE));
- },
-
- highlightList: ({ commit, state }, listId) => {
- if ([ListType.backlog, ListType.closed].includes(state.boardLists[listId].listType)) {
- return;
- }
-
- commit(types.ADD_LIST_TO_HIGHLIGHTED_LISTS, listId);
-
- setTimeout(() => {
- commit(types.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS, listId);
- }, flashAnimationDuration);
- },
-
- createList: ({ dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
- dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId });
- },
-
- createIssueList: (
- { state, commit, dispatch, getters },
- { backlog, labelId, milestoneId, assigneeId, iterationId },
- ) => {
- const { fullBoardId } = state;
-
- const existingList = getters.getListByLabelId(labelId);
-
- if (existingList) {
- dispatch('highlightList', existingList.id);
- return;
- }
-
- gqlClient
- .mutate({
- mutation: createBoardListMutation,
- variables: {
- boardId: fullBoardId,
- backlog,
- labelId,
- milestoneId,
- assigneeId,
- iterationId,
- },
- })
- .then(({ data }) => {
- if (data.boardListCreate?.errors.length) {
- commit(types.CREATE_LIST_FAILURE, data.boardListCreate.errors[0]);
- } else {
- const list = data.boardListCreate?.list;
- dispatch('addList', list);
- dispatch('highlightList', list.id);
- }
- })
- .catch((e) => {
- commit(types.CREATE_LIST_FAILURE);
- throw e;
- });
- },
-
- addList: ({ commit, dispatch, getters }, list) => {
- commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list));
-
- dispatch('fetchItemsForList', {
- listId: getters.getListByTitle(ListTypeTitles.backlog)?.id,
- });
- },
-
- fetchLabels: ({ state, commit }, searchTerm) => {
- const { fullPath, boardType } = state;
-
- const variables = {
- fullPath,
- searchTerm,
- isGroup: boardType === WORKSPACE_GROUP,
- isProject: boardType === WORKSPACE_PROJECT,
- };
-
- commit(types.RECEIVE_LABELS_REQUEST);
-
- return gqlClient
- .query({
- query: boardLabelsQuery,
- variables,
- })
- .then(({ data }) => {
- const labels = data[boardType]?.labels.nodes;
-
- commit(types.RECEIVE_LABELS_SUCCESS, labels);
- return labels;
- })
- .catch((e) => {
- commit(types.RECEIVE_LABELS_FAILURE);
- throw e;
- });
- },
-
- fetchMilestones({ state, commit }, searchTerm) {
- commit(types.RECEIVE_MILESTONES_REQUEST);
-
- const { fullPath, boardType } = state;
-
- const variables = {
- fullPath,
- searchTerm,
- };
-
- let query;
- if (boardType === WORKSPACE_PROJECT) {
- query = projectBoardMilestonesQuery;
- }
- if (boardType === WORKSPACE_GROUP) {
- query = groupBoardMilestonesQuery;
- }
-
- if (!query) {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- throw new Error('Unknown board type');
- }
-
- return gqlClient
- .query({
- query,
- variables,
- })
- .then(({ data }) => {
- const errors = data.workspace?.errors;
- const milestones = data.workspace?.milestones.nodes;
-
- if (errors?.[0]) {
- throw new Error(errors[0]);
- }
-
- commit(types.RECEIVE_MILESTONES_SUCCESS, milestones);
-
- return milestones;
- })
- .catch((e) => {
- commit(types.RECEIVE_MILESTONES_FAILURE);
- throw e;
- });
- },
-
- moveList: (
- { state: { boardLists }, commit, dispatch },
- {
- item: {
- dataset: { listId: movedListId, draggableItemType },
- },
- newIndex,
- to: { children },
- },
- ) => {
- if (draggableItemType !== DraggableItemTypes.list) {
- return;
- }
-
- const displacedListId = children[newIndex].dataset.listId;
- if (movedListId === displacedListId) {
- return;
- }
-
- const listIds = sortBy(
- Object.keys(boardLists).filter(
- (listId) =>
- listId !== movedListId &&
- boardLists[listId].listType !== ListType.backlog &&
- boardLists[listId].listType !== ListType.closed,
- ),
- (i) => boardLists[i].position,
- );
-
- const targetPosition = boardLists[displacedListId].position;
- // When the dragged list moves left, displaced list should shift right.
- const shiftOffset = Number(boardLists[movedListId].position < targetPosition);
- const displacedListIndex = listIds.findIndex((listId) => listId === displacedListId);
-
- commit(
- types.MOVE_LISTS,
- listIds
- .slice(0, displacedListIndex + shiftOffset)
- .concat([movedListId], listIds.slice(displacedListIndex + shiftOffset))
- .map((listId, index) => ({ listId, position: index })),
- );
- dispatch('updateList', { listId: movedListId, position: targetPosition });
- },
-
- updateList: (
- { state: { issuableType, boardItemsByListId = {} }, dispatch },
- { listId, position, collapsed },
- ) => {
- gqlClient
- .mutate({
- mutation: updateListQueries[issuableType].mutation,
- variables: {
- listId,
- position,
- collapsed,
- },
- })
- .then(({ data }) => {
- if (data?.updateBoardList?.errors.length) {
- throw new Error();
- }
-
- // Only fetch when board items havent been fetched on a collapsed list
- if (!boardItemsByListId[listId]) {
- dispatch('fetchItemsForList', { listId });
- }
- })
- .catch(() => {
- dispatch('handleUpdateListFailure');
- });
- },
-
- handleUpdateListFailure: ({ dispatch, commit }) => {
- dispatch('fetchLists');
-
- commit(
- types.SET_ERROR,
- s__('Boards|An error occurred while updating the board list. Please try again.'),
- );
- },
-
- toggleListCollapsed: ({ commit }, { listId, collapsed }) => {
- commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed });
- },
-
- removeList: ({ state: { issuableType, boardLists }, commit, dispatch, getters }, listId) => {
- const listsBackup = { ...boardLists };
-
- commit(types.REMOVE_LIST, listId);
-
- return gqlClient
- .mutate({
- mutation: deleteListQueries[issuableType].mutation,
- variables: {
- listId,
- },
- })
- .then(
- ({
- data: {
- destroyBoardList: { errors },
- },
- }) => {
- if (errors.length > 0) {
- commit(types.REMOVE_LIST_FAILURE, listsBackup);
- } else {
- dispatch('fetchItemsForList', {
- listId: getters.getListByTitle(ListTypeTitles.backlog)?.id,
- });
- }
- },
- )
- .catch(() => {
- commit(types.REMOVE_LIST_FAILURE, listsBackup);
- });
- },
-
- fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => {
- if (!listId) return null;
-
- commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext });
-
- const { fullPath, fullBoardId, boardType, filterParams } = state;
- const variables = {
- fullPath,
- boardId: fullBoardId,
- id: listId,
- filters: filterParams,
- isGroup: boardType === WORKSPACE_GROUP,
- isProject: boardType === WORKSPACE_PROJECT,
- first: DEFAULT_BOARD_LIST_ITEMS_SIZE,
- after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined,
- };
-
- return gqlClient
- .query({
- query: listsIssuesQuery,
- variables,
- ...(!fetchNext ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}),
- })
- .then(({ data }) => {
- const { lists } = data[boardType].board;
- const listItems = formatListIssues(lists);
- const listPageInfo = formatListsPageInfo(lists);
- commit(types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, { listItems, listPageInfo, listId });
- })
- .catch(() => commit(types.RECEIVE_ITEMS_FOR_LIST_FAILURE, listId));
- },
-
- resetIssues: ({ commit }) => {
- commit(types.RESET_ISSUES);
- },
-
- moveItem: ({ dispatch }, payload) => {
- dispatch('moveIssue', payload);
- },
-
- moveIssue: ({ dispatch, state }, params) => {
- const moveData = getMoveData(state, params);
-
- dispatch('moveIssueCard', moveData);
- dispatch('updateMovedIssue', moveData);
- dispatch('updateIssueOrder', { moveData });
- },
-
- moveIssueCard: ({ commit }, moveData) => {
- const {
- reordering,
- shouldClone,
- itemNotInToList,
- originalIndex,
- itemId,
- fromListId,
- toListId,
- moveBeforeId,
- moveAfterId,
- positionInList,
- allItemsLoadedInList,
- } = moveData;
-
- commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
-
- if (reordering && !allItemsLoadedInList && positionInList === -1) {
- return;
- }
-
- if (reordering) {
- commit(types.ADD_BOARD_ITEM_TO_LIST, {
- itemId,
- listId: toListId,
- moveBeforeId,
- moveAfterId,
- positionInList,
- atIndex: originalIndex,
- allItemsLoadedInList,
- });
-
- return;
- }
-
- if (itemNotInToList) {
- commit(types.ADD_BOARD_ITEM_TO_LIST, {
- itemId,
- listId: toListId,
- moveBeforeId,
- moveAfterId,
- positionInList,
- });
- }
-
- if (shouldClone) {
- commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex });
- }
- },
-
- updateMovedIssue: (
- { commit, state: { boardItems, boardLists } },
- { itemId, fromListId, toListId },
- ) => {
- const updatedIssue = moveItemListHelper(
- boardItems[itemId],
- boardLists[fromListId],
- boardLists[toListId],
- );
-
- commit(types.UPDATE_BOARD_ITEM, updatedIssue);
- },
-
- undoMoveIssueCard: ({ commit }, moveData) => {
- const {
- reordering,
- shouldClone,
- itemNotInToList,
- itemId,
- fromListId,
- toListId,
- originalIssue,
- originalIndex,
- } = moveData;
-
- commit(types.UPDATE_BOARD_ITEM, originalIssue);
-
- if (reordering) {
- commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
- commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex });
- return;
- }
-
- if (shouldClone) {
- commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
- }
- if (itemNotInToList) {
- commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: toListId });
- }
-
- commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex });
- },
-
- updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => {
- try {
- const {
- itemId,
- fromListId,
- toListId,
- moveBeforeId,
- moveAfterId,
- itemNotInToList,
- positionInList,
- } = moveData;
- const {
- fullBoardId,
- filterParams,
- boardItems: {
- [itemId]: { iid, referencePath },
- },
- } = state;
-
- commit(types.MUTATE_ISSUE_IN_PROGRESS, true);
-
- const { data } = await gqlClient.mutate({
- mutation: issueMoveListMutation,
- variables: {
- iid,
- projectPath: referencePath.split(/[#]/)[0],
- boardId: fullBoardId,
- fromListId: getIdFromGraphQLId(fromListId),
- toListId: getIdFromGraphQLId(toListId),
- moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined,
- moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined,
- positionInList,
- // 'mutationVariables' allows EE code to pass in extra parameters.
- ...mutationVariables,
- },
- update(
- cache,
- {
- data: {
- issuableMoveList: {
- issuable: { weight },
- },
- },
- },
- ) {
- if (fromListId === toListId) return;
-
- const updateFromList = () => {
- const fromList = cache.readQuery({
- query: totalCountAndWeightQuery,
- variables: { id: fromListId, filters: filterParams },
- });
-
- const updatedFromList = {
- boardList: {
- __typename: 'BoardList',
- id: fromList.boardList.id,
- issuesCount: fromList.boardList.issuesCount - 1,
- totalIssueWeight: fromList.boardList.totalIssueWeight - Number(weight),
- },
- };
-
- cache.writeQuery({
- query: totalCountAndWeightQuery,
- variables: { id: fromListId, filters: filterParams },
- data: updatedFromList,
- });
- };
-
- const updateToList = () => {
- if (!itemNotInToList) return;
-
- const toList = cache.readQuery({
- query: totalCountAndWeightQuery,
- variables: { id: toListId, filters: filterParams },
- });
-
- const updatedToList = {
- boardList: {
- __typename: 'BoardList',
- id: toList.boardList.id,
- issuesCount: toList.boardList.issuesCount + 1,
- totalIssueWeight: toList.boardList.totalIssueWeight + Number(weight),
- },
- };
-
- cache.writeQuery({
- query: totalCountAndWeightQuery,
- variables: { id: toListId, filters: filterParams },
- data: updatedToList,
- });
- };
-
- updateFromList();
- updateToList();
- },
- });
-
- if (data?.issuableMoveList?.errors.length || !data.issuableMoveList) {
- throw new Error('issueMoveList empty');
- }
-
- commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issuableMoveList.issuable });
- commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
- } catch {
- commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
- commit(
- types.SET_ERROR,
- s__('Boards|An error occurred while moving the issue. Please try again.'),
- );
- dispatch('undoMoveIssueCard', moveData);
- }
- },
-
- setAssignees: ({ commit }, { id, assignees }) => {
- commit('UPDATE_BOARD_ITEM_BY_ID', {
- itemId: id,
- prop: 'assignees',
- value: assignees,
- });
- },
-
- addListItem: ({ commit, dispatch }, { list, item, position, inProgress = false }) => {
- commit(types.ADD_BOARD_ITEM_TO_LIST, {
- listId: list.id,
- itemId: item.id,
- atIndex: position,
- inProgress,
- });
- commit(types.UPDATE_BOARD_ITEM, item);
- if (!inProgress) {
- dispatch('setActiveId', { id: item.id, sidebarType: ISSUABLE });
- }
- },
-
- removeListItem: ({ commit }, { listId, itemId }) => {
- commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { listId, itemId });
- commit(types.REMOVE_BOARD_ITEM, itemId);
- },
-
- addListNewIssue: (
- { state: { boardConfig, boardType, fullPath, filterParams }, dispatch, commit },
- { issueInput, list, placeholderId = `tmp-${new Date().getTime()}` },
- ) => {
- const input = formatIssueInput(issueInput, boardConfig);
-
- if (boardType === WORKSPACE_PROJECT) {
- input.projectPath = fullPath;
- }
-
- const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId, isLoading: true });
- dispatch('addListItem', { list, item: placeholderIssue, position: 0, inProgress: true });
-
- gqlClient
- .mutate({
- mutation: issueCreateMutation,
- variables: { input },
- update(cache) {
- const fromList = cache.readQuery({
- query: totalCountAndWeightQuery,
- variables: { id: list.id, filters: filterParams },
- });
-
- const updatedList = {
- boardList: {
- __typename: 'BoardList',
- id: fromList.boardList.id,
- issuesCount: fromList.boardList.issuesCount + 1,
- totalIssueWeight: fromList.boardList.totalIssueWeight,
- },
- };
-
- cache.writeQuery({
- query: totalCountAndWeightQuery,
- variables: { id: list.id, filters: filterParams },
- data: updatedList,
- });
- },
- })
- .then(({ data }) => {
- if (data.createIssuable.errors.length) {
- throw new Error();
- }
-
- const rawIssue = data.createIssuable?.issuable;
- const formattedIssue = formatIssue(rawIssue);
- dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
- dispatch('addListItem', { list, item: formattedIssue, position: 0 });
- })
- .catch(() => {
- dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
- commit(
- types.SET_ERROR,
- s__('Boards|An error occurred while creating the issue. Please try again.'),
- );
- });
- },
-
- setActiveBoardItemLabels: ({ dispatch }, params) => {
- dispatch('setActiveIssueLabels', params);
- },
-
- setActiveIssueLabels: async ({ commit, getters }, input) => {
- const { activeBoardItem } = getters;
-
- let labels = input?.labels || [];
- if (input.removeLabelIds) {
- labels = activeBoardItem.labels.filter(
- (label) => input.removeLabelIds[0] !== getIdFromGraphQLId(label.id),
- );
- }
- commit(types.UPDATE_BOARD_ITEM_BY_ID, {
- itemId: input.id || activeBoardItem.id,
- prop: 'labels',
- value: labels,
- });
- },
-
- setActiveItemSubscribed: async ({ commit, getters, state }, input) => {
- const { activeBoardItem, isEpicBoard } = getters;
- const { fullPath, issuableType } = state;
- const workspacePath = isEpicBoard
- ? { groupPath: fullPath }
- : { projectPath: input.projectPath };
- const { data } = await gqlClient.mutate({
- mutation: subscriptionQueries[issuableType].mutation,
- variables: {
- input: {
- ...workspacePath,
- iid: String(activeBoardItem.iid),
- subscribedState: input.subscribed,
- },
- },
- });
-
- if (data.updateIssuableSubscription?.errors?.length > 0) {
- throw new Error(data.updateIssuableSubscription[issuableType].errors);
- }
-
- commit(types.UPDATE_BOARD_ITEM_BY_ID, {
- itemId: activeBoardItem.id,
- prop: 'subscribed',
- value: data.updateIssuableSubscription[issuableType].subscribed,
- });
- },
-
- setActiveItemTitle: async ({ commit, getters, state }, input) => {
- const { activeBoardItem, isEpicBoard } = getters;
- const { fullPath, issuableType } = state;
- const workspacePath = isEpicBoard
- ? { groupPath: fullPath }
- : { projectPath: input.projectPath };
- const { data } = await gqlClient.mutate({
- mutation: titleQueries[issuableType].mutation,
- variables: {
- input: {
- ...workspacePath,
- iid: String(activeBoardItem.iid),
- title: input.title,
- },
- },
- });
-
- if (data.updateIssuableTitle?.errors?.length > 0) {
- throw new Error(data.updateIssuableTitle.errors);
- }
-
- commit(types.UPDATE_BOARD_ITEM_BY_ID, {
- itemId: activeBoardItem.id,
- prop: 'title',
- value: data.updateIssuableTitle[issuableType].title,
- });
- },
-
- setActiveItemConfidential: ({ commit, getters }, confidential) => {
- const { activeBoardItem } = getters;
- commit(types.UPDATE_BOARD_ITEM_BY_ID, {
- itemId: activeBoardItem.id,
- prop: 'confidential',
- value: confidential,
- });
- },
-
- fetchGroupProjects: ({ commit, state }, { search = '', fetchNext = false }) => {
- commit(types.REQUEST_GROUP_PROJECTS, fetchNext);
-
- const { fullPath } = state;
-
- const variables = {
- fullPath,
- search: search !== '' ? search : undefined,
- after: fetchNext ? state.groupProjectsFlags.pageInfo.endCursor : undefined,
- };
-
- return gqlClient
- .query({
- query: groupProjectsQuery,
- variables,
- })
- .then(({ data }) => {
- const { projects } = data.group;
- commit(types.RECEIVE_GROUP_PROJECTS_SUCCESS, {
- projects: projects.nodes,
- pageInfo: projects.pageInfo,
- fetchNext,
- });
- })
- .catch(() => commit(types.RECEIVE_GROUP_PROJECTS_FAILURE));
- },
-
- setSelectedProject: ({ commit }, project) => {
- commit(types.SET_SELECTED_PROJECT, project);
- },
-
- toggleBoardItemMultiSelection: ({ commit, state, dispatch, getters }, boardItem) => {
- const { selectedBoardItems } = state;
- const index = selectedBoardItems.indexOf(boardItem);
-
- // If user already selected an item (activeBoardItem) without using mult-select,
- // include that item in the selection and unset state.ActiveId to hide the sidebar.
- if (getters.activeBoardItem) {
- commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeBoardItem);
- dispatch('unsetActiveId');
- }
-
- if (index === -1) {
- commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem);
- } else {
- commit(types.REMOVE_BOARD_ITEM_FROM_SELECTION, boardItem);
- }
- },
-
- setAddColumnFormVisibility: ({ commit }, visible) => {
- commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible);
- },
-
- resetBoardItemMultiSelection: ({ commit }) => {
- commit(types.RESET_BOARD_ITEM_SELECTION);
- },
-
- toggleBoardItem: ({ state, dispatch }, { boardItem, sidebarType = ISSUABLE }) => {
- dispatch('resetBoardItemMultiSelection');
-
- if (boardItem.id === state.activeId) {
- dispatch('unsetActiveId');
- } else {
- dispatch('setActiveId', { id: boardItem.id, sidebarType });
- }
- },
-
- setError: ({ commit }, { message, error, captureError = true }) => {
- commit(types.SET_ERROR, message);
-
- if (captureError) {
- Sentry.captureException(error);
- }
- },
-
- unsetError: ({ commit }) => {
- commit(types.SET_ERROR, undefined);
- },
-
- // EE action needs CE empty equivalent
- setActiveItemWeight: () => {},
- setActiveItemHealthStatus: () => {},
-};
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
deleted file mode 100644
index 0ad71165996..00000000000
--- a/app/assets/javascripts/boards/stores/getters.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import { find } from 'lodash';
-import { TYPE_ISSUE } from '~/issues/constants';
-import { inactiveId } from '../constants';
-
-export default {
- isSidebarOpen: (state) => state.activeId !== inactiveId,
- isSwimlanesOn: () => false,
- getBoardItemById: (state) => (id) => {
- return state.boardItems[id] || {};
- },
-
- getBoardItemsByList: (state, getters) => (listId) => {
- const listItemsIds = state.boardItemsByListId[listId] || [];
- return listItemsIds.map((id) => getters.getBoardItemById(id));
- },
-
- activeBoardItem: (state) => {
- return state.boardItems[state.activeId] || { iid: '', id: '' };
- },
-
- groupPathForActiveIssue: (_, getters) => {
- const { referencePath = '' } = getters.activeBoardItem;
- return referencePath.slice(0, referencePath.lastIndexOf('/'));
- },
-
- projectPathForActiveIssue: (_, getters) => {
- const { referencePath = '' } = getters.activeBoardItem;
- return referencePath.slice(0, referencePath.indexOf('#'));
- },
-
- activeGroupProjects: (state) => {
- return state.groupProjects.filter((p) => !p.archived);
- },
-
- getListByLabelId: (state) => (labelId) => {
- if (!labelId) {
- return null;
- }
- return find(state.boardLists, (l) => l.label?.id === labelId);
- },
-
- getListByTitle: (state) => (title) => {
- return find(state.boardLists, (l) => l.title === title);
- },
-
- isIssueBoard: (state) => {
- return state.issuableType === TYPE_ISSUE;
- },
-
- isEpicBoard: () => {
- return false;
- },
-
- hasScope: (state) => {
- const { boardConfig } = state;
- if (boardConfig.labels?.length > 0) {
- return true;
- }
- let hasScope = false;
- ['assigneeId', 'iterationCadenceId', 'iterationId', 'milestoneId', 'weight'].forEach((attr) => {
- if (boardConfig[attr] !== null && boardConfig[attr] !== undefined) {
- hasScope = true;
- }
- });
- return hasScope;
- },
-};
diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js
deleted file mode 100644
index fd562df1df7..00000000000
--- a/app/assets/javascripts/boards/stores/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import actions from 'ee_else_ce/boards/stores/actions';
-import getters from 'ee_else_ce/boards/stores/getters';
-import mutations from 'ee_else_ce/boards/stores/mutations';
-import state from 'ee_else_ce/boards/stores/state';
-
-Vue.use(Vuex);
-
-export const storeOptions = {
- state,
- getters,
- actions,
- mutations,
-};
-
-export const createStore = (options = storeOptions) => new Vuex.Store(options);
-
-export default createStore();
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
deleted file mode 100644
index 0e496677b7b..00000000000
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ /dev/null
@@ -1,47 +0,0 @@
-export const REQUEST_CURRENT_BOARD = 'REQUEST_CURRENT_BOARD';
-export const RECEIVE_BOARD_SUCCESS = 'RECEIVE_BOARD_SUCCESS';
-export const RECEIVE_BOARD_FAILURE = 'RECEIVE_BOARD_FAILURE';
-export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA';
-export const SET_BOARD_CONFIG = 'SET_BOARD_CONFIG';
-export const SET_FILTERS = 'SET_FILTERS';
-export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS';
-export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
-export const RECEIVE_LABELS_REQUEST = 'RECEIVE_LABELS_REQUEST';
-export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
-export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
-export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE';
-export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
-export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE';
-export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST';
-export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
-export const MOVE_LISTS = 'MOVE_LISTS';
-export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED';
-export const REMOVE_LIST = 'REMOVE_LIST';
-export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
-export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST';
-export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE';
-export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS';
-export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST';
-export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
-export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE';
-export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM';
-export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM';
-export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS';
-export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST';
-export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST';
-export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
-export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID';
-export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
-export const RESET_ISSUES = 'RESET_ISSUES';
-export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS';
-export const RECEIVE_GROUP_PROJECTS_SUCCESS = 'RECEIVE_GROUP_PROJECTS_SUCCESS';
-export const RECEIVE_GROUP_PROJECTS_FAILURE = 'RECEIVE_GROUP_PROJECTS_FAILURE';
-export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT';
-export const ADD_BOARD_ITEM_TO_SELECTION = 'ADD_BOARD_ITEM_TO_SELECTION';
-export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTION';
-export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE';
-export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
-export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
-export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
-export const SET_ERROR = 'SET_ERROR';
-export const MUTATE_ISSUE_IN_PROGRESS = 'MUTATE_ISSUE_IN_PROGRESS';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
deleted file mode 100644
index 505c011b034..00000000000
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ /dev/null
@@ -1,319 +0,0 @@
-import { cloneDeep, pull, union } from 'lodash';
-import Vue from 'vue';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_EPIC } from '~/issues/constants';
-import { s__, __ } from '~/locale';
-import { formatIssue } from '../boards_util';
-import * as mutationTypes from './mutation_types';
-
-const updateListItemsCount = ({ state, listId, value }) => {
- const list = state.boardLists[listId];
- if (state.issuableType === TYPE_EPIC) {
- const listItem = cloneDeep(state.boardLists[listId]);
- listItem.metadataepicsCount += value;
- Vue.set(state.boardLists[listId], listId, listItem);
- }
- Vue.set(state.boardLists, listId, { ...list });
-};
-
-export const removeItemFromList = ({ state, listId, itemId, reordering = false }) => {
- Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId));
- if (!reordering) {
- updateListItemsCount({ state, listId, value: -1 });
- }
-};
-
-export const addItemToList = ({
- state,
- listId,
- itemId,
- moveBeforeId,
- moveAfterId,
- atIndex,
- positionInList,
- reordering = false,
-}) => {
- const listIssues = state.boardItemsByListId[listId];
- let newIndex = atIndex || 0;
- const moveToStartOrLast = positionInList !== undefined;
- if (moveBeforeId) {
- newIndex = listIssues.indexOf(moveBeforeId) + 1;
- } else if (moveAfterId) {
- newIndex = listIssues.indexOf(moveAfterId);
- } else if (moveToStartOrLast) {
- newIndex = positionInList === -1 ? listIssues.length : 0;
- }
- listIssues.splice(newIndex, 0, itemId);
- Vue.set(state.boardItemsByListId, listId, listIssues);
- if (!reordering) {
- updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 });
- }
-};
-
-export default {
- [mutationTypes.REQUEST_CURRENT_BOARD]: (state) => {
- state.isBoardLoading = true;
- },
-
- [mutationTypes.RECEIVE_BOARD_SUCCESS]: (state, board) => {
- state.board = {
- ...board,
- labels: board?.labels?.nodes || [],
- };
- state.fullBoardId = board.id;
- state.boardId = getIdFromGraphQLId(board.id);
- state.isBoardLoading = false;
- },
-
- [mutationTypes.RECEIVE_BOARD_FAILURE]: (state) => {
- state.error = s__('Boards|An error occurred while fetching the board. Please reload the page.');
- state.isBoardLoading = false;
- },
-
- [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
- const {
- allowSubEpics,
- boardId,
- boardType,
- disabled,
- fullBoardId,
- fullPath,
- issuableType,
- } = data;
- state.allowSubEpics = allowSubEpics;
- state.boardId = boardId;
- state.boardType = boardType;
- state.disabled = disabled;
- state.fullBoardId = fullBoardId;
- state.fullPath = fullPath;
- state.issuableType = issuableType;
- },
-
- [mutationTypes.SET_BOARD_CONFIG](state, boardConfig) {
- state.boardConfig = boardConfig;
- },
-
- [mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => {
- state.boardLists = lists;
- },
-
- [mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: (state) => {
- state.error = s__(
- 'Boards|An error occurred while fetching the board lists. Please reload the page.',
- );
- },
-
- [mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) {
- state.activeId = id;
- state.sidebarType = sidebarType;
- },
-
- [mutationTypes.SET_FILTERS](state, filterParams) {
- state.filterParams = filterParams;
- },
-
- [mutationTypes.CREATE_LIST_FAILURE]: (
- state,
- error = s__('Boards|An error occurred while creating the list. Please try again.'),
- ) => {
- state.error = error;
- },
-
- [mutationTypes.RECEIVE_LABELS_REQUEST]: (state) => {
- state.labelsLoading = true;
- },
-
- [mutationTypes.RECEIVE_LABELS_SUCCESS]: (state, labels) => {
- state.labels = labels;
- state.labelsLoading = false;
- },
-
- [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => {
- state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.');
- state.labelsLoading = false;
- },
-
- [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => {
- state.error = s__('Boards|An error occurred while generating lists. Please reload the page.');
- },
-
- [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: (state, list) => {
- Vue.set(state.boardLists, list.id, list);
- },
-
- [mutationTypes.MOVE_LISTS]: (state, movedLists) => {
- const updatedBoardList = movedLists.reduce((acc, { listId, position }) => {
- acc[listId].position = position;
- return acc;
- }, cloneDeep(state.boardLists));
- Vue.set(state, 'boardLists', updatedBoardList);
- },
-
- [mutationTypes.TOGGLE_LIST_COLLAPSED]: (state, { listId, collapsed }) => {
- Vue.set(state.boardLists[listId], 'collapsed', collapsed);
- },
-
- [mutationTypes.REMOVE_LIST]: (state, listId) => {
- Vue.delete(state.boardLists, listId);
- },
-
- [mutationTypes.REMOVE_LIST_FAILURE](state, listsBackup) {
- state.error = s__('Boards|An error occurred while removing the list. Please try again.');
- state.boardLists = listsBackup;
- },
-
- [mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => {
- Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true });
- },
-
- [mutationTypes.RECEIVE_MILESTONES_SUCCESS](state, milestones) {
- state.milestones = milestones;
- state.milestonesLoading = false;
- },
-
- [mutationTypes.RECEIVE_MILESTONES_REQUEST](state) {
- state.milestonesLoading = true;
- },
-
- [mutationTypes.RECEIVE_MILESTONES_FAILURE](state) {
- state.milestonesLoading = false;
- state.error = __('Failed to load milestones.');
- },
-
- [mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => {
- const { listData, boardItems } = listItems;
- Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems });
- Vue.set(
- state.boardItemsByListId,
- listId,
- union(state.boardItemsByListId[listId] || [], listData[listId]),
- );
- Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]);
- Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
- },
-
- [mutationTypes.RECEIVE_ITEMS_FOR_LIST_FAILURE]: (state, listId) => {
- state.error = s__(
- 'Boards|An error occurred while fetching the board issues. Please reload the page.',
- );
- Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
- },
-
- [mutationTypes.RESET_ISSUES]: (state) => {
- Object.keys(state.boardItemsByListId).forEach((listId) => {
- Vue.set(state.boardItemsByListId, listId, []);
- });
- },
-
- [mutationTypes.UPDATE_BOARD_ITEM_BY_ID]: (state, { itemId, prop, value }) => {
- if (!state.boardItems[itemId]) {
- /* eslint-disable-next-line @gitlab/require-i18n-strings */
- throw new Error('No issue found.');
- }
-
- Vue.set(state.boardItems[itemId], prop, value);
- },
-
- [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
- state.isSettingAssignees = isLoading;
- },
-
- [mutationTypes.MUTATE_ISSUE_SUCCESS]: (state, { issue }) => {
- Vue.set(state.boardItems, issue.id, formatIssue(issue));
- },
-
- [mutationTypes.MUTATE_ISSUE_IN_PROGRESS](state, isLoading) {
- state.isUpdateIssueOrderInProgress = isLoading;
- },
-
- [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: (
- state,
- {
- itemId,
- listId,
- moveBeforeId,
- moveAfterId,
- atIndex,
- positionInList,
- allItemsLoadedInList,
- inProgress = false,
- },
- ) => {
- Vue.set(state.listsFlags, listId, { ...state.listsFlags, addItemToListInProgress: inProgress });
- addItemToList({
- state,
- listId,
- itemId,
- moveBeforeId,
- moveAfterId,
- atIndex,
- positionInList,
- allItemsLoadedInList,
- });
- },
-
- [mutationTypes.REMOVE_BOARD_ITEM_FROM_LIST]: (state, { itemId, listId }) => {
- removeItemFromList({ state, listId, itemId });
- },
-
- [mutationTypes.UPDATE_BOARD_ITEM]: (state, item) => {
- Vue.set(state.boardItems, item.id, item);
- },
-
- [mutationTypes.REMOVE_BOARD_ITEM]: (state, itemId) => {
- Vue.delete(state.boardItems, itemId);
- },
-
- [mutationTypes.REQUEST_GROUP_PROJECTS]: (state, fetchNext) => {
- Vue.set(state, 'groupProjectsFlags', {
- [fetchNext ? 'isLoadingMore' : 'isLoading']: true,
- pageInfo: state.groupProjectsFlags.pageInfo,
- });
- },
-
- [mutationTypes.RECEIVE_GROUP_PROJECTS_SUCCESS]: (state, { projects, pageInfo, fetchNext }) => {
- Vue.set(state, 'groupProjects', fetchNext ? [...state.groupProjects, ...projects] : projects);
- Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false, pageInfo });
- },
-
- [mutationTypes.RECEIVE_GROUP_PROJECTS_FAILURE]: (state) => {
- state.error = s__('Boards|An error occurred while fetching group projects. Please try again.');
- Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false });
- },
-
- [mutationTypes.SET_SELECTED_PROJECT]: (state, project) => {
- state.selectedProject = project;
- },
-
- [mutationTypes.ADD_BOARD_ITEM_TO_SELECTION]: (state, boardItem) => {
- state.selectedBoardItems = [...state.selectedBoardItems, boardItem];
- },
-
- [mutationTypes.REMOVE_BOARD_ITEM_FROM_SELECTION]: (state, boardItem) => {
- Vue.set(
- state,
- 'selectedBoardItems',
- state.selectedBoardItems.filter((obj) => obj !== boardItem),
- );
- },
-
- [mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => {
- Vue.set(state.addColumnForm, 'visible', visible);
- },
-
- [mutationTypes.ADD_LIST_TO_HIGHLIGHTED_LISTS]: (state, listId) => {
- state.highlightedLists.push(listId);
- },
-
- [mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => {
- state.highlightedLists = state.highlightedLists.filter((id) => id !== listId);
- },
-
- [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
- state.selectedBoardItems = [];
- },
-
- [mutationTypes.SET_ERROR]: (state, error) => {
- state.error = error;
- },
-};
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
deleted file mode 100644
index bf3f777ea7d..00000000000
--- a/app/assets/javascripts/boards/stores/state.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import { inactiveId, ListType } from '~/boards/constants';
-
-export default () => ({
- board: {},
- isBoardLoading: false,
- boardType: null,
- issuableType: null,
- fullPath: null,
- disabled: false,
- isShowingLabels: true,
- activeId: inactiveId,
- sidebarType: '',
- boardLists: {},
- listsFlags: {},
- boardItemsByListId: {},
- isSettingAssignees: false,
- pageInfoByListId: {},
- boardItems: {},
- filterParams: {},
- boardConfig: {},
- labelsLoading: false,
- labels: [],
- milestones: [],
- milestonesLoading: false,
- highlightedLists: [],
- selectedBoardItems: [],
- groupProjects: [],
- groupProjectsFlags: {
- isLoading: false,
- isLoadingMore: false,
- pageInfo: {},
- },
- selectedProject: {},
- error: undefined,
- iterations: [],
- iterationsLoading: false,
- addColumnForm: {
- visible: false,
- columnType: ListType.label,
- },
- // TODO: remove after ce/ee split of board_content.vue
- isShowingEpicsSwimlanes: false,
- isUpdateIssueOrderInProgress: false,
-});
diff --git a/app/assets/javascripts/branches/components/graph_bar.vue b/app/assets/javascripts/branches/components/graph_bar.vue
index 21cbcac820a..885db7651a1 100644
--- a/app/assets/javascripts/branches/components/graph_bar.vue
+++ b/app/assets/javascripts/branches/components/graph_bar.vue
@@ -62,7 +62,7 @@ export default {
:class="[roundedClass, positionSideClass]"
class="position-absolute bar js-graph-bar"
></div>
- <span :class="textAlignmentClass" class="d-block pt-1 pr-1 count js-graph-count">
+ <span :class="textAlignmentClass" class="gl-display-block gl-pt-1 gl-px-1 count js-graph-count">
{{ label }}
</span>
</div>
diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js
index 0dacd5af5cc..b06a6f8b141 100644
--- a/app/assets/javascripts/breadcrumb.js
+++ b/app/assets/javascripts/breadcrumb.js
@@ -34,7 +34,7 @@ export default () => {
$('li.expander').remove();
// set focus on first breadcrumb item
- $('.breadcrumb-item-text').first().focus();
+ $('.js-breadcrumb-item-text').first().focus();
});
}
};
diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue
index 36aa098d5ff..fd37e498699 100644
--- a/app/assets/javascripts/captcha/captcha_modal.vue
+++ b/app/assets/javascripts/captcha/captcha_modal.vue
@@ -26,10 +26,21 @@ export default {
type: String,
required: true,
},
+ showModal: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ resetSession: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
modalId: uniqueId('captcha-modal-'),
+ captcha: null,
};
},
watch: {
@@ -37,29 +48,44 @@ export default {
// If this is true, we need to present the captcha modal to the user.
// When the modal is shown we will also initialize and render the form.
if (newNeedsCaptchaResponse) {
- this.$refs.modal.show();
+ this.renderCaptcha();
}
},
+ resetSession: {
+ immediate: true,
+ handler(reset) {
+ if (reset && this.captcha) {
+ this.resetCaptcha();
+ }
+ },
+ },
},
mounted() {
// If this is true, we need to present the captcha modal to the user.
// When the modal is shown we will also initialize and render the form.
if (this.needsCaptchaResponse) {
- this.$refs.modal.show();
+ this.renderCaptcha();
}
},
methods: {
emitReceivedCaptchaResponse(captchaResponse) {
- this.$refs.modal.hide();
+ if (this.showModal) this.$refs.modal.hide();
this.$emit('receivedCaptchaResponse', captchaResponse);
},
emitNullReceivedCaptchaResponse() {
this.emitReceivedCaptchaResponse(null);
},
+ renderCaptcha() {
+ if (this.showModal) {
+ this.$refs.modal.show();
+ } else {
+ this.initCaptcha();
+ }
+ },
/**
* handler for when modal is shown
*/
- shown() {
+ initCaptcha() {
const containerRef = this.$refs.captcha;
// NOTE: This is the only bit that is specific to Google's reCAPTCHA captcha implementation.
@@ -72,12 +98,13 @@ export default {
// TODO: Also need to handle expired-callback and error-callback
// See https://gitlab.com/gitlab-org/gitlab/-/issues/217722#future-follow-on-issuesmrs
});
+
+ this.captcha = grecaptcha;
})
.catch((e) => {
// TODO: flash the error or notify the user some other way
// See https://gitlab.com/gitlab-org/gitlab/-/issues/217722#future-follow-on-issuesmrs
this.emitNullReceivedCaptchaResponse();
- this.$refs.modal.hide();
// eslint-disable-next-line no-console
console.error(
@@ -96,6 +123,9 @@ export default {
this.emitNullReceivedCaptchaResponse();
}
},
+ resetCaptcha() {
+ this.captcha.reset();
+ },
},
};
</script>
@@ -104,17 +134,19 @@ export default {
<!-- there must be at least one button or focusable element, or the gl-modal fails to render. -->
<!-- We could modify gl-model to remove this requirement. -->
<gl-modal
+ v-if="showModal"
ref="modal"
:modal-id="modalId"
:title="__('Please solve the captcha')"
:action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
text: __('Cancel'),
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- @shown="shown"
+ @shown="initCaptcha"
@hide="hide"
@hidden="$emit('hidden')"
>
<div ref="captcha"></div>
<p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p>
</gl-modal>
+ <div v-else ref="captcha" class="gl-display-inline-block"></div>
</template>
diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue
index 6d062d8b7f1..7085397c649 100644
--- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue
+++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue
@@ -106,7 +106,7 @@ export default {
<div class="gl-display-flex">
<pre
class="gl-w-85p gl-py-4 gl-display-flex gl-justify-content-space-between gl-m-0 gl-border-r-none"
- ><span>{{ generateSnippet(component.path) }}</span>
+ ><span>{{ generateSnippet(component.includePath) }}</span>
</pre>
<div class="gl--flex-center gl-bg-gray-10 gl-border gl-border-l-none">
<gl-button
@@ -115,7 +115,7 @@ export default {
icon="copy-to-clipboard"
size="small"
:title="$options.i18n.copyText"
- :data-clipboard-text="generateSnippet(component.path)"
+ :data-clipboard-text="generateSnippet(component.includePath)"
data-testid="copy-to-clipboard"
:aria-label="$options.i18n.copyAriaText"
/>
diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue
index b9d6173a777..929175b964f 100644
--- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue
+++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue
@@ -49,7 +49,7 @@ export default {
return getIdFromGraphQLId(this.resource.id);
},
hasLatestVersion() {
- return this.latestVersion?.tagName;
+ return this.latestVersion?.name;
},
hasPipelineStatus() {
return this.pipelineStatus?.text;
@@ -58,7 +58,7 @@ export default {
return this.resource.latestVersion;
},
versionBadgeText() {
- return this.latestVersion.tagName;
+ return this.latestVersion.name;
},
webPath() {
return cleanLeadingSeparator(this.resource?.webPath);
@@ -92,7 +92,7 @@ export default {
v-if="hasLatestVersion"
size="sm"
class="gl-ml-3 gl-my-1"
- :href="latestVersion.tagPath"
+ :href="latestVersion.path"
>
{{ versionBadgeText }}
</gl-badge>
diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue
index 343b555c4d8..ccef50e469d 100644
--- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue
+++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue
@@ -50,6 +50,6 @@ export default {
<template>
<div>
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
- <div v-else v-safe-html="readmeHtml"></div>
+ <div v-else v-safe-html="readmeHtml" class="md"></div>
</div>
</template>
diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue
index 3a9ec341789..001e3ec3720 100644
--- a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue
+++ b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue
@@ -1,5 +1,6 @@
<script>
import { GlBanner, GlLink } from '@gitlab/ui';
+import ChatBubbleSvg from '@gitlab/svgs/dist/illustrations/chat-sm.svg?url';
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue';
@@ -44,6 +45,7 @@ export default {
learnMore: __('Learn more'),
},
learnMorePath: helpPagePath('ci/components/index'),
+ ChatBubbleSvg,
};
</script>
<template>
@@ -54,6 +56,7 @@ export default {
:title="$options.i18n.banner.title"
:button-text="$options.i18n.banner.btnText"
button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/407556"
+ :svg-path="$options.ChatBubbleSvg"
@close="handleDismissBanner"
>
<p>
diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue
index e074cfda6f7..1319f204573 100644
--- a/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue
+++ b/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSearchBoxByClick, GlSorting, GlSortingItem } from '@gitlab/ui';
+import { GlSearchBoxByClick, GlSorting } from '@gitlab/ui';
import { __ } from '~/locale';
import { SORT_ASC, SORT_DESC, SORT_OPTION_CREATED } from '../../constants';
@@ -7,7 +7,6 @@ export default {
components: {
GlSearchBoxByClick,
GlSorting,
- GlSortingItem,
},
data() {
return {
@@ -25,7 +24,7 @@ export default {
},
currentSortText() {
const currentSort = this.$options.sortOptions.find(
- (sort) => sort.key === this.currentSortOption,
+ (sort) => sort.value === this.currentSortOption,
);
return currentSort.text;
},
@@ -36,9 +35,6 @@ export default {
},
},
methods: {
- isActiveSort(sortItem) {
- return sortItem === this.currentSortOption;
- },
onClear() {
this.$emit('update-search-term', '');
},
@@ -49,10 +45,10 @@ export default {
this.$emit('update-search-term', this.searchTerm);
},
setSelectedSortOption(sortingItem) {
- this.currentSortOption = sortingItem.key;
+ this.currentSortOption = sortingItem;
},
},
- sortOptions: [{ key: SORT_OPTION_CREATED, text: __('Created at') }],
+ sortOptions: [{ value: SORT_OPTION_CREATED, text: __('Created at') }],
};
</script>
<template>
@@ -66,16 +62,10 @@ export default {
<gl-sorting
:is-ascending="isAscending"
:text="currentSortText"
+ :sort-options="$options.sortOptions"
+ :sort-by="currentSortOption"
+ @sortByChange="setSelectedSortOption"
@sortDirectionChange="onSortDirectionChange"
- >
- <gl-sorting-item
- v-for="sortingItem in $options.sortOptions"
- :key="sortingItem.key"
- :active="isActiveSort(sortingItem.key)"
- @click="setSelectedSortOption(sortingItem)"
- >
- {{ sortingItem.text }}
- </gl-sorting-item>
- </gl-sorting>
+ />
</div>
</template>
diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_tabs.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_tabs.vue
new file mode 100644
index 00000000000..f43255ab76b
--- /dev/null
+++ b/app/assets/javascripts/ci/catalog/components/list/catalog_tabs.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { SCOPE } from '../../constants';
+
+export default {
+ components: {
+ GlBadge,
+ GlTab,
+ GlTabs,
+ GlLoadingIcon,
+ },
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ resourceCounts: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ tabs() {
+ return [
+ {
+ text: s__('CiCatalog|All'),
+ scope: SCOPE.all,
+ testId: 'resources-all-tab',
+ count: this.resourceCounts.all,
+ },
+ {
+ text: s__('CiCatalog|Your resources'),
+ scope: SCOPE.namespaces,
+ testId: 'resources-your-tab',
+ count: this.resourceCounts.namespaces,
+ },
+ ];
+ },
+ showLoadingIcon() {
+ return this.isLoading;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex align-items-lg-center">
+ <gl-tabs content-class="gl-py-0" class="gl-w-full">
+ <gl-tab
+ v-for="tab in tabs"
+ :key="tab.text"
+ :data-testid="tab.testId"
+ @click="$emit('setScope', tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.text }}</span>
+ <gl-loading-icon v-if="showLoadingIcon" class="gl-ml-3" />
+
+ <gl-badge v-else size="sm" class="gl-tab-counter-badge">
+ {{ tab.count }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue
index 57d19af614f..42f8cea8727 100644
--- a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue
+++ b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue
@@ -67,8 +67,8 @@ export default {
releasedAt() {
return getTimeago().format(this.latestVersion?.releasedAt);
},
- tagName() {
- return this.latestVersion?.tagName || this.$options.i18n.unreleased;
+ name() {
+ return this.latestVersion?.name || this.$options.i18n.unreleased;
},
webPath() {
return cleanLeadingSeparator(this.resource?.webPath);
@@ -117,7 +117,7 @@ export default {
<b> {{ resource.name }}</b>
</gl-link>
<div class="gl-display-flex gl-flex-grow-1 gl-md-justify-content-space-between">
- <gl-badge size="sm" class="gl-h-5 gl-align-self-center">{{ tagName }}</gl-badge>
+ <gl-badge size="sm" class="gl-h-5 gl-align-self-center">{{ name }}</gl-badge>
<span class="gl-display-flex gl-align-items-center gl-ml-5">
<span
v-gl-tooltip.top
diff --git a/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue b/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue
index e1c86f38d7e..08500d3093c 100644
--- a/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue
+++ b/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue
@@ -3,6 +3,7 @@ import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { ciCatalogResourcesItemsCount } from '~/ci/catalog/graphql/settings';
import CatalogSearch from '../list/catalog_search.vue';
+import CatalogTabs from '../list/catalog_tabs.vue';
import CiResourcesList from '../list/ci_resources_list.vue';
import CatalogListSkeletonLoader from '../list/catalog_list_skeleton_loader.vue';
import CatalogHeader from '../list/catalog_header.vue';
@@ -10,29 +11,60 @@ import EmptyState from '../list/empty_state.vue';
import getCatalogResources from '../../graphql/queries/get_ci_catalog_resources.query.graphql';
import getCurrentPage from '../../graphql/queries/client/get_current_page.query.graphql';
import updateCurrentPageMutation from '../../graphql/mutations/client/update_current_page.mutation.graphql';
+import getCatalogResourcesCount from '../../graphql/queries/get_ci_catalog_resources_count.query.graphql';
+import { DEFAULT_SORT_VALUE, SCOPE } from '../../constants';
export default {
+ i18n: {
+ fetchError: s__('CiCatalog|There was an error fetching CI/CD Catalog resources.'),
+ countFetchError: s__('CiCatalog|There was an error fetching the CI/CD Catalog resource count.'),
+ },
components: {
CatalogHeader,
CatalogListSkeletonLoader,
CatalogSearch,
+ CatalogTabs,
CiResourcesList,
EmptyState,
},
data() {
return {
catalogResources: [],
+ catalogResourcesCount: { all: 0, namespaces: 0 },
currentPage: 1,
pageInfo: {},
- searchTerm: '',
- totalCount: 0,
+ scope: SCOPE.all,
+ searchTerm: null,
+ sortValue: DEFAULT_SORT_VALUE,
};
},
apollo: {
+ catalogResourcesCount: {
+ query: getCatalogResourcesCount,
+ variables() {
+ return {
+ searchTerm: this.searchTerm,
+ };
+ },
+ update({ namespaces, all }) {
+ return {
+ namespaces: namespaces.count,
+ all: all.count,
+ };
+ },
+ error(e) {
+ createAlert({
+ message: e.message || this.$options.i18n.countFetchError,
+ });
+ },
+ },
catalogResources: {
query: getCatalogResources,
variables() {
return {
+ scope: this.scope,
+ searchTerm: this.searchTerm,
+ sortValue: this.sortValue,
first: ciCatalogResourcesItemsCount,
};
},
@@ -42,10 +74,9 @@ export default {
result({ data }) {
const { pageInfo } = data?.ciCatalogResources || {};
this.pageInfo = pageInfo;
- this.totalCount = data?.ciCatalogResources?.count || 0;
},
error(e) {
- createAlert({ message: e.message || this.$options.i18n.fetchError, variant: 'danger' });
+ createAlert({ message: e.message || this.$options.i18n.fetchError });
},
},
currentPage: {
@@ -62,11 +93,14 @@ export default {
isLoading() {
return this.$apollo.queries.catalogResources.loading;
},
- isSearching() {
- return this.searchTerm?.length > 0;
+ isLoadingCounts() {
+ return this.$apollo.queries.catalogResourcesCount.loading;
+ },
+ namespacesCount() {
+ return this.catalogResourcesCount.namespaces;
},
- showEmptyState() {
- return !this.hasResources && !this.isSearching;
+ currentTabTotalCount() {
+ return this.catalogResourcesCount[this.scope.toLowerCase()];
},
},
methods: {
@@ -103,6 +137,11 @@ export default {
createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' });
}
},
+ handleSetScope(scope) {
+ if (this.scope === scope) return;
+
+ this.scope = scope;
+ },
updatePageCount(pageNumber) {
this.$apollo.mutate({
mutation: updateCurrentPageMutation,
@@ -120,30 +159,28 @@ export default {
onUpdateSearchTerm(searchTerm) {
this.searchTerm = !searchTerm.length ? null : searchTerm;
this.resetPageCount();
- this.$apollo.queries.catalogResources.refetch({
- searchTerm: this.searchTerm,
- });
},
onUpdateSorting(sortValue) {
+ this.sortValue = sortValue;
this.resetPageCount();
- this.$apollo.queries.catalogResources.refetch({
- sortValue,
- });
},
resetPageCount() {
this.updatePageCount(1);
},
},
- i18n: {
- fetchError: s__('CiCatalog|There was an error fetching CI/CD Catalog resources.'),
- },
};
</script>
<template>
<div>
<catalog-header />
+ <catalog-tabs
+ :is-loading="isLoadingCounts"
+ :resource-counts="catalogResourcesCount"
+ class="gl-mb-3"
+ @setScope="handleSetScope"
+ />
<catalog-search
- class="gl-py-4 gl-border-b-1 gl-border-gray-100 gl-border-b-solid gl-border-t-1 gl-border-t-solid"
+ class="gl-py-2"
@update-search-term="onUpdateSearchTerm"
@update-sorting="onUpdateSorting"
/>
@@ -156,7 +193,7 @@ export default {
:prev-text="__('Prev')"
:next-text="__('Next')"
:resources="catalogResources"
- :total-count="totalCount"
+ :total-count="currentTabTotalCount"
@onPrevPage="handlePrevPage"
@onNextPage="handleNextPage"
/>
diff --git a/app/assets/javascripts/ci/catalog/constants.js b/app/assets/javascripts/ci/catalog/constants.js
index 34c0ac797c1..a180aa84344 100644
--- a/app/assets/javascripts/ci/catalog/constants.js
+++ b/app/assets/javascripts/ci/catalog/constants.js
@@ -2,8 +2,14 @@ import { helpPagePath } from '~/helpers/help_page_helper';
export const CATALOG_FEEDBACK_DISMISSED_KEY = 'catalog_feedback_dismissed';
+export const SCOPE = {
+ all: 'ALL',
+ namespaces: 'NAMESPACES',
+};
+
export const SORT_OPTION_CREATED = 'CREATED';
export const SORT_ASC = 'ASC';
export const SORT_DESC = 'DESC';
+export const DEFAULT_SORT_VALUE = `${SORT_OPTION_CREATED}_${SORT_DESC}`;
export const COMPONENTS_DOCS_URL = helpPagePath('ci/components/index');
diff --git a/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql
index b3a750e9604..316308e96d7 100644
--- a/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql
+++ b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql
@@ -7,8 +7,8 @@ fragment CatalogResourceFields on CiCatalogResource {
starCount
latestVersion {
id
- tagName
- tagPath
+ name
+ path
releasedAt
author {
id
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql b/app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_create.mutation.graphql
index c3b73ebf248..c3b73ebf248 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql
+++ b/app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_create.mutation.graphql
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.mutation.graphql b/app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_destroy.mutation.graphql
index fa42b081a5f..fa42b081a5f 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.mutation.graphql
+++ b/app/assets/javascripts/ci/catalog/graphql/mutations/catalog_resources_destroy.mutation.graphql
diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql
index 41ac72aa9de..bf1edf1af6e 100644
--- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql
+++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql
@@ -8,7 +8,7 @@ query getCiCatalogResourceComponents($fullPath: ID!) {
nodes {
id
name
- path
+ includePath
inputs {
name
required
diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql
index a77e8f12d03..efc8aa777d4 100644
--- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql
+++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql
@@ -22,7 +22,7 @@ query getCiCatalogResourceDetails($fullPath: ID!) {
}
}
}
- tagName
+ name
releasedAt
}
}
diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql
index 1cf213dec63..24789e9c4ed 100644
--- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql
+++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql
@@ -1,6 +1,7 @@
#import "~/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql"
query getCatalogResources(
+ $scope: CiCatalogResourceScope
$searchTerm: String
$sortValue: CiCatalogResourceSort
$after: String
@@ -9,6 +10,7 @@ query getCatalogResources(
$last: Int
) {
ciCatalogResources(
+ scope: $scope
search: $searchTerm
sort: $sortValue
after: $after
@@ -22,7 +24,6 @@ query getCatalogResources(
hasNextPage
hasPreviousPage
}
- count
nodes {
...CatalogResourceFields
}
diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources_count.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources_count.query.graphql
new file mode 100644
index 00000000000..d4a298e7e09
--- /dev/null
+++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources_count.query.graphql
@@ -0,0 +1,8 @@
+query getCatalogResourcesCount($searchTerm: String) {
+ all: ciCatalogResources(scope: ALL, search: $searchTerm) {
+ count
+ }
+ namespaces: ciCatalogResources(scope: NAMESPACES, search: $searchTerm) {
+ count
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql
index 0de06028386..0de06028386 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql
+++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql
diff --git a/app/assets/javascripts/ci/catalog/graphql/settings.js b/app/assets/javascripts/ci/catalog/graphql/settings.js
index 4038188a7ce..abc95592b14 100644
--- a/app/assets/javascripts/ci/catalog/graphql/settings.js
+++ b/app/assets/javascripts/ci/catalog/graphql/settings.js
@@ -15,7 +15,7 @@ export const cacheConfig = {
});
},
ciCatalogResources: {
- keyArgs: false,
+ keyArgs: ['scope', 'search', 'sort'],
},
},
},
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_environments_dropdown/ci_environments_dropdown.vue
index 77af643cbb3..2d2e3e280c0 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci/ci_environments_dropdown/ci_environments_dropdown.vue
@@ -3,8 +3,12 @@ import { debounce, uniq } from 'lodash';
import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__, sprintf } from '~/locale';
-import { convertEnvironmentScope } from '../utils';
-import { ENVIRONMENT_QUERY_LIMIT } from '../constants';
+import { convertEnvironmentScope } from './utils';
+import {
+ ALL_ENVIRONMENTS_OPTION,
+ ENVIRONMENT_QUERY_LIMIT,
+ NO_ENVIRONMENT_OPTION,
+} from './constants';
export default {
name: 'CiEnvironmentsDropdown',
@@ -16,10 +20,20 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
+ isEnvironmentRequired: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
areEnvironmentsLoading: {
type: Boolean,
required: true,
},
+ canCreateWildcard: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
environments: {
type: Array,
required: true,
@@ -51,22 +65,31 @@ export default {
searchedEnvironments() {
let filtered = this.environments;
- // If there is no search term, make sure to include *
- if (!this.searchTerm) {
- filtered = uniq([...filtered, '*']);
- }
-
// add custom env scope if it matches the search term
if (this.customEnvScope && this.customEnvScope.startsWith(this.searchTerm)) {
filtered = uniq([...filtered, this.customEnvScope]);
}
+ // If there is no search term, make sure to include *
+ if (!this.searchTerm) {
+ filtered = uniq([...filtered, ALL_ENVIRONMENTS_OPTION.type]);
+
+ // lastly, add Not Applicable (None) as the first option if isEnvironmentRequired is true
+ if (!this.isEnvironmentRequired) {
+ filtered = [NO_ENVIRONMENT_OPTION.type, ...filtered];
+ }
+ }
+
return filtered.sort().map((environment) => ({
value: environment,
text: environment,
}));
},
shouldRenderCreateButton() {
+ if (!this.canCreateWildcard) {
+ return false;
+ }
+
return (
this.searchTerm && ![...this.environments, this.customEnvScope].includes(this.searchTerm)
);
diff --git a/app/assets/javascripts/ci/ci_environments_dropdown/constants.js b/app/assets/javascripts/ci/ci_environments_dropdown/constants.js
new file mode 100644
index 00000000000..98e543a75d0
--- /dev/null
+++ b/app/assets/javascripts/ci/ci_environments_dropdown/constants.js
@@ -0,0 +1,14 @@
+import { __ } from '~/locale';
+
+export const ENVIRONMENT_QUERY_LIMIT = 30;
+
+export const ALL_ENVIRONMENTS_OPTION = {
+ type: '*',
+ text: __('All (default)'),
+};
+
+export const NO_ENVIRONMENT_OPTION = {
+ // TODO: This is a placeholder value. It will be replaced with the actual value used once it's implemented on the backend
+ type: 'Not applicable',
+ text: __('Not applicable'),
+};
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql b/app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/group_environments.query.graphql
index 5768d370474..5768d370474 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql
+++ b/app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/group_environments.query.graphql
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/project_environments.query.graphql
index 26d1b6a3aaa..26d1b6a3aaa 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
+++ b/app/assets/javascripts/ci/ci_environments_dropdown/graphql/queries/project_environments.query.graphql
diff --git a/app/assets/javascripts/ci/ci_variable_list/utils.js b/app/assets/javascripts/ci/ci_environments_dropdown/utils.js
index a7e020206ea..093b04dab0c 100644
--- a/app/assets/javascripts/ci/ci_variable_list/utils.js
+++ b/app/assets/javascripts/ci/ci_environments_dropdown/utils.js
@@ -1,4 +1,4 @@
-import { allEnvironments } from './constants';
+import { ALL_ENVIRONMENTS_OPTION, NO_ENVIRONMENT_OPTION } from './constants';
/**
* This function job is to convert the * wildcard to text when applicable
@@ -10,11 +10,14 @@ import { allEnvironments } from './constants';
*/
export const convertEnvironmentScope = (environmentScope = '') => {
- if (environmentScope === allEnvironments.type || !environmentScope) {
- return allEnvironments.text;
+ switch (environmentScope) {
+ case ALL_ENVIRONMENTS_OPTION.type || '':
+ return ALL_ENVIRONMENTS_OPTION.text;
+ case NO_ENVIRONMENT_OPTION.type:
+ return NO_ENVIRONMENT_OPTION.text;
+ default:
+ return environmentScope;
}
-
- return environmentScope;
};
/**
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
index 842d88e1267..b118d54d2b0 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
@@ -2,8 +2,8 @@
import { TYPENAME_GROUP } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { getGroupEnvironments } from '~/ci/common/private/ci_environments_dropdown';
import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants';
-import getGroupEnvironments from '../graphql/queries/group_environments.query.graphql';
import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql';
import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql';
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue
index 43938e9b88f..822a2b01f24 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue
@@ -2,8 +2,8 @@
import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { getProjectEnvironments } from '~/ci/common/private/ci_environments_dropdown';
import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants';
-import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql';
import getProjectVariables from '../graphql/queries/project_variables.query.graphql';
import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql';
import deleteProjectVariable from '../graphql/mutations/project_delete_variable.mutation.graphql';
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue
index 2ad6c7c6578..ad4b7b790d0 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue
@@ -20,8 +20,8 @@ import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { helpPagePath } from '~/helpers/help_page_helper';
import Tracking from '~/tracking';
+import CiEnvironmentsDropdown from '~/ci/common/private/ci_environments_dropdown';
import {
- allEnvironments,
defaultVariableState,
DRAWER_EVENT_LABEL,
EDIT_VARIABLE_ACTION,
@@ -34,7 +34,6 @@ import {
variableOptions,
WHITESPACE_REG_EX,
} from '../constants';
-import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
import { awsTokenList } from './ci_variable_autocomplete_tokens';
const trackingMixin = Tracking.mixin({ label: DRAWER_EVENT_LABEL });
@@ -43,9 +42,10 @@ const KEY_REGEX = /^\w+$/;
export const i18n = {
addVariable: s__('CiVariables|Add variable'),
cancel: __('Cancel'),
- defaultScope: allEnvironments.text,
+ defaultScope: __('All (default)'),
deleteVariable: s__('CiVariables|Delete variable'),
editVariable: s__('CiVariables|Edit variable'),
+ saveVariable: __('Save changes'),
environments: __('Environments'),
environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE,
expandedField: s__('CiVariables|Expand variable reference'),
@@ -259,9 +259,12 @@ export default {
return validationIssuesText.trim();
},
- modalActionText() {
+ modalTitle() {
return this.isEditing ? this.$options.i18n.editVariable : this.$options.i18n.addVariable;
},
+ modalActionText() {
+ return this.isEditing ? this.$options.i18n.saveVariable : this.$options.i18n.addVariable;
+ },
removeVariableMessage() {
return sprintf(this.$options.i18n.modalDeleteMessage, { key: this.variable.key });
},
@@ -359,7 +362,7 @@ export default {
@close="close"
>
<template #title>
- <h2 class="gl-m-0">{{ modalActionText }}</h2>
+ <h2 class="gl-m-0">{{ modalTitle }}</h2>
</template>
<gl-form-group
:label="$options.i18n.type"
@@ -493,8 +496,8 @@ export default {
v-model="variable.value"
:spellcheck="false"
class="gl-border-none gl-font-monospace!"
- rows="3"
- max-rows="10"
+ rows="5"
+ :no-resize="false"
data-testid="ci-variable-value"
/>
<p
@@ -515,9 +518,15 @@ export default {
>
{{ $options.i18n.variableReferenceDescription }}
</gl-alert>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close"
- >{{ $options.i18n.cancel }}
+ <div class="gl-display-flex">
+ <gl-button
+ category="primary"
+ class="gl-mr-3"
+ variant="confirm"
+ :disabled="!canSubmit"
+ data-testid="ci-variable-confirm-button"
+ @click="submit"
+ >{{ modalActionText }}
</gl-button>
<gl-button
v-if="isEditing"
@@ -528,13 +537,8 @@ export default {
data-testid="ci-variable-delete-button"
>{{ $options.i18n.deleteVariable }}</gl-button
>
- <gl-button
- category="primary"
- variant="confirm"
- :disabled="!canSubmit"
- data-testid="ci-variable-confirm-button"
- @click="submit"
- >{{ modalActionText }}
+ <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close"
+ >{{ $options.i18n.cancel }}
</gl-button>
</div>
</gl-drawer>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
index 011a424b6c2..609b8523612 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
@@ -3,11 +3,13 @@ import { createAlert } from '~/alert';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { reportToSentry } from '~/ci/utils';
-import { mapEnvironmentNames } from '../utils';
+import {
+ ENVIRONMENT_QUERY_LIMIT,
+ mapEnvironmentNames,
+} from '~/ci/common/private/ci_environments_dropdown';
import {
ADD_MUTATION_ACTION,
DELETE_MUTATION_ACTION,
- ENVIRONMENT_QUERY_LIMIT,
SORT_DIRECTIONS,
UPDATE_MUTATION_ACTION,
mapMutationActionToToast,
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
index 86287d586ec..901bd39930a 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
@@ -15,13 +15,13 @@ import {
} from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { convertEnvironmentScope } from '~/ci/common/private/ci_environments_dropdown';
import {
DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT,
EXCEEDS_VARIABLE_LIMIT_TEXT,
MAXIMUM_VARIABLE_LIMIT_REACHED,
variableTypes,
} from '../constants';
-import { convertEnvironmentScope } from '../utils';
export default {
defaultFields: [
diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index 4ec7333f465..c4f92fed829 100644
--- a/app/assets/javascripts/ci/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
@@ -1,7 +1,5 @@
import { __, s__, sprintf } from '~/locale';
-export const ENVIRONMENT_QUERY_LIMIT = 30;
-
export const MASKED_VALUE_MIN_LENGTH = 8;
export const WHITESPACE_REG_EX = /\s/;
@@ -15,18 +13,13 @@ export const variableTypes = {
fileType: 'FILE',
};
-export const allEnvironments = {
- type: '*',
- text: __('All (default)'),
-};
-
export const variableOptions = [
{ value: variableTypes.envType, text: __('Variable (default)') },
{ value: variableTypes.fileType, text: __('File') },
];
export const defaultVariableState = {
- environmentScope: allEnvironments.type,
+ environmentScope: '*',
key: '',
masked: false,
protected: false,
diff --git a/app/assets/javascripts/ci/common/private/ci_environments_dropdown.js b/app/assets/javascripts/ci/common/private/ci_environments_dropdown.js
new file mode 100644
index 00000000000..f8958f9600c
--- /dev/null
+++ b/app/assets/javascripts/ci/common/private/ci_environments_dropdown.js
@@ -0,0 +1,9 @@
+import CiEnvironmentsDropdown from '~/ci/ci_environments_dropdown/ci_environments_dropdown.vue';
+
+export default CiEnvironmentsDropdown;
+
+export { getGroupEnvironments } from '~/ci/ci_environments_dropdown/graphql/queries/group_environments.query.graphql';
+export { getProjectEnvironments } from '~/ci/ci_environments_dropdown/graphql/queries/project_environments.query.graphql';
+
+export { ENVIRONMENT_QUERY_LIMIT } from '~/ci/ci_environments_dropdown/constants';
+export * from '~/ci/ci_environments_dropdown/utils';
diff --git a/app/assets/javascripts/ci/pipeline_details/constants.js b/app/assets/javascripts/ci/pipeline_details/constants.js
index 51d0e980e78..90e723ea442 100644
--- a/app/assets/javascripts/ci/pipeline_details/constants.js
+++ b/app/assets/javascripts/ci/pipeline_details/constants.js
@@ -5,7 +5,7 @@ export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source
export const NEEDS_PROPERTY = 'needs';
export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds';
-export const TestStatus = {
+export const testStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
SUCCESS: 'success',
diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js
index e3984685094..87081e61e48 100644
--- a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js
+++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js
@@ -1,6 +1,6 @@
import { __, sprintf } from '~/locale';
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
-import { TestStatus } from '../../constants';
+import { testStatus } from '../../constants';
/**
* Removes `./` from the beginning of a file path so it can be appended onto a blob path
@@ -13,15 +13,15 @@ export function formatFilePath(file) {
export function iconForTestStatus(status) {
switch (status) {
- case TestStatus.SUCCESS:
+ case testStatus.SUCCESS:
return 'status_success';
- case TestStatus.FAILED:
+ case testStatus.FAILED:
return 'status_failed';
- case TestStatus.ERROR:
+ case testStatus.ERROR:
return 'status_warning';
- case TestStatus.SKIPPED:
+ case testStatus.SKIPPED:
return 'status_skipped';
- case TestStatus.UNKNOWN:
+ case testStatus.UNKNOWN:
default:
return 'status_notfound';
}
diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue
index 6e9a705c046..5fd9f7cfd4f 100644
--- a/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue
+++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue
@@ -2,7 +2,12 @@
import { GlLoadingIcon } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
-import { getParameterValues } from '~/lib/utils/url_utility';
+import {
+ getParameterValues,
+ updateHistory,
+ setUrlParams,
+ removeParams,
+} from '~/lib/utils/url_utility';
import EmptyState from './empty_state.vue';
import TestSuiteTable from './test_suite_table.vue';
import TestSummary from './test_summary.vue';
@@ -49,12 +54,28 @@ export default {
]),
summaryBackClick() {
this.removeSelectedSuiteIndex();
+
+ updateHistory({
+ url: removeParams(['job_name']),
+ title: document.title,
+ replace: true,
+ });
},
summaryTableRowClick(index) {
this.setSelectedSuiteIndex(index);
// Fetch the test suite when the user clicks to see more details
this.fetchTestSuite(index);
+
+ const urlParams = {
+ job_name: this.getSelectedSuite.name,
+ };
+
+ updateHistory({
+ url: setUrlParams(urlParams),
+ title: document.title,
+ replace: true,
+ });
},
beforeEnterTransition() {
document.documentElement.style.overflowX = 'hidden';
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
index 1d152a63407..a6e679e6d4e 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
@@ -13,6 +13,7 @@ import {
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { pipelineEditorTrackingOptions } from '../../constants';
import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue';
import CiLintResults from '../lint/ci_lint_results.vue';
@@ -189,6 +190,7 @@ export default {
},
i18n,
BASE_CLASSES,
+ lintHref: helpPagePath('ci/lint.md'),
};
</script>
@@ -290,7 +292,7 @@ export default {
<code>{{ content }}</code>
</template>
<template #link="{ content }">
- <gl-link target="_blank" href="#">{{ content }}</gl-link>
+ <gl-link target="_blank" :href="$options.lintHref">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
index e287e4e17d1..63957d9b7fc 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
@@ -33,16 +33,13 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-display-flex gl-flex-wrap gl-gap-2">
<runner-status-badge
:contacted-at="contactedAt"
:status="status"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- />
- <runner-paused-badge
- v-if="paused"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ class="gl-max-w-full gl-text-truncate"
/>
+ <runner-paused-badge v-if="paused" class="gl-max-w-full gl-text-truncate" />
<slot :runner="runner" name="runner-job-status-badge"></slot>
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue
index bed592e3f30..0dc23882cdc 100644
--- a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue
@@ -26,12 +26,12 @@ export default {
switch (this.jobStatus) {
case JOB_STATUS_RUNNING:
return {
- classes: 'gl-text-blue-600! gl-border gl-border-blue-600!',
+ classes: 'gl-text-blue-600! gl-inset-border-1-gray-400 gl-border-blue-600!',
label: I18N_JOB_STATUS_RUNNING,
};
case JOB_STATUS_IDLE:
return {
- classes: 'gl-text-gray-700! gl-border gl-border-gray-500!',
+ classes: 'gl-text-gray-700! gl-inset-border-1-gray-400 gl-border-gray-500!',
label: I18N_JOB_STATUS_IDLE,
};
default:
@@ -45,7 +45,7 @@ export default {
<gl-badge
v-if="badge"
v-bind="$attrs"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate gl-bg-transparent!"
+ class="gl-max-w-full gl-text-truncate gl-bg-transparent!"
variant="muted"
:class="badge.classes"
>
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index 9d7d68ee31c..c1a6f7e0800 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -161,6 +161,8 @@ export default {
</div>
</div>
</gl-tab>
+
+ <slot name="ee-workspaces-tab" :agent-name="agentName" :project-path="projectPath"></slot>
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue
index 5a5d221591a..0e92f20f0c1 100644
--- a/app/assets/javascripts/comment_templates/components/form.vue
+++ b/app/assets/javascripts/comment_templates/components/form.vue
@@ -1,11 +1,12 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
import { InternalEvents } from '~/tracking';
+import Api from '~/api';
import createSavedReplyMutation from '../queries/create_saved_reply.mutation.graphql';
import updateSavedReplyMutation from '../queries/update_saved_reply.mutation.graphql';
@@ -16,7 +17,7 @@ export default {
GlFormGroup,
GlFormInput,
GlAlert,
- MarkdownField,
+ MarkdownEditor,
},
mixins: [InternalEvents.mixin()],
props: {
@@ -45,6 +46,14 @@ export default {
name: this.name,
content: this.content,
},
+ formFieldProps: {
+ id: 'comment-template-content',
+ name: 'comment-template-content',
+ 'aria-label': __('Content'),
+ placeholder: __('Write comment template content here…'),
+ 'data-testid': 'comment-template-content-input',
+ class: 'note-textarea js-gfm-input js-autosize markdown-area',
+ },
};
},
computed: {
@@ -61,6 +70,9 @@ export default {
isValid() {
return this.isNameValid && this.isContentValid;
},
+ markdownPath() {
+ return Api.buildUrl(Api.markdownPath);
+ },
},
methods: {
onCancel() {
@@ -116,7 +128,6 @@ export default {
});
},
},
- restrictedToolbarItems: ['full-screen'],
markdownDocsPath: helpPagePath('user/markdown'),
};
</script>
@@ -156,30 +167,19 @@ export default {
data-testid="comment-template-content-form-group"
class="gl-lg-max-w-80p"
>
- <markdown-field
- :enable-preview="false"
+ <markdown-editor
+ v-model="updateCommentTemplate.content"
+ class="js-no-autosize"
:is-submitting="saving"
- :add-spacing-classes="false"
- :textarea-value="updateCommentTemplate.content"
+ :disable-attachments="true"
+ :render-markdown-path="markdownPath"
:markdown-docs-path="$options.markdownDocsPath"
+ :form-field-props="formFieldProps"
:restricted-tool-bar-items="$options.restrictedToolbarItems"
:force-autosize="false"
- class="js-no-autosize gl-border-gray-400!"
- >
- <template #textarea>
- <textarea
- v-model="updateCommentTemplate.content"
- dir="auto"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- data-supports-quick-actions="false"
- :aria-label="__('Content')"
- :placeholder="__('Write comment template content here…')"
- data-testid="comment-template-content-input"
- @keydown.meta.enter="onSubmit"
- @keydown.ctrl.enter="onSubmit"
- ></textarea>
- </template>
- </markdown-field>
+ @keydown.meta.enter="onSubmit"
+ @keydown.ctrl.enter="onSubmit"
+ />
</gl-form-group>
<gl-button
variant="confirm"
diff --git a/app/assets/javascripts/commit/components/signature_badge.vue b/app/assets/javascripts/commit/components/signature_badge.vue
index edc7c9d2f96..dc6d2df22b7 100644
--- a/app/assets/javascripts/commit/components/signature_badge.vue
+++ b/app/assets/javascripts/commit/components/signature_badge.vue
@@ -1,7 +1,7 @@
<script>
import { GlBadge, GlLink, GlPopover } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { typeConfig, statusConfig } from '../constants';
+import { typeConfig, statusConfig } from 'ee_else_ce/commit/constants';
import X509CertificateDetails from './x509_certificate_details.vue';
export default {
diff --git a/app/assets/javascripts/commit/constants.js b/app/assets/javascripts/commit/constants.js
index e28009ab996..9eba316b371 100644
--- a/app/assets/javascripts/commit/constants.js
+++ b/app/assets/javascripts/commit/constants.js
@@ -11,6 +11,7 @@ export const verificationStatuses = {
SAME_USER_DIFFERENT_EMAIL: 'SAME_USER_DIFFERENT_EMAIL',
MULTIPLE_SIGNATURES: 'MULTIPLE_SIGNATURES',
REVOKED_KEY: 'REVOKED_KEY',
+ VERIFIED_SYSTEM: 'VERIFIED_SYSTEM',
};
export const signatureTypes = {
@@ -28,15 +29,25 @@ const UNVERIFIED_CONFIG = {
description: __('This commit was signed with an unverified signature.'),
};
+export const VERIFIED_CONFIG = {
+ variant: 'success',
+ label: __('Verified'),
+ title: __('Verified commit'),
+};
+
export const statusConfig = {
[verificationStatuses.VERIFIED]: {
- variant: 'success',
- label: __('Verified'),
- title: __('Verified commit'),
+ ...VERIFIED_CONFIG,
description: __(
'This commit was signed with a verified signature and the committer email was verified to belong to the same user.',
),
},
+ [verificationStatuses.VERIFIED_SYSTEM]: {
+ ...VERIFIED_CONFIG,
+ description: __(
+ 'This commit was created in the GitLab UI, and signed with a GitLab-verified signature.',
+ ),
+ },
[verificationStatuses.UNVERIFIED]: {
...UNVERIFIED_CONFIG,
},
diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue
index 0b80802d993..b7031a4885c 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/image.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue
@@ -1,5 +1,6 @@
<script>
import { NodeViewWrapper } from '@tiptap/vue-2';
+import { uploadingStates } from '../../services/upload_helpers';
export default {
name: 'ImageWrapper',
@@ -30,6 +31,12 @@ export default {
dragData: {},
};
},
+ computed: {
+ isStaleUploadedImage() {
+ const { uploading } = this.node.attrs;
+ return uploading && uploadingStates[uploading];
+ },
+ },
mounted() {
document.addEventListener('mousemove', this.onDrag);
document.addEventListener('mouseup', this.onDragEnd);
@@ -80,7 +87,11 @@ export default {
};
</script>
<template>
- <node-view-wrapper as="span" class="gl-relative gl-display-inline-block">
+ <node-view-wrapper
+ v-if="!isStaleUploadedImage"
+ as="span"
+ class="gl-relative gl-display-inline-block"
+ >
<span
v-for="handle in $options.resizeHandles"
v-show="selected"
diff --git a/app/assets/javascripts/content_editor/components/wrappers/playable.vue b/app/assets/javascripts/content_editor/components/wrappers/playable.vue
new file mode 100644
index 00000000000..8a380f23a3f
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/playable.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
+import { uploadingStates } from '../../services/upload_helpers';
+
+export default {
+ name: 'PlayableWrapper',
+ components: {
+ NodeViewWrapper,
+ NodeViewContent,
+ GlLink,
+ },
+ props: {
+ getPos: {
+ type: Function,
+ required: true,
+ },
+ editor: {
+ type: Object,
+ required: true,
+ },
+ node: {
+ type: Object,
+ required: true,
+ },
+ selected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ dragData: {},
+ };
+ },
+ computed: {
+ isStaleUploadedMedia() {
+ const { uploading } = this.node.attrs;
+ return uploading && uploadingStates[uploading];
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper
+ v-if="!isStaleUploadedMedia"
+ as="span"
+ :class="`media-container ${node.type.name}-container`"
+ >
+ <node-view-content
+ :as="node.type.name"
+ :src="node.attrs.src"
+ controls="true"
+ data-setup="{}"
+ :data-title="node.attrs.title || node.attrs.alt"
+ />
+ <gl-link :href="node.attrs.src" class="with-attachment-icon" target="_blank">
+ {{ node.attrs.title || node.attrs.alt }}
+ </gl-link>
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
index e7a1b058341..11ac024b799 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
@@ -7,7 +7,7 @@ import { __, n__ } from '~/locale';
const TABLE_CELL_HEADER = 'th';
const TABLE_CELL_BODY = 'td';
-function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 }) {
+function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1, align = 'left' }) {
const totalRows = selectedRect?.map.height;
const totalCols = selectedRect?.map.width;
const isTableBodyCell = cellType === TABLE_CELL_BODY;
@@ -20,9 +20,21 @@ function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 })
const showDeleteRowOption = totalRows > selectedRows + 1 && isTableBodyCell;
const showDeleteColumnOption = totalCols > selectedCols;
+ const isTableBodyHeader = cellType === TABLE_CELL_HEADER;
+ const showAlignLeftOption = isTableBodyHeader && (align === 'center' || align === 'right');
+ const showAlignCenterOption = isTableBodyHeader && align !== 'center';
+ const showAlignRightOption = isTableBodyHeader && align !== 'right';
+
return [
{
items: [
+ showAlignLeftOption && { text: __('Align column left'), value: 'alignColumnLeft' },
+ showAlignCenterOption && { text: __('Align column center'), value: 'alignColumnCenter' },
+ showAlignRightOption && { text: __('Align column right'), value: 'alignColumnRight' },
+ ].filter(Boolean),
+ },
+ {
+ items: [
{ text: __('Insert column before'), value: 'addColumnBefore' },
{ text: __('Insert column after'), value: 'addColumnAfter' },
isTableBodyCell && { text: __('Insert row before'), value: 'addRowBefore' },
@@ -93,6 +105,7 @@ export default {
cellType: this.cellType,
rowspan: this.node.attrs.rowspan,
colspan: this.node.attrs.colspan,
+ align: this.node.attrs.align,
});
},
},
@@ -129,7 +142,7 @@ export default {
runCommand({ value: command }) {
this.hideDropdown();
- this.editor.chain()[command]().run();
+ this.editor.chain()[command](this.getPos()).run();
},
hideDropdown() {
@@ -143,6 +156,7 @@ export default {
:as="cellType"
:rowspan="node.attrs.rowspan || 1"
:colspan="node.attrs.colspan || 1"
+ :align="node.attrs.align || 'left'"
dir="auto"
class="gl-m-0! gl-p-0! gl-relative"
@click="hideDropdown"
@@ -168,6 +182,10 @@ export default {
@action="runCommand"
/>
</span>
- <node-view-content as="div" class="gl-p-5 gl-min-w-10" />
+ <node-view-content
+ as="div"
+ class="gl-p-5 gl-min-w-10"
+ :style="{ 'text-align': node.attrs.align || 'left' }"
+ />
</node-view-wrapper>
</template>
diff --git a/app/assets/javascripts/content_editor/extensions/copy_paste.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js
index 23f2da7bc28..1164e7ead33 100644
--- a/app/assets/javascripts/content_editor/extensions/copy_paste.js
+++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js
@@ -172,6 +172,8 @@ export default Extension.create({
return true;
}
+ if (!textContent) return false;
+
const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js
index 47766c966a1..7726e0b6572 100644
--- a/app/assets/javascripts/content_editor/extensions/playable.js
+++ b/app/assets/javascripts/content_editor/extensions/playable.js
@@ -1,4 +1,6 @@
import { Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import PlayableWrapper from '../components/wrappers/playable.vue';
const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
@@ -58,7 +60,6 @@ export default Node.create({
controls: true,
'data-setup': '{}',
'data-title': node.attrs.alt,
- ...this.extraElementAttrs,
},
],
[
@@ -68,4 +69,8 @@ export default Node.create({
],
];
},
+
+ addNodeView() {
+ return VueNodeViewRenderer(PlayableWrapper);
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js
index 9f437ce066c..53dba4fd960 100644
--- a/app/assets/javascripts/content_editor/extensions/table_cell.js
+++ b/app/assets/javascripts/content_editor/extensions/table_cell.js
@@ -5,6 +5,17 @@ import TableCellBodyWrapper from '../components/wrappers/table_cell_body.vue';
export default TableCell.extend({
content: 'block+',
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ align: {
+ default: 'left',
+ parseHTML: (element) => element.getAttribute('align') || element.style.textAlign || 'left',
+ renderHTML: () => '',
+ },
+ };
+ },
+
addNodeView() {
return VueNodeViewRenderer(TableCellBodyWrapper);
},
diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js
index 045fd03199b..ca2a0eb5cfd 100644
--- a/app/assets/javascripts/content_editor/extensions/table_header.js
+++ b/app/assets/javascripts/content_editor/extensions/table_header.js
@@ -1,9 +1,45 @@
import { TableHeader } from '@tiptap/extension-table-header';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { CellSelection } from '@tiptap/pm/tables';
import TableCellHeaderWrapper from '../components/wrappers/table_cell_header.vue';
export default TableHeader.extend({
content: 'block+',
+
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ align: {
+ default: 'left',
+ parseHTML: (element) => element.getAttribute('align') || element.style.textAlign || 'left',
+ renderHTML: () => '',
+ },
+ };
+ },
+
+ addCommands() {
+ return {
+ ...this.parent?.(),
+ alignColumn: (pos, align) => ({ commands }) => {
+ commands.selectColumn(pos);
+ commands.updateAttributes('tableHeader', { align });
+ commands.updateAttributes('tableCell', { align });
+ },
+ alignColumnLeft: (pos) => ({ commands }) => commands.alignColumn(pos, 'left'),
+ alignColumnCenter: (pos) => ({ commands }) => commands.alignColumn(pos, 'center'),
+ alignColumnRight: (pos) => ({ commands }) => commands.alignColumn(pos, 'right'),
+ selectColumn: (pos) => ({ tr, dispatch }) => {
+ if (dispatch) {
+ const position = tr.doc.resolve(pos);
+ const colSelection = CellSelection.colSelection(position);
+ tr.setSelection(colSelection);
+ }
+
+ return true;
+ },
+ };
+ },
+
addNodeView() {
return VueNodeViewRenderer(TableCellHeaderWrapper);
},
diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js
index 849fd55034e..1e19878be9b 100644
--- a/app/assets/javascripts/content_editor/extensions/task_item.js
+++ b/app/assets/javascripts/content_editor/extensions/task_item.js
@@ -19,9 +19,17 @@ export default TaskItem.extend({
return checkbox?.checked;
},
- renderHTML: (attributes) => ({
- 'data-checked': attributes.checked,
- }),
+ renderHTML: (attributes) => attributes.checked && { 'data-checked': true },
+ keepOnSplit: false,
+ },
+ inapplicable: {
+ default: false,
+ parseHTML: (element) => {
+ const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox');
+
+ return typeof checkbox?.dataset.inapplicable !== 'undefined';
+ },
+ renderHTML: (attributes) => attributes.inapplicable && { 'data-inapplicable': true },
keepOnSplit: false,
},
};
@@ -33,6 +41,24 @@ export default TaskItem.extend({
tag: 'li.task-list-item',
priority: PARSE_HTML_PRIORITY_HIGHEST,
},
+ {
+ tag: 'li.task-list-item.inapplicable s',
+ skip: true,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
];
},
+
+ addNodeView() {
+ const nodeView = this.parent?.();
+ return ({ node, ...args }) => {
+ const nodeViewInstance = nodeView({ node, ...args });
+
+ if (node.attrs.inapplicable) {
+ nodeViewInstance.dom.querySelector('input[type=checkbox]').disabled = true;
+ }
+
+ return nodeViewInstance;
+ };
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/video.js b/app/assets/javascripts/content_editor/extensions/video.js
index 312e8cd5ff6..fa9fa866137 100644
--- a/app/assets/javascripts/content_editor/extensions/video.js
+++ b/app/assets/javascripts/content_editor/extensions/video.js
@@ -6,7 +6,6 @@ export default Playable.extend({
return {
...this.parent?.(),
mediaType: 'video',
- extraElementAttrs: { width: '400' },
};
},
});
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 972b4acf523..3b759de57f2 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -228,7 +228,11 @@ const defaultSerializerConfig = {
[TableHeader.name]: renderTableCell,
[TableRow.name]: renderTableRow,
[TaskItem.name]: preserveUnchanged((state, node) => {
- state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
+ let symbol = ' ';
+ if (node.attrs.inapplicable) symbol = '~';
+ else if (node.attrs.checked) symbol = 'x';
+
+ state.write(`[${symbol}] `);
if (!node.textContent) state.write('&nbsp;');
state.renderContent(node);
}),
diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
index a4abb8dcf38..de230c370b1 100644
--- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
+++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
@@ -24,19 +24,23 @@ const getRangeFromSourcePos = (sourcePos) => {
export const getMarkdownSource = (element) => {
if (!element.dataset.sourcepos) return undefined;
- const source = getFullSource(element);
- const range = getRangeFromSourcePos(element.dataset.sourcepos);
- let elSource = '';
-
- if (!source.length) return undefined;
-
- for (let i = range.start.row; i <= range.end.row; i += 1) {
- if (i === range.start.row) {
- elSource += source[i].substring(range.start.col);
- } else {
- elSource += `\n${source[i]}` || '';
+ try {
+ const source = getFullSource(element);
+ const range = getRangeFromSourcePos(element.dataset.sourcepos);
+ let elSource = '';
+
+ if (!source.length) return undefined;
+
+ for (let i = range.start.row; i <= range.end.row; i += 1) {
+ if (i === range.start.row) {
+ elSource += source[i].substring(range.start.col);
+ } else {
+ elSource += `\n${source[i]}` || '';
+ }
}
- }
- return elSource.trim();
+ return elSource.trim();
+ } catch {
+ return undefined;
+ }
};
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 87959a44560..7a2fbf8fcab 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -2,8 +2,8 @@ import { uniq, isString, omit, isFunction } from 'lodash';
import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility';
const defaultAttrs = {
- td: { colspan: 1, rowspan: 1, colwidth: null },
- th: { colspan: 1, rowspan: 1, colwidth: null },
+ td: { colspan: 1, rowspan: 1, colwidth: null, align: 'left' },
+ th: { colspan: 1, rowspan: 1, colwidth: null, align: 'left' },
};
const defaultIgnoreAttrs = ['sourceMarkdown', 'sourceMapKey'];
@@ -649,6 +649,9 @@ export const link = {
const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (href.startsWith('data:') || href.startsWith('blob:')) return '';
+
if (linkType(sourceMarkdown) === LINK_MARKDOWN) {
return '[';
}
@@ -668,6 +671,9 @@ export const link = {
const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (href.startsWith('data:') || href.startsWith('blob:')) return '';
+
if (isReference) {
return `][${state.esc(canonicalSrc || href || '')}]`;
}
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index f5785397bf0..960f28747b0 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -133,6 +133,8 @@ export const uploadFile = ({ uploadsPath, renderMarkdown, file }) => {
});
};
+export const uploadingStates = {};
+
const uploadMedia = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
// needed to avoid mismatched transaction error
await Promise.resolve();
@@ -170,6 +172,8 @@ const uploadMedia = async ({ type, editor, file, uploadsPath, renderMarkdown, ev
// the position might have changed while uploading, so we need to find it again
position = findUploadedFilePosition(editor, file.name);
+ uploadingStates[file.name] = true;
+
editor.view.dispatch(
editor.state.tr.setMeta('preventAutolink', true).setNodeMarkup(position, undefined, {
uploading: false,
@@ -200,6 +204,8 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve
const { selection } = editor.view.state;
const currentNode = selection.$to.node();
+ uploadingStates[file.name] = true;
+
let position = selection.to;
let content = {
type: 'text',
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue
index 7bc1eb5d652..4a2da487e9b 100644
--- a/app/assets/javascripts/deploy_keys/components/action_btn.vue
+++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton } from '@gitlab/ui';
-import eventHub from '../eventhub';
export default {
components: {
@@ -11,10 +10,6 @@ export default {
type: Object,
required: true,
},
- type: {
- type: String,
- required: true,
- },
category: {
type: String,
required: false,
@@ -30,6 +25,10 @@ export default {
required: false,
default: '',
},
+ mutation: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -39,10 +38,15 @@ export default {
methods: {
doAction() {
this.isLoading = true;
-
- eventHub.$emit(`${this.type}.key`, this.deployKey, () => {
- this.isLoading = false;
- });
+ this.$apollo
+ .mutate({
+ mutation: this.mutation,
+ variables: { id: this.deployKey.id },
+ })
+ .catch((error) => this.$emit('error', error))
+ .finally(() => {
+ this.isLoading = false;
+ });
},
},
};
@@ -50,6 +54,7 @@ export default {
<template>
<gl-button
+ v-bind="$attrs"
:category="category"
:variant="variant"
:icon="icon"
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index ec17bbea48f..7168a209b52 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -1,11 +1,18 @@
<script>
-import { GlButton, GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlLoadingIcon, GlPagination } from '@gitlab/ui';
import { createAlert } from '~/alert';
-import { s__ } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
+import { captureException } from '~/sentry/sentry_browser_wrapper';
+import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
-import eventHub from '../eventhub';
-import DeployKeysService from '../service';
-import DeployKeysStore from '../store';
+import deployKeysQuery from '../graphql/queries/deploy_keys.query.graphql';
+import currentPageQuery from '../graphql/queries/current_page.query.graphql';
+import currentScopeQuery from '../graphql/queries/current_scope.query.graphql';
+import confirmRemoveKeyQuery from '../graphql/queries/confirm_remove_key.query.graphql';
+import updateCurrentScopeMutation from '../graphql/mutations/update_current_scope.mutation.graphql';
+import updateCurrentPageMutation from '../graphql/mutations/update_current_page.mutation.graphql';
+import confirmDisableMutation from '../graphql/mutations/confirm_action.mutation.graphql';
+import disableKeyMutation from '../graphql/mutations/disable_key.mutation.graphql';
import ConfirmModal from './confirm_modal.vue';
import KeysPanel from './keys_panel.vue';
@@ -17,120 +24,147 @@ export default {
GlButton,
GlIcon,
GlLoadingIcon,
+ GlPagination,
},
props: {
- endpoint: {
+ projectId: {
type: String,
required: true,
},
- projectId: {
+ projectPath: {
type: String,
required: true,
},
},
+ apollo: {
+ deployKeys: {
+ query: deployKeysQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ scope: this.currentScope,
+ page: this.currentPage,
+ };
+ },
+ update(data) {
+ return data?.project?.deployKeys || [];
+ },
+ error(error) {
+ createAlert({
+ message: s__('DeployKeys|Error getting deploy keys'),
+ captureError: true,
+ error,
+ });
+ },
+ },
+ pageInfo: {
+ query: pageInfoQuery,
+ variables() {
+ return { input: { page: this.currentPage, scope: this.currentScope } };
+ },
+ update({ pageInfo }) {
+ return pageInfo || {};
+ },
+ },
+ currentPage: {
+ query: currentPageQuery,
+ },
+ currentScope: {
+ query: currentScopeQuery,
+ },
+ deployKeyToRemove: {
+ query: confirmRemoveKeyQuery,
+ },
+ },
data() {
return {
- currentTab: 'enabled_keys',
- isLoading: false,
- store: new DeployKeysStore(),
- removeKey: () => {},
- cancel: () => {},
- confirmModalVisible: false,
+ deployKeys: [],
+ pageInfo: {},
+ deployKeyToRemove: null,
};
},
scopes: {
- enabled_keys: s__('DeployKeys|Enabled deploy keys'),
- available_project_keys: s__('DeployKeys|Privately accessible deploy keys'),
- public_keys: s__('DeployKeys|Publicly accessible deploy keys'),
+ enabledKeys: s__('DeployKeys|Enabled deploy keys'),
+ availableProjectKeys: s__('DeployKeys|Privately accessible deploy keys'),
+ availablePublicKeys: s__('DeployKeys|Publicly accessible deploy keys'),
},
i18n: {
loading: s__('DeployKeys|Loading deploy keys'),
addButton: s__('DeployKeys|Add new key'),
+ prevPage: __('Go to previous page'),
+ nextPage: __('Go to next page'),
+ next: __('Next'),
+ prev: __('Prev'),
+ goto: (page) => sprintf(__('Go to page %{page}'), { page }),
},
computed: {
tabs() {
- return Object.keys(this.$options.scopes).map((scope) => {
- const count = Array.isArray(this.keys[scope]) ? this.keys[scope].length : null;
-
+ return Object.entries(this.$options.scopes).map(([scope, name]) => {
return {
- name: this.$options.scopes[scope],
+ name,
scope,
- isActive: scope === this.currentTab,
- count,
+ isActive: scope === this.currentScope,
};
});
},
- hasKeys() {
- return Object.keys(this.keys).length;
- },
- keys() {
- return this.store.keys;
+ confirmModalVisible() {
+ return Boolean(this.deployKeyToRemove);
},
},
- created() {
- this.service = new DeployKeysService(this.endpoint);
-
- eventHub.$on('enable.key', this.enableKey);
- eventHub.$on('remove.key', this.confirmRemoveKey);
- eventHub.$on('disable.key', this.confirmRemoveKey);
- },
- mounted() {
- this.fetchKeys();
- },
- beforeDestroy() {
- eventHub.$off('enable.key', this.enableKey);
- eventHub.$off('remove.key', this.confirmRemoveKey);
- eventHub.$off('disable.key', this.confirmRemoveKey);
- },
methods: {
- onChangeTab(tab) {
- this.currentTab = tab;
+ onChangeTab(scope) {
+ return this.$apollo
+ .mutate({
+ mutation: updateCurrentScopeMutation,
+ variables: { scope },
+ })
+ .then(() => {
+ this.$apollo.queries.deployKeys.refetch();
+ })
+ .catch((error) => {
+ captureException(error, {
+ tags: {
+ deployKeyScope: scope,
+ },
+ });
+ });
},
- fetchKeys() {
- this.isLoading = true;
-
- return this.service
- .getKeys()
- .then((data) => {
- this.isLoading = false;
- this.store.keys = data;
+ moveNext() {
+ return this.movePage('next');
+ },
+ movePrevious() {
+ return this.movePage('previous');
+ },
+ movePage(direction) {
+ return this.moveToPage(this.pageInfo[`${direction}Page`]);
+ },
+ moveToPage(page) {
+ return this.$apollo.mutate({ mutation: updateCurrentPageMutation, variables: { page } });
+ },
+ removeKey() {
+ this.$apollo
+ .mutate({
+ mutation: disableKeyMutation,
+ variables: { id: this.deployKeyToRemove.id },
+ })
+ .then(() => {
+ if (!this.deployKeys.length) {
+ return this.movePage('previous');
+ }
+ return null;
})
+ .then(() => this.$apollo.queries.deployKeys.refetch())
.catch(() => {
- this.isLoading = false;
- this.store.keys = {};
- return createAlert({
- message: s__('DeployKeys|Error getting deploy keys'),
+ createAlert({
+ message: s__('DeployKeys|Error removing deploy key'),
});
});
},
- enableKey(deployKey) {
- this.service
- .enableKey(deployKey.id)
- .then(this.fetchKeys)
- .catch(() =>
- createAlert({
- message: s__('DeployKeys|Error enabling deploy key'),
- }),
- );
- },
- confirmRemoveKey(deployKey, callback) {
- const hideModal = () => {
- this.confirmModalVisible = false;
- callback?.();
- };
- this.removeKey = () => {
- this.service
- .disableKey(deployKey.id)
- .then(this.fetchKeys)
- .then(hideModal)
- .catch(() =>
- createAlert({
- message: s__('DeployKeys|Error removing deploy key'),
- }),
- );
- };
- this.cancel = hideModal;
- this.confirmModalVisible = true;
+ cancel() {
+ this.$apollo.mutate({
+ mutation: confirmDisableMutation,
+ variables: { id: null },
+ });
},
},
};
@@ -139,47 +173,59 @@ export default {
<template>
<div class="deploy-keys">
<confirm-modal :visible="confirmModalVisible" @remove="removeKey" @cancel="cancel" />
+ <div class="gl-new-card-header gl-align-items-center gl-py-0 gl-pl-0">
+ <div class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-b-0">
+ <div class="fade-left">
+ <gl-icon name="chevron-lg-left" :size="12" />
+ </div>
+ <div class="fade-right">
+ <gl-icon name="chevron-lg-right" :size="12" />
+ </div>
+
+ <navigation-tabs
+ :tabs="tabs"
+ scope="deployKeys"
+ class="gl-rounded-lg"
+ @onChangeTab="onChangeTab"
+ />
+ </div>
+
+ <div class="gl-new-card-actions">
+ <gl-button
+ size="small"
+ class="js-toggle-button js-toggle-content"
+ data-testid="add-new-deploy-key-button"
+ >
+ {{ $options.i18n.addButton }}
+ </gl-button>
+ </div>
+ </div>
<gl-loading-icon
- v-if="isLoading && !hasKeys"
+ v-if="$apollo.queries.deployKeys.loading"
:label="$options.i18n.loading"
- size="sm"
+ size="md"
class="gl-m-5"
/>
- <template v-else-if="hasKeys">
- <div class="gl-new-card-header gl-align-items-center gl-pt-0 gl-pb-0 gl-pl-0">
- <div class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-b-0">
- <div class="fade-left">
- <gl-icon name="chevron-lg-left" :size="12" />
- </div>
- <div class="fade-right">
- <gl-icon name="chevron-lg-right" :size="12" />
- </div>
-
- <navigation-tabs
- :tabs="tabs"
- scope="deployKeys"
- class="gl-rounded-lg"
- @onChangeTab="onChangeTab"
- />
- </div>
-
- <div class="gl-new-card-actions">
- <gl-button
- size="small"
- class="js-toggle-button js-toggle-content"
- data-testid="add-new-deploy-key-button"
- >
- {{ $options.i18n.addButton }}
- </gl-button>
- </div>
- </div>
+ <template v-else>
<keys-panel
:project-id="projectId"
- :keys="keys[currentTab]"
- :store="store"
- :endpoint="endpoint"
+ :keys="deployKeys"
data-testid="project-deploy-keys-container"
/>
+ <gl-pagination
+ align="center"
+ :total-items="pageInfo.total"
+ :per-page="pageInfo.perPage"
+ :value="currentPage"
+ :next="$options.i18n.next"
+ :prev="$options.i18n.prev"
+ :label-previous-page="$options.i18n.prevPage"
+ :label-next-page="$options.i18n.nextPage"
+ :label-page="$options.i18n.goto"
+ @next="moveNext()"
+ @previous="movePrevious()"
+ @input="moveToPage"
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 16c745d8cff..d4b140f1adb 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -2,8 +2,12 @@
<script>
import { GlBadge, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { head, tail } from 'lodash';
+import { createAlert } from '~/alert';
import { s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import currentScopeQuery from '../graphql/queries/current_scope.query.graphql';
+import enableKeyMutation from '../graphql/mutations/enable_key.mutation.graphql';
+import confirmDisableMutation from '../graphql/mutations/confirm_action.mutation.graphql';
import ActionBtn from './action_btn.vue';
@@ -23,48 +27,25 @@ export default {
type: Object,
required: true,
},
- store: {
- type: Object,
- required: true,
- },
- endpoint: {
- type: String,
- required: true,
- },
projectId: {
type: String,
required: false,
default: null,
},
},
+ apollo: {
+ currentScope: {
+ query: currentScopeQuery,
+ },
+ },
data() {
return {
projectsExpanded: false,
};
},
computed: {
- editDeployKeyPath() {
- return `${this.endpoint}/${this.deployKey.id}/edit`;
- },
projects() {
- const projects = [...this.deployKey.deploy_keys_projects];
-
- if (this.projectId !== null) {
- const indexOfCurrentProject = projects.findIndex(
- (project) =>
- project &&
- project.project &&
- project.project.id &&
- project.project.id.toString() === this.projectId,
- );
-
- if (indexOfCurrentProject > -1) {
- const currentProject = projects.splice(indexOfCurrentProject, 1);
- currentProject[0].project.full_name = s__('DeployKeys|Current project');
- return currentProject.concat(projects);
- }
- }
- return projects;
+ return this.deployKey.deployKeysProjects;
},
firstProject() {
return head(this.projects);
@@ -81,13 +62,11 @@ export default {
return sprintf(s__('DeployKeys|+%{count} others'), { count: this.restProjects.length });
},
isEnabled() {
- return this.store.isEnabled(this.deployKey.id);
+ return this.currentScope === 'enabledKeys';
},
isRemovable() {
return (
- this.store.isEnabled(this.deployKey.id) &&
- this.deployKey.destroyed_when_orphaned &&
- this.deployKey.almost_orphaned
+ this.isEnabled && this.deployKey.destroyedWhenOrphaned && this.deployKey.almostOrphaned
);
},
isExpandable() {
@@ -99,14 +78,37 @@ export default {
},
methods: {
projectTooltipTitle(project) {
- return project.can_push
+ return project.canPush
? s__('DeployKeys|Grant write permissions to this key')
: s__('DeployKeys|Read access only');
},
toggleExpanded() {
this.projectsExpanded = !this.projectsExpanded;
},
+ isCurrentProject({ project } = {}) {
+ if (this.projectId !== null) {
+ return Boolean(project?.id?.toString() === this.projectId);
+ }
+
+ return false;
+ },
+ projectName(project) {
+ if (this.isCurrentProject(project)) {
+ return s__('DeployKeys|Current project');
+ }
+
+ return project?.project?.fullName;
+ },
+ onEnableError(error) {
+ createAlert({
+ message: s__('DeployKeys|Error enabling deploy key'),
+ captureError: true,
+ error,
+ });
+ },
},
+ enableKeyMutation,
+ confirmDisableMutation,
};
</script>
@@ -128,7 +130,7 @@ export default {
<dl class="gl-font-sm gl-mb-0">
<dt>{{ __('SHA256') }}</dt>
<dd class="fingerprint" data-testid="key-sha256-fingerprint-content">
- {{ deployKey.fingerprint_sha256 }}
+ {{ deployKey.fingerprintSha256 }}
</dd>
<template v-if="deployKey.fingerprint">
<dt>
@@ -150,10 +152,10 @@ export default {
<gl-badge
v-gl-tooltip
:title="projectTooltipTitle(firstProject)"
- :icon="firstProject.can_push ? 'lock-open' : 'lock'"
+ :icon="firstProject.canPush ? 'lock-open' : 'lock'"
class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate"
>
- <span class="gl-text-truncate">{{ firstProject.project.full_name }}</span>
+ <span class="gl-text-truncate">{{ projectName(firstProject) }}</span>
</gl-badge>
<gl-badge
@@ -170,14 +172,14 @@ export default {
<gl-badge
v-for="deployKeysProject in restProjects"
v-else-if="isExpanded"
- :key="deployKeysProject.project.full_path"
+ :key="deployKeysProject.project.fullPath"
v-gl-tooltip
- :href="deployKeysProject.project.full_path"
+ :href="deployKeysProject.project.fullPath"
:title="projectTooltipTitle(deployKeysProject)"
- :icon="deployKeysProject.can_push ? 'lock-open' : 'lock'"
+ :icon="deployKeysProject.canPush ? 'lock-open' : 'lock'"
class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate"
>
- <span class="gl-text-truncate">{{ deployKeysProject.project.full_name }}</span>
+ <span class="gl-text-truncate">{{ projectName(deployKeysProject) }}</span>
</gl-badge>
</template>
<span v-else class="gl-text-secondary">{{ __('None') }}</span>
@@ -188,8 +190,8 @@ export default {
{{ __('Created') }}
</div>
<div class="table-mobile-content gl-text-gray-700 key-created-at">
- <span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)">
- <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.created_at) }}</span>
+ <span v-gl-tooltip :title="tooltipTitle(deployKey.createdAt)">
+ <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.createdAt) }}</span>
</span>
</div>
</div>
@@ -199,12 +201,12 @@ export default {
</div>
<div class="table-mobile-content gl-text-gray-700 key-expires-at">
<span
- v-if="deployKey.expires_at"
+ v-if="deployKey.expiresAt"
v-gl-tooltip
- :title="tooltipTitle(deployKey.expires_at)"
+ :title="tooltipTitle(deployKey.expiresAt)"
data-testid="expires-at-tooltip"
>
- <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expires_at) }}</span>
+ <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expiresAt) }}</span>
</span>
<span v-else>
<span data-testid="expires-never">{{ __('Never') }}</span>
@@ -213,13 +215,19 @@ export default {
</div>
<div class="table-section section-10 table-button-footer deploy-key-actions">
<div class="btn-group table-action-buttons">
- <action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary">
+ <action-btn
+ v-if="!isEnabled"
+ :deploy-key="deployKey"
+ :mutation="$options.enableKeyMutation"
+ category="secondary"
+ @error="onEnableError"
+ >
{{ __('Enable') }}
</action-btn>
<gl-button
- v-if="deployKey.can_edit"
+ v-if="deployKey.editPath"
v-gl-tooltip
- :href="editDeployKeyPath"
+ :href="deployKey.editPath"
:title="__('Edit')"
:aria-label="__('Edit')"
data-container="body"
@@ -232,10 +240,10 @@ export default {
:deploy-key="deployKey"
:title="__('Remove')"
:aria-label="__('Remove')"
+ :mutation="$options.confirmDisableMutation"
category="secondary"
variant="danger"
icon="remove"
- type="remove"
data-container="body"
/>
<action-btn
@@ -244,7 +252,7 @@ export default {
:deploy-key="deployKey"
:title="__('Disable')"
:aria-label="__('Disable')"
- type="disable"
+ :mutation="$options.confirmDisableMutation"
data-container="body"
icon="cancel"
category="secondary"
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index dac63188aa5..088b85e6093 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -10,14 +10,6 @@ export default {
type: Array,
required: true,
},
- store: {
- type: Object,
- required: true,
- },
- endpoint: {
- type: String,
- required: true,
- },
projectId: {
type: String,
required: false,
@@ -48,8 +40,6 @@ export default {
v-for="deployKey in keys"
:key="deployKey.id"
:deploy-key="deployKey"
- :store="store"
- :endpoint="endpoint"
:project-id="projectId"
/>
</template>
diff --git a/app/assets/javascripts/deploy_keys/graphql/resolvers.js b/app/assets/javascripts/deploy_keys/graphql/resolvers.js
index 1993801636e..a8693665b90 100644
--- a/app/assets/javascripts/deploy_keys/graphql/resolvers.js
+++ b/app/assets/javascripts/deploy_keys/graphql/resolvers.js
@@ -15,6 +15,8 @@ export const mapDeployKey = (deployKey) => ({
__typename: 'LocalDeployKey',
});
+const DEFAULT_PAGE_SIZE = 5;
+
export const resolvers = (endpoints) => ({
Project: {
deployKeys(_, { scope, page }, { client }) {
@@ -25,19 +27,21 @@ export const resolvers = (endpoints) => ({
endpoint = endpoints.enabledKeysEndpoint;
}
- return axios.get(endpoint, { params: { page } }).then(({ headers, data }) => {
- const normalizedHeaders = normalizeHeaders(headers);
- const pageInfo = {
- ...parseIntPagination(normalizedHeaders),
- __typename: 'LocalPageInfo',
- };
- client.writeQuery({
- query: pageInfoQuery,
- variables: { input: { page, scope } },
- data: { pageInfo },
+ return axios
+ .get(endpoint, { params: { page, per_page: DEFAULT_PAGE_SIZE } })
+ .then(({ headers, data }) => {
+ const normalizedHeaders = normalizeHeaders(headers);
+ const pageInfo = {
+ ...parseIntPagination(normalizedHeaders),
+ __typename: 'LocalPageInfo',
+ };
+ client.writeQuery({
+ query: pageInfoQuery,
+ variables: { input: { page, scope } },
+ data: { pageInfo },
+ });
+ return data?.keys?.map(mapDeployKey) || [];
});
- return data?.keys?.map(mapDeployKey) || [];
- });
},
},
Mutation: {
@@ -48,6 +52,13 @@ export const resolvers = (endpoints) => ({
});
},
currentScope(_, { scope }, { client }) {
+ const key = `${scope}Endpoint`;
+ const { [key]: endpoint } = endpoints;
+
+ if (!endpoint) {
+ throw new Error(`invalid deploy key scope selected: ${scope}`);
+ }
+
client.writeQuery({
query: currentPageQuery,
data: { currentPage: 1 },
diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js
index 83601d5b2e3..673462073f0 100644
--- a/app/assets/javascripts/deploy_keys/index.js
+++ b/app/assets/javascripts/deploy_keys/index.js
@@ -1,24 +1,26 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import DeployKeysApp from './components/app.vue';
+import { createApolloProvider } from './graphql/client';
-export default () =>
- new Vue({
- el: document.getElementById('js-deploy-keys'),
- components: {
- DeployKeysApp,
- },
- data() {
- return {
- endpoint: this.$options.el.dataset.endpoint,
- projectId: this.$options.el.dataset.projectId,
- };
- },
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.getElementById('js-deploy-keys');
+ return new Vue({
+ el,
+ apolloProvider: createApolloProvider({
+ enabledKeysEndpoint: el.dataset.enabledEndpoint,
+ availableProjectKeysEndpoint: el.dataset.availableProjectEndpoint,
+ availablePublicKeysEndpoint: el.dataset.availablePublicEndpoint,
+ }),
render(createElement) {
- return createElement('deploy-keys-app', {
+ return createElement(DeployKeysApp, {
props: {
- endpoint: this.endpoint,
- projectId: this.projectId,
+ projectId: el.dataset.projectId,
+ projectPath: el.dataset.projectPath,
},
});
},
});
+};
diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js
deleted file mode 100644
index 2837fc8ed88..00000000000
--- a/app/assets/javascripts/deploy_keys/service/index.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-export default class DeployKeysService {
- constructor(endpoint) {
- this.endpoint = endpoint;
- }
-
- getKeys() {
- return axios.get(this.endpoint).then((response) => response.data);
- }
-
- enableKey(id) {
- return axios.put(`${this.endpoint}/${id}/enable`).then((response) => response.data);
- }
-
- disableKey(id) {
- return axios.put(`${this.endpoint}/${id}/disable`).then((response) => response.data);
- }
-}
diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js
deleted file mode 100644
index dcd77e921cd..00000000000
--- a/app/assets/javascripts/deploy_keys/store/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-export default class DeployKeysStore {
- constructor() {
- this.keys = {};
- }
-
- isEnabled(id) {
- return this.keys.enabled_keys.some((key) => key.id === id);
- }
-}
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 00fd9f43a4f..698fd3909ed 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -144,6 +144,11 @@ export default {
required: false,
default: '',
},
+ pinnedFileUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -153,6 +158,7 @@ export default {
autoScrolled: false,
activeProject: undefined,
hasScannerError: false,
+ pinnedFileStatus: '',
};
},
apollo: {
@@ -215,7 +221,6 @@ export default {
...mapState('findingsDrawer', ['activeDrawer']),
...mapState('diffs', [
'isLoading',
- 'diffFiles',
'diffViewType',
'commit',
'renderOverflowWarning',
@@ -245,6 +250,7 @@ export default {
'isBatchLoading',
'isBatchLoadingError',
'flatBlobsList',
+ 'diffFiles',
]),
...mapGetters(['isNotesFetched', 'getNoteableData']),
...mapGetters('findingsDrawer', ['activeDrawer']),
@@ -355,7 +361,7 @@ export default {
const id = window?.location?.hash;
if (id && id.indexOf('#note') !== 0) {
- this.setHighlightedRow(id.split('diff-content').pop().slice(1));
+ this.setHighlightedRow({ lineCode: id.split('diff-content').pop().slice(1) });
}
const events = [];
@@ -438,6 +444,7 @@ export default {
'setFileByFile',
'disableVirtualScroller',
'setGenerateTestFilePath',
+ 'fetchPinnedFile',
]),
...mapActions('findingsDrawer', ['setDrawer']),
closeDrawer() {
@@ -509,6 +516,20 @@ export default {
return !this.diffFiles.length;
},
fetchData({ toggleTree = true, fetchMeta = true } = {}) {
+ if (this.pinnedFileUrl && this.pinnedFileStatus !== 'loaded') {
+ this.pinnedFileStatus = 'loading';
+ this.fetchPinnedFile(this.pinnedFileUrl)
+ .then(() => {
+ this.pinnedFileStatus = 'loaded';
+ if (toggleTree) this.setTreeDisplay();
+ })
+ .catch(() => {
+ this.pinnedFileStatus = 'error';
+ createAlert({
+ message: __("Couldn't fetch the pinned file."),
+ });
+ });
+ }
if (fetchMeta) {
this.fetchDiffFilesMeta()
.then((data) => {
@@ -539,7 +560,7 @@ export default {
}
if (!this.viewDiffsFileByFile) {
- this.fetchDiffFilesBatch()
+ this.fetchDiffFilesBatch(Boolean(this.pinnedFileUrl))
.then(() => {
if (toggleTree) this.setTreeDisplay();
// Guarantee the discussions are assigned after the batch finishes.
@@ -724,6 +745,9 @@ export default {
<gl-loading-icon size="lg" />
</div>
<template v-else-if="renderDiffFiles">
+ <div v-if="pinnedFileStatus === 'loading'" class="loading">
+ <gl-loading-icon size="lg" />
+ </div>
<dynamic-scroller
v-if="isVirtualScrollingEnabled"
:items="diffs"
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 7493bd5fdf7..3545eb4ed73 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -94,7 +94,7 @@ export default {
class="d-block d-sm-flex flex-row-reverse justify-content-between align-items-start flex-lg-row-reverse"
>
<div
- class="commit-actions flex-row d-none d-sm-flex align-items-start flex-wrap justify-content-end"
+ class="commit-actions flex-row d-none d-sm-flex align-items-center flex-wrap justify-content-end"
>
<div
v-if="commit.signature_html"
@@ -105,7 +105,7 @@ export default {
:endpoint="commit.pipeline_status_path"
class="d-inline-flex mb-2"
/>
- <gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group">
+ <gl-button-group class="gl-ml-4" data-testid="commit-sha-group">
<gl-button label class="gl-font-monospace" data-testid="commit-sha-short-id">{{
commit.short_id
}}</gl-button>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 82b721da493..39f642b0831 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -17,6 +17,7 @@ import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue';
import NoteForm from '~/notes/components/note_form.vue';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
+import { fileContentsId } from '~/diffs/components/diff_row_utils';
import {
DIFF_FILE_AUTOMATIC_COLLAPSE,
DIFF_FILE_MANUAL_COLLAPSE,
@@ -110,7 +111,10 @@ export default {
'canMerge',
]),
...mapGetters(['isNotesFetched', 'getNoteableData', 'noteableType']),
- ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']),
+ ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled', 'pinnedFile']),
+ isPinnedFile() {
+ return this.file === this.pinnedFile;
+ },
viewBlobHref() {
return escape(this.file.view_path);
},
@@ -206,6 +210,9 @@ export default {
diffFileHash() {
return this.file.file_hash;
},
+ fileId() {
+ return fileContentsId(this.file);
+ },
},
watch: {
'file.id': {
@@ -293,7 +300,7 @@ export default {
},
handleToggle({ viaUserInteraction = false } = {}) {
const collapsingNow = !this.isCollapsed;
- const contentElement = this.$el.querySelector(`#diff-content-${this.file.file_hash}`);
+ const contentElement = this.$el.querySelector(`#${fileContentsId(this.file)}`);
this.setFileCollapsedByUser({
filePath: this.file.file_path,
@@ -386,6 +393,7 @@ export default {
'comments-disabled': Boolean(file.brokenSymlink),
'has-body': showBody,
'is-virtual-scrolling': isVirtualScrollingEnabled,
+ 'pinned-file': isPinnedFile,
}"
:data-path="file.new_path"
class="diff-file file-holder gl-border-none gl-mb-0! gl-pb-5"
@@ -400,6 +408,7 @@ export default {
:add-merge-request-buttons="true"
:view-diffs-file-by-file="viewDiffsFileByFile"
:show-local-file-reviews="showLocalFileReviews"
+ :pinned="isPinnedFile"
class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100"
:class="hasBodyClasses.header"
@toggleFile="handleToggle({ viaUserInteraction: true })"
@@ -428,7 +437,7 @@ export default {
</div>
<template v-else>
<div
- :id="`diff-content-${file.file_hash}`"
+ :id="fileId"
:class="hasBodyClasses.contentByHash"
class="diff-content"
data-testid="content-area"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index e45fd508a5b..97db0fc1c24 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -22,6 +22,7 @@ import { __, s__, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { fileContentsId, pinnedFileHref } from '~/diffs/components/diff_row_utils';
import { DIFF_FILE_AUTOMATIC_COLLAPSE } from '../constants';
import { DIFF_FILE_HEADER } from '../i18n';
import { collapsedType, isCollapsed } from '../utils/diff_file';
@@ -102,6 +103,11 @@ export default {
required: false,
default: false,
},
+ pinned: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
idState() {
return {
@@ -113,9 +119,8 @@ export default {
...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']),
...mapGetters(['getNoteableData']),
diffContentIDSelector() {
- return `#diff-content-${this.diffFile.file_hash}`;
+ return `${pinnedFileHref(this.diffFile)}#${fileContentsId(this.diffFile)}`;
},
-
titleLink() {
if (this.diffFile.submodule) {
return this.diffFile.submodule_tree_url || this.diffFile.submodule_link;
@@ -222,6 +227,7 @@ export default {
'setFileForcedOpen',
'setGenerateTestFilePath',
'toggleFileCommentForm',
+ 'unpinFile',
]),
handleToggleFile() {
this.setFileForcedOpen({
@@ -295,7 +301,19 @@ export default {
>
<div class="file-header-content">
<gl-button
- v-if="collapsible"
+ v-if="pinned"
+ v-gl-tooltip.hover.focus
+ :title="__('Unpin the file')"
+ :aria-label="__('Unpin the file')"
+ icon="thumbtack"
+ size="small"
+ class="btn-icon gl-mr-2"
+ category="tertiary"
+ data-testid="unpin-button"
+ @click="unpinFile"
+ />
+ <gl-button
+ v-else-if="collapsible"
ref="collapseButton"
class="gl-mr-2"
category="tertiary"
@@ -305,10 +323,10 @@ export default {
@click.stop="handleToggleFile"
/>
<a
- ref="titleWrapper"
:v-once="!viewDiffsFileByFile"
class="gl-mr-2 gl-text-decoration-none! gl-word-break-all"
:href="titleLink"
+ data-testid="file-title"
@click="handleFileNameClick"
>
<span v-if="isFileRenamed">
@@ -354,7 +372,7 @@ export default {
<small
v-if="isModeChanged"
ref="fileMode"
- v-gl-tooltip.hover
+ v-gl-tooltip.hover.focus
class="mr-1"
:title="$options.i18n.fileModeTooltip"
>
@@ -377,7 +395,7 @@ export default {
/>
<gl-form-checkbox
v-if="isReviewable && showLocalFileReviews"
- v-gl-tooltip.hover
+ v-gl-tooltip.hover.focus
data-testid="fileReviewCheckbox"
class="gl-mr-5 gl-mb-n3 gl-display-flex gl-align-items-center"
:title="$options.i18n.fileReviewTooltip"
@@ -388,7 +406,7 @@ export default {
</gl-form-checkbox>
<gl-button
v-if="showCommentButton"
- v-gl-tooltip.hover
+ v-gl-tooltip.hover.focus
:title="__('Comment on this file')"
:aria-label="__('Comment on this file')"
icon="comment"
@@ -402,7 +420,7 @@ export default {
<gl-button
v-if="diffFile.external_url"
ref="externalLink"
- v-gl-tooltip.hover
+ v-gl-tooltip.hover.focus
:href="diffFile.external_url"
:title="externalUrlLabel"
:aria-label="externalUrlLabel"
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 3dad7a1a8e4..a9da77104d4 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -292,7 +292,7 @@ export default {
v-if="props.line.left.old_line && props.line.left.type !== $options.CONFLICT_THEIR"
:data-linenumber="props.line.left.old_line"
:href="props.line.lineHrefOld"
- @click="listeners.setHighlightedRow(props.line.lineCode)"
+ @click="listeners.setHighlightedRow({ lineCode: props.line.lineCode, event: $event })"
>
</a>
<component
@@ -318,7 +318,7 @@ export default {
v-if="props.line.left.new_line && props.line.left.type !== $options.CONFLICT_OUR"
:data-linenumber="props.line.left.new_line"
:href="props.line.lineHrefOld"
- @click="listeners.setHighlightedRow(props.line.lineCode)"
+ @click="listeners.setHighlightedRow({ lineCode: props.line.lineCode, event: $event })"
>
</a>
</div>
@@ -446,7 +446,7 @@ export default {
v-if="props.line.right.new_line"
:data-linenumber="props.line.right.new_line"
:href="props.line.lineHrefNew"
- @click="listeners.setHighlightedRow(props.line.lineCode)"
+ @click="listeners.setHighlightedRow({ lineCode: props.line.lineCode, event: $event })"
>
</a>
<component
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
index a489c96b0c9..5c62e0179ac 100644
--- a/app/assets/javascripts/diffs/components/diff_row_utils.js
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -33,7 +33,19 @@ export const shouldRenderCommentButton = (isLoggedIn, isCommentButtonRendered) =
export const hasDiscussions = (line) => line?.discussions?.length > 0;
-export const lineHref = (line) => `#${line?.line_code || ''}`;
+export const pinnedFileHref = (diffFile) => {
+ if (!window?.gon?.features?.pinnedFile) return '';
+ return `?pin=${diffFile.file_hash}`;
+};
+
+export const lineHref = (line, content) => {
+ if (!line || !line.line_code) return '';
+ return `${pinnedFileHref(content.diffFile)}#${line.line_code}`;
+};
+
+export const fileContentsId = (diffFile) => {
+ return `diff-content-${diffFile.file_hash}`;
+};
export const lineCode = (line) => {
if (!line) return undefined;
@@ -179,8 +191,8 @@ export const mapParallel = (content) => (line) => {
isContextLineRight: isContextLine(right?.type),
hasDiscussionsLeft: hasDiscussions(left),
hasDiscussionsRight: hasDiscussions(right),
- lineHrefOld: lineHref(left),
- lineHrefNew: lineHref(right),
+ lineHrefOld: lineHref(left, content),
+ lineHrefNew: lineHref(right, content),
lineCode: lineCode(line),
isMetaLineLeft: isMetaLine(left?.type),
isMetaLineRight: isMetaLine(right?.type),
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 07984beb709..ab21391b364 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -34,7 +34,7 @@ export default {
},
computed: {
...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']),
- ...mapGetters('diffs', ['allBlobs']),
+ ...mapGetters('diffs', ['allBlobs', 'pinnedFile']),
filteredTreeList() {
let search = this.search.toLowerCase().trim();
@@ -71,21 +71,59 @@ export default {
// out: [{ path: 'a', tree: [{ path: 'b' }] }, { path: 'b' }, { path: 'c' }]
flatFilteredTreeList() {
const result = [];
- const createFlatten = (level) => (item) => {
+ const createFlatten = (level, hidden) => (item) => {
result.push({
...item,
+ hidden,
level: item.isHeader ? 0 : level,
key: item.key || item.path,
});
- if (item.opened || item.isHeader) {
- item.tree.forEach(createFlatten(level + 1));
- }
+ const isHidden = hidden || (item.type === 'tree' && !item.opened);
+ item.tree.forEach(createFlatten(level + 1, isHidden));
};
this.filteredTreeList.forEach(createFlatten(0));
return result;
},
+ flatListWithPinnedFile() {
+ const result = [...this.flatFilteredTreeList];
+ const pinnedIndex = result.findIndex((item) => item.path === this.pinnedFile.file_path);
+ const [pinnedItem] = result.splice(pinnedIndex, 1);
+
+ if (pinnedItem.parentPath === '/')
+ return [{ ...pinnedItem, level: 0, pinned: true, hidden: false }, ...result];
+
+ // remove detached folder from the tree
+ const next = result[pinnedIndex];
+ const prev = result[pinnedIndex - 1];
+ const hasContainingFolder =
+ prev && prev.type === 'tree' && prev.level === pinnedItem.level - 1;
+ const hasSibling = next && next.type !== 'tree' && next.level === pinnedItem.level;
+ if (hasContainingFolder && !hasSibling) {
+ // folder tree is always condensed so we only need to remove the parent folder
+ result.splice(pinnedIndex - 1, 1);
+ }
+
+ return [
+ {
+ level: 0,
+ key: 'pinned-path',
+ isHeader: true,
+ opened: true,
+ path: pinnedItem.parentPath,
+ type: 'tree',
+ hidden: false,
+ },
+ { ...pinnedItem, level: 1, pinned: true, hidden: false },
+ ...result,
+ ];
+ },
+ treeList() {
+ const list = this.pinnedFile ? this.flatListWithPinnedFile : this.flatFilteredTreeList;
+ if (this.search) return list;
+ return list.filter((item) => !item.hidden);
+ },
},
methods: {
...mapActions('diffs', ['toggleTreeOpen', 'goToFile']),
@@ -125,13 +163,13 @@ export default {
</button>
</div>
</div>
- <tree-list-height class="gl-flex-grow-1 gl-min-h-0" :items-count="flatFilteredTreeList.length">
+ <tree-list-height class="gl-flex-grow-1 gl-min-h-0" :items-count="treeList.length">
<template #default="{ scrollerHeight, rowHeight }">
<div :class="{ 'tree-list-blobs': !renderTreeList || search }" class="mr-tree-list">
<recycle-scroller
- v-if="flatFilteredTreeList.length"
+ v-if="treeList.length"
:style="{ height: `${scrollerHeight}px` }"
- :items="flatFilteredTreeList"
+ :items="treeList"
:item-size="rowHeight"
:buffer="100"
key-field="key"
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 15e4225f062..b219b5499c9 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -93,6 +93,7 @@ export default function initDiffsApp(store = notesStore) {
helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
changesEmptyStateIllustration: this.changesEmptyStateIllustration,
+ pinnedFileUrl: dataset.pinnedFileUrl,
},
});
},
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 1c0e20183e2..e15d403bd11 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -10,7 +10,7 @@ import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
-import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
+import { mergeUrlParams, getLocationHash, getParameterValues } from '~/lib/utils/url_utility';
import notesEventHub from '~/notes/event_hub';
import { generateTreeList } from '~/diffs/utils/tree_worker_utils';
import { sortTree } from '~/ide/stores/utils';
@@ -54,7 +54,6 @@ import {
} from '../constants';
import {
DISCUSSION_SINGLE_DIFF_FAILED,
- LOAD_SINGLE_DIFF_FAILED,
BUILDING_YOUR_MR,
SOMETHING_WENT_WRONG,
ERROR_LOADING_FULL_DIFF,
@@ -119,7 +118,9 @@ export const setBaseConfig = ({ commit }, options) => {
};
export const prefetchSingleFile = async ({ state, getters, commit }, treeEntry) => {
- const versionPath = state.mergeRequestDiff?.version_path;
+ const url = new URL(state.endpointBatch, 'https://gitlab.com');
+ const diffId = getParameterValues('diff_id', url)[0];
+ const startSha = getParameterValues('start_sha', url)[0];
if (
treeEntry &&
@@ -133,12 +134,14 @@ export const prefetchSingleFile = async ({ state, getters, commit }, treeEntry)
w: state.showWhitespace ? '0' : '1',
view: 'inline',
commit_id: getters.commitId,
+ diff_head: true,
};
- if (versionPath) {
- const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath });
-
+ if (diffId) {
urlParams.diff_id = diffId;
+ }
+
+ if (startSha) {
urlParams.start_sha = startSha;
}
@@ -161,7 +164,9 @@ export const prefetchSingleFile = async ({ state, getters, commit }, treeEntry)
export const fetchFileByFile = async ({ state, getters, commit }) => {
const isNoteLink = isUrlHashNoteLink(window?.location?.hash);
const id = parseUrlHashAsFileHash(window?.location?.hash, state.currentDiffFileId);
- const versionPath = state.mergeRequestDiff?.version_path;
+ const url = new URL(state.endpointBatch, 'https://gitlab.com');
+ const diffId = getParameterValues('diff_id', url)[0];
+ const startSha = getParameterValues('start_sha', url)[0];
const treeEntry = id
? getters.flatBlobsList.find(({ fileHash }) => fileHash === id)
: getters.flatBlobsList[0];
@@ -179,12 +184,14 @@ export const fetchFileByFile = async ({ state, getters, commit }) => {
w: state.showWhitespace ? '0' : '1',
view: 'inline',
commit_id: getters.commitId,
+ diff_head: true,
};
- if (versionPath) {
- const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath });
-
+ if (diffId) {
urlParams.diff_id = diffId;
+ }
+
+ if (startSha) {
urlParams.start_sha = startSha;
}
@@ -210,7 +217,7 @@ export const fetchFileByFile = async ({ state, getters, commit }) => {
}
};
-export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
+export const fetchDiffFilesBatch = ({ commit, state, dispatch }, pinnedFileLoading = false) => {
let perPage = state.viewDiffsFileByFile ? 1 : state.perPage;
let increaseAmount = 1.4;
const startPage = 0;
@@ -224,8 +231,10 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
let totalLoaded = 0;
let scrolledVirtualScroller = hash === '';
- commit(types.SET_BATCH_LOADING_STATE, 'loading');
- commit(types.SET_RETRIEVING_BATCHES, true);
+ if (!pinnedFileLoading) {
+ commit(types.SET_BATCH_LOADING_STATE, 'loading');
+ commit(types.SET_RETRIEVING_BATCHES, true);
+ }
eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START);
const getBatch = (page = startPage) =>
@@ -237,7 +246,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffFiles });
commit(types.SET_BATCH_LOADING_STATE, 'loaded');
- if (!scrolledVirtualScroller) {
+ if (!scrolledVirtualScroller && !pinnedFileLoading) {
const index = state.diffFiles.findIndex(
(f) =>
f.file_hash === hash || f[INLINE_DIFF_LINES_KEY].find((l) => l.line_code === hash),
@@ -301,9 +310,10 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
return null;
})
- .catch(() => {
+ .catch((error) => {
commit(types.SET_RETRIEVING_BATCHES, false);
commit(types.SET_BATCH_LOADING_STATE, 'error');
+ throw error;
});
return getBatch();
@@ -384,7 +394,11 @@ export const fetchCoverageFiles = ({ commit, state }) => {
coveragePoll.makeRequest();
};
-export const setHighlightedRow = ({ commit }, lineCode) => {
+export const setHighlightedRow = ({ commit }, { lineCode, event }) => {
+ if (event && event.target.href) {
+ event.preventDefault();
+ window.history.replaceState(null, undefined, event.target.href);
+ }
const fileHash = lineCode.split('_')[0];
commit(types.SET_HIGHLIGHTED_ROW, lineCode);
commit(types.SET_CURRENT_DIFF_FILE, fileHash);
@@ -657,6 +671,8 @@ export const goToFile = ({ state, commit, dispatch, getters }, { path }) => {
} else {
if (!state.treeEntries[path]) return;
+ dispatch('unpinFile');
+
const { fileHash } = state.treeEntries[path];
commit(types.SET_CURRENT_DIFF_FILE, fileHash);
@@ -667,11 +683,7 @@ export const goToFile = ({ state, commit, dispatch, getters }, { path }) => {
scrollToElement('.diff-files-holder', { duration: 0 });
if (!getters.isTreePathLoaded(path)) {
- dispatch('fetchFileByFile').catch(() => {
- createAlert({
- message: LOAD_SINGLE_DIFF_FAILED,
- });
- });
+ dispatch('fetchFileByFile');
}
}
};
@@ -995,6 +1007,8 @@ export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, n
};
export const navigateToDiffFileIndex = ({ state, getters, commit, dispatch }, index) => {
+ dispatch('unpinFile');
+
const { fileHash } = getters.flatBlobsList[index];
document.location.hash = fileHash;
@@ -1055,3 +1069,50 @@ export const toggleFileCommentForm = ({ state, commit }, filePath) => {
export const addDraftToFile = ({ commit }, { filePath, draft }) =>
commit(types.ADD_DRAFT_TO_FILE, { filePath, draft });
+
+export const fetchPinnedFile = ({ state, commit }, pinnedFileUrl) => {
+ const isNoteLink = isUrlHashNoteLink(window?.location?.hash);
+
+ commit(types.SET_BATCH_LOADING_STATE, 'loading');
+ commit(types.SET_RETRIEVING_BATCHES, true);
+
+ return axios
+ .get(pinnedFileUrl)
+ .then(({ data: diffData }) => {
+ const [{ file_hash }] = diffData.diff_files;
+
+ // we must store pinned file in the `diffs`, otherwise collapsing and commenting on a file won't work
+ // once the same file arrives in a file batch we must only update its' position
+ // we also must not update file's position since it's loaded out of order
+ commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files, updatePosition: false });
+ commit(types.SET_PINNED_FILE_HASH, file_hash);
+
+ if (!isNoteLink && !state.currentDiffFileId) {
+ commit(types.SET_CURRENT_DIFF_FILE, file_hash);
+ }
+
+ commit(types.SET_BATCH_LOADING_STATE, 'loaded');
+
+ setTimeout(() => {
+ handleLocationHash();
+ });
+
+ eventHub.$emit('diffFilesModified');
+ })
+ .catch((error) => {
+ commit(types.SET_BATCH_LOADING_STATE, 'error');
+ throw error;
+ })
+ .finally(() => {
+ commit(types.SET_RETRIEVING_BATCHES, false);
+ });
+};
+
+export const unpinFile = ({ getters, commit }) => {
+ if (!getters.pinnedFile) return;
+ commit(types.SET_PINNED_FILE_HASH, null);
+ const newUrl = new URL(window.location);
+ newUrl.searchParams.delete('pin');
+ newUrl.hash = '';
+ window.history.replaceState(null, undefined, newUrl);
+};
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index bfafb4d281d..f15c3d1693c 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -201,3 +201,18 @@ export const isVirtualScrollingEnabled = (state) => {
export const isBatchLoading = (state) => state.batchLoadingState === 'loading';
export const isBatchLoadingError = (state) => state.batchLoadingState === 'error';
+
+export const diffFiles = (state, getters) => {
+ const { pinnedFile } = getters;
+ if (pinnedFile) {
+ const diffs = state.diffFiles.slice(0);
+ diffs.splice(diffs.indexOf(pinnedFile), 1);
+ return [pinnedFile, ...diffs];
+ }
+ return state.diffFiles;
+};
+
+export const pinnedFile = (state) => {
+ if (!state.pinnedFileHash) return null;
+ return state.diffFiles.find((file) => file.file_hash === state.pinnedFileHash);
+};
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index d5e1a05f4a5..3b6408e6d78 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -37,4 +37,5 @@ export default () => ({
mrReviews: {},
latestDiff: true,
disableVirtualScroller: false,
+ pinnedFileHash: null,
});
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index b155804c70c..7b19f96b151 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -6,6 +6,7 @@ export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES';
export const SET_DIFF_METADATA = 'SET_DIFF_METADATA';
export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH';
export const SET_DIFF_FILES = 'SET_DIFF_FILES';
+export const SET_DIFF_TREE_ENTRY = 'SET_DIFF_TREE_ENTRY';
export const SET_MR_FILE_REVIEWS = 'SET_MR_FILE_REVIEWS';
@@ -23,6 +24,7 @@ export const TREE_ENTRY_DIFF_LOADING = 'TREE_ENTRY_DIFF_LOADING';
export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST';
export const SET_CURRENT_DIFF_FILE = 'SET_CURRENT_DIFF_FILE';
export const SET_DIFF_FILE_VIEWED = 'SET_DIFF_FILE_VIEWED';
+export const SET_PINNED_FILE_HASH = 'SET_PINNED_FILE_HASH';
export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM';
export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index bc5ed3c40df..3c750830baa 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -81,15 +81,27 @@ export default {
});
},
- [types.SET_DIFF_DATA_BATCH](state, data) {
+ [types.SET_DIFF_DATA_BATCH](state, { diff_files: diffFiles, updatePosition = true }) {
Object.assign(state, {
diffFiles: prepareDiffData({
- diff: data,
+ diff: { diff_files: diffFiles },
priorFiles: state.diffFiles,
+ // when a pinned file is added to diffs its position may be incorrect since it's loaded out of order
+ // we need to ensure when we load it in batched request it updates it position
+ updatePosition,
}),
treeEntries: markTreeEntriesLoaded({
priorEntries: state.treeEntries,
- loadedFiles: data.diff_files,
+ loadedFiles: diffFiles,
+ }),
+ });
+ },
+
+ [types.SET_DIFF_TREE_ENTRY](state, diffFile) {
+ Object.assign(state, {
+ treeEntries: markTreeEntriesLoaded({
+ priorEntries: state.treeEntries,
+ loadedFiles: [diffFile],
}),
});
},
@@ -404,4 +416,7 @@ export default {
file?.drafts.push(draft);
},
+ [types.SET_PINNED_FILE_HASH](state, fileHash) {
+ state.pinnedFileHash = fileHash;
+ },
};
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index fb467a606b9..ad8cacf8504 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -395,9 +395,15 @@ function finalizeDiffFile(file) {
return file;
}
-function deduplicateFilesList(files) {
+function deduplicateFilesList(files, updatePosition) {
const dedupedFiles = files.reduce((newList, file) => {
const id = diffFileUniqueId(file);
+ if (updatePosition && id in newList) {
+ // Object.values preserves key order but doesn't update order when writing to the same key
+ // In order to update position of the item we have to delete it first and then add it back
+ // eslint-disable-next-line no-param-reassign
+ delete newList[id];
+ }
return {
...newList,
@@ -408,14 +414,20 @@ function deduplicateFilesList(files) {
return Object.values(dedupedFiles);
}
-export function prepareDiffData({ diff, priorFiles = [], meta = false }) {
- const cleanedFiles = (diff.diff_files || [])
- .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta, index }))
- .map(ensureBasicDiffFileLines)
- .map(prepareDiffFileLines)
- .map((file) => finalizeDiffFile(file));
+export function prepareDiffData({ diff, priorFiles = [], meta = false, updatePosition = false }) {
+ const transformersChain = [
+ (file, index, allFiles) => prepareRawDiffFile({ file, index, allFiles, meta }),
+ ensureBasicDiffFileLines,
+ prepareDiffFileLines,
+ finalizeDiffFile,
+ ];
+ const cleanedFiles = (diff.diff_files || []).map((file, index, allFiles) => {
+ return transformersChain.reduce((fileResult, transformer) => {
+ return transformer(fileResult, index, allFiles);
+ }, file);
+ });
- return deduplicateFilesList([...priorFiles, ...cleanedFiles]);
+ return deduplicateFilesList([...priorFiles, ...cleanedFiles], updatePosition);
}
export function getDiffPositionByLineCode(diffFiles) {
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 9ee4f7cf4aa..17744e2c6ab 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -46,7 +46,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
let hasPlainText;
formTextarea.wrap('<div class="div-dropzone"></div>');
- formTextarea.on('paste', (event) => handlePaste(event));
// Add dropzone area to the form.
const $mdArea = formTextarea.closest('.md-area');
@@ -60,6 +59,8 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
return null;
}
+ formTextarea.on('paste', (event) => handlePaste(event));
+
const dropzone = $formDropzone.dropzone({
url: uploadsPath,
dictDefaultMessage: '',
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 1fb68394912..3fbd4728cfc 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -154,6 +154,9 @@
"always",
"never"
]
+ },
+ "auto_cancel": {
+ "$ref": "#/definitions/workflowAutoCancel"
}
},
"additionalProperties": false
@@ -528,6 +531,12 @@
"type": "string",
"minLength": 1,
"description": "Image architecture to pull."
+ },
+ "user": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "Username or UID to use for the container."
}
}
},
@@ -603,6 +612,12 @@
"type": "string",
"minLength": 1,
"description": "Image architecture to pull."
+ },
+ "user": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "Username or UID to use for the container."
}
}
},
@@ -735,6 +750,30 @@
}
]
},
+ "gcp_secret_manager": {
+ "type": "object",
+ "markdownDescription": "Defines the secret version to be fetched from GCP Secret Manager. Name refers to the secret name in GCP secret manager. Version refers to the desired secret version (defaults to 'latest').",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "integer"
+ }
+ ],
+ "default": "version"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "additionalProperties": false
+ },
"azure_key_vault": {
"type": "object",
"properties": {
@@ -757,7 +796,7 @@
},
"token": {
"type": "string",
- "description": "Specifies the JWT variable that should be used to authenticate with Hashicorp Vault."
+ "description": "Specifies the JWT variable that should be used to authenticate with the secret provider."
}
},
"anyOf": [
@@ -770,8 +809,18 @@
"required": [
"azure_key_vault"
]
+ },
+ {
+ "required": [
+ "gcp_secret_manager"
+ ]
}
],
+ "dependencies": {
+ "gcp_secret_manager": [
+ "token"
+ ]
+ },
"additionalProperties": false
}
}
@@ -944,7 +993,8 @@
},
"workflowAutoCancel": {
"type": "object",
- "markdownDescription": "Define the rules for when pipeline should be automatically cancelled.",
+ "description": "Define the rules for when pipeline should be automatically cancelled.",
+ "additionalProperties": false,
"properties": {
"on_job_failure": {
"markdownDescription": "Define which jobs to stop after a job fails.",
@@ -954,6 +1004,15 @@
"none",
"all"
]
+ },
+ "on_new_commit": {
+ "markdownDescription": "Configure the behavior of the auto-cancel redundant pipelines feature. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#workflowauto_cancelon_new_commit)",
+ "type": "string",
+ "enum": [
+ "conservative",
+ "interruptible",
+ "none"
+ ]
}
}
},
diff --git a/app/assets/javascripts/entrypoints/analytics.js b/app/assets/javascripts/entrypoints/analytics.js
index e18c4bc8742..f5c61d82f64 100644
--- a/app/assets/javascripts/entrypoints/analytics.js
+++ b/app/assets/javascripts/entrypoints/analytics.js
@@ -13,6 +13,10 @@ if (appId && host) {
performanceTiming: false,
errorTracking: false,
},
+ pagePingTracking: {
+ minimumVisitLength: 10,
+ heartbeatDelay: 10,
+ },
});
const userId = window.gl?.snowplowStandardContext?.data?.user_id;
diff --git a/app/assets/javascripts/entrypoints/sandboxed_swagger.js b/app/assets/javascripts/entrypoints/sandboxed_swagger.js
new file mode 100644
index 00000000000..993896e6a2a
--- /dev/null
+++ b/app/assets/javascripts/entrypoints/sandboxed_swagger.js
@@ -0,0 +1 @@
+import '../lib/swagger';
diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
index 20ed67f6bd9..8b305160b2b 100644
--- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
@@ -6,6 +6,8 @@ import {
SYNC_STATUS_BADGES,
STATUS_TRUE,
STATUS_FALSE,
+ STATUS_UNKNOWN,
+ REASON_PROGRESSING,
HELM_RELEASES_RESOURCE_TYPE,
KUSTOMIZATIONS_RESOURCE_TYPE,
} from '../constants';
@@ -115,6 +117,15 @@ export default {
return condition.status === STATUS_TRUE && condition.type === 'Stalled';
});
},
+ fluxAnyReconcilingWithBadConfig() {
+ return this.fluxCRD.find((condition) => {
+ return (
+ condition.status === STATUS_UNKNOWN &&
+ condition.type === 'Ready' &&
+ condition.reason === REASON_PROGRESSING
+ );
+ });
+ },
fluxAnyReconciling() {
return this.fluxCRD.find((condition) => {
return condition.status === STATUS_TRUE && condition.type === 'Reconciling';
@@ -143,6 +154,12 @@ export default {
if (this.fluxAnyStalled) {
return { ...SYNC_STATUS_BADGES.stalled, popoverText: this.fluxAnyStalled.message };
}
+ if (this.fluxAnyReconcilingWithBadConfig) {
+ return {
+ ...SYNC_STATUS_BADGES.reconciling,
+ popoverText: this.fluxAnyReconcilingWithBadConfig.message,
+ };
+ }
if (this.fluxAnyReconciling) {
return SYNC_STATUS_BADGES.reconciling;
}
diff --git a/app/assets/javascripts/environments/components/kubernetes_tabs.vue b/app/assets/javascripts/environments/components/kubernetes_tabs.vue
index 0d80b1fd797..da37df3fae7 100644
--- a/app/assets/javascripts/environments/components/kubernetes_tabs.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_tabs.vue
@@ -1,9 +1,12 @@
<script>
import { GlTabs, GlTab, GlLoadingIcon, GlBadge, GlTable, GlPagination } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import { getAge } from '~/kubernetes_dashboard/helpers/k8s_integration_helper';
+import {
+ getAge,
+ generateServicePortsString,
+} from '~/kubernetes_dashboard/helpers/k8s_integration_helper';
+import { SERVICES_TABLE_FIELDS } from '~/kubernetes_dashboard/constants';
import k8sServicesQuery from '../graphql/queries/k8s_services.query.graphql';
-import { generateServicePortsString } from '../helpers/k8s_integration_helper';
import { SERVICES_LIMIT_PER_PAGE } from '../constants';
import KubernetesSummary from './kubernetes_summary.vue';
@@ -82,6 +85,14 @@ export default {
? null
: nextPage;
},
+ servicesFields() {
+ return SERVICES_TABLE_FIELDS.map((field) => {
+ return {
+ ...field,
+ thClass: tableHeadingClasses,
+ };
+ });
+ },
},
i18n: {
servicesTitle: s__('Environment|Services'),
@@ -94,43 +105,6 @@ export default {
ports: s__('Environment|Ports'),
age: s__('Environment|Age'),
},
- servicesFields: [
- {
- key: 'name',
- label: __('Name'),
- thClass: tableHeadingClasses,
- },
- {
- key: 'namespace',
- label: __('Namespace'),
- thClass: tableHeadingClasses,
- },
- {
- key: 'type',
- label: __('Type'),
- thClass: tableHeadingClasses,
- },
- {
- key: 'clusterIP',
- label: s__('Environment|Cluster IP'),
- thClass: tableHeadingClasses,
- },
- {
- key: 'externalIP',
- label: s__('Environment|External IP'),
- thClass: tableHeadingClasses,
- },
- {
- key: 'ports',
- label: s__('Environment|Ports'),
- thClass: tableHeadingClasses,
- },
- {
- key: 'age',
- label: s__('Environment|Age'),
- thClass: tableHeadingClasses,
- },
- ],
SERVICES_LIMIT_PER_PAGE,
};
</script>
@@ -154,7 +128,7 @@ export default {
<gl-table
v-else
- :fields="$options.servicesFields"
+ :fields="servicesFields"
:items="servicesItems"
:per-page="$options.SERVICES_LIMIT_PER_PAGE"
:current-page="currentPage"
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index aacb460a817..8bd55e697fa 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -368,6 +368,7 @@ export default {
</div>
<div v-if="clusterAgent" :class="$options.kubernetesOverviewClasses">
<kubernetes-overview
+ :class="{ 'gl-ml-7': inFolder }"
:cluster-agent="clusterAgent"
:namespace="kubernetesNamespace"
:flux-resource-path="fluxResourcePath"
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index b583694e154..76fa0c9bcb2 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -1,13 +1,15 @@
<script>
import { GlSprintf, GlTooltipDirective, GlModal } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
+import { helpPagePath } from '~/helpers/help_page_helper';
import eventHub from '../event_hub';
import stopEnvironmentMutation from '../graphql/mutations/stop_environment.mutation.graphql';
export default {
- yamlDocsLink: `${DOCS_URL_IN_EE_DIR}/ee/ci/yaml/`,
- stoppingEnvironmentDocsLink: `${DOCS_URL_IN_EE_DIR}/environments/#stopping-an-environment`,
+ yamlDocsLink: helpPagePath('ci/yaml/index'),
+ stoppingEnvironmentDocsLink: helpPagePath('ci/environments/index', {
+ anchor: 'stopping-an-environment',
+ }),
id: 'stop-environment-modal',
name: 'StopEnvironmentModal',
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index 2fe9008c042..c996d70af52 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -154,6 +154,9 @@ export const SYNC_STATUS_BADGES = {
export const STATUS_TRUE = 'True';
export const STATUS_FALSE = 'False';
+export const STATUS_UNKNOWN = 'Unknown';
+
+export const REASON_PROGRESSING = 'Progressing';
const ERROR_UNAUTHORIZED = 'unauthorized';
const ERROR_FORBIDDEN = 'forbidden';
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 0201fb53f77..05a2eba6af3 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
import Translate from '~/vue_shared/translate';
+import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility';
import { apolloProvider } from '../graphql/client';
import EnvironmentsFolderView from './environments_folder_view.vue';
import EnvironmentsFolderApp from './environments_folder_app.vue';
@@ -20,10 +21,9 @@ export default () => {
if (gon.features.environmentsFolderNewLook) {
Vue.use(VueRouter);
- const folderName = environmentsData.environmentsDataFolderName;
- const folderPath = environmentsData.environmentsDataEndpoint.replace('.json', '');
- const projectPath = environmentsData.environmentsDataProjectPath;
- const helpPagePath = environmentsData.environmentsDataHelpPagePath;
+ const folderPath = environmentsData.endpoint.replace('.json', '');
+ const kasTunnelUrl = removeLastSlashInUrlPath(environmentsData.kasTunnelUrl);
+ const { projectPath, folderName, helpPagePath } = environmentsData;
const router = new VueRouter({
mode: 'history',
@@ -54,6 +54,7 @@ export default () => {
provide: {
projectPath,
helpPagePath,
+ kasTunnelUrl,
},
apolloProvider,
router,
@@ -74,8 +75,8 @@ export default () => {
},
data() {
return {
- endpoint: environmentsData.environmentsDataEndpoint,
- folderName: environmentsData.environmentsDataFolderName,
+ endpoint: environmentsData.endpoint,
+ folderName: environmentsData.folderName,
cssContainerClass: environmentsData.cssClass,
};
},
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index 0eb12427914..de89934cd52 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -174,6 +174,8 @@ export const apolloProvider = (endpoint) => {
cache.writeQuery({
query: fluxKustomizationStatusQuery,
data: {
+ message: '',
+ reason: '',
status: '',
type: '',
},
@@ -181,6 +183,8 @@ export const apolloProvider = (endpoint) => {
cache.writeQuery({
query: fluxHelmReleaseStatusQuery,
data: {
+ message: '',
+ reason: '',
status: '',
type: '',
},
diff --git a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql
index 042bdc1992d..35f7fe56b47 100644
--- a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql
@@ -2,6 +2,7 @@ query getFluxHelmReleaseStatusQuery($configuration: LocalConfiguration, $fluxRes
fluxHelmReleaseStatus(configuration: $configuration, fluxResourcePath: $fluxResourcePath)
@client {
message
+ reason
status
type
}
diff --git a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql
index 458b8a4d9db..8564b306d5b 100644
--- a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql
@@ -5,6 +5,7 @@ query getFluxHelmKustomizationStatusQuery(
fluxKustomizationStatus(configuration: $configuration, fluxResourcePath: $fluxResourcePath)
@client {
message
+ reason
status
type
}
diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
index bb5cab7c279..99d5ee44b6c 100644
--- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
+++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
@@ -2,21 +2,17 @@ import {
calculateDeploymentStatus,
calculateStatefulSetStatus,
calculateDaemonSetStatus,
+ calculateJobStatus,
+ calculateCronJobStatus,
} from '~/kubernetes_dashboard/helpers/k8s_integration_helper';
-import { STATUS_READY, STATUS_FAILED } from '~/kubernetes_dashboard/constants';
+import {
+ STATUS_READY,
+ STATUS_FAILED,
+ STATUS_COMPLETED,
+ STATUS_SUSPENDED,
+} from '~/kubernetes_dashboard/constants';
import { CLUSTER_AGENT_ERROR_MESSAGES } from '../constants';
-export function generateServicePortsString(ports) {
- if (!ports?.length) return '';
-
- return ports
- .map((port) => {
- const nodePort = port.nodePort ? `:${port.nodePort}` : '';
- return `${port.port}${nodePort}/${port.protocol}`;
- })
- .join(', ');
-}
-
export function getDeploymentsStatuses(items) {
const failed = [];
const ready = [];
@@ -45,13 +41,14 @@ export function getDeploymentsStatuses(items) {
};
}
+const isCompleted = (status) => status === STATUS_COMPLETED;
+const isReady = (status) => status === STATUS_READY;
+const isFailed = (status) => status === STATUS_FAILED;
+const isSuspended = (status) => status === STATUS_SUSPENDED;
+
export function getDaemonSetStatuses(items) {
- const failed = items.filter((item) => {
- return calculateDaemonSetStatus(item) === STATUS_FAILED;
- });
- const ready = items.filter((item) => {
- return calculateDaemonSetStatus(item) === STATUS_READY;
- });
+ const failed = items.filter((item) => isFailed(calculateDaemonSetStatus(item)));
+ const ready = items.filter((item) => isReady(calculateDaemonSetStatus(item)));
return {
...(failed.length && { failed }),
@@ -60,12 +57,8 @@ export function getDaemonSetStatuses(items) {
}
export function getStatefulSetStatuses(items) {
- const failed = items.filter((item) => {
- return calculateStatefulSetStatus(item) === STATUS_FAILED;
- });
- const ready = items.filter((item) => {
- return calculateStatefulSetStatus(item) === STATUS_READY;
- });
+ const failed = items.filter((item) => isFailed(calculateStatefulSetStatus(item)));
+ const ready = items.filter((item) => isReady(calculateStatefulSetStatus(item)));
return {
...(failed.length && { failed }),
@@ -74,12 +67,8 @@ export function getStatefulSetStatuses(items) {
}
export function getReplicaSetStatuses(items) {
- const failed = items.filter((item) => {
- return calculateStatefulSetStatus(item) === STATUS_FAILED;
- });
- const ready = items.filter((item) => {
- return calculateStatefulSetStatus(item) === STATUS_READY;
- });
+ const failed = items.filter((item) => isFailed(calculateStatefulSetStatus(item)));
+ const ready = items.filter((item) => isReady(calculateStatefulSetStatus(item)));
return {
...(failed.length && { failed }),
@@ -88,12 +77,8 @@ export function getReplicaSetStatuses(items) {
}
export function getJobsStatuses(items) {
- const failed = items.filter((item) => {
- return item.status.failed > 0 || item.status?.succeeded !== item.spec?.completions;
- });
- const completed = items.filter((item) => {
- return item.status?.succeeded === item.spec?.completions;
- });
+ const failed = items.filter((item) => isFailed(calculateJobStatus(item)));
+ const completed = items.filter((item) => isCompleted(calculateJobStatus(item)));
return {
...(failed.length && { failed }),
@@ -107,11 +92,11 @@ export function getCronJobsStatuses(items) {
const suspended = [];
items.forEach((item) => {
- if (item.status?.active > 0 && !item.status?.lastScheduleTime) {
+ if (isFailed(calculateCronJobStatus(item))) {
failed.push(item);
- } else if (item.spec?.suspend) {
+ } else if (isSuspended(calculateCronJobStatus(item))) {
suspended.push(item);
- } else if (item.status?.lastScheduleTime) {
+ } else if (isReady(calculateCronJobStatus(item))) {
ready.push(item);
}
});
diff --git a/app/assets/javascripts/error_tracking/components/error_details_info.vue b/app/assets/javascripts/error_tracking/components/error_details_info.vue
index 0b4eabe25d1..3db946acdfc 100644
--- a/app/assets/javascripts/error_tracking/components/error_details_info.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details_info.vue
@@ -74,7 +74,9 @@ export default {
</template>
</gl-card>
+ <!-- user count is currently not supported for integrated error tracking https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2345 -->
<gl-card
+ v-if="!error.integrated"
:class="$options.CARD_CLASS"
:body-class="$options.BODY_CLASS"
:header-class="$options.HEADER_CLASS"
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 95ae5e5a92c..95b47a9e491 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -76,7 +76,7 @@ export default {
{
key: 'status',
label: '',
- tdClass: `${tableDataClass}`,
+ tdClass: `${tableDataClass} gl-text-center`,
},
],
statusFilters: {
@@ -182,6 +182,13 @@ export default {
showIntegratedDisabledAlert() {
return !this.isAlertDismissed && this.showIntegratedTrackingDisabledAlert;
},
+ fields() {
+ if (this.integratedErrorTrackingEnabled) {
+ // user count is currently not supported for integrated error tracking https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2345
+ return this.$options.fields.filter((field) => field.key !== 'users');
+ }
+ return this.$options.fields;
+ },
},
watch: {
pagination() {
@@ -417,7 +424,7 @@ export default {
<gl-table
class="error-list-table gl-mt-5"
:items="errors"
- :fields="$options.fields"
+ :fields="fields"
:show-empty="true"
fixed
stacked="md"
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b11f7b1ba76..f1e46262b2f 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -3,6 +3,7 @@ import '~/lib/utils/jquery_at_who';
import { escape as lodashEscape, sortBy, template, escapeRegExp } from 'lodash';
import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { s__, __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
@@ -26,6 +27,8 @@ export const CONTACT_STATE_ACTIVE = 'active';
export const CONTACTS_ADD_COMMAND = '/add_contacts';
export const CONTACTS_REMOVE_COMMAND = '/remove_contacts';
+const useMentionsBackendFiltering = window.gon.features?.mentionAutocompleteBackendFiltering;
+
/**
* Escapes user input before we pass it to at.js, which
* renders it as HTML in the autocomplete dropdown.
@@ -62,6 +65,8 @@ export function showAndHideHelper($input, alias = '') {
});
}
+// This should be kept in sync with the backend filtering in
+// `User#gfm_autocomplete_search` and `Namespace#gfm_autocomplete_search`
function createMemberSearchString(member) {
return `${member.name.replace(/ /g, '')} ${member.username}`;
}
@@ -344,6 +349,7 @@ class GfmAutoComplete {
}
setupMembers($input) {
+ const instance = this;
const fetchData = this.fetchData.bind(this);
const MEMBER_COMMAND = {
ASSIGN: '/assign',
@@ -383,6 +389,7 @@ class GfmAutoComplete {
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${username}',
limit: 10,
+ delay: useMentionsBackendFiltering ? DEFAULT_DEBOUNCE_AND_THROTTLE_MS : null,
searchKey: 'search',
alwaysHighlightFirst: true,
skipSpecialCharacterTest: true,
@@ -409,16 +416,19 @@ class GfmAutoComplete {
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
return match && match.length ? match[1] : null;
},
- filter(query, data, searchKey) {
- if (GfmAutoComplete.isLoading(data)) {
+ filter(query, data) {
+ if (useMentionsBackendFiltering) {
+ if (GfmAutoComplete.isLoading(data) || instance.previousQuery !== query) {
+ instance.previousQuery = query;
+
+ fetchData(this.$inputor, this.at, query);
+ return data;
+ }
+ } else if (GfmAutoComplete.isLoading(data)) {
fetchData(this.$inputor, this.at);
return data;
}
- if (data === GfmAutoComplete.defaultLoadingData) {
- return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
- }
-
if (command === MEMBER_COMMAND.ASSIGN) {
// Only include members which are not assigned to Issuable currently
return data.filter((member) => !assignees.includes(member.search));
@@ -988,6 +998,11 @@ GfmAutoComplete.atTypeMap = {
};
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
+
+if (useMentionsBackendFiltering) {
+ GfmAutoComplete.typesWithBackendFiltering.push('members');
+}
+
GfmAutoComplete.isTypeWithBackendFiltering = (type) =>
GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[type]);
@@ -1040,6 +1055,8 @@ GfmAutoComplete.Members = {
// `member.search` is a name:username string like `MargeSimpson msimpson`
return member.search.toLowerCase().includes(query);
},
+ // This should be kept in sync with the backend sorting in
+ // `User#gfm_autocomplete_search` and `Namespace#gfm_autocomplete_search`
sort(query, members) {
const lowercaseQuery = query.toLowerCase();
const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index d0ba34b6127..8e19de9f7c2 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -1,6 +1,5 @@
import produce from 'immer';
import VueApollo from 'vue-apollo';
-import { defaultDataIdFromObject } from '@apollo/client/core';
import { concatPagination } from '@apollo/client/utilities';
import errorQuery from '~/boards/graphql/client/error.query.graphql';
import selectedBoardItemsQuery from '~/boards/graphql/client/selected_board_items.query.graphql';
@@ -14,13 +13,6 @@ import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_
export const config = {
typeDefs,
cacheConfig: {
- // included temporarily until Vuex is removed from boards app
- dataIdFromObject: (object) => {
- // eslint-disable-next-line no-underscore-dangle
- return object.__typename === 'BoardList' && !window.gon?.features?.apolloBoards
- ? object.iid
- : defaultDataIdFromObject(object);
- },
typePolicies: {
Query: {
fields: {
@@ -34,6 +26,12 @@ export const config = {
return currentState ?? [];
},
},
+ boardList: {
+ keyArgs: ['id'],
+ },
+ epicBoardList: {
+ keyArgs: ['id'],
+ },
},
},
Project: {
@@ -135,104 +133,80 @@ export const config = {
nodes: concatPagination(),
},
},
- ...(window.gon?.features?.apolloBoards
- ? {
- BoardList: {
- fields: {
- issues: {
- keyArgs: ['filters'],
- },
- },
- },
- IssueConnection: {
- merge(existing = { nodes: [] }, incoming, { args }) {
- if (!args?.after) {
- return incoming;
- }
- return {
- ...incoming,
- nodes: [...existing.nodes, ...incoming.nodes],
- };
- },
- },
- EpicList: {
- fields: {
- epics: {
- keyArgs: ['filters'],
- },
- },
- },
- EpicConnection: {
- merge(existing = { nodes: [] }, incoming, { args }) {
- if (!args.after) {
- return incoming;
- }
- return {
- ...incoming,
- nodes: [...existing.nodes, ...incoming.nodes],
- };
- },
- },
- Group: {
- fields: {
- projects: {
- keyArgs: ['includeSubgroups', 'search'],
- },
- descendantGroups: {
- keyArgs: ['includeSubgroups', 'search'],
- },
- },
- },
- ProjectConnection: {
- fields: {
- nodes: concatPagination(),
- },
- },
- GroupConnection: {
- fields: {
- nodes: concatPagination(),
- },
- },
- Board: {
- fields: {
- epics: {
- keyArgs: ['boardId'],
- },
- },
- },
- BoardEpicConnection: {
- merge(existing = { nodes: [] }, incoming, { args }) {
- if (!args.after) {
- return incoming;
- }
- return {
- ...incoming,
- nodes: [...existing.nodes, ...incoming.nodes],
- };
- },
- },
- Query: {
- fields: {
- boardList: {
- keyArgs: ['id'],
- },
- epicBoardList: {
- keyArgs: ['id'],
- },
- isShowingLabels: {
- read(currentState) {
- return currentState ?? true;
- },
- },
- selectedBoardItems: {
- read(currentState) {
- return currentState ?? [];
- },
- },
- },
- },
+ BoardList: {
+ fields: {
+ issues: {
+ keyArgs: ['filters'],
+ },
+ },
+ },
+ IssueConnection: {
+ merge(existing = { nodes: [] }, incoming, { args }) {
+ if (!args?.after) {
+ return incoming;
}
- : {}),
+ return {
+ ...incoming,
+ nodes: [...existing.nodes, ...incoming.nodes],
+ };
+ },
+ },
+ EpicList: {
+ fields: {
+ epics: {
+ keyArgs: ['filters'],
+ },
+ },
+ },
+ EpicConnection: {
+ merge(existing = { nodes: [] }, incoming, { args }) {
+ if (!args?.after) {
+ return incoming;
+ }
+ return {
+ ...incoming,
+ nodes: [...existing.nodes, ...incoming.nodes],
+ };
+ },
+ },
+ Group: {
+ fields: {
+ projects: {
+ keyArgs: ['includeSubgroups', 'search'],
+ },
+ descendantGroups: {
+ keyArgs: ['includeSubgroups', 'search'],
+ },
+ },
+ },
+ ProjectConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
+ GroupConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
+ Board: {
+ fields: {
+ epics: {
+ keyArgs: ['boardId'],
+ },
+ },
+ },
+ BoardEpicConnection: {
+ merge(existing = { nodes: [] }, incoming, { args }) {
+ if (!args.after) {
+ return incoming;
+ }
+ return {
+ ...incoming,
+ nodes: [...existing.nodes, ...incoming.nodes],
+ };
+ },
+ },
},
},
};
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 4edad63cc79..fe151c2b358 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -187,6 +187,7 @@
"WorkItemWidget": [
"WorkItemWidgetAssignees",
"WorkItemWidgetAwardEmoji",
+ "WorkItemWidgetColor",
"WorkItemWidgetCurrentUserTodos",
"WorkItemWidgetDescription",
"WorkItemWidgetHealthStatus",
@@ -199,6 +200,7 @@
"WorkItemWidgetNotifications",
"WorkItemWidgetProgress",
"WorkItemWidgetRequirementLegacy",
+ "WorkItemWidgetRolledupDates",
"WorkItemWidgetStartAndDueDate",
"WorkItemWidgetStatus",
"WorkItemWidgetTestReports",
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index 6a64e8a2fa8..828a18a240e 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -50,10 +50,10 @@ export const getTypeFromGraphQLId = (gid = '') => {
return type || null;
};
-export const MutationOperationMode = {
- Append: 'APPEND',
- Remove: 'REMOVE',
- Replace: 'REPLACE',
+export const mutationOperationMode = {
+ append: 'APPEND',
+ remove: 'REMOVE',
+ replace: 'REPLACE',
};
/**
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 3440bd87e6b..4ede8fda01d 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -22,6 +22,7 @@ export default {
GlLoadingIcon,
GlEmptyState,
},
+ inject: ['emptySearchIllustration'],
props: {
action: {
type: String,
@@ -245,6 +246,7 @@ export default {
<groups-component v-if="hasGroups" :groups="groups" :page-info="pageInfo" :action="action" />
<gl-empty-state
v-else-if="fromSearch"
+ :svg-path="emptySearchIllustration"
:title="$options.i18n.searchEmptyState.title"
:description="$options.i18n.searchEmptyState.description"
data-testid="search-empty-state"
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 3a08e3e546f..6e347a3c95b 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -20,6 +20,7 @@ import {
VISIBILITY_LEVELS_STRING_TO_INTEGER,
VISIBILITY_TYPE_ICON,
GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
} from '~/visibility_level/constants';
import { ITEM_TYPE, ACTIVE_TAB_SHARED } from '../constants';
@@ -105,7 +106,8 @@ export default {
return VISIBILITY_TYPE_ICON[this.group.visibility];
},
visibilityTooltip() {
- return GROUP_VISIBILITY_TYPE[this.group.visibility];
+ if (this.isGroup) return GROUP_VISIBILITY_TYPE[this.group.visibility];
+ return PROJECT_VISIBILITY_TYPE[this.group.visibility];
},
microdata() {
return this.group.microdata || {};
diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
index 853fdd7c55e..83953feedbf 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -39,6 +39,9 @@ export default {
'Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.',
),
invalidFeedback: s__('Groups|Enter a descriptive name for your group.'),
+ warningForUsingDotInName: s__(
+ 'Groups|Your group name must not contain a period if you intend to use SCIM integration, as it can lead to errors.',
+ ),
},
path: {
placeholder: __('my-awesome-group'),
@@ -299,6 +302,14 @@ export default {
@invalid="handleInvalidName"
/>
</gl-form-group>
+ <gl-alert
+ class="gl-mb-5"
+ :dismissible="false"
+ variant="warning"
+ data-testid="dot-in-path-alert"
+ >
+ {{ $options.i18n.inputs.name.warningForUsingDotInName }}
+ </gl-alert>
<div :class="newSubgroup && 'row gl-mb-3'">
<gl-form-group v-if="newSubgroup" class="col-sm-6 gl-pr-0" :label="inputLabels.subgroupPath">
@@ -386,7 +397,12 @@ export default {
</div>
<template v-if="isEditingGroup">
- <gl-alert class="gl-mb-5" :dismissible="false" variant="warning">
+ <gl-alert
+ class="gl-mb-5"
+ :dismissible="false"
+ variant="warning"
+ data-testid="changing-url-alert"
+ >
{{ $options.i18n.changingUrlWarningMessage }}
<gl-link :href="$options.changingGroupPathHelpPagePath"
>{{ $options.i18n.learnMore }}
diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue
index 8781f03a412..82bd4765986 100644
--- a/app/assets/javascripts/groups/components/overview_tabs.vue
+++ b/app/assets/javascripts/groups/components/overview_tabs.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTabs, GlTab, GlSearchBoxByType, GlSorting, GlSortingItem } from '@gitlab/ui';
+import { GlTabs, GlTab, GlSearchBoxByType, GlSorting } from '@gitlab/ui';
import { isString, debounce } from 'lodash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
@@ -30,7 +30,6 @@ export default {
GroupsApp,
GlSearchBoxByType,
GlSorting,
- GlSortingItem,
SubgroupsAndProjectsEmptyState,
SharedProjectsEmptyState,
ArchivedProjectsEmptyState,
@@ -84,6 +83,9 @@ export default {
sortQueryStringValue() {
return this.isAscending ? this.sort.asc : this.sort.desc;
},
+ activeTabSortOptions() {
+ return this.activeTab.sortingItems.map(({ label }) => ({ value: label, text: label }));
+ },
},
mounted() {
this.search = this.$route.query?.filter || '';
@@ -178,12 +180,14 @@ export default {
this.handleSearchOrSortChange();
},
- handleSortingItemClick(sortingItem) {
- if (sortingItem === this.sort) {
+ handleSortingItemClick(value) {
+ const selectedSortingItem = this.activeTab.sortingItems.find((item) => item.label === value);
+
+ if (selectedSortingItem === this.sort) {
return;
}
- this.sort = sortingItem;
+ this.sort = selectedSortingItem;
this.handleSearchOrSortChange();
},
@@ -239,16 +243,11 @@ export default {
data-testid="group_sort_by_dropdown"
:text="sort.label"
:is-ascending="isAscending"
+ :sort-options="activeTabSortOptions"
+ :sort-by="sort.label"
+ @sortByChange="handleSortingItemClick"
@sortDirectionChange="handleSortDirectionChange"
- >
- <gl-sorting-item
- v-for="sortingItem in activeTab.sortingItems"
- :key="sortingItem.label"
- :active="sortingItem === sort"
- @click="handleSortingItemClick(sortingItem)"
- >{{ sortingItem.label }}</gl-sorting-item
- >
- </gl-sorting>
+ />
</div>
</div>
</li>
diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js
index 80dd1d36734..2f03705b453 100644
--- a/app/assets/javascripts/groups/init_overview_tabs.js
+++ b/app/assets/javascripts/groups/init_overview_tabs.js
@@ -47,6 +47,7 @@ export const initGroupOverviewTabs = () => {
newProjectIllustration,
emptyProjectsIllustration,
emptySubgroupIllustration,
+ emptySearchIllustration,
canCreateSubgroups,
canCreateProjects,
currentGroupVisibility,
@@ -67,6 +68,7 @@ export const initGroupOverviewTabs = () => {
newProjectIllustration,
emptyProjectsIllustration,
emptySubgroupIllustration,
+ emptySearchIllustration,
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
currentGroupVisibility,
diff --git a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
index 5560d10d179..37f95a7ab30 100644
--- a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
+++ b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
@@ -7,10 +7,15 @@ const buildUrl = (urlRoot, url) => {
return joinPaths(urlRoot, url);
};
-export const getSubGroups = () => {
+const defaultOptions = { includeParentDescendants: false };
+
+export const getSubGroups = (options = defaultOptions) => {
+ const { includeParentDescendants } = options;
+
return axios.get(buildUrl(gon.relative_url_root || '', GROUP_SUBGROUPS_PATH), {
params: {
group_id: gon.current_group_id,
+ include_parent_descendants: includeParentDescendants,
},
});
};
diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
index 457a2db174c..fee1383c8e2 100644
--- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
@@ -93,7 +93,11 @@ export default {
this.loading = true;
if (this.hasLicense) {
- Promise.all([this.groups.length ? Promise.resolve({ data: this.groups }) : getSubGroups()])
+ Promise.all([
+ this.groups.length
+ ? Promise.resolve({ data: this.groups })
+ : getSubGroups({ includeParentDescendants: true }),
+ ])
.then(([groupsResponse]) => {
this.consolidateData(groupsResponse.data);
this.setSelected({ initial });
diff --git a/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue b/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue
index 76b0b819698..238e382fabe 100644
--- a/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue
+++ b/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue
@@ -2,6 +2,7 @@
import {
GlButton,
GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
GlDisclosureDropdown,
GlIcon,
GlTooltipDirective,
@@ -13,6 +14,7 @@ export default {
components: {
GlButton,
GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
GlDisclosureDropdown,
GlIcon,
},
@@ -32,6 +34,9 @@ export default {
namespaceType() {
return this.isGroup ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
+ hasPath() {
+ return this.leavePath || this.withdrawPath || this.requestAccessPath;
+ },
leaveTitle() {
return this.isGroup
? this.$options.i18n.groupLeaveTitle
@@ -143,12 +148,12 @@ export default {
</div>
</template>
- <gl-disclosure-dropdown-item v-if="leavePath" ref="leaveItem" :item="leaveItem" />
-
- <gl-disclosure-dropdown-item v-else-if="withdrawPath" :item="withdrawItem" />
-
- <gl-disclosure-dropdown-item v-else-if="requestAccessPath" :item="requestAccessItem" />
-
<gl-disclosure-dropdown-item v-if="id" :item="copyIdItem" :data-clipboard-text="id" />
+
+ <gl-disclosure-dropdown-group v-if="hasPath" bordered>
+ <gl-disclosure-dropdown-item v-if="leavePath" ref="leaveItem" :item="leaveItem" />
+ <gl-disclosure-dropdown-item v-else-if="withdrawPath" :item="withdrawItem" />
+ <gl-disclosure-dropdown-item v-else-if="requestAccessPath" :item="requestAccessItem" />
+ </gl-disclosure-dropdown-group>
</gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/ide/components/file_alert.vue b/app/assets/javascripts/ide/components/file_alert.vue
deleted file mode 100644
index 2a894596bf4..00000000000
--- a/app/assets/javascripts/ide/components/file_alert.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-import { GlAlert } from '@gitlab/ui';
-import { getAlert } from '../lib/alerts';
-
-export default {
- components: {
- GlAlert,
- },
- props: {
- alertKey: {
- type: Symbol,
- required: true,
- },
- },
- computed: {
- alert() {
- return getAlert(this.alertKey);
- },
- },
-};
-</script>
-<template>
- <gl-alert v-bind="alert.props" @dismiss="alert.dismiss($store)">
- <component :is="alert.message" />
- </gl-alert>
-</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 8f4f777d396..d2375078820 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,6 +1,5 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
-import { debounce } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import {
@@ -29,7 +28,7 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
import { readFileAsDataURL } from '~/lib/utils/file_utility';
-import { isDefaultCiConfig, hasCiConfigExtension } from '~/lib/utils/common_utils';
+import { hasCiConfigExtension } from '~/lib/utils/common_utils';
import {
leftSidebarViews,
@@ -43,7 +42,6 @@ import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, registerSchema, isTextFile } from '../utils';
-import FileAlert from './file_alert.vue';
import FileTemplatesBar from './file_templates/bar.vue';
const MARKDOWN_FILE_TYPE = 'markdown';
@@ -53,7 +51,6 @@ export default {
components: {
GlTabs,
GlTab,
- FileAlert,
ContentViewer,
DiffViewer,
FileTemplatesBar,
@@ -93,7 +90,6 @@ export default {
'previewMarkdownPath',
]),
...mapGetters([
- 'getAlert',
'currentMergeRequest',
'getStagedFile',
'isEditModeActive',
@@ -102,9 +98,6 @@ export default {
'getJsonSchemaForPath',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
- alertKey() {
- return this.getAlert(this.file);
- },
fileEditor() {
return getFileEditorOrDefault(this.fileEditors, this.file.path);
},
@@ -159,16 +152,6 @@ export default {
},
},
watch: {
- 'file.name': {
- handler() {
- this.stopWatchingCiYaml();
-
- if (isDefaultCiConfig(this.file.name)) {
- this.startWatchingCiYaml();
- }
- },
- immediate: true,
- },
file(newVal, oldVal) {
if (oldVal.pending) {
this.removePendingTab(oldVal);
@@ -235,7 +218,6 @@ export default {
'removePendingTab',
'triggerFilesChange',
'addTempImage',
- 'detectGitlabCiFileAlerts',
]),
...mapActions('editor', ['updateFileEditor']),
initEditor() {
@@ -498,18 +480,6 @@ export default {
this.updateFileEditor({ path: this.file.path, data });
},
- startWatchingCiYaml() {
- this.unwatchCiYaml = this.$watch(
- 'file.content',
- debounce(this.detectGitlabCiFileAlerts, 500),
- );
- },
- stopWatchingCiYaml() {
- if (this.unwatchCiYaml) {
- this.unwatchCiYaml();
- this.unwatchCiYaml = null;
- }
- },
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
@@ -531,7 +501,6 @@ export default {
@click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
/>
</gl-tabs>
- <file-alert v-if="alertKey" :alert-key="alertKey" />
<file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
<div
v-show="showEditor"
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index b09cd7f6643..51c7e69449b 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -70,7 +70,6 @@ export const initLegacyWebIDE = (el, options = {}) => {
this.init({
renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode),
editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME,
- environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
previewMarkdownPath: el.dataset.previewMarkdownPath,
userPreferencesPath: el.dataset.userPreferencesPath,
});
diff --git a/app/assets/javascripts/ide/lib/alerts/environments.vue b/app/assets/javascripts/ide/lib/alerts/environments.vue
deleted file mode 100644
index bfe101bc7e7..00000000000
--- a/app/assets/javascripts/ide/lib/alerts/environments.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<!-- eslint-disable vue/multi-word-component-names -->
-<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import { __ } from '~/locale';
-
-export default {
- components: { GlSprintf, GlLink },
- message: __(
- "No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}",
- ),
- computed: {
- helpLink() {
- return helpPagePath('ci/environments/index.md');
- },
- },
-};
-</script>
-<template>
- <span>
- <gl-sprintf :message="$options.message">
- <template #link="{ content }">
- <gl-link
- :href="helpLink"
- target="_blank"
- data-track-action="click_link"
- data-track-experiment="in_product_guidance_environments_webide"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </span>
-</template>
diff --git a/app/assets/javascripts/ide/lib/alerts/index.js b/app/assets/javascripts/ide/lib/alerts/index.js
deleted file mode 100644
index ac4eeb0386f..00000000000
--- a/app/assets/javascripts/ide/lib/alerts/index.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { isDefaultCiConfig } from '~/lib/utils/common_utils';
-import { leftSidebarViews } from '../../constants';
-import EnvironmentsMessage from './environments.vue';
-
-const alerts = [
- {
- key: Symbol('ALERT_ENVIRONMENT'),
- show: (state, file) =>
- state.currentActivityView === leftSidebarViews.commit.name &&
- isDefaultCiConfig(file.path) &&
- state.environmentsGuidanceAlertDetected &&
- !state.environmentsGuidanceAlertDismissed,
- props: { variant: 'tip' },
- dismiss: ({ dispatch }) => dispatch('dismissEnvironmentsGuidance'),
- message: EnvironmentsMessage,
- },
-];
-
-export const findAlertKeyToShow = (...args) => alerts.find((x) => x.show(...args))?.key;
-
-export const getAlert = (key) => alerts.find((x) => x.key === key);
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 1f9bc834140..01e1fd50bcc 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,11 +1,9 @@
import Api from '~/api';
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
-import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
-import ciConfig from '~/ci/pipeline_editor/graphql/queries/ci_config.query.graphql';
-import { query, mutate } from './gql';
+import { query } from './gql';
export default {
getFileData(endpoint) {
@@ -84,18 +82,6 @@ export default {
const url = `${gon.relative_url_root}/${projectPath}/service_ping/web_ide_pipelines_count`;
return axios.post(url);
},
- getCiConfig(projectPath, content) {
- return query({
- query: ciConfig,
- variables: { projectPath, content },
- }).then(({ data }) => data.ciConfig);
- },
- dismissUserCallout(name) {
- return mutate({
- mutation: dismissUserCallout,
- variables: { input: { featureName: name } },
- }).then(({ data }) => data);
- },
getProjectPermissionsData(projectPath) {
return query({
query: getIdeProject,
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 0106eeae162..bb4b181c56d 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -311,4 +311,3 @@ export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
export * from './actions/merge_request';
-export * from './actions/alert';
diff --git a/app/assets/javascripts/ide/stores/actions/alert.js b/app/assets/javascripts/ide/stores/actions/alert.js
deleted file mode 100644
index 4c33dc19520..00000000000
--- a/app/assets/javascripts/ide/stores/actions/alert.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import service from '../../services';
-import {
- DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
- DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
-} from '../mutation_types';
-
-export const detectGitlabCiFileAlerts = ({ dispatch }, content) =>
- dispatch('detectEnvironmentsGuidance', content);
-
-export const detectEnvironmentsGuidance = ({ commit, state }, content) =>
- service.getCiConfig(state.currentProjectId, content).then((data) => {
- commit(DETECT_ENVIRONMENTS_GUIDANCE_ALERT, data?.stages);
- });
-
-export const dismissEnvironmentsGuidance = ({ commit }) =>
- service.dismissUserCallout('web_ide_ci_environments_guidance').then(() => {
- commit(DISMISS_ENVIRONMENTS_GUIDANCE_ALERT);
- });
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 74fe61b6e2f..0f4dbb56e04 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -260,5 +260,3 @@ export const getJsonSchemaForPath = (state, getters) => (path) => {
fileMatch: [`*${path}`],
};
};
-
-export * from './getters/alert';
diff --git a/app/assets/javascripts/ide/stores/getters/alert.js b/app/assets/javascripts/ide/stores/getters/alert.js
deleted file mode 100644
index 714e2d89b4f..00000000000
--- a/app/assets/javascripts/ide/stores/getters/alert.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import { findAlertKeyToShow } from '../../lib/alerts';
-
-export const getAlert = (state) => (file) => findAlertKeyToShow(state, file);
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 13f338c4a48..ae6588f948f 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -71,8 +71,3 @@ export const RENAME_ENTRY = 'RENAME_ENTRY';
export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
export const RESTORE_TREE = 'RESTORE_TREE';
-
-// Alert mutation types
-
-export const DETECT_ENVIRONMENTS_GUIDANCE_ALERT = 'DETECT_ENVIRONMENTS_GUIDANCE_ALERT';
-export const DISMISS_ENVIRONMENTS_GUIDANCE_ALERT = 'DISMISS_ENVIRONMENTS_GUIDANCE_ALERT';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index d11fc388d5e..300d352f81c 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
import * as types from './mutation_types';
-import alertMutations from './mutations/alert';
import branchMutations from './mutations/branch';
import fileMutations from './mutations/file';
import mergeRequestMutation from './mutations/merge_request';
@@ -247,5 +246,4 @@ export default {
...fileMutations,
...treeMutations,
...branchMutations,
- ...alertMutations,
};
diff --git a/app/assets/javascripts/ide/stores/mutations/alert.js b/app/assets/javascripts/ide/stores/mutations/alert.js
deleted file mode 100644
index bb2d33a836b..00000000000
--- a/app/assets/javascripts/ide/stores/mutations/alert.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import {
- DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
- DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
-} from '../mutation_types';
-
-export default {
- [DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, stages) {
- if (!stages) {
- return;
- }
- const hasEnvironments = stages?.nodes?.some((stage) =>
- stage.groups.nodes.some((group) => group.jobs.nodes.some((job) => job.environment)),
- );
- const hasParsedCi = Array.isArray(stages.nodes);
-
- state.environmentsGuidanceAlertDetected = !hasEnvironments && hasParsedCi;
- },
- [DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state) {
- state.environmentsGuidanceAlertDismissed = true;
- },
-};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 356bbf28a48..6297231e252 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -28,8 +28,6 @@ export default () => ({
},
renderWhitespaceInCode: false,
editorTheme: DEFAULT_THEME,
- environmentsGuidanceAlertDismissed: false,
- environmentsGuidanceAlertDetected: false,
previewMarkdownPath: '',
userPreferencesPath: '',
});
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_status.vue b/app/assets/javascripts/import_entities/import_groups/components/import_status.vue
index cdb38cdf7f1..e2c5a4aadb8 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_status.vue
@@ -31,11 +31,6 @@ export default {
required: false,
default: false,
},
- showDetailsLink: {
- type: Boolean,
- required: false,
- default: false,
- },
status: {
type: String,
required: true,
@@ -56,7 +51,7 @@ export default {
},
showDetails() {
- return this.showDetailsLink && Boolean(this.detailsPathWithId) && this.hasFailures;
+ return Boolean(this.detailsPathWithId) && this.hasFailures;
},
detailsPathWithId() {
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index db354a01899..25d47f3ced3 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -18,7 +18,6 @@ import { createAlert } from '~/alert';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { s__, __, n__, sprintf } from '~/locale';
import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import { getGroupPathAvailability } from '~/rest_api';
@@ -69,7 +68,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
sourceUrl: {
type: String,
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index dead90eeb71..4f6ca5c5638 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -7,11 +7,7 @@ import Api from '~/api';
import Tracking from '~/tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { n__, s__, sprintf } from '~/locale';
-import {
- memberName,
- triggerExternalAlert,
- inviteMembersTrackingOptions,
-} from 'ee_else_ce/invite_members/utils/member_utils';
+import { memberName, triggerExternalAlert } from 'ee_else_ce/invite_members/utils/member_utils';
import { captureException } from '~/ci/runner/sentry_utils';
import {
USERS_FILTER_ALL,
@@ -142,9 +138,6 @@ export default {
isCelebration() {
return this.mode === 'celebrate';
},
- baseTrackingDetails() {
- return { label: this.source, celebrate: this.isCelebration };
- },
isTextForAdmin() {
return this.isCurrentUserAdmin && Boolean(this.newUsersUrl);
},
@@ -265,7 +258,7 @@ export default {
this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
- this.track('render', inviteMembersTrackingOptions(this.baseTrackingDetails));
+ this.track('render', { label: this.source });
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
@@ -335,10 +328,10 @@ export default {
return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
},
onCancel() {
- this.track('click_cancel', inviteMembersTrackingOptions(this.baseTrackingDetails));
+ this.track('click_cancel', { label: this.source });
},
onClose() {
- this.track('click_x', inviteMembersTrackingOptions(this.baseTrackingDetails));
+ this.track('click_x', { label: this.source });
},
resetFields() {
this.clearValidation();
@@ -347,7 +340,7 @@ export default {
this.newUsersToInvite = [];
},
onInviteSuccess() {
- this.track('invite_successful', inviteMembersTrackingOptions(this.baseTrackingDetails));
+ this.track('invite_successful', { label: this.source });
if (this.reloadPageOnSubmit) {
reloadOnInvitationSuccess();
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index 7f76b7ca1ac..5d9663abaf2 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -94,7 +94,6 @@ export default {
<gl-dropdown-item
v-else-if="isDropdownWithEmojiTrigger"
v-bind="componentAttributes"
- button-class="top-nav-menu-item"
@click="openModal"
>
{{ displayText }}
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index 00b7c3f4bdd..574bbacc498 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -215,6 +215,7 @@ export default {
this.$emit('reset');
},
onShowModal() {
+ this.$emit('shown');
if (this.usersLimitDataset.reachedLimit) {
this.track('render', { category: 'default', label: ON_SHOW_TRACK_LABEL });
}
diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js
index 52fb5e98f27..7998cb69445 100644
--- a/app/assets/javascripts/invite_members/utils/member_utils.js
+++ b/app/assets/javascripts/invite_members/utils/member_utils.js
@@ -6,7 +6,3 @@ export function memberName(member) {
export function triggerExternalAlert() {
return false;
}
-
-export function inviteMembersTrackingOptions(options) {
- return { label: options.label };
-}
diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue
index adc789a205b..8ac7990c28d 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -915,7 +915,7 @@ export default {
v-if="issuesDrawerEnabled"
:open="isIssuableSelected"
header-height="calc(var(--top-bar-height) + var(--performance-bar-height))"
- class="gl-w-full gl-sm-w-40p"
+ class="gl-w-full gl-sm-w-40p gl-reset-line-height"
@close="activeIssuable = null"
>
<template #title>
@@ -927,6 +927,7 @@ export default {
<work-item-detail
:key="activeIssuable.iid"
:work-item-iid="activeIssuable.iid"
+ class="gl-pt-0!"
@work-item-updated="updateIssuablesCache"
@work-item-emoji-updated="updateIssuableEmojis"
@addChild="refetchIssuables"
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index 5a836e3e40a..ab6ff825554 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -137,6 +137,7 @@ export async function mountIssuesListApp() {
hasOkrsFeature: parseBoolean(hasOkrsFeature),
initialSort,
isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled),
+ isGroup: !parseBoolean(isProject),
isProject: parseBoolean(isProject),
isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted),
isSignedIn: parseBoolean(isSignedIn),
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index dcdfd06fbf1..fb7058d95dc 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -14,6 +14,7 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
+import { ISSUABLE_EDIT_DESCRIPTION } from '~/behaviors/shortcuts/keybindings';
import { STATUS_CLOSED, TYPE_ISSUE, issuableTypeText } from '~/issues/constants';
import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
@@ -196,6 +197,12 @@ export default {
href: this.submitAsSpamPath,
};
},
+ editShortcutKey() {
+ return ISSUABLE_EDIT_DESCRIPTION.defaultKeys[0];
+ },
+ editTooltip() {
+ return `${this.$options.i18n.editTitleAndDescription} <kbd class="glat gl-ml-1" aria-hidden=true>${this.editShortcutKey}</kbd>`;
+ },
},
created() {
eventHub.$on('toggle.issuable.state', this.toggleIssueState);
@@ -395,9 +402,10 @@ export default {
<gl-button
v-if="canUpdateIssue"
- v-gl-tooltip.bottom
- :title="$options.i18n.editTitleAndDescription"
+ v-gl-tooltip.viewport.html
+ :title="editTooltip"
:aria-label="$options.i18n.editTitleAndDescription"
+ :aria-keyshortcuts="editShortcutKey"
class="js-issuable-edit gl-display-none! gl-md-display-block!"
data-testid="edit-button"
@click="edit"
diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
index 52a12cc7771..e3fa0ce8073 100644
--- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
+++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
@@ -1,20 +1,20 @@
<script>
import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui';
import { debounce } from 'lodash';
+import produce from 'immer';
import { __ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { PROJECTS_PER_PAGE } from '../constants';
import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
export default {
- PROJECTS_PER_PAGE,
- projectQueryPageInfo: {
- endCursor: '',
- },
+ name: 'ProjectDropdown',
+
components: {
GlAvatarLabeled,
GlCollapsibleListbox,
},
+
props: {
selectedProject: {
type: Object,
@@ -22,27 +22,29 @@ export default {
default: null,
},
},
+
data() {
return {
initialProjectsLoading: true,
+ isLoadingMore: false,
projectSearchQuery: '',
selectedProjectId: this.selectedProject?.id,
};
},
+
apollo: {
projects: {
query: getProjectsQuery,
variables() {
return {
- search: this.projectSearchQuery,
- first: this.$options.PROJECTS_PER_PAGE,
- after: this.$options.projectQueryPageInfo.endCursor,
- searchNamespaces: true,
- sort: 'similarity',
+ ...this.queryVariables,
};
},
update(data) {
- return data?.projects?.nodes.filter((project) => !project.repository?.empty) ?? [];
+ return {
+ nodes: data?.projects?.nodes.filter((project) => !project.repository?.empty) ?? [],
+ pageInfo: data?.projects?.pageInfo,
+ };
},
result() {
this.initialProjectsLoading = false;
@@ -52,24 +54,37 @@ export default {
},
},
},
+
computed: {
- projectsLoading() {
- return Boolean(this.$apollo.queries.projects.loading);
+ queryVariables() {
+ return {
+ search: this.projectSearchQuery,
+ first: PROJECTS_PER_PAGE,
+ searchNamespaces: true,
+ sort: 'similarity',
+ };
+ },
+ isLoading() {
+ return this.$apollo.queries.projects.loading && !this.isLoadingMore;
},
projectDropdownText() {
return this.selectedProject?.nameWithNamespace || this.$options.i18n.selectProjectText;
},
projectList() {
- return (this.projects || []).map((project) => ({
+ return (this.projects?.nodes || []).map((project) => ({
...project,
text: project.nameWithNamespace,
value: String(project.id),
}));
},
+ hasNextPage() {
+ return this.projects?.pageInfo?.hasNextPage;
+ },
},
+
methods: {
findProjectById(id) {
- return this.projects.find((project) => id === project.id);
+ return this.projects?.nodes?.find((project) => id === project.id);
},
onProjectSelect(projectId) {
this.$emit('change', this.findProjectById(projectId));
@@ -77,13 +92,41 @@ export default {
onError({ message } = {}) {
this.$emit('error', { message });
},
+ async onBottomReached() {
+ if (!this.hasNextPage) return;
+
+ this.isLoadingMore = true;
+
+ try {
+ await this.$apollo.queries.projects.fetchMore({
+ variables: {
+ ...this.queryVariables,
+ after: this.projects.pageInfo?.endCursor,
+ },
+ updateQuery: (previousResult, { fetchMoreResult }) => {
+ return produce(fetchMoreResult, (draftData) => {
+ draftData.projects.nodes = [
+ ...previousResult.projects.nodes,
+ ...draftData.projects.nodes,
+ ];
+ });
+ },
+ });
+ } catch (error) {
+ this.onError({ message: __('Failed to load projects') });
+ } finally {
+ this.isLoadingMore = false;
+ }
+ },
onSearch: debounce(function debouncedSearch(query) {
this.projectSearchQuery = query;
}, 250),
},
+
i18n: {
selectProjectText: __('Select a project'),
},
+
AVATAR_SHAPE_OPTION_RECT,
};
</script>
@@ -97,8 +140,11 @@ export default {
:header-text="$options.i18n.selectProjectText"
:loading="initialProjectsLoading"
:searchable="true"
- :searching="projectsLoading"
+ :searching="isLoading"
fluid-width
+ infinite-scroll
+ :infinite-scroll-loading="isLoadingMore"
+ @bottom-reached="onBottomReached"
@search="onSearch"
@select="onProjectSelect"
>
diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue
index 0d219f915c9..bcc0ddf824a 100644
--- a/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue
+++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue
@@ -14,8 +14,7 @@ export default {
item: {
type: Object,
required: true,
- validator: (item) =>
- ['name', 'kind', 'labels', 'annotations', 'status'].every((key) => item[key]),
+ validator: (item) => ['name', 'kind', 'labels', 'annotations'].every((key) => item[key]),
},
},
computed: {
@@ -51,7 +50,7 @@ export default {
<template>
<ul class="gl-list-style-none">
<workload-details-item :label="$options.i18n.name">
- {{ item.name }}
+ <span class="gl-word-break-word"> {{ item.name }}</span>
</workload-details-item>
<workload-details-item :label="$options.i18n.kind">
{{ item.kind }}
@@ -63,7 +62,7 @@ export default {
</gl-badge>
</div>
</workload-details-item>
- <workload-details-item :label="$options.i18n.status">
+ <workload-details-item v-if="item.status" :label="$options.i18n.status">
<gl-badge :variant="$options.WORKLOAD_STATUS_BADGE_VARIANTS[item.status]">{{
item.status
}}</gl-badge></workload-details-item
diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue
index 8c6a08ad504..6579e0229e6 100644
--- a/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue
+++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlAlert, GlDrawer } from '@gitlab/ui';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import WorkloadStats from './workload_stats.vue';
import WorkloadTable from './workload_table.vue';
import WorkloadDetails from './workload_details.vue';
@@ -33,6 +34,11 @@ export default {
type: Array,
required: true,
},
+ fields: {
+ type: Array,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
@@ -40,6 +46,11 @@ export default {
selectedItem: {},
};
},
+ computed: {
+ getDrawerHeaderHeight() {
+ return getContentWrapperHeight();
+ },
+ },
methods: {
closeDetailsDrawer() {
this.showDetailsDrawer = false;
@@ -59,16 +70,18 @@ export default {
</gl-alert>
<div v-else>
<workload-stats :stats="stats" />
- <workload-table :items="items" @select-item="onItemSelect" />
+ <workload-table :items="items" :fields="fields" @select-item="onItemSelect" />
<gl-drawer
:open="showDetailsDrawer"
- header-height="calc(var(--top-bar-height) + var(--performance-bar-height))"
+ :header-height="getDrawerHeaderHeight"
:z-index="$options.DRAWER_Z_INDEX"
@close="closeDetailsDrawer"
>
<template #title>
- <h4 class="gl-font-weight-bold gl-font-size-h2 gl-m-0">{{ selectedItem.name }}</h4>
+ <h4 class="gl-font-weight-bold gl-font-size-h2 gl-m-0 gl-word-break-word">
+ {{ selectedItem.name }}
+ </h4>
</template>
<template #default>
<workload-details :item="selectedItem" />
diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue
index d3704863538..83940fb91c8 100644
--- a/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue
+++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue
@@ -1,9 +1,9 @@
<script>
import { GlTable, GlBadge, GlPagination } from '@gitlab/ui';
+import { __ } from '~/locale';
import {
WORKLOAD_STATUS_BADGE_VARIANTS,
PAGE_SIZE,
- TABLE_HEADING_CLASSES,
DEFAULT_WORKLOAD_TABLE_FIELDS,
} from '../constants';
@@ -34,7 +34,6 @@ export default {
return this.fields.map((field) => {
return {
...field,
- thClass: TABLE_HEADING_CLASSES,
sortable: true,
};
});
@@ -45,6 +44,9 @@ export default {
this.$emit('select-item', item);
},
},
+ i18n: {
+ emptyText: __('No results found'),
+ },
PAGE_SIZE,
WORKLOAD_STATUS_BADGE_VARIANTS,
TABLE_CELL_CLASSES: 'gl-p-2',
@@ -58,9 +60,10 @@ export default {
:fields="tableFields"
:per-page="$options.PAGE_SIZE"
:current-page="currentPage"
+ :empty-text="$options.i18n.emptyText"
tbody-tr-class="gl-hover-cursor-pointer"
+ show-empty
stacked="md"
- bordered
hover
@row-clicked="selectItem"
>
diff --git a/app/assets/javascripts/kubernetes_dashboard/constants.js b/app/assets/javascripts/kubernetes_dashboard/constants.js
index b93740aec90..458a79cbcb6 100644
--- a/app/assets/javascripts/kubernetes_dashboard/constants.js
+++ b/app/assets/javascripts/kubernetes_dashboard/constants.js
@@ -1,10 +1,12 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const STATUS_RUNNING = 'Running';
export const STATUS_PENDING = 'Pending';
export const STATUS_SUCCEEDED = 'Succeeded';
export const STATUS_FAILED = 'Failed';
export const STATUS_READY = 'Ready';
+export const STATUS_COMPLETED = 'Completed';
+export const STATUS_SUSPENDED = 'Suspended';
export const STATUS_LABELS = {
[STATUS_RUNNING]: s__('KubernetesDashboard|Running'),
@@ -12,6 +14,8 @@ export const STATUS_LABELS = {
[STATUS_SUCCEEDED]: s__('KubernetesDashboard|Succeeded'),
[STATUS_FAILED]: s__('KubernetesDashboard|Failed'),
[STATUS_READY]: s__('KubernetesDashboard|Ready'),
+ [STATUS_COMPLETED]: s__('KubernetesDashboard|Completed'),
+ [STATUS_SUSPENDED]: s__('KubernetesDashboard|Suspended'),
};
export const WORKLOAD_STATUS_BADGE_VARIANTS = {
@@ -20,24 +24,27 @@ export const WORKLOAD_STATUS_BADGE_VARIANTS = {
[STATUS_SUCCEEDED]: 'success',
[STATUS_FAILED]: 'danger',
[STATUS_READY]: 'success',
+ [STATUS_COMPLETED]: 'success',
+ [STATUS_SUSPENDED]: 'neutral',
};
export const PAGE_SIZE = 20;
-export const TABLE_HEADING_CLASSES = 'gl-bg-gray-50! gl-font-weight-bold gl-white-space-nowrap';
-
export const DEFAULT_WORKLOAD_TABLE_FIELDS = [
{
key: 'name',
label: s__('KubernetesDashboard|Name'),
+ tdClass: 'gl-md-w-half gl-lg-w-40p gl-word-break-word',
},
{
key: 'status',
label: s__('KubernetesDashboard|Status'),
+ tdClass: 'gl-md-w-15',
},
{
key: 'namespace',
label: s__('KubernetesDashboard|Namespace'),
+ tdClass: 'gl-md-w-30p gl-lg-w-40p gl-word-break-word',
},
{
key: 'age',
@@ -47,3 +54,34 @@ export const DEFAULT_WORKLOAD_TABLE_FIELDS = [
export const STATUS_TRUE = 'True';
export const STATUS_FALSE = 'False';
+
+export const SERVICES_TABLE_FIELDS = [
+ {
+ key: 'name',
+ label: __('Name'),
+ },
+ {
+ key: 'namespace',
+ label: __('Namespace'),
+ },
+ {
+ key: 'type',
+ label: __('Type'),
+ },
+ {
+ key: 'clusterIP',
+ label: s__('Environment|Cluster IP'),
+ },
+ {
+ key: 'externalIP',
+ label: s__('Environment|External IP'),
+ },
+ {
+ key: 'ports',
+ label: s__('Environment|Ports'),
+ },
+ {
+ key: 'age',
+ label: s__('Environment|Age'),
+ },
+];
diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/client.js b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js
index 5894472d83b..9454465df9d 100644
--- a/app/assets/javascripts/kubernetes_dashboard/graphql/client.js
+++ b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js
@@ -6,6 +6,9 @@ import k8sDeploymentsQuery from './queries/k8s_dashboard_deployments.query.graph
import k8sStatefulSetsQuery from './queries/k8s_dashboard_stateful_sets.query.graphql';
import k8sReplicaSetsQuery from './queries/k8s_dashboard_replica_sets.query.graphql';
import k8sDaemonSetsQuery from './queries/k8s_dashboard_daemon_sets.query.graphql';
+import k8sJobsQuery from './queries/k8s_dashboard_jobs.query.graphql';
+import k8sCronJobsQuery from './queries/k8s_dashboard_cron_jobs.query.graphql';
+import k8sServicesQuery from './queries/k8s_dashboard_services.query.graphql';
import { resolvers } from './resolvers';
export const apolloProvider = () => {
@@ -14,16 +17,18 @@ export const apolloProvider = () => {
});
const { cache } = defaultClient;
+ const metadata = {
+ name: null,
+ namespace: null,
+ creationTimestamp: null,
+ labels: null,
+ annotations: null,
+ };
+
cache.writeQuery({
query: k8sPodsQuery,
data: {
- metadata: {
- name: null,
- namespace: null,
- creationTimestamp: null,
- labels: null,
- annotations: null,
- },
+ metadata,
status: {
phase: null,
},
@@ -33,13 +38,7 @@ export const apolloProvider = () => {
cache.writeQuery({
query: k8sDeploymentsQuery,
data: {
- metadata: {
- name: null,
- namespace: null,
- creationTimestamp: null,
- labels: null,
- annotations: null,
- },
+ metadata,
status: {
conditions: null,
},
@@ -49,13 +48,7 @@ export const apolloProvider = () => {
cache.writeQuery({
query: k8sStatefulSetsQuery,
data: {
- metadata: {
- name: null,
- namespace: null,
- creationTimestamp: null,
- labels: null,
- annotations: null,
- },
+ metadata,
status: {
readyReplicas: null,
},
@@ -68,13 +61,7 @@ export const apolloProvider = () => {
cache.writeQuery({
query: k8sReplicaSetsQuery,
data: {
- metadata: {
- name: null,
- namespace: null,
- creationTimestamp: null,
- labels: null,
- annotations: null,
- },
+ metadata,
status: {
readyReplicas: null,
},
@@ -87,13 +74,7 @@ export const apolloProvider = () => {
cache.writeQuery({
query: k8sDaemonSetsQuery,
data: {
- metadata: {
- name: null,
- namespace: null,
- creationTimestamp: null,
- labels: null,
- annotations: null,
- },
+ metadata,
status: {
numberMisscheduled: null,
numberReady: null,
@@ -102,6 +83,47 @@ export const apolloProvider = () => {
},
});
+ cache.writeQuery({
+ query: k8sJobsQuery,
+ data: {
+ metadata,
+ status: {
+ failed: null,
+ succeeded: null,
+ },
+ spec: {
+ completions: null,
+ },
+ },
+ });
+
+ cache.writeQuery({
+ query: k8sCronJobsQuery,
+ data: {
+ metadata,
+ status: {
+ active: null,
+ lastScheduleTime: null,
+ },
+ spec: {
+ suspend: null,
+ },
+ },
+ });
+
+ cache.writeQuery({
+ query: k8sServicesQuery,
+ data: {
+ metadata,
+ spec: {
+ type: null,
+ clusterIP: null,
+ externalIP: null,
+ ports: null,
+ },
+ },
+ });
+
return new VueApollo({
defaultClient,
});
diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js b/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js
index 47c2f543357..b9c195d83d0 100644
--- a/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js
+++ b/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js
@@ -43,6 +43,62 @@ export const mapSetItem = (item) => {
return { status, metadata, spec };
};
+export const mapJobItem = (item) => {
+ const metadata = {
+ ...item.metadata,
+ annotations: item.metadata?.annotations || {},
+ labels: item.metadata?.labels || {},
+ };
+
+ const status = {
+ failed: item.status?.failed || 0,
+ succeeded: item.status?.succeeded || 0,
+ };
+
+ return {
+ status,
+ metadata,
+ spec: item.spec,
+ };
+};
+
+export const mapServicesItems = (item) => {
+ const { type, clusterIP, externalIP, ports } = item.spec;
+
+ return {
+ metadata: {
+ ...item.metadata,
+ annotations: item.metadata?.annotations || {},
+ labels: item.metadata?.labels || {},
+ },
+ spec: {
+ type,
+ clusterIP: clusterIP || '-',
+ externalIP: externalIP || '-',
+ ports,
+ },
+ };
+};
+
+export const mapCronJobItem = (item) => {
+ const metadata = {
+ ...item.metadata,
+ annotations: item.metadata?.annotations || {},
+ labels: item.metadata?.labels || {},
+ };
+
+ const status = {
+ active: item.status?.active || 0,
+ lastScheduleTime: item.status?.lastScheduleTime || null,
+ };
+
+ return {
+ status,
+ metadata,
+ spec: item.spec,
+ };
+};
+
export const watchWorkloadItems = ({
client,
query,
diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_cron_jobs.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_cron_jobs.query.graphql
new file mode 100644
index 00000000000..fe20cd2e70e
--- /dev/null
+++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_cron_jobs.query.graphql
@@ -0,0 +1,18 @@
+query getK8sDashboardCronJobs($configuration: LocalConfiguration) {
+ k8sCronJobs(configuration: $configuration) @client {
+ metadata {
+ name
+ namespace
+ creationTimestamp
+ labels
+ annotations
+ }
+ status {
+ active
+ lastScheduleTime
+ }
+ spec {
+ suspend
+ }
+ }
+}
diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_jobs.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_jobs.query.graphql
new file mode 100644
index 00000000000..86afb47f2f9
--- /dev/null
+++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_jobs.query.graphql
@@ -0,0 +1,18 @@
+query getK8sDashboardJobs($configuration: LocalConfiguration) {
+ k8sJobs(configuration: $configuration) @client {
+ metadata {
+ name
+ namespace
+ creationTimestamp
+ labels
+ annotations
+ }
+ status {
+ failed
+ succeeded
+ }
+ spec {
+ completions
+ }
+ }
+}
diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_services.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_services.query.graphql
new file mode 100644
index 00000000000..7d42d66183e
--- /dev/null
+++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_services.query.graphql
@@ -0,0 +1,17 @@
+query getK8sDashboardServices($configuration: LocalConfiguration) {
+ k8sServices(configuration: $configuration) @client {
+ metadata {
+ name
+ namespace
+ creationTimestamp
+ labels
+ annotations
+ }
+ spec {
+ type
+ clusterIP
+ externalIP
+ ports
+ }
+ }
+}
diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js
index e59bed5581b..75285ad2cca 100644
--- a/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js
+++ b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js
@@ -1,4 +1,4 @@
-import { Configuration, AppsV1Api } from '@gitlab/cluster-client';
+import { Configuration, CoreV1Api, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client';
import {
getK8sPods,
@@ -7,12 +7,18 @@ import {
mapSetItem,
buildWatchPath,
watchWorkloadItems,
+ mapJobItem,
+ mapCronJobItem,
+ mapServicesItems,
} from '../helpers/resolver_helpers';
import k8sDashboardPodsQuery from '../queries/k8s_dashboard_pods.query.graphql';
import k8sDashboardDeploymentsQuery from '../queries/k8s_dashboard_deployments.query.graphql';
import k8sDashboardStatefulSetsQuery from '../queries/k8s_dashboard_stateful_sets.query.graphql';
import k8sDashboardReplicaSetsQuery from '../queries/k8s_dashboard_replica_sets.query.graphql';
import k8sDaemonSetsQuery from '../queries/k8s_dashboard_daemon_sets.query.graphql';
+import k8sJobsQuery from '../queries/k8s_dashboard_jobs.query.graphql';
+import k8sCronJobsQuery from '../queries/k8s_dashboard_cron_jobs.query.graphql';
+import k8sServicesQuery from '../queries/k8s_dashboard_services.query.graphql';
export default {
k8sPods(_, { configuration }, { client }) {
@@ -61,10 +67,10 @@ export default {
const config = new Configuration(configuration);
const appsV1api = new AppsV1Api(config);
- const deploymentsApi = namespace
+ const statefulSetsApi = namespace
? appsV1api.listAppsV1NamespacedStatefulSet({ namespace })
: appsV1api.listAppsV1StatefulSetForAllNamespaces();
- return deploymentsApi
+ return statefulSetsApi
.then((res) => {
const watchPath = buildWatchPath({
resource: 'statefulsets',
@@ -98,10 +104,10 @@ export default {
const config = new Configuration(configuration);
const appsV1api = new AppsV1Api(config);
- const deploymentsApi = namespace
+ const replicaSetsApi = namespace
? appsV1api.listAppsV1NamespacedReplicaSet({ namespace })
: appsV1api.listAppsV1ReplicaSetForAllNamespaces();
- return deploymentsApi
+ return replicaSetsApi
.then((res) => {
const watchPath = buildWatchPath({
resource: 'replicasets',
@@ -135,10 +141,10 @@ export default {
const config = new Configuration(configuration);
const appsV1api = new AppsV1Api(config);
- const deploymentsApi = namespace
+ const daemonSetsApi = namespace
? appsV1api.listAppsV1NamespacedDaemonSet({ namespace })
: appsV1api.listAppsV1DaemonSetForAllNamespaces();
- return deploymentsApi
+ return daemonSetsApi
.then((res) => {
const watchPath = buildWatchPath({
resource: 'daemonsets',
@@ -166,4 +172,114 @@ export default {
}
});
},
+
+ k8sJobs(_, { configuration, namespace = '' }, { client }) {
+ const config = new Configuration(configuration);
+
+ const batchV1api = new BatchV1Api(config);
+ const jobsApi = namespace
+ ? batchV1api.listBatchV1NamespacedJob({ namespace })
+ : batchV1api.listBatchV1JobForAllNamespaces();
+ return jobsApi
+ .then((res) => {
+ const watchPath = buildWatchPath({
+ resource: 'jobs',
+ api: 'apis/batch/v1',
+ namespace,
+ });
+ watchWorkloadItems({
+ client,
+ query: k8sJobsQuery,
+ configuration,
+ namespace,
+ watchPath,
+ queryField: 'k8sJobs',
+ mapFn: mapJobItem,
+ });
+
+ const data = res?.items || [];
+
+ return data.map(mapJobItem);
+ })
+ .catch(async (err) => {
+ try {
+ await handleClusterError(err);
+ } catch (error) {
+ throw new Error(error.message);
+ }
+ });
+ },
+
+ k8sCronJobs(_, { configuration, namespace = '' }, { client }) {
+ const config = new Configuration(configuration);
+
+ const batchV1api = new BatchV1Api(config);
+ const cronJobsApi = namespace
+ ? batchV1api.listBatchV1NamespacedCronJob({ namespace })
+ : batchV1api.listBatchV1CronJobForAllNamespaces();
+ return cronJobsApi
+ .then((res) => {
+ const watchPath = buildWatchPath({
+ resource: 'cronjobs',
+ api: 'apis/batch/v1',
+ namespace,
+ });
+ watchWorkloadItems({
+ client,
+ query: k8sCronJobsQuery,
+ configuration,
+ namespace,
+ watchPath,
+ queryField: 'k8sCronJobs',
+ mapFn: mapCronJobItem,
+ });
+
+ const data = res?.items || [];
+
+ return data.map(mapCronJobItem);
+ })
+ .catch(async (err) => {
+ try {
+ await handleClusterError(err);
+ } catch (error) {
+ throw new Error(error.message);
+ }
+ });
+ },
+
+ k8sServices(_, { configuration, namespace = '' }, { client }) {
+ const config = new Configuration(configuration);
+
+ const coreV1Api = new CoreV1Api(config);
+ const servicesApi = namespace
+ ? coreV1Api.listCoreV1NamespacedService({ namespace })
+ : coreV1Api.listCoreV1ServiceForAllNamespaces();
+ return servicesApi
+ .then((res) => {
+ const watchPath = buildWatchPath({
+ resource: 'services',
+ namespace,
+ });
+ watchWorkloadItems({
+ client,
+ query: k8sServicesQuery,
+ configuration,
+ namespace,
+ watchPath,
+ queryField: 'k8sServices',
+ mapFn: mapServicesItems,
+ });
+
+ const data = res?.items || [];
+
+ return data.map(mapServicesItems);
+ })
+ .catch(async (err) => {
+ try {
+ await handleClusterError(err);
+ } catch (error) {
+ throw new Error(error.message);
+ }
+ });
+ },
};
diff --git a/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js b/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js
index 24f43e21506..d3116fd611a 100644
--- a/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js
+++ b/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js
@@ -5,6 +5,8 @@ import {
STATUS_PENDING,
STATUS_READY,
STATUS_FAILED,
+ STATUS_COMPLETED,
+ STATUS_SUSPENDED,
} from '../constants';
export function getAge(creationTimestamp) {
@@ -58,3 +60,31 @@ export function calculateDaemonSetStatus(item) {
}
return STATUS_FAILED;
}
+
+export function calculateJobStatus(item) {
+ if (item.status.failed > 0 || item.status?.succeeded !== item.spec?.completions) {
+ return STATUS_FAILED;
+ }
+ return STATUS_COMPLETED;
+}
+
+export function calculateCronJobStatus(item) {
+ if (item.status?.active > 0 && !item.status?.lastScheduleTime) {
+ return STATUS_FAILED;
+ }
+ if (item.spec?.suspend) {
+ return STATUS_SUSPENDED;
+ }
+ return STATUS_READY;
+}
+
+export function generateServicePortsString(ports) {
+ if (!ports?.length) return '';
+
+ return ports
+ .map((port) => {
+ const nodePort = port.nodePort ? `:${port.nodePort}` : '';
+ return `${port.port}${nodePort}/${port.protocol}`;
+ })
+ .join(', ');
+}
diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/cron_jobs_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/cron_jobs_page.vue
new file mode 100644
index 00000000000..2d57bfdc9fc
--- /dev/null
+++ b/app/assets/javascripts/kubernetes_dashboard/pages/cron_jobs_page.vue
@@ -0,0 +1,84 @@
+<script>
+import { s__ } from '~/locale';
+import { getAge, calculateCronJobStatus } from '../helpers/k8s_integration_helper';
+import WorkloadLayout from '../components/workload_layout.vue';
+import k8sCronJobsQuery from '../graphql/queries/k8s_dashboard_cron_jobs.query.graphql';
+import { STATUS_FAILED, STATUS_READY, STATUS_SUSPENDED, STATUS_LABELS } from '../constants';
+
+export default {
+ components: {
+ WorkloadLayout,
+ },
+ inject: ['configuration'],
+ apollo: {
+ k8sCronJobs: {
+ query: k8sCronJobsQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ };
+ },
+ update(data) {
+ return (
+ data?.k8sCronJobs?.map((job) => {
+ return {
+ name: job.metadata?.name,
+ namespace: job.metadata?.namespace,
+ status: calculateCronJobStatus(job),
+ age: getAge(job.metadata?.creationTimestamp),
+ labels: job.metadata?.labels,
+ annotations: job.metadata?.annotations,
+ kind: s__('KubernetesDashboard|CronJob'),
+ };
+ }) || []
+ );
+ },
+ error(err) {
+ this.errorMessage = err?.message;
+ },
+ },
+ },
+ data() {
+ return {
+ k8sCronJobs: [],
+ errorMessage: '',
+ };
+ },
+ computed: {
+ cronJobsStats() {
+ return [
+ {
+ value: this.countJobsByStatus(STATUS_READY),
+ title: STATUS_LABELS[STATUS_READY],
+ },
+ {
+ value: this.countJobsByStatus(STATUS_FAILED),
+ title: STATUS_LABELS[STATUS_FAILED],
+ },
+ {
+ value: this.countJobsByStatus(STATUS_SUSPENDED),
+ title: STATUS_LABELS[STATUS_SUSPENDED],
+ },
+ ];
+ },
+ loading() {
+ return this.$apollo.queries.k8sCronJobs.loading;
+ },
+ },
+ methods: {
+ countJobsByStatus(phase) {
+ const filteredJobs = this.k8sCronJobs.filter((item) => item.status === phase) || [];
+
+ return filteredJobs.length;
+ },
+ },
+};
+</script>
+<template>
+ <workload-layout
+ :loading="loading"
+ :error-message="errorMessage"
+ :stats="cronJobsStats"
+ :items="k8sCronJobs"
+ />
+</template>
diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/jobs_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/jobs_page.vue
new file mode 100644
index 00000000000..f9dbb53e8b4
--- /dev/null
+++ b/app/assets/javascripts/kubernetes_dashboard/pages/jobs_page.vue
@@ -0,0 +1,80 @@
+<script>
+import { s__ } from '~/locale';
+import { getAge, calculateJobStatus } from '../helpers/k8s_integration_helper';
+import WorkloadLayout from '../components/workload_layout.vue';
+import k8sJobsQuery from '../graphql/queries/k8s_dashboard_jobs.query.graphql';
+import { STATUS_FAILED, STATUS_COMPLETED, STATUS_LABELS } from '../constants';
+
+export default {
+ components: {
+ WorkloadLayout,
+ },
+ inject: ['configuration'],
+ apollo: {
+ k8sJobs: {
+ query: k8sJobsQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ };
+ },
+ update(data) {
+ return (
+ data?.k8sJobs?.map((job) => {
+ return {
+ name: job.metadata?.name,
+ namespace: job.metadata?.namespace,
+ status: calculateJobStatus(job),
+ age: getAge(job.metadata?.creationTimestamp),
+ labels: job.metadata?.labels,
+ annotations: job.metadata?.annotations,
+ kind: s__('KubernetesDashboard|Job'),
+ };
+ }) || []
+ );
+ },
+ error(err) {
+ this.errorMessage = err?.message;
+ },
+ },
+ },
+ data() {
+ return {
+ k8sJobs: [],
+ errorMessage: '',
+ };
+ },
+ computed: {
+ jobsStats() {
+ return [
+ {
+ value: this.countJobsByStatus(STATUS_COMPLETED),
+ title: STATUS_LABELS[STATUS_COMPLETED],
+ },
+ {
+ value: this.countJobsByStatus(STATUS_FAILED),
+ title: STATUS_LABELS[STATUS_FAILED],
+ },
+ ];
+ },
+ loading() {
+ return this.$apollo.queries.k8sJobs.loading;
+ },
+ },
+ methods: {
+ countJobsByStatus(phase) {
+ const filteredJobs = this.k8sJobs.filter((item) => item.status === phase) || [];
+
+ return filteredJobs.length;
+ },
+ },
+};
+</script>
+<template>
+ <workload-layout
+ :loading="loading"
+ :error-message="errorMessage"
+ :stats="jobsStats"
+ :items="k8sJobs"
+ />
+</template>
diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/services_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/services_page.vue
new file mode 100644
index 00000000000..4dc8fb6b6c0
--- /dev/null
+++ b/app/assets/javascripts/kubernetes_dashboard/pages/services_page.vue
@@ -0,0 +1,69 @@
+<script>
+import { s__ } from '~/locale';
+import { getAge, generateServicePortsString } from '../helpers/k8s_integration_helper';
+import { SERVICES_TABLE_FIELDS } from '../constants';
+import WorkloadLayout from '../components/workload_layout.vue';
+import k8sServicesQuery from '../graphql/queries/k8s_dashboard_services.query.graphql';
+
+export default {
+ components: {
+ WorkloadLayout,
+ },
+ inject: ['configuration'],
+ apollo: {
+ k8sServices: {
+ query: k8sServicesQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ };
+ },
+ update(data) {
+ return (
+ data?.k8sServices?.map((service) => {
+ return {
+ name: service.metadata?.name,
+ namespace: service.metadata?.namespace,
+ type: service.spec?.type,
+ clusterIP: service.spec?.clusterIP,
+ externalIP: service.spec?.externalIP,
+ ports: generateServicePortsString(service?.spec?.ports),
+ age: getAge(service.metadata?.creationTimestamp),
+ labels: service.metadata?.labels,
+ annotations: service.metadata?.annotations,
+ kind: s__('KubernetesDashboard|Service'),
+ };
+ }) || []
+ );
+ },
+ error(err) {
+ this.errorMessage = err?.message;
+ },
+ },
+ },
+ data() {
+ return {
+ k8sServices: [],
+ errorMessage: '',
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.k8sServices.loading;
+ },
+ servicesStats() {
+ return [];
+ },
+ },
+ SERVICES_TABLE_FIELDS,
+};
+</script>
+<template>
+ <workload-layout
+ :loading="loading"
+ :error-message="errorMessage"
+ :stats="servicesStats"
+ :items="k8sServices"
+ :fields="$options.SERVICES_TABLE_FIELDS"
+ />
+</template>
diff --git a/app/assets/javascripts/kubernetes_dashboard/router/constants.js b/app/assets/javascripts/kubernetes_dashboard/router/constants.js
index 700f501ade4..f02c01d7973 100644
--- a/app/assets/javascripts/kubernetes_dashboard/router/constants.js
+++ b/app/assets/javascripts/kubernetes_dashboard/router/constants.js
@@ -3,9 +3,15 @@ export const DEPLOYMENTS_ROUTE_NAME = 'deployments';
export const STATEFUL_SETS_ROUTE_NAME = 'statefulSets';
export const REPLICA_SETS_ROUTE_NAME = 'replicaSets';
export const DAEMON_SETS_ROUTE_NAME = 'daemonSets';
+export const JOBS_ROUTE_NAME = 'jobs';
+export const CRON_JOBS_ROUTE_NAME = 'cronJobs';
+export const SERVICES_ROUTE_NAME = 'services';
export const PODS_ROUTE_PATH = '/pods';
export const DEPLOYMENTS_ROUTE_PATH = '/deployments';
export const STATEFUL_SETS_ROUTE_PATH = '/statefulsets';
export const REPLICA_SETS_ROUTE_PATH = '/replicasets';
export const DAEMON_SETS_ROUTE_PATH = '/daemonsets';
+export const JOBS_ROUTE_PATH = '/jobs';
+export const CRON_JOBS_ROUTE_PATH = '/cronjobs';
+export const SERVICES_ROUTE_PATH = '/services';
diff --git a/app/assets/javascripts/kubernetes_dashboard/router/routes.js b/app/assets/javascripts/kubernetes_dashboard/router/routes.js
index a1684a62ca4..7448508de8a 100644
--- a/app/assets/javascripts/kubernetes_dashboard/router/routes.js
+++ b/app/assets/javascripts/kubernetes_dashboard/router/routes.js
@@ -4,6 +4,10 @@ import DeploymentsPage from '../pages/deployments_page.vue';
import StatefulSetsPage from '../pages/stateful_sets_page.vue';
import ReplicaSetsPage from '../pages/replica_sets_page.vue';
import DaemonSetsPage from '../pages/daemon_sets_page.vue';
+import JobsPage from '../pages/jobs_page.vue';
+import CronJobsPage from '../pages/cron_jobs_page.vue';
+import ServicesPage from '../pages/services_page.vue';
+
import {
PODS_ROUTE_NAME,
PODS_ROUTE_PATH,
@@ -15,6 +19,12 @@ import {
REPLICA_SETS_ROUTE_PATH,
DAEMON_SETS_ROUTE_NAME,
DAEMON_SETS_ROUTE_PATH,
+ JOBS_ROUTE_NAME,
+ JOBS_ROUTE_PATH,
+ CRON_JOBS_ROUTE_NAME,
+ CRON_JOBS_ROUTE_PATH,
+ SERVICES_ROUTE_NAME,
+ SERVICES_ROUTE_PATH,
} from './constants';
export default [
@@ -58,4 +68,28 @@ export default [
title: s__('KubernetesDashboard|DaemonSets'),
},
},
+ {
+ name: JOBS_ROUTE_NAME,
+ path: JOBS_ROUTE_PATH,
+ component: JobsPage,
+ meta: {
+ title: s__('KubernetesDashboard|Jobs'),
+ },
+ },
+ {
+ name: CRON_JOBS_ROUTE_NAME,
+ path: CRON_JOBS_ROUTE_PATH,
+ component: CronJobsPage,
+ meta: {
+ title: s__('KubernetesDashboard|CronJobs'),
+ },
+ },
+ {
+ name: SERVICES_ROUTE_NAME,
+ path: SERVICES_ROUTE_PATH,
+ component: ServicesPage,
+ meta: {
+ title: s__('KubernetesDashboard|Services'),
+ },
+ },
];
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 674a901aebc..7dc776a1446 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -634,7 +634,7 @@ export const roundDownFloat = (number, precision = 0) => {
* Represents navigation type constants of the Performance Navigation API.
* Detailed explanation see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigation.
*/
-export const NavigationType = {
+export const navigationType = {
TYPE_NAVIGATE: 0,
TYPE_RELOAD: 1,
TYPE_BACK_FORWARD: 2,
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index d9ac0abf7b3..77986539403 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -19,10 +19,5 @@ export const DRAWER_Z_INDEX = 252;
export const MIN_USERNAME_LENGTH = 2;
-export const BYTES_FORMAT_BYTES = 'B';
-export const BYTES_FORMAT_KIB = 'KiB';
-export const BYTES_FORMAT_MIB = 'MiB';
-export const BYTES_FORMAT_GIB = 'GiB';
-
export const DEFAULT_CI_CONFIG_PATH = '.gitlab-ci.yml';
export const CI_CONFIG_PATH_EXTENSION = /(\.gitlab-ci\.yml)/;
diff --git a/app/assets/javascripts/lib/utils/headers.js b/app/assets/javascripts/lib/utils/headers.js
index 80ae3fb146f..fb635a124d6 100644
--- a/app/assets/javascripts/lib/utils/headers.js
+++ b/app/assets/javascripts/lib/utils/headers.js
@@ -1,3 +1,3 @@
-export const ContentTypeMultipartFormData = {
+export const contentTypeMultipartFormData = {
'Content-Type': 'multipart/form-data',
};
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index d17719c0bc0..01c5bc1f1fc 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -1,12 +1,5 @@
import { sprintf, __ } from '~/locale';
-import {
- BYTES_IN_KIB,
- THOUSAND,
- BYTES_FORMAT_BYTES,
- BYTES_FORMAT_KIB,
- BYTES_FORMAT_MIB,
- BYTES_FORMAT_GIB,
-} from './constants';
+import { BYTES_IN_KIB, THOUSAND } from './constants';
/**
* Function that allows a number with an X amount of decimals
@@ -73,47 +66,47 @@ export function bytesToGiB(number) {
/**
* Formats the bytes in number into a more understandable
* representation. Returns an array with the first value being the human size
- * and the second value being the format (e.g., [1.5, 'KiB']).
+ * and the second value being the label (e.g., [1.5, 'KiB']).
*
- * @param {Number} size
- * @param {Number} digits - The number of digits to appear after the decimal point
- * @returns {String}
+ * @param {number} size
+ * @param {number} [digits=2] - The number of digits to appear after the decimal point
+ * @returns {string[]}
*/
export function numberToHumanSizeSplit(size, digits = 2) {
const abs = Math.abs(size);
if (abs < BYTES_IN_KIB) {
- return [size.toString(), BYTES_FORMAT_BYTES];
+ return [size.toString(), __('B')];
}
if (abs < BYTES_IN_KIB ** 2) {
- return [bytesToKiB(size).toFixed(digits), BYTES_FORMAT_KIB];
+ return [bytesToKiB(size).toFixed(digits), __('KiB')];
}
if (abs < BYTES_IN_KIB ** 3) {
- return [bytesToMiB(size).toFixed(digits), BYTES_FORMAT_MIB];
+ return [bytesToMiB(size).toFixed(digits), __('MiB')];
}
- return [bytesToGiB(size).toFixed(digits), BYTES_FORMAT_GIB];
+ return [bytesToGiB(size).toFixed(digits), __('GiB')];
}
/**
* Port of rails number_to_human_size
* Formats the bytes in number into a more understandable
- * representation (e.g., giving it 1500 yields 1.5 KB).
+ * representation (e.g., giving it 1536 yields 1.5 KiB).
*
- * @param {Number} size
- * @param {Number} digits - The number of digits to appear after the decimal point
- * @returns {String}
+ * @param {number} size
+ * @param {number} [digits=2] - The number of digits to appear after the decimal point
+ * @returns {string}
*/
export function numberToHumanSize(size, digits = 2) {
- const [humanSize, format] = numberToHumanSizeSplit(size, digits);
+ const [humanSize, label] = numberToHumanSizeSplit(size, digits);
- switch (format) {
- case BYTES_FORMAT_BYTES:
+ switch (label) {
+ case __('B'):
return sprintf(__('%{size} B'), { size: humanSize });
- case BYTES_FORMAT_KIB:
+ case __('KiB'):
return sprintf(__('%{size} KiB'), { size: humanSize });
- case BYTES_FORMAT_MIB:
+ case __('MiB'):
return sprintf(__('%{size} MiB'), { size: humanSize });
- case BYTES_FORMAT_GIB:
+ case __('GiB'):
return sprintf(__('%{size} GiB'), { size: humanSize });
default:
return '';
diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js
index 4d8612aeeff..dad4af004cc 100644
--- a/app/assets/javascripts/lib/utils/secret_detection.js
+++ b/app/assets/javascripts/lib/utils/secret_detection.js
@@ -32,6 +32,14 @@ export const containsSensitiveToken = (message) => {
name: 'GitLab Deploy Token',
regex: `gldt-[0-9a-zA-Z_-]{20}`,
},
+ {
+ name: 'GitLab SCIM OAuth Access Token',
+ regex: `glsoat-[0-9a-zA-Z_-]{20}`,
+ },
+ {
+ name: 'GitLab CI Build (Job) Token',
+ regex: `glcbt-[0-9a-zA-Z]{1,5}_[0-9a-zA-Z_-]{20}`,
+ },
];
for (const rule of sensitiveDataPatterns) {
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 6c30294cbbb..b30eba25aa8 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -173,7 +173,7 @@ export const truncateSha = (sha) => sha.substring(0, 8);
* @return {String}
*/
export function capitalizeFirstCharacter(text) {
- return `${text[0].toUpperCase()}${text.slice(1)}`;
+ return text?.length ? `${text[0].toUpperCase()}${text.slice(1)}` : '';
}
/**
diff --git a/app/assets/javascripts/logo.js b/app/assets/javascripts/logo.js
index c76e44a196d..b4933376d4e 100644
--- a/app/assets/javascripts/logo.js
+++ b/app/assets/javascripts/logo.js
@@ -13,7 +13,7 @@ export function initPortraitLogoDetection() {
const isPortrait = img.height > img.width;
if (isPortrait) {
// Limit the width when the logo has portrait format
- img.classList.replace('gl-h-9', 'gl-w-10');
+ img.classList.replace('gl-h-10', 'gl-w-10');
}
img.classList.remove('gl-visibility-hidden');
},
diff --git a/app/assets/javascripts/merge_requests/components/compare_app.vue b/app/assets/javascripts/merge_requests/components/compare_app.vue
index 538aa090aa8..a753641ffd2 100644
--- a/app/assets/javascripts/merge_requests/components/compare_app.vue
+++ b/app/assets/javascripts/merge_requests/components/compare_app.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: () => ({}),
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -120,6 +125,7 @@ export default {
:default="currentBranch"
:toggle-class="toggleClass.branch"
:data-qa-compare-side="compareSide"
+ :disabled="disabled"
data-testid="compare-dropdown"
@selected="selectBranch"
/>
diff --git a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
index 20989206a51..35fbf4bc4e6 100644
--- a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
+++ b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
@@ -46,6 +46,11 @@ export default {
required: false,
default: '',
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -131,6 +136,7 @@ export default {
:toggle-text="current.text || dropdownHeader"
:header-text="dropdownHeader"
:searching="isLoading"
+ :disabled="disabled"
searchable
class="gl-w-full dropdown-target-project"
:toggle-class="[
diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
index 877e6142bae..e405a534cdc 100644
--- a/app/assets/javascripts/merge_requests/components/sticky_header.vue
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -70,6 +70,7 @@ export default {
title: { default: '' },
tabs: { default: () => [] },
isFluidLayout: { default: false },
+ blocksMerge: { default: false },
},
data() {
return {
@@ -226,7 +227,7 @@ export default {
</li>
</ul>
<div class="gl-display-none gl-lg-display-flex gl-align-items-center gl-ml-auto">
- <discussion-counter blocks-merge hide-options />
+ <discussion-counter :blocks-merge="blocksMerge" hide-options />
<div
v-if="isSignedIn"
:class="{ 'gl-display-flex gl-gap-3': isNotificationsTodosButtons }"
diff --git a/app/assets/javascripts/ml/model_registry/apps/index.js b/app/assets/javascripts/ml/model_registry/apps/index.js
index 92d159f68be..60fe5dfa804 100644
--- a/app/assets/javascripts/ml/model_registry/apps/index.js
+++ b/app/assets/javascripts/ml/model_registry/apps/index.js
@@ -1,5 +1,6 @@
import ShowMlModel from './show_ml_model.vue';
import ShowMlModelVersion from './show_ml_model_version.vue';
import IndexMlModels from './index_ml_models.vue';
+import NewMlModel from './new_ml_model.vue';
-export { ShowMlModel, ShowMlModelVersion, IndexMlModels };
+export { ShowMlModel, ShowMlModelVersion, IndexMlModels, NewMlModel };
diff --git a/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue b/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue
index e5e093db5ca..59b68fc0063 100644
--- a/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue
+++ b/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue
@@ -1,6 +1,6 @@
<script>
import { isEmpty } from 'lodash';
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui';
import Pagination from '~/vue_shared/components/incubation/pagination.vue';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
@@ -10,6 +10,7 @@ import * as i18n from '../translations';
import { BASE_SORT_FIELDS, MODEL_ENTITIES } from '../constants';
import SearchBar from '../components/search_bar.vue';
import ModelRow from '../components/model_row.vue';
+import ActionsDropdown from '../components/actions_dropdown.vue';
export default {
name: 'IndexMlModels',
@@ -21,6 +22,16 @@ export default {
TitleArea,
GlBadge,
EmptyState,
+ GlButton,
+ ActionsDropdown,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ provide() {
+ return {
+ mlflowTrackingUrl: this.mlflowTrackingUrl,
+ };
},
props: {
models: {
@@ -31,11 +42,25 @@ export default {
type: Object,
required: true,
},
+ createModelPath: {
+ type: String,
+ required: true,
+ },
modelCount: {
type: Number,
required: false,
default: 0,
},
+ canWriteModelRegistry: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ mlflowTrackingUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
hasModels() {
@@ -63,6 +88,13 @@ export default {
<template #metadata-models-count>
<metadata-item icon="machine-learning" :text="$options.i18n.modelsCountLabel(modelCount)" />
</template>
+ <template #right-actions>
+ <gl-button v-if="canWriteModelRegistry" :href="createModelPath">{{
+ $options.i18n.CREATE_MODEL_LABEL
+ }}</gl-button>
+
+ <actions-dropdown />
+ </template>
</title-area>
<template v-if="hasModels">
<search-bar :sortable-fields="$options.sortableFields" />
diff --git a/app/assets/javascripts/ml/model_registry/apps/new_ml_model.vue b/app/assets/javascripts/ml/model_registry/apps/new_ml_model.vue
new file mode 100644
index 00000000000..618d4cea1a5
--- /dev/null
+++ b/app/assets/javascripts/ml/model_registry/apps/new_ml_model.vue
@@ -0,0 +1,127 @@
+<script>
+import {
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlAlert,
+ GlButton,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import createModelMutation from '../graphql/mutations/create_model.mutation.graphql';
+import {
+ NEW_MODEL_LABEL,
+ ERROR_CREATING_MODEL_LABEL,
+ CREATE_MODEL_WITH_CLIENT_LABEL,
+ NAME_LABEL,
+ DESCRIPTION_LABEL,
+ CREATE_MODEL_LABEL,
+} from '../translations';
+
+export default {
+ name: 'NewMlModel',
+ components: {
+ TitleArea,
+ GlForm,
+ GlFormInput,
+ GlFormGroup,
+ GlFormTextarea,
+ GlAlert,
+ GlButton,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ errorMessage: '',
+ modelName: '',
+ modelDescription: '',
+ };
+ },
+ methods: {
+ async createModel() {
+ this.errorMessage = '';
+ try {
+ const variables = {
+ projectPath: this.projectPath,
+ name: this.modelName,
+ description: this.modelDescription,
+ };
+
+ const { data } = await this.$apollo.mutate({
+ mutation: createModelMutation,
+ variables,
+ });
+
+ const [error] = data?.mlModelCreate?.errors || [];
+
+ if (error) {
+ this.errorMessage = data.mlModelCreate.errors.join(', ');
+ } else {
+ visitUrl(data?.mlModelCreate?.model?._links?.showPath);
+ }
+ } catch (error) {
+ Sentry.captureException(error);
+ this.errorMessage = ERROR_CREATING_MODEL_LABEL;
+ }
+ },
+ },
+ i18n: {
+ NEW_MODEL_LABEL,
+ CREATE_MODEL_WITH_CLIENT_LABEL,
+ NAME_LABEL,
+ DESCRIPTION_LABEL,
+ CREATE_MODEL_LABEL,
+ },
+ docHref: helpPagePath('user/project/ml/model_registry/index.md'),
+};
+</script>
+
+<template>
+ <div>
+ <title-area :title="$options.i18n.NEW_MODEL_LABEL" />
+
+ <gl-alert variant="tip" icon="bulb" class="gl-mb-3" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.CREATE_MODEL_WITH_CLIENT_LABEL">
+ <template #link="{ content }">
+ <gl-link :href="$options.docHref" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <gl-alert
+ v-if="errorMessage"
+ :dismissible="false"
+ variant="danger"
+ class="gl-mb-3"
+ data-testid="new-model-errors"
+ >
+ {{ errorMessage }}
+ </gl-alert>
+
+ <gl-form @submit.prevent="createModel">
+ <gl-form-group :label="$options.i18n.NAME_LABEL">
+ <gl-form-input v-model="modelName" />
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.DESCRIPTION_LABEL" optional>
+ <gl-form-textarea v-model="modelDescription" />
+ </gl-form-group>
+
+ <gl-button type="submit" variant="confirm" class="js-no-auto-disable">{{
+ $options.i18n.CREATE_MODEL_LABEL
+ }}</gl-button>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue
index 51b8fca6511..e771639dade 100644
--- a/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue
+++ b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTab, GlTabs, GlBadge } from '@gitlab/ui';
+import { GlTab, GlTabs, GlBadge, GlLink } from '@gitlab/ui';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import ModelVersionDetail from '~/ml/model_registry/components/model_version_detail.vue';
@@ -19,6 +19,7 @@ export default {
GlBadge,
MetadataItem,
ModelVersionDetail,
+ GlLink,
},
props: {
model: {
@@ -33,9 +34,6 @@ export default {
candidateCount() {
return this.model.candidateCount || 0;
},
- latestVersionTitle() {
- return `${i18n.LATEST_VERSION_LABEL}: ${this.model.latestVersion.version}`;
- },
},
i18n,
modelVersionEntity: MODEL_ENTITIES.modelVersion,
@@ -60,7 +58,14 @@ export default {
<gl-tabs class="gl-mt-4">
<gl-tab :title="$options.i18n.MODEL_DETAILS_TAB_LABEL">
<template v-if="model.latestVersion">
- <h3 class="gl-font-lg">{{ latestVersionTitle }}</h3>
+ <h3 class="gl-font-lg">
+ {{ $options.i18n.LATEST_VERSION_LABEL }}:
+
+ <gl-link :href="model.latestVersion.path" data-testid="model-version-link">
+ {{ model.latestVersion.version }}
+ </gl-link>
+ </h3>
+
<model-version-detail :model-version="model.latestVersion" />
</template>
diff --git a/app/assets/javascripts/ml/model_registry/components/actions_dropdown.vue b/app/assets/javascripts/ml/model_registry/components/actions_dropdown.vue
new file mode 100644
index 00000000000..5b4f9e27437
--- /dev/null
+++ b/app/assets/javascripts/ml/model_registry/components/actions_dropdown.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdown,
+ },
+ inject: ['mlflowTrackingUrl'],
+ computed: {
+ copyIdItem() {
+ return {
+ text: s__('MlModelRegistry|Copy MLflow tracking URL'),
+ action: () => {
+ this.$toast.show(s__('MlModelRegistry|Copied MLflow tracking URL to clipboard'));
+ },
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown
+ placement="right"
+ category="tertiary"
+ :aria-label="__('More actions')"
+ icon="ellipsis_v"
+ no-caret
+ >
+ <gl-disclosure-dropdown-item :item="copyIdItem" :data-clipboard-text="mlflowTrackingUrl" />
+ </gl-disclosure-dropdown>
+</template>
diff --git a/app/assets/javascripts/ml/model_registry/components/candidate_list.vue b/app/assets/javascripts/ml/model_registry/components/candidate_list.vue
index fc24a538293..fca4462d7d2 100644
--- a/app/assets/javascripts/ml/model_registry/components/candidate_list.vue
+++ b/app/assets/javascripts/ml/model_registry/components/candidate_list.vue
@@ -1,22 +1,17 @@
<script>
-import { GlAlert } from '@gitlab/ui';
-import { n__ } from '~/locale';
-import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
-import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue';
import { makeLoadCandidatesErrorMessage, NO_CANDIDATES_LABEL } from '../translations';
import getModelCandidatesQuery from '../graphql/queries/get_model_candidates.query.graphql';
import { GRAPHQL_PAGE_SIZE } from '../constants';
+import SearchableList from './searchable_list.vue';
+import CandidateListRow from './candidate_list_row.vue';
export default {
name: 'MlCandidateList',
components: {
- GlAlert,
CandidateListRow,
- PackagesListLoader,
- RegistryList,
+ SearchableList,
},
props: {
modelId: {
@@ -26,7 +21,7 @@ export default {
},
data() {
return {
- modelVersions: {},
+ candidates: {},
errorMessage: undefined,
};
},
@@ -49,18 +44,12 @@ export default {
gid() {
return convertToGraphQLId('Ml::Model', this.modelId);
},
- isListEmpty() {
- return this.count === 0;
- },
isLoading() {
return this.$apollo.queries.candidates.loading;
},
pageInfo() {
return this.candidates?.pageInfo ?? {};
},
- listTitle() {
- return n__('%d candidate', '%d candidates', this.count);
- },
queryVariables() {
return {
id: this.gid,
@@ -70,18 +59,12 @@ export default {
items() {
return this.candidates?.nodes ?? [];
},
- count() {
- return this.candidates?.count ?? 0;
- },
},
methods: {
- fetchPage({ first = null, last = null, before = null, after = null } = {}) {
+ fetchPage(newPageInfo) {
const variables = {
...this.queryVariables,
- first,
- last,
- before,
- after,
+ ...newPageInfo,
};
this.$apollo.queries.candidates.fetchMore({
@@ -91,18 +74,6 @@ export default {
},
});
},
- fetchPreviousCandidatesPage() {
- this.fetchPage({
- last: GRAPHQL_PAGE_SIZE,
- before: this.pageInfo?.startCursor,
- });
- },
- fetchNextCandidatesPage() {
- this.fetchPage({
- first: GRAPHQL_PAGE_SIZE,
- after: this.pageInfo?.endCursor,
- });
- },
},
i18n: {
NO_CANDIDATES_LABEL,
@@ -111,29 +82,19 @@ export default {
</script>
<template>
<div>
- <div v-if="isLoading">
- <packages-list-loader />
- </div>
- <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false">{{
- errorMessage
- }}</gl-alert>
- <div v-else-if="isListEmpty" class="gl-text-secondary">
- {{ $options.i18n.NO_CANDIDATES_LABEL }}
- </div>
- <div v-else>
- <registry-list
- :hidden-delete="true"
- :is-loading="isLoading"
- :items="items"
- :pagination="pageInfo"
- :title="listTitle"
- @prev-page="fetchPreviousCandidatesPage"
- @next-page="fetchNextCandidatesPage"
- >
- <template #default="{ item }">
- <candidate-list-row :candidate="item" />
- </template>
- </registry-list>
- </div>
+ <searchable-list
+ :page-info="pageInfo"
+ :items="items"
+ :error-message="errorMessage"
+ @fetch-page="fetchPage"
+ >
+ <template #empty-state>
+ {{ $options.i18n.NO_CANDIDATES_LABEL }}
+ </template>
+
+ <template #item="{ item }">
+ <candidate-list-row :candidate="item" />
+ </template>
+ </searchable-list>
</div>
</template>
diff --git a/app/assets/javascripts/ml/model_registry/components/model_version_list.vue b/app/assets/javascripts/ml/model_registry/components/model_version_list.vue
index 6b44cb2f613..5a649a9596a 100644
--- a/app/assets/javascripts/ml/model_registry/components/model_version_list.vue
+++ b/app/assets/javascripts/ml/model_registry/components/model_version_list.vue
@@ -1,23 +1,18 @@
<script>
-import { GlAlert } from '@gitlab/ui';
-import { n__ } from '~/locale';
-import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
-import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { makeLoadVersionsErrorMessage } from '~/ml/model_registry/translations';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import getModelVersionsQuery from '../graphql/queries/get_model_versions.query.graphql';
import { GRAPHQL_PAGE_SIZE, MODEL_ENTITIES } from '../constants';
+import SearchableList from './searchable_list.vue';
import EmptyState from './empty_state.vue';
import ModelVersionRow from './model_version_row.vue';
export default {
components: {
EmptyState,
- GlAlert,
ModelVersionRow,
- PackagesListLoader,
- RegistryList,
+ SearchableList,
},
props: {
modelId: {
@@ -50,18 +45,12 @@ export default {
gid() {
return convertToGraphQLId('Ml::Model', this.modelId);
},
- isListEmpty() {
- return this.count === 0;
- },
isLoading() {
return this.$apollo.queries.modelVersions.loading;
},
pageInfo() {
return this.modelVersions?.pageInfo ?? {};
},
- listTitle() {
- return n__('%d version', '%d versions', this.versions.length);
- },
queryVariables() {
return {
id: this.gid,
@@ -71,31 +60,12 @@ export default {
versions() {
return this.modelVersions?.nodes ?? [];
},
- count() {
- return this.modelVersions?.count ?? 0;
- },
},
methods: {
- fetchPreviousVersionsPage() {
+ fetchPage(pageInfo) {
const variables = {
...this.queryVariables,
- first: null,
- last: GRAPHQL_PAGE_SIZE,
- before: this.pageInfo?.startCursor,
- };
- this.$apollo.queries.modelVersions.fetchMore({
- variables,
- updateQuery: (previousResult, { fetchMoreResult }) => {
- return fetchMoreResult;
- },
- });
- },
- fetchNextVersionsPage() {
- const variables = {
- ...this.queryVariables,
- first: GRAPHQL_PAGE_SIZE,
- last: null,
- after: this.pageInfo?.endCursor,
+ ...pageInfo,
};
this.$apollo.queries.modelVersions.fetchMore({
@@ -110,28 +80,18 @@ export default {
};
</script>
<template>
- <div>
- <div v-if="isLoading">
- <packages-list-loader />
- </div>
- <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false">{{
- errorMessage
- }}</gl-alert>
- <empty-state v-else-if="isListEmpty" :entity-type="$options.modelVersionEntity" />
- <div v-else>
- <registry-list
- :hidden-delete="true"
- :is-loading="isLoading"
- :items="versions"
- :pagination="pageInfo"
- :title="listTitle"
- @prev-page="fetchPreviousVersionsPage"
- @next-page="fetchNextVersionsPage"
- >
- <template #default="{ item }">
- <model-version-row :model-version="item" />
- </template>
- </registry-list>
- </div>
- </div>
+ <searchable-list
+ :page-info="pageInfo"
+ :items="versions"
+ :error-message="errorMessage"
+ @fetch-page="fetchPage"
+ >
+ <template #empty-state>
+ <empty-state :entity-type="$options.modelVersionEntity" />
+ </template>
+
+ <template #item="{ item }">
+ <model-version-row :model-version="item" />
+ </template>
+ </searchable-list>
</template>
diff --git a/app/assets/javascripts/ml/model_registry/components/searchable_list.vue b/app/assets/javascripts/ml/model_registry/components/searchable_list.vue
new file mode 100644
index 00000000000..05062ae6fbf
--- /dev/null
+++ b/app/assets/javascripts/ml/model_registry/components/searchable_list.vue
@@ -0,0 +1,79 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import { GRAPHQL_PAGE_SIZE } from '~/ml/model_registry/constants';
+
+export default {
+ name: 'SearchableList',
+ components: { PackagesListLoader, RegistryList, GlAlert },
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ errorMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ isListEmpty() {
+ return this.items.length === 0;
+ },
+ },
+ methods: {
+ prevPage() {
+ const pageInfo = {
+ first: null,
+ last: GRAPHQL_PAGE_SIZE,
+ before: this.pageInfo.startCursor,
+ };
+
+ this.$emit('fetch-page', pageInfo);
+ },
+ nextPage() {
+ const pageInfo = {
+ first: GRAPHQL_PAGE_SIZE,
+ last: null,
+ after: this.pageInfo.endCursor,
+ };
+
+ this.$emit('fetch-page', pageInfo);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <packages-list-loader v-if="isLoading" />
+ <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false">
+ {{ errorMessage }}
+ </gl-alert>
+ <slot v-else-if="isListEmpty" name="empty-state"></slot>
+ <registry-list
+ v-else
+ :hidden-delete="true"
+ :is-loading="isLoading"
+ :items="items"
+ :pagination="pageInfo"
+ @prev-page="prevPage"
+ @next-page="nextPage"
+ >
+ <template #default="{ item }">
+ <slot name="item" :item="item"></slot>
+ </template>
+ </registry-list>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/model_registry/graphql/mutations/create_model.mutation.graphql b/app/assets/javascripts/ml/model_registry/graphql/mutations/create_model.mutation.graphql
new file mode 100644
index 00000000000..af801474e80
--- /dev/null
+++ b/app/assets/javascripts/ml/model_registry/graphql/mutations/create_model.mutation.graphql
@@ -0,0 +1,11 @@
+mutation createModel($projectPath: ID!, $name: String!, $description: String) {
+ mlModelCreate(input: { projectPath: $projectPath, name: $name, description: $description }) {
+ model {
+ id
+ _links {
+ showPath
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ml/model_registry/translations.js b/app/assets/javascripts/ml/model_registry/translations.js
index 968ec83434d..006142979e2 100644
--- a/app/assets/javascripts/ml/model_registry/translations.js
+++ b/app/assets/javascripts/ml/model_registry/translations.js
@@ -32,6 +32,15 @@ export const CI_SECTION_LABEL = s__('MlModelRegistry|CI Info');
export const JOB_LABEL = __('Job');
export const CI_USER_LABEL = s__('MlModelRegistry|Triggered by');
export const CI_MR_LABEL = __('Merge request');
+export const NEW_MODEL_LABEL = s__('MlModelRegistry|New model');
+export const CREATE_MODEL_LABEL = s__('MlModelRegistry|Create model');
+export const ERROR_CREATING_MODEL_LABEL = s__(
+ 'MlModelRegistry|An error has occurred when saving the model.',
+);
+export const CREATE_MODEL_WITH_CLIENT_LABEL = s__(
+ 'MlModelRegistry|Creating models is also possible through the MLflow client. %{linkStart}Follow the documentation to learn more.%{linkEnd}',
+);
+export const NAME_LABEL = __('Name');
export const makeLoadVersionsErrorMessage = (message) =>
sprintf(s__('MlModelRegistry|Failed to load model versions with error: %{message}'), {
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 9aaae960b6f..77ce5ea5910 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -352,7 +352,7 @@ export default {
</template>
</gl-sprintf>
</div>
- <div class="flash-container timeline-content"></div>
+ <div class="flash-container"></div>
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<comment-field-layout :noteable-data="getNoteableData" :is-internal-note="isInternalNote">
<markdown-editor
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 2524b9efdb6..86f93ee425e 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,6 +1,7 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapActions } from 'vuex';
+import { v4 as uuidv4 } from 'uuid';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
@@ -39,6 +40,11 @@ export default {
AiSummary: () => import('ee_component/notes/components/ai_summary.vue'),
},
mixins: [glFeatureFlagsMixin()],
+ provide() {
+ return {
+ summarizeClientSubscriptionId: uuidv4(),
+ };
+ },
props: {
noteableData: {
type: Object,
diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js
index 3a793c9dc14..b7e4ae8e8ea 100644
--- a/app/assets/javascripts/observability/client.js
+++ b/app/assets/javascripts/observability/client.js
@@ -1,3 +1,4 @@
+import { isValidDate } from '~/lib/utils/datetime_utility';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import axios from '~/lib/utils/axios_utils';
import { logError } from '~/lib/logger';
@@ -128,6 +129,24 @@ function handleAttributeFilter(filterValue, filterOperator, searchParams) {
}
}
+function handlePeriodFilter(rawValue, filterName, filterParams) {
+ if (rawValue.trim().indexOf(' ') < 0) {
+ filterParams.append(filterName, rawValue.trim());
+ return;
+ }
+
+ const dateParts = rawValue.split(' - ');
+ if (dateParts.length === 2) {
+ const [start, end] = dateParts;
+ const startDate = new Date(start);
+ const endDate = new Date(end);
+ if (isValidDate(startDate) && isValidDate(endDate)) {
+ filterParams.append('start_time', startDate.toISOString());
+ filterParams.append('end_time', endDate.toISOString());
+ }
+ }
+}
+
/**
* Builds URLSearchParams from a filter object of type { [filterName]: undefined | null | Array<{operator: String, value: any} }
* e.g:
@@ -154,6 +173,8 @@ function filterObjToQueryParams(filterObj) {
validFilters.forEach(({ operator, value: rawValue }) => {
if (filterName === 'attribute') {
handleAttributeFilter(rawValue, operator, filterParams);
+ } else if (filterName === 'period') {
+ handlePeriodFilter(rawValue, filterName, filterParams);
} else {
const paramName = getFilterParamName(filterName, operator);
let value = rawValue;
diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
index dba738de5e1..ebe69925491 100644
--- a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
+++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
@@ -172,6 +172,6 @@ export default {
</div>
</div>
</div>
- <component :is="routerView" />
+ <component :is="routerView" list-item-class="gl-px-5" />
</div>
</template>
diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js
index 0c363cf7c7f..92381087917 100644
--- a/app/assets/javascripts/organizations/mock_data.js
+++ b/app/assets/javascripts/organizations/mock_data.js
@@ -39,14 +39,14 @@ export const organizationProjects = {
id: 'gid://gitlab/Project/8',
nameWithNamespace: 'Twitter / Typeahead.Js',
webUrl: 'http://127.0.0.1:3000/twitter/Typeahead.Js',
- topics: ['JavaScript', 'Vue.js'],
+ topics: ['JavaScript', 'Vue.js', 'GraphQL', 'Jest', 'CSS', 'HTML'],
forksCount: 4,
avatarUrl: null,
starCount: 0,
visibility: 'public',
openIssuesCount: 48,
descriptionHtml:
- '<p data-sourcepos="1:1-1:59" dir="auto">Optio et reprehenderit enim doloremque deserunt et commodi.</p>',
+ '<p data-sourcepos="1:1-1:59" dir="auto">Optio et reprehenderit enim doloremque deserunt et commodi. Sed sit amet iaculis neque. Morbi vel convallis elit. Aliquam vitae arcu orci. Aenean sem velit, dapibus eget enim id, tempor lobortis orci. Pellentesque dignissim nec velit eget sagittis. Maecenas lectus sapien, tincidunt ac cursus a, aliquam eu ipsum. Aliquam posuere maximus augue, ut vehicula elit vulputate condimentum. In libero leo, vehicula nec risus in, ullamcorper convallis risus. Phasellus sit amet lectus sit amet sem volutpat cursus. Nullam facilisis nulla nec lacus pretium, in pretium ex aliquam.</p>',
issuesAccessLevel: 'enabled',
forkingAccessLevel: 'enabled',
isForked: true,
@@ -141,7 +141,7 @@ export const organizationGroups = {
parent: null,
webUrl: 'http://127.0.0.1:3000/groups/Commit451',
descriptionHtml:
- '<p data-sourcepos="1:1-1:52" dir="auto">Autem praesentium vel ut ratione itaque ullam culpa.</p>',
+ '<p data-sourcepos="1:1-1:52" dir="auto">Autem praesentium vel ut ratione itaque ullam culpa. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse libero sem, congue ut sem id, semper pharetra ante. Sed at dui ac nunc pellentesque congue. Phasellus posuere, nisl non pellentesque dignissim, lacus nisi molestie ex, vel placerat neque ligula non libero. Curabitur ipsum enim, pretium eu dignissim vitae, euismod nec nibh. Praesent eget ipsum eleifend, pellentesque tortor vel, consequat neque. Etiam suscipit dolor massa, sed consectetur nunc tincidunt ut. Suspendisse posuere malesuada finibus. Maecenas iaculis et diam eu iaculis. Proin at nulla sit amet erat sollicitudin suscipit sit amet a libero.</p>',
avatarUrl: null,
descendantGroupsCount: 0,
projectsCount: 3,
diff --git a/app/assets/javascripts/organizations/new/components/app.vue b/app/assets/javascripts/organizations/new/components/app.vue
index f7f7b79d52b..3a2b786dbae 100644
--- a/app/assets/javascripts/organizations/new/components/app.vue
+++ b/app/assets/javascripts/organizations/new/components/app.vue
@@ -42,7 +42,15 @@ export default {
} = await this.$apollo.mutate({
mutation: organizationCreateMutation,
variables: {
- input: { name: formValues.name, path: formValues.path },
+ input: {
+ name: formValues.name,
+ path: formValues.path,
+ description: formValues.description,
+ avatar: formValues.avatar,
+ },
+ },
+ context: {
+ hasUpload: formValues.avatar instanceof File,
},
});
diff --git a/app/assets/javascripts/organizations/new/index.js b/app/assets/javascripts/organizations/new/index.js
index 9c7e5344800..563e366b2c6 100644
--- a/app/assets/javascripts/organizations/new/index.js
+++ b/app/assets/javascripts/organizations/new/index.js
@@ -13,7 +13,9 @@ export const initOrganizationsNew = () => {
const {
dataset: { appData },
} = el;
- const { organizationsPath, rootUrl } = convertObjectPropsToCamelCase(JSON.parse(appData));
+ const { organizationsPath, rootUrl, previewMarkdownPath } = convertObjectPropsToCamelCase(
+ JSON.parse(appData),
+ );
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -26,6 +28,7 @@ export const initOrganizationsNew = () => {
provide: {
organizationsPath,
rootUrl,
+ previewMarkdownPath,
},
render(createElement) {
return createElement(App);
diff --git a/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue b/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue
index 1acc4c54f75..1cea2ceeb90 100644
--- a/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue
+++ b/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue
@@ -3,7 +3,12 @@ import { s__, __ } from '~/locale';
import { createAlert } from '~/alert';
import { visitUrlWithAlerts } from '~/lib/utils/url_utility';
import NewEditForm from '~/organizations/shared/components/new_edit_form.vue';
-import { FORM_FIELD_NAME, FORM_FIELD_ID } from '~/organizations/shared/constants';
+import {
+ FORM_FIELD_NAME,
+ FORM_FIELD_ID,
+ FORM_FIELD_DESCRIPTION,
+ FORM_FIELD_AVATAR,
+} from '~/organizations/shared/constants';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ORGANIZATION } from '~/graphql_shared/constants';
@@ -25,7 +30,7 @@ export default {
),
successMessage: s__('Organization|Organization was successfully updated.'),
},
- fieldsToRender: [FORM_FIELD_NAME, FORM_FIELD_ID],
+ fieldsToRender: [FORM_FIELD_NAME, FORM_FIELD_ID, FORM_FIELD_DESCRIPTION, FORM_FIELD_AVATAR],
data() {
return {
loading: false,
@@ -33,9 +38,24 @@ export default {
};
},
methods: {
+ avatarInput(formValues) {
+ // Organization has an avatar and it is been explicitly removed.
+ if (this.organization.avatar && formValues.avatar === null) {
+ return { avatar: null };
+ }
+
+ // Avatar has been set or changed.
+ if (formValues.avatar instanceof File) {
+ return { avatar: formValues.avatar };
+ }
+
+ // Avatar has not been changed at all, do not include the `avatar` key in input.
+ return {};
+ },
async onSubmit(formValues) {
this.errors = [];
this.loading = true;
+
try {
const {
data: {
@@ -47,8 +67,13 @@ export default {
input: {
id: convertToGraphQLId(TYPE_ORGANIZATION, this.organization.id),
name: formValues.name,
+ description: formValues.description,
+ ...this.avatarInput(formValues),
},
},
+ context: {
+ hasUpload: formValues.avatar instanceof File,
+ },
});
if (errors.length) {
diff --git a/app/assets/javascripts/organizations/settings/general/index.js b/app/assets/javascripts/organizations/settings/general/index.js
index 138606a0aab..3ac1243ff0f 100644
--- a/app/assets/javascripts/organizations/settings/general/index.js
+++ b/app/assets/javascripts/organizations/settings/general/index.js
@@ -13,9 +13,12 @@ export const initOrganizationsSettingsGeneral = () => {
const {
dataset: { appData },
} = el;
- const { organization, organizationsPath, rootUrl } = convertObjectPropsToCamelCase(
- JSON.parse(appData),
- );
+ const {
+ organization,
+ organizationsPath,
+ rootUrl,
+ previewMarkdownPath,
+ } = convertObjectPropsToCamelCase(JSON.parse(appData));
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -29,6 +32,7 @@ export const initOrganizationsSettingsGeneral = () => {
organization,
organizationsPath,
rootUrl,
+ previewMarkdownPath,
},
render(createElement) {
return createElement(App);
diff --git a/app/assets/javascripts/organizations/shared/components/groups_view.vue b/app/assets/javascripts/organizations/shared/components/groups_view.vue
index eaa3017ef97..87bc79a5405 100644
--- a/app/assets/javascripts/organizations/shared/components/groups_view.vue
+++ b/app/assets/javascripts/organizations/shared/components/groups_view.vue
@@ -32,6 +32,11 @@ export default {
required: false,
default: false,
},
+ listItemClass: {
+ type: [String, Array, Object],
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -77,6 +82,11 @@ export default {
<template>
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
- <groups-list v-else-if="groups.length" :groups="groups" show-group-icon />
+ <groups-list
+ v-else-if="groups.length"
+ :groups="groups"
+ show-group-icon
+ :list-item-class="listItemClass"
+ />
<gl-empty-state v-else v-bind="emptyStateProps" />
</template>
diff --git a/app/assets/javascripts/organizations/shared/components/new_edit_form.vue b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue
index c5bb16b944a..49519369e9a 100644
--- a/app/assets/javascripts/organizations/shared/components/new_edit_form.vue
+++ b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue
@@ -3,10 +3,15 @@ import { GlForm, GlFormFields, GlButton } from '@gitlab/ui';
import { formValidators } from '@gitlab/ui/dist/utils';
import { s__, __ } from '~/locale';
import { slugify } from '~/lib/utils/text_utility';
+import AvatarUploadDropzone from '~/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
import {
FORM_FIELD_NAME,
FORM_FIELD_ID,
FORM_FIELD_PATH,
+ FORM_FIELD_DESCRIPTION,
+ FORM_FIELD_AVATAR,
FORM_FIELD_PATH_VALIDATORS,
} from '../constants';
import OrganizationUrlField from './organization_url_field.vue';
@@ -18,12 +23,28 @@ export default {
GlFormFields,
GlButton,
OrganizationUrlField,
+ AvatarUploadDropzone,
+ MarkdownField,
},
i18n: {
cancel: __('Cancel'),
},
formId: 'new-organization-form',
- inject: ['organizationsPath'],
+ markdownDocsPath: helpPagePath('user/organization/index', {
+ anchor: 'organization-description-supported-markdown',
+ }),
+ restrictedToolBarItems: [
+ 'code',
+ 'quote',
+ 'bullet-list',
+ 'numbered-list',
+ 'task-list',
+ 'collapsible-section',
+ 'table',
+ 'attach-file',
+ 'full-screen',
+ ],
+ inject: ['organizationsPath', 'previewMarkdownPath'],
props: {
loading: {
type: Boolean,
@@ -36,6 +57,8 @@ export default {
return {
[FORM_FIELD_NAME]: '',
[FORM_FIELD_PATH]: '',
+ [FORM_FIELD_DESCRIPTION]: '',
+ [FORM_FIELD_AVATAR]: null,
};
},
},
@@ -43,7 +66,7 @@ export default {
type: Array,
required: false,
default() {
- return [FORM_FIELD_NAME, FORM_FIELD_PATH];
+ return [FORM_FIELD_NAME, FORM_FIELD_PATH, FORM_FIELD_DESCRIPTION, FORM_FIELD_AVATAR];
},
},
submitButtonText: {
@@ -98,6 +121,19 @@ export default {
class: 'gl-w-full',
},
},
+ [FORM_FIELD_DESCRIPTION]: {
+ label: s__('Organization|Organization description (optional)'),
+ groupAttrs: {
+ class: 'gl-w-full common-note-form',
+ },
+ },
+ [FORM_FIELD_AVATAR]: {
+ label: s__('Organization|Organization avatar'),
+ groupAttrs: {
+ class: 'gl-w-full',
+ labelSrOnly: true,
+ },
+ },
};
return Object.entries(fields).reduce((accumulator, [fieldKey, fieldDefinition]) => {
@@ -126,6 +162,7 @@ export default {
formFieldsInputEvent(event);
this.hasPathBeenManuallySet = true;
},
+ helpPagePath,
},
};
</script>
@@ -148,11 +185,46 @@ export default {
@blur="blur"
/>
</template>
+ <template #input(description)="{ id, value, input, blur }">
+ <div class="gl-md-form-input-xl">
+ <markdown-field
+ :can-attach-file="false"
+ :markdown-preview-path="previewMarkdownPath"
+ :markdown-docs-path="$options.markdownDocsPath"
+ :textarea-value="value"
+ :restricted-tool-bar-items="$options.restrictedToolBarItems"
+ >
+ <template #textarea>
+ <textarea
+ :id="id"
+ :value="value"
+ class="note-textarea js-gfm-input markdown-area"
+ maxlength="1024"
+ @input="input($event.target.value)"
+ @blur="blur"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </div>
+ </template>
+ <template #input(avatar)="{ input, value }">
+ <avatar-upload-dropzone
+ :value="value"
+ :entity="formValues"
+ :label="fields.avatar.label"
+ @input="input"
+ />
+ </template>
</gl-form-fields>
<div class="gl-display-flex gl-gap-3">
- <gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="loading">{{
- submitButtonText
- }}</gl-button>
+ <gl-button
+ type="submit"
+ variant="confirm"
+ class="js-no-auto-disable"
+ :loading="loading"
+ data-testid="submit-button"
+ >{{ submitButtonText }}</gl-button
+ >
<gl-button v-if="showCancelButton" :href="organizationsPath">{{
$options.i18n.cancel
}}</gl-button>
diff --git a/app/assets/javascripts/organizations/shared/components/organization_url_field.vue b/app/assets/javascripts/organizations/shared/components/organization_url_field.vue
index d36f62477e6..c4b31e6b8a6 100644
--- a/app/assets/javascripts/organizations/shared/components/organization_url_field.vue
+++ b/app/assets/javascripts/organizations/shared/components/organization_url_field.vue
@@ -39,7 +39,7 @@ export default {
</script>
<template>
- <gl-form-input-group>
+ <gl-form-input-group class="gl-md-form-input-xl">
<template #prepend>
<gl-input-group-text class="organization-root-path">
<gl-truncate :text="baseUrl" position="middle" />
@@ -50,7 +50,7 @@ export default {
:id="id"
:value="value"
:placeholder="$options.i18n.pathPlaceholder"
- class="gl-h-auto! gl-md-form-input-lg"
+ class="gl-h-auto!"
@input="$emit('input', $event)"
@blur="$emit('blur', $event)"
/>
diff --git a/app/assets/javascripts/organizations/shared/components/projects_view.vue b/app/assets/javascripts/organizations/shared/components/projects_view.vue
index 9bf4e597884..323a8895821 100644
--- a/app/assets/javascripts/organizations/shared/components/projects_view.vue
+++ b/app/assets/javascripts/organizations/shared/components/projects_view.vue
@@ -36,6 +36,11 @@ export default {
required: false,
default: false,
},
+ listItemClass: {
+ type: [String, Array, Object],
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -81,6 +86,11 @@ export default {
<template>
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
- <projects-list v-else-if="projects.length" :projects="projects" show-project-icon />
+ <projects-list
+ v-else-if="projects.length"
+ :projects="projects"
+ show-project-icon
+ :list-item-class="listItemClass"
+ />
<gl-empty-state v-else v-bind="emptyStateProps" />
</template>
diff --git a/app/assets/javascripts/organizations/shared/constants.js b/app/assets/javascripts/organizations/shared/constants.js
index 7287d84f99f..c4f4f348ff2 100644
--- a/app/assets/javascripts/organizations/shared/constants.js
+++ b/app/assets/javascripts/organizations/shared/constants.js
@@ -4,6 +4,8 @@ import { s__ } from '~/locale';
export const FORM_FIELD_NAME = 'name';
export const FORM_FIELD_ID = 'id';
export const FORM_FIELD_PATH = 'path';
+export const FORM_FIELD_DESCRIPTION = 'description';
+export const FORM_FIELD_AVATAR = 'avatar';
export const FORM_FIELD_PATH_VALIDATORS = [
formValidators.required(s__('Organization|Organization URL is required.')),
diff --git a/app/assets/javascripts/organizations/show/components/app.vue b/app/assets/javascripts/organizations/show/components/app.vue
index 47264d80454..4cd5ada1d66 100644
--- a/app/assets/javascripts/organizations/show/components/app.vue
+++ b/app/assets/javascripts/organizations/show/components/app.vue
@@ -1,11 +1,12 @@
<script>
import OrganizationAvatar from './organization_avatar.vue';
+import OrganizationDescription from './organization_description.vue';
import GroupsAndProjects from './groups_and_projects.vue';
import AssociationCounts from './association_counts.vue';
export default {
name: 'OrganizationShowApp',
- components: { OrganizationAvatar, GroupsAndProjects, AssociationCounts },
+ components: { OrganizationAvatar, OrganizationDescription, GroupsAndProjects, AssociationCounts },
props: {
organization: {
type: Object,
@@ -26,6 +27,7 @@ export default {
<template>
<div class="gl-py-6">
<organization-avatar :organization="organization" />
+ <organization-description :organization="organization" />
<association-counts
:association-counts="associationCounts"
:groups-and-projects-organization-path="groupsAndProjectsOrganizationPath"
diff --git a/app/assets/javascripts/organizations/show/components/organization_avatar.vue b/app/assets/javascripts/organizations/show/components/organization_avatar.vue
index c57ee0ea5b5..d569af3e9b4 100644
--- a/app/assets/javascripts/organizations/show/components/organization_avatar.vue
+++ b/app/assets/javascripts/organizations/show/components/organization_avatar.vue
@@ -44,6 +44,7 @@ export default {
:entity-name="organization.name"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
:size="64"
+ :src="organization.avatar_url"
/>
<div class="gl-ml-3">
<div class="gl-display-flex gl-align-items-center">
diff --git a/app/assets/javascripts/organizations/show/components/organization_description.vue b/app/assets/javascripts/organizations/show/components/organization_description.vue
new file mode 100644
index 00000000000..6222bdcd082
--- /dev/null
+++ b/app/assets/javascripts/organizations/show/components/organization_description.vue
@@ -0,0 +1,24 @@
+<script>
+import SafeHtml from '~/vue_shared/directives/safe_html';
+
+export default {
+ name: 'OrganizationDescription',
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ organization: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="organization.description_html"
+ v-safe-html="organization.description_html"
+ class="gl-mt-5 md"
+ ></div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
index 75af0286e12..7fd7c0fe542 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
@@ -142,7 +142,12 @@ export default {
</template>
<template v-if="formattedSize" #metadata-size>
- <metadata-item icon="disk" :text="formattedSize" data-testid="image-size" />
+ <metadata-item
+ icon="disk"
+ :text="formattedSize"
+ :text-tooltip="s__('ContainerRegistry|Includes both tagged and untagged images')"
+ data-testid="image-size"
+ />
</template>
<template #metadata-cleanup>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index 6ff7d58fd9e..89ab184d808 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { n__ } from '~/locale';
+import { __ } from '~/locale';
import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue';
import PackagesForwardingSettings from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue';
import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue';
@@ -50,21 +50,13 @@ export default {
dismissAlert() {
this.alertMessage = null;
},
- handleSuccess(amount = 1) {
- const successMessage = n__(
- 'Setting saved successfully',
- 'Settings saved successfully',
- amount,
- );
+ handleSuccess() {
+ const successMessage = __('Settings saved successfully.');
this.$toast.show(successMessage);
this.dismissAlert();
},
- handleError(amount = 1) {
- const errorMessage = n__(
- 'An error occurred while saving the setting',
- 'An error occurred while saving the settings',
- amount,
- );
+ handleError() {
+ const errorMessage = __('An error occurred while saving the settings.');
this.alertMessage = errorMessage;
},
},
@@ -81,14 +73,14 @@ export default {
class="settings-section-no-bottom"
:package-settings="packageSettings"
:is-loading="isLoading"
- @success="handleSuccess(2)"
- @error="handleError(2)"
+ @success="handleSuccess"
+ @error="handleError"
/>
<packages-forwarding-settings
:forward-settings="packageSettings"
- @success="handleSuccess(2)"
- @error="handleError(2)"
+ @success="handleSuccess"
+ @error="handleError"
/>
<dependency-proxy-settings
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue
new file mode 100644
index 00000000000..d9177778803
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue
@@ -0,0 +1,115 @@
+<script>
+import { GlCard, GlTable, GlLoadingIcon } from '@gitlab/ui';
+import packagesProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql';
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
+import { s__ } from '~/locale';
+
+const PAGINATION_DEFAULT_PER_PAGE = 10;
+
+export default {
+ components: {
+ SettingsBlock,
+ GlCard,
+ GlTable,
+ GlLoadingIcon,
+ },
+ inject: ['projectPath'],
+ i18n: {
+ settingBlockTitle: s__('PackageRegistry|Protected packages'),
+ settingBlockDescription: s__(
+ 'PackageRegistry|When a package is protected then only certain user roles are able to update and delete the protected package. This helps to avoid tampering with the package.',
+ ),
+ },
+ data() {
+ return {
+ fetchSettingsError: false,
+ packageProtectionRules: [],
+ };
+ },
+ computed: {
+ tableItems() {
+ return this.packageProtectionRules.map((packagesProtectionRule) => {
+ return {
+ col_1_package_name_pattern: packagesProtectionRule.packageNamePattern,
+ col_2_package_type: packagesProtectionRule.packageType,
+ col_3_push_protected_up_to_access_level:
+ packagesProtectionRule.pushProtectedUpToAccessLevel,
+ };
+ });
+ },
+ totalItems() {
+ return this.packageProtectionRules.length;
+ },
+ isLoadingPackageProtectionRules() {
+ return this.$apollo.queries.packageProtectionRules.loading;
+ },
+ },
+ apollo: {
+ packageProtectionRules: {
+ query: packagesProtectionRuleQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ first: PAGINATION_DEFAULT_PER_PAGE,
+ };
+ },
+ update: (data) => {
+ return data.project?.packagesProtectionRules?.nodes || [];
+ },
+ error(e) {
+ this.fetchSettingsError = e;
+ },
+ },
+ },
+ fields: [
+ {
+ key: 'col_1_package_name_pattern',
+ label: s__('PackageRegistry|Package name pattern'),
+ },
+ { key: 'col_2_package_type', label: s__('PackageRegistry|Package type') },
+ {
+ key: 'col_3_push_protected_up_to_access_level',
+ label: s__('PackageRegistry|Push protected up to access level'),
+ },
+ ],
+};
+</script>
+
+<template>
+ <settings-block>
+ <template #title>{{ $options.i18n.settingBlockTitle }}</template>
+
+ <template #description>
+ {{ $options.i18n.settingBlockDescription }}
+ </template>
+
+ <template #default>
+ <gl-card
+ class="gl-new-card"
+ header-class="gl-new-card-header"
+ body-class="gl-new-card-body gl-px-0"
+ >
+ <template #header>
+ <div class="gl-new-card-title-wrapper gl-justify-content-space-between">
+ <h3 class="gl-new-card-title">{{ $options.i18n.settingBlockTitle }}</h3>
+ </div>
+ </template>
+
+ <template #default>
+ <gl-table
+ :items="tableItems"
+ :fields="$options.fields"
+ show-empty
+ stacked="md"
+ class="mb-3"
+ :busy="isLoadingPackageProtectionRules"
+ >
+ <template #table-busy>
+ <gl-loading-icon size="sm" class="gl-my-5" />
+ </template>
+ </gl-table>
+ </template>
+ </gl-card>
+ </template>
+ </settings-block>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 06af69ff250..8e4c50b199b 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -8,6 +8,7 @@ import {
} from '~/packages_and_registries/settings/project/constants';
import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue';
import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -18,7 +19,10 @@ export default {
),
GlAlert,
PackagesCleanupPolicy,
+ PackagesProtectionRules: () =>
+ import('~/packages_and_registries/settings/project/components/packages_protection_rules.vue'),
},
+ mixins: [glFeatureFlagsMixin()],
inject: [
'showContainerRegistrySettings',
'showPackageRegistrySettings',
@@ -32,6 +36,11 @@ export default {
showAlert: false,
};
},
+ computed: {
+ showProtectedPackagesSettings() {
+ return this.showPackageRegistrySettings && this.glFeatures.packagesProtectedPackages;
+ },
+ },
mounted() {
this.checkAlert();
},
@@ -60,6 +69,7 @@ export default {
>
{{ $options.i18n.UPDATE_SETTINGS_SUCCESS_MESSAGE }}
</gl-alert>
+ <packages-protection-rules v-if="showProtectedPackagesSettings" />
<packages-cleanup-policy v-if="showPackageRegistrySettings" />
<container-expiration-policy v-if="showContainerRegistrySettings" />
<dependency-proxy-packages-settings v-if="showDependencyProxySettings" />
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql
new file mode 100644
index 00000000000..e0a072b93e4
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql
@@ -0,0 +1,13 @@
+query getProjectPackageProtectionRules($projectPath: ID!, $first: Int) {
+ project(fullPath: $projectPath) {
+ id
+ packagesProtectionRules(first: $first) {
+ nodes {
+ id
+ packageNamePattern
+ packageType
+ pushProtectedUpToAccessLevel
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
index 1d54dad43a9..e66040c5a99 100644
--- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
@@ -1,6 +1,5 @@
<script>
import {
- GlButton,
GlEmptyState,
GlIcon,
GlLink,
@@ -22,7 +21,6 @@ import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isImporting } from '../utils';
import { DEFAULT_ERROR } from '../utils/error_messages';
@@ -43,7 +41,6 @@ const tableCell = (config) => ({
export default {
components: {
- GlButton,
GlEmptyState,
GlIcon,
GlLink,
@@ -59,8 +56,6 @@ export default {
GlTooltip,
},
- mixins: [glFeatureFlagMixin()],
-
inject: ['realtimeChangesPath'],
data() {
@@ -107,10 +102,6 @@ export default {
.map((item) => item.bulk_import_id);
},
- showDetailsLink() {
- return this.glFeatures.bulkImportDetailsPage;
- },
-
paginationConfigCopy() {
return { ...this.paginationConfig };
},
@@ -265,7 +256,7 @@ export default {
<template #cell(created_at)="{ value }">
<time-ago :time="value" />
</template>
- <template #cell(status)="{ value, item, toggleDetails, detailsShowing }">
+ <template #cell(status)="{ value, item }">
<div
class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-flex-start gl-justify-content-space-between gl-gap-3"
>
@@ -273,20 +264,10 @@ export default {
:id="item.bulk_import_id"
:entity-id="item.id"
:has-failures="item.has_failures"
- :show-details-link="showDetailsLink"
:status="value"
/>
- <gl-button
- v-if="!showDetailsLink && item.failures.length"
- :selected="detailsShowing"
- @click="toggleDetails"
- >{{ __('Details') }}</gl-button
- >
</div>
</template>
- <template #row-details="{ item }">
- <pre><code>{{ item.failures }}</code></pre>
- </template>
</gl-table-lite>
<pagination-bar
:page-info="pageInfo"
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
index 8cb1462c883..3d877bb3abb 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
@@ -16,8 +16,9 @@ if (mrNewCompareNode) {
const sourceCompareEl = document.getElementById('js-source-project-dropdown');
const compareEl = document.querySelector('.js-merge-request-new-compare');
const targetBranch = Vue.observable({ name: '' });
-
const currentSourceBranch = JSON.parse(sourceCompareEl.dataset.currentBranch);
+ const sourceBranch = Vue.observable(currentSourceBranch);
+
// eslint-disable-next-line no-new
new Vue({
el: sourceCompareEl,
@@ -52,6 +53,9 @@ if (mrNewCompareNode) {
if (targetBranchName) {
targetBranch.name = targetBranchName;
}
+
+ sourceBranch.value = branchName;
+ sourceBranch.text = branchName;
},
},
render(h) {
@@ -102,9 +106,14 @@ if (mrNewCompareNode) {
return currentTargetBranch;
},
+ isDisabled() {
+ return !sourceBranch.value;
+ },
},
render(h) {
- return h(CompareApp, { props: { currentBranch: this.currentBranch } });
+ return h(CompareApp, {
+ props: { currentBranch: this.currentBranch, disabled: this.isDisabled },
+ });
},
});
} else {
diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js
index a9d281fc899..69032455fe3 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/page.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/page.js
@@ -28,7 +28,15 @@ requestIdleCallback(() => {
if (el) {
const { data } = el.dataset;
- const { iid, projectPath, title, tabs, isFluidLayout, sourceProjectPath } = JSON.parse(data);
+ const {
+ iid,
+ projectPath,
+ title,
+ tabs,
+ isFluidLayout,
+ sourceProjectPath,
+ blocksMerge,
+ } = JSON.parse(data);
// eslint-disable-next-line no-new
new Vue({
@@ -42,6 +50,7 @@ requestIdleCallback(() => {
title,
tabs,
isFluidLayout: parseBoolean(isFluidLayout),
+ blocksMerge: parseBoolean(blocksMerge),
sourceProjectPath,
},
render(h) {
diff --git a/app/assets/javascripts/pages/projects/ml/models/index/index.js b/app/assets/javascripts/pages/projects/ml/models/index/index.js
index 3f8ef4910a7..54dcf28164f 100644
--- a/app/assets/javascripts/pages/projects/ml/models/index/index.js
+++ b/app/assets/javascripts/pages/projects/ml/models/index/index.js
@@ -1,4 +1,4 @@
import { initSimpleApp } from '~/helpers/init_simple_app_helper';
import { IndexMlModels } from '~/ml/model_registry/apps';
-initSimpleApp('#js-index-ml-models', IndexMlModels);
+initSimpleApp('#js-index-ml-models', IndexMlModels, { withApolloProvider: true });
diff --git a/app/assets/javascripts/pages/projects/ml/models/new/index.js b/app/assets/javascripts/pages/projects/ml/models/new/index.js
new file mode 100644
index 00000000000..8dc5c5aea4e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ml/models/new/index.js
@@ -0,0 +1,4 @@
+import { initSimpleApp } from '~/helpers/init_simple_app_helper';
+import { NewMlModel } from '~/ml/model_registry/apps';
+
+initSimpleApp('#js-mount-new-ml-model', NewMlModel, { withApolloProvider: true });
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index dce40c1f322..ad6f84fbc07 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -1,11 +1,9 @@
import initArtifactsSettings from '~/artifacts_settings';
-import SecretValues from '~/behaviors/secret_values';
import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
import initVariableList from '~/ci/ci_variable_list';
import initInheritedGroupCiVariables from '~/ci/inherited_ci_variables';
import initDeployFreeze from '~/deploy_freeze';
import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle';
-import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import initRefSwitcherBadges from '~/projects/settings/mount_ref_switcher_badges';
import initSettingsPanels from '~/settings_panels';
@@ -18,14 +16,6 @@ import { initGeneralPipelinesOptions } from '~/ci_settings_general_pipeline';
// Initialize expandable settings panels
initSettingsPanels();
-const runnerToken = document.querySelector('.js-secret-runner-token');
-if (runnerToken) {
- const runnerTokenSecretValue = new SecretValues({
- container: runnerToken,
- });
- runnerTokenSecretValue.init();
-}
-
initVariableList();
initInheritedGroupCiVariables();
@@ -47,7 +37,6 @@ initArtifactsSettings();
initProjectRunnersRegistrationDropdown();
initSharedRunnersToggle();
initRefSwitcherBadges();
-initInstallRunner();
initTokenAccess();
initCiSecureFiles();
initGeneralPipelinesOptions();
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue
index 32775ac553c..c35e10462a0 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue
@@ -5,9 +5,9 @@ import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue';
-import getCiCatalogSettingsQuery from '../graphql/queries/get_ci_catalog_settings.query.graphql';
-import catalogResourcesCreate from '../graphql/mutations/catalog_resources_create.mutation.graphql';
-import catalogResourcesDestroy from '../graphql/mutations/catalog_resources_destroy.mutation.graphql';
+import getCiCatalogSettingsQuery from '~/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql';
+import catalogResourcesCreate from '~/ci/catalog/graphql/mutations/catalog_resources_create.mutation.graphql';
+import catalogResourcesDestroy from '~/ci/catalog/graphql/mutations/catalog_resources_destroy.mutation.graphql';
const i18n = {
catalogResourceQueryError: s__(
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/index.js b/app/assets/javascripts/pages/projects/shared/permissions/index.js
index 4b4589dc46c..78dd456aad9 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/index.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/index.js
@@ -14,6 +14,9 @@ export default function initProjectPermissionsSettings() {
const mountPoint = document.querySelector('.js-project-permissions-form');
const componentPropsEl = document.querySelector('.js-project-permissions-form-data');
+
+ if (!mountPoint) return null;
+
const componentProps = JSON.parse(componentPropsEl.innerHTML);
const {
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
index 32df2911a48..ee1a7633a11 100644
--- a/app/assets/javascripts/pages/sessions/new/index.js
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -16,7 +16,7 @@ new SigninTabsMemoizer(); // eslint-disable-line no-new
new NoEmojiValidator(); // eslint-disable-line no-new
new OAuthRememberMe({
- container: $('.omniauth-container'),
+ container: $('.js-oauth-login'),
}).bindEvents();
// Save the URL fragment from the current window location. This will be present if the user was
diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
index bad8a7cedc6..3336b094560 100644
--- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -20,8 +20,8 @@ export default class OAuthRememberMe {
toggleRememberMe(event) {
const rememberMe = $(event.target).is(':checked');
- $('.js-oauth-login', this.container).each((i, element) => {
- const $form = $(element).parent('form');
+ $('.js-oauth-login form', this.container).each((_, form) => {
+ const $form = $(form);
const href = $form.attr('action');
if (rememberMe) {
diff --git a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js
index 70e5e336e78..54ec3c52f62 100644
--- a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js
+++ b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js
@@ -12,7 +12,7 @@ export default function preserveUrlFragment(fragment = '') {
// Append the fragment to all sign-in/sign-up form actions so it is preserved when the user is
// eventually redirected back to the originally requested URL.
- const forms = document.querySelectorAll('#signin-container .tab-content form');
+ const forms = document.querySelectorAll('.js-non-oauth-login form');
Array.prototype.forEach.call(forms, (form) => {
const actionWithFragment = setUrlFragment(form.getAttribute('action'), `#${normalFragment}`);
form.setAttribute('action', actionWithFragment);
@@ -20,7 +20,7 @@ export default function preserveUrlFragment(fragment = '') {
// Append a redirect_fragment query param to all oauth provider links. The redirect_fragment
// query param will be available in the omniauth callback upon successful authentication
- const oauthForms = document.querySelectorAll('#signin-container .omniauth-container form');
+ const oauthForms = document.querySelectorAll('.js-oauth-login form');
Array.prototype.forEach.call(oauthForms, (oauthForm) => {
const newHref = mergeUrlParams(
{ redirect_fragment: normalFragment },
diff --git a/app/assets/javascripts/pages/shared/mount_badge_settings.js b/app/assets/javascripts/pages/shared/mount_badge_settings.js
index aeb9f2fb8d3..9c92d4f8f1e 100644
--- a/app/assets/javascripts/pages/shared/mount_badge_settings.js
+++ b/app/assets/javascripts/pages/shared/mount_badge_settings.js
@@ -5,6 +5,8 @@ import store from '~/badges/store';
export default (kind) => {
const badgeSettingsElement = document.getElementById('badge-settings');
+ if (!badgeSettingsElement) return null;
+
store.dispatch('loadBadges', {
kind,
apiEndpointUrl: badgeSettingsElement.dataset.apiEndpointUrl,
diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
deleted file mode 100644
index e83c73edfde..00000000000
--- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
-import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
-
-Vue.use(VueApollo);
-
-export function initInstallRunner(componentId = 'js-install-runner') {
- const installRunnerEl = document.getElementById(componentId);
-
- if (installRunnerEl) {
- const defaultClient = createDefaultClient();
-
- const apolloProvider = new VueApollo({
- defaultClient,
- });
-
- // eslint-disable-next-line no-new
- new Vue({
- el: installRunnerEl,
- apolloProvider,
- render(createElement) {
- return createElement(RunnerInstructions);
- },
- });
- }
-}
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
index d2c31314bba..134962721d2 100644
--- a/app/assets/javascripts/pages/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -8,7 +8,7 @@ import UserTabs from './user_tabs';
function initUserProfile(action) {
// TODO: Remove both Vue and legacy JS tabs code/feature flag uses with the
// removal of the old navigation.
- // See https://gitlab.com/groups/gitlab-org/-/epics/11875.
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/435899.
if (gon.features?.profileTabsVue) {
initProfileTabs();
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 79eb3902116..f9e22808b0d 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -1,5 +1,5 @@
// TODO: Remove this with the removal of the old navigation.
-// See https://gitlab.com/groups/gitlab-org/-/epics/11875.
+// See https://gitlab.com/gitlab-org/gitlab/-/issues/435899.
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue
index 96c11ea9e4e..c4e27d89f49 100644
--- a/app/assets/javascripts/performance_bar/components/request_warning.vue
+++ b/app/assets/javascripts/performance_bar/components/request_warning.vue
@@ -37,6 +37,7 @@ export default {
:id="htmlId"
v-gl-tooltip.viewport="warningMessage"
data-name="warning"
+ data-testid="warning"
class="gl-ml-2"
/>
</span>
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 7420542a065..95ef04ceb30 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -24,7 +24,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-branch-rules-info-callout',
'.js-new-nav-for-everyone-callout',
'.js-namespace-over-storage-users-combined-alert',
- '.js-code-suggestions-ga-non-owner-alert',
+ '.js-code-suggestions-ga-alert',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index 2fc1f99c183..8d20ef1989e 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/alert';
+import { createAlert, VARIANT_DANGER } from '~/alert';
import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants';
import IntegrationView from './integration_view.vue';
@@ -94,9 +94,8 @@ export default {
return;
}
updateClasses(this.bodyClasses, this.getSelectedTheme().css_class, this.selectedLayout);
- const { message = this.$options.i18n.defaultSuccess, variant = VARIANT_INFO } =
- customEvent?.detail?.[0] || {};
- createAlert({ message, variant });
+ const message = customEvent?.detail?.[0]?.message || this.$options.i18n.defaultSuccess || '';
+ this.$toast.show(message);
this.isSubmitEnabled = true;
},
handleError(customEvent) {
diff --git a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
index 8e4d42a42c6..3ba34078cd7 100644
--- a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
+++ b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
import { initListboxInputs } from '~/vue_shared/components/listbox_input/init_listbox_inputs';
import ProfilePreferences from './components/profile_preferences.vue';
@@ -21,6 +22,8 @@ export default () => {
{ formEl },
);
+ Vue.use(GlToast);
+
return new Vue({
el,
name: 'ProfilePreferencesApp',
diff --git a/app/assets/javascripts/projects/commit/components/commit_comments_button.vue b/app/assets/javascripts/projects/commit/components/commit_comments_button.vue
deleted file mode 100644
index 67b5e1e512c..00000000000
--- a/app/assets/javascripts/projects/commit/components/commit_comments_button.vue
+++ /dev/null
@@ -1,42 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { n__ } from '~/locale';
-
-export default {
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- components: {
- GlButton,
- },
- props: {
- commentsCount: {
- type: Number,
- required: true,
- },
- },
- computed: {
- tooltipText() {
- return n__('%d comment on this commit', '%d comments on this commit', this.commentsCount);
- },
- showCommentButton() {
- return this.commentsCount > 0;
- },
- },
-};
-</script>
-
-<template>
- <span
- v-if="showCommentButton"
- v-gl-tooltip
- class="gl-display-none gl-sm-display-inline-block"
- tabindex="0"
- :title="tooltipText"
- data-testid="comment-button-wrapper"
- >
- <gl-button icon="comment" class="gl-mr-3" disabled>
- {{ commentsCount }}
- </gl-button>
- </span>
-</template>
diff --git a/app/assets/javascripts/projects/commit/index.js b/app/assets/javascripts/projects/commit/index.js
index d8d30c4332c..4eb51d566c5 100644
--- a/app/assets/javascripts/projects/commit/index.js
+++ b/app/assets/javascripts/projects/commit/index.js
@@ -1,11 +1,9 @@
import initCherryPickCommitModal from './init_cherry_pick_commit_modal';
-import initCommitCommentsButton from './init_commit_comments_button';
import initCommitOptionsDropdown from './init_commit_options_dropdown';
import initRevertCommitModal from './init_revert_commit_modal';
export default () => {
initRevertCommitModal();
initCherryPickCommitModal();
- initCommitCommentsButton();
initCommitOptionsDropdown();
};
diff --git a/app/assets/javascripts/projects/commit/init_commit_comments_button.js b/app/assets/javascripts/projects/commit/init_commit_comments_button.js
deleted file mode 100644
index d70f7cb65f3..00000000000
--- a/app/assets/javascripts/projects/commit/init_commit_comments_button.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Vue from 'vue';
-import CommitCommentsButton from './components/commit_comments_button.vue';
-
-export default function initCommitCommentsButton() {
- const el = document.querySelector('#js-commit-comments-button');
-
- if (!el) {
- return false;
- }
-
- const { commentsCount } = el.dataset;
-
- return new Vue({
- el,
- render: (createElement) =>
- createElement(CommitCommentsButton, { props: { commentsCount: Number(commentsCount) } }),
- });
-}
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue
index 25af4cc8082..684ae5343b0 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue
@@ -107,7 +107,7 @@ export default {
</script>
<template>
- <div class="gl-ml-7">
+ <div>
<refs-list
v-if="hasBranches"
:has-containing-refs="hasContainingBranches"
diff --git a/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue b/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue
index 8ceab9cb60b..7a926c3f4e6 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue
@@ -1,6 +1,6 @@
<script>
import { GlCollapse, GlBadge, GlButton, GlIcon, GlSkeletonLoader } from '@gitlab/ui';
-import { CONTAINING_COMMIT, FETCH_CONTAINING_REFS_EVENT } from '../constants';
+import { CONTAINING_COMMIT, FETCH_CONTAINING_REFS_EVENT, BRANCHES_REF_TYPE } from '../constants';
export default {
name: 'RefsList',
@@ -55,6 +55,9 @@ export default {
isLoadingRefs() {
return this.isLoading && !this.containingRefs.length;
},
+ refIcon() {
+ return this.refType === BRANCHES_REF_TYPE ? 'branch' : 'tag';
+ },
},
methods: {
toggleCollapse() {
@@ -75,7 +78,8 @@ export default {
</script>
<template>
- <div class="gl-pt-4">
+ <div class="gl-p-5 gl-border-b gl-border-gray-50">
+ <gl-icon :name="refIcon" :size="14" class="gl-ml-2 gl-mr-3" />
<span data-testid="title" class="gl-mr-2">{{ namespace }}</span>
<gl-badge
v-for="ref in tippingRefs"
diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
index 84a2ddfce07..c9a502bb6d2 100644
--- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue
+++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
@@ -1,20 +1,11 @@
<script>
-import {
- GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
- GlTruncate,
-} from '@gitlab/ui';
+import { GlButton, GlButtonGroup, GlTruncate, GlCollapsibleListbox, GlIcon } from '@gitlab/ui';
import { joinPaths, PATH_SEPARATOR } from '~/lib/utils/url_utility';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
-import { s__ } from '~/locale';
+import { __, s__, n__ } from '~/locale';
import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql';
import eventHub from '../event_hub';
@@ -22,12 +13,9 @@ export default {
components: {
GlButton,
GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlDropdownSectionHeader,
- GlSearchBoxByType,
GlTruncate,
+ GlCollapsibleListbox,
+ GlIcon,
},
mixins: [Tracking.mixin()],
apollo: {
@@ -91,12 +79,61 @@ export default {
!this.groupPathToFilterBy
);
},
- hasNoMatches() {
- return !this.hasGroupMatches && !this.hasNamespaceMatches;
+ items() {
+ return this.groupsItems.concat(this.namespaceItems);
+ },
+ groupsItems() {
+ if (this.hasGroupMatches) {
+ return [
+ {
+ text: __('Groups'),
+ options: this.filteredGroups.map((group) => ({
+ value: group.id,
+ text: group.fullPath,
+ })),
+ },
+ ];
+ }
+
+ return [];
+ },
+ allItems() {
+ return this.filteredGroups.concat(this.currentUser.namespace);
+ },
+ namespaceItems() {
+ if (this.hasNamespaceMatches && this.userNamespaceUniqueId)
+ return [
+ {
+ text: __('Users'),
+ options: [
+ {
+ value: this.userNamespace.id,
+ text: this.userNamespace.fullPath,
+ },
+ ],
+ },
+ ];
+ return [];
},
dropdownPlaceholderClass() {
return this.selectedNamespace.id ? '' : 'gl-text-gray-500!';
},
+ dropdownText() {
+ if (this.selectedNamespace && this.selectedNamespace?.fullPath) {
+ return this.selectedNamespace.fullPath;
+ }
+ return null;
+ },
+ loading() {
+ return this.$apollo.queries.currentUser.loading;
+ },
+ searchSummary() {
+ return n__(
+ 'ProjectsNew|%d group or namespace found',
+ 'ProjectsNew|%d groups or namespaces found',
+ this.items.length,
+ );
+ },
},
created() {
eventHub.$on('select-template', this.handleSelectTemplate);
@@ -109,15 +146,18 @@ export default {
if (this.shouldSkipQuery) {
this.shouldSkipQuery = false;
}
- this.$refs.search.focusInput();
- },
- handleDropdownItemClick(namespace) {
- eventHub.$emit('update-visibility', {
- name: namespace.name,
- visibility: namespace.visibility,
- showPath: namespace.webUrl,
- editPath: joinPaths(namespace.webUrl, '-', 'edit'),
- });
+ },
+ handleDropdownItemClick(namespaceId) {
+ const namespace = this.allItems.find((item) => item.id === namespaceId);
+
+ if (namespace) {
+ eventHub.$emit('update-visibility', {
+ name: namespace.name,
+ visibility: namespace.visibility,
+ showPath: namespace.webUrl,
+ editPath: joinPaths(namespace.webUrl, '-', 'edit'),
+ });
+ }
this.setNamespace(namespace);
},
handleSelectTemplate(id, fullPath) {
@@ -137,6 +177,12 @@ export default {
this.track('activate_form_input', { label: this.trackLabel, property: 'project_path' });
}
},
+ onSearch(query) {
+ this.search = query;
+ },
+ },
+ i18n: {
+ emptySearchResult: __('No matches found'),
},
emptyNameSpace: {
id: undefined,
@@ -154,48 +200,38 @@ export default {
>{{ rootUrl }}</gl-button
>
- <gl-dropdown
- class="js-group-namespace-dropdown gl-flex-grow-1"
- :toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`"
+ <gl-collapsible-listbox
+ searchable
+ fluid-width
+ :searching="loading"
+ :items="items"
+ class="js-group-namespace-dropdown group-namespace-dropdown gl-flex-grow-1"
+ :toggle-text="dropdownText"
+ :no-results-text="$options.i18n.emptySearchResult"
data-testid="select-namespace-dropdown"
@show="trackDropdownShow"
@shown="handleDropdownShown"
+ @select="handleDropdownItemClick"
+ @search="onSearch"
>
- <template #button-text>
- <gl-truncate
- v-if="selectedNamespace.fullPath"
- :text="selectedNamespace.fullPath"
- position="start"
- with-tooltip
- />
+ <template #toggle>
+ <gl-button
+ class="gl-flex-basis-full! gl-rounded-left-none! gl-w-20"
+ :class="dropdownPlaceholderClass"
+ >
+ <gl-truncate
+ :text="dropdownText"
+ position="start"
+ class="gl-overflow-hidden gl-mr-auto"
+ with-tooltip
+ />
+ <gl-icon class="gl-button-icon dropdown-chevron gl-mr-0! gl-ml-2!" name="chevron-down" />
+ </gl-button>
</template>
- <gl-search-box-by-type
- ref="search"
- v-model.trim="search"
- :is-loading="$apollo.queries.currentUser.loading"
- data-testid="select-namespace-dropdown-search-field"
- />
- <template v-if="!$apollo.queries.currentUser.loading">
- <template v-if="hasGroupMatches">
- <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="group of filteredGroups"
- :key="group.id"
- @click="handleDropdownItemClick(group)"
- >
- {{ group.fullPath }}
- </gl-dropdown-item>
- </template>
- <template v-if="hasNamespaceMatches && userNamespaceUniqueId">
- <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="handleDropdownItemClick(userNamespace)">
- {{ userNamespace.fullPath }}
- </gl-dropdown-item>
- </template>
- <gl-dropdown-text v-if="hasNoMatches">{{ __('No matches found') }}</gl-dropdown-text>
+ <template #search-summary-sr-only>
+ {{ searchSummary }}
</template>
- </gl-dropdown>
-
+ </gl-collapsible-listbox>
<input type="hidden" name="project[selected_namespace_id]" :value="selectedNamespace.id" />
<input
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 2b5e2dcb301..9e71e662d70 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -70,7 +70,8 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr
};
const selectedNamespaceId = () => document.querySelector('[name="project[selected_namespace_id]"]');
-const dropdownButton = () => document.querySelector('.js-group-namespace-dropdown > button');
+const dropdownButton = () =>
+ document.querySelector('.js-group-namespace-dropdown .gl-new-dropdown-custom-toggle > button');
const namespaceButton = () => document.querySelector('.js-group-namespace-button');
const namespaceError = () => document.querySelector('.js-group-namespace-error');
diff --git a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
index df99aac6b9e..89ae2a82c6d 100644
--- a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
+++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
@@ -25,10 +25,11 @@ export const getUsers = (query, states) => {
});
};
-export const getGroups = () => {
+export const getGroups = ({ withProjectAccess = false }) => {
return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), {
params: {
project_id: gon.current_project_id,
+ with_project_access: withProjectAccess,
},
});
};
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
index 2dd7633e2c8..c863973cd2e 100644
--- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -94,6 +94,11 @@ export default {
required: false,
default: true,
},
+ groupsWithProjectAccess: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -229,7 +234,9 @@ export default {
Promise.all([
getDeployKeys(this.query),
getUsers(this.query),
- this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(),
+ this.groups.length
+ ? Promise.resolve({ data: this.groups })
+ : getGroups({ withProjectAccess: this.groupsWithProjectAccess }),
])
.then(([deployKeysResponse, usersResponse, groupsResponse]) => {
this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data);
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
index 7d9ad83a1c6..cb6bea76762 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -1,9 +1,20 @@
<script>
-import { GlButton, GlModal, GlModalDirective, GlCard, GlIcon } from '@gitlab/ui';
+import {
+ GlButton,
+ GlModal,
+ GlModalDirective,
+ GlCard,
+ GlIcon,
+ GlDisclosureDropdown,
+ GlCollapsibleListbox,
+ GlFormGroup,
+} from '@gitlab/ui';
import { createAlert } from '~/alert';
import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { expandSection } from '~/settings_panels';
import { scrollToElement } from '~/lib/utils/common_utils';
+import createBranchRuleMutation from './graphql/mutations/create_branch_rule.mutation.graphql';
import BranchRule from './components/branch_rule.vue';
import { I18N, PROTECTED_BRANCHES_ANCHOR, BRANCH_PROTECTION_MODAL_ID } from './constants';
@@ -14,12 +25,16 @@ export default {
BranchRule,
GlButton,
GlModal,
+ GlFormGroup,
GlCard,
GlIcon,
+ GlCollapsibleListbox,
+ GlDisclosureDropdown,
},
directives: {
GlModal: GlModalDirective,
},
+ mixins: [glFeatureFlagsMixin()],
apollo: {
branchRules: {
query: branchRulesQuery,
@@ -38,18 +53,87 @@ export default {
},
inject: {
projectPath: { default: '' },
+ branchRulesPath: { default: '' },
},
data() {
return {
branchRules: [],
+ branchRuleName: '',
+ searchQuery: '',
};
},
+ computed: {
+ addRuleItems() {
+ return [{ text: this.$options.i18n.branchName, action: () => this.openCreateRuleModal() }];
+ },
+ createRuleItems() {
+ return this.isWildcardAvailable ? [this.wildcardItem] : this.filteredOpenBranches;
+ },
+ filteredOpenBranches() {
+ const openBranches = window.gon.open_branches.map((item) => ({
+ text: item.text,
+ value: item.text,
+ }));
+ return openBranches.filter((item) => item.text.includes(this.searchQuery));
+ },
+ wildcardItem() {
+ return { text: this.$options.i18n.createWildcard, value: this.searchQuery };
+ },
+ isWildcardAvailable() {
+ return this.searchQuery.includes('*');
+ },
+ createRuleText() {
+ return this.branchRuleName || this.$options.i18n.branchNamePlaceholder;
+ },
+ branchRuleEditPath() {
+ return `${this.branchRulesPath}?branch=${encodeURIComponent(this.branchRuleName)}`;
+ },
+ primaryProps() {
+ return {
+ text: this.$options.i18n.createProtectedBranch,
+ attributes: {
+ variant: 'confirm',
+ disabled: !this.branchRuleName,
+ },
+ };
+ },
+ cancelProps() {
+ return {
+ text: this.$options.i18n.createBranchRule,
+ };
+ },
+ },
methods: {
showProtectedBranches() {
// Protected branches section is on the same page as the branch rules section.
expandSection(this.$options.protectedBranchesAnchor);
scrollToElement(this.$options.protectedBranchesAnchor);
},
+ openCreateRuleModal() {
+ this.$refs[this.$options.modalId].show();
+ },
+ handleBranchRuleSearch(query) {
+ this.searchQuery = query;
+ },
+ addBranchRule() {
+ this.$apollo
+ .mutate({
+ mutation: createBranchRuleMutation,
+ variables: {
+ projectPath: this.projectPath,
+ name: this.branchRuleName,
+ },
+ })
+ .then(() => {
+ window.location.assign(this.branchRuleEditPath);
+ })
+ .catch(() => {
+ createAlert({ message: this.$options.i18n.createBranchRuleError });
+ });
+ },
+ selectBranchRuleName(branchName) {
+ this.branchRuleName = branchName;
+ },
},
modalId: BRANCH_PROTECTION_MODAL_ID,
protectedBranchesAnchor: PROTECTED_BRANCHES_ANCHOR,
@@ -72,7 +156,15 @@ export default {
{{ branchRules.length }}
</div>
</div>
+ <gl-disclosure-dropdown
+ v-if="glFeatures.addBranchRule"
+ :toggle-text="$options.i18n.addBranchRule"
+ :items="addRuleItems"
+ size="small"
+ no-caret
+ />
<gl-button
+ v-else
v-gl-modal="$options.modalId"
size="small"
class="gl-ml-3"
@@ -99,6 +191,37 @@ export default {
</div>
</ul>
<gl-modal
+ v-if="glFeatures.addBranchRule"
+ :ref="$options.modalId"
+ :modal-id="$options.modalId"
+ :title="$options.i18n.createBranchRule"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="addBranchRule"
+ @change="searchQuery = ''"
+ >
+ <gl-form-group
+ :label="$options.i18n.branchName"
+ :description="$options.i18n.branchNameDescription"
+ >
+ <gl-collapsible-listbox
+ v-model="branchRuleName"
+ searchable
+ :items="createRuleItems"
+ :toggle-text="createRuleText"
+ block
+ @search="handleBranchRuleSearch"
+ @select="selectBranchRuleName"
+ >
+ <template v-if="isWildcardAvailable" #list-item="{ item }">
+ {{ item.text }}
+ <code>{{ searchQuery }}</code>
+ </template>
+ </gl-collapsible-listbox>
+ </gl-form-group>
+ </gl-modal>
+ <gl-modal
+ v-else
:ref="$options.modalId"
:modal-id="$options.modalId"
:title="$options.i18n.addBranchRule"
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js
index 4413d8eab4e..d16894e7d6f 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js
@@ -15,6 +15,16 @@ export const I18N = {
'BranchRules|After a protected branch is created, it will show up in the list as a branch rule.',
),
createProtectedBranch: s__('BranchRules|Create protected branch'),
+ createBranchRule: s__('BranchRules|Create branch rule'),
+ branchName: s__('BranchRules|Branch name or pattern'),
+ branchNamePlaceholder: s__('BranchRules|Select Branch or create wildcard'),
+ branchNameDescription: s__(
+ 'BranchRules|Wildcards such as *-stable or production/* are supported',
+ ),
+ createBranchRuleError: s__('BranchRules|Something went wrong while creating branch rule.'),
+ createBranchRuleSuccess: s__('BranchRules|Branch rule created.'),
+ createWildcard: s__('BranchRules|Create wildcard'),
+ cancel: s__('BranchRules|Cancel'),
};
export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings';
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/mutations/create_branch_rule.mutation.graphql b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/mutations/create_branch_rule.mutation.graphql
new file mode 100644
index 00000000000..e5fb79e0176
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/mutations/create_branch_rule.mutation.graphql
@@ -0,0 +1,8 @@
+mutation createBranchRule($projectPath: ID!, $name: String!) {
+ branchRuleCreate(input: { projectPath: $projectPath, name: $name }) {
+ errors
+ branchRule {
+ name
+ }
+ }
+}
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 49c55efca7b..612f801300e 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -83,6 +83,7 @@ export default class ProtectedBranchCreate {
block: true,
accessLevel,
accessLevelsData,
+ groupsWithProjectAccess: true,
testId,
});
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 66da3de516a..67ae33e1fc8 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -97,6 +97,7 @@ export default class ProtectedBranchEdit {
block: true,
accessLevel,
accessLevelsData,
+ groupsWithProjectAccess: true,
testId,
});
diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js
index b3754cecce4..e1cd069fd92 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_create.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -40,6 +40,7 @@ export default class ProtectedTagCreate {
hasLicense: this.hasLicense,
accessLevel: ACCESS_LEVELS.CREATE,
accessLevelsData: gon.create_access_levels,
+ groupsWithProjectAccess: true,
searchEnabled: dropdownEl.dataset.filter !== undefined,
testId: 'allowed-to-create-dropdown',
});
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.vue b/app/assets/javascripts/protected_tags/protected_tag_edit.vue
index 7fe1dc9c01a..d5ec88c171c 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit.vue
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.vue
@@ -107,6 +107,7 @@ export default {
:access-levels-data="accessLevelsData"
:preselected-items="selected"
:search-enabled="searchEnabled"
+ groups-with-project-access
:block="true"
@hidden="updatePermissions"
/>
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 228007dd7d6..6fce9b4a129 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -55,6 +55,7 @@ export default {
'groupId',
'groupMilestonesAvailable',
'tagNotes',
+ 'isFetchingTagNotes',
]),
...mapGetters('editNew', ['isValid', 'formattedReleaseNotes']),
showForm() {
@@ -113,7 +114,7 @@ export default {
return this.isExistingRelease ? __('Save changes') : __('Create release');
},
isFormSubmissionDisabled() {
- return this.isUpdatingRelease || !this.isValid;
+ return this.isUpdatingRelease || !this.isValid || this.isFetchingTagNotes;
},
milestoneComboboxExtraLinks() {
return [
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index eebaeeea286..31c7ed8482d 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,13 +1,15 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlAlert, GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { historyPushState } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
import { convertAllReleasesGraphQLResponse } from '~/releases/util';
import { popDeleteReleaseNotification } from '~/releases/release_notification_service';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import getCiCatalogSettingsQuery from '~/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql';
import allReleasesQuery from '../graphql/queries/all_releases.query.graphql';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
@@ -18,7 +20,10 @@ import ReleasesSort from './releases_sort.vue';
export default {
name: 'ReleasesIndexApp',
components: {
+ GlAlert,
GlButton,
+ GlLink,
+ GlSprintf,
ReleaseBlock,
ReleaseSkeletonLoader,
ReleasesEmptyState,
@@ -79,6 +84,20 @@ export default {
});
},
},
+ isCatalogResource: {
+ query: getCiCatalogSettingsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ };
+ },
+ update({ project }) {
+ return project?.isCatalogResource || false;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.catalogResourceQueryError });
+ },
+ },
},
data() {
return {
@@ -227,13 +246,44 @@ export default {
},
},
i18n: {
- newRelease: __('New release'),
+ alertButtonLink: helpPagePath('ci/components/index', { anchor: 'release-a-component' }),
+ alertButtonText: __('View the publishing guide'),
+ alertInfoMessage: s__(
+ 'CiCatalog|The CI/CD components in this project can be published in the CI/CD Catalog by creating a release. We recommend using the %{linkStart}release%{linkEnd} keyword in a CI/CD job to release new component versions for the Catalog.',
+ ),
+ alertInfoMessageLink: helpPagePath('ci/yaml/index.html', { anchor: 'release' }),
+ alertTitle: __('Publish the CI/CD components in this project to the CI/CD Catalog'),
+ catalogResourceQueryError: s__(
+ 'CiCatalog|There was a problem fetching the CI/CD Catalog setting.',
+ ),
errorMessage: __('An error occurred while fetching the releases. Please try again.'),
+ newRelease: __('New release'),
},
};
</script>
<template>
<div class="gl-display-flex gl-flex-direction-column gl-mt-3">
+ <gl-alert
+ v-if="isCatalogResource"
+ :title="$options.i18n.alertTitle"
+ :primary-button-text="$options.i18n.alertButtonText"
+ :primary-button-link="$options.i18n.alertButtonLink"
+ :dismissible="false"
+ variant="warning"
+ class="mb-3 mt-2"
+ >
+ <gl-sprintf :message="$options.i18n.alertInfoMessage">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.i18n.alertInfoMessageLink"
+ target="_blank"
+ class="gl-text-decoration-none!"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<releases-empty-state v-if="shouldRenderEmptyState" />
<div v-else class="gl-align-self-end gl-mb-3">
<releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" />
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 04f3d73235b..370e920be02 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -43,6 +43,9 @@ export default {
return this.newTagName ? this.$options.i18n.createTag : this.$options.i18n.typeNew;
},
},
+ mounted() {
+ this.newTagName = this.release?.tagName || '';
+ },
methods: {
...mapActions('editNew', [
'setSearching',
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 8bdfb057adc..a0d782a02a1 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -3,6 +3,7 @@ import { getTag } from '~/rest_api';
import { createAlert } from '~/alert';
import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import AccessorUtilities from '~/lib/utils/accessor';
+import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import { s__ } from '~/locale';
import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql';
import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql';
@@ -245,7 +246,7 @@ export const updateRelease = async ({ commit, dispatch, state, getters }) => {
}
};
-export const fetchTagNotes = ({ commit, state }, tagName) => {
+export const fetchTagNotes = ({ commit, state, dispatch }, tagName) => {
commit(types.REQUEST_TAG_NOTES);
return getTag(state.projectId, tagName)
@@ -253,11 +254,15 @@ export const fetchTagNotes = ({ commit, state }, tagName) => {
commit(types.RECEIVE_TAG_NOTES_SUCCESS, data);
})
.catch((error) => {
+ if (error?.response?.status === HTTP_STATUS_NOT_FOUND) {
+ commit(types.RECEIVE_TAG_NOTES_SUCCESS, {});
+ return Promise.all([dispatch('setNewTag'), dispatch('setCreating')]);
+ }
createAlert({
message: s__('Release|Unable to fetch the tag notes.'),
});
- commit(types.RECEIVE_TAG_NOTES_ERROR, error);
+ return commit(types.RECEIVE_TAG_NOTES_ERROR, error);
});
};
@@ -326,7 +331,7 @@ export const clearDraftRelease = ({ getters }) => {
}
};
-export const loadDraftRelease = ({ commit, getters, state }) => {
+export const loadDraftRelease = ({ commit, getters, state, dispatch }) => {
try {
const release = window.localStorage.getItem(getters.localStorageKey);
const createFrom = window.localStorage.getItem(getters.localStorageCreateFromKey);
@@ -340,6 +345,10 @@ export const loadDraftRelease = ({ commit, getters, state }) => {
: state.originalReleasedAt,
});
commit(types.UPDATE_CREATE_FROM, JSON.parse(createFrom));
+
+ if (parsedRelease.tagName) {
+ dispatch('fetchTagNotes', parsedRelease.tagName);
+ }
} else {
commit(types.INITIALIZE_EMPTY_RELEASE);
}
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index 0b37c2b81d1..d1cde8b9029 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -170,13 +170,11 @@ export const releaseDeleteMutationVariables = (state) => ({
},
});
-export const formattedReleaseNotes = ({
- includeTagNotes,
- release: { description, tagMessage },
- tagNotes,
- showCreateFrom,
-}) => {
- const notes = showCreateFrom ? tagMessage : tagNotes;
+export const formattedReleaseNotes = (
+ { includeTagNotes, release: { description, tagMessage }, tagNotes },
+ { isNewTag },
+) => {
+ const notes = isNewTag ? tagMessage : tagNotes;
return includeTagNotes && notes
? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${notes}\n`
: description;
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
index 7bd3968dd93..a02949568b2 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
@@ -61,6 +61,7 @@ export default ({
updateError: null,
tagNotes: '',
+ isFetchingTagNotes: false,
includeTagNotes: false,
existingRelease: null,
originalReleasedAt: new Date(),
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index 4ca625bc0de..c9ac5a19697 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -11,7 +11,7 @@ import {
} from '@gitlab/ui';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
-import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
+import { contentTypeMultipartFormData } from '~/lib/utils/headers';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -159,7 +159,7 @@ export default {
url,
data: this.formData(),
headers: {
- ...ContentTypeMultipartFormData,
+ ...contentTypeMultipartFormData,
},
})
.then((response) => {
diff --git a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue
index 874803a720d..eb56113a4dd 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue
@@ -1,6 +1,6 @@
<script>
// eslint-disable-next-line no-restricted-imports
-import { mapActions, mapState, mapGetters } from 'vuex';
+import { mapActions, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import eventHub from '~/super_sidebar/event_hub';
import NavItem from '~/super_sidebar/components/nav_item.vue';
@@ -15,15 +15,11 @@ export default {
NavItem,
},
computed: {
- ...mapState(['navigation', 'urlQuery']),
...mapGetters(['navigationItems']),
},
created() {
eventHub.$emit('toggle-menu-header', false);
-
- if (this.urlQuery?.search) {
- this.fetchSidebarCount();
- }
+ this.fetchSidebarCount();
},
methods: {
...mapActions(['fetchSidebarCount']),
diff --git a/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue b/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue
index c1f0bfc59f3..32327a39de0 100644
--- a/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue
+++ b/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue
@@ -185,6 +185,7 @@ export default {
:searching="loading"
:reset-button-label="$options.i18n.reset"
:toggle-aria-labelled-by="labelId"
+ fluid-width
searchable
block
@shown="openDropdown"
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index 211bbaf82cd..d5e275b8a19 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -1,7 +1,13 @@
import Api from '~/api';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
-import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import {
+ visitUrl,
+ setUrlParams,
+ getBaseURL,
+ queryToObject,
+ objectToQuery,
+} from '~/lib/utils/url_utility';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
@@ -135,19 +141,38 @@ export const setLabelFilterSearch = ({ commit }, { value }) => {
commit(types.SET_LABEL_SEARCH_STRING, value);
};
+const injectWildCardSearch = (state, link) => {
+ const urlObject = new URL(`${getBaseURL()}${link}`);
+ if (!state.urlQuery.search) {
+ const queryObject = queryToObject(urlObject.search);
+ urlObject.search = objectToQuery({ ...queryObject, search: '*' });
+ }
+
+ return urlObject.href;
+};
+
export const fetchSidebarCount = ({ commit, state }) => {
- const promises = Object.values(state.navigation).map((navItem) => {
- // active nav item has count already so we skip it
- if (!navItem.active && navItem.count_link) {
- return axios
- .get(navItem.count_link)
- .then(({ data: { count } }) => {
- commit(types.RECEIVE_NAVIGATION_COUNT, { key: navItem.scope, count });
- })
- .catch((e) => logError(e));
- }
- return Promise.resolve();
- });
+ const items = Object.values(state.navigation)
+ .filter((navigationItem) => !navigationItem.active && navigationItem.count_link)
+ .map((navItem) => {
+ const navigationItem = { ...navItem };
+
+ if (navigationItem.count_link) {
+ navigationItem.count_link = injectWildCardSearch(state, navigationItem.count_link);
+ }
+
+ return navigationItem;
+ });
+
+ const promises = items.map((navigationItem) =>
+ axios
+ .get(navigationItem.count_link)
+ .then(({ data: { count } }) => {
+ commit(types.RECEIVE_NAVIGATION_COUNT, { key: navigationItem.scope, count });
+ })
+ .catch((e) => logError(e)),
+ );
+
return Promise.all(promises);
};
diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js
index 94bcf81a3eb..f8ab8b4685a 100644
--- a/app/assets/javascripts/security_configuration/constants.js
+++ b/app/assets/javascripts/security_configuration/constants.js
@@ -7,15 +7,7 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_SAST_IAC,
- REPORT_TYPE_DAST,
- REPORT_TYPE_DAST_PROFILES,
- REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
REPORT_TYPE_SECRET_DETECTION,
- REPORT_TYPE_DEPENDENCY_SCANNING,
- REPORT_TYPE_CONTAINER_SCANNING,
- REPORT_TYPE_COVERAGE_FUZZING,
- REPORT_TYPE_CORPUS_MANAGEMENT,
- REPORT_TYPE_API_FUZZING,
} from '~/vue_shared/security_reports/constants';
import configureSastMutation from './graphql/configure_sast.mutation.graphql';
@@ -23,132 +15,35 @@ import configureSastIacMutation from './graphql/configure_iac.mutation.graphql';
import configureSecretDetectionMutation from './graphql/configure_secret_detection.mutation.graphql';
/**
- * Translations & helpPagePaths for Security Configuration Page
+ * Translations for Security Configuration Page
* Make sure to add new scanner translations to the SCANNER_NAMES_MAP below.
*/
-
export const SAST_NAME = __('Static Application Security Testing (SAST)');
export const SAST_SHORT_NAME = s__('ciReport|SAST');
-export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.');
-export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index');
-export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sast/index', {
- anchor: 'configuration',
-});
export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning');
export const SAST_IAC_SHORT_NAME = s__('ciReport|SAST IaC');
-export const SAST_IAC_DESCRIPTION = __(
- 'Analyze your infrastructure as code configuration files for known vulnerabilities.',
-);
-export const SAST_IAC_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index');
-export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath(
- 'user/application_security/iac_scanning/index',
- {
- anchor: 'configuration',
- },
-);
export const DAST_NAME = __('Dynamic Application Security Testing (DAST)');
export const DAST_SHORT_NAME = s__('ciReport|DAST');
-export const DAST_DESCRIPTION = s__(
- 'ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running.',
-);
-export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index');
-export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', {
- anchor: 'enable-automatic-dast-run',
-});
-export const DAST_BADGE_TEXT = __('Available on demand');
-export const DAST_BADGE_TOOLTIP = __(
- 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects',
-);
export const DAST_PROFILES_NAME = __('DAST profiles');
-export const DAST_PROFILES_DESCRIPTION = s__(
- 'SecurityConfiguration|Manage profiles for use by DAST scans.',
-);
-export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles');
+export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index');
-export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature');
-export const BAS_BADGE_TOOLTIP = s__(
- 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.',
-);
-export const BAS_DESCRIPTION = s__(
- 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.',
-);
-export const BAS_HELP_PATH = helpPagePath(
- 'user/application_security/breach_and_attack_simulation/index',
-);
export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)');
export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS');
-export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__(
- 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.',
-);
-export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath(
- 'user/application_security/breach_and_attack_simulation/index',
- { anchor: 'extend-dynamic-application-security-testing-dast' },
-);
-export const BAS_DAST_FEATURE_FLAG_NAME = s__(
- 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)',
-);
-
export const SECRET_DETECTION_NAME = __('Secret Detection');
-export const SECRET_DETECTION_DESCRIPTION = __(
- 'Analyze your source code and git history for secrets.',
-);
-export const SECRET_DETECTION_HELP_PATH = helpPagePath(
- 'user/application_security/secret_detection/index',
-);
-export const SECRET_DETECTION_CONFIG_HELP_PATH = helpPagePath(
- 'user/application_security/secret_detection/index',
- { anchor: 'configuration' },
-);
export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning');
-export const DEPENDENCY_SCANNING_DESCRIPTION = __(
- 'Analyze your dependencies for known vulnerabilities.',
-);
-export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath(
- 'user/application_security/dependency_scanning/index',
-);
-export const DEPENDENCY_SCANNING_CONFIG_HELP_PATH = helpPagePath(
- 'user/application_security/dependency_scanning/index',
- { anchor: 'configuration' },
-);
export const CONTAINER_SCANNING_NAME = __('Container Scanning');
-export const CONTAINER_SCANNING_DESCRIPTION = __(
- 'Check your Docker images for known vulnerabilities.',
-);
-export const CONTAINER_SCANNING_HELP_PATH = helpPagePath(
- 'user/application_security/container_scanning/index',
-);
-export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath(
- 'user/application_security/container_scanning/index',
- { anchor: 'configuration' },
-);
export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing');
-export const COVERAGE_FUZZING_DESCRIPTION = __(
- 'Find bugs in your code with coverage-guided fuzzing.',
-);
-export const COVERAGE_FUZZING_HELP_PATH = helpPagePath(
- 'user/application_security/coverage_fuzzing/index',
-);
-export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath(
- 'user/application_security/coverage_fuzzing/index',
- { anchor: 'enable-coverage-guided-fuzz-testing' },
-);
export const CORPUS_MANAGEMENT_NAME = __('Corpus Management');
-export const CORPUS_MANAGEMENT_DESCRIPTION = s__(
- 'SecurityConfiguration|Manage corpus files used as seed inputs with coverage-guided fuzzing.',
-);
-export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus');
export const API_FUZZING_NAME = __('API Fuzzing');
-export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.');
-export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index');
export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning');
@@ -166,105 +61,6 @@ export const SCANNER_NAMES_MAP = {
GENERIC: s__('ciReport|Manually added'),
};
-export const securityFeatures = [
- {
- name: SAST_NAME,
- shortName: SAST_SHORT_NAME,
- description: SAST_DESCRIPTION,
- helpPath: SAST_HELP_PATH,
- configurationHelpPath: SAST_CONFIG_HELP_PATH,
- type: REPORT_TYPE_SAST,
- },
- {
- name: SAST_IAC_NAME,
- shortName: SAST_IAC_SHORT_NAME,
- description: SAST_IAC_DESCRIPTION,
- helpPath: SAST_IAC_HELP_PATH,
- configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH,
- type: REPORT_TYPE_SAST_IAC,
- },
- {
- badge: {
- text: DAST_BADGE_TEXT,
- tooltipText: DAST_BADGE_TOOLTIP,
- variant: 'info',
- },
- secondary: {
- type: REPORT_TYPE_DAST_PROFILES,
- name: DAST_PROFILES_NAME,
- description: DAST_PROFILES_DESCRIPTION,
- configurationText: DAST_PROFILES_CONFIG_TEXT,
- },
- name: DAST_NAME,
- shortName: DAST_SHORT_NAME,
- description: DAST_DESCRIPTION,
- helpPath: DAST_HELP_PATH,
- configurationHelpPath: DAST_CONFIG_HELP_PATH,
- type: REPORT_TYPE_DAST,
- anchor: 'dast',
- },
- {
- name: DEPENDENCY_SCANNING_NAME,
- description: DEPENDENCY_SCANNING_DESCRIPTION,
- helpPath: DEPENDENCY_SCANNING_HELP_PATH,
- configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH,
- type: REPORT_TYPE_DEPENDENCY_SCANNING,
- anchor: 'dependency-scanning',
- },
- {
- name: CONTAINER_SCANNING_NAME,
- description: CONTAINER_SCANNING_DESCRIPTION,
- helpPath: CONTAINER_SCANNING_HELP_PATH,
- configurationHelpPath: CONTAINER_SCANNING_CONFIG_HELP_PATH,
- type: REPORT_TYPE_CONTAINER_SCANNING,
- },
- {
- name: SECRET_DETECTION_NAME,
- description: SECRET_DETECTION_DESCRIPTION,
- helpPath: SECRET_DETECTION_HELP_PATH,
- configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH,
- type: REPORT_TYPE_SECRET_DETECTION,
- },
- {
- name: API_FUZZING_NAME,
- description: API_FUZZING_DESCRIPTION,
- helpPath: API_FUZZING_HELP_PATH,
- type: REPORT_TYPE_API_FUZZING,
- },
- {
- name: COVERAGE_FUZZING_NAME,
- description: COVERAGE_FUZZING_DESCRIPTION,
- helpPath: COVERAGE_FUZZING_HELP_PATH,
- configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH,
- type: REPORT_TYPE_COVERAGE_FUZZING,
- secondary: {
- type: REPORT_TYPE_CORPUS_MANAGEMENT,
- name: CORPUS_MANAGEMENT_NAME,
- description: CORPUS_MANAGEMENT_DESCRIPTION,
- configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
- },
- },
- {
- anchor: 'bas',
- badge: {
- alwaysDisplay: true,
- text: BAS_BADGE_TEXT,
- tooltipText: BAS_BADGE_TOOLTIP,
- variant: 'info',
- },
- description: BAS_DESCRIPTION,
- name: BAS_NAME,
- helpPath: BAS_HELP_PATH,
- secondary: {
- configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH,
- description: BAS_DAST_FEATURE_FLAG_DESCRIPTION,
- name: BAS_DAST_FEATURE_FLAG_NAME,
- },
- shortName: BAS_SHORT_NAME,
- type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
- },
-];
-
export const featureToMutationMap = {
[REPORT_TYPE_SAST]: {
mutationId: 'configureSast',
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index 8086b200891..40c82661305 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -3,7 +3,6 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils';
import SecurityConfigurationApp from './components/app.vue';
-import { securityFeatures } from './constants';
import { augmentFeatures } from './utils';
export const initSecurityConfiguration = (el) => {
@@ -28,10 +27,7 @@ export const initSecurityConfiguration = (el) => {
vulnerabilityTrainingDocsPath,
} = el.dataset;
- const { augmentedSecurityFeatures } = augmentFeatures(
- securityFeatures,
- features ? JSON.parse(features) : [],
- );
+ const { augmentedSecurityFeatures } = augmentFeatures(features ? JSON.parse(features) : []);
return new Vue({
el,
diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js
index 59b49cb3820..23f86b30445 100644
--- a/app/assets/javascripts/security_configuration/utils.js
+++ b/app/assets/javascripts/security_configuration/utils.js
@@ -1,33 +1,41 @@
+import { isEmpty } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { SCANNER_NAMES_MAP } from '~/security_configuration/constants';
import { REPORT_TYPE_DAST } from '~/vue_shared/security_reports/constants';
/**
- * This function takes in 3 arrays of objects, securityFeatures and features.
- * securityFeatures are static arrays living in the constants.
+ * This function takes in a arrays of features.
* features is dynamic and coming from the backend.
- * This function builds a superset of those arrays.
- * It looks for matching keys within the dynamic and the static arrays
- * and will enrich the objects with the available static data.
- * @param [{}] securityFeatures
+ * securityFeatures is nested in features and are static arrays living in backend constants
+ * This function takes the nested securityFeatures config and flattens it to the top level object.
+ * It then filters out any scanner features that lack a security config for rednering in the UI
* @param [{}] features
* @returns {Object} Object with enriched features from constants divided into Security and Compliance Features
*/
-export const augmentFeatures = (securityFeatures, features = []) => {
+export const augmentFeatures = (features = []) => {
const featuresByType = features.reduce((acc, feature) => {
acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true });
return acc;
}, {});
+ /**
+ * Track feature configs that are used as nested elements in the UI
+ * so they aren't rendered at the top level as a seperate card
+ */
+ const secondaryFeatures = [];
+
+ // Modify each feature
const augmentFeature = (feature) => {
const augmented = {
...feature,
...featuresByType[feature.type],
};
+ // Secondary layer copies some values from the first layer
if (augmented.secondary) {
augmented.secondary = { ...augmented.secondary, ...featuresByType[feature.secondary.type] };
+ secondaryFeatures.push(feature.secondary.type);
}
if (augmented.type === REPORT_TYPE_DAST && !augmented.onDemandAvailable) {
@@ -41,8 +49,20 @@ export const augmentFeatures = (securityFeatures, features = []) => {
return augmented;
};
+ // Filter out any features that lack a security feature definition or is used as a nested UI element
+ const filterFeatures = (feature) => {
+ return !secondaryFeatures.includes(feature.type) && !isEmpty(feature.securityFeatures || {});
+ };
+
+ // Convert backend provided properties to camelCase, and spread nested security config to the root
+ // level for UI rendering.
+ const flattenFeatures = (feature) => {
+ const flattenedFeature = convertObjectPropsToCamelCase(feature, { deep: true });
+ return augmentFeature({ ...flattenedFeature, ...flattenedFeature.securityFeatures });
+ };
+
return {
- augmentedSecurityFeatures: securityFeatures.map((feature) => augmentFeature(feature)),
+ augmentedSecurityFeatures: features.map(flattenFeatures).filter(filterFeatures),
};
};
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index 4ff12824008..0ac6208c7d3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -6,6 +6,7 @@ import { TYPE_ALERT, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, n__ } from '~/locale';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ISSUE_MR_CHANGE_ASSIGNEE } from '~/behaviors/shortcuts/keybindings';
import { assigneesQueries } from '../../queries/constants';
import SidebarEditableItem from '../sidebar_editable_item.vue';
import SidebarAssigneesRealtime from './assignees_realtime.vue';
@@ -156,6 +157,12 @@ export default {
issuableAuthor() {
return this.issuable?.author;
},
+ assigneeShortcutDescription() {
+ return ISSUE_MR_CHANGE_ASSIGNEE.description;
+ },
+ assigneeShortcutKey() {
+ return ISSUE_MR_CHANGE_ASSIGNEE.defaultKeys[0];
+ },
},
watch: {
iid(_, oldIid) {
@@ -246,6 +253,9 @@ export default {
:loading="isSettingAssignees"
:initial-loading="isAssigneesLoading"
:title="assigneeText"
+ :edit-tooltip="`${assigneeShortcutDescription} <kbd class='flat ml-1' aria-hidden=true>${assigneeShortcutKey}</kbd>`"
+ :edit-aria-label="assigneeShortcutDescription"
+ :edit-keyshortcuts="assigneeShortcutKey"
:is-dirty="isDirty"
@open="showDropdown"
@close="saveAssignees"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
index 93e3cfba309..f3209d1a02f 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
@@ -1,17 +1,11 @@
<script>
import { get } from 'lodash';
-import {
- GlAlert,
- GlTooltipDirective,
- GlButton,
- GlFormInput,
- GlLink,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlAlert, GlTooltipDirective, GlButton, GlFormInput, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer';
import { createAlert } from '~/alert';
import { WORKSPACE_GROUP } from '~/issues/constants';
import { __ } from '~/locale';
+import SidebarColorPicker from '../../sidebar_color_picker.vue';
import { workspaceLabelsQueries, workspaceCreateLabelMutation } from '../../../queries/constants';
import { DEFAULT_LABEL_COLOR } from './constants';
@@ -22,8 +16,8 @@ export default {
GlAlert,
GlButton,
GlFormInput,
- GlLink,
GlLoadingIcon,
+ SidebarColorPicker,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -84,15 +78,6 @@ export default {
},
},
methods: {
- getColorCode(color) {
- return Object.keys(color).pop();
- },
- getColorName(color) {
- return Object.values(color).pop();
- },
- handleColorClick(color) {
- this.selectedColor = this.getColorCode(color);
- },
updateLabelsInCache(store, label) {
const { query, dataPath } = workspaceLabelsQueries[this.workspaceType];
@@ -163,34 +148,7 @@ export default {
data-testid="label-title-input"
/>
</div>
- <div class="dropdown-content gl-px-3">
- <div class="suggest-colors suggest-colors-dropdown gl-mt-0! gl-mb-3! gl-mb-0">
- <gl-link
- v-for="(color, index) in suggestedColors"
- :key="index"
- v-gl-tooltip:tooltipcontainer
- :style="{ backgroundColor: getColorCode(color) }"
- :title="getColorName(color)"
- @click.prevent="handleColorClick(color)"
- />
- </div>
- <div class="color-input-container gl-display-flex">
- <gl-form-input
- v-model.trim="selectedColor"
- class="gl-rounded-top-right-none gl-rounded-bottom-right-none gl-mr-n1 gl-mb-2 gl-w-8"
- type="color"
- :value="selectedColor"
- :placeholder="__('Select color')"
- data-testid="selected-color"
- />
- <gl-form-input
- v-model.trim="selectedColor"
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
- :placeholder="__('Use custom color #FF0000')"
- data-testid="selected-color-text"
- />
- </div>
- </div>
+ <sidebar-color-picker v-model.trim="selectedColor" />
<div class="dropdown-actions gl-display-flex gl-justify-content-space-between gl-pt-3 gl-px-3">
<gl-button
:disabled="disableCreate"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
index e0d7400f7a6..cbaa8a59813 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
@@ -1,12 +1,13 @@
<script>
import { debounce } from 'lodash';
import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
-import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { mutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { createAlert } from '~/alert';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_TEST_CASE } from '~/issues/constants';
import { __ } from '~/locale';
+import { ISSUABLE_CHANGE_LABEL } from '~/behaviors/shortcuts/keybindings';
import { issuableLabelsQueries } from '../../../queries/constants';
import SidebarEditableItem from '../../sidebar_editable_item.vue';
import { DEBOUNCE_DROPDOWN_DELAY, VARIANT_SIDEBAR } from './constants';
@@ -159,6 +160,12 @@ export default {
isLockOnMergeSupported() {
return this.issuableSupportsLockOnMerge || this.issuable?.supportsLockOnMerge;
},
+ labelShortcutDescription() {
+ return ISSUABLE_CHANGE_LABEL.description;
+ },
+ labelShortcutKey() {
+ return ISSUABLE_CHANGE_LABEL.defaultKeys[0];
+ },
},
apollo: {
issuable: {
@@ -275,7 +282,7 @@ export default {
case TYPE_MERGE_REQUEST:
return {
...updateVariables,
- operationMode: MutationOperationMode.Replace,
+ operationMode: mutationOperationMode.replace,
};
case TYPE_EPIC:
return {
@@ -336,7 +343,7 @@ export default {
return {
...removeVariables,
labelIds: [labelId],
- operationMode: MutationOperationMode.Remove,
+ operationMode: mutationOperationMode.remove,
};
case TYPE_EPIC:
return {
@@ -375,6 +382,9 @@ export default {
<sidebar-editable-item
ref="editable"
:title="__('Labels')"
+ :edit-tooltip="`${labelShortcutDescription} <kbd class='flat ml-1' aria-hidden=true>${labelShortcutKey}</kbd>`"
+ :edit-aria-label="labelShortcutDescription"
+ :edit-keyshortcuts="labelShortcutKey"
:loading="isLoading"
:can-edit="allowLabelEdit"
@open="oldIid = null"
diff --git a/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue b/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue
new file mode 100644
index 00000000000..95b1febb575
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlFormInput, GlLink, GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormInput,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ suggestedColors() {
+ const colorsMap = gon.suggested_label_colors;
+ return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
+ },
+ selectedColor: {
+ get() {
+ return this.value;
+ },
+ set(color) {
+ this.handleColorClick(color);
+ },
+ },
+ },
+ methods: {
+ handleColorClick(color) {
+ this.$emit('input', color);
+ },
+ getColorCode(color) {
+ return Object.keys(color).pop();
+ },
+ getColorName(color) {
+ return Object.values(color).pop();
+ },
+ },
+};
+</script>
+<template>
+ <div class="dropdown-content gl-px-3">
+ <div class="suggest-colors suggest-colors-dropdown gl-mt-0!">
+ <gl-link
+ v-for="(color, index) in suggestedColors"
+ :key="index"
+ v-gl-tooltip:tooltipcontainer
+ :style="{ backgroundColor: getColorCode(color) }"
+ :title="getColorName(color)"
+ @click.prevent="handleColorClick(getColorCode(color))"
+ />
+ </div>
+ <div class="color-input-container gl-display-flex">
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-right-none gl-rounded-bottom-right-none gl-mr-n1 gl-mb-2 gl-w-8"
+ type="color"
+ :value="selectedColor"
+ :placeholder="__('Select color')"
+ data-testid="selected-color"
+ />
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
+ :placeholder="__('Use custom color #FF0000')"
+ data-testid="selected-color-text"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index ad83866ceb2..c887d5d292e 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
@@ -7,6 +7,9 @@ export default {
unassigned: __('Unassigned'),
},
components: { GlButton, GlLoadingIcon },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
inject: {
canUpdate: {},
isClassicSidebar: {
@@ -58,6 +61,21 @@ export default {
required: false,
default: false,
},
+ editTooltip: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ editAriaLabel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ editKeyshortcuts: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -68,6 +86,15 @@ export default {
editButtonText() {
return this.isDirty ? __('Apply') : __('Edit');
},
+ editTooltipText() {
+ return this.isDirty ? '' : this.editTooltip;
+ },
+ editAriaLabelText() {
+ return this.isDirty ? this.editButtonText : this.editAriaLabel;
+ },
+ editKeyshortcutsText() {
+ return this.isDirty ? __('Escape') : this.editKeyshortcuts;
+ },
},
destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick);
@@ -150,9 +177,13 @@ export default {
<gl-button
v-if="canUpdate && !initialLoading && canEdit"
:id="buttonId"
+ v-gl-tooltip.viewport.html
category="tertiary"
size="small"
class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2 shortcut-sidebar-dropdown-toggle"
+ :title="editTooltipText"
+ :aria-label="editAriaLabelText"
+ :aria-keyshortcuts="editKeyshortcutsText"
data-testid="edit-button"
:data-track-action="tracking.event"
:data-track-label="tracking.label"
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
index f2257adb79c..3dfe5b6cc15 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
@@ -6,7 +6,7 @@ import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking';
-import { TodoMutationTypes } from '../../constants';
+import { todoMutationTypes } from '../../constants';
import { todoQueries, todoMutations } from '../../queries/constants';
import { todoLabel } from '../../utils';
import TodoButton from './todo_button.vue';
@@ -104,9 +104,9 @@ export default {
},
todoMutationType() {
if (this.hasTodo) {
- return TodoMutationTypes.MarkDone;
+ return todoMutationTypes.markDone;
}
- return TodoMutationTypes.Create;
+ return todoMutationTypes.create;
},
collapsedButtonIcon() {
return this.hasTodo ? 'todo-done' : 'todo-add';
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index f13f613733b..953684b5c93 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -44,9 +44,9 @@ export const IssuableAttributeState = {
[IssuableAttributeType.Milestone]: 'active',
};
-export const TodoMutationTypes = {
- Create: 'create',
- MarkDone: 'mark-done',
+export const todoMutationTypes = {
+ create: 'create',
+ markDone: 'mark-done',
};
export function dropdowni18nText(issuableAttribute, issuableType) {
diff --git a/app/assets/javascripts/sidebar/queries/constants.js b/app/assets/javascripts/sidebar/queries/constants.js
index 6bcdc01a003..dbb2f14880a 100644
--- a/app/assets/javascripts/sidebar/queries/constants.js
+++ b/app/assets/javascripts/sidebar/queries/constants.js
@@ -22,7 +22,7 @@ import groupLabelsQuery from '../components/labels/labels_select_widget/graphql/
import issueLabelsQuery from '../components/labels/labels_select_widget/graphql/issue_labels.query.graphql';
import mergeRequestLabelsQuery from '../components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql';
import projectLabelsQuery from '../components/labels/labels_select_widget/graphql/project_labels.query.graphql';
-import { IssuableAttributeType, TodoMutationTypes } from '../constants';
+import { IssuableAttributeType, todoMutationTypes } from '../constants';
import epicConfidentialQuery from './epic_confidential.query.graphql';
import epicDueDateQuery from './epic_due_date.query.graphql';
import epicParticipantsQuery from './epic_participants.query.graphql';
@@ -282,8 +282,8 @@ export const todoQueries = {
};
export const todoMutations = {
- [TodoMutationTypes.Create]: todoCreateMutation,
- [TodoMutationTypes.MarkDone]: todoMarkDoneMutation,
+ [todoMutationTypes.create]: todoCreateMutation,
+ [todoMutationTypes.markDone]: todoMarkDoneMutation,
};
export const escalationStatusQuery = getEscalationStatusQuery;
diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
index 05040218164..2be9f9e9f7d 100644
--- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue
+++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
@@ -116,7 +116,11 @@ export default {
@pin-remove="onPinRemove(item.id, item.title)"
/>
</draggable>
- <li v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem">
+ <li
+ v-else
+ class="gl-text-secondary gl-font-sm gl-py-3 super-sidebar-empty-pinned-text"
+ style="margin-left: 2.5rem"
+ >
{{ $options.i18n.emptyHint }}
</li>
</menu-section>
diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue
index f129d067cdc..cc558edfd68 100644
--- a/app/assets/javascripts/super_sidebar/components/user_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -30,6 +30,8 @@ export default {
oneOfGroupsRunningOutOfPipelineMinutes: s__('CurrentUser|One of your groups is running out'),
gitlabNext: s__('CurrentUser|Switch to GitLab Next'),
startTrial: s__('CurrentUser|Start an Ultimate trial'),
+ enterAdminMode: s__('CurrentUser|Enter Admin Mode'),
+ leaveAdminMode: s__('CurrentUser|Leave Admin Mode'),
signOut: __('Sign out'),
},
components: {
@@ -142,6 +144,27 @@ export default {
},
};
},
+ enterAdminModeItem() {
+ return {
+ text: this.$options.i18n.enterAdminMode,
+ href: this.data.admin_mode.enter_admin_mode_url,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'enter_admin_mode',
+ },
+ };
+ },
+ leaveAdminModeItem() {
+ return {
+ text: this.$options.i18n.leaveAdminMode,
+ href: this.data.admin_mode.leave_admin_mode_url,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'leave_admin_mode',
+ 'data-method': 'post',
+ },
+ };
+ },
signOutGroup() {
return {
items: [
@@ -184,6 +207,20 @@ export default {
}
: {};
},
+ showEnterAdminModeItem() {
+ return (
+ this.data.admin_mode.user_is_admin &&
+ this.data.admin_mode.admin_mode_feature_enabled &&
+ !this.data.admin_mode.admin_mode_active
+ );
+ },
+ showLeaveAdminModeItem() {
+ return (
+ this.data.admin_mode.user_is_admin &&
+ this.data.admin_mode.admin_mode_feature_enabled &&
+ this.data.admin_mode.admin_mode_active
+ );
+ },
showNotificationDot() {
return this.data.pipeline_minutes?.show_notification_dot;
},
@@ -248,7 +285,11 @@ export default {
@shown="onShow"
>
<template #toggle>
- <gl-button category="tertiary" class="user-bar-dropdown-toggle btn-with-notification">
+ <gl-button
+ category="tertiary"
+ class="user-bar-dropdown-toggle btn-with-notification"
+ data-testid="user-menu-toggle"
+ >
<span class="gl-sr-only">{{ toggleText }}</span>
<gl-avatar
:size="24"
@@ -320,6 +361,17 @@ export default {
:item="gitlabNextItem"
data-testid="gitlab-next-item"
/>
+
+ <gl-disclosure-dropdown-item
+ v-if="showEnterAdminModeItem"
+ :item="enterAdminModeItem"
+ data-testid="enter-admin-mode-item"
+ />
+ <gl-disclosure-dropdown-item
+ v-if="showLeaveAdminModeItem"
+ :item="leaveAdminModeItem"
+ data-testid="leave-admin-mode-item"
+ />
</gl-disclosure-dropdown-group>
<gl-disclosure-dropdown-group
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index bc416b20e80..06d324a970f 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -31,8 +31,6 @@ export const REFERRER_TTL = 24 * 60 * 60 * 1000;
export const GOOGLE_ANALYTICS_ID_COOKIE_NAME = '_ga';
-export const GITLAB_INTERNAL_EVENT_CATEGORY = 'InternalEventTracking';
-
export const SERVICE_PING_SCHEMA = 'iglu:com.gitlab/gitlab_service_ping/jsonschema/1-0-1';
export const SERVICE_PING_SECURITY_CONFIGURATION_THREAT_MANAGEMENT_VISIT =
diff --git a/app/assets/javascripts/tracking/internal_events.js b/app/assets/javascripts/tracking/internal_events.js
index a6d14bfbfd8..7da6da16d6f 100644
--- a/app/assets/javascripts/tracking/internal_events.js
+++ b/app/assets/javascripts/tracking/internal_events.js
@@ -1,11 +1,7 @@
import API from '~/api';
import Tracking from './tracking';
-import {
- GITLAB_INTERNAL_EVENT_CATEGORY,
- LOAD_INTERNAL_EVENTS_SELECTOR,
- SERVICE_PING_SCHEMA,
-} from './constants';
+import { LOAD_INTERNAL_EVENTS_SELECTOR, SERVICE_PING_SCHEMA } from './constants';
import { Tracker } from './tracker';
import { InternalEventHandler, createInternalEventPayload } from './utils';
@@ -16,7 +12,7 @@ const InternalEvents = {
*/
trackEvent(event) {
API.trackInternalEvent(event);
- Tracking.event(GITLAB_INTERNAL_EVENT_CATEGORY, event, {
+ Tracking.event(undefined, event, {
context: {
schema: SERVICE_PING_SCHEMA,
data: {
diff --git a/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue b/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue
new file mode 100644
index 00000000000..ddf240fcafa
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/components/namespace_storage_app.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import StorageUsageStatistics from 'ee_else_ce/usage_quotas/storage/components/storage_usage_statistics.vue';
+
+export default {
+ name: 'NamespaceStorageApp',
+ components: {
+ GlAlert,
+ StorageUsageStatistics,
+ },
+ props: {
+ namespaceLoadingError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ projectsLoadingError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isNamespaceStorageStatisticsLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ namespace: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ usedStorage() {
+ return (
+ // This is the coefficient adjusted forked repo size, only used in EE
+ this.namespace.rootStorageStatistics?.costFactoredStorageSize ??
+ // This is the actual storage size value, used in CE or when the above is disabled
+ this.namespace.rootStorageStatistics?.storageSize
+ );
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="namespaceLoadingError || projectsLoadingError"
+ variant="danger"
+ :dismissible="false"
+ class="gl-mt-4"
+ >
+ {{
+ s__(
+ 'UsageQuota|An error occured while loading the storage usage details. Please refresh the page to try again.',
+ )
+ }}
+ </gl-alert>
+ <storage-usage-statistics
+ :additional-purchased-storage-size="namespace.additionalPurchasedStorageSize"
+ :used-storage="usedStorage"
+ :loading="isNamespaceStorageStatisticsLoading"
+ />
+
+ <slot name="ee-storage-app"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_usage_overview_card.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_usage_overview_card.vue
new file mode 100644
index 00000000000..6e73fc0ba69
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/components/storage_usage_overview_card.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlCard, GlSkeletonLoader } from '@gitlab/ui';
+import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue';
+
+export default {
+ name: 'StorageUsageOverviewCard',
+ components: {
+ GlCard,
+ GlSkeletonLoader,
+ NumberToHumanSize,
+ },
+ props: {
+ usedStorage: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-card>
+ <gl-skeleton-loader v-if="loading" :height="64">
+ <rect width="140" height="30" x="5" y="0" rx="4" />
+ <rect width="240" height="10" x="5" y="40" rx="4" />
+ <rect width="340" height="10" x="5" y="54" rx="4" />
+ </gl-skeleton-loader>
+
+ <div v-else>
+ <div class="gl-font-weight-bold" data-testid="namespace-storage-card-title">
+ {{ s__('UsageQuota|Namespace storage used') }}
+ </div>
+ <div class="gl-font-size-h-display gl-font-weight-bold gl-line-height-ratio-1000 gl-my-3">
+ <number-to-human-size label-class="gl-font-lg" :value="Number(usedStorage)" plain-zero />
+ </div>
+ <hr class="gl-my-4" />
+ <p>
+ {{
+ s__(
+ 'UsageQuota|Namespace total storage represents the sum of storage consumed by all projects, Container Registry, and Dependency Proxy.',
+ )
+ }}
+ </p>
+ </div>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue
new file mode 100644
index 00000000000..d7e550dd715
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/storage/components/storage_usage_statistics.vue
@@ -0,0 +1,33 @@
+<script>
+import StorageUsageOverviewCard from './storage_usage_overview_card.vue';
+
+export default {
+ name: 'StorageUsageStatistics',
+ components: {
+ StorageUsageOverviewCard,
+ },
+ props: {
+ usedStorage: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <h3 data-testid="overview-subtitle">{{ s__('UsageQuota|Namespace overview') }}</h3>
+ <div class="gl-display-grid gl-md-grid-template-columns-2 gl-gap-5 gl-py-4">
+ <storage-usage-overview-card
+ :used-storage="usedStorage"
+ :loading="loading"
+ data-testid="namespace-usage-total"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index a4afdee4d49..1aed3362c42 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -48,7 +48,7 @@ export default {
variables() {
return this.mergeRequestQueryVariables;
},
- update: (data) => data.project.mergeRequest,
+ update: (data) => data.project?.mergeRequest || {},
},
},
components: {
@@ -90,7 +90,7 @@ export default {
return this.state.rebaseInProgress;
},
canPushToSourceBranch() {
- return this.state.userPermissions.pushToSourceBranch;
+ return this.state.userPermissions?.pushToSourceBranch || false;
},
targetBranch() {
return this.state.targetBranch;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 1516b63f96d..af7453d84d6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -119,7 +119,11 @@ export default {
},
) {
if (mergeRequestMergeStatusUpdated) {
- this.state = mergeRequestMergeStatusUpdated;
+ this.state = {
+ ...mergeRequestMergeStatusUpdated,
+ mergeRequestsFfOnlyEnabled: this.state.mergeRequestsFfOnlyEnabled,
+ onlyAllowMergeIfPipelineSucceeds: this.state.onlyAllowMergeIfPipelineSucceeds,
+ };
if (!this.commitMessageIsTouched) {
this.commitMessage = mergeRequestMergeStatusUpdated.defaultMergeCommitMessage;
@@ -277,10 +281,7 @@ export default {
return __('Merge');
},
showAutoMergeHelperText() {
- return (
- !(this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) &&
- this.isAutoMergeAvailable
- );
+ return this.isAutoMergeAvailable;
},
hasPipelineMustSucceedConflict() {
return !this.hasCI && this.state.onlyAllowMergeIfPipelineSucceeds;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
index 7413e2237c3..afdb9e9ff08 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
@@ -72,6 +72,11 @@ export default {
return this.actionButtons.length > 0;
},
},
+ methods: {
+ hasHeader() {
+ return Boolean(this.$scopedSlots.header || this.header || this.shouldShowHeaderActions);
+ },
+ },
i18n: {
learnMore: __('Learn more'),
},
@@ -91,9 +96,9 @@ export default {
:icon-name="statusIconName"
/>
<div class="gl-w-full gl-min-w-0">
- <div class="gl-display-flex">
+ <div v-if="hasHeader()" class="gl-display-flex">
<slot name="header">
- <div v-if="header" class="gl-mb-2">
+ <div class="gl-mb-2">
<strong v-safe-html="generatedHeader" class="gl-display-block"></strong
><span
v-if="generatedSubheader"
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 5e9b72e13cf..db1daa3ce01 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -46,6 +46,7 @@ export default () => {
gl.mrWidgetData.can_create_pipeline_in_target_project,
),
commitPathTemplate: gl.mrWidgetData.commit_path_template,
+ canAdminVulnerability: gl.mrWidgetData.can_admin_vulnerability,
dismissalDescriptions,
},
...MrWidgetOptions,
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 6ac75230d88..3419e6eb1b6 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTruncate } from '@gitlab/ui';
+import { GlTruncate, GlIcon } from '@gitlab/ui';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
@@ -10,6 +10,7 @@ export default {
FileHeader,
FileIcon,
GlTruncate,
+ GlIcon,
},
props: {
file: {
@@ -141,6 +142,7 @@ export default {
data-testid="file-row-name-container"
:class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]"
>
+ <gl-icon v-if="file.pinned" name="thumbtack" :size="16" />
<file-icon
class="file-row-icon"
:class="{ 'text-secondary': file.type === 'tree' }"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 5362ceac9ee..6549de96c98 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -67,6 +67,7 @@ export const TOKEN_TITLE_AUTHOR = __('Author');
export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
export const TOKEN_TITLE_CONTACT = s__('Crm|Contact');
export const TOKEN_TITLE_GROUP = __('Group');
+export const TOKEN_TITLE_GROUP_INVITE = __('Group invite');
export const TOKEN_TITLE_LABEL = __('Label');
export const TOKEN_TITLE_PROJECT = __('Project');
export const TOKEN_TITLE_MILESTONE = __('Milestone');
@@ -90,6 +91,7 @@ export const TOKEN_TYPE_AUTHOR = 'author';
export const TOKEN_TYPE_CONFIDENTIAL = 'confidential';
export const TOKEN_TYPE_CONTACT = 'contact';
export const TOKEN_TYPE_GROUP = 'group';
+export const TOKEN_TYPE_GROUP_INVITE = 'group-invite';
export const TOKEN_TYPE_EPIC = 'epic';
// As health status gets reused between issue lists and boards
// this is in the shared constants. Until we have not decoupled the EE filtered search bar
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/daterange_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/daterange_token.vue
new file mode 100644
index 00000000000..8da5f0cca4a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/daterange_token.vue
@@ -0,0 +1,158 @@
+<script>
+import {
+ GlIcon,
+ GlDaterangePicker,
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlOutsideDirective,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+import { formatDate } from '~/lib/utils/datetime/date_format_utility';
+
+const CUSTOM_DATE_FILTER_TYPE = 'custom-date';
+
+export default {
+ directives: { Outside: GlOutsideDirective },
+ components: {
+ GlIcon,
+ GlDaterangePicker,
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ },
+ props: {
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ config: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ datePickerShown: false,
+ };
+ },
+ computed: {
+ isActive() {
+ return this.datePickerShown || this.active;
+ },
+ computedValue() {
+ if (this.datePickerShown) {
+ return {
+ ...this.value,
+ data: '',
+ };
+ }
+ return this.value;
+ },
+ dataSegmentInputAttributes() {
+ const id = 'time_range_data_segment_input';
+ if (this.datePickerShown) {
+ return {
+ id,
+ placeholder: 'YYYY-MM-DD - YYYY-MM-DD', // eslint-disable-line @gitlab/require-i18n-strings
+ style: 'padding-left: 23px;',
+ };
+ }
+ return {
+ id,
+ };
+ },
+ computedConfig() {
+ return {
+ ...this.config,
+ options: undefined, // remove options from config to avoid default options being rendered
+ };
+ },
+ suggestions() {
+ const suggestions = this.config.options.map((option) => ({
+ value: option.value,
+ text: option.title,
+ }));
+ suggestions.push({ value: CUSTOM_DATE_FILTER_TYPE, text: __('Custom') });
+ return suggestions;
+ },
+ defaultStartDate() {
+ return new Date();
+ },
+ },
+ methods: {
+ hideDatePicker() {
+ this.datePickerShown = false;
+ },
+ showDatePicker() {
+ this.datePickerShown = true;
+ },
+ handleClickOutside() {
+ this.hideDatePicker();
+ },
+ handleComplete(value) {
+ if (value === CUSTOM_DATE_FILTER_TYPE) {
+ this.showDatePicker();
+ }
+ },
+ selectValue(inputValue, submitValue) {
+ let value = inputValue;
+ if (typeof inputValue === 'object' && inputValue.startDate && inputValue.endDate) {
+ const { startDate, endDate } = inputValue;
+ const format = 'yyyy-mm-dd';
+ value = `${formatDate(startDate, format)} - ${formatDate(endDate, format)}`;
+ }
+ submitValue(value);
+ this.hideDatePicker();
+ },
+ },
+ CUSTOM_DATE_FILTER_TYPE: 'custom-date',
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :data-segment-input-attributes="dataSegmentInputAttributes"
+ v-bind="{ ...$props, ...$attrs }"
+ :view-only="datePickerShown"
+ :active="isActive"
+ :value="computedValue"
+ :config="computedConfig"
+ v-on="$listeners"
+ @complete="handleComplete"
+ >
+ <template #before-data-segment-input="{ submitValue }">
+ <gl-icon
+ v-if="datePickerShown"
+ class="gl-text-gray-500"
+ name="calendar"
+ style="margin-left: 5px; margin-right: -15px; z-index: 1; pointer-events: none"
+ />
+ <div
+ v-if="datePickerShown"
+ v-outside="handleClickOutside"
+ class="gl-absolute gl-z-index-1 gl-bg-white gl-border-1 gl-border-gray-200 gl-my-2 gl-p-4 gl-rounded-base gl-shadow-x0-y2-b4-s0 gl-top-full"
+ >
+ <gl-daterange-picker
+ start-opened
+ :default-start-date="defaultStartDate"
+ @input="selectValue($event, submitValue)"
+ />
+ </div>
+ </template>
+
+ <template #suggestions>
+ <div v-if="!datePickerShown">
+ <gl-filtered-search-suggestion
+ v-for="token in suggestions"
+ :key="token.value"
+ :value="token.value"
+ >
+ {{ token.text }}
+ </gl-filtered-search-suggestion>
+ </div>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/gl_countdown.vue b/app/assets/javascripts/vue_shared/components/gl_countdown.vue
index 1769a283d8c..0e8ecc36f37 100644
--- a/app/assets/javascripts/vue_shared/components/gl_countdown.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_countdown.vue
@@ -30,6 +30,11 @@ export default {
mounted() {
const updateRemainingTime = () => {
const remainingMilliseconds = calculateRemainingMilliseconds(this.endDateString);
+
+ if (remainingMilliseconds < 1) {
+ this.$emit('timer-expired');
+ }
+
this.remainingTime = formatTime(remainingMilliseconds);
};
diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue
index a375a167c68..e84a810c0b0 100644
--- a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue
+++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue
@@ -13,6 +13,11 @@ export default {
required: false,
default: false,
},
+ listItemClass: {
+ type: [String, Array, Object],
+ required: false,
+ default: '',
+ },
},
};
</script>
@@ -24,6 +29,7 @@ export default {
:key="group.id"
:group="group"
:show-group-icon="showGroupIcon"
+ :class="listItemClass"
@delete="$emit('delete', $event)"
/>
</ul>
diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue
index ca1e7400f2d..ace3846723c 100644
--- a/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue
@@ -1,10 +1,9 @@
<script>
-import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } from '@gitlab/ui';
+import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText, GlBadge } from '@gitlab/ui';
import uniqueId from 'lodash/uniqueId';
import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/visibility_level/constants';
import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
-import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { __ } from '~/locale';
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import SafeHtml from '~/vue_shared/directives/safe_html';
@@ -20,15 +19,12 @@ export default {
showMore: __('Show more'),
showLess: __('Show less'),
},
- avatarSize: { default: 32, md: 48 },
- safeHtmlConfig: {
- ADD_TAGS: ['gl-emoji'],
- },
+ truncateTextToggleButtonProps: { class: 'gl-font-sm!' },
components: {
GlAvatarLabeled,
GlIcon,
- UserAccessRoleBadge,
GlTruncateText,
+ GlBadge,
ListActions,
DangerConfirmModal,
},
@@ -76,7 +72,7 @@ export default {
return this.group.parent ? 'subgroup' : 'group';
},
statsPadding() {
- return this.showGroupIcon ? 'gl-pl-11' : 'gl-pl-8';
+ return this.showGroupIcon ? 'gl-pl-12' : 'gl-pl-10';
},
descendantGroupsCount() {
return numberToMetricPrefix(this.group.descendantGroupsCount);
@@ -113,21 +109,22 @@ export default {
</script>
<template>
- <li class="groups-list-item gl-py-5 gl-border-b gl-display-flex gl-align-items-flex-start">
- <div class="gl-md-display-flex gl-align-items-center gl-flex-grow-1">
+ <li class="groups-list-item gl-py-5 gl-border-b gl-display-flex">
+ <div class="gl-md-display-flex gl-flex-grow-1">
<div class="gl-display-flex gl-flex-grow-1">
- <gl-icon
+ <div
v-if="showGroupIcon"
- class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary"
- :name="groupIconName"
- />
+ class="gl-display-flex gl-align-items-center gl-flex-shrink-0 gl-h-9 gl-mr-3"
+ >
+ <gl-icon class="gl-text-secondary" :name="groupIconName" />
+ </div>
<gl-avatar-labeled
:entity-id="group.id"
:entity-name="group.fullName"
:label="group.fullName"
:label-link="group.webUrl"
shape="rect"
- :size="$options.avatarSize"
+ :size="48"
>
<template #meta>
<div class="gl-px-2">
@@ -141,9 +138,9 @@ export default {
/>
</div>
<div class="gl-px-2">
- <user-access-role-badge v-if="shouldShowAccessLevel">{{
+ <gl-badge v-if="shouldShowAccessLevel" size="sm" class="gl-display-block">{{
accessLevelLabel
- }}</user-access-role-badge>
+ }}</gl-badge>
</div>
</div>
</div>
@@ -154,57 +151,57 @@ export default {
:mobile-lines="2"
:show-more-text="$options.i18n.showMore"
:show-less-text="$options.i18n.showLess"
- class="gl-mt-2"
+ :toggle-button-props="$options.truncateTextToggleButtonProps"
+ class="gl-mt-2 gl-max-w-88"
>
<div
- v-safe-html:[$options.safeHtmlConfig]="group.descriptionHtml"
- class="gl-font-sm md"
+ v-safe-html="group.descriptionHtml"
+ class="gl-font-sm gl-text-secondary md"
data-testid="group-description"
></div>
</gl-truncate-text>
</gl-avatar-labeled>
</div>
<div
- class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0 gl-md-ml-3"
+ class="gl-display-flex gl-align-items-center gl-gap-x-3 gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0 gl-md-ml-3 gl-md-h-9"
:class="statsPadding"
>
- <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
- <div
- v-gl-tooltip="$options.i18n.subgroups"
- :aria-label="$options.i18n.subgroups"
- class="gl-text-secondary"
- data-testid="subgroups-count"
- >
- <gl-icon name="subgroup" />
- <span>{{ descendantGroupsCount }}</span>
- </div>
- <div
- v-gl-tooltip="$options.i18n.projects"
- :aria-label="$options.i18n.projects"
- class="gl-text-secondary"
- data-testid="projects-count"
- >
- <gl-icon name="project" />
- <span>{{ projectsCount }}</span>
- </div>
- <div
- v-gl-tooltip="$options.i18n.directMembers"
- :aria-label="$options.i18n.directMembers"
- class="gl-text-secondary"
- data-testid="members-count"
- >
- <gl-icon name="users" />
- <span>{{ groupMembersCount }}</span>
- </div>
+ <div
+ v-gl-tooltip="$options.i18n.subgroups"
+ :aria-label="$options.i18n.subgroups"
+ class="gl-text-secondary"
+ data-testid="subgroups-count"
+ >
+ <gl-icon name="subgroup" />
+ <span>{{ descendantGroupsCount }}</span>
+ </div>
+ <div
+ v-gl-tooltip="$options.i18n.projects"
+ :aria-label="$options.i18n.projects"
+ class="gl-text-secondary"
+ data-testid="projects-count"
+ >
+ <gl-icon name="project" />
+ <span>{{ projectsCount }}</span>
+ </div>
+ <div
+ v-gl-tooltip="$options.i18n.directMembers"
+ :aria-label="$options.i18n.directMembers"
+ class="gl-text-secondary"
+ data-testid="members-count"
+ >
+ <gl-icon name="users" />
+ <span>{{ groupMembersCount }}</span>
</div>
</div>
</div>
- <list-actions
- v-if="hasActions"
- class="gl-ml-3 gl-md-align-self-center"
- :actions="actions"
- :available-actions="group.availableActions"
- />
+ <div class="gl-display-flex gl-align-items-center gl-h-9 gl-ml-3">
+ <list-actions
+ v-if="hasActions"
+ :actions="actions"
+ :available-actions="group.availableActions"
+ />
+ </div>
<danger-confirm-modal
v-if="hasActionDelete"
diff --git a/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.stories.js b/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.stories.js
new file mode 100644
index 00000000000..0576e9796fc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.stories.js
@@ -0,0 +1,35 @@
+import HelpPageLink from './help_page_link.vue';
+
+export default {
+ component: HelpPageLink,
+ title: 'vue_shared/help_page_link',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { HelpPageLink },
+ props: Object.keys(argTypes),
+ template: '<help-page-link v-bind="$props">link</help-page-link>',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ href: 'user/usage_quotas',
+};
+
+export const LinkWithAnAnchor = Template.bind({});
+LinkWithAnAnchor.args = {
+ ...Default.args,
+ anchor: 'namespace-storage-limit',
+};
+
+export const LinkWithAnchorInPath = Template.bind({});
+LinkWithAnchorInPath.args = {
+ ...Default.args,
+ href: 'user/usage_quotas#namespace-storage-limit',
+};
+
+export const CustomAttributes = Template.bind({});
+CustomAttributes.args = {
+ ...Default.args,
+ target: '_blank',
+};
diff --git a/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.vue b/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.vue
new file mode 100644
index 00000000000..11b269855ad
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/help_page_link/help_page_link.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+/**
+ * Component to link to GitLab docs.
+ *
+ * @example
+ * <help-page-link href="user/usage_quotas">
+ * Usage Quotas help.
+ * <help-page-link>
+ */
+export default {
+ name: 'HelpPageLink',
+ components: {
+ GlLink,
+ },
+ props: {
+ href: {
+ type: String,
+ required: true,
+ },
+ anchor: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ compiledHref() {
+ return helpPagePath(this.href, { anchor: this.anchor });
+ },
+ attributes() {
+ const { href, anchor, ...attrs } = this.$attrs;
+ return attrs;
+ },
+ },
+};
+</script>
+<template>
+ <gl-link v-bind="attributes" :href="compiledHref" v-on="$listeners">
+ <slot></slot>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index cffd8471d18..525f684df86 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -382,6 +382,7 @@ export default {
@click="handleQuote"
/>
<toolbar-button
+ v-if="!restrictedToolBarItems.includes('code')"
v-show="!previewMarkdown"
tag="`"
tag-block="```"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
index 73c030b23dc..8fedc816502 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -55,7 +55,7 @@ export default {
uploadsPath: {
type: String,
required: false,
- default: () => window.uploads_path,
+ default: () => window.uploads_path || '',
},
enableContentEditor: {
type: Boolean,
@@ -190,9 +190,9 @@ export default {
renderMarkdown(markdown) {
const url = setUrlParams(
{ render_quick_actions: this.supportsQuickActions },
- joinPaths(window.location.origin, gon.relative_url_root, this.renderMarkdownPath),
+ joinPaths(window.location.origin, this.renderMarkdownPath),
);
- return axios.post(url, { text: markdown }).then(({ data }) => data.body);
+ return axios.post(url, { text: markdown }).then(({ data }) => data.body || data.html);
},
onEditingModeChange(editingMode) {
this.editingMode = editingMode;
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 6a5884e4857..8f35fbdff6e 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -160,13 +160,12 @@ export default {
v-if="canSeeDescriptionVersion || note.outdated_line_change_path"
#extra-controls
>
- &middot;
<gl-button
v-if="canSeeDescriptionVersion"
variant="link"
:icon="descriptionVersionToggleIcon"
data-testid="compare-btn"
- class="gl-vertical-align-text-bottom gl-font-sm!"
+ class="gl-vertical-align-text-bottom gl-font-sm! gl-ml-3"
@click="toggleDescriptionVersion"
>{{ __('Compare with previous version') }}</gl-button
>
@@ -190,8 +189,11 @@ export default {
class="note-text md"
></div>
<div v-if="hasMoreCommits" class="flex-list">
- <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded">
- <gl-icon :name="toggleIcon" :size="8" class="gl-mr-2" />
+ <div
+ class="system-note-commit-list-toggler flex-row gl-pl-4 gl-pt-3"
+ @click="expanded = !expanded"
+ >
+ <gl-icon :name="toggleIcon" :size="12" class="gl-mr-2" />
<span>{{ __('Toggle commit list') }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
index 3a4da54c84c..d06024638fa 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
@@ -35,6 +35,11 @@ export default {
required: false,
default: false,
},
+ listItemClass: {
+ type: [String, Array, Object],
+ required: false,
+ default: '',
+ },
},
};
</script>
@@ -46,6 +51,7 @@ export default {
:key="project.id"
:project="project"
:show-project-icon="showProjectIcon"
+ :class="listItemClass"
@delete="$emit('delete', $event)"
/>
</ul>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
index ce75e305473..3a077d09e40 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -7,13 +7,13 @@ import {
GlTooltipDirective,
GlPopover,
GlSprintf,
+ GlTruncateText,
} from '@gitlab/ui';
import uniqueId from 'lodash/uniqueId';
import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants';
import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
import { FEATURABLE_ENABLED } from '~/featurable/constants';
-import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { __ } from '~/locale';
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { truncate } from '~/lib/utils/text_utility';
@@ -37,19 +37,18 @@ export default {
moreTopics: __('More topics'),
updated: __('Updated'),
actions: __('Actions'),
+ showMore: __('Show more'),
+ showLess: __('Show less'),
},
- avatarSize: { default: 32, md: 48 },
- safeHtmlConfig: {
- ADD_TAGS: ['gl-emoji'],
- },
+ truncateTextToggleButtonProps: { class: 'gl-font-sm!' },
components: {
GlAvatarLabeled,
GlIcon,
- UserAccessRoleBadge,
GlLink,
GlBadge,
GlPopover,
GlSprintf,
+ GlTruncateText,
TimeAgoTooltip,
DeleteModal,
ListActions,
@@ -203,21 +202,22 @@ export default {
</script>
<template>
- <li class="projects-list-item gl-py-5 gl-border-b gl-display-flex gl-align-items-flex-start">
- <div class="gl-md-display-flex gl-align-items-center gl-flex-grow-1">
+ <li class="projects-list-item gl-py-5 gl-border-b gl-display-flex">
+ <div class="gl-md-display-flex gl-flex-grow-1">
<div class="gl-display-flex gl-flex-grow-1">
- <gl-icon
+ <div
v-if="showProjectIcon"
- class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary"
- name="project"
- />
+ class="gl-display-flex gl-align-items-center gl-flex-shrink-0 gl-h-9 gl-mr-3"
+ >
+ <gl-icon class="gl-text-secondary" name="project" />
+ </div>
<gl-avatar-labeled
:entity-id="project.id"
:entity-name="project.name"
:label="project.name"
:label-link="project.webUrl"
shape="rect"
- :size="$options.avatarSize"
+ :size="48"
>
<template #meta>
<div class="gl-px-2">
@@ -231,33 +231,46 @@ export default {
/>
</div>
<div class="gl-px-2">
- <user-access-role-badge v-if="shouldShowAccessLevel">{{
+ <gl-badge v-if="shouldShowAccessLevel" size="sm" class="gl-display-block">{{
accessLevelLabel
- }}</user-access-role-badge>
+ }}</gl-badge>
</div>
</div>
</div>
</template>
- <div
+ <gl-truncate-text
v-if="project.descriptionHtml"
- v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml"
- class="gl-font-sm gl-overflow-hidden gl-line-height-20 description md"
- data-testid="project-description"
- ></div>
+ :lines="2"
+ :mobile-lines="2"
+ :show-more-text="$options.i18n.showMore"
+ :show-less-text="$options.i18n.showLess"
+ :toggle-button-props="$options.truncateTextToggleButtonProps"
+ class="gl-mt-2 gl-max-w-88"
+ >
+ <div
+ v-safe-html="project.descriptionHtml"
+ class="gl-font-sm gl-text-secondary md"
+ data-testid="project-description"
+ ></div>
+ </gl-truncate-text>
<div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
<div
class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2"
>
- <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span>
+ <span class="gl-p-2 gl-font-sm gl-text-secondary">{{ $options.i18n.topics }}:</span>
<div v-for="topic in visibleTopics" :key="topic" class="gl-p-2">
- <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ <gl-badge
+ v-gl-tooltip="topicTooltipTitle(topic)"
+ size="sm"
+ :href="topicPath(topic)"
+ >
{{ topicTitle(topic) }}
</gl-badge>
</div>
<template v-if="popoverTopics.length">
<div
:id="topicsPopoverTarget"
- class="gl-p-2 gl-text-secondary"
+ class="gl-p-2 gl-font-sm gl-text-secondary"
role="button"
tabindex="0"
>
@@ -272,7 +285,11 @@ export default {
:key="topic"
class="gl-p-2 gl-display-inline-block"
>
- <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ <gl-badge
+ v-gl-tooltip="topicTooltipTitle(topic)"
+ size="sm"
+ :href="topicPath(topic)"
+ >
{{ topicTitle(topic) }}
</gl-badge>
</div>
@@ -285,9 +302,9 @@ export default {
</div>
<div
class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0"
- :class="showProjectIcon ? 'gl-pl-11' : 'gl-pl-8'"
+ :class="showProjectIcon ? 'gl-pl-12' : 'gl-pl-10'"
>
- <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
+ <div class="gl-display-flex gl-align-items-center gl-gap-x-3 gl-md-h-9">
<gl-badge v-if="project.archived" variant="warning">{{
$options.i18n.archived
}}</gl-badge>
@@ -323,19 +340,20 @@ export default {
</div>
<div
v-if="project.updatedAt"
- class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3"
+ class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3 gl-md-mt-0"
>
<span>{{ $options.i18n.updated }}</span>
<time-ago-tooltip :time="project.updatedAt" />
</div>
</div>
</div>
- <list-actions
- v-if="hasActions"
- class="gl-ml-3 gl-md-align-self-center"
- :actions="actions"
- :available-actions="project.availableActions"
- />
+ <div class="gl-display-flex gl-align-items-center gl-h-9 gl-ml-3">
+ <list-actions
+ v-if="hasActions"
+ :actions="actions"
+ :available-actions="project.availableActions"
+ />
+ </div>
<delete-modal
v-if="hasActionDelete"
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
deleted file mode 100644
index 06852f511bf..00000000000
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<script>
-import { GlButton, GlModalDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import RunnerInstructionsModal from './runner_instructions_modal.vue';
-
-export default {
- components: {
- GlButton,
- RunnerInstructionsModal,
- },
- directives: {
- GlModal: GlModalDirective,
- },
- modalId: 'runner-instructions-modal',
- i18n: {
- buttonText: s__('Runners|Show runner installation instructions'),
- },
-};
-</script>
-<template>
- <div>
- <gl-button v-gl-modal="$options.modalId" class="gl-mt-4" data-testid="show-modal-button">
- {{ $options.i18n.buttonText }}
- </gl-button>
- <runner-instructions-modal :modal-id="$options.modalId" />
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue b/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue
index e0e8200580a..fff70d003b7 100644
--- a/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue
+++ b/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue
@@ -43,6 +43,7 @@ export default {
:key="opt.value"
:disabled="!!opt.disabled"
:selected="value === opt.value"
+ v-bind="opt.props"
@click="$emit('input', opt.value)"
>
<slot name="button-content" v-bind="opt">{{ opt.text }}</slot>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql
index c497224cde3..0067a0ca0d9 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql
@@ -1,10 +1,16 @@
#import "~/graphql_shared/fragments/author.fragment.graphql"
-query getBlameData($fullPath: ID!, $filePath: String!, $fromLine: Int, $toLine: Int) {
+query getBlameData(
+ $fullPath: ID!
+ $filePath: String!
+ $fromLine: Int
+ $toLine: Int
+ $ref: String!
+) {
project(fullPath: $fullPath) {
id
repository {
- blobs(paths: [$filePath]) {
+ blobs(ref: $ref, paths: [$filePath]) {
nodes {
id
blame(fromLine: $fromLine, toLine: $toLine) {
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
index bc46f11ab2d..e62f38d9ca3 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
@@ -118,6 +118,7 @@ export default {
const { data } = await this.$apollo.query({
query: blameDataQuery,
variables: {
+ ref: this.currentRef,
fullPath: this.projectPath,
filePath: this.blob.path,
fromLine: chunk.startingFrom + 1,
diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue
index 6764ad4ce73..d4d241b12ec 100644
--- a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue
@@ -110,6 +110,7 @@ export default {
:no-results-text="$options.translations.noResultsText"
:selected="tzValue"
block
+ fluid-width
searchable
@search="setSearchTerm"
@select="selectTimezone"
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue
new file mode 100644
index 00000000000..944a48df279
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlButton, GlAvatar, GlSprintf, GlTruncate } from '@gitlab/ui';
+import { __ } from '~/locale';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
+
+export default {
+ i18n: {
+ uploadText: __('Drop or %{linkStart}upload%{linkEnd} an avatar.'),
+ maxFileSize: __('Max file size is 200 KiB.'),
+ removeAvatar: __('Remove avatar'),
+ },
+ AVATAR_SHAPE_OPTION_RECT,
+ components: { GlButton, GlAvatar, GlSprintf, GlTruncate, UploadDropzone },
+ props: {
+ entity: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ value: {
+ type: [String, File],
+ required: false,
+ default: '',
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ avatarObjectUrl: null,
+ };
+ },
+ computed: {
+ avatarSrc() {
+ if (this.avatarObjectUrl) {
+ return this.avatarObjectUrl;
+ }
+
+ if (this.isValueAFile) {
+ return null;
+ }
+
+ return this.value;
+ },
+ isValueAFile() {
+ return this.value instanceof File;
+ },
+ },
+ watch: {
+ value(newValue) {
+ this.revokeAvatarObjectUrl();
+
+ if (newValue instanceof File) {
+ this.avatarObjectUrl = URL.createObjectURL(newValue);
+ } else {
+ this.avatarObjectUrl = null;
+ }
+ },
+ },
+ beforeDestroy() {
+ this.revokeAvatarObjectUrl();
+ },
+ methods: {
+ revokeAvatarObjectUrl() {
+ if (this.avatarObjectUrl === null) {
+ return;
+ }
+
+ URL.revokeObjectURL(this.avatarObjectUrl);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-column-gap-5">
+ <gl-avatar
+ :entity-id="entity.id || null"
+ :entity-name="entity.name || 'organization'"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :size="96"
+ :src="avatarSrc"
+ />
+ <div class="gl-min-w-0">
+ <p class="gl-font-weight-bold gl-line-height-1 gl-mb-3">
+ {{ label }}
+ </p>
+ <div v-if="value" class="gl-display-flex gl-align-items-center gl-column-gap-3">
+ <gl-button @click="$emit('input', null)">{{ $options.i18n.removeAvatar }}</gl-button>
+ <gl-truncate
+ v-if="isValueAFile"
+ class="gl-text-secondary gl-max-w-48 gl-min-w-0"
+ position="middle"
+ :text="value.name"
+ />
+ </div>
+ <upload-dropzone v-else single-file-selection @change="$emit('input', $event)">
+ <template #upload-text>
+ <gl-sprintf :message="$options.i18n.uploadText">
+ <template #link="{ content }">
+ <span class="gl-link gl-hover-text-decoration-underline">{{ content }}</span>
+ </template>
+ </gl-sprintf>
+ </template>
+ </upload-dropzone>
+ <p class="gl-mb-0 gl-mt-3 gl-text-secondary">{{ $options.i18n.maxFileSize }}</p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index 863c43b0e55..a113a5ccc66 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -252,6 +252,7 @@ export default {
selected.push(user);
this.$emit('input', selected);
}
+ this.clearAndFocusSearch();
},
unassign() {
this.$emit('input', []);
@@ -260,6 +261,7 @@ export default {
unselect(name) {
const selected = this.value.filter((user) => user.username !== name);
this.$emit('input', selected);
+ this.clearAndFocusSearch();
},
focusSearch() {
this.$refs.search.focusInput();
@@ -296,6 +298,10 @@ export default {
}
return user.canMerge ? '' : __('Cannot merge');
},
+ clearAndFocusSearch() {
+ this.search = '';
+ this.focusSearch();
+ },
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue
index 5d86f90880d..5f197066cb5 100644
--- a/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue
+++ b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { truncate } from '~/lib/utils/text_utility';
import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from './constants';
@@ -12,6 +13,7 @@ export default {
GlBadge,
GlIcon,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
user: {
type: Object,
@@ -65,7 +67,12 @@ export default {
<div v-if="user.note" class="gl-text-gray-500 gl-p-1">
<gl-icon v-gl-tooltip="userNoteShort" name="document" />
</div>
- <div v-for="(badge, idx) in user.badges" :key="idx" class="gl-p-1">
+ <div
+ v-for="(badge, idx) in user.badges"
+ :key="idx"
+ class="gl-p-1"
+ :class="{ 'gl-pb-0': glFeatures.simplifiedBadges }"
+ >
<gl-badge class="gl-display-flex!" size="sm" :variant="badge.variant">{{
badge.text
}}</gl-badge>
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 3514a9c2d5d..1a3e5208508 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -11,6 +11,7 @@ import { s__, __ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue';
+import { GO_TO_PROJECT_WEBIDE } from '~/behaviors/shortcuts/keybindings';
import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants';
export const i18n = {
@@ -197,6 +198,9 @@ export default {
...handleOptions,
};
},
+ webIdeActionShortcutKey() {
+ return GO_TO_PROJECT_WEBIDE.defaultKeys[0];
+ },
webIdeActionText() {
if (this.webIdeText) {
return this.webIdeText;
@@ -234,6 +238,7 @@ export default {
key: KEY_WEB_IDE,
text: this.webIdeActionText,
secondaryText: this.$options.i18n.webIdeText,
+ shortcut: this.webIdeActionShortcutKey,
tracking: {
action: TRACKING_ACTION_NAME,
label: 'web_ide',
@@ -357,9 +362,14 @@ export default {
>
<template #list-item>
<div class="gl-display-flex gl-flex-direction-column">
- <span data-testid="action-primary-text" class="gl-font-weight-bold gl-mb-2">{{
- action.text
- }}</span>
+ <span
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-mb-2"
+ >
+ <span data-testid="action-primary-text" class="gl-font-weight-bold">{{
+ action.text
+ }}</span>
+ <kbd v-if="action.shortcut" class="flat">{{ action.shortcut }}</kbd>
+ </span>
<span data-testid="action-secondary-text" class="gl-font-sm gl-text-secondary">
{{ action.secondaryText }}
</span>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
index bb36df0a778..de2f7887237 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -1,19 +1,27 @@
<script>
-import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-
+import {
+ GlBadge,
+ GlLink,
+ GlIcon,
+ GlLabel,
+ GlFormCheckbox,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { STATUS_CLOSED } from '~/issues/constants';
+import { STATUS_OPEN, STATUS_CLOSED } from '~/issues/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
-import { STATE_CLOSED } from '~/work_items/constants';
+import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants';
import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils';
export default {
components: {
+ GlBadge,
GlLink,
GlIcon,
GlLabel,
@@ -120,8 +128,11 @@ export default {
createdAt() {
return this.timeFormatted(this.issuable.createdAt);
},
+ isNotOpen() {
+ return ![STATUS_OPEN, STATE_OPEN].includes(this.issuable.state);
+ },
isClosed() {
- return this.issuable.state === STATUS_CLOSED || this.issuable.state === STATE_CLOSED;
+ return [STATUS_CLOSED, STATE_CLOSED].includes(this.issuable.state);
},
timestamp() {
return this.isClosed && this.issuable.closedAt
@@ -351,8 +362,12 @@ export default {
</div>
<div class="issuable-meta">
<ul v-if="showIssuableMeta" class="controls">
- <li v-if="hasSlotContents('status')">
- <slot name="status"></slot>
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
+ <li v-if="$slots.status" data-testid="issuable-status">
+ <gl-badge v-if="isNotOpen" size="sm" variant="info">
+ <slot name="status"></slot>
+ </gl-badge>
+ <slot v-else name="status"></slot>
</li>
<li v-if="assignees.length">
<issuable-assignees
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index ad908a674d3..dd203283b3b 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -406,9 +406,7 @@ export default {
<slot name="timeframe" :issuable="issuable"></slot>
</template>
<template #status>
- <gl-badge size="sm" variant="info">
- <slot name="status" :issuable="issuable"></slot>
- </gl-badge>
+ <slot name="status" :issuable="issuable"></slot>
</template>
<template #statistics>
<slot name="statistics" :issuable="issuable"></slot>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
index 1e6bd9ff1ac..9ede327b2f1 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
@@ -235,7 +235,9 @@ export default {
:work-item-id="workItemId"
:work-item-state="workItemState"
:work-item-type="workItemType"
+ :has-comment="!!commentText.length"
can-update
+ @submit-comment="$emit('submitForm', { commentText, isNoteInternal })"
@error="$emit('error', $event)"
/>
<gl-button
diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
index 503328f7b03..78afb9a04ef 100644
--- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
@@ -131,7 +131,7 @@ export default {
class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base gl-gap-3"
data-testid="links-child"
>
- <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
+ <div class="gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
<div
class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0"
>
diff --git a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue
new file mode 100644
index 00000000000..53149f62893
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue
@@ -0,0 +1,194 @@
+<script>
+import { GlButton, GlForm, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+
+import { s__, __ } from '~/locale';
+
+export default {
+ i18n: {
+ none: s__('WorkItem|None'),
+ noMatchingResults: s__('WorkItem|No matching results'),
+ editButtonLabel: __('Edit'),
+ applyButtonLabel: __('Apply'),
+ resetButtonText: __('Clear'),
+ },
+ components: {
+ GlButton,
+ GlLoadingIcon,
+ GlForm,
+ GlCollapsibleListbox,
+ },
+ props: {
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ dropdownLabel: {
+ type: String,
+ required: true,
+ },
+ dropdownName: {
+ type: String,
+ required: true,
+ },
+ listItems: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ itemValue: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ updateInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ resetButtonLabel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ headerText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ toggleDropdownText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ isEditing: false,
+ localSelectedItem: this.itemValue?.id,
+ };
+ },
+ computed: {
+ hasValue() {
+ return this.itemValue != null || !isEmpty(this.item);
+ },
+ listboxText() {
+ return (
+ this.listItems.find(({ value }) => this.localSelectedItem === value)?.text ||
+ this.itemValue?.title ||
+ this.$options.i18n.none
+ );
+ },
+ inputId() {
+ return `work-item-dropdown-listbox-value-${this.dropdownName}`;
+ },
+ toggleText() {
+ return this.toggleDropdownText || this.listboxText;
+ },
+ resetButton() {
+ return this.resetButtonLabel || this.$options.i18n.resetButtonText;
+ },
+ },
+ watch: {
+ itemValue: {
+ handler(newVal) {
+ if (!this.isEditing) {
+ this.localSelectedItem = newVal?.id;
+ }
+ },
+ },
+ },
+ methods: {
+ setSearchKey(value) {
+ this.$emit('searchStarted', value);
+ },
+ handleItemClick(item) {
+ this.localSelectedItem = item;
+ this.$emit('updateValue', item);
+ },
+ onListboxShown() {
+ this.$emit('dropdownShown');
+ },
+ onListboxHide() {
+ this.isEditing = false;
+ },
+ unassignValue() {
+ this.localSelectedItem = null;
+ this.isEditing = false;
+ this.$emit('updateValue', null);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-flex gl-align-items-center gl-gap-3">
+ <!-- hide header when editing, since we then have a form label. Keep it reachable for screenreader nav -->
+ <h3 :class="{ 'gl-sr-only': isEditing }" class="gl-mb-0! gl-heading-scale-5">
+ {{ dropdownLabel }}
+ </h3>
+ <gl-loading-icon v-if="updateInProgress" />
+ <gl-button
+ v-if="canUpdate && !isEditing"
+ data-testid="edit-button"
+ category="tertiary"
+ size="small"
+ class="gl-ml-auto gl-mr-2"
+ :disabled="updateInProgress"
+ @click="isEditing = true"
+ >{{ $options.i18n.editButtonLabel }}</gl-button
+ >
+ </div>
+ <gl-form v-if="isEditing">
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
+ <label :for="inputId" class="gl-mb-0">{{ dropdownLabel }}</label>
+ <gl-button
+ data-testid="apply-button"
+ category="tertiary"
+ size="small"
+ class="gl-mr-2"
+ :disabled="updateInProgress"
+ @click="isEditing = false"
+ >{{ $options.i18n.applyButtonLabel }}</gl-button
+ >
+ </div>
+ <gl-collapsible-listbox
+ :id="inputId"
+ block
+ searchable
+ start-opened
+ is-check-centered
+ fluid-width
+ :searching="loading"
+ :header-text="headerText"
+ :toggle-text="toggleText"
+ :no-results-text="$options.i18n.noMatchingResults"
+ :items="listItems"
+ :selected="localSelectedItem"
+ :reset-button-label="resetButton"
+ @reset="unassignValue"
+ @search="setSearchKey"
+ @select="handleItemClick"
+ @shown="onListboxShown"
+ @hidden="onListboxHide"
+ >
+ <template #list-item="{ item }">
+ <slot name="list-item" :item="item">{{ item.text }}</slot>
+ </template>
+ </gl-collapsible-listbox>
+ </gl-form>
+ <slot v-else-if="hasValue" name="readonly">
+ {{ listboxText }}
+ </slot>
+ <div v-else class="gl-text-secondary">
+ {{ $options.i18n.none }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue
index c122db6c902..719507d1341 100644
--- a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue
@@ -1,21 +1,25 @@
<script>
import { GlTokenSelector, GlAlert } from '@gitlab/ui';
import { debounce } from 'lodash';
-
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isNumeric } from '~/lib/utils/number_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import SafeHtml from '~/vue_shared/directives/safe_html';
+import { isSafeURL } from '~/lib/utils/url_utility';
+
import { highlighter } from 'ee_else_ce/gfm_auto_complete';
import groupWorkItemsQuery from '../../graphql/group_work_items.query.graphql';
import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
+import workItemsByReferencesQuery from '../../graphql/work_items_by_references.query.graphql';
import {
WORK_ITEMS_TYPE_MAP,
I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER,
I18N_WORK_ITEM_SEARCH_ERROR,
+ I18N_WORK_ITEM_NO_MATCHES_FOUND,
sprintfWorkItem,
} from '../../constants';
+import { isReference } from '../../utils';
export default {
components: {
@@ -55,41 +59,63 @@ export default {
},
},
apollo: {
- availableWorkItems: {
+ workspaceWorkItems: {
query() {
return this.isGroup ? groupWorkItemsQuery : projectWorkItemsQuery;
},
variables() {
- return {
- fullPath: this.fullPath,
- searchTerm: '',
- types: this.childrenType ? [this.childrenType] : [],
- isNumber: false,
- };
+ return this.queryVariables;
},
skip() {
- return !this.searchStarted;
+ return !this.searchStarted || this.isSearchingByReference;
},
update(data) {
return [
...this.filterItems(data.workspace.workItemsByIid?.nodes),
- ...this.filterItems(data.workspace.workItems.nodes),
+ ...this.filterItems(data.workspace.workItems?.nodes),
];
},
error() {
this.error = sprintfWorkItem(I18N_WORK_ITEM_SEARCH_ERROR, this.childrenTypeName);
},
},
+ workItemsByReference: {
+ query: workItemsByReferencesQuery,
+ variables() {
+ return {
+ contextNamespacePath: this.fullPath,
+ refs: [this.searchTerm],
+ };
+ },
+ skip() {
+ return !this.isSearchingByReference;
+ },
+ update(data) {
+ return data.workItemsByReference.nodes;
+ },
+ error() {
+ this.error = sprintfWorkItem(I18N_WORK_ITEM_SEARCH_ERROR, this.childrenTypeName);
+ },
+ },
},
data() {
return {
- availableWorkItems: [],
- query: '',
+ workspaceWorkItems: [],
+ searchTerm: '',
searchStarted: false,
error: '',
+ textInputAttrs: {
+ class: 'gl-min-w-fit-content!',
+ },
};
},
computed: {
+ availableWorkItems() {
+ return this.isSearchingByReference ? this.workItemsByReference : this.workspaceWorkItems;
+ },
+ isSearchingByReference() {
+ return isReference(this.searchTerm) || isSafeURL(this.searchTerm);
+ },
workItemsToAdd: {
get() {
return this.value;
@@ -99,10 +125,10 @@ export default {
},
},
isLoading() {
- return this.$apollo.queries.availableWorkItems.loading;
- },
- addInputPlaceholder() {
- return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName);
+ return (
+ this.$apollo.queries.workspaceWorkItems.loading ||
+ this.$apollo.queries.workItemsByReference.loading
+ );
},
childrenTypeName() {
return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name;
@@ -110,31 +136,25 @@ export default {
tokenSelectorContainerClass() {
return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : '';
},
+ queryVariables() {
+ return {
+ fullPath: this.fullPath,
+ searchTerm: this.searchTerm,
+ types: this.childrenType ? [this.childrenType] : [],
+ in: this.searchTerm ? 'TITLE' : undefined,
+ iid: isNumeric(this.searchTerm) ? this.searchTerm : null,
+ searchByIid: isNumeric(this.searchTerm),
+ searchByText: true,
+ };
+ },
},
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: {
getIdFromGraphQLId,
- setSearchKey(value) {
- this.query = value;
-
- // Query parameters for searching by text
- const variables = {
- searchTerm: value,
- in: value ? 'TITLE' : undefined,
- iid: null,
- isNumber: false,
- };
-
- // Check if it is a number, add iid as query parameter
- if (isNumeric(value) && value) {
- variables.iid = value;
- variables.isNumber = true;
- }
-
- // Fetch combined results of search by iid and search by title.
- this.$apollo.queries.availableWorkItems.refetch(variables);
+ async setSearchKey(value) {
+ this.searchTerm = value;
},
handleFocus() {
this.searchStarted = true;
@@ -154,16 +174,16 @@ export default {
focusInputText() {
this.$nextTick(() => {
if (this.areWorkItemsToAddValid) {
- this.$refs.tokenSelector.$el.querySelector('input[type="text"]').focus();
+ this.$refs.tokenSelector.focusTextInput();
}
});
},
formatResults(input) {
- if (!this.query) {
+ if (!this.searchTerm) {
return input;
}
- return highlighter(`<span class="gl-text-black-normal">${input}</span>`, this.query);
+ return highlighter(`<span class="gl-text-black-normal">${input}</span>`, this.searchTerm);
},
unsetError() {
this.error = '';
@@ -176,6 +196,10 @@ export default {
);
},
},
+ i18n: {
+ noMatchesFoundMessage: I18N_WORK_ITEM_NO_MATCHES_FOUND,
+ addInputPlaceholder: I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER,
+ },
};
</script>
<template>
@@ -188,10 +212,11 @@ export default {
v-model="workItemsToAdd"
:dropdown-items="availableWorkItems"
:loading="isLoading"
- :placeholder="addInputPlaceholder"
+ :placeholder="$options.i18n.addInputPlaceholder"
menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!"
:container-class="tokenSelectorContainerClass"
data-testid="work-item-token-select-input"
+ :text-input-attrs="textInputAttrs"
@text-input="debouncedSearchKeyUpdate"
@focus="handleFocus"
@mouseover.native="handleMouseOver"
@@ -210,6 +235,11 @@ export default {
<div v-safe-html="formatResults(dropdownItem.title)" class="gl-text-truncate"></div>
</div>
</template>
+ <template #no-results-content>
+ <span data-testid="no-match-found-namespace-message">{{
+ $options.i18n.noMatchesFoundMessage
+ }}</span>
+ </template>
</gl-token-selector>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue
index 27de858fe4e..6feae8dd94e 100644
--- a/app/assets/javascripts/work_items/components/widget_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue
@@ -57,28 +57,31 @@ export default {
</script>
<template>
- <div :id="widgetName" class="gl-new-card" :aria-expanded="isOpenString">
+ <div :id="widgetName" class="gl-new-card">
<div class="gl-new-card-header">
<div class="gl-new-card-title-wrapper">
- <h3 class="gl-new-card-title">
- <gl-link
- :id="anchorLinkId"
- class="gl-text-decoration-none"
- :href="anchorLink"
- aria-hidden="true"
- />
+ <h2 class="gl-new-card-title">
+ <div aria-hidden="true">
+ <gl-link
+ :id="anchorLinkId"
+ class="gl-text-decoration-none gl-display-none"
+ :href="anchorLink"
+ />
+ </div>
<slot name="header"></slot>
- </h3>
+ </h2>
<slot name="header-suffix"></slot>
</div>
<slot name="header-right"></slot>
<div class="gl-new-card-toggle">
+ <!-- https://www.w3.org/TR/wai-aria-1.2/#aria-expanded -->
<gl-button
category="tertiary"
size="small"
:icon="toggleIcon"
:aria-label="toggleLabel"
data-testid="widget-toggle"
+ :aria-expanded="isOpenString"
@click="toggle"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
index b7206d502a6..79f0fdca061 100644
--- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
@@ -17,14 +17,16 @@ import {
import WorkItemDueDate from './work_item_due_date.vue';
import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
-import WorkItemMilestone from './work_item_milestone.vue';
+import WorkItemMilestoneInline from './work_item_milestone_inline.vue';
+import WorkItemMilestoneWithEdit from './work_item_milestone_with_edit.vue';
import WorkItemParentInline from './work_item_parent_inline.vue';
import WorkItemParent from './work_item_parent_with_edit.vue';
export default {
components: {
WorkItemLabels,
- WorkItemMilestone,
+ WorkItemMilestoneInline,
+ WorkItemMilestoneWithEdit,
WorkItemAssignees,
WorkItemDueDate,
WorkItemParent,
@@ -34,7 +36,10 @@ export default {
WorkItemWeight: () =>
import('ee_component/work_items/components/work_item_weight_with_edit.vue'),
WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'),
- WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'),
+ WorkItemIterationInline: () =>
+ import('ee_component/work_items/components/work_item_iteration_inline.vue'),
+ WorkItemIteration: () =>
+ import('ee_component/work_items/components/work_item_iteration_with_edit.vue'),
WorkItemHealthStatus: () =>
import('ee_component/work_items/components/work_item_health_status_with_edit.vue'),
WorkItemHealthStatusInline: () =>
@@ -137,15 +142,28 @@ export default {
:work-item-type="workItemType"
@error="$emit('error', $event)"
/>
- <work-item-milestone
- v-if="workItemMilestone"
- :full-path="fullPath"
- :work-item-id="workItem.id"
- :work-item-milestone="workItemMilestone.milestone"
- :work-item-type="workItemType"
- :can-update="canUpdate"
- @error="$emit('error', $event)"
- />
+ <template v-if="workItemMilestone">
+ <work-item-milestone-with-edit
+ v-if="glFeatures.workItemsMvc2"
+ class="gl-mb-5"
+ :full-path="fullPath"
+ :work-item-id="workItem.id"
+ :work-item-milestone="workItemMilestone.milestone"
+ :work-item-type="workItemType"
+ :can-update="canUpdate"
+ @error="$emit('error', $event)"
+ />
+ <work-item-milestone-inline
+ v-else
+ class="gl-mb-5"
+ :full-path="fullPath"
+ :work-item-id="workItem.id"
+ :work-item-milestone="workItemMilestone.milestone"
+ :work-item-type="workItemType"
+ :can-update="canUpdate"
+ @error="$emit('error', $event)"
+ />
+ </template>
<template v-if="workItemWeight">
<work-item-weight
v-if="glFeatures.workItemsMvc2"
@@ -177,17 +195,30 @@ export default {
:work-item-type="workItemType"
@error="$emit('error', $event)"
/>
- <work-item-iteration
- v-if="workItemIteration"
- class="gl-mb-5"
- :full-path="fullPath"
- :iteration="workItemIteration.iteration"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :work-item-iid="workItem.iid"
- :work-item-type="workItemType"
- @error="$emit('error', $event)"
- />
+ <template v-if="workItemIteration">
+ <work-item-iteration
+ v-if="glFeatures.workItemsMvc2"
+ class="gl-mb-5"
+ :full-path="fullPath"
+ :iteration="workItemIteration.iteration"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="$emit('error', $event)"
+ />
+ <work-item-iteration-inline
+ v-else
+ class="gl-mb-5"
+ :full-path="fullPath"
+ :iteration="workItemIteration.iteration"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="$emit('error', $event)"
+ />
+ </template>
<template v-if="workItemHealthStatus">
<work-item-health-status
v-if="glFeatures.workItemsMvc2"
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index 77c573b47e4..4301dcca30b 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -40,12 +40,27 @@ export default {
type: String,
required: true,
},
+ disableInlineEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ editMode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ updateInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
markdownDocsPath: helpPagePath('user/markdown'),
data() {
return {
workItem: {},
- isEditing: false,
+ isEditing: this.editMode,
isSubmitting: false,
isSubmittingWithKeydown: false,
descriptionText: '',
@@ -126,6 +141,26 @@ export default {
autocompleteDataSources() {
return autocompleteDataSources(this.fullPath, this.workItem.iid);
},
+ saveButtonText() {
+ return this.editMode ? __('Save changes') : __('Save');
+ },
+ formGroupClass() {
+ return {
+ 'gl-border-t gl-pt-6': !this.disableInlineEditing,
+ 'gl-mb-5 common-note-form': true,
+ };
+ },
+ },
+ watch: {
+ updateInProgress(newValue) {
+ this.isSubmitting = newValue;
+ },
+ editMode(newValue) {
+ this.isEditing = newValue;
+ if (newValue) {
+ this.startEditing();
+ }
+ },
},
methods: {
checkForConflicts() {
@@ -159,6 +194,7 @@ export default {
}
this.isEditing = false;
+ this.$emit('cancelEditing');
clearDraft(this.autosaveKey);
},
onInput() {
@@ -175,6 +211,11 @@ export default {
this.isSubmittingWithKeydown = true;
}
+ if (this.disableInlineEditing) {
+ this.$emit('updateWorkItem');
+ return;
+ }
+
this.isSubmitting = true;
try {
@@ -210,6 +251,9 @@ export default {
},
setDescriptionText(newText) {
this.descriptionText = newText;
+ if (this.disableInlineEditing) {
+ this.$emit('updateDraft', this.descriptionText);
+ }
updateDraft(this.autosaveKey, this.descriptionText);
},
handleDescriptionTextUpdated(newText) {
@@ -224,12 +268,13 @@ export default {
<div>
<gl-form v-if="isEditing" @submit.prevent="updateWorkItem" @reset.prevent="cancelEditing">
<gl-form-group
- class="gl-mb-5 gl-border-t gl-pt-6 common-note-form"
+ :class="formGroupClass"
:label="__('Description')"
+ :label-sr-only="disableInlineEditing"
label-for="work-item-description"
>
<markdown-editor
- class="gl-my-5"
+ class="gl-mb-5"
:value="descriptionText"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="$options.markdownDocsPath"
@@ -285,9 +330,9 @@ export default {
:loading="isSubmitting"
data-testid="save-description"
type="submit"
- >{{ __('Save') }}
+ >{{ saveButtonText }}
</gl-button>
- <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" type="reset"
+ <gl-button category="secondary" class="gl-ml-3" data-testid="cancel" type="reset"
>{{ __('Cancel') }}
</gl-button>
</template>
@@ -296,13 +341,14 @@ export default {
</gl-form>
<work-item-description-rendered
v-else
+ :disable-inline-editing="disableInlineEditing"
:work-item-description="workItemDescription"
:can-edit="canEdit"
@startEditing="startEditing"
@descriptionUpdated="handleDescriptionTextUpdated"
/>
<edited-at
- v-if="lastEditedAt"
+ v-if="lastEditedAt && !editMode"
:updated-at="lastEditedAt"
:updated-by-name="lastEditedByName"
:updated-by-path="lastEditedByPath"
diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
index 124e05db431..1699f6c419e 100644
--- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
@@ -22,6 +22,16 @@ export default {
type: Boolean,
required: true,
},
+ disableInlineEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ checkboxes: [],
+ };
},
computed: {
descriptionText() {
@@ -33,6 +43,12 @@ export default {
descriptionEmpty() {
return this.descriptionHtml?.trim() === '';
},
+ showEmptyDescription() {
+ return this.descriptionEmpty && !this.disableInlineEditing;
+ },
+ showEditButton() {
+ return this.canEdit && !this.disableInlineEditing;
+ },
},
watch: {
descriptionHtml: {
@@ -96,9 +112,11 @@ export default {
<template>
<div class="gl-mb-5">
<div class="gl-display-inline-flex gl-align-items-center gl-mb-3">
- <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
+ <label v-if="!disableInlineEditing" class="d-block col-form-label gl-mr-5">{{
+ __('Description')
+ }}</label>
<gl-button
- v-if="canEdit"
+ v-if="showEditButton"
v-gl-tooltip
class="gl-ml-auto"
icon="pencil"
@@ -109,9 +127,9 @@ export default {
/>
</div>
- <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
+ <div v-if="showEmptyDescription" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
<div
- v-else
+ v-else-if="!descriptionEmpty"
ref="gfm-content"
v-safe-html="descriptionHtml"
class="md gl-mb-5 gl-min-h-8 gl-clearfix"
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index b74cbc85379..85b981d9370 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -2,6 +2,7 @@
import { isEmpty } from 'lodash';
import { GlAlert, GlSkeletonLoader, GlButton, GlTooltipDirective, GlEmptyState } from '@gitlab/ui';
import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg?raw';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { s__ } from '~/locale';
import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -18,6 +19,7 @@ import {
WIDGET_TYPE_AWARD_EMOJI,
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
+ WORK_ITEM_TYPE_VALUE_EPIC,
WIDGET_TYPE_NOTES,
WIDGET_TYPE_LINKED_ITEMS,
} from '../constants';
@@ -41,6 +43,7 @@ import WorkItemAwardEmoji from './work_item_award_emoji.vue';
import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue';
import WorkItemStickyHeader from './work_item_sticky_header.vue';
import WorkItemAncestors from './work_item_ancestors/work_item_ancestors.vue';
+import WorkItemTitleWithEdit from './work_item_title_with_edit.vue';
export default {
i18n,
@@ -67,6 +70,7 @@ export default {
WorkItemRelationships,
WorkItemStickyHeader,
WorkItemAncestors,
+ WorkItemTitleWithEdit,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath', 'isGroup', 'reportAbusePath'],
@@ -94,6 +98,8 @@ export default {
reportedUrl: '',
reportedUserId: 0,
isStickyHeaderShowing: false,
+ editMode: false,
+ draftData: {},
};
},
apollo: {
@@ -219,7 +225,7 @@ export default {
};
},
showIntersectionObserver() {
- return !this.isModal && this.workItemsMvc2Enabled;
+ return !this.isModal && this.workItemsMvc2Enabled && !this.editMode;
},
hasLinkedWorkItems() {
return this.glFeatures.linkedWorkItems;
@@ -227,19 +233,26 @@ export default {
workItemLinkedItems() {
return this.isWidgetPresent(WIDGET_TYPE_LINKED_ITEMS);
},
+ showWorkItemTree() {
+ return [WORK_ITEM_TYPE_VALUE_OBJECTIVE, WORK_ITEM_TYPE_VALUE_EPIC].includes(
+ this.workItemType,
+ );
+ },
showWorkItemLinkedItems() {
return this.hasLinkedWorkItems && this.workItemLinkedItems;
},
titleClassHeader() {
return {
'gl-sm-display-none!': this.parentWorkItem,
- 'gl-w-full': !this.parentWorkItem,
+ 'gl-w-full': !this.parentWorkItem && !this.editMode,
+ 'editable-wi-title': this.editMode && !this.parentWorkItem,
};
},
titleClassComponent() {
return {
'gl-sm-display-block!': !this.parentWorkItem,
'gl-display-none gl-sm-display-block!': this.parentWorkItem,
+ 'gl-mt-3 editable-wi-title': this.workItemsMvc2Enabled,
};
},
headerWrapperClass() {
@@ -258,6 +271,9 @@ export default {
}
},
methods: {
+ enableEditMode() {
+ this.editMode = true;
+ },
isWidgetPresent(type) {
return this.workItem.widgets?.find((widget) => widget.type === type);
},
@@ -349,6 +365,45 @@ export default {
this.isStickyHeaderShowing = true;
}
},
+ updateDraft(type, value) {
+ this.draftData[type] = value;
+ },
+ async updateWorkItem() {
+ this.updateInProgress = true;
+ try {
+ const {
+ data: { workItemUpdate },
+ } = await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItem.id,
+ title: this.draftData.title,
+ descriptionWidget: {
+ description: this.draftData.description,
+ },
+ },
+ },
+ });
+
+ const { errors } = workItemUpdate;
+
+ if (errors?.length) {
+ this.updateError = errors.join('\n');
+ throw new Error(this.updateError);
+ }
+
+ this.editMode = false;
+ } catch (error) {
+ Sentry.captureException(error);
+ } finally {
+ this.updateInProgress = false;
+ }
+ },
+ cancelEditing() {
+ this.draftData = {};
+ this.editMode = false;
+ },
},
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
WORKSPACE_PROJECT,
@@ -388,8 +443,17 @@ export default {
:class="titleClassHeader"
data-testid="work-item-type"
>
+ <work-item-title-with-edit
+ v-if="workItem.title && workItemsMvc2Enabled"
+ ref="title"
+ class="gl-mt-3 gl-sm-display-block!"
+ :is-editing="editMode"
+ :title="workItem.title"
+ @updateWorkItem="updateWorkItem"
+ @updateDraft="updateDraft('title', $event)"
+ />
<work-item-title
- v-if="workItem.title"
+ v-else-if="workItem.title"
ref="title"
class="gl-sm-display-block!"
:work-item-id="workItem.id"
@@ -402,6 +466,14 @@ export default {
<div
class="detail-page-header-actions gl-display-flex gl-align-self-start gl-ml-auto gl-gap-3"
>
+ <gl-button
+ v-if="workItemsMvc2Enabled && !editMode"
+ category="secondary"
+ data-testid="work-item-edit-form-button"
+ @click="enableEditMode"
+ >
+ {{ __('Edit') }}
+ </gl-button>
<work-item-todos
v-if="showWorkItemCurrentUserTodos"
:work-item-id="workItem.id"
@@ -441,8 +513,17 @@ export default {
/>
</div>
<div>
+ <work-item-title-with-edit
+ v-if="workItem.title && workItemsMvc2Enabled && parentWorkItem"
+ ref="title"
+ :is-editing="editMode"
+ :class="titleClassComponent"
+ :title="workItem.title"
+ @updateWorkItem="updateWorkItem"
+ @updateDraft="updateDraft('title', $event)"
+ />
<work-item-title
- v-if="workItem.title && parentWorkItem"
+ v-else-if="workItem.title && parentWorkItem"
ref="title"
:class="titleClassComponent"
:work-item-id="workItem.id"
@@ -453,6 +534,7 @@ export default {
@error="updateError = $event"
/>
<work-item-created-updated
+ v-if="!editMode"
:full-path="fullPath"
:work-item-iid="workItemIid"
:update-in-progress="updateInProgress"
@@ -490,10 +572,16 @@ export default {
/>
<work-item-description
v-if="hasDescriptionWidget"
+ :class="workItemsMvc2Enabled ? '' : 'gl-pt-5'"
+ :disable-inline-editing="workItemsMvc2Enabled"
+ :edit-mode="editMode"
:full-path="fullPath"
:work-item-id="workItem.id"
:work-item-iid="workItem.iid"
- class="gl-pt-5"
+ :update-in-progress="updateInProgress"
+ @updateWorkItem="updateWorkItem"
+ @updateDraft="updateDraft('description', $event)"
+ @cancelEditing="cancelEditing"
@error="updateError = $event"
/>
<work-item-award-emoji
@@ -506,7 +594,7 @@ export default {
@emoji-updated="$emit('work-item-emoji-updated', $event)"
/>
<work-item-tree
- v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
+ v-if="showWorkItemTree"
:full-path="fullPath"
:work-item-type="workItemType"
:parent-work-item-type="workItem.workItemType.name"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index f24b56cac36..cc46932539d 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -11,6 +11,7 @@ import {
import { __, s__, sprintf } from '~/locale';
import WorkItemTokenInput from '../shared/work_item_token_input.vue';
import { addHierarchyChild } from '../../graphql/cache_utils';
+import groupWorkItemTypesQuery from '../../graphql/group_work_item_types.query.graphql';
import projectWorkItemTypesQuery from '../../graphql/project_work_item_types.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql';
@@ -90,7 +91,9 @@ export default {
},
apollo: {
workItemTypes: {
- query: projectWorkItemTypesQuery,
+ query() {
+ return this.isGroup ? groupWorkItemTypesQuery : projectWorkItemTypesQuery;
+ },
variables() {
return {
fullPath: this.fullPath,
diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone_inline.vue
index dbeb3d4d3ff..dbeb3d4d3ff 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone_inline.vue
diff --git a/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue
new file mode 100644
index 00000000000..9588d21a3c5
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_milestone_with_edit.vue
@@ -0,0 +1,203 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import Tracking from '~/tracking';
+import { s__, __ } from '~/locale';
+import { MILESTONE_STATE } from '~/sidebar/constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue';
+import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import {
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ sprintfWorkItem,
+ TRACKING_CATEGORY_SHOW,
+} from '../constants';
+
+export default {
+ i18n: {
+ milestone: s__('WorkItem|Milestone'),
+ none: s__('WorkItem|None'),
+ noMilestone: s__('WorkItem|No milestone'),
+ milestoneFetchError: s__(
+ 'WorkItem|Something went wrong while fetching milestones. Please try again.',
+ ),
+ expiredText: __('(expired)'),
+ },
+ components: {
+ WorkItemSidebarDropdownWidgetWithEdit,
+ GlLink,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemMilestone: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ shouldFetch: false,
+ updateInProgress: false,
+ milestones: [],
+ localMilestone: this.workItemMilestone,
+ };
+ },
+ computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_milestone',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ emptyPlaceholder() {
+ return this.canUpdate ? this.$options.i18n.noMilestone : this.$options.i18n.none;
+ },
+ expired() {
+ return this.localMilestone?.expired ? ` ${this.$options.i18n.expiredText}` : '';
+ },
+ dropdownText() {
+ return this.localMilestone?.title
+ ? `${this.localMilestone?.title}${this.expired}`
+ : this.emptyPlaceholder;
+ },
+ isLoadingMilestones() {
+ return this.$apollo.queries.milestones.loading;
+ },
+ milestonesList() {
+ return this.milestones.map(({ id, title, expired }) => ({
+ value: id,
+ text: title,
+ expired,
+ }));
+ },
+ },
+ apollo: {
+ milestones: {
+ query: projectMilestonesQuery,
+ debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ title: this.searchTerm,
+ state: MILESTONE_STATE.ACTIVE,
+ first: 20,
+ };
+ },
+ skip() {
+ return !this.shouldFetch;
+ },
+ update(data) {
+ return data?.workspace?.attributes?.nodes || [];
+ },
+ error() {
+ this.$emit('error', this.i18n.milestoneFetchError);
+ },
+ },
+ },
+ methods: {
+ onDropdownShown() {
+ this.searchTerm = '';
+ this.shouldFetch = true;
+ },
+ search(searchTerm) {
+ this.searchTerm = searchTerm;
+ this.shouldFetch = true;
+ },
+ itemExpiredText(item) {
+ return item.expired ? this.$options.i18n.expiredText : '';
+ },
+ updateMilestone(selectedMilestoneId) {
+ if (this.localMilestone?.id === selectedMilestoneId) {
+ return;
+ }
+
+ this.localMilestone = selectedMilestoneId
+ ? this.milestones.find(({ id }) => id === selectedMilestoneId)
+ : null;
+
+ this.track('updated_milestone');
+ this.updateInProgress = true;
+
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ milestoneWidget: {
+ milestoneId: selectedMilestoneId,
+ },
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.workItemUpdate.errors.length) {
+ throw new Error(data.workItemUpdate.errors.join('\n'));
+ }
+ })
+ .catch((error) => {
+ this.localMilestone = this.workItemMilestone;
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+ this.$emit('error', msg);
+ Sentry.captureException(error);
+ })
+ .finally(() => {
+ this.updateInProgress = false;
+ this.searchTerm = '';
+ this.shouldFetch = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <work-item-sidebar-dropdown-widget-with-edit
+ :dropdown-label="$options.i18n.milestone"
+ :can-update="canUpdate"
+ dropdown-name="milestone"
+ :loading="isLoadingMilestones"
+ :list-items="milestonesList"
+ :item-value="localMilestone"
+ :update-in-progress="updateInProgress"
+ :toggle-dropdown-text="dropdownText"
+ :header-text="__('Select milestone')"
+ :reset-button-label="__('Clear')"
+ data-testid="work-item-milestone-with-edit"
+ @dropdownShown="onDropdownShown"
+ @searchStarted="search"
+ @updateValue="updateMilestone"
+ >
+ <template #list-item="{ item }">
+ <div>{{ item.text }}{{ itemExpiredText(item) }}</div>
+ <div v-if="item.title">{{ item.title }}</div>
+ </template>
+ <template #readonly>
+ <gl-link class="gl-text-gray-900!" :href="localMilestone.webPath">
+ {{ localMilestone.title }}{{ expired }}
+ </gl-link>
+ </template>
+ </work-item-sidebar-dropdown-widget-with-edit>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_parent_inline.vue b/app/assets/javascripts/work_items/components/work_item_parent_inline.vue
index 0c0842a3e05..bb75de677c3 100644
--- a/app/assets/javascripts/work_items/components/work_item_parent_inline.vue
+++ b/app/assets/javascripts/work_items/components/work_item_parent_inline.vue
@@ -108,7 +108,7 @@ export default {
types: this.parentType,
in: this.search ? 'TITLE' : undefined,
iid: null,
- isNumber: false,
+ searchByIid: false,
};
},
skip() {
diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue
index c98bd6ce1e9..10c59d677f7 100644
--- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue
@@ -172,7 +172,7 @@ export default {
relatedToLabel: s__('WorkItem|relates to'),
blockingLabel: s__('WorkItem|blocks'),
blockedByLabel: s__('WorkItem|is blocked by'),
- linkItemInputLabel: s__('WorkItem|the following item(s)'),
+ linkItemInputLabel: s__('WorkItem|the following items'),
addLinkedItemErrorMessage: s__(
'WorkItem|Something went wrong when trying to link a item. Please try again.',
),
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 69752967efe..48884be54f6 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
@@ -38,6 +38,11 @@ export default {
required: false,
default: false,
},
+ hasComment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -49,9 +54,15 @@ export default {
return this.workItemState === STATE_OPEN;
},
toggleWorkItemStateText() {
- const baseText = this.isWorkItemOpen
+ let baseText = this.isWorkItemOpen
? __('Close %{workItemType}')
: __('Reopen %{workItemType}');
+
+ if (this.hasComment) {
+ baseText = this.isWorkItemOpen
+ ? __('Comment & close %{workItemType}')
+ : __('Comment & reopen %{workItemType}');
+ }
return sprintfWorkItem(baseText, this.workItemType);
},
tracking() {
@@ -96,6 +107,10 @@ export default {
Sentry.captureException(error);
}
+ if (this.hasComment) {
+ this.$emit('submit-comment');
+ }
+
this.updateInProgress = false;
},
},
diff --git a/app/assets/javascripts/work_items/components/work_item_title_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_title_with_edit.vue
new file mode 100644
index 00000000000..6af564a6a91
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_title_with_edit.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ i18n: {
+ titleLabel: __('Title (required)'),
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group v-if="isEditing" :label="$options.i18n.titleLabel" label-for="work-item-title">
+ <gl-form-input
+ class="gl-w-full"
+ :value="title"
+ data-testid="work-item-title-with-edit"
+ @keydown.meta.enter="$emit('updateWorkItem')"
+ @keydown.ctrl.enter="$emit('updateWorkItem')"
+ @input="$emit('updateDraft', $event)"
+ />
+ </gl-form-group>
+ <h1
+ v-else
+ data-testid="work-item-title"
+ class="gl-w-full gl-font-weight-normal gl-sm-font-weight-bold gl-mb-1 gl-mt-0 gl-font-size-h-display"
+ >
+ {{ title }}
+ </h1>
+</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 41cf5d8932d..62fdc8a21c2 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -46,6 +46,9 @@ export const WORK_ITEM_TYPE_VALUE_REQUIREMENTS = 'Requirements';
export const WORK_ITEM_TYPE_VALUE_KEY_RESULT = 'Key Result';
export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective';
+export const NAMESPACE_GROUP = 'group';
+export const NAMESPACE_PROJECT = 'project';
+
export const WORK_ITEM_TITLE_MAX_LENGTH = 255;
export const i18n = {
@@ -91,10 +94,13 @@ export const I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR = s__(
export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}');
export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}');
export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s');
-export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__('WorkItem|Search existing items');
+export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__(
+ 'WorkItem|Search existing items, paste URL, or enter reference ID',
+);
export const I18N_WORK_ITEM_SEARCH_ERROR = s__(
'WorkItem|Something went wrong while fetching the %{workItemType}. Please try again.',
);
+export const I18N_WORK_ITEM_NO_MATCHES_FOUND = s__('WorkItem|No matches found');
export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL = s__(
'WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access',
);
@@ -195,6 +201,8 @@ export const WORK_ITEMS_TYPE_MAP = {
export const WORK_ITEM_TYPE_VALUE_MAP = {
[WORK_ITEM_TYPE_VALUE_OBJECTIVE]: WORK_ITEM_TYPE_ENUM_OBJECTIVE,
[WORK_ITEM_TYPE_VALUE_KEY_RESULT]: WORK_ITEM_TYPE_ENUM_KEY_RESULT,
+ [WORK_ITEM_TYPE_VALUE_ISSUE]: WORK_ITEM_TYPE_ENUM_ISSUE,
+ [WORK_ITEM_TYPE_VALUE_EPIC]: WORK_ITEM_TYPE_ENUM_EPIC,
};
export const WORK_ITEMS_TREE_TEXT_MAP = {
@@ -208,9 +216,14 @@ export const WORK_ITEMS_TREE_TEXT_MAP = {
'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.',
),
},
+ [WORK_ITEM_TYPE_VALUE_EPIC]: {
+ title: s__('WorkItem|Child items'),
+ empty: s__('WorkItem|No epics or issues are currently assigned.'),
+ },
};
export const WORK_ITEM_NAME_TO_ICON_MAP = {
+ Epic: 'epic',
Issue: 'issue-type-issue',
Task: 'issue-type-task',
Objective: 'issue-type-objective',
diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
index 7efd67467e5..17b338f7a8d 100644
--- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
@@ -4,11 +4,12 @@ query projectWorkItems(
$types: [IssueType!]
$in: [IssuableSearchableField!]
$iid: String = null
- $isNumber: Boolean!
+ $searchByIid: Boolean = false
+ $searchByText: Boolean = true
) {
workspace: project(fullPath: $fullPath) {
id
- workItems(search: $searchTerm, types: $types, in: $in) {
+ workItems(search: $searchTerm, types: $types, in: $in) @include(if: $searchByText) {
nodes {
id
iid
@@ -16,7 +17,7 @@ query projectWorkItems(
confidential
}
}
- workItemsByIid: workItems(iid: $iid, types: $types) @include(if: $isNumber) {
+ workItemsByIid: workItems(iid: $iid, types: $types) @include(if: $searchByIid) {
nodes {
id
iid
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index ef43b9c026d..c1ec3fe276f 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -12,6 +12,7 @@ fragment WorkItem on WorkItem {
createdAt
updatedAt
closedAt
+ webUrl
reference(full: true)
createNoteEmail
namespace {
diff --git a/app/assets/javascripts/work_items/graphql/work_items_by_references.query.graphql b/app/assets/javascripts/work_items/graphql/work_items_by_references.query.graphql
new file mode 100644
index 00000000000..1e8d62596b7
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_items_by_references.query.graphql
@@ -0,0 +1,10 @@
+query getWorkItemsByReferences($contextNamespacePath: ID!, $refs: [String!]!) {
+ workItemsByReference(contextNamespacePath: $contextNamespacePath, refs: $refs) {
+ nodes {
+ id
+ iid
+ title
+ confidential
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
index c3c292c3dd9..6d304e7ebf0 100644
--- a/app/assets/javascripts/work_items/utils.js
+++ b/app/assets/javascripts/work_items/utils.js
@@ -55,3 +55,14 @@ export const markdownPreviewPath = (fullPath, iid) =>
`${
gon.relative_url_root || ''
}/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`;
+
+export const isReference = (input) => {
+ /**
+ * The regular expression checks if the `value` is
+ * a project work item or group work item.
+ * e.g., gitlab-org/project-path#101 or gitlab-org&101
+ * or #1234
+ */
+
+ return /^([\w-]+(?:\/[\w-]+)*)?[#&](\d+)$/.test(input);
+};
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index ce8ccb2bc08..1b99a27b12c 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -13,10 +13,10 @@
@import 'page_specific_files';
// Component specific styles, will be moved to gitlab-ui
-@import 'components/**/*';
+@import 'components/index';
// Vendors specific styles
-@import 'vendors/**/*';
+@import 'vendors/index';
// Styles for JS behaviors.
@import 'behaviors';
@@ -27,7 +27,5 @@
// JH-only stylesheets
@import 'application_jh';
-/* print styles */
-@media print {
- @import 'print';
-}
+// print styles
+@import 'print';
diff --git a/app/assets/stylesheets/components/_index.scss b/app/assets/stylesheets/components/_index.scss
new file mode 100644
index 00000000000..f53837b5671
--- /dev/null
+++ b/app/assets/stylesheets/components/_index.scss
@@ -0,0 +1,11 @@
+@import './avatar';
+@import './collapsible_card';
+@import './content_editor';
+@import './deployment_instance';
+@import './detail_page';
+@import './ref_selector';
+@import './related_items_list';
+@import './severity/icons';
+@import './shortcuts_help';
+@import './upload_dropzone/upload_dropzone';
+@import './whats_new';
diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss
index 97f2add4e77..c654eb16af5 100644
--- a/app/assets/stylesheets/components/content_editor.scss
+++ b/app/assets/stylesheets/components/content_editor.scss
@@ -35,6 +35,10 @@
background-color: transparent;
}
+ th[align] *, td[align] * {
+ text-align: inherit;
+ }
+
td,
th,
li,
@@ -149,6 +153,11 @@
padding: $gl-spacing-scale-1 $gl-spacing-scale-3 0 0;
margin: 0;
}
+
+ &[data-inapplicable] * {
+ text-decoration: line-through;
+ color: $gl-text-color-disabled;
+ }
}
}
diff --git a/app/assets/stylesheets/emoji_sprites.scss b/app/assets/stylesheets/emoji_sprites.scss
index 5a5f39a4b77..10bf54b4ffb 100644
--- a/app/assets/stylesheets/emoji_sprites.scss
+++ b/app/assets/stylesheets/emoji_sprites.scss
@@ -7176,7 +7176,7 @@
}
.emoji-icon {
- background-image: image-url('emoji.png');
+ background-image: url('emoji.png');
background-repeat: no-repeat;
color: transparent;
text-indent: -99em;
@@ -7190,7 +7190,7 @@
only screen and (min-device-pixel-ratio: 2),
only screen and (min-resolution: 192dpi),
only screen and (min-resolution: 2dppx) {
- background-image: image-url('emoji@2x.png');
+ background-image: url('emoji@2x.png');
background-size: 860px 840px;
}
/* stylelint-enable media-feature-name-no-vendor-prefix */
diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss
index 6886e751b72..f776328ebdf 100644
--- a/app/assets/stylesheets/fonts.scss
+++ b/app/assets/stylesheets/fonts.scss
@@ -11,7 +11,7 @@ Usage:
font-style: normal;
/* stylelint-disable-next-line property-no-unknown */
font-named-instance: 'Regular';
- src: font-url('gitlab-sans/GitLabSans.woff2') format('woff2');
+ src: url('gitlab-sans/GitLabSans.woff2') format('woff2');
}
@font-face {
@@ -21,7 +21,7 @@ Usage:
font-style: italic;
/* stylelint-disable-next-line property-no-unknown */
font-named-instance: 'Regular';
- src: font-url('gitlab-sans/GitLabSans-Italic.woff2') format('woff2');
+ src: url('gitlab-sans/GitLabSans-Italic.woff2') format('woff2');
}
/* -------------------------------------------------------
@@ -35,7 +35,7 @@ Usage:
font-weight: 100 900;
font-display: swap;
font-style: normal;
- src: font-url('gitlab-mono/GitLabMono.woff2') format('woff2');
+ src: url('gitlab-mono/GitLabMono.woff2') format('woff2');
}
@font-face {
@@ -43,7 +43,7 @@ Usage:
font-weight: 100 900;
font-display: swap;
font-style: italic;
- src: font-url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2');
+ src: url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2');
}
// This isn't the best solution, but we needed a quick fix
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 6f4f7a29334..dd4b6f51ebe 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -10,6 +10,7 @@
@import 'framework/animations';
@import 'framework/vue_transitions';
@import 'framework/blocks';
+@import 'framework/breadcrumbs';
@import 'framework/buttons';
@import 'framework/badges';
@import 'framework/calendar';
@@ -23,7 +24,9 @@
@import 'framework/gfm';
@import 'framework/kbd';
@import 'framework/header';
+@import 'framework/top_bar';
@import 'framework/highlight';
+@import 'framework/labels';
@import 'framework/lists';
@import 'framework/logo';
@import 'framework/markdown_area';
diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss
index 3f1d742ca14..7c3684f7c2e 100644
--- a/app/assets/stylesheets/framework/badges.scss
+++ b/app/assets/stylesheets/framework/badges.scss
@@ -12,3 +12,56 @@
color: $green;
}
}
+
+// FF :simplified_badges
+//
+// Temporarily override badge styles
+// globally
+//
+// Once verified we will update the
+// badge component in GitLab UI
+// refactor GitLab and remove this
+// custom code
+//
+// see https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3307
+.ff-simplified-badges-enabled {
+ // These changes will be moved to
+ // GitLab UI's badge component
+ .gl-badge,
+ .gl-badge.sm,
+ .gl-badge.md,
+ .gl-badge.lg {
+ @include gl-font-sm;
+ padding-block: $gl-spacing-scale-1;
+ padding-inline: calc(#{$gl-spacing-scale-3} - 2px);
+
+ > .gl-icon {
+ @include gl-ml-0;
+ }
+ }
+
+ // These changes will be moved to
+ // GitLab UI's button component
+ .gl-button .gl-badge {
+ @include gl-py-0;
+ }
+
+ // These changes will be moved to
+ // app/assets/stylesheets/framework/super_sidebar.scss
+ .super-sidebar-nav-item .gl-badge {
+ vertical-align: 2px;
+ }
+
+ // These changes will be moved to
+ // GitLab UI's tab component
+ .gl-tab-nav-item .gl-badge {
+ margin-block: -2px;
+ }
+
+ // Temporarily needed because of the
+ // speciality this FF adds
+ // the utility class gets overriden
+ .gl-badge.ci-icon {
+ @include gl-p-2;
+ }
+}
diff --git a/app/assets/stylesheets/framework/breadcrumbs.scss b/app/assets/stylesheets/framework/breadcrumbs.scss
new file mode 100644
index 00000000000..b71382f5570
--- /dev/null
+++ b/app/assets/stylesheets/framework/breadcrumbs.scss
@@ -0,0 +1,13 @@
+.breadcrumbs {
+ flex: 1;
+ min-width: 0;
+ align-self: center;
+ color: $gl-text-color-secondary;
+
+ .avatar-tile {
+ margin-right: 4px;
+ border: 1px solid $border-color;
+ border-radius: 50%;
+ vertical-align: sub;
+ }
+}
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index b948a57ea33..497a8a08a6f 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -225,7 +225,7 @@ $diff-file-header: 41px;
width: 15px;
position: absolute;
top: 0;
- background: image-url('swipemode_sprites.gif') 0 3px no-repeat;
+ background: url('swipemode_sprites.gif') 0 3px no-repeat;
}
.bottom-handle {
@@ -234,7 +234,7 @@ $diff-file-header: 41px;
width: 15px;
position: absolute;
bottom: 0;
- background: image-url('swipemode_sprites.gif') 0 -11px no-repeat;
+ background: url('swipemode_sprites.gif') 0 -11px no-repeat;
}
}
}
@@ -272,7 +272,7 @@ $diff-file-header: 41px;
left: 12px;
height: 10px;
width: 276px;
- background: image-url('onion_skin_sprites.gif') -4px -20px repeat-x;
+ background: url('onion_skin_sprites.gif') -4px -20px repeat-x;
}
.dragger {
@@ -282,7 +282,7 @@ $diff-file-header: 41px;
top: 0;
height: 14px;
width: 14px;
- background: image-url('onion_skin_sprites.gif') 0 -34px repeat-x;
+ background: url('onion_skin_sprites.gif') 0 -34px repeat-x;
cursor: pointer;
}
@@ -293,7 +293,7 @@ $diff-file-header: 41px;
right: 0;
height: 10px;
width: 10px;
- background: image-url('onion_skin_sprites.gif') -2px 0 no-repeat;
+ background: url('onion_skin_sprites.gif') -2px 0 no-repeat;
}
.opaque {
@@ -303,7 +303,7 @@ $diff-file-header: 41px;
left: 0;
height: 10px;
width: 10px;
- background: image-url('onion_skin_sprites.gif') -2px -10px no-repeat;
+ background: url('onion_skin_sprites.gif') -2px -10px no-repeat;
}
}
}
@@ -770,12 +770,12 @@ table.code {
.frame.click-to-comment,
.btn-transparent.image-diff-overlay-add-comment {
position: relative;
- cursor: image-url('illustrations/image_comment_light_cursor.svg') $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
+ cursor: url('illustrations/image_comment_light_cursor.svg') $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
// Retina cursor
- cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x,
- image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
+ cursor: image-set(url('illustrations/image_comment_light_cursor.svg') 1x,
+ url('illustrations/image_comment_light_cursor@2x.svg') 2x) $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
.comment-indicator {
@@ -944,3 +944,12 @@ table.code {
left: -2px !important;
}
}
+
+.diff-file.pinned-file .file-title {
+ background-color: $blue-50;
+ border-color: $blue-200;
+}
+
+.diff-file.pinned-file .diff-content {
+ border-color: $blue-200;
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index e791a0dbbbd..2558ddec9b9 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -575,7 +575,7 @@
left: 1rem;
width: 1rem;
height: 1rem;
- mask-image: asset_url('icons-stacked.svg#check');
+ mask-image: url('icons-stacked.svg#check');
mask-repeat: no-repeat;
mask-size: cover;
mask-position: center center;
@@ -806,28 +806,6 @@
}
}
-@include media-breakpoint-down(xs) {
- .navbar-gitlab {
- li.dropdown {
- position: static;
- }
- }
-
- header.navbar-gitlab .dropdown {
- .dropdown-menu {
- width: 100%;
- min-width: 100%;
- }
- }
-
- header.navbar-gitlab-new .header-content .dropdown {
- .dropdown-menu {
- left: 0;
- min-width: 100%;
- }
- }
-}
-
.dropdown-content-faded-mask {
position: relative;
@@ -959,3 +937,17 @@
width: 100%;
}
}
+
+.group-namespace-dropdown .gl-new-dropdown-custom-toggle {
+ display: flex;
+ flex: auto;
+
+ .gl-button-text {
+ display: flex;
+ @include gl-w-full;
+ }
+}
+
+.group-namespace-dropdown .gl-new-dropdown-item-text-wrapper {
+ word-break: break-word;
+}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 9cb264c992b..7dcde5f0b3c 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -420,7 +420,7 @@ span.idiff {
@include gl-h-5;
@include gl-float-left;
background-color: $gray-400;
- mask-image: asset_url('icons-stacked.svg#doc-versions');
+ mask-image: url('icons-stacked.svg#doc-versions');
mask-repeat: no-repeat;
mask-size: cover;
mask-position: center;
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index 6b4f1478978..56667c10752 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -96,14 +96,6 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
}
}
-@include media-breakpoint-down(sm) {
- ul.notes {
- .flash-container.timeline-content {
- margin-left: 0;
- }
- }
-}
-
.gl-browser-ie .flash-container {
position: fixed;
max-width: $limited-layout-width;
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 23f40dfe4bf..84e69e40bc2 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -9,119 +9,6 @@
left: 0;
right: 0;
border-radius: 0;
-
- .close-icon {
- display: none;
- }
-
- .header-content {
- width: 100%;
- display: flex;
- justify-content: space-between;
- position: relative;
- min-height: var(--header-height);
- padding-left: 0;
-
- .title {
- padding-right: 0;
- color: currentColor;
- display: flex;
- position: relative;
- margin: 0;
- font-size: 18px;
- vertical-align: top;
- white-space: nowrap;
-
- img {
- height: 24px;
-
- + .logo-text {
- margin-left: 8px;
- }
- }
-
- &.wrap {
- white-space: normal;
- }
-
- &.initializing {
- opacity: 0;
- }
-
- a:not(.canary-badge) {
- display: flex;
- align-items: center;
- padding: 2px 8px;
- margin: 4px 2px 4px -8px;
- border-radius: $border-radius-default;
-
- &:active,
- &:focus {
- @include gl-focus($focus-ring: $focus-ring-dark);
- }
- }
- }
-
- .dropdown.open {
- > a {
- border-bottom-color: $white;
- }
- }
- }
-
- .container-fluid {
- padding: 0;
-
- .nav > li {
- > a {
- will-change: color;
- margin: 4px 0;
- padding: 6px 8px;
- height: 32px;
- }
- }
- }
-}
-
-.top-bar-container {
- min-height: $top-bar-height;
-}
-
-.top-bar-fixed {
- @include gl-inset-border-b-1-gray-100;
- background-color: $body-bg;
- left: var(--application-bar-left);
- position: fixed;
- right: var(--application-bar-right);
- top: $calc-application-bars-height;
- width: auto;
- z-index: $top-bar-z-index;
-
- @media (prefers-reduced-motion: no-preference) {
- transition: left $gl-transition-duration-medium, right $gl-transition-duration-medium;
- }
-}
-
-.breadcrumbs {
- flex: 1;
- min-width: 0;
- align-self: center;
- color: $gl-text-color-secondary;
-
- .avatar-tile {
- margin-right: 4px;
- border: 1px solid $border-color;
- border-radius: 50%;
- vertical-align: sub;
- }
-}
-
-.breadcrumb-item-text {
- text-decoration: inherit;
-
- @include media-breakpoint-down(xs) {
- @include str-truncated(128px);
- }
}
.navbar-empty {
@@ -173,17 +60,6 @@
@include media-breakpoint-down(xs) { margin-right: 3px; }
}
-.top-nav-menu-item {
- &.active,
- &:hover {
- background-color: $nav-active-bg !important;
- }
-
- .gl-icon {
- color: inherit !important;
- }
-}
-
.header-logged-out {
z-index: $header-zindex;
min-height: var(--header-height);
diff --git a/app/assets/stylesheets/framework/labels.scss b/app/assets/stylesheets/framework/labels.scss
new file mode 100644
index 00000000000..1933af5151c
--- /dev/null
+++ b/app/assets/stylesheets/framework/labels.scss
@@ -0,0 +1,56 @@
+// FF :simplified_labels
+//
+// Temporarily override label styles
+// globally
+//
+// Once verified we will update the
+// label component in GitLab UI
+// refactor GitLab and remove this
+// custom code
+//
+// see https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3307
+.ff-simplified-labels-enabled {
+ // These changes will be moved to
+ // GitLab UI's label component
+ .gl-label,
+ .gl-label-sm {
+ @include gl-vertical-align-bottom;
+
+ &:focus:active {
+ @include gl-reset-color;
+ @include gl-shadow-none;
+ @include gl-outline-none;
+ }
+
+ .gl-label-text,
+ .gl-label-text-scoped {
+ @include gl-font-sm;
+ padding-block: $gl-spacing-scale-1;
+ padding-inline: calc(#{$gl-spacing-scale-3} - 2px);
+ }
+
+ > .gl-label-close.gl-button {
+ width: px-to-rem(14px);
+ height: px-to-rem(14px);
+ margin-left: calc(#{-$gl-spacing-scale-2} - 1px);
+ margin-right: calc(#{$gl-spacing-scale-2} - 1px);
+ }
+ }
+
+ // These changes will be moved to
+ // app/assets/stylesheets/framework/sidebar.scss
+ .issuable-show-labels .gl-label {
+ margin-bottom: $gl-spacing-scale-2;
+ margin-right: $gl-spacing-scale-2;
+ }
+
+ // These changes will be moved to
+ // app/assets/stylesheets/framework/typography.scss
+ .md p > code {
+ font-size: px-to-rem(13px);
+ }
+
+ .md code {
+ @include gl-vertical-align-bottom;
+ }
+}
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 7ec13c3d54c..4ef53c673f6 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -1,6 +1,4 @@
html {
- overflow-y: scroll;
-
&.touch .tooltip {
display: none !important;
}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 0eecf7bddc1..04799a6b8f8 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -601,6 +601,7 @@
.gutter-toggle {
width: 100%;
height: $sidebar-toggle-height;
+ margin-top: 0;
margin-left: 0;
border-bottom: 1px solid $border-color;
border-radius: 0;
diff --git a/app/assets/stylesheets/framework/source_editor.scss b/app/assets/stylesheets/framework/source_editor.scss
index a09ab7ed64c..2b597634519 100644
--- a/app/assets/stylesheets/framework/source_editor.scss
+++ b/app/assets/stylesheets/framework/source_editor.scss
@@ -78,7 +78,7 @@
@include gl-mr-2;
@include gl-w-4;
@include gl-h-4;
- mask-image: asset_url('icons-stacked.svg#link');
+ mask-image: url('icons-stacked.svg#link');
mask-repeat: no-repeat;
mask-size: cover;
mask-position: center;
diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss
index 84f0612a7b4..5a9a739fb13 100644
--- a/app/assets/stylesheets/framework/super_sidebar.scss
+++ b/app/assets/stylesheets/framework/super_sidebar.scss
@@ -22,7 +22,8 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
.super-sidebar {
--super-sidebar-bg: #{$gray-10};
--super-sidebar-border-color: #{$t-gray-a-08};
- --super-sidebar-primary: #{$blue-500};
+ --super-sidebar-context-header-color: inherit;
+ --super-sidebar-active-indicator-color: #{$blue-500};
--super-sidebar-notification-dot: #{$blue-500};
--super-sidebar-user-bar-bg: #{$t-gray-a-04};
@@ -42,6 +43,8 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
--super-sidebar-nav-item-current-bg: #{$t-gray-a-08};
--super-sidebar-nav-item-icon-color: #{$gray-500};
+ --super-sidebar-hr-mix-blend-mode: normal;
+
.gl-dark & {
--super-sidebar-border-color: #{$t-white-a-08};
--super-sidebar-user-bar-bg: #{$t-white-a-04};
@@ -58,7 +61,148 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
--super-sidebar-nav-item-current-bg: #{$t-white-a-08};
--super-sidebar-nav-item-icon-color: #{$gray-600};
}
+}
+
+@mixin super-sidebar-theme(
+ $background,
+ $user-bar-background,
+ $user-bar-button-color,
+ $user-bar-button-icon-color,
+ $context-header,
+ $active-indicator,
+ $notification-dot,
+) {
+ .super-sidebar {
+ --super-sidebar-bg: #{$background};
+ --super-sidebar-user-bar-bg: #{$user-bar-background};
+ --super-sidebar-context-header-color: #{$context-header};
+ --super-sidebar-active-indicator-color: #{$active-indicator};
+ --super-sidebar-notification-dot: #{$notification-dot};
+
+ --super-sidebar-user-bar-button-bg: #{$t-white-a-16};
+ --super-sidebar-user-bar-button-color: #{$user-bar-button-color};
+ --super-sidebar-user-bar-button-border-color: #{$t-white-a-16};
+ --super-sidebar-user-bar-button-hover-bg: #{$t-white-a-24};
+ --super-sidebar-user-bar-button-hover-color: #{$white};
+ --super-sidebar-user-bar-button-active-bg: #{$t-white-a-36};
+
+ --super-sidebar-user-bar-button-icon-color: #{$user-bar-button-icon-color};
+ --super-sidebar-user-bar-button-icon-hover-color: #{$user-bar-button-icon-color};
+ --super-sidebar-user-bar-button-icon-mix-blend-mode: screen;
+
+ --super-sidebar-hr-mix-blend-mode: multiply;
+ }
+}
+
+.ui-blue {
+ @include super-sidebar-theme(
+ $background: $theme-blue-10,
+ $user-bar-background: $theme-blue-900,
+ $user-bar-button-color: $theme-blue-50,
+ $user-bar-button-icon-color: $theme-blue-100,
+ $context-header: $theme-blue-900,
+ $active-indicator: $theme-blue-900,
+ $notification-dot: $theme-blue-900,
+ );
+}
+
+.ui-gray {
+ @include super-sidebar-theme(
+ $background: $gray-10,
+ $user-bar-background: $gray-900,
+ $user-bar-button-color: $gray-50,
+ $user-bar-button-icon-color: $gray-100,
+ $context-header: $gray-900,
+ $active-indicator: $gray-900,
+ $notification-dot: $gray-900,
+ );
+}
+
+.ui-green {
+ @include super-sidebar-theme(
+ $background: $theme-green-10,
+ $user-bar-background: $theme-green-900,
+ $user-bar-button-color: $theme-green-50,
+ $user-bar-button-icon-color: $theme-green-100,
+ $context-header: $theme-green-900,
+ $active-indicator: $theme-green-900,
+ $notification-dot: $theme-green-900,
+ );
+}
+
+.ui-indigo {
+ @include super-sidebar-theme(
+ $background: $theme-indigo-10,
+ $user-bar-background: $theme-indigo-900,
+ $user-bar-button-color: $theme-indigo-50,
+ $user-bar-button-icon-color: $theme-indigo-100,
+ $context-header: $theme-indigo-900,
+ $active-indicator: $theme-indigo-900,
+ $notification-dot: $theme-indigo-900,
+ );
+}
+
+.ui-light-blue {
+ @include super-sidebar-theme(
+ $background: $theme-light-blue-10,
+ $user-bar-background: $theme-light-blue-700,
+ $user-bar-button-color: $theme-light-blue-50,
+ $user-bar-button-icon-color: $theme-light-blue-100,
+ $context-header: $theme-light-blue-900,
+ $active-indicator: $theme-light-blue-900,
+ $notification-dot: $theme-light-blue-900,
+ );
+}
+
+.ui-light-green {
+ @include super-sidebar-theme(
+ $background: $theme-green-10,
+ $user-bar-background: $theme-green-700,
+ $user-bar-button-color: $theme-green-50,
+ $user-bar-button-icon-color: $theme-green-100,
+ $context-header: $theme-green-900,
+ $active-indicator: $theme-green-900,
+ $notification-dot: $theme-green-900,
+ );
+}
+
+.ui-light-indigo {
+ @include super-sidebar-theme(
+ $background: $theme-indigo-10,
+ $user-bar-background: $theme-indigo-700,
+ $user-bar-button-color: $theme-indigo-50,
+ $user-bar-button-icon-color: $theme-indigo-100,
+ $context-header: $theme-indigo-900,
+ $active-indicator: $theme-indigo-900,
+ $notification-dot: $theme-indigo-900,
+ );
+}
+
+.ui-light-red {
+ @include super-sidebar-theme(
+ $background: $theme-light-red-10,
+ $user-bar-background: $theme-light-red-700,
+ $user-bar-button-color: $theme-light-red-50,
+ $user-bar-button-icon-color: $theme-light-red-100,
+ $context-header: $theme-light-red-900,
+ $active-indicator: $theme-light-red-900,
+ $notification-dot: $theme-light-red-900,
+ );
+}
+.ui-red {
+ @include super-sidebar-theme(
+ $background: $theme-red-10,
+ $user-bar-background: $theme-red-900,
+ $user-bar-button-color: $theme-red-50,
+ $user-bar-button-icon-color: $theme-red-100,
+ $context-header: $theme-red-900,
+ $active-indicator: $theme-red-900,
+ $notification-dot: $theme-red-900,
+ );
+}
+
+.super-sidebar {
display: flex;
flex-direction: column;
position: fixed;
@@ -167,8 +311,12 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
color: var(--super-sidebar-nav-item-icon-color);
}
+ hr {
+ mix-blend-mode: var(--super-sidebar-hr-mix-blend-mode);
+ }
+
.active-indicator {
- background-color: var(--super-sidebar-primary);
+ background-color: var(--super-sidebar-active-indicator-color);
}
.btn-with-notification {
@@ -200,6 +348,10 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
}
}
+.super-sidebar-context-header {
+ color: var(--super-sidebar-context-header-color);
+}
+
.super-sidebar-overlay {
display: none;
}
@@ -408,6 +560,13 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
}
}
+.super-sidebar-empty-pinned-text {
+ mix-blend-mode: multiply;
+
+ .gl-dark & {
+ mix-blend-mode: screen;
+ }
+}
// Styles for the ScrollScrim component.
// Should eventually be moved to gitlab-ui.
@@ -461,3 +620,17 @@ $scroll-scrim-height: 2.25rem;
opacity: 1;
}
}
+
+// Tweaks to the styles for the ScrollScrim component above (line 418)
+// are leaking into the collapsible list box dropdowns
+// https://gitlab.com/gitlab-org/gitlab/-/issues/435538
+
+.gl-new-dropdown {
+ .top-scrim-wrapper {
+ margin-bottom: 0;
+ }
+
+ .bottom-scrim-wrapper {
+ margin-top: 0;
+ }
+}
diff --git a/app/assets/stylesheets/framework/top_bar.scss b/app/assets/stylesheets/framework/top_bar.scss
new file mode 100644
index 00000000000..d4b36b82584
--- /dev/null
+++ b/app/assets/stylesheets/framework/top_bar.scss
@@ -0,0 +1,20 @@
+.top-bar-container {
+ min-height: $top-bar-height;
+}
+
+.top-bar-fixed {
+ @include gl-inset-border-b-1-gray-100;
+ background-color: $body-bg;
+ position: fixed;
+ left: var(--application-bar-left);
+ right: var(--application-bar-right);
+ top: $calc-application-bars-height;
+ width: calc(100% - var(--application-bar-left));
+ z-index: $top-bar-z-index;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: left $gl-transition-duration-medium,
+ right $gl-transition-duration-medium,
+ width $gl-transition-duration-medium;
+ }
+}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index eefdbda8f4f..15e794fc347 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -495,7 +495,7 @@
&::after {
@include gl-dark-invert-keep-hue;
- content: image-url('icon_anchor.svg');
+ content: url('icon_anchor.svg');
visibility: hidden;
}
}
@@ -602,6 +602,20 @@
}
@include email-code-block;
+
+ &.gl-text-secondary {
+ color: $gl-text-color-secondary;
+
+ p,
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ table:not(.code) {
+ color: $gl-text-color-secondary;
+ }
+ }
}
/**
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index 23fa1326881..3fd72904655 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -118,7 +118,7 @@
@include gl-w-5;
@include gl-h-5;
background-color: rgba($color, 0.3);
- mask-image: asset_url('icons-stacked.svg##{$icon}');
+ mask-image: url('icons-stacked.svg##{$icon}');
mask-repeat: no-repeat;
mask-size: cover;
mask-position: center;
diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss
index 8b353b42f58..05563f8e314 100644
--- a/app/assets/stylesheets/page_bundles/issuable.scss
+++ b/app/assets/stylesheets/page_bundles/issuable.scss
@@ -105,8 +105,3 @@
@include gl-font-weight-normal;
}
}
-
-[data-page="projects:issues:show"] .top-bar-fixed,
-[data-page="groups:epics:show"] .top-bar-fixed {
- width: 100%;
-}
diff --git a/app/assets/stylesheets/page_bundles/issuable_list.scss b/app/assets/stylesheets/page_bundles/issuable_list.scss
index 1ca0c5e7ce6..9084bffa951 100644
--- a/app/assets/stylesheets/page_bundles/issuable_list.scss
+++ b/app/assets/stylesheets/page_bundles/issuable_list.scss
@@ -90,12 +90,19 @@
.issuable-list li,
.issuable-info-container .controls {
.avatar-counter {
- display: inline-block;
- vertical-align: middle;
- min-width: 16px;
+ @include gl-pl-1
+ @include gl-pr-2;
+ @include gl-h-5;
+ @include gl-min-w-5;
line-height: 14px;
- height: 16px;
- padding-left: 2px;
- padding-right: 2px;
+ }
+}
+
+.merge-request {
+ .issuable-info-container .controls {
+ .avatar-counter {
+ @include gl-line-height-normal;
+ border: 0;
+ }
}
}
diff --git a/app/assets/stylesheets/page_bundles/labels.scss b/app/assets/stylesheets/page_bundles/labels.scss
index bc0bf4bc490..3204e678986 100644
--- a/app/assets/stylesheets/page_bundles/labels.scss
+++ b/app/assets/stylesheets/page_bundles/labels.scss
@@ -1,54 +1,5 @@
@import 'mixins_and_variables_and_functions';
-.suggest-colors {
- padding-top: 3px;
-
- a {
- border-radius: 4px;
- width: 30px;
- height: 30px;
- display: inline-block;
- margin-right: 10px;
- margin-bottom: 10px;
- text-decoration: none;
-
- &:focus,
- &:focus:active {
- position: relative;
- z-index: 1;
- @include gl-focus;
- }
- }
-
- &.suggest-colors-dropdown {
- margin-top: 10px;
- margin-bottom: 10px;
-
- a {
- border-radius: 0;
- width: (100% / 7);
- margin-right: 0;
- margin-bottom: -5px;
-
- &:first-of-type {
- border-top-left-radius: $gl-border-radius-base;
- }
-
- &:nth-of-type(7) {
- border-top-right-radius: $gl-border-radius-base;
- }
-
- &:nth-last-child(7) {
- border-bottom-left-radius: $gl-border-radius-base;
- }
-
- &:last-of-type {
- border-bottom-right-radius: $gl-border-radius-base;
- }
- }
- }
-}
-
.labels-select-contents-create {
.dropdown-input {
margin-bottom: 4px;
diff --git a/app/assets/stylesheets/page_bundles/login.scss b/app/assets/stylesheets/page_bundles/login.scss
index b63f199f7b9..11582ff72f0 100644
--- a/app/assets/stylesheets/page_bundles/login.scss
+++ b/app/assets/stylesheets/page_bundles/login.scss
@@ -16,43 +16,8 @@
top: 8px;
}
- .brand-holder {
- font-size: 18px;
- line-height: 1.5;
-
- p {
- font-size: 16px;
- color: $login-brand-holder-color;
- }
-
- h3 {
- font-size: 22px;
- }
-
- img {
- max-width: 100%;
- margin-bottom: 30px;
- }
-
- a {
- font-weight: $gl-font-weight-bold;
- }
- }
-
- p {
- font-size: 13px;
- }
-
- .signin-text {
- p {
- margin-bottom: 0;
- line-height: 1.5;
- }
- }
-
.borderless {
- .login-box,
- .omniauth-container {
+ .login-box {
box-shadow: none;
}
}
@@ -64,67 +29,6 @@
}
}
- .login-box,
- .omniauth-container {
- box-shadow: 0 0 0 1px $border-color;
- border-radius: $border-radius;
-
- .login-heading h3 {
- font-weight: $gl-font-weight-normal;
- line-height: 1.5;
- margin: 0 0 10px;
- }
-
- .login-footer {
- margin-top: 10px;
-
- p:last-child {
- margin-bottom: 0;
- }
- }
-
- a.forgot {
- float: right;
- padding-top: 6px;
- }
-
- .nav .active a {
- background: transparent;
- }
-
- // Styles the glowing border of focused input for username async validation
- .login-body {
- font-size: 13px;
-
- .username .validation-success {
- color: $green-600;
- }
-
- .username .validation-error {
- color: $red-500;
- }
-
- .terms .gl-form-checkbox {
- @include gl-reset-font-size;
- }
- }
- }
-
- .omniauth-container {
- border-radius: $border-radius;
- font-size: 13px;
-
- p {
- margin: 0;
- }
-
- form {
- padding: 0;
- border: 0;
- background: none;
- }
- }
-
.new-session-tabs {
&.nav-links-unboxed {
border-color: transparent;
@@ -143,30 +47,6 @@
border-top-right-radius: $border-radius-default;
border-top-left-radius: $border-radius-default;
- // Ldap configurations may need more tabs & the tab labels are user generated (arbitrarily long).
- // These styles prevent this from breaking the layout, and only applied when providers are configured.
- &.custom-provider-tabs {
- flex-wrap: wrap;
-
- li {
- min-width: 85px;
- flex-basis: auto;
-
- // This styles tab elements that have wrapped to a second line. We cannot easily predict when this will happen.
- // We are making somewhat of an assumption about the configuration here: that users do not have more than
- // 3 LDAP servers configured (in addition to standard login) and they are not using especially long names for any
- // of them. If either condition is false, this will work as expected. If both are true, there may be a missing border
- // above one of the bottom row elements. If you know a better way, please implement it!
- &:nth-child(n+5) {
- border-top: 1px solid $border-color;
- }
- }
-
- a {
- font-size: 16px;
- }
- }
-
li {
flex: 1;
text-align: center;
@@ -230,11 +110,8 @@
height: 100%;
body {
- padding-top: 48px; // Remove this line when the restyle_login_page feature flag is deleted. Instead, add self-align `center` to container, and maybe a top margin.
-
&.with-system-header {
padding-top: $system-header-height;
- padding-top: calc(#{$system-header-height} + 48px); // Remove this line when the restyle_login_page feature flag is deleted
}
&.with-system-footer {
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index d112fd83ebf..b30ec4b4253 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -259,7 +259,7 @@ $tabs-holder-z-index: 250;
position: sticky;
top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
// height calc is fully delegated to the tree_list_height.vue component
- height: 0;
+ height: 100%;
min-height: 300px;
.drag-handle {
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index 9bab5d65b59..c729bd7a380 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -130,6 +130,11 @@
.gl-pipeline-job-width {
width: 100%;
+ max-width: 400px;
+
+ .pipeline-graph-container & {
+ max-width: unset;
+ }
}
.gl-pipeline-job-width\! {
@@ -318,3 +323,13 @@
background-color: $gray-100;
}
}
+
+.scan-reports-summary-grid {
+ grid-template-columns: 1fr 1fr max-content;
+}
+
+@media (max-width: $breakpoint-sm) {
+ .scan-reports-summary-grid :nth-child(3n+1) {
+ grid-column: 1 / -1;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss
index 9a8eeb9c9d6..912f0145bf1 100644
--- a/app/assets/stylesheets/page_bundles/profile.scss
+++ b/app/assets/stylesheets/page_bundles/profile.scss
@@ -164,12 +164,6 @@
.user-profile {
.profile-header {
- margin: 0 $gl-padding;
-
- &.with-no-profile-tabs {
- margin-bottom: $gl-padding-24;
- }
-
.avatar-holder {
margin: 0 auto 10px;
}
diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss
index c2ecf3702f9..bd24d991c8d 100644
--- a/app/assets/stylesheets/page_bundles/project.scss
+++ b/app/assets/stylesheets/page_bundles/project.scss
@@ -189,10 +189,6 @@
.project-page-sidebar-block {
width: $right-sidebar-width - 1px;
-
- &:first-of-type {
- padding-top: $gl-spacing-scale-1;
- }
}
.nav {
diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss
index b145d046fa4..87d0d5b91d3 100644
--- a/app/assets/stylesheets/page_bundles/search.scss
+++ b/app/assets/stylesheets/page_bundles/search.scss
@@ -281,14 +281,6 @@ $language-filter-max-height: 20rem;
margin-right: 5px;
}
}
-
- .dropdown-menu-toggle,
- .gl-dropdown {
- @include media-breakpoint-up(sm) {
- width: 180px;
- margin-top: 0;
- }
- }
}
.search-page-form {
diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss
index ed2c7662a98..6d85a4da035 100644
--- a/app/assets/stylesheets/page_bundles/wiki.scss
+++ b/app/assets/stylesheets/page_bundles/wiki.scss
@@ -101,7 +101,7 @@
}
.active > .wiki-list {
- background-color: $gray-50;
+ background-color: var(--gray-50, $gray-50);
}
.wiki-list {
@@ -110,7 +110,7 @@
@include gl-rounded-base;
&:hover {
- background: $gray-50;
+ background: var(--gray-50, $gray-50);
.wiki-list-create-child-button {
display: block;
@@ -150,10 +150,6 @@
.wiki-sidebar-header {
padding: 0 $gl-padding $gl-padding;
-
- .gutter-toggle {
- margin-top: 0;
- }
}
}
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index b9ab2450ff9..5b354f3575c 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -4,6 +4,7 @@
$work-item-field-inset-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-200, $gray-200) !important;
$work-item-overview-right-sidebar-width: 23rem;
$work-item-sticky-header-height: 52px;
+$work-item-overview-gap-width: 2rem;
.gl-token-selector-token-container {
display: flex;
@@ -146,7 +147,7 @@ $work-item-sticky-header-height: 52px;
@include media-breakpoint-up(md) {
display: grid;
grid-template-columns: 1fr $work-item-overview-right-sidebar-width;
- gap: 2rem;
+ gap: $work-item-overview-gap-width;
}
}
@@ -216,6 +217,12 @@ $work-item-sticky-header-height: 52px;
}
}
+.editable-wi-title {
+ width: 100%;
+ @include media-breakpoint-up(md) {
+ width: calc(100% - #{$work-item-overview-right-sidebar-width} - #{$work-item-overview-gap-width});
+ }
+}
// Disclosure hierarchy component, used for Ancestors widget
$disclosure-hierarchy-chevron-dimension: 1.2rem;
diff --git a/app/assets/stylesheets/pages/colors.scss b/app/assets/stylesheets/pages/colors.scss
index 85c1f7da07f..aeaf2d7c1b3 100644
--- a/app/assets/stylesheets/pages/colors.scss
+++ b/app/assets/stylesheets/pages/colors.scss
@@ -29,3 +29,52 @@
.danger-title {
color: var(--red-500, $red-500);
}
+
+.suggest-colors {
+ padding-top: 3px;
+
+ a {
+ border-radius: 4px;
+ width: 30px;
+ height: 30px;
+ display: inline-block;
+ margin-right: 10px;
+ margin-bottom: 10px;
+ text-decoration: none;
+
+ &:focus,
+ &:focus:active {
+ position: relative;
+ z-index: 1;
+ @include gl-focus;
+ }
+ }
+
+ &.suggest-colors-dropdown {
+ margin-top: 10px;
+ margin-bottom: 10px;
+
+ a {
+ border-radius: 0;
+ width: (100% / 7);
+ margin-right: 0;
+ margin-bottom: -5px;
+
+ &:first-of-type {
+ border-top-left-radius: $gl-border-radius-base;
+ }
+
+ &:nth-of-type(7) {
+ border-top-right-radius: $gl-border-radius-base;
+ }
+
+ &:nth-last-child(7) {
+ border-bottom-left-radius: $gl-border-radius-base;
+ }
+
+ &:last-of-type {
+ border-bottom-right-radius: $gl-border-radius-base;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 9748983d1ae..f57a8519992 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -225,7 +225,7 @@ ul.related-merge-requests > li gl-emoji {
&::after {
@include gl-dark-invert-keep-hue;
- content: image-url('icon_anchor.svg');
+ content: url('icon_anchor.svg');
visibility: hidden;
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 8792c7f9a72..da03726fa64 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -88,18 +88,18 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
margin-top: 5px;
}
- .timeline-content:not(.flash-container) {
+ .timeline-content {
margin-left: 2.5rem;
border: 1px solid $border-color;
border-radius: $gl-border-radius-base;
padding: $gl-padding-4 $gl-padding-8;
}
- &:not(.target) .timeline-content:not(.flash-container) {
+ &:not(.target) .timeline-content {
background-color: $white;
}
- &.draft-note .timeline-content:not(.flash-container) {
+ &.draft-note .timeline-content {
border: 0;
}
@@ -127,7 +127,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
margin-top: 5px;
}
- .timeline-content:not(.flash-container) {
+ .timeline-content {
margin-left: 2.5rem;
border-left: 1px solid $border-color;
border-right: 1px solid $border-color;
@@ -138,11 +138,11 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
}
- &:not(.target) .timeline-content:not(.flash-container) {
+ &:not(.target) .timeline-content {
background-color: $white;
}
- &.draft-note .timeline-content:not(.flash-container) {
+ &.draft-note .timeline-content {
margin-left: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
@@ -154,7 +154,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
border-right: 1px solid $border-color;
background-color: $white;
- .timeline-content:not(.flash-container) {
+ .timeline-content {
padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 18px;
}
@@ -419,7 +419,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.system-note-commit-list-toggler {
color: $blue-600;
- padding: 10px 0 0;
cursor: pointer;
position: relative;
z-index: 2;
@@ -966,7 +965,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.unified-diff-components-diff-note-button {
&::before {
background-color: $blue-500;
- mask-image: asset_url('icons-stacked.svg#comment');
+ mask-image: url('icons-stacked.svg#comment');
mask-repeat: no-repeat;
mask-size: cover;
mask-position: center;
@@ -1057,7 +1056,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
padding-left: 0;
ul.notes li.note-wrapper {
- .timeline-content:not(.flash-container) {
+ .timeline-content {
padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding;
}
@@ -1106,7 +1105,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
.draft-note-component.draft-note.timeline-entry {
- .timeline-content:not(.flash-container) {
+ .timeline-content {
padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding;
}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 315b9c829a7..d6fcfb3461d 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -4,97 +4,99 @@
@import '@gitlab/ui/src/scss/variables';
@import '@gitlab/ui/src/scss/utility-mixins/index';
-.md h1,
-.md h2,
-.md h3,
-.md h4,
-.md h5,
-.md h6 {
- margin-top: 17px;
-}
+@media print {
+ .md h1,
+ .md h2,
+ .md h3,
+ .md h4,
+ .md h5,
+ .md h6 {
+ margin-top: 17px;
+ }
-.md h1 {
- font-size: 30px;
-}
+ .md h1 {
+ font-size: 30px;
+ }
-.md h2 {
- font-size: 22px;
-}
+ .md h2 {
+ font-size: 22px;
+ }
-.md h3 {
- font-size: 18px;
- font-weight: 600;
-}
+ .md h3 {
+ font-size: 18px;
+ font-weight: 600;
+ }
-.md {
- print-color-adjust: exact;
- -webkit-print-color-adjust: exact;
+ .md {
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
- // fix blockquote style in print
- blockquote {
- &::before {
- position: absolute;
- top: 0;
- left: -4px;
- content: ' ';
- height: 100%;
- width: 4px;
- background-color: $gray-100;
- }
+ // fix blockquote style in print
+ blockquote {
+ &::before {
+ position: absolute;
+ top: 0;
+ left: -4px;
+ content: ' ';
+ height: 100%;
+ width: 4px;
+ background-color: $gray-100;
+ }
- position: relative;
- font-size: inherit;
- @include gl-text-gray-700;
- @include gl-py-3;
- @include gl-pl-6;
- @include gl-my-3;
- @include gl-mx-0;
- @include gl-inset-border-l-4-gray-100;
- margin-left: 4px;
- border: 0 !important;
+ position: relative;
+ font-size: inherit;
+ @include gl-text-gray-700;
+ @include gl-py-3;
+ @include gl-pl-6;
+ @include gl-my-3;
+ @include gl-mx-0;
+ @include gl-inset-border-l-4-gray-100;
+ margin-left: 4px;
+ border: 0 !important;
+ }
}
-}
-header,
-nav,
-.nav-sidebar,
-.super-sidebar,
-.profiler-results,
-.tree-ref-holder,
-.tree-holder .breadcrumb,
-.nav,
-.btn,
-ul.notes-form,
-.issuable-gutter-toggle,
-.gutter-toggle,
-.issuable-details .content-block-small,
-.edit-link,
-.note-action-button,
-.right-sidebar,
-.flash-container,
-copy-code,
-#js-peek {
- display: none !important;
-}
+ header,
+ nav,
+ .nav-sidebar,
+ .super-sidebar,
+ .profiler-results,
+ .tree-ref-holder,
+ .tree-holder .breadcrumb,
+ .nav,
+ .btn,
+ ul.notes-form,
+ .issuable-gutter-toggle,
+ .gutter-toggle,
+ .issuable-details .content-block-small,
+ .edit-link,
+ .note-action-button,
+ .right-sidebar,
+ .flash-container,
+ copy-code,
+ #js-peek {
+ display: none !important;
+ }
-pre {
- page-break-before: avoid;
- page-break-inside: auto;
-}
+ pre {
+ page-break-before: avoid;
+ page-break-inside: auto;
+ }
-.page-gutter {
- padding-top: 0;
- padding-left: 0;
-}
+ .page-gutter {
+ padding-top: 0;
+ padding-left: 0;
+ }
-.right-sidebar {
- top: 0;
-}
+ .right-sidebar {
+ top: 0;
+ }
-a[href]::after {
- content: none !important;
-}
+ a[href]::after {
+ content: none !important;
+ }
-.with-performance-bar .layout-page {
- padding-top: 0;
+ .with-performance-bar .layout-page {
+ padding-top: 0;
+ }
}
diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss
index 91b381462be..e1b14df683e 100644
--- a/app/assets/stylesheets/snippets.scss
+++ b/app/assets/stylesheets/snippets.scss
@@ -15,8 +15,7 @@
.gl-snippet-icon {
display: inline-block;
- /* stylelint-disable-next-line function-url-quotes */
- background: url(asset_path('ext_snippet_icons/ext_snippet_icons.png')) no-repeat;
+ background: url('ext_snippet_icons/ext_snippet_icons.png') no-repeat;
overflow: hidden;
text-align: left;
width: 16px;
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index 3ab3e195b06..59c2391d2e9 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -1,7 +1,6 @@
@import './themes/dark';
@import '@gitlab/ui/dist/tokens/css/tokens.dark';
@import 'page_bundles/mixins_and_variables_and_functions';
-@import './themes/theme_helper';
:root {
color-scheme: dark;
diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss
deleted file mode 100644
index 1a373fbfeda..00000000000
--- a/app/assets/stylesheets/themes/theme_blue.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@import './theme_helper';
-
-:root {
- &.ui-blue {
- .page-with-super-sidebar {
- @include gitlab-theme-super-sidebar(
- $theme-blue-50,
- $theme-blue-100,
- $theme-blue-900,
- $theme-blue-900,
- );
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss
deleted file mode 100644
index 9a24142f286..00000000000
--- a/app/assets/stylesheets/themes/theme_gray.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@import './theme_helper';
-
-:root {
- &.ui-gray {
- .page-with-super-sidebar {
- @include gitlab-theme-super-sidebar(
- $gray-50,
- $gray-100,
- $gray-900,
- $gray-900,
- );
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss
deleted file mode 100644
index a766fdddc78..00000000000
--- a/app/assets/stylesheets/themes/theme_green.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@import './theme_helper';
-
-:root {
- &.ui-green {
- .page-with-super-sidebar {
- @include gitlab-theme-super-sidebar(
- $theme-green-50,
- $theme-green-100,
- $theme-green-900,
- $theme-green-900,
- );
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
deleted file mode 100644
index c94a32891f6..00000000000
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ /dev/null
@@ -1,36 +0,0 @@
-@import '../page_bundles/mixins_and_variables_and_functions';
-/**
- * Styles the GitLab application with a specific color theme
- */
-@mixin gitlab-theme-super-sidebar(
- $theme-color-lightest,
- $theme-color-light,
- $theme-color,
- $theme-color-darkest,
-) {
- .super-sidebar {
- --super-sidebar-bg: #{mix(white, $theme-color-lightest, 50%)};
- --super-sidebar-user-bar-bg: #{$theme-color};
- --super-sidebar-primary: #{$theme-color};
- --super-sidebar-notification-dot: #{$theme-color-darkest};
-
- --super-sidebar-user-bar-button-bg: #{$t-white-a-16};
- --super-sidebar-user-bar-button-color: #{$theme-color-lightest};
- --super-sidebar-user-bar-button-border-color: #{$t-white-a-16};
- --super-sidebar-user-bar-button-hover-bg: #{$t-white-a-24};
- --super-sidebar-user-bar-button-hover-color: #{$white};
- --super-sidebar-user-bar-button-active-bg: #{$t-white-a-36};
-
- --super-sidebar-user-bar-button-icon-color: #{$theme-color-light};
- --super-sidebar-user-bar-button-icon-hover-color: #{$theme-color-light};
- --super-sidebar-user-bar-button-icon-mix-blend-mode: screen;
-
- hr {
- mix-blend-mode: multiply;
- }
-
- .super-sidebar-context-header {
- color: var(--super-sidebar-primary);
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss
deleted file mode 100644
index d0a8d597b59..00000000000
--- a/app/assets/stylesheets/themes/theme_indigo.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@import './theme_helper';
-
-:root {
- &.ui-indigo {
- .page-with-super-sidebar {
- @include gitlab-theme-super-sidebar(
- $theme-indigo-50,
- $theme-indigo-100,
- $theme-indigo-900,
- $theme-indigo-900,
- );
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss
deleted file mode 100644
index e712b6ae859..00000000000
--- a/app/assets/stylesheets/themes/theme_light_blue.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@import './theme_helper';
-
-:root {
- &.ui-light-blue {
- .page-with-super-sidebar {
- @include gitlab-theme-super-sidebar(
- $theme-light-blue-50,
- $theme-light-blue-100,
- $theme-light-blue-700,
- $theme-light-blue-900,
- );
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss
deleted file mode 100644
index 5cb9bee37b0..00000000000
--- a/app/assets/stylesheets/themes/theme_light_gray.scss
+++ /dev/null
@@ -1,2 +0,0 @@
-// "Light gray" is the default unthemed state of the sidebar.
-// Nothing to do here.
diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss
deleted file mode 100644
index 44e19b02e36..00000000000
--- a/app/assets/stylesheets/themes/theme_light_green.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@import './theme_helper';
-
-:root {
- &.ui-light-green {
- .page-with-super-sidebar {
- @include gitlab-theme-super-sidebar(
- $theme-green-50,
- $theme-green-100,
- $theme-green-700,
- $theme-green-900,
- );
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss
deleted file mode 100644
index ab299ca9d84..00000000000
--- a/app/assets/stylesheets/themes/theme_light_indigo.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@import './theme_helper';
-
-:root {
- &.ui-light-indigo {
- .page-with-super-sidebar {
- @include gitlab-theme-super-sidebar(
- $theme-indigo-50,
- $theme-indigo-100,
- $theme-indigo-700,
- $theme-indigo-900,
- );
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss
deleted file mode 100644
index 499cdace772..00000000000
--- a/app/assets/stylesheets/themes/theme_light_red.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@import './theme_helper';
-
-:root {
- &.ui-light-red {
- .page-with-super-sidebar {
- @include gitlab-theme-super-sidebar(
- $theme-light-red-50,
- $theme-light-red-100,
- $theme-light-red-700,
- $theme-light-red-900,
- );
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss
deleted file mode 100644
index 9a17f98aa80..00000000000
--- a/app/assets/stylesheets/themes/theme_red.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@import './theme_helper';
-
-:root {
- &.ui-red {
- .page-with-super-sidebar {
- @include gitlab-theme-super-sidebar(
- $theme-red-50,
- $theme-red-100,
- $theme-red-900,
- $theme-red-900,
- );
- }
- }
-}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 79ea8d3cc70..7ae17f4c191 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -136,3 +136,13 @@
.gl-last-of-type-border-b-0:last-of-type {
@include gl-border-b-0;
}
+
+.gl-md-h-9 {
+ @include gl-media-breakpoint-up(md) {
+ height: $gl-spacing-scale-9;
+ }
+}
+
+.gl-pl-12 {
+ padding-left: $gl-spacing-scale-12;
+}
diff --git a/app/assets/stylesheets/vendors/_index.scss b/app/assets/stylesheets/vendors/_index.scss
new file mode 100644
index 00000000000..e26ba23d1b9
--- /dev/null
+++ b/app/assets/stylesheets/vendors/_index.scss
@@ -0,0 +1 @@
+@import './atwho';
diff --git a/app/components/pajamas/avatar_component.rb b/app/components/pajamas/avatar_component.rb
index 423934b6887..0821818103a 100644
--- a/app/components/pajamas/avatar_component.rb
+++ b/app/components/pajamas/avatar_component.rb
@@ -1,16 +1,21 @@
# frozen_string_literal: true
module Pajamas
+ AvatarEmail = Struct.new(:email) do
+ def name
+ email
+ end
+ end
class AvatarComponent < Pajamas::Component
include Gitlab::Utils::StrongMemoize
- # @param record [User, Project, Group]
+ # @param item [User, Project, Group, AvatarEmail]
# @param alt [String] text for the alt tag
# @param class [String] custom CSS class(es)
# @param size [Integer] size in pixel
# @param [Hash] avatar_options
- def initialize(record, alt: nil, class: "", size: 64, avatar_options: {})
- @record = record
+ def initialize(item, alt: nil, class: "", size: 64, avatar_options: {})
+ @item = item
@alt = alt
@class = binding.local_variable_get(:class)
@size = filter_attribute(size.to_i, SIZE_OPTIONS, default: 64)
@@ -23,11 +28,11 @@ module Pajamas
def avatar_classes
classes = ["gl-avatar", "gl-avatar-s#{@size}", @class]
- classes.push("gl-avatar-circle") if @record.is_a?(User)
+ classes.push("gl-avatar-circle") if @item.is_a?(User) || @item.is_a?(AvatarEmail)
unless src
classes.push("gl-avatar-identicon")
- classes.push("gl-avatar-identicon-bg#{((@record.id || 0) % 7) + 1}")
+ classes.push("gl-avatar-identicon-bg#{((@item.id || 0) % 7) + 1}")
end
classes.join(' ')
@@ -35,7 +40,7 @@ module Pajamas
def src
strong_memoize(:src) do
- if @record.is_a?(User)
+ if @item.is_a?(User)
# Users show a gravatar instead of an identicon. Also avatars of
# blocked users are only shown if the current_user is an admin.
# To not duplicate this logic, we are using existing helpers here.
@@ -44,9 +49,11 @@ module Pajamas
rescue StandardError
nil
end
- helpers.avatar_icon_for_user(@record, @size, current_user: current_user)
- elsif @record.try(:avatar_url)
- "#{@record.avatar_url}?width=#{@size}"
+ helpers.avatar_icon_for_user(@item, @size, current_user: current_user)
+ elsif @item.is_a?(AvatarEmail)
+ helpers.avatar_icon_for_email(@item.email, @size)
+ elsif @item.try(:avatar_url)
+ "#{@item.avatar_url}?width=#{@size}"
end
end
end
@@ -59,11 +66,11 @@ module Pajamas
end
def alt
- @alt || @record.name
+ @alt || @item.name
end
def initial
- @record.name[0, 1].upcase
+ @item.name[0, 1].upcase
end
end
end
diff --git a/app/components/projects/ml/models_index_component.rb b/app/components/projects/ml/models_index_component.rb
index 5e37777eb61..ef5c8a34bf5 100644
--- a/app/components/projects/ml/models_index_component.rb
+++ b/app/components/projects/ml/models_index_component.rb
@@ -3,11 +3,16 @@
module Projects
module Ml
class ModelsIndexComponent < ViewComponent::Base
- attr_reader :paginator, :model_count
+ include Rails.application.routes.url_helpers
+ include API::Helpers::RelatedResourcesHelpers
- def initialize(paginator:, model_count:)
+ attr_reader :paginator, :model_count, :project, :user
+
+ def initialize(project:, current_user:, paginator:, model_count:)
+ @project = project
@paginator = paginator
@model_count = model_count
+ @user = current_user
end
private
@@ -16,7 +21,10 @@ module Projects
vm = {
models: models_view_model,
page_info: page_info_view_model,
- model_count: model_count
+ model_count: model_count,
+ create_model_path: create_model_path,
+ can_write_model_registry: user.can?(:write_model_registry, project),
+ mlflow_tracking_url: mlflow_tracking_url
}
Gitlab::Json.generate(vm.deep_transform_keys { |k| k.to_s.camelize(:lower) })
@@ -35,6 +43,10 @@ module Projects
end
end
+ def create_model_path
+ new_project_ml_model_path(project)
+ end
+
def page_info_view_model
{
has_next_page: paginator.has_next_page?,
@@ -43,6 +55,14 @@ module Projects
end_cursor: paginator.cursor_for_next_page
}
end
+
+ def mlflow_tracking_url
+ path = api_v4_projects_ml_mlflow_api_2_0_mlflow_registered_models_create_path(id: project.id)
+
+ path = path.delete_suffix('registered-models/create')
+
+ expose_url(path)
+ end
end
end
end
diff --git a/app/components/projects/ml/show_ml_model_component.rb b/app/components/projects/ml/show_ml_model_component.rb
index 11a36a78b18..424c8e262e2 100644
--- a/app/components/projects/ml/show_ml_model_component.rb
+++ b/app/components/projects/ml/show_ml_model_component.rb
@@ -31,11 +31,12 @@ module Projects
def latest_version_view_model
return unless model.latest_version
- model_version = model.latest_version
+ model_version = model.latest_version.present
{
version: model_version.version,
description: model_version.description,
+ path: model_version.path,
project_path: project_path(model_version.project),
package_id: model_version.package_id,
**::Ml::CandidateDetailsPresenter.new(model_version.candidate, current_user).present
diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb
index 4ab67e54766..1085de6fa05 100644
--- a/app/controllers/admin/ci/variables_controller.rb
+++ b/app/controllers/admin/ci/variables_controller.rb
@@ -44,7 +44,7 @@ module Admin
end
def variable_params_attributes
- %i[id variable_type key secret_value protected masked raw _destroy]
+ %i[id variable_type key description secret_value protected masked raw _destroy]
end
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index ee78d5a8c35..3a0618c0d40 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -9,6 +9,10 @@ class Admin::UsersController < Admin::ApplicationController
before_action :ensure_destroy_prerequisites_met, only: [:destroy]
before_action :set_shared_view_parameters, only: [:show, :projects, :keys]
+ before_action only: [:index] do
+ push_frontend_feature_flag(:simplified_badges)
+ end
+
feature_category :user_management
PAGINATION_WITH_COUNT_LIMIT = 1000
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index fca3bb3460f..d7b005d03b5 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -26,7 +26,6 @@ class ApplicationController < BaseActionController
include CheckRateLimit
include RequestPayloadLogger
- before_action :limit_session_time, if: -> { !current_user }
before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms?
before_action :check_password_expiration, if: :html_request?
@@ -51,7 +50,6 @@ class ApplicationController < BaseActionController
around_action :set_current_admin
after_action :set_page_title_header, if: :json_request?
- after_action :ensure_authenticated_session_time, if: -> { current_user }
protect_from_forgery with: :exception, prepend: true
diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb
index 2efea461a35..c55911eed48 100644
--- a/app/controllers/concerns/confirm_email_warning.rb
+++ b/app/controllers/concerns/confirm_email_warning.rb
@@ -38,6 +38,6 @@ module ConfirmEmailWarning
end
def email_to_display
- html_escape(email)
+ ERB::Util.html_escape(email)
end
end
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index 24475909b62..81130fcd6a6 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -46,15 +46,11 @@ module EnforcesTwoFactorAuthentication
end
# rubocop: disable CodeReuse/ActiveRecord
- def two_factor_authentication_reason(global: -> {}, group: -> {})
- if two_factor_authentication_required?
- if Gitlab::CurrentSettings.require_two_factor_authentication?
- global.call
- else
- groups = current_user.source_groups_of_two_factor_authentication_requirement.reorder(name: :asc)
- group.call(groups)
- end
- end
+ def execute_action_for_2fa_reason(actions)
+ reason = two_factor_verifier.two_factor_authentication_reason
+ groups_enforcing_two_factor = current_user.source_groups_of_two_factor_authentication_requirement
+ .reorder(name: :asc)
+ actions[reason].call(groups_enforcing_two_factor)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index e344e0dcd8c..d71ab98c3fd 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -38,6 +38,9 @@ module Integrations
:default_irc_uri,
:device,
:disable_diffs,
+ :diffblue_access_token_name,
+ :diffblue_access_token_secret,
+ :diffblue_license_key,
:drone_url,
:enable_ssl_verification,
:external_wiki_url,
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index 7f1b961e92a..8bd120b5ed5 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -44,6 +44,7 @@ module PreviewMarkdown
when 'groups' then { group: group, issuable_reference_expansion_enabled: true }
when 'projects' then projects_filter_params
when 'timeline_events' then timeline_events_filter_params
+ when 'organizations' then { pipeline: :description }
else {}
end.merge(
requested_path: params[:path],
diff --git a/app/controllers/explore/catalog_controller.rb b/app/controllers/explore/catalog_controller.rb
index d384ad10c86..39c43182fbf 100644
--- a/app/controllers/explore/catalog_controller.rb
+++ b/app/controllers/explore/catalog_controller.rb
@@ -6,7 +6,7 @@ module Explore
feature_category :pipeline_composition
before_action :check_resource_access, only: :show
- track_internal_event :index, name: 'unique_users_visiting_ci_catalog'
+ track_internal_event :index, name: 'unique_users_visiting_ci_catalog', conditions: :current_user
before_action do
push_frontend_feature_flag(:ci_catalog_components_tab, current_user)
end
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 1941920325f..e39f1148cf2 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -256,8 +256,7 @@ class GraphqlController < ApplicationController
def authorize_access_api!
if current_user.nil? &&
- request_authenticator.authentication_token_present? &&
- Feature.enabled?(:invalid_graphql_auth_401)
+ request_authenticator.authentication_token_present?
render_error('Invalid token', status: :unauthorized)
end
diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb
index 7a490b34511..191720f69a0 100644
--- a/app/controllers/groups/autocomplete_sources_controller.rb
+++ b/app/controllers/groups/autocomplete_sources_controller.rb
@@ -10,7 +10,7 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
urgency :low, [:issues, :labels, :milestones, :commands, :merge_requests, :members]
def members
- render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target)
+ render json: ::Groups::ParticipantsService.new(@group, current_user, params).execute(target)
end
def issues
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 7cc0e6a8558..eb3661ea3d7 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -7,7 +7,6 @@ class Groups::BoardsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:board_multi_select, group)
- push_frontend_feature_flag(:apollo_boards, group)
push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, group)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control {}
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index fad3a6ab9f5..d27d70dc857 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -50,7 +50,7 @@ module Groups
end
def variable_params_attributes
- %i[id variable_type key secret_value protected masked raw _destroy]
+ %i[id variable_type key description secret_value protected masked raw _destroy]
end
def authorize_admin_build!
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 5b9b3b7de11..b151793ad8b 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -305,7 +305,8 @@ class GroupsController < Groups::ApplicationController
:prevent_sharing_groups_outside_hierarchy,
:setup_for_company,
:jobs_to_be_done,
- :crm_enabled
+ :crm_enabled,
+ :enable_namespace_descendants_cache
] + [group_feature_attributes: group_feature_attributes]
end
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index ba2743e1002..01657df28fd 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -49,6 +49,9 @@ class Import::BitbucketServerController < Import::BaseController
session[bitbucket_server_username_key] = params[:bitbucket_server_username]
session[bitbucket_server_url_key] = params[:bitbucket_server_url]
+ experiment(:default_to_import_tab, actor: current_user)
+ .track(:authentication, property: provider_name)
+
redirect_to status_import_bitbucket_server_path(namespace_id: params[:namespace_id])
end
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index e211ea70a56..6ff0f55d2f6 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -6,10 +6,6 @@ class Import::BulkImportsController < ApplicationController
before_action :ensure_bulk_import_enabled
before_action :verify_blocked_uri, only: :status
- before_action only: [:history] do
- push_frontend_feature_flag(:bulk_import_details_page)
- end
-
feature_category :importers
urgency :low
@@ -53,9 +49,7 @@ class Import::BulkImportsController < ApplicationController
end
end
- def details
- render_404 unless Feature.enabled?(:bulk_import_details_page)
- end
+ def details; end
def create
return render json: { success: false }, status: :too_many_requests if throttled_request?
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index 34fdf513313..05ba317057d 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -22,6 +22,9 @@ class Import::FogbugzController < Import::BaseController
session[:fogbugz_token] = res.get_token.to_s
session[:fogbugz_uri] = params[:uri]
+ experiment(:default_to_import_tab, actor: current_user)
+ .track(:successfully_authenticated, property: provider_name)
+
redirect_to new_user_map_import_fogbugz_path(namespace_id: params[:namespace_id])
end
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 2b72ceceb5a..0159c1913af 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -41,6 +41,9 @@ class Import::GithubController < Import::BaseController
end
def personal_access_token
+ experiment(:default_to_import_tab, actor: current_user)
+ .track(:authentication, property: provider_name)
+
session[access_token_key] = params[:personal_access_token]&.strip
redirect_to status_import_url
end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index d1b182a57d8..71d66dc3db8 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -21,6 +21,9 @@ class Import::GitlabProjectsController < Import::BaseController
@project = ::Projects::GitlabProjectsImportService.new(current_user, project_params).execute
if @project.saved?
+ experiment(:default_to_import_tab, actor: current_user)
+ .track(:successfully_imported, property: 'gitlab_export')
+
redirect_to(
project_path(@project),
notice: _("Project '%{project_name}' is being imported.") % { project_name: @project.name }
diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb
index 03884717e54..7d3c91a7f5c 100644
--- a/app/controllers/import/manifest_controller.rb
+++ b/app/controllers/import/manifest_controller.rb
@@ -31,6 +31,9 @@ class Import::ManifestController < Import::BaseController
if manifest.valid?
manifest_import_metadata.save(manifest.projects, group.id)
+ experiment(:default_to_import_tab, actor: current_user)
+ .track(:successfully_imported, property: provider_name)
+
redirect_to status_import_manifest_path
else
@errors = manifest.errors
diff --git a/app/controllers/jwks_controller.rb b/app/controllers/jwks_controller.rb
index 2e030cf46c4..fb190846ffa 100644
--- a/app/controllers/jwks_controller.rb
+++ b/app/controllers/jwks_controller.rb
@@ -2,9 +2,7 @@
class JwksController < Doorkeeper::OpenidConnect::DiscoveryController
def index
- if ::Feature.enabled?(:cache_control_headers_for_openid_jwks)
- expires_in 24.hours, public: true, must_revalidate: true, 'no-transform': true
- end
+ expires_in 24.hours, public: true, must_revalidate: true, 'no-transform': true
render json: { keys: payload }
end
diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb
index 955dfe58449..1c79bd3a668 100644
--- a/app/controllers/ldap/omniauth_callbacks_controller.rb
+++ b/app/controllers/ldap/omniauth_callbacks_controller.rb
@@ -28,7 +28,7 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController
define_providers!
override :set_remember_me
- def set_remember_me(user)
+ def set_remember_me(user, _auth_user)
user.remember_me = params[:remember_me] if user.persisted?
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 907ece1a06e..0701b1ee977 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -139,9 +139,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session)
link_identity(identity_linker)
- set_remember_me(current_user)
- store_idp_two_factor_status(build_auth_user(auth_module::User).bypass_two_factor?)
+ current_auth_user = build_auth_user(auth_module::User)
+ set_remember_me(current_user, current_auth_user)
+
+ store_idp_two_factor_status(current_auth_user.bypass_two_factor?)
if identity_linker.changed?
redirect_identity_linked
@@ -193,7 +195,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
track_event(@user, oauth['provider'], 'succeeded')
Gitlab::Tracking.event(self.class.name, "#{oauth['provider']}_sso", user: @user) if new_user
- set_remember_me(@user)
+ set_remember_me(@user, auth_user)
set_session_active_since(oauth['provider']) if ::AuthHelper.saml_providers.include?(oauth['provider'].to_sym)
if @user.two_factor_enabled? && !auth_user.bypass_two_factor?
@@ -278,10 +280,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
.for_authentication.security_event
end
- def set_remember_me(user)
+ def set_remember_me(user, auth_user)
return unless remember_me?
- if user.two_factor_enabled?
+ if user.two_factor_enabled? && !auth_user.bypass_two_factor?
params[:remember_me] = '1'
else
remember_me(user)
diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb
index 9f09627b1e4..0596441591d 100644
--- a/app/controllers/organizations/organizations_controller.rb
+++ b/app/controllers/organizations/organizations_controller.rb
@@ -2,9 +2,11 @@
module Organizations
class OrganizationsController < ApplicationController
+ include PreviewMarkdown
+
feature_category :cell
- skip_before_action :authenticate_user!, except: [:index, :new, :users]
+ skip_before_action :authenticate_user!, only: [:show, :groups_and_projects]
def index; end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index f1646027e8e..5a956a14552 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -207,15 +207,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def setup_show_page
if two_factor_authentication_required? && !current_user.two_factor_enabled?
- two_factor_authentication_reason(
- global: lambda do
+ two_factor_auth_actions = {
+ global: lambda do |_|
flash.now[:alert] =
_('The global settings require you to enable Two-Factor Authentication for your account.')
end,
+ admin_2fa: lambda do |_|
+ flash.now[:alert] = _('Administrator users are required to enable Two-Factor Authentication for their account.')
+ end,
group: lambda do |groups|
flash.now[:alert] = groups_notification(groups)
end
- )
+ }
+ execute_action_for_2fa_reason(two_factor_auth_actions)
unless two_factor_grace_period_expired?
grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index ff3484d3020..dc10004c62b 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -15,7 +15,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
urgency :low, [:issues, :labels, :milestones, :commands, :contacts]
def members
- render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target)
+ render json: ::Projects::ParticipantsService.new(@project, current_user, params).execute(target)
end
def issues
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index fd853b5aaed..29bc00ae870 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -7,7 +7,6 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :check_issues_available!
before_action do
push_frontend_feature_flag(:board_multi_select, project)
- push_frontend_feature_flag(:apollo_boards, project)
push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, project)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control {}
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 88e9113188a..c36742e8bb9 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -186,7 +186,6 @@ class Projects::CommitController < Projects::ApplicationController
opts[:use_extra_viewer_as_main] = false
@diffs = commit.diffs(opts)
- @notes_count = commit.notes.count
@environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, commit: @commit, find_latest: true).execute.last
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 8cdd6efa7c5..65cbe5a78ce 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -26,7 +26,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :cancel_auto_stop]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
- before_action :set_kas_cookie, only: [:index, :edit, :new], if: -> { current_user && request.format.html? }
+ before_action :set_kas_cookie, only: [:index, :folder, :edit, :new], if: -> { current_user && request.format.html? }
after_action :expire_etag_cache, only: [:cancel_auto_stop]
track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal,
diff --git a/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb b/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb
index b88b86975a4..60adbbe6e5d 100644
--- a/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb
+++ b/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb
@@ -25,7 +25,7 @@ module Projects
private
def service
- ::Integrations::GoogleCloudPlatform::ArtifactRegistry::ListDockerImagesService.new(
+ ::GoogleCloudPlatform::ArtifactRegistry::ListDockerImagesService.new(
project: @project,
current_user: current_user,
params: {
@@ -124,6 +124,10 @@ module Projects
Time.zone.parse(upload_time)
end
+
+ def details_url
+ "https://#{uri}"
+ end
end
end
end
diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb
index d35b2d54c53..3baa1210ec2 100644
--- a/app/controllers/projects/google_cloud/configuration_controller.rb
+++ b/app/controllers/projects/google_cloud/configuration_controller.rb
@@ -8,7 +8,7 @@ module Projects
configurationUrl: project_google_cloud_configuration_path(project),
deploymentsUrl: project_google_cloud_deployments_path(project),
databasesUrl: project_google_cloud_databases_path(project),
- serviceAccounts: ::GoogleCloud::ServiceAccountsService.new(project).find_for_project,
+ serviceAccounts: ::CloudSeed::GoogleCloud::ServiceAccountsService.new(project).find_for_project,
createServiceAccountUrl: project_google_cloud_service_accounts_path(project),
emptyIllustrationUrl:
ActionController::Base.helpers.image_path('illustrations/empty-state/empty-pipeline-md.svg'),
diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb
index ea79efd9f4f..9023b8a5fa6 100644
--- a/app/controllers/projects/google_cloud/databases_controller.rb
+++ b/app/controllers/projects/google_cloud/databases_controller.rb
@@ -14,7 +14,7 @@ module Projects
cloudsqlPostgresUrl: new_project_google_cloud_database_path(project, :postgres),
cloudsqlMysqlUrl: new_project_google_cloud_database_path(project, :mysql),
cloudsqlSqlserverUrl: new_project_google_cloud_database_path(project, :sqlserver),
- cloudsqlInstances: ::GoogleCloud::GetCloudsqlInstancesService.new(project).execute,
+ cloudsqlInstances: ::CloudSeed::GoogleCloud::GetCloudsqlInstancesService.new(project).execute,
emptyIllustrationUrl:
ActionController::Base.helpers.image_path('illustrations/empty-state/empty-pipeline-md.svg')
}
@@ -46,7 +46,7 @@ module Projects
end
def create
- enable_response = ::GoogleCloud::EnableCloudsqlService
+ enable_response = ::CloudSeed::GoogleCloud::EnableCloudsqlService
.new(project, current_user, enable_service_params)
.execute
@@ -54,7 +54,7 @@ module Projects
track_event(:error_enable_cloudsql_services)
flash[:alert] = error_message(enable_response[:message])
else
- create_response = ::GoogleCloud::CreateCloudsqlInstanceService
+ create_response = ::CloudSeed::GoogleCloud::CreateCloudsqlInstanceService
.new(project, current_user, create_service_params)
.execute
diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb
index 92c99ad4271..e4666f9335c 100644
--- a/app/controllers/projects/google_cloud/deployments_controller.rb
+++ b/app/controllers/projects/google_cloud/deployments_controller.rb
@@ -17,7 +17,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base
def cloud_run
params = { google_oauth2_token: token_in_session }
- enable_cloud_run_response = GoogleCloud::EnableCloudRunService
+ enable_cloud_run_response = CloudSeed::GoogleCloud::EnableCloudRunService
.new(project, current_user, params).execute
if enable_cloud_run_response[:status] == :error
@@ -25,8 +25,8 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base
flash[:alert] = enable_cloud_run_response[:message]
redirect_to project_google_cloud_deployments_path(project)
else
- params = { action: GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN }
- generate_pipeline_response = GoogleCloud::GeneratePipelineService
+ params = { action: CloudSeed::GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN }
+ generate_pipeline_response = CloudSeed::GoogleCloud::GeneratePipelineService
.new(project, current_user, params).execute
if generate_pipeline_response[:status] == :error
diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
index c51261721b2..593e27eeebf 100644
--- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb
+++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
@@ -20,7 +20,7 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC
def create
permitted_params = params.permit(:ref, :gcp_region)
- GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region])
+ CloudSeed::GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region])
track_event(:configure_region)
redirect_to project_google_cloud_configuration_path(project), notice: _('GCP region configured')
end
diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb
index 7b029e25ea2..5a5f53943c0 100644
--- a/app/controllers/projects/google_cloud/service_accounts_controller.rb
+++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb
@@ -27,7 +27,7 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
def create
permitted_params = params.permit(:gcp_project, :ref)
- response = GoogleCloud::CreateServiceAccountsService.new(
+ response = CloudSeed::GoogleCloud::CreateServiceAccountsService.new(
project,
current_user,
google_oauth2_token: token_in_session,
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index d0eabf8d837..c1de24f300b 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -71,6 +71,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, project)
push_force_frontend_feature_flag(:linked_work_items, project.linked_work_items_feature_flag_enabled?)
push_frontend_feature_flag(:notifications_todos_buttons, current_user)
+ push_frontend_feature_flag(:mention_autocomplete_backend_filtering, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index b269d41fa77..c62a1e09c00 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -9,11 +9,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
before_action :commit
before_action :define_diff_vars
before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata]
- before_action :update_diff_discussion_positions!
+ before_action :update_diff_discussion_positions!, except: [:diff_by_file_hash]
around_action :allow_gitaly_ref_name_caching
- after_action :track_viewed_diffs_events, only: [:diffs_batch, :diff_for_path]
+ after_action :track_viewed_diffs_events, only: [:diffs_batch, :diff_for_path, :diff_by_file_hash]
urgency :low, [
:show,
@@ -26,6 +26,14 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
render_diffs
end
+ def diff_by_file_hash
+ diff_file = @compare.diffs.diff_files.find { |file| file.file_hash == params[:file_hash] }
+ params[:old_path] = diff_file&.old_path
+ params[:new_path] = diff_file&.new_path
+
+ render_diffs
+ end
+
def diff_for_path
render_diffs
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 0899e303305..6cb00fea922 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -46,6 +46,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:notifications_todos_buttons, current_user)
push_frontend_feature_flag(:mr_request_changes, current_user)
push_frontend_feature_flag(:merge_blocked_component, current_user)
+ push_frontend_feature_flag(:mention_autocomplete_backend_filtering, project)
+ push_frontend_feature_flag(:pinned_file, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions]
@@ -448,6 +450,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@update_current_user_path = expose_path(api_v4_user_preferences_path)
@endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request)
@endpoint_diff_batch_url = endpoint_diff_batch_url(@project, @merge_request)
+ if params[:pin] && Feature.enabled?(:pinned_file)
+ @pinned_file_url = diff_by_file_hash_namespace_project_merge_request_path(
+ format: 'json',
+ id: merge_request.iid,
+ namespace_id: project&.namespace.to_param,
+ project_id: project&.path,
+ file_hash: params[:pin]
+ )
+ end
if merge_request.diffs_batch_cache_with_max_age?
@diffs_batch_cache_key = @merge_request.merge_head_diff&.patch_id_sha
diff --git a/app/controllers/projects/ml/models_controller.rb b/app/controllers/projects/ml/models_controller.rb
index 68a8b7a1686..2dff3ec3325 100644
--- a/app/controllers/projects/ml/models_controller.rb
+++ b/app/controllers/projects/ml/models_controller.rb
@@ -4,7 +4,7 @@ module Projects
module Ml
class ModelsController < ::Projects::ApplicationController
before_action :authorize_read_model_registry!
- before_action :authorize_write_model_registry!, only: [:destroy]
+ before_action :authorize_write_model_registry!, only: [:destroy, :new]
before_action :set_model, only: [:show, :destroy]
feature_category :mlops
@@ -22,6 +22,8 @@ module Projects
@model_count = finder.count
end
+ def new; end
+
def show; end
def destroy
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index 278d306301a..e52e13e8ce6 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -42,7 +42,7 @@ class Projects::RefsController < Projects::ApplicationController
redirect_to new_path
end
end
- rescue Gitlab::PathTraversal::PathTraversalAttackError
+ rescue Gitlab::PathTraversal::PathTraversalAttackError, ActionController::UrlGenerationError
head :bad_request
end
diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb
index ee2e60b5a1a..abf564a00e1 100644
--- a/app/controllers/projects/security/configuration_controller.rb
+++ b/app/controllers/projects/security/configuration_controller.rb
@@ -24,11 +24,7 @@ module Projects
private
def configuration
- if unify_configuration_enabled?
- configuration_presenter
- else
- {}
- end
+ configuration_presenter
end
def configuration_presenter
@@ -38,10 +34,6 @@ module Projects
def presenter_attributes
{}
end
-
- def unify_configuration_enabled?
- Feature.enabled?(:unify_security_configuration, project)
- end
end
end
end
diff --git a/app/controllers/projects/settings/packages_and_registries_controller.rb b/app/controllers/projects/settings/packages_and_registries_controller.rb
index 76c9cead360..5c352866c8d 100644
--- a/app/controllers/projects/settings/packages_and_registries_controller.rb
+++ b/app/controllers/projects/settings/packages_and_registries_controller.rb
@@ -7,6 +7,7 @@ module Projects
before_action :authorize_admin_project!
before_action :packages_and_registries_settings_enabled!
+ before_action :set_feature_flag_packages_protected_packages, only: :show
feature_category :package_registry
urgency :low
@@ -30,6 +31,10 @@ module Projects
render_404 unless Gitlab.config.registry.enabled &&
can?(current_user, :admin_container_image, project)
end
+
+ def set_feature_flag_packages_protected_packages
+ push_frontend_feature_flag(:packages_protected_packages, project)
+ end
end
end
end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 38b23b24c9a..6a10d603ad7 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -7,6 +7,10 @@ module Projects
before_action :authorize_admin_project!
before_action :define_variables, only: [:create_deploy_token]
+ before_action do
+ push_frontend_feature_flag(:add_branch_rule, @project)
+ end
+
feature_category :source_code_management, [:show, :cleanup, :update]
feature_category :continuous_delivery, [:create_deploy_token]
urgency :low, [:show, :create_deploy_token]
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index f7542d68642..29ecca1b7e0 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -47,6 +47,6 @@ class Projects::VariablesController < Projects::ApplicationController
end
def variable_params_attributes
- %i[id variable_type key secret_value protected masked raw environment_scope _destroy]
+ %i[id variable_type key description secret_value protected masked raw environment_scope _destroy]
end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 1152bdcf058..d4b77c588dc 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -29,7 +29,7 @@ class ProjectsController < Projects::ApplicationController
before_action :authorize_read_code!, only: [:refs]
# Authorize
- before_action :authorize_admin_project_or_custom_permissions!, only: :edit
+ before_action :authorize_view_edit_page!, only: :edit
before_action :authorize_admin_project!, only: [:update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
before_action :authorize_archive_project!, only: [:archive, :unarchive]
before_action :event_filter, only: [:show, :activity]
@@ -44,6 +44,7 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:explain_code_chat, current_user)
push_frontend_feature_flag(:issue_email_participants, @project)
push_frontend_feature_flag(:encoding_logs_tree)
+ push_frontend_feature_flag(:add_branch_rule, @project)
# TODO: We need to remove the FF eventually when we rollout page_specific_styles
push_frontend_feature_flag(:page_specific_styles, current_user)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
@@ -87,8 +88,14 @@ class ProjectsController < Projects::ApplicationController
@parent_group = Group.find_by(id: params[:namespace_id])
+ manageable_groups_count = current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).count
+
+ if manageable_groups_count == 0 && !can?(current_user, :create_projects, current_user.namespace)
+ return access_denied!
+ end
+
@current_user_group =
- if current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).count == 1
+ if manageable_groups_count == 1
current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).first
end
@@ -612,11 +619,6 @@ class ProjectsController < Projects::ApplicationController
def render_edit
render 'edit'
end
-
- # Overridden in EE
- def authorize_admin_project_or_custom_permissions!
- authorize_admin_project!
- end
end
ProjectsController.prepend_mod_with('ProjectsController')
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 64d9db41a1b..896b71d2822 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -175,7 +175,7 @@ class SearchController < ApplicationController
return false unless commit.present?
link = search_path(safe_params.merge(force_search_results: true))
- flash[:notice] = html_escape(_("You have been redirected to the only result; see the %{a_start}search results%{a_end} instead.")) % { a_start: "<a href=\"#{link}\"><u>".html_safe, a_end: '</u></a>'.html_safe }
+ flash[:notice] = ERB::Util.html_escape(_("You have been redirected to the only result; see the %{a_start}search results%{a_end} instead.")) % { a_start: "<a href=\"#{link}\"><u>".html_safe, a_end: '</u></a>'.html_safe }
redirect_to project_commit_path(@project, commit)
true
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 6d3811514d9..94e114e7da8 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -16,6 +16,7 @@ class UploadsController < ApplicationController
"projects/topic" => Projects::Topic,
'alert_management_metric_image' => ::AlertManagement::MetricImage,
"achievements/achievement" => Achievements::Achievement,
+ "organizations/organization_detail" => Organizations::OrganizationDetail,
"abuse_report" => AbuseReport,
nil => PersonalSnippet
}.freeze
@@ -65,6 +66,8 @@ class UploadsController < ApplicationController
can?(current_user, :read_alert_management_metric_image, model.alert)
when ::Achievements::Achievement
true
+ when Organizations::OrganizationDetail
+ can?(current_user, :read_organization, model.organization)
else
can?(current_user, "read_#{model.class.underscore}".to_sym, model)
end
@@ -96,7 +99,7 @@ class UploadsController < ApplicationController
def cache_settings
case model
- when User, Appearance, Projects::Topic, Achievements::Achievement
+ when User, Appearance, Projects::Topic, Achievements::Achievement, Organizations::OrganizationDetail
[5.minutes, { public: true, must_revalidate: false }]
when Project, Group
[5.minutes, { private: true, must_revalidate: true }]
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 88a8851607b..83cd84c396a 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -261,7 +261,8 @@ class UsersController < ApplicationController
end
def load_groups
- @groups = JoinedGroupsFinder.new(user).execute(current_user)
+ groups = JoinedGroupsFinder.new(user).execute(current_user)
+ @groups = groups.with_route.page(params[:page]).without_count
prepare_groups_for_rendering(@groups)
end
diff --git a/app/events/ci/job_artifacts_deleted_event.rb b/app/events/ci/job_artifacts_deleted_event.rb
index 2972342cae6..4d85c0cfbee 100644
--- a/app/events/ci/job_artifacts_deleted_event.rb
+++ b/app/events/ci/job_artifacts_deleted_event.rb
@@ -9,8 +9,8 @@ module Ci
'properties' => {
'job_ids' => {
'type' => 'array',
- 'properties' => {
- 'job_id' => { 'type' => 'integer' }
+ 'items' => {
+ 'type' => 'integer'
}
}
}
diff --git a/app/events/project_authorizations/authorizations_added_event.rb b/app/events/project_authorizations/authorizations_added_event.rb
new file mode 100644
index 00000000000..521a862218d
--- /dev/null
+++ b/app/events/project_authorizations/authorizations_added_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module ProjectAuthorizations
+ class AuthorizationsAddedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'required' => %w[project_id user_ids],
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' },
+ 'user_ids' => { 'type' => 'array' }
+ }
+ }
+ end
+ end
+end
diff --git a/app/events/projects/release_published_event.rb b/app/events/projects/release_published_event.rb
new file mode 100644
index 00000000000..f0be95b893e
--- /dev/null
+++ b/app/events/projects/release_published_event.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Projects
+ class ReleasePublishedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'release_id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[release_id]
+ }
+ end
+ end
+end
diff --git a/app/experiments/in_product_guidance_environments_webide_experiment.rb b/app/experiments/in_product_guidance_environments_webide_experiment.rb
deleted file mode 100644
index 78602874cb7..00000000000
--- a/app/experiments/in_product_guidance_environments_webide_experiment.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment
- control { false }
-
- exclude :has_environments?
-
- private
-
- def has_environments?
- !context.project.environments.empty?
- end
-end
diff --git a/app/finders/ci/catalog/resources/versions_finder.rb b/app/finders/ci/catalog/resources/versions_finder.rb
index b37d4f0377a..16f8531c5c4 100644
--- a/app/finders/ci/catalog/resources/versions_finder.rb
+++ b/app/finders/ci/catalog/resources/versions_finder.rb
@@ -18,6 +18,7 @@ module Ci
versions = params[:latest] ? get_latest_versions : get_versions
versions = versions.preloaded
+ versions = by_name(versions)
sort(versions)
end
@@ -45,6 +46,12 @@ module Ci
end
strong_memoize_attr :authorized_catalog_resources
+ def by_name(versions)
+ return versions unless params[:name]
+
+ versions.by_name(params[:name])
+ end
+
def sort(versions)
versions.order_by(params[:sort] || DEFAULT_SORT)
end
diff --git a/app/finders/ci/runner_jobs_finder.rb b/app/finders/ci/runner_jobs_finder.rb
index b659eda6646..91d8eccff21 100644
--- a/app/finders/ci/runner_jobs_finder.rb
+++ b/app/finders/ci/runner_jobs_finder.rb
@@ -13,7 +13,13 @@ module Ci
end
def execute
- items = @runner.builds
+ items = if params[:system_id].blank?
+ runner.builds
+ else
+ runner_manager = Ci::RunnerManager.for_runner(runner).with_system_xid(params[:system_id]).first
+ Ci::Build.belonging_to_runner_manager(runner_manager&.id)
+ end
+
items = by_permission(items)
items = by_status(items)
sort_items(items)
diff --git a/app/finders/ci/runner_managers_finder.rb b/app/finders/ci/runner_managers_finder.rb
new file mode 100644
index 00000000000..f24be74bbeb
--- /dev/null
+++ b/app/finders/ci/runner_managers_finder.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ class RunnerManagersFinder
+ def initialize(runner:, params:)
+ @runner = runner
+ @params = params
+ end
+
+ def execute
+ items = runner_managers
+
+ filter_by_status(items)
+ end
+
+ private
+
+ attr_reader :runner, :params
+
+ def runner_managers
+ ::Ci::RunnerManager.for_runner(runner)
+ end
+
+ def filter_by_status(items)
+ status = params[:status]
+ return items if status.blank?
+
+ items.with_status(status)
+ end
+ end
+end
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index 945d332ff47..18be2aec2e2 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -14,18 +14,25 @@ module Ci
end
def execute
- search!
- filter_by_active!
- filter_by_status!
- filter_by_upgrade_status!
- filter_by_runner_type!
- filter_by_tag_list!
- filter_by_creator_id!
- filter_by_version_prefix!
- sort!
- request_tag_list!
-
- @runners
+ items = if @project
+ project_runners
+ elsif @group
+ group_runners
+ else
+ all_runners
+ end
+
+ items = search(items)
+ items = by_active(items)
+ items = by_status(items)
+ items = by_upgrade_status(items)
+ items = by_runner_type(items)
+ items = by_tag_list(items)
+ items = by_creator_id(items)
+ items = by_version_prefix(items)
+ items = request_tag_list(items)
+
+ sort(items)
end
def sort_key
@@ -40,110 +47,104 @@ module Ci
%w[contacted_asc contacted_desc created_at_asc created_at_desc created_date token_expires_at_asc token_expires_at_desc]
end
- def search!
- if @project
- project_runners
- elsif @group
- group_runners
- else
- all_runners
- end
-
- @runners = @runners.search(@params[:search]) if @params[:search].present?
- end
-
def all_runners
raise Gitlab::Access::AccessDeniedError unless @current_user&.can_admin_all_resources?
- @runners = Ci::Runner.all
+ Ci::Runner.all
end
def group_runners
raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :read_group_runners, @group)
- @runners = case @params[:membership]
- when :direct
- Ci::Runner.belonging_to_group(@group.id)
- when :descendants, nil
- Ci::Runner.belonging_to_group_or_project_descendants(@group.id)
- when :all_available
- unless can?(@current_user, :read_group_all_available_runners, @group)
- raise Gitlab::Access::AccessDeniedError
- end
-
- Ci::Runner.usable_from_scope(@group)
- else
- raise ArgumentError, 'Invalid membership filter'
- end
+ case @params[:membership]
+ when :direct
+ Ci::Runner.belonging_to_group(@group.id)
+ when :descendants, nil
+ Ci::Runner.belonging_to_group_or_project_descendants(@group.id)
+ when :all_available
+ unless can?(@current_user, :read_group_all_available_runners, @group)
+ raise Gitlab::Access::AccessDeniedError
+ end
+
+ Ci::Runner.usable_from_scope(@group)
+ else
+ raise ArgumentError, 'Invalid membership filter'
+ end
end
def project_runners
raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :read_project_runners, @project)
- @runners = ::Ci::Runner.owned_or_instance_wide(@project.id)
+ ::Ci::Runner.owned_or_instance_wide(@project.id)
+ end
+
+ def search(items)
+ return items unless @params[:search].present?
+
+ items.search(@params[:search])
end
- def filter_by_active!
- @runners = @runners.active(@params[:active]) if @params.include?(:active)
+ def by_active(items)
+ return items if @params.exclude?(:active)
+
+ items.active(@params[:active])
end
- def filter_by_status!
- filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES)
+ def by_status(items)
+ status = @params[:status_status].presence
+ return items unless status
+
+ items.with_status(status)
end
- def filter_by_upgrade_status!
+ def by_upgrade_status(items)
upgrade_status = @params[:upgrade_status]
- return unless upgrade_status
+ return items unless upgrade_status
unless Ci::RunnerVersion.statuses.key?(upgrade_status)
raise ArgumentError, "Invalid upgrade status value '#{upgrade_status}'"
end
- @runners = @runners.with_upgrade_status(upgrade_status)
+ items.with_upgrade_status(upgrade_status)
end
- def filter_by_runner_type!
- filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES)
+ def by_runner_type(items)
+ runner_type = @params[:type_type].presence
+ return items unless runner_type
+
+ items.with_runner_type(runner_type)
end
- def filter_by_tag_list!
+ def by_tag_list(items)
tag_list = @params[:tag_name].presence
+ return items unless tag_list
- if tag_list
- @runners = @runners.tagged_with(tag_list)
- end
+ items.tagged_with(tag_list)
end
- def filter_by_creator_id!
- creator_id = @params[:creator_id]
- @runners = @runners.with_creator_id(creator_id) if creator_id.present?
- end
+ def by_creator_id(items)
+ creator_id = @params[:creator_id].presence
+ return items unless creator_id
- def filter_by_version_prefix!
- return @runners unless @params[:version_prefix]
-
- sanitized_prefix = @params[:version_prefix][/^[\d+.]+/]
-
- return @runners unless sanitized_prefix
-
- @runners = @runners.with_version_prefix(sanitized_prefix)
+ items.with_creator_id(creator_id)
end
- def sort!
- @runners = @runners.order_by(sort_key)
+ def by_version_prefix(items)
+ sanitized_prefix = @params.fetch(:version_prefix, '')[/^[\d+.]+/]
+ return items unless sanitized_prefix
+
+ items.with_version_prefix(sanitized_prefix)
end
- def request_tag_list!
- @runners = @runners.with_tags if !@params[:preload].present? || @params.dig(:preload, :tag_name)
+ def sort(items)
+ items.order_by(sort_key)
end
- def filter_by!(scope_name, available_scopes)
- scope = @params[scope_name]
+ def request_tag_list(items)
+ return items if @params.include?(:preload) && !@params.dig(:preload, :tag_name) # Backward-compatible behavior
- if scope.present? && available_scopes.include?(scope)
- @runners = @runners.public_send(scope) # rubocop:disable GitlabSecurity/PublicSend
- end
+ items.with_tags
end
end
end
diff --git a/app/finders/groups/accepting_project_shares_finder.rb b/app/finders/groups/accepting_project_shares_finder.rb
index c85e5a0f538..cf20292318e 100644
--- a/app/finders/groups/accepting_project_shares_finder.rb
+++ b/app/finders/groups/accepting_project_shares_finder.rb
@@ -42,7 +42,12 @@ module Groups
# rubocop: disable CodeReuse/Finder
def groups_with_guest_access_plus
- GroupsFinder.new(current_user, min_access_level: Gitlab::Access::GUEST).execute
+ groups = GroupsFinder.new(current_user, min_access_level: Gitlab::Access::GUEST).execute
+
+ # We move the result into a materialized CTE to improve query performance during text search.
+ union_query = ::Group.from_union([groups])
+ cte = Gitlab::SQL::CTE.new(:my_union_cte, union_query)
+ Group.with(cte.to_arel).from(cte.alias_to(Group.arel_table)) # rubocop: disable CodeReuse/ActiveRecord -- CTE use
end
# rubocop: enable CodeReuse/Finder
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index bc136848dd5..6b56c966025 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -135,14 +135,9 @@ class IssuableFinder
strong_memoize(:projects) do
next Array.wrap(project) if project?
- projects =
- if current_user && params[:authorized_only].presence && !current_user_related?
- current_user.authorized_projects(min_access_level)
- else
- projects_public_or_visible_to_user
- end
-
- projects.with_feature_available_for_user(klass.base_class, current_user).reorder(nil) # rubocop: disable CodeReuse/ActiveRecord
+ projects_public_or_visible_to_user
+ .with_feature_available_for_user(klass.base_class, current_user)
+ .without_order
end
end
diff --git a/app/finders/packages/terraform_module/packages_finder.rb b/app/finders/packages/terraform_module/packages_finder.rb
new file mode 100644
index 00000000000..bcef8738622
--- /dev/null
+++ b/app/finders/packages/terraform_module/packages_finder.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Packages
+ module TerraformModule
+ class PackagesFinder
+ def initialize(project, params = {})
+ @project = project
+ @params = params
+ end
+
+ def execute
+ return ::Packages::Package.none unless project && params[:package_name]
+
+ packages
+ end
+
+ private
+
+ attr_reader :project, :params
+
+ def packages
+ result = project
+ .packages
+ .with_name(params[:package_name])
+ .terraform_module
+ .installable
+
+ params[:package_version] ? result.with_version(params[:package_version]) : result.has_version.order_version_desc
+ end
+ end
+ end
+end
diff --git a/app/finders/projects/ml/experiment_finder.rb b/app/finders/projects/ml/experiment_finder.rb
new file mode 100644
index 00000000000..0363cc6ec39
--- /dev/null
+++ b/app/finders/projects/ml/experiment_finder.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Projects
+ module Ml
+ class ExperimentFinder
+ include Gitlab::Utils::StrongMemoize
+
+ VALID_ORDER_BY = %w[name created_at updated_at id].freeze
+ VALID_SORT = %w[asc desc].freeze
+
+ def initialize(project, params = {})
+ @project = project
+ @params = params
+ end
+
+ def execute
+ relation
+ end
+
+ private
+
+ def relation
+ @experiments = ::Ml::Experiment
+ .by_project(project)
+ .including_project
+
+ ordered
+ end
+
+ def ordered
+ order_by = valid_or_default(params[:order_by]&.downcase, VALID_ORDER_BY, 'id')
+ sort = valid_or_default(params[:sort]&.downcase, VALID_SORT, 'desc')
+
+ experiments.order_by("#{order_by}_#{sort}").with_order_id_desc
+ end
+
+ def valid_or_default(value, valid_values, default)
+ return value if valid_values.include?(value)
+
+ default
+ end
+
+ attr_reader :params, :project, :experiments
+ end
+ end
+end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 2a781c037f6..cd919c88f99 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -29,7 +29,7 @@
# repository_storage: string
# not_aimed_for_deletion: boolean
# full_paths: string[]
-# organization_id: int
+# organization: Scope the groups to the Organizations::Organization
#
class ProjectsFinder < UnionFinder
include CustomAttributesFilter
@@ -96,7 +96,7 @@ class ProjectsFinder < UnionFinder
collection = by_language(collection)
collection = by_feature_availability(collection)
collection = by_updated_at(collection)
- collection = by_organization_id(collection)
+ collection = by_organization(collection)
by_repository_storage(collection)
end
@@ -173,7 +173,7 @@ class ProjectsFinder < UnionFinder
# rubocop: enable CodeReuse/ActiveRecord
def by_full_paths(items)
- params[:full_paths].present? ? items.where_full_path_in(params[:full_paths], use_includes: false) : items
+ params[:full_paths].present? ? items.where_full_path_in(params[:full_paths], preload_routes: false) : items
end
def union(items)
@@ -295,8 +295,11 @@ class ProjectsFinder < UnionFinder
items
end
- def by_organization_id(items)
- params[:organization_id].present? ? items.in_organization(params[:organization_id]) : items
+ def by_organization(items)
+ organization = params[:organization]
+ return items unless organization
+
+ items.in_organization(organization)
end
def finder_params
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index 88ba635e20b..101562de209 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -55,7 +55,16 @@ class UsersFinder
private
def base_scope
- scope = current_user&.can_admin_all_resources? ? User.all : User.without_forbidden_states
+ group = params[:group]
+
+ if group
+ raise Gitlab::Access::AccessDeniedError unless user_can_read_group?(group)
+
+ scope = ::Autocomplete::GroupUsersFinder.new(group: group).execute # rubocop: disable CodeReuse/Finder -- For SQL optimization sake we need to scope out group members first see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/137647#note_1664081899
+ else
+ scope = current_user&.can_admin_all_resources? ? User.all : User.without_forbidden_states
+ end
+
scope.order_id_desc
end
@@ -155,6 +164,10 @@ class UsersFinder
users.order_by(params[:sort])
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def user_can_read_group?(group)
+ Ability.allowed?(current_user, :read_group, group)
+ end
end
UsersFinder.prepend_mod_with('UsersFinder')
diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb
index 527eb50b644..52f3e56aec3 100644
--- a/app/graphql/graphql_triggers.rb
+++ b/app/graphql/graphql_triggers.rb
@@ -59,6 +59,12 @@ module GraphqlTriggers
)
end
+ def self.merge_request_diff_generated(merge_request)
+ GitlabSchema.subscriptions.trigger(
+ :merge_request_diff_generated, { issuable_id: merge_request.to_gid }, merge_request
+ )
+ end
+
def self.work_item_updated(work_item)
# becomes is necessary here since this can be triggered with both a WorkItem and also an Issue
# depending on the update service the call comes from
diff --git a/app/graphql/mutations/branch_rules/create.rb b/app/graphql/mutations/branch_rules/create.rb
new file mode 100644
index 00000000000..c478d981c33
--- /dev/null
+++ b/app/graphql/mutations/branch_rules/create.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Mutations
+ module BranchRules
+ class Create < BaseMutation
+ graphql_name 'BranchRuleCreate'
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Full path to the project that the branch is associated with.'
+
+ argument :name, GraphQL::Types::String,
+ required: true,
+ description: 'Branch name, with wildcards, for the branch rules.'
+
+ field :branch_rule,
+ Types::Projects::BranchRuleType,
+ null: true,
+ description: 'Branch rule after mutation.'
+
+ def resolve(project_path:, name:)
+ project = Project.find_by_full_path(project_path)
+
+ service_params = protected_branch_params(name)
+ protected_branch = ::ProtectedBranches::CreateService.new(project, current_user, service_params).execute
+
+ if protected_branch.persisted?
+ {
+ branch_rule: ::Projects::BranchRule.new(project, protected_branch),
+ errors: []
+ }
+ else
+ { errors: errors_on_object(protected_branch) }
+ end
+ rescue Gitlab::Access::AccessDeniedError
+ raise_resource_not_available_error!
+ end
+
+ def protected_branch_params(name)
+ {
+ name: name,
+ push_access_levels_attributes: access_level_attributes(:push),
+ merge_access_levels_attributes: access_level_attributes(:merge)
+ }
+ end
+
+ def access_level_attributes(type)
+ ::ProtectedRefs::AccessLevelParams.new(
+ type,
+ {},
+ with_defaults: true
+ ).access_levels
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
index 7aa78509bea..a5d9014af17 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
@@ -48,6 +48,10 @@ module Mutations
::Types::WorkItems::Widgets::AwardEmojiUpdateInputType,
required: false,
description: 'Input for emoji reactions widget.'
+ argument :notes_widget,
+ ::Types::WorkItems::Widgets::NotesInputType,
+ required: false,
+ description: 'Input for notes widget.'
end
end
end
diff --git a/app/graphql/mutations/issues/set_assignees.rb b/app/graphql/mutations/issues/set_assignees.rb
index 8413c89b010..1e55cdee0a8 100644
--- a/app/graphql/mutations/issues/set_assignees.rb
+++ b/app/graphql/mutations/issues/set_assignees.rb
@@ -8,7 +8,7 @@ module Mutations
include Assignable
def assign!(issue, users, mode)
- permitted, forbidden = users.partition { |u| u.can?(:read_issue, issue) }
+ permitted, forbidden = users.partition { |u| u.can?(:read_issue, issue.resource_parent) }
super(issue, permitted, mode)
diff --git a/app/graphql/mutations/ml/models/base.rb b/app/graphql/mutations/ml/models/base.rb
new file mode 100644
index 00000000000..e3c5a7a13a8
--- /dev/null
+++ b/app/graphql/mutations/ml/models/base.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ml
+ module Models
+ class Base < BaseMutation
+ authorize :write_model_registry
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: "Project the model to mutate is in."
+
+ field :model,
+ Types::Ml::ModelType,
+ null: true,
+ description: 'Model after mutation.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ml/models/create.rb b/app/graphql/mutations/ml/models/create.rb
new file mode 100644
index 00000000000..21570fc34b8
--- /dev/null
+++ b/app/graphql/mutations/ml/models/create.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ml
+ module Models
+ class Create < Base
+ graphql_name 'MlModelCreate'
+
+ include FindsProject
+
+ argument :name, GraphQL::Types::String,
+ required: true,
+ description: 'Name of the model.'
+
+ argument :description, GraphQL::Types::String,
+ required: false,
+ description: 'Description of the model.'
+
+ def resolve(**args)
+ project = authorized_find!(args[:project_path])
+
+ model = ::Ml::CreateModelService.new(project, args[:name], current_user, args[:description]).execute
+
+ {
+ model: model.persisted? ? model : nil,
+ errors: errors_on_object(model)
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb
index 813c5687642..a429dd06a7c 100644
--- a/app/graphql/mutations/namespace/package_settings/update.rb
+++ b/app/graphql/mutations/namespace/package_settings/update.rb
@@ -51,6 +51,16 @@ module Mutations
required: false,
description: copy_field_description(Types::Namespace::PackageSettingsType, :nuget_duplicate_exception_regex)
+ argument :terraform_module_duplicates_allowed,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: copy_field_description(Types::Namespace::PackageSettingsType, :terraform_module_duplicates_allowed)
+
+ argument :terraform_module_duplicate_exception_regex,
+ Types::UntrustedRegexp,
+ required: false,
+ description: copy_field_description(Types::Namespace::PackageSettingsType, :terraform_module_duplicate_exception_regex)
+
argument :maven_package_requests_forwarding,
GraphQL::Types::Boolean,
required: false,
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index 7ce508e5ef1..754b453ce5d 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -60,6 +60,7 @@ module Mutations
def resolve(project_path: nil, namespace_path: nil, **attributes)
container_path = project_path || namespace_path
container = authorized_find!(container_path)
+ check_env_feature_available!(container)
check_feature_available!(container)
params = global_id_compatibility_params(attributes).merge(author_id: current_user.id)
@@ -83,6 +84,15 @@ module Mutations
private
+ # This is just a temporary measure while we migrate and backfill epic internal_ids
+ # More info in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/139367
+ def check_env_feature_available!(container)
+ return unless container.is_a?(::Group) && Rails.env.production?
+
+ message = 'Group level work items are disabled. Only project paths allowed in `namespacePath`.'
+ raise Gitlab::Graphql::Errors::ArgumentError, message
+ end
+
def check_feature_available!(container)
return unless container.is_a?(::Group) && Feature.disabled?(:namespace_level_work_items, container)
diff --git a/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb b/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb
index 9332076a493..899b407b180 100644
--- a/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb
+++ b/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb
@@ -11,12 +11,18 @@ module Resolvers
# field is evaluated on more than one node, it causes performance degradation.
extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
+ argument :name, GraphQL::Types::String,
+ required: false,
+ description: 'Name of the version.'
+
argument :sort, Types::Ci::Catalog::Resources::VersionSortEnum,
required: false,
description: 'Sort versions by given criteria.'
- def resolve(sort: nil)
- ::Ci::Catalog::Resources::VersionsFinder.new(object, current_user, sort: sort).execute
+ alias_method :catalog_resource, :object
+
+ def resolve(name: nil, sort: nil)
+ ::Ci::Catalog::Resources::VersionsFinder.new(catalog_resource, current_user, name: name, sort: sort).execute
end
end
end
diff --git a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb
index f4e044b81c9..28c39427872 100644
--- a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb
@@ -34,7 +34,7 @@ module Resolvers
def resolve_owner
return unless runner.project_type?
- BatchLoader::GraphQL.for(runner.id).batch(key: :runner_owner_projects) do |runner_ids, loader|
+ BatchLoader::GraphQL.for(runner.id).batch do |runner_ids, loader|
# rubocop: disable CodeReuse/ActiveRecord
runner_and_projects_with_row_number =
::Ci::RunnerProject
diff --git a/app/graphql/resolvers/ci/runner_projects_resolver.rb b/app/graphql/resolvers/ci/runner_projects_resolver.rb
index c5037965e20..99c9bba1bd6 100644
--- a/app/graphql/resolvers/ci/runner_projects_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_projects_resolver.rb
@@ -28,7 +28,7 @@ module Resolvers
return unless runner.project_type?
# rubocop:disable CodeReuse/ActiveRecord
- BatchLoader::GraphQL.for(runner.id).batch(key: :runner_projects) do |runner_ids, loader|
+ BatchLoader::GraphQL.for(runner.id).batch do |runner_ids, loader|
plucked_runner_and_project_ids = ::Ci::RunnerProject
.select(:runner_id, :project_id)
.where(runner_id: runner_ids)
diff --git a/app/graphql/resolvers/ci/runner_resolver.rb b/app/graphql/resolvers/ci/runner_resolver.rb
index 4250b069d20..60fb4163afe 100644
--- a/app/graphql/resolvers/ci/runner_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_resolver.rb
@@ -6,13 +6,12 @@ module Resolvers
include LooksAhead
type Types::Ci::RunnerType, null: true
- extras [:lookahead]
description 'Runner information.'
argument :id,
- type: ::Types::GlobalIDType[::Ci::Runner],
- required: true,
- description: 'Runner ID.'
+ type: ::Types::GlobalIDType[::Ci::Runner],
+ required: true,
+ description: 'Runner ID.'
def resolve_with_lookahead(id:)
find_runner(id: id)
@@ -21,19 +20,13 @@ module Resolvers
private
def find_runner(id:)
- runner_id = GitlabSchema.parse_gid(id, expected_type: ::Ci::Runner).model_id.to_i
- key = {
- preload_tag_list: lookahead.selects?(:tag_list),
- preload_creator: lookahead.selects?(:created_by)
- }
-
- BatchLoader::GraphQL.for(runner_id).batch(key: key) do |ids, loader, batch|
- results = ::Ci::Runner.id_in(ids)
- results = results.with_tags if batch[:key][:preload_tag_list]
- results = results.with_creator if batch[:key][:preload_creator]
-
- results.each { |record| loader.call(record.id, record) }
- end
+ preloads = []
+ preloads << :creator if lookahead.selects?(:created_by)
+ preloads << :tags if lookahead.selects?(:tag_list)
+
+ runner_id = GitlabSchema.parse_gid(id, expected_type: ::Ci::Runner).model_id
+
+ ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Runner, runner_id, preloads).find
end
end
end
diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
index 9121c413b1f..38d2ebe046b 100644
--- a/app/graphql/resolvers/ci/runners_resolver.rb
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -82,7 +82,7 @@ module Resolvers
creator_id:
params[:creator_id] ? ::GitlabSchema.parse_gid(params[:creator_id], expected_type: ::User).model_id : nil,
version_prefix: params[:version_prefix],
- preload: false # we'll handle preloading ourselves
+ preload: {} # we'll handle preloading ourselves
}.compact
.merge(parent_param)
end
diff --git a/app/graphql/resolvers/concerns/resolves_groups.rb b/app/graphql/resolvers/concerns/resolves_groups.rb
index 86dda5cb1cb..1673b1bd37f 100644
--- a/app/graphql/resolvers/concerns/resolves_groups.rb
+++ b/app/graphql/resolvers/concerns/resolves_groups.rb
@@ -5,6 +5,19 @@ module ResolvesGroups
extend ActiveSupport::Concern
include LooksAhead
+ PRELOADS = {
+ container_repositories_count: [:container_repositories],
+ custom_emoji: [:custom_emoji],
+ full_path: [:route],
+ path: [:route],
+ web_url: [:route],
+ dependency_proxy_blob_count: [:dependency_proxy_blobs],
+ dependency_proxy_blobs: [:dependency_proxy_blobs],
+ dependency_proxy_image_count: [:dependency_proxy_manifests],
+ dependency_proxy_image_ttl_policy: [:dependency_proxy_image_ttl_policy],
+ dependency_proxy_setting: [:dependency_proxy_setting]
+ }.freeze
+
def resolve_with_lookahead(...)
apply_lookahead(resolve_groups(...))
end
@@ -17,17 +30,8 @@ module ResolvesGroups
end
def preloads
- {
- container_repositories_count: [:container_repositories],
- custom_emoji: [:custom_emoji],
- full_path: [:route],
- path: [:route],
- web_url: [:route],
- dependency_proxy_blob_count: [:dependency_proxy_blobs],
- dependency_proxy_blobs: [:dependency_proxy_blobs],
- dependency_proxy_image_count: [:dependency_proxy_manifests],
- dependency_proxy_image_ttl_policy: [:dependency_proxy_image_ttl_policy],
- dependency_proxy_setting: [:dependency_proxy_setting]
- }
+ PRELOADS
end
end
+
+ResolvesGroups.prepend_mod
diff --git a/app/graphql/resolvers/container_repository_tags_resolver.rb b/app/graphql/resolvers/container_repository_tags_resolver.rb
index 50adf98fa07..d3929451bd0 100644
--- a/app/graphql/resolvers/container_repository_tags_resolver.rb
+++ b/app/graphql/resolvers/container_repository_tags_resolver.rb
@@ -14,6 +14,11 @@ module Resolvers
required: false,
default_value: nil
+ argument :referrers, GraphQL::Types::Boolean,
+ description: 'Include tag referrers.',
+ required: false,
+ default_value: nil
+
alias_method :container_repository, :object
def resolve(sort:, **filters)
@@ -25,7 +30,8 @@ module Resolvers
last: filters[:after],
sort: map_sort_field(sort),
name: filters[:name],
- page_size: page_size
+ page_size: page_size,
+ referrers: filters[:referrers]
)
Gitlab::Graphql::ExternallyPaginatedArray.new(
diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb
index b8df54f49ab..2c64d08a219 100644
--- a/app/graphql/resolvers/full_path_resolver.rb
+++ b/app/graphql/resolvers/full_path_resolver.rb
@@ -4,10 +4,10 @@ module Resolvers
module FullPathResolver
extend ActiveSupport::Concern
- prepended do
+ included do
argument :full_path, GraphQL::Types::ID,
- required: true,
- description: 'Full path of the project, group, or namespace. For example, `gitlab-org/gitlab-foss`.'
+ required: true,
+ description: "Full path of the #{target_type}. For example, `gitlab-org/gitlab-foss`."
end
def model_by_full_path(model, full_path)
diff --git a/app/graphql/resolvers/group_resolver.rb b/app/graphql/resolvers/group_resolver.rb
index 4260e18829e..e3b651b6493 100644
--- a/app/graphql/resolvers/group_resolver.rb
+++ b/app/graphql/resolvers/group_resolver.rb
@@ -2,7 +2,11 @@
module Resolvers
class GroupResolver < BaseResolver
- prepend FullPathResolver
+ def self.target_type
+ 'group'
+ end
+
+ include FullPathResolver
type Types::GroupType, null: true
diff --git a/app/graphql/resolvers/ml/find_models_resolver.rb b/app/graphql/resolvers/ml/find_models_resolver.rb
new file mode 100644
index 00000000000..b9901100e22
--- /dev/null
+++ b/app/graphql/resolvers/ml/find_models_resolver.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ml
+ class FindModelsResolver < Resolvers::BaseResolver
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
+
+ type ::Types::Ml::ModelType.connection_type, null: true
+
+ argument :name, GraphQL::Types::String,
+ required: false,
+ description: 'Search for names that include the string.'
+
+ argument :order_by, ::Types::Ml::ModelsOrderByEnum,
+ required: false,
+ description: 'Ordering column. Default is created_at.'
+
+ argument :sort, ::Types::SortDirectionEnum,
+ required: false,
+ description: 'Ordering column. Default is desc.'
+
+ def resolve(**args)
+ return unless current_user.can?(:read_model_registry, object)
+
+ find_params = {
+ name: args[:name],
+ order_by: args[:order_by].to_s,
+ sort: args[:sort].to_s
+ }
+
+ ::Projects::Ml::ModelFinder.new(object, find_params).execute
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/namespace_resolver.rb b/app/graphql/resolvers/namespace_resolver.rb
index 17b3800d151..a0b16758625 100644
--- a/app/graphql/resolvers/namespace_resolver.rb
+++ b/app/graphql/resolvers/namespace_resolver.rb
@@ -2,7 +2,11 @@
module Resolvers
class NamespaceResolver < BaseResolver
- prepend FullPathResolver
+ def self.target_type
+ 'namespace'
+ end
+
+ include FullPathResolver
type Types::NamespaceType, null: true
diff --git a/app/graphql/resolvers/organizations/organizations_resolver.rb b/app/graphql/resolvers/organizations/organizations_resolver.rb
new file mode 100644
index 00000000000..ab21a84645b
--- /dev/null
+++ b/app/graphql/resolvers/organizations/organizations_resolver.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Organizations
+ class OrganizationsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::Organizations::OrganizationType.connection_type, null: true
+ authorize :read_organization
+
+ def resolve
+ # For the Organization MVC, all the organizations are public. We need to change this to only accessible
+ # organizations once we start supporting private organizations.
+ # See https://gitlab.com/groups/gitlab-org/-/epics/10649.
+ ::Organizations::Organization.all
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/organizations/projects_resolver.rb b/app/graphql/resolvers/organizations/projects_resolver.rb
new file mode 100644
index 00000000000..836fe0ae059
--- /dev/null
+++ b/app/graphql/resolvers/organizations/projects_resolver.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Organizations
+ class ProjectsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::ProjectType, null: true
+
+ authorize :read_project
+
+ alias_method :organization, :object
+
+ def resolve
+ ::ProjectsFinder.new(current_user: current_user, params: { organization: organization }).execute
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/project_resolver.rb b/app/graphql/resolvers/project_resolver.rb
index 2132447da5e..931fefcea50 100644
--- a/app/graphql/resolvers/project_resolver.rb
+++ b/app/graphql/resolvers/project_resolver.rb
@@ -2,7 +2,11 @@
module Resolvers
class ProjectResolver < BaseResolver
- prepend FullPathResolver
+ def self.target_type
+ 'project'
+ end
+
+ include FullPathResolver
type Types::ProjectType, null: true
diff --git a/app/graphql/resolvers/projects/fork_targets_resolver.rb b/app/graphql/resolvers/projects/fork_targets_resolver.rb
index 5e8be325d43..27797b9f0af 100644
--- a/app/graphql/resolvers/projects/fork_targets_resolver.rb
+++ b/app/graphql/resolvers/projects/fork_targets_resolver.rb
@@ -3,7 +3,7 @@
module Resolvers
module Projects
class ForkTargetsResolver < BaseResolver
- include ResolvesGroups
+ include LooksAhead
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::NamespaceType.connection_type, null: true
@@ -17,10 +17,15 @@ module Resolvers
required: false,
description: 'Search query for path or name.'
+ def resolve_with_lookahead(**args)
+ fork_targets = ForkTargetsFinder.new(project, current_user).execute(args)
+ apply_lookahead(fork_targets)
+ end
+
private
- def resolve_groups(**args)
- ForkTargetsFinder.new(project, current_user).execute(args)
+ def preloads
+ ResolvesGroups::PRELOADS
end
end
end
diff --git a/app/graphql/resolvers/users_resolver.rb b/app/graphql/resolvers/users_resolver.rb
index 90a6bd3e6b2..a512c6bafe1 100644
--- a/app/graphql/resolvers/users_resolver.rb
+++ b/app/graphql/resolvers/users_resolver.rb
@@ -28,10 +28,19 @@ module Resolvers
default_value: false,
description: 'Return only admin users.'
- def resolve(ids: nil, usernames: nil, sort: nil, search: nil, admins: nil)
+ argument :group_id, ::Types::GlobalIDType[::Group],
+ required: false,
+ description: 'Return users member of a given group.'
+
+ def resolve(ids: nil, usernames: nil, sort: nil, search: nil, admins: nil, group_id: nil)
authorize!(usernames)
- ::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort, search, admins)).execute
+ group = group_id ? find_authorized_group!(group_id) : nil
+
+ ::UsersFinder.new(
+ context[:current_user],
+ finder_params(ids, usernames, sort, search, admins, group)
+ ).execute
end
def ready?(**args)
@@ -52,16 +61,27 @@ module Resolvers
private
- def finder_params(ids, usernames, sort, search, admins)
+ def finder_params(ids, usernames, sort, search, admins, group)
params = {}
params[:sort] = sort if sort
params[:username] = usernames if usernames
params[:id] = parse_gids(ids) if ids
params[:search] = search if search
params[:admins] = admins if admins
+ params[:group] = group if group
params
end
+ def find_authorized_group!(group_id)
+ group = GitlabSchema.find_by_gid(group_id).sync
+
+ unless Ability.allowed?(current_user, :read_group, group)
+ raise_resource_not_available_error! "Could not find a Group with ID #{group_id}"
+ end
+
+ group
+ end
+
def parse_gids(gids)
gids.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::User).model_id }
end
diff --git a/app/graphql/types/ci/catalog/resources/component_type.rb b/app/graphql/types/ci/catalog/resources/component_type.rb
index 3b4771446cb..71ed31725a6 100644
--- a/app/graphql/types/ci/catalog/resources/component_type.rb
+++ b/app/graphql/types/ci/catalog/resources/component_type.rb
@@ -16,7 +16,8 @@ module Types
description: 'Name of the component.',
alpha: { milestone: '16.7' }
- field :path, GraphQL::Types::String, null: true,
+ field :include_path, GraphQL::Types::String, null: true,
+ method: :path,
description: 'Path used to include the component.',
alpha: { milestone: '16.7' }
diff --git a/app/graphql/types/ci/catalog/resources/version_type.rb b/app/graphql/types/ci/catalog/resources/version_type.rb
index 689f649afc5..b52a1c6b13d 100644
--- a/app/graphql/types/ci/catalog/resources/version_type.rb
+++ b/app/graphql/types/ci/catalog/resources/version_type.rb
@@ -20,13 +20,13 @@ module Types
field :released_at, Types::TimeType, null: true, description: 'Timestamp of when the version was released.',
alpha: { milestone: '16.7' }
- field :tag_name, GraphQL::Types::String, null: true, method: :name,
- description: 'Name of the tag associated with the version.',
- alpha: { milestone: '16.7' }
+ field :name, GraphQL::Types::String, null: true,
+ description: 'Name that uniquely identifies the version within the catalog resource.',
+ alpha: { milestone: '16.8' }
- field :tag_path, GraphQL::Types::String, null: true,
- description: 'Relative web path to the tag associated with the version.',
- alpha: { milestone: '16.7' }
+ field :path, GraphQL::Types::String, null: true,
+ description: 'Relative web path to the version.',
+ alpha: { milestone: '16.8' }
field :author, Types::UserType, null: true, description: 'User that created the version.',
alpha: { milestone: '16.7' }
@@ -39,12 +39,22 @@ module Types
description: 'Components belonging to the catalog resource.',
alpha: { milestone: '16.7' }
+ field :readme_html, GraphQL::Types::String, null: true, calls_gitaly: true,
+ description: 'GitLab Flavored Markdown rendering of README.md. This field ' \
+ 'can only be resolved for one version in any single request.',
+ alpha: { milestone: '16.8' } do
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 # To avoid N+1 calls to Gitaly
+ end
+
def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end
- def tag_path
- Gitlab::Routing.url_helpers.project_tag_path(object.project, object.name)
+ def readme_html
+ return unless Ability.allowed?(current_user, :read_code, object.project)
+
+ markdown_context = context.to_h.dup.merge(project: object.project)
+ ::MarkupHelper.markdown(object.readme&.data, markdown_context)
end
end
# rubocop: enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb
index f01c63d717b..0c2d1b788af 100644
--- a/app/graphql/types/ci/ci_cd_setting_type.rb
+++ b/app/graphql/types/ci/ci_cd_setting_type.rb
@@ -27,7 +27,7 @@ module Types
field :merge_pipelines_enabled,
GraphQL::Types::Boolean,
null: true,
- description: 'Whether merge pipelines are enabled.',
+ description: 'Whether merged results pipelines are enabled.',
method: :merge_pipelines_enabled?
field :project,
Types::ProjectType,
diff --git a/app/graphql/types/ci/inherited_ci_variable_type.rb b/app/graphql/types/ci/inherited_ci_variable_type.rb
index 2d8dcdaeefe..c90e34b25dd 100644
--- a/app/graphql/types/ci/inherited_ci_variable_type.rb
+++ b/app/graphql/types/ci/inherited_ci_variable_type.rb
@@ -15,6 +15,10 @@ module Types
null: true,
description: 'Name of the variable.'
+ field :description, GraphQL::Types::String,
+ null: true,
+ description: 'Description of the variable.'
+
field :raw, GraphQL::Types::Boolean,
null: true,
description: 'Indicates whether the variable is raw.'
diff --git a/app/graphql/types/ci/instance_variable_type.rb b/app/graphql/types/ci/instance_variable_type.rb
index e3230556769..457cd8a1ba2 100644
--- a/app/graphql/types/ci/instance_variable_type.rb
+++ b/app/graphql/types/ci/instance_variable_type.rb
@@ -13,6 +13,10 @@ module Types
null: false,
description: 'ID of the variable.'
+ field :description, GraphQL::Types::String,
+ null: true,
+ description: 'Description of the variable.'
+
field :environment_scope, GraphQL::Types::String,
null: true,
deprecated: {
diff --git a/app/graphql/types/commit_signatures/verification_status_enum.rb b/app/graphql/types/commit_signatures/verification_status_enum.rb
index 9df1b7abd82..d0d8f6670c3 100644
--- a/app/graphql/types/commit_signatures/verification_status_enum.rb
+++ b/app/graphql/types/commit_signatures/verification_status_enum.rb
@@ -6,10 +6,10 @@ module Types
module CommitSignatures
class VerificationStatusEnum < BaseEnum
graphql_name 'VerificationStatus'
- description 'Verification status of a GPG or X.509 signature for a commit.'
+ description 'Verification status of a GPG, X.509 or SSH signature for a commit.'
- ::CommitSignatures::GpgSignature.verification_statuses.each do |status, _|
- value status.upcase, value: status, description: "#{status} verification status."
+ ::Enums::CommitSignature.verification_statuses.each_key do |status|
+ value status.to_s.upcase, value: status.to_s, description: "#{status} verification status."
end
end
end
diff --git a/app/graphql/types/container_repository_referrer_type.rb b/app/graphql/types/container_repository_referrer_type.rb
new file mode 100644
index 00000000000..d9d4d150b95
--- /dev/null
+++ b/app/graphql/types/container_repository_referrer_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerRepositoryReferrerType < BaseObject
+ graphql_name 'ContainerRepositoryReferrer'
+
+ description 'A referrer for a container repository tag'
+
+ authorize :read_container_image
+
+ expose_permissions Types::PermissionTypes::ContainerRepositoryTag
+
+ field :artifact_type, GraphQL::Types::String, description: 'Artifact type of the referrer.'
+ field :digest, GraphQL::Types::String, description: 'Digest of the referrer.'
+ end
+end
diff --git a/app/graphql/types/container_repository_tag_type.rb b/app/graphql/types/container_repository_tag_type.rb
index cf8796410d3..7691844645a 100644
--- a/app/graphql/types/container_repository_tag_type.rb
+++ b/app/graphql/types/container_repository_tag_type.rb
@@ -22,6 +22,8 @@ module Types
field :location, GraphQL::Types::String, null: false, description: 'URL of the tag.'
field :name, GraphQL::Types::String, null: false, description: 'Name of the tag.'
field :path, GraphQL::Types::String, null: false, description: 'Path of the tag.'
+ field :published_at, Types::TimeType, null: true, description: 'Timestamp when the tag was published.'
+ field :referrers, [Types::ContainerRepositoryReferrerType], null: true, description: 'Referrers for this tag.'
field :revision, GraphQL::Types::String, null: true, description: 'Revision of the tag.'
field :short_revision, GraphQL::Types::String, null: true, description: 'Short revision of the tag.'
field :total_size, GraphQL::Types::BigInt, null: true, description: 'Size of the tag.'
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 7234948033b..01b741b5a98 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -214,6 +214,21 @@ module Types
complexity: 5,
resolver: Resolvers::NestedGroupsResolver
+ field :descendant_groups_count,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Count of direct descendant groups of this group.'
+
+ field :group_members_count,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Count of direct members of this group.'
+
+ field :projects_count,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Count of direct projects in this group.'
+
field :ci_variables,
Types::Ci::GroupVariableType.connection_type,
null: true,
@@ -339,6 +354,27 @@ module Types
group.dependency_proxy_setting || group.create_dependency_proxy_setting
end
+ def descendant_groups_count
+ BatchLoader::GraphQL.for(object.id).batch do |group_ids, loader|
+ descendants_counts = Group.id_in(group_ids).descendant_groups_counts
+ descendants_counts.each { |group_id, count| loader.call(group_id, count) }
+ end
+ end
+
+ def projects_count
+ BatchLoader::GraphQL.for(object.id).batch do |group_ids, loader|
+ projects_counts = Group.id_in(group_ids).projects_counts
+ projects_counts.each { |group_id, count| loader.call(group_id, count) }
+ end
+ end
+
+ def group_members_count
+ BatchLoader::GraphQL.for(object.id).batch do |group_ids, loader|
+ members_counts = Group.id_in(group_ids).group_members_counts
+ members_counts.each { |group_id, count| loader.call(group_id, count) }
+ end
+ end
+
private
def group
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index d7c3b313f84..3572cfd346b 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -248,6 +248,18 @@ module Types
'if `sast_reports_in_inline_diff` feature flag is disabled.',
resolver: ::Resolvers::CodequalityReportsComparerResolver
+ field :allows_multiple_assignees,
+ GraphQL::Types::Boolean,
+ method: :allows_multiple_assignees?,
+ description: 'Allows assigning multiple users to a merge request.',
+ null: false
+
+ field :allows_multiple_reviewers,
+ GraphQL::Types::Boolean,
+ method: :allows_multiple_reviewers?,
+ description: 'Allows assigning multiple reviewers to a merge request.',
+ null: false
+
markdown_field :title_html, null: true
markdown_field :description_html, null: true
diff --git a/app/graphql/types/ml/model_links_type.rb b/app/graphql/types/ml/model_links_type.rb
new file mode 100644
index 00000000000..9d18efb2e17
--- /dev/null
+++ b/app/graphql/types/ml/model_links_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ module Ml
+ # rubocop: disable Graphql/AuthorizeTypes -- authorization in ModelDetailsResolver
+ class ModelLinksType < BaseObject
+ graphql_name 'MLModelLinks'
+ description 'Represents links to perform actions on the model'
+
+ present_using ::Ml::ModelPresenter
+
+ field :show_path, GraphQL::Types::String,
+ null: true, description: 'Path to the details page of the model.', method: :path
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ml/model_type.rb b/app/graphql/types/ml/model_type.rb
index ca63918b370..a26d50cbdc4 100644
--- a/app/graphql/types/ml/model_type.rb
+++ b/app/graphql/types/ml/model_type.rb
@@ -7,10 +7,25 @@ module Types
graphql_name 'MlModel'
description 'Machine learning model in the model registry'
+ connection_type_class Types::LimitedCountableConnectionType
+
+ present_using ::Ml::ModelPresenter
+
field :id, ::Types::GlobalIDType[::Ml::Model], null: false, description: 'ID of the model.'
field :name, ::GraphQL::Types::String, null: false, description: 'Name of the model.'
+ field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
+
+ field :description, ::GraphQL::Types::String, null: false, description: 'Description of the model.'
+
+ field :latest_version, ::Types::Ml::ModelVersionType, null: true, description: 'Latest version of the model.'
+
+ field :version_count, ::GraphQL::Types::Int, null: true, description: 'Count of versions in the model.'
+
+ field :_links, ::Types::Ml::ModelLinksType, null: false, method: :itself,
+ description: 'Map of links to perform actions on the model.'
+
field :versions, ::Types::Ml::ModelVersionType.connection_type, null: true,
description: 'Versions of the model.'
diff --git a/app/graphql/types/ml/model_version_links_type.rb b/app/graphql/types/ml/model_version_links_type.rb
index 142f62bfad2..a8497334fc6 100644
--- a/app/graphql/types/ml/model_version_links_type.rb
+++ b/app/graphql/types/ml/model_version_links_type.rb
@@ -11,6 +11,9 @@ module Types
field :show_path, GraphQL::Types::String,
null: true, description: 'Path to the details page of the model version.', method: :path
+
+ field :package_path, GraphQL::Types::String,
+ null: true, description: 'Path to the package of the model version.', method: :package_path
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ml/models_order_by_enum.rb b/app/graphql/types/ml/models_order_by_enum.rb
new file mode 100644
index 00000000000..db96a2e2d7d
--- /dev/null
+++ b/app/graphql/types/ml/models_order_by_enum.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module Ml
+ class ModelsOrderByEnum < BaseEnum
+ graphql_name 'MlModelsOrderBy'
+ description 'Values for ordering machine learning models by a specific field'
+
+ value 'NAME', 'Ordered by name.', value: :name
+ value 'CREATED_AT', 'Ordered by creation time.', value: :created_at
+ value 'UPDATED_AT', 'Ordered by update time.', value: :updated_at
+ value 'ID', 'Ordered by id.', value: :id
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 590bc0ed282..0a725c2e0a7 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -111,6 +111,7 @@ module Types
mount_mutation Mutations::Projects::SyncFork, calls_gitaly: true, alpha: { milestone: '15.9' }
mount_mutation Mutations::Projects::Star, alpha: { milestone: '16.7' }
mount_mutation Mutations::BranchRules::Update, alpha: { milestone: '16.7' }
+ mount_mutation Mutations::BranchRules::Create, alpha: { milestone: '16.7' }
mount_mutation Mutations::Releases::Create
mount_mutation Mutations::Releases::Update
mount_mutation Mutations::Releases::Delete
@@ -202,6 +203,7 @@ module Types
mount_mutation Mutations::Users::SetNamespaceCommitEmail
mount_mutation Mutations::WorkItems::Subscribe, alpha: { milestone: '16.3' }
mount_mutation Mutations::Admin::AbuseReportLabels::Create, alpha: { milestone: '16.4' }
+ mount_mutation Mutations::Ml::Models::Create, alpha: { milestone: '16.8' }
end
end
diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb
index 7bf76ae7de5..621cb091019 100644
--- a/app/graphql/types/namespace/package_settings_type.rb
+++ b/app/graphql/types/namespace/package_settings_type.rb
@@ -35,6 +35,12 @@ module Types
field :pypi_package_requests_forwarding, GraphQL::Types::Boolean,
null: true,
description: 'Indicates whether PyPI package forwarding is allowed for this namespace.'
+ field :terraform_module_duplicate_exception_regex, Types::UntrustedRegexp,
+ null: true,
+ description: 'When terraform_module_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.'
+ field :terraform_module_duplicates_allowed, GraphQL::Types::Boolean,
+ null: false,
+ description: 'Indicates whether duplicate Terraform packages are allowed for this namespace.'
field :lock_maven_package_requests_forwarding, GraphQL::Types::Boolean,
null: false,
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
index 85bda507ff7..3420f16213f 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -4,7 +4,7 @@ module Types
class NamespaceType < BaseObject
graphql_name 'Namespace'
- authorize :read_namespace_via_membership
+ authorize :read_namespace
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the namespace.'
diff --git a/app/graphql/types/organizations/organization_type.rb b/app/graphql/types/organizations/organization_type.rb
index 379bf9956a3..d36c92541ef 100644
--- a/app/graphql/types/organizations/organization_type.rb
+++ b/app/graphql/types/organizations/organization_type.rb
@@ -43,6 +43,10 @@ module Types
null: false,
description: 'Path of the organization.',
alpha: { milestone: '16.4' }
+ field :projects, Types::ProjectType.connection_type, null: false,
+ description: 'Projects within this organization that the user has access to.',
+ alpha: { milestone: '16.8' },
+ resolver: ::Resolvers::Organizations::ProjectsResolver
field :web_url,
GraphQL::Types::String,
null: false,
diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb
index a76dc88adfc..65586b384be 100644
--- a/app/graphql/types/permission_types/issue.rb
+++ b/app/graphql/types/permission_types/issue.rb
@@ -8,7 +8,7 @@ module Types
abilities :read_issue, :admin_issue, :update_issue, :reopen_issue,
:read_design, :create_design, :destroy_design,
- :create_note, :update_design
+ :create_note, :update_design, :admin_issue_relation
end
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 8e84605cb05..7f49c717c78 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -663,6 +663,24 @@ module Types
null: true,
resolver: Resolvers::Analytics::CycleAnalytics::ValueStreamsResolver
+ field :ml_models, ::Types::Ml::ModelType.connection_type,
+ null: true,
+ alpha: { milestone: '16.8' },
+ description: 'Finds machine learning models',
+ resolver: Resolvers::Ml::FindModelsResolver
+
+ field :allows_multiple_merge_request_assignees,
+ GraphQL::Types::Boolean,
+ method: :allows_multiple_merge_request_assignees?,
+ description: 'Project allows assigning multiple users to a merge request.',
+ null: false
+
+ field :allows_multiple_merge_request_reviewers,
+ GraphQL::Types::Boolean,
+ method: :allows_multiple_merge_request_reviewers?,
+ description: 'Project allows assigning multiple reviewers to a merge request.',
+ null: false
+
def timelog_categories
object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories)
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 0e39ff2c030..47a049fe10c 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -86,7 +86,8 @@ module Types
field :jobs,
::Types::Ci::JobType.connection_type,
null: true,
- description: 'All jobs on this GitLab instance.',
+ description: 'All jobs on this GitLab instance.' \
+ ' Returns an empty result for users without administrator access.',
resolver: ::Resolvers::Ci::AllJobsResolver
field :merge_request, Types::MergeRequestType,
null: true,
@@ -122,6 +123,11 @@ module Types
resolver: Resolvers::Organizations::OrganizationResolver,
description: "Find an organization.",
alpha: { milestone: '16.4' }
+ field :organizations, Types::Organizations::OrganizationType.connection_type,
+ null: true,
+ resolver: Resolvers::Organizations::OrganizationsResolver,
+ description: "List organizations.",
+ alpha: { milestone: '16.8' }
field :package,
description: 'Find a package. This field can only be resolved for one query in any single request. Returns `null` if a package has no `default` status.',
resolver: Resolvers::PackageDetailsResolver
diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb
index 7f33f77ec14..5a90a65f50f 100644
--- a/app/graphql/types/subscription_type.rb
+++ b/app/graphql/types/subscription_type.rb
@@ -63,6 +63,10 @@ module Types
field :merge_request_approval_state_updated,
subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when approval state of a merge request is updated.'
+
+ field :merge_request_diff_generated,
+ subscription: Subscriptions::IssuableUpdated, null: true,
+ description: 'Triggered when a merge request diff is generated.'
end
end
diff --git a/app/graphql/types/work_items/widgets/notes_input_type.rb b/app/graphql/types/work_items/widgets/notes_input_type.rb
new file mode 100644
index 00000000000..fc7f4c84658
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/notes_input_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class NotesInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetNotesInput'
+
+ argument :discussion_locked, GraphQL::Types::Boolean,
+ required: true,
+ description: 'Discussion lock attribute for notes widget of the work item.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/notes_type.rb b/app/graphql/types/work_items/widgets/notes_type.rb
index 199001649bb..4f7f1c3b4cc 100644
--- a/app/graphql/types/work_items/widgets/notes_type.rb
+++ b/app/graphql/types/work_items/widgets/notes_type.rb
@@ -12,6 +12,10 @@ module Types
implements Types::WorkItems::WidgetInterface
+ field :discussion_locked, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Discussion lock attribute of the work item.'
+
# This field loads user comments, system notes and resource events as a discussion for an work item,
# raising the complexity considerably. In order to discourage fetching this field as part of fetching
# a list of issues we raise the complexity
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 07a5e711d1c..81aa4757862 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -44,7 +44,7 @@ module AppearancesHelper
end
def brand_image
- image_tag(brand_image_path, alt: brand_title, class: 'gl-visibility-hidden gl-h-9 js-portrait-logo-detection')
+ image_tag(brand_image_path, alt: brand_title, class: 'gl-visibility-hidden gl-h-10 js-portrait-logo-detection')
end
def brand_image_path
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 49230e558a8..892b046e410 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -295,10 +295,6 @@ module ApplicationHelper
end
end
- def truncate_first_line(message, length = 50)
- truncate(message.each_line.first.chomp, length: length) if message
- end
-
# While similarly named to Rails's `link_to_if`, this method behaves quite differently.
# If `condition` is truthy, a link will be returned with the result of the block
# as its body. If `condition` is falsy, only the result of the block will be returned.
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 655fdf8b8ec..1affdd8f433 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -353,6 +353,7 @@ module ApplicationSettingsHelper
:repository_checks_enabled,
:repository_storages_weighted,
:require_admin_approval_after_user_signup,
+ :require_admin_two_factor_authentication,
:require_two_factor_authentication,
:remember_me_enabled,
:restricted_visibility_levels,
@@ -449,6 +450,7 @@ module ApplicationSettingsHelper
:issues_create_limit,
:notes_create_limit,
:notes_create_limit_allowlist_raw,
+ :members_delete_limit,
:raw_blob_request_limit,
:project_import_limit,
:project_export_limit,
@@ -511,7 +513,8 @@ module ApplicationSettingsHelper
:namespace_aggregation_schedule_lease_duration_in_seconds,
:ci_max_total_yaml_size_bytes,
:project_jobs_api_rate_limit,
- :security_txt_content
+ :security_txt_content,
+ :allow_project_creation_for_guest_and_below
].tap do |settings|
next if Gitlab.com?
@@ -564,10 +567,6 @@ module ApplicationSettingsHelper
can?(current_user, :read_cluster, clusterable)
end
- def omnibus_protected_paths_throttle?
- Rack::Attack.throttles.key?('protected paths')
- end
-
def valid_runner_registrars
Gitlab::CurrentSettings.valid_runner_registrars
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index b21c8687d69..dff1123f10b 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -113,7 +113,7 @@ module AvatarsHelper
when Namespaces::UserNamespace
user_avatar_without_link(options.merge(user: resource.first_owner))
when Group
- group_icon(resource, options.merge(class: 'avatar'))
+ render Pajamas::AvatarComponent.new(resource, class: 'gl-avatar-circle gl-mr-3', size: 32)
end
end
diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb
index da8310995cc..a1094027291 100644
--- a/app/helpers/breadcrumbs_helper.rb
+++ b/app/helpers/breadcrumbs_helper.rb
@@ -22,7 +22,7 @@ module BreadcrumbsHelper
end
def breadcrumb_list_item(link)
- content_tag :li, link, class: 'gl-breadcrumb-item'
+ content_tag :li, link, class: 'gl-breadcrumb-item gl-display-inline-flex'
end
def add_to_breadcrumb_collapsed_links(link, location: :before)
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index e6212ee7d8d..851de133a38 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -8,7 +8,9 @@ module ButtonHelper
# :gfm - GitLab Flavored Markdown to copy, if different from `text` (optional)
# :target - Selector for target element to copy from (optional)
# :class - CSS classes to be applied to the button (optional)
- # :title - Button's title attribute (used for the tooltip) (optional)
+ # :title - Button's title attribute (used for the tooltip) (optional, default: Copy)
+ # :aria_label - Button's aria-label attribute (optional)
+ # :aria_keyshortcuts - Button's aria-keyshortcuts attribute (optional)
# :button_text - Button's displayed label (optional)
# :hide_tooltip - Whether the tooltip should be hidden (optional, default: false)
# :hide_button_icon - Whether the icon should be hidden (optional, default: false)
@@ -31,6 +33,8 @@ module ButtonHelper
def clipboard_button(data = {})
css_class = data.delete(:class)
title = data.delete(:title) || _('Copy')
+ aria_keyshortcuts = data.delete(:aria_keyshortcuts) || nil
+ aria_label = data.delete(:aria_label) || title
button_text = data[:button_text] || nil
hide_tooltip = data[:hide_tooltip] || false
hide_button_icon = data[:hide_button_icon] || false
@@ -54,7 +58,7 @@ module ButtonHelper
data[:clipboard_target] = target if target
unless hide_tooltip
- data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data)
+ data = { toggle: 'tooltip', placement: 'bottom', container: 'body', html: 'true' }.merge(data)
end
render ::Pajamas::ButtonComponent.new(
@@ -62,7 +66,7 @@ module ButtonHelper
variant: variant,
category: category,
size: size,
- button_options: { class: css_class, title: title, aria: { label: title, live: 'polite' }, data: data, itemprop: item_prop }) do
+ button_options: { class: css_class, title: title, aria: { keyshortcuts: aria_keyshortcuts, label: aria_label, live: 'polite' }, data: data, itemprop: item_prop }) do
button_text
end
end
diff --git a/app/helpers/ci/builds_helper.rb b/app/helpers/ci/builds_helper.rb
index 001b316fcf2..6fa3840fac0 100644
--- a/app/helpers/ci/builds_helper.rb
+++ b/app/helpers/ci/builds_helper.rb
@@ -2,13 +2,6 @@
module Ci
module BuildsHelper
- def sidebar_build_class(build, current_build)
- build_class = []
- build_class << 'active' if build.id === current_build.id
- build_class << 'retried' if build.retried?
- build_class.join(' ')
- end
-
def build_failed_issue_options
{
title: _("Job Failed #%{build_id}") % { build_id: @build.id },
diff --git a/app/helpers/ci/status_helper.rb b/app/helpers/ci/status_helper.rb
index 21d982d42bc..5ff8ce74866 100644
--- a/app/helpers/ci/status_helper.rb
+++ b/app/helpers/ci/status_helper.rb
@@ -9,11 +9,6 @@
#
module Ci
module StatusHelper
- def ci_status_for_statuseable(subject)
- status = subject.try(:status) || 'not found'
- status.humanize
- end
-
# rubocop:disable Metrics/CyclomaticComplexity
def ci_icon_for_status(status, size: 24)
icon_name =
@@ -56,10 +51,6 @@ module Ci
end
# rubocop:enable Metrics/CyclomaticComplexity
- def pipeline_status_cache_key(pipeline_status)
- "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}"
- end
-
def render_commit_status(commit, status, ref: nil, tooltip_placement: 'left')
project = commit.project
path = pipelines_project_commit_path(project, commit, ref: ref)
diff --git a/app/helpers/ci/variables_helper.rb b/app/helpers/ci/variables_helper.rb
index 0dbd1adeb71..86e2667c7bb 100644
--- a/app/helpers/ci/variables_helper.rb
+++ b/app/helpers/ci/variables_helper.rb
@@ -32,21 +32,6 @@ module Ci
end
end
- def ci_variable_masked?(variable, only_key_value)
- if variable && !only_key_value
- variable.masked
- else
- false
- end
- end
-
- def ci_variable_type_options
- [
- %w[Variable env_var],
- %w[File file]
- ]
- end
-
def ci_variable_maskable_raw_regex
Ci::Maskable::MASK_AND_RAW_REGEX.inspect.sub('\\A', '^').sub('\\z', '$')[1...-1]
end
diff --git a/app/helpers/count_helper.rb b/app/helpers/count_helper.rb
index 62bb2e4da23..aed730850fd 100644
--- a/app/helpers/count_helper.rb
+++ b/app/helpers/count_helper.rb
@@ -11,7 +11,7 @@ module CountHelper
# This will approximate the fork count by checking all counting all fork network
# memberships, and deducting 1 for each root of the fork network.
- # This might be inacurate as the root of the fork network might have been deleted.
+ # This might be inaccurate as the root of the fork network might have been deleted.
#
# This makes querying this information a lot more efficient and it should be
# accurate enough for the instance wide statistics
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 9031d0556da..6069e4e64a1 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -30,7 +30,7 @@ module DiffHelper
def diff_options
options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded?, use_extra_viewer_as_main: true }
- if action_name == 'diff_for_path'
+ if action_name == 'diff_for_path' || action_name == 'diff_by_file_hash'
options[:expanded] = true
options[:paths] = params.values_at(:old_path, :new_path)
options[:use_extra_viewer_as_main] = false
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index fa47a12a72c..adb2b03cd0a 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -1,14 +1,6 @@
# frozen_string_literal: true
module EnvironmentHelper
- # rubocop: disable CodeReuse/ActiveRecord
- def environment_for_build(project, build)
- return unless build.environment
-
- project.environments.find_by(name: build.expanded_environment_name)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def deployment_path(deployment)
[deployment.project, deployment.deployable]
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 6b1e3075968..ac34f429508 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -9,13 +9,14 @@ module EnvironmentsHelper
}
end
- def environments_folder_list_view_data
+ def environments_folder_list_view_data(project, folder)
{
- "endpoint" => folder_project_environments_path(@project, @folder, format: :json),
- "folder_name" => @folder,
- "project_path" => project_path(@project),
+ "endpoint" => folder_project_environments_path(project, folder, format: :json),
+ "folder_name" => folder,
+ "project_path" => project.full_path,
"help_page_path" => help_page_path("ci/environments/index"),
- "can_read_environment" => can?(current_user, :read_environment, @project).to_s
+ "can_read_environment" => can?(current_user, :read_environment, @project).to_s,
+ "kas_tunnel_url" => ::Gitlab::Kas.tunnel_url
}
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index d5f38debae4..bbcf408650d 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -24,7 +24,7 @@ module FormHelper
# user"), use the message as-is
message = error.message if custom_message.include?(attribute)
- message = html_escape_once(message).html_safe
+ message = ERB::Util.html_escape_once(message).html_safe
message = tag.span(message, class: 'str-truncated-100') if truncate.include?(attribute)
message = append_help_page_link(message, error.options) if error.options[:help_page_url].present?
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index 60011e31d43..07672343384 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -20,7 +20,7 @@ module Groups::GroupMembersHelper
end
def group_member_header_subtext(group)
- html_escape(_("You're viewing members of %{strong_start}%{group_name}%{strong_end}.").html_safe) % {
+ ERB::Util.html_escape(_("You're viewing members of %{strong_start}%{group_name}%{strong_end}.").html_safe) % {
group_name: group.name,
strong_start: '<strong>'.html_safe,
strong_end: '</strong>'.html_safe
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 25a2cc8a5ae..96ae7be5fdc 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -37,7 +37,7 @@ module GroupsHelper
group.try(:avatar_url) || ActionController::Base.helpers.image_path('no_group_avatar.png')
end
- def group_title(group, name = nil, url = nil)
+ def group_title(group)
@has_group_title = true
full_title = []
@@ -56,11 +56,6 @@ module GroupsHelper
full_title << breadcrumb_list_item(group_title_link(group))
push_to_schema_breadcrumb(simple_sanitize(group.name), group_path(group))
- if name
- full_title << ' &middot; '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path breadcrumb-item-text js-breadcrumb-item-text')
- push_to_schema_breadcrumb(simple_sanitize(name), url)
- end
-
full_title.join.html_safe
end
@@ -160,6 +155,7 @@ module GroupsHelper
new_project_illustration: image_path('illustrations/project-create-new-sm.svg'),
empty_projects_illustration: image_path('illustrations/empty-state/empty-projects-md.svg'),
empty_subgroup_illustration: image_path('illustrations/empty-state/empty-subgroup-md.svg'),
+ empty_search_illustration: image_path('illustrations/empty-state/empty-search-md.svg'),
render_empty_state: 'true',
can_create_subgroups: can?(current_user, :create_subgroup, group).to_s,
can_create_projects: can?(current_user, :create_projects, group).to_s
@@ -241,8 +237,8 @@ module GroupsHelper
private
- def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false)
- link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do
+ def group_title_link(group, hidable: false, show_avatar: false)
+ link_to(group_path(group), class: "group-path js-breadcrumb-item-text #{'hidable' if hidable}") do
icon = render Pajamas::AvatarComponent.new(group, alt: group.name, class: "avatar-tile", size: 16) if group.try(:avatar_url) || show_avatar
[icon, simple_sanitize(group.name)].join.html_safe
end
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 2ec11b8a9ed..312807c004a 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -88,7 +88,6 @@ module IdeHelper
'render-whitespace-in-code': current_user.render_whitespace_in_code.to_s,
'default-branch' => project && project.default_branch,
'project' => convert_to_project_entity_json(project),
- 'enable-environments-guidance' => enable_environments_guidance?(project).to_s,
'preview-markdown-path' => project && preview_markdown_path(project),
'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'),
'web-terminal-help-path' => help_page_path('user/project/web_ide/index', anchor: 'interactive-web-terminals-for-the-web-ide'),
@@ -103,14 +102,6 @@ module IdeHelper
API::Entities::Project.represent(project, current_user: current_user).to_json
end
- def enable_environments_guidance?(project)
- experiment(:in_product_guidance_environments_webide, project: project) do |e|
- e.candidate { !has_dismissed_ide_environments_callout? }
-
- e.run
- end
- end
-
def has_dismissed_ide_environments_callout?
current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance')
end
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index 81b881592d0..1eac140c216 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -56,7 +56,7 @@ module ImportHelper
link_url = 'https://github.com/settings/tokens'
link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: link_url }
- html_escape(_('Create and provide your GitHub %{link_start}Personal Access Token%{link_end}. You will need to select the %{code_open}repo%{code_close} scope, so we can display a list of your public and private repositories which are available to import.')) % { link_start: link_start, link_end: '</a>'.html_safe, code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+ ERB::Util.html_escape(_('Create and provide your GitHub %{link_start}Personal Access Token%{link_end}. You will need to select the %{code_open}repo%{code_close} scope, so we can display a list of your public and private repositories which are available to import.')) % { link_start: link_start, link_end: '</a>'.html_safe, code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
end
def import_configure_github_admin_message
diff --git a/app/helpers/listbox_helper.rb b/app/helpers/listbox_helper.rb
index 6a7e09f75e4..ba292f560c4 100644
--- a/app/helpers/listbox_helper.rb
+++ b/app/helpers/listbox_helper.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module ListboxHelper
- DROPDOWN_CONTAINER_CLASSES = %w[dropdown b-dropdown gl-dropdown btn-group js-redirect-listbox].freeze
+ DROPDOWN_CONTAINER_CLASSES = %w[dropdown b-dropdown gl-dropdown js-redirect-listbox].freeze
DROPDOWN_BUTTON_CLASSES = %w[btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle].freeze
DROPDOWN_INNER_CLASS = 'gl-dropdown-button-text'
DROPDOWN_ICON_CLASS = 'gl-button-icon dropdown-chevron gl-icon'
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 2f042ea6417..75a41054ace 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -204,7 +204,8 @@ module MergeRequestsHelper
is_forked: project.forked?.to_s,
new_comment_template_path: profile_comment_templates_path,
iid: merge_request.iid,
- per_page: DIFF_BATCH_ENDPOINT_PER_PAGE
+ per_page: DIFF_BATCH_ENDPOINT_PER_PAGE,
+ pinned_file_url: @pinned_file_url
}
end
@@ -253,13 +254,13 @@ module MergeRequestsHelper
end
branch = if merge_request.for_fork?
- html_escape(_('%{fork_icon} %{source_project_path}:%{source_branch}')) % { fork_icon: fork_icon.html_safe, source_project_path: merge_request.source_project_path, source_branch: merge_request.source_branch }
+ ERB::Util.html_escape(_('%{fork_icon} %{source_project_path}:%{source_branch}')) % { fork_icon: fork_icon.html_safe, source_project_path: merge_request.source_project_path, source_branch: merge_request.source_branch }
else
merge_request.source_branch
end
branch_title = if merge_request.for_fork?
- html_escape(_('%{source_project_path}:%{source_branch}')) % { source_project_path: merge_request.source_project_path, source_branch: merge_request.source_branch }
+ ERB::Util.html_escape(_('%{source_project_path}:%{source_branch}')) % { source_project_path: merge_request.source_project_path, source_branch: merge_request.source_branch }
else
merge_request.source_branch
end
@@ -275,7 +276,10 @@ module MergeRequestsHelper
def merge_request_header(project, merge_request)
link_to_author = link_to_member(project, merge_request.author, size: 24, extra_class: 'gl-font-weight-bold gl-mr-2', avatar: false)
- copy_button = clipboard_button(text: merge_request.source_branch, title: _('Copy branch name'), class: 'gl-display-none! gl-md-display-inline-block! js-source-branch-copy')
+ copy_action_description = _('Copy branch name')
+ copy_action_shortcut = 'b'
+ copy_button_title = "#{copy_action_description} <kbd class='flat ml-1'>#{copy_action_shortcut}</kbd>"
+ copy_button = clipboard_button(text: merge_request.source_branch, title: copy_button_title, aria_keyshortcuts: copy_action_shortcut, aria_label: copy_action_description, class: 'gl-display-none! gl-md-display-inline-block! js-source-branch-copy')
target_branch = link_to merge_request.target_branch, project_tree_path(merge_request.target_project, merge_request.target_branch), title: merge_request.target_branch, class: 'ref-container gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2'
@@ -289,6 +293,7 @@ module MergeRequestsHelper
sourceProjectPath: @merge_request.source_project_path,
title: markdown_field(@merge_request, :title),
isFluidLayout: fluid_layout.to_s,
+ blocksMerge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s,
tabs: [
['show', _('Overview'), project_merge_request_path(@project, @merge_request), @merge_request.related_notes.user.count],
['commits', _('Commits'), commits_project_merge_request_path(@project, @merge_request), @commits_count],
diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb
index 158aa5e0944..1df79fb2083 100644
--- a/app/helpers/mirror_helper.rb
+++ b/app/helpers/mirror_helper.rb
@@ -12,7 +12,7 @@ module MirrorHelper
docs_link_url = help_page_path('topics/git/lfs/index')
docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
- html_escape(_('Git LFS objects will be synced if LFS is %{docs_link_start}enabled for the project%{docs_link_end}. Push mirrors will %{strong_open}not%{strong_close} sync LFS objects over SSH.')) %
+ ERB::Util.html_escape(_('Git LFS objects will be synced if LFS is %{docs_link_start}enabled for the project%{docs_link_end}. Push mirrors will %{strong_open}not%{strong_close} sync LFS objects over SSH.')) %
{ docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
end
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index cb9a270253f..424a3f5f8c5 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -53,13 +53,6 @@ module NavHelper
%w[system_info background_migrations background_jobs health_check]
end
- def show_super_sidebar?(_user = current_user)
- # The new navigation is now enabled for everyone.
- # We are working on cleaning up the use of this helper and other related code.
- # See https://gitlab.com/groups/gitlab-org/-/epics/11875
- true
- end
-
private
def get_header_links
diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb
index d0dd9dc5aea..445dd3a1f6f 100644
--- a/app/helpers/organizations/organization_helper.rb
+++ b/app/helpers/organizations/organization_helper.rb
@@ -4,7 +4,8 @@ module Organizations
module OrganizationHelper
def organization_show_app_data(organization)
{
- organization: organization.slice(:id, :name),
+ organization: organization.slice(:id, :name, :description_html)
+ .merge({ avatar_url: organization.avatar_url(size: 128) }),
groups_and_projects_organization_path: groups_and_projects_organization_path(organization),
# TODO: Update counts to use real data
# https://gitlab.com/gitlab-org/gitlab/-/issues/424531
@@ -17,18 +18,14 @@ module Organizations
end
def organization_new_app_data
- {
- organizations_path: organizations_path,
- root_url: root_url
- }.to_json
+ shared_new_settings_general_app_data.to_json
end
def organization_settings_general_app_data(organization)
{
- organization: organization.slice(:id, :name, :path),
- organizations_path: organizations_path,
- root_url: root_url
- }.to_json
+ organization: organization.slice(:id, :name, :path, :description)
+ .merge({ avatar: organization.avatar_url(size: 192) })
+ }.merge(shared_new_settings_general_app_data).to_json
end
def organization_groups_and_projects_app_data
@@ -66,6 +63,14 @@ module Organizations
}
end
+ def shared_new_settings_general_app_data
+ {
+ preview_markdown_path: preview_markdown_organizations_path,
+ organizations_path: organizations_path,
+ root_url: root_url
+ }
+ end
+
# See UsersHelper#admin_users_paths for inspiration to this method
def organizations_users_paths
{
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 204e3b149b9..da8ef2277f1 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -75,10 +75,6 @@ module PreferencesHelper
user_application_theme == 'gl-dark'
end
- def user_application_theme_css_filename
- @user_application_theme_css_filename ||= Gitlab::Themes.for_user(current_user).css_filename
- end
-
def user_theme_primary_color
Gitlab::Themes.for_user(current_user).primary_color
end
diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb
index 634f6e8ba59..20af643d159 100644
--- a/app/helpers/projects/project_members_helper.rb
+++ b/app/helpers/projects/project_members_helper.rb
@@ -19,7 +19,7 @@ module Projects::ProjectMembersHelper
if can?(current_user, :admin_project_member, project)
share_project_description(project)
else
- html_escape(_("Members can be added by project " \
+ ERB::Util.html_escape(_("Members can be added by project " \
"%{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % {
i_open: '<i>'.html_safe, i_close: '</i>'.html_safe
}
@@ -41,7 +41,7 @@ module Projects::ProjectMembersHelper
_("You can invite a new member to %{project_name}.")
end
- html_escape(description) % { project_name: tag.strong(project.name) }
+ ERB::Util.html_escape(description) % { project_name: tag.strong(project.name) }
end
def project_members_serialized(project, members)
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index c2014508f4f..7ef6e0f5d02 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -191,7 +191,7 @@ module ProjectsHelper
end
def autodeploy_flash_notice(branch_name)
- html_escape(_("Branch %{branch_name} was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}")) %
+ ERB::Util.html_escape(_("Branch %{branch_name} was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}")) %
{ branch_name: tag.strong(truncate(sanitize(branch_name))), link_to_autodeploy_doc: link_to_autodeploy_doc }
end
@@ -252,7 +252,7 @@ module ProjectsHelper
_('Your account is authenticated with SSO or SAML. To %{push_pull_link_start}push and pull%{link_end} over %{protocol} with Git using this account, you must %{set_up_pat_link_start}set up a Personal Access Token%{link_end} to use instead of a password. For more information, see %{clone_with_https_link_start}Clone with HTTPS%{link_end}.')
end
- html_escape(message) % {
+ ERB::Util.html_escape(message) % {
push_pull_link_start: push_pull_link_start,
protocol: gitlab_config.protocol.upcase,
clone_with_https_link_start: clone_with_https_link_start,
@@ -768,7 +768,7 @@ module ProjectsHelper
message = _("You're about to reduce the visibility of the project %{strong_start}%{project_name}%{strong_end} in %{strong_start}%{group_name}%{strong_end}.")
end
- html_escape(message) % { strong_start: strong_start, strong_end: strong_end, project_name: project.name, group_name: project.group ? project.group.name : nil }
+ ERB::Util.html_escape(message) % { strong_start: strong_start, strong_end: strong_end, project_name: project.name, group_name: project.group ? project.group.name : nil }
end
def visibility_confirm_modal_data(project, target_form_id = nil)
@@ -789,15 +789,15 @@ module ProjectsHelper
push_to_schema_breadcrumb(project_name, project_path(project))
- link_to project_path(project) do
+ link_to project_path(project), class: 'gl-display-inline-flex!' do
icon = render Pajamas::AvatarComponent.new(project, alt: project.name, size: 16, class: 'avatar-tile') if project.avatar_url && !Rails.env.test?
- [icon, content_tag("span", project_name, class: "breadcrumb-item-text js-breadcrumb-item-text")].join.html_safe
+ [icon, content_tag("span", project_name, class: "js-breadcrumb-item-text")].join.html_safe
end
end
def build_namespace_breadcrumb_link(project)
if project.group
- group_title(project.group, nil, nil)
+ group_title(project.group)
else
owner = project.namespace.owner
name = simple_sanitize(owner.name)
diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb
index 363c38ffe59..12f7bd41968 100644
--- a/app/helpers/registrations_helper.rb
+++ b/app/helpers/registrations_helper.rb
@@ -11,10 +11,6 @@ module RegistrationsHelper
}
end
- def signup_box_template
- 'devise/shared/signup_box'
- end
-
# overridden in EE
def oauth_tracking_label; end
diff --git a/app/helpers/reminder_emails_helper.rb b/app/helpers/reminder_emails_helper.rb
index e46d9273100..f53ebd51380 100644
--- a/app/helpers/reminder_emails_helper.rb
+++ b/app/helpers/reminder_emails_helper.rb
@@ -41,7 +41,7 @@ module ReminderEmailsHelper
body = invitation_reminder_body_text(reminder_index)
- (format == :html ? html_escape(body) : body) % options
+ (format == :html ? ERB::Util.html_escape(body) : body) % options
end
def invitation_reminder_accept_link(token, format: nil)
diff --git a/app/helpers/safe_format_helper.rb b/app/helpers/safe_format_helper.rb
index 9f8c5082c26..281bd783c93 100644
--- a/app/helpers/safe_format_helper.rb
+++ b/app/helpers/safe_format_helper.rb
@@ -3,8 +3,8 @@
module SafeFormatHelper
# Returns a HTML-safe String.
#
- # @param [String] format is escaped via `html_escape_once`
- # @param [Array<Hash>] args are escaped via `html_escape` if they are not marked as HTML-safe
+ # @param [String] format is escaped via `ERB::Util.html_escape_once`
+ # @param [Array<Hash>] args are escaped via `ERB::Util.html_escape` if they are not marked as HTML-safe
#
# @example
# safe_format('See %{user_input}', user_input: '<b>bold</b>')
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index f002a0c454d..2ee20887129 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -136,15 +136,15 @@ module SearchHelper
# - group
# - group: nil, project: nil
if project
- html_escape(_("We couldn't find any %{scope} matching %{term} in project %{project}")) % options.merge(
+ ERB::Util.html_escape(_("We couldn't find any %{scope} matching %{term} in project %{project}")) % options.merge(
project: link_to(project.full_name, project_path(project), target: '_blank', rel: 'noopener noreferrer').html_safe
)
elsif group
- html_escape(_("We couldn't find any %{scope} matching %{term} in group %{group}")) % options.merge(
+ ERB::Util.html_escape(_("We couldn't find any %{scope} matching %{term} in group %{group}")) % options.merge(
group: link_to(group.full_name, group_path(group), target: '_blank', rel: 'noopener noreferrer').html_safe
)
else
- html_escape(_("We couldn't find any %{scope} matching %{term}")) % options
+ ERB::Util.html_escape(_("We couldn't find any %{scope} matching %{term}")) % options
end
end
diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb
index cf5cc92587f..7dccaa6cd73 100644
--- a/app/helpers/sessions_helper.rb
+++ b/app/helpers/sessions_helper.rb
@@ -3,43 +3,10 @@
module SessionsHelper
include Gitlab::Utils::StrongMemoize
- def recently_confirmed_com?
- strong_memoize(:recently_confirmed_com) do
- ::Gitlab.com? &&
- !!flash[:notice]&.include?(t(:confirmed, scope: [:devise, :confirmations]))
- end
- end
-
def unconfirmed_email?
flash[:alert] == t(:unconfirmed, scope: [:devise, :failure])
end
- # By default, all sessions are given the same expiration time configured in
- # the session store (e.g. 1 week). However, unauthenticated users can
- # generate a lot of sessions, primarily for CSRF verification. It makes
- # sense to reduce the TTL for unauthenticated to something much lower than
- # the default (e.g. 1 hour) to limit Redis memory. In addition, Rails
- # creates a new session after login, so the short TTL doesn't even need to
- # be extended.
- def limit_session_time
- set_session_time(Settings.gitlab['unauthenticated_session_expire_delay'])
- end
-
- def ensure_authenticated_session_time
- set_session_time(nil)
- end
-
- def set_session_time(expiry_s)
- # Rack sets this header, but not all tests may have it: https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L251-L259
- return unless request.env['rack.session.options']
-
- # This works because Rack uses these options every time a request is handled, and redis-store
- # uses the Rack setting first:
- # 1. https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L342
- # 2. https://github.com/redis-store/redis-store/blob/3acfa95f4eb6260c714fdb00a3d84be8eedc13b2/lib/redis/store/ttl.rb#L32
- request.env['rack.session.options'][:expire_after] = expiry_s
- end
-
def obfuscated_email(email)
# Moved to Gitlab::Utils::Email in 15.9
Gitlab::Utils::Email.obfuscated_email(email)
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
index 9933fa8e4d9..92a4f32dfda 100644
--- a/app/helpers/sidebars_helper.rb
+++ b/app/helpers/sidebars_helper.rb
@@ -68,6 +68,16 @@ module SidebarsHelper
name: user.name,
username: user.username,
admin_url: admin_root_url,
+ admin_mode: {
+ admin_mode_feature_enabled: Gitlab::CurrentSettings.admin_mode,
+ admin_mode_active: current_user_mode.admin_mode?,
+ enter_admin_mode_url: new_admin_session_path,
+ leave_admin_mode_url: destroy_admin_session_path,
+ # Usually, using current_user.admin? is discouraged because it does not
+ # check for admin mode, but since here we want to check admin? and admin mode
+ # separately, we'll have to ignore the cop rule.
+ user_is_admin: user.admin? # rubocop: disable Cop/UserAdmin
+ },
avatar_url: user.avatar_url,
has_link_to_profile: current_user_menu?(:profile),
link_to_profile: user_path(user),
@@ -353,38 +363,12 @@ module SidebarsHelper
({ title: s_('Navigation|Preferences'), link: profile_preferences_path, icon: 'preferences' } if current_user)
]
- # Usually, using current_user.admin? is discouraged because it does not
- # check for admin mode, but since here we want to check admin? and admin mode
- # separately, we'll have to ignore the cop rule.
- # rubocop: disable Cop/UserAdmin
if current_user&.can_admin_all_resources?
links.append(
{ title: s_('Navigation|Admin Area'), link: admin_root_path, icon: 'admin' }
)
end
- if Gitlab::CurrentSettings.admin_mode
- if header_link?(:admin_mode)
- links.append(
- {
- title: s_('Navigation|Leave admin mode'),
- link: destroy_admin_session_path,
- icon: 'lock-open',
- data_method: 'post'
- }
- )
- elsif current_user&.admin?
- links.append(
- {
- title: s_('Navigation|Enter admin mode'),
- link: new_admin_session_path,
- icon: 'lock'
- }
- )
- end
- end
- # rubocop: enable Cop/UserAdmin
-
links.compact
end
diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb
index 29bd5a84651..3ea043557b8 100644
--- a/app/helpers/time_zone_helper.rb
+++ b/app/helpers/time_zone_helper.rb
@@ -33,6 +33,19 @@ module TimeZoneHelper
end
end
+ # The identifiers in `timezone_data` are not unique. Some cities (e.g. London and Edinburgh) have
+ # the same `identifier` value (e.g. "Europe/London").
+ # This method merges such entries into one, joining the city names.
+ # This unique list is better suited for selectboxes etc.
+ def timezone_data_with_unique_identifiers(format: :short)
+ timezone_data(format: format)
+ .group_by { |entry| entry[:identifier] }
+ .map do |_identifier, entries|
+ names = entries.map { |entry| entry[:name] }.sort.join(', ') # rubocop:disable Rails/Pluck -- Not a ActiveRecord object
+ entries.first.merge({ name: names })
+ end
+ end
+
def local_timezone_instance(timezone)
return Time.zone if timezone.blank?
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 84a809bc510..c0658859cc1 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -326,7 +326,7 @@ module UsersHelper
job_title = '<span itemprop="jobTitle">'.html_safe + job_title + "</span>".html_safe
organization = '<span itemprop="worksFor">'.html_safe + organization + "</span>".html_safe
- html_escape(s_('Profile|%{job_title} at %{organization}')) % { job_title: job_title, organization: organization }
+ ERB::Util.html_escape(s_('Profile|%{job_title} at %{organization}')) % { job_title: job_title, organization: organization }
else
s_('Profile|%{job_title} at %{organization}') % { job_title: job_title, organization: organization }
end
diff --git a/app/helpers/vite_helper.rb b/app/helpers/vite_helper.rb
index adb9ffa39e0..1d2ff400995 100644
--- a/app/helpers/vite_helper.rb
+++ b/app/helpers/vite_helper.rb
@@ -4,11 +4,7 @@ module ViteHelper
def vite_enabled?
# vite is not production ready yet
return false if Rails.env.production?
- # Enable vite if explicitly turned on in the GDK
- return Gitlab::Utils.to_boolean(ViteRuby.env['VITE_ENABLED'], default: false) if ViteRuby.env.key?('VITE_ENABLED')
- # Enable vite the legacy way (in case GDK hasn't been updated)
- # This is going to be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/431041
- Rails.env.development? ? Feature.enabled?(:vite) : false
+ Gitlab::Utils.to_boolean(ViteRuby.env['VITE_ENABLED'], default: false)
end
end
diff --git a/app/helpers/wiki_page_version_helper.rb b/app/helpers/wiki_page_version_helper.rb
index ae20717ad99..1771681c9b1 100644
--- a/app/helpers/wiki_page_version_helper.rb
+++ b/app/helpers/wiki_page_version_helper.rb
@@ -15,6 +15,6 @@ module WikiPageVersionHelper
name = "<strong>".html_safe + wiki_page_version.author_name + "</strong>".html_safe
link_start = "<a href='".html_safe + wiki_page_version_author_url(wiki_page_version) + "'>".html_safe
- html_escape(_("Last edited by %{link_start}%{avatar} %{name}%{link_end}")) % { avatar: avatar, name: name, link_start: link_start, link_end: '</a>'.html_safe }
+ ERB::Util.html_escape(_("Last edited by %{link_start}%{avatar} %{name}%{link_end}")) % { avatar: avatar, name: name, link_start: link_start, link_end: '</a>'.html_safe }
end
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index f859294960c..bec37610594 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -37,8 +37,10 @@ module Emails
def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- @previous_assignees = []
- @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
+ previous_assignees = []
+ previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
+ @added_assignees = @issue.assignees.map(&:name) - previous_assignees.map(&:name)
+ @removed_assignees = previous_assignees.map(&:name) - @issue.assignees.map(&:name)
mail_answer_thread(
@issue,
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index c702b107b7e..07d033ec53c 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -42,8 +42,10 @@ module Emails
def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_ids, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- @previous_assignees = []
- @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
+ previous_assignees = []
+ previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
+ @added_assignees = @merge_request.assignees.map(&:name) - previous_assignees.map(&:name)
+ @removed_assignees = previous_assignees.map(&:name) - @merge_request.assignees.map(&:name)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 19dc0e40564..e19a75a68e8 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -205,11 +205,12 @@ class AbuseReport < ApplicationRecord
return if links_to_spam.blank?
links_to_spam.each do |link|
- Gitlab::UrlBlocker.validate!(
+ Gitlab::HTTP_V2::UrlBlocker.validate!(
link,
schemes: %w[http https],
allow_localhost: true,
- dns_rebind_protection: true
+ dns_rebind_protection: true,
+ deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?
)
next unless link.length > MAX_CHAR_LIMIT_URL
diff --git a/app/models/ai/service_access_token.rb b/app/models/ai/service_access_token.rb
index 46dfbe9078c..d2d64079c74 100644
--- a/app/models/ai/service_access_token.rb
+++ b/app/models/ai/service_access_token.rb
@@ -2,11 +2,8 @@
module Ai
class ServiceAccessToken < ApplicationRecord
- include IgnorableColumns
self.table_name = 'service_access_tokens'
- ignore_column :category, remove_with: '16.8', remove_after: '2024-01-22'
-
scope :expired, -> { where('expires_at < :now', now: Time.current) }
scope :active, -> { where('expires_at > :now', now: Time.current) }
diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb
index 0f8e184933e..5ac5437a442 100644
--- a/app/models/analytics/cycle_analytics/aggregation.rb
+++ b/app/models/analytics/cycle_analytics/aggregation.rb
@@ -59,19 +59,26 @@ class Analytics::CycleAnalytics::Aggregation < ApplicationRecord
estimation < 1 ? nil : estimation.from_now
end
- def self.safe_create_for_namespace(group_or_project_namespace)
+ def self.safe_create_for_namespace(target_namespace)
# Namespaces::ProjectNamespace has no root_ancestor
# Related: https://gitlab.com/gitlab-org/gitlab/-/issues/386124
- group = group_or_project_namespace.is_a?(Group) ? group_or_project_namespace : group_or_project_namespace.parent
- top_level_group = group.root_ancestor
- aggregation = find_by(group_id: top_level_group.id)
+ namespace = if target_namespace.is_a?(Group) || target_namespace.is_a?(Namespaces::UserNamespace)
+ target_namespace
+ else
+ target_namespace.parent
+ end
+ # personal namespace projects and associated ProjectNamespace respond to `namespace`
+ # and this is close enough to "root ancestor"
+ top_level_namespace =
+ target_namespace.respond_to?(:root_ancestor) ? namespace.root_ancestor : namespace.namespace
+ aggregation = find_by(group_id: top_level_namespace.id)
return aggregation if aggregation&.enabled?
# At this point we're sure that the group is licensed, we can always enable the aggregation.
# This re-enables the aggregation in case the group downgraded and later upgraded the license.
- upsert({ group_id: top_level_group.id, enabled: true })
+ upsert({ group_id: top_level_namespace.id, enabled: true })
- find(top_level_group.id)
+ find(top_level_namespace.id)
end
private
diff --git a/app/models/analytics/cycle_analytics/stage.rb b/app/models/analytics/cycle_analytics/stage.rb
index 6f152e7749e..4686dc3aedd 100644
--- a/app/models/analytics/cycle_analytics/stage.rb
+++ b/app/models/analytics/cycle_analytics/stage.rb
@@ -7,7 +7,6 @@ module Analytics
self.table_name = :analytics_cycle_analytics_group_stages
- include DatabaseEventTracking
include Analytics::CycleAnalytics::Stageable
include Analytics::CycleAnalytics::Parentable
@@ -38,22 +37,6 @@ module Analytics
.select("DISTINCT ON(stage_event_hash_id) #{quoted_table_name}.*")
end
- SNOWPLOW_ATTRIBUTES = %i[
- id
- created_at
- updated_at
- relative_position
- start_event_identifier
- end_event_identifier
- group_id
- start_event_label_id
- end_event_label_id
- hidden
- custom
- name
- group_value_stream_id
- ].freeze
-
private
def max_stages_count
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index cb533a5e99d..35d4722b711 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -99,7 +99,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :default_branch_protection_defaults, json_schema: { filename: 'default_branch_protection_defaults' }
validates :default_branch_protection_defaults, bytesize: { maximum: -> { DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE } }
- validates :failed_login_attempts_unlock_period_in_minutes,
+ validates :external_pipeline_validation_service_timeout,
+ :failed_login_attempts_unlock_period_in_minutes,
+ :max_login_attempts,
allow_nil: true,
numericality: { only_integer: true, greater_than: 0 }
@@ -118,10 +120,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
allow_nil: false,
qualified_domain_array: true
- validates :session_expire_delay,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
validates :minimum_password_length,
presence: true,
numericality: {
@@ -222,38 +220,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
hostname: true,
length: { maximum: 255 }
- validates :max_attachment_size,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :max_artifacts_size,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :max_export_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :max_import_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :max_import_remote_file_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :bulk_import_max_download_file_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :max_decompressed_archive_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :max_login_attempts,
- allow_nil: true,
- numericality: { only_integer: true, greater_than: 0 }
-
validates :max_pages_size,
presence: true,
numericality: {
@@ -261,31 +227,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte
}
- validates :max_pages_custom_domains_per_project,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :jobs_per_stage_page_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :max_terraform_state_size_bytes,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
validates :default_artifacts_expire_in, presence: true, duration: true
validates :container_expiration_policies_enable_historic_entries,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
- validates :container_registry_token_expire_delay,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :decompress_archive_file_timeout,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
validate :check_repository_storages_weighted
validates :auto_devops_domain,
@@ -300,14 +246,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
presence: { message: 'Domain denylist cannot be empty if denylist is enabled.' },
if: :domain_denylist_enabled?
- validates :housekeeping_optimize_repository_period,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :terminal_max_session_time,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
validates :polling_interval_multiplier,
presence: true,
numericality: { greater_than_or_equal_to: 0 }
@@ -413,59 +351,26 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
allow_nil: false
- validates :push_event_hooks_limit,
- numericality: { greater_than_or_equal_to: 0 }
-
validates :push_event_activities_limit,
+ :push_event_hooks_limit,
numericality: { greater_than_or_equal_to: 0 }
- validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 }
validates :wiki_page_max_content_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 1.kilobytes }
validates :wiki_asciidoc_allow_uri_includes, inclusion: { in: [true, false], message: N_('must be a boolean value') }
- validates :max_yaml_size_bytes, numericality: { only_integer: true, greater_than: 0 }, presence: true
- validates :max_yaml_depth, numericality: { only_integer: true, greater_than: 0 }, presence: true
-
- validates :ci_max_total_yaml_size_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true
-
- validates :ci_max_includes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true
validates :email_restrictions, untrusted_regexp: true
validates :hashed_storage_enabled, inclusion: { in: [true], message: N_("Hashed storage can't be disabled anymore for new projects") }
- validates :container_registry_delete_tags_service_timeout,
- :container_registry_cleanup_tags_service_max_list_size,
- :container_registry_data_repair_detail_worker_max_concurrency,
- :container_registry_expiration_policies_worker_capacity,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
validates :container_registry_expiration_policies_caching,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
- validates :container_registry_import_max_tags_count,
- :container_registry_import_max_retries,
- :container_registry_import_start_max_retries,
- :container_registry_import_max_step_duration,
- :container_registry_pre_import_timeout,
- :container_registry_import_timeout,
- allow_nil: false,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
validates :container_registry_pre_import_tags_rate,
allow_nil: false,
numericality: { greater_than_or_equal_to: 0 }
validates :container_registry_import_target_plan, presence: true
validates :container_registry_import_created_before, presence: true
- validates :dependency_proxy_ttl_group_policy_worker_capacity,
- allow_nil: false,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :packages_cleanup_package_file_worker_capacity,
- :package_registry_cleanup_policies_worker_capacity,
- allow_nil: false,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
validates :invisible_captcha_enabled,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
@@ -584,15 +489,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
length: { maximum: 255 },
allow_blank: true
- validates :issues_create_limit,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :raw_blob_request_limit,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :pipeline_limit_per_project_user_sha,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
validates :ci_jwt_signing_key,
rsa_key: true, allow_nil: true
@@ -619,41 +515,90 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :slack_app_verification_token
end
- with_options(presence: true, numericality: { only_integer: true, greater_than: 0 }) do
- validates :throttle_unauthenticated_api_requests_per_period
- validates :throttle_unauthenticated_api_period_in_seconds
- validates :throttle_unauthenticated_requests_per_period
- validates :throttle_unauthenticated_period_in_seconds
- validates :throttle_unauthenticated_packages_api_requests_per_period
- validates :throttle_unauthenticated_packages_api_period_in_seconds
- validates :throttle_unauthenticated_files_api_requests_per_period
- validates :throttle_unauthenticated_files_api_period_in_seconds
- validates :throttle_unauthenticated_deprecated_api_requests_per_period
- validates :throttle_unauthenticated_deprecated_api_period_in_seconds
- validates :throttle_authenticated_api_requests_per_period
- validates :throttle_authenticated_api_period_in_seconds
- validates :throttle_authenticated_git_lfs_requests_per_period
- validates :throttle_authenticated_git_lfs_period_in_seconds
- validates :throttle_authenticated_web_requests_per_period
- validates :throttle_authenticated_web_period_in_seconds
- validates :throttle_authenticated_packages_api_requests_per_period
- validates :throttle_authenticated_packages_api_period_in_seconds
- validates :throttle_authenticated_files_api_requests_per_period
- validates :throttle_authenticated_files_api_period_in_seconds
- validates :throttle_authenticated_deprecated_api_requests_per_period
- validates :throttle_authenticated_deprecated_api_period_in_seconds
- validates :throttle_protected_paths_requests_per_period
- validates :throttle_protected_paths_period_in_seconds
- validates :project_jobs_api_rate_limit
+ with_options(numericality: { only_integer: true, greater_than: 0 }) do
+ validates :bulk_import_concurrent_pipeline_batch_limit,
+ :container_registry_token_expire_delay,
+ :housekeeping_optimize_repository_period,
+ :inactive_projects_delete_after_months,
+ :max_artifacts_size,
+ :max_attachment_size,
+ :max_yaml_depth,
+ :max_yaml_size_bytes,
+ :namespace_aggregation_schedule_lease_duration_in_seconds,
+ :project_jobs_api_rate_limit,
+ :snippet_size_limit,
+ :throttle_authenticated_api_period_in_seconds,
+ :throttle_authenticated_api_requests_per_period,
+ :throttle_authenticated_deprecated_api_period_in_seconds,
+ :throttle_authenticated_deprecated_api_requests_per_period,
+ :throttle_authenticated_files_api_period_in_seconds,
+ :throttle_authenticated_files_api_requests_per_period,
+ :throttle_authenticated_git_lfs_period_in_seconds,
+ :throttle_authenticated_git_lfs_requests_per_period,
+ :throttle_authenticated_packages_api_period_in_seconds,
+ :throttle_authenticated_packages_api_requests_per_period,
+ :throttle_authenticated_web_period_in_seconds,
+ :throttle_authenticated_web_requests_per_period,
+ :throttle_protected_paths_period_in_seconds,
+ :throttle_protected_paths_requests_per_period,
+ :throttle_unauthenticated_api_period_in_seconds,
+ :throttle_unauthenticated_api_requests_per_period,
+ :throttle_unauthenticated_deprecated_api_period_in_seconds,
+ :throttle_unauthenticated_deprecated_api_requests_per_period,
+ :throttle_unauthenticated_files_api_period_in_seconds,
+ :throttle_unauthenticated_files_api_requests_per_period,
+ :throttle_unauthenticated_packages_api_period_in_seconds,
+ :throttle_unauthenticated_packages_api_requests_per_period,
+ :throttle_unauthenticated_period_in_seconds,
+ :throttle_unauthenticated_requests_per_period
end
with_options(numericality: { only_integer: true, greater_than_or_equal_to: 0 }) do
- validates :notes_create_limit
- validates :search_rate_limit
- validates :search_rate_limit_unauthenticated
- validates :projects_api_rate_limit_unauthenticated
- validates :gitlab_shell_operation_limit
- end
+ validates :bulk_import_max_download_file_size,
+ :ci_max_includes,
+ :ci_max_total_yaml_size_bytes,
+ :container_registry_cleanup_tags_service_max_list_size,
+ :container_registry_data_repair_detail_worker_max_concurrency,
+ :container_registry_delete_tags_service_timeout,
+ :container_registry_expiration_policies_worker_capacity,
+ :container_registry_import_max_retries,
+ :container_registry_import_max_step_duration,
+ :container_registry_import_max_tags_count,
+ :container_registry_import_start_max_retries,
+ :container_registry_import_timeout,
+ :container_registry_pre_import_timeout,
+ :decompress_archive_file_timeout,
+ :dependency_proxy_ttl_group_policy_worker_capacity,
+ :gitlab_shell_operation_limit,
+ :inactive_projects_min_size_mb,
+ :issues_create_limit,
+ :jobs_per_stage_page_size,
+ :max_decompressed_archive_size,
+ :max_export_size,
+ :max_import_remote_file_size,
+ :max_import_size,
+ :max_pages_custom_domains_per_project,
+ :max_terraform_state_size_bytes,
+ :members_delete_limit,
+ :notes_create_limit,
+ :package_registry_cleanup_policies_worker_capacity,
+ :packages_cleanup_package_file_worker_capacity,
+ :pipeline_limit_per_project_user_sha,
+ :projects_api_rate_limit_unauthenticated,
+ :raw_blob_request_limit,
+ :search_rate_limit,
+ :search_rate_limit_unauthenticated,
+ :session_expire_delay,
+ :sidekiq_job_limiter_compression_threshold_bytes,
+ :sidekiq_job_limiter_limit_bytes,
+ :terminal_max_session_time,
+ :users_get_by_id_limit
+ end
+
+ jsonb_accessor :rate_limits,
+ members_delete_limit: [:integer, { default: 60 }]
+
+ validates :rate_limits, json_schema: { filename: "application_setting_rate_limits" }
validates :search_rate_limit_allowlist,
length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
@@ -669,10 +614,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :external_pipeline_validation_service_url,
addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS, allow_blank: true
- validates :external_pipeline_validation_service_timeout,
- allow_nil: true,
- numericality: { only_integer: true, greater_than: 0 }
-
validates :whats_new_variant,
inclusion: { in: ApplicationSetting.whats_new_variants.keys }
@@ -686,10 +627,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :sidekiq_job_limiter_mode,
inclusion: { in: self.sidekiq_job_limiter_modes }
- validates :sidekiq_job_limiter_compression_threshold_bytes,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
- validates :sidekiq_job_limiter_limit_bytes,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :sentry_enabled,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
@@ -711,8 +648,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
length: { maximum: 255 },
if: :error_tracking_enabled?
- validates :users_get_by_id_limit,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :users_get_by_id_limit_allowlist,
length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
allow_nil: false
@@ -724,20 +659,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
presence: true,
if: :update_runner_versions_enabled?
- validates :inactive_projects_min_size_mb,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :inactive_projects_delete_after_months,
- numericality: { only_integer: true, greater_than: 0 }
-
validates :inactive_projects_send_warning_email_after_months,
numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months }
validates :prometheus_alert_db_indicators_settings, json_schema: { filename: 'application_setting_prometheus_alert_db_indicators_settings' }, allow_nil: true
- validates :namespace_aggregation_schedule_lease_duration_in_seconds,
- numericality: { only_integer: true, greater_than: 0 }
-
validates :sentry_clientside_traces_sample_rate,
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1, message: N_('must be a value between 0 and 1') }
@@ -815,10 +741,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
allow_nil: false,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
- validates :bulk_import_concurrent_pipeline_batch_limit,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
validates :allow_runner_registration_token,
allow_nil: false,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
@@ -835,6 +757,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :math_rendering_limits_enabled,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ validates :require_admin_two_factor_authentication,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
+
before_validation :ensure_uuid!
before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed?
before_validation :normalize_default_branch_name
@@ -982,7 +907,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
end
def parsed_kroki_url
- @parsed_kroki_url ||= Gitlab::UrlBlocker.validate!(kroki_url, schemes: %w[http https], enforce_sanitization: true)[0]
+ @parsed_kroki_url ||= Gitlab::HTTP_V2::UrlBlocker.validate!(
+ kroki_url, schemes: %w[http https],
+ enforce_sanitization: true,
+ deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?)[0]
rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e
self.errors.add(
:kroki_url,
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 851b65055d0..d1899b18a4f 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -79,6 +79,7 @@ module ApplicationSettingImplementation
ecdsa_sk_key_restriction: default_min_key_size(:ecdsa_sk),
ed25519_key_restriction: default_min_key_size(:ed25519),
ed25519_sk_key_restriction: default_min_key_size(:ed25519_sk),
+ require_admin_two_factor_authentication: false,
eks_access_key_id: nil,
eks_account_id: nil,
eks_integration_enabled: false,
@@ -136,6 +137,7 @@ module ApplicationSettingImplementation
mirror_available: true,
notes_create_limit: 300,
notes_create_limit_allowlist: [],
+ members_delete_limit: 60,
notify_on_unknown_sign_in: true,
outbound_local_requests_whitelist: [],
password_authentication_enabled_for_git: true,
@@ -275,7 +277,8 @@ module ApplicationSettingImplementation
allow_account_deletion: true,
gitlab_shell_operation_limit: 600,
project_jobs_api_rate_limit: 600,
- security_txt_content: nil
+ security_txt_content: nil,
+ allow_project_creation_for_guest_and_below: true
}.tap do |hsh|
hsh.merge!(non_production_defaults) unless Rails.env.production?
end
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 894e28dd88a..a6969ce6f76 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -150,9 +150,9 @@ class BulkImports::Entity < ApplicationRecord
File.join(base_resource_path, 'export_relations')
end
- def export_relations_url_path(batched: false)
- if batched && bulk_import.supports_batched_export?
- Gitlab::Utils.add_url_parameters(export_relations_url_path_base, batched: batched)
+ def export_relations_url_path
+ if bulk_import.supports_batched_export?
+ Gitlab::Utils.add_url_parameters(export_relations_url_path_base, batched: true)
else
export_relations_url_path_base
end
diff --git a/app/models/bulk_imports/failure.rb b/app/models/bulk_imports/failure.rb
index 8a6077b523c..e23e49c6396 100644
--- a/app/models/bulk_imports/failure.rb
+++ b/app/models/bulk_imports/failure.rb
@@ -19,6 +19,14 @@ class BulkImports::Failure < ApplicationRecord
super(::Projects::ImportErrorFilter.filter_message(message).truncate(255))
end
+ def source_title=(title)
+ super(title&.truncate(255, omission: ''))
+ end
+
+ def source_url=(url)
+ super(url&.truncate(255, omission: ''))
+ end
+
private
def pipeline_relation
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index e56f3d2536c..d4c70a294ff 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -27,6 +27,7 @@ module Ci
foreign_key: :commit_id,
partition_foreign_key: :partition_id,
inverse_of: :builds
+ belongs_to :project_mirror, primary_key: :project_id, foreign_key: :project_id, inverse_of: :builds
RUNNER_FEATURES = {
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
@@ -42,6 +43,8 @@ module Ci
DEPLOYMENT_NAMES = %w[deploy release rollout].freeze
+ TOKEN_PREFIX = 'glcbt-'
+
has_one :pending_state, class_name: 'Ci::BuildPendingState', foreign_key: :build_id, inverse_of: :build
has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id, inverse_of: :build
has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id, inverse_of: :build
@@ -98,6 +101,7 @@ module Ci
delegate :harbor_integration, to: :project
delegate :apple_app_store_integration, to: :project
delegate :google_play_integration, to: :project
+ delegate :diffblue_cover_integration, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
delegate :ensure_persistent_ref, to: :pipeline
delegate :enable_debug_trace!, to: :metadata
@@ -188,6 +192,10 @@ module Ci
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123131
scope :with_runner_type, -> (runner_type) { joins(:runner).where(runner: { runner_type: runner_type }) }
+ scope :belonging_to_runner_manager, -> (runner_machine_id) {
+ joins(:runner_manager_build).where(p_ci_runner_machine_builds: { runner_machine_id: runner_machine_id })
+ }
+
scope :with_secure_reports_from_config_options, -> (job_types) do
joins(:metadata).where("#{Ci::BuildMetadata.quoted_table_name}.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types)
end
@@ -204,7 +212,7 @@ module Ci
add_authentication_token_field :token,
encrypted: :required,
- format_with_prefix: :partition_id_prefix_in_16_bit_encode
+ format_with_prefix: :prefix_and_partition_for_token
after_save :stick_build_if_status_changed
@@ -516,6 +524,7 @@ module Ci
.concat(harbor_variables)
.concat(apple_app_store_variables)
.concat(google_play_variables)
+ .concat(diffblue_cover_variables)
end
end
@@ -568,6 +577,12 @@ module Ci
Gitlab::Ci::Variables::Collection.new(google_play_integration.ci_variables(protected_ref: pipeline.protected_ref?))
end
+ def diffblue_cover_variables
+ return [] unless diffblue_cover_integration.try(:activated?)
+
+ Gitlab::Ci::Variables::Collection.new(diffblue_cover_integration.ci_variables)
+ end
+
def features
{
trace_sections: true,
@@ -1232,6 +1247,14 @@ module Ci
def partition_id_prefix_in_16_bit_encode
"#{partition_id.to_s(16)}_"
end
+
+ def prefix_and_partition_for_token
+ if Feature.enabled?(:prefix_ci_build_tokens, project, type: :beta)
+ TOKEN_PREFIX + partition_id_prefix_in_16_bit_encode
+ else
+ partition_id_prefix_in_16_bit_encode
+ end
+ end
end
end
diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb
index 4273c4515bc..0ea2735b030 100644
--- a/app/models/ci/catalog/resources/version.rb
+++ b/app/models/ci/catalog/resources/version.rb
@@ -19,6 +19,7 @@ module Ci
scope :for_catalog_resources, ->(catalog_resources) { where(catalog_resource_id: catalog_resources) }
scope :preloaded, -> { includes(:catalog_resource, project: [:route, { namespace: :route }], release: :author) }
+ scope :by_name, ->(name) { joins(:release).merge(Release.where(tag: name)) }
scope :order_by_created_at_asc, -> { reorder(created_at: :asc) }
scope :order_by_created_at_desc, -> { reorder(created_at: :desc) }
@@ -122,6 +123,14 @@ module Ci
project.commit_by(oid: sha)
end
+ def path
+ Gitlab::Routing.url_helpers.project_tag_path(project, name)
+ end
+
+ def readme
+ project.repository.tree(sha).readme
+ end
+
private
def update_catalog_resource
diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb
index 179befb8469..6a2fb1132c0 100644
--- a/app/models/ci/instance_variable.rb
+++ b/app/models/ci/instance_variable.rb
@@ -13,6 +13,7 @@ module Ci
alias_attribute :secret_value, :value
+ validates :description, length: { maximum: 255 }, allow_blank: true
validates :key, uniqueness: {
message: -> (object, data) { _("(%{value}) has already been taken") }
}
diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb
index ff7e681217a..5f55713b436 100644
--- a/app/models/ci/namespace_mirror.rb
+++ b/app/models/ci/namespace_mirror.rb
@@ -7,6 +7,7 @@ module Ci
include FromUnion
belongs_to :namespace
+ has_many :project_mirrors, primary_key: :namespace_id, foreign_key: :namespace_id, inverse_of: :namespace_mirror
scope :by_group_and_descendants, -> (id) do
where('traversal_ids @> ARRAY[?]::int[]', id)
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 9d5b2e5a0b1..1bf4d585e1c 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -20,7 +20,6 @@ module Ci
include IgnorableColumns
ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22'
- ignore_column :auto_canceled_by_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22'
MAX_OPEN_MERGE_REQUESTS_REFS = 4
@@ -439,7 +438,7 @@ module Ci
where_exists(Ci::Build.latest.scoped_pipeline.with_artifacts(reports_scope))
end
- scope :with_only_interruptible_builds, -> do
+ scope :conservative_interruptible, -> do
where_not_exists(
Ci::Build.scoped_pipeline.with_status(STARTED_STATUSES).not_interruptible
)
@@ -621,7 +620,7 @@ module Ci
end
def valid_commit_sha
- if self.sha == Gitlab::Git::BLANK_SHA
+ if self.sha == Gitlab::Git::SHA1_BLANK_SHA
self.errors.add(:sha, " cant be 00000000 (branch removal)")
end
end
@@ -675,7 +674,7 @@ module Ci
end
def before_sha
- super || Gitlab::Git::BLANK_SHA
+ super || Gitlab::Git::SHA1_BLANK_SHA
end
def short_sha
@@ -1394,6 +1393,10 @@ module Ci
merge_request.merge_request_diff_for(merge_request_diff_sha)
end
+ def auto_cancel_on_new_commit
+ pipeline_metadata&.auto_cancel_on_new_commit || 'conservative'
+ end
+
private
def add_message(severity, content)
diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb
index 6d22a875aab..e0e6906f211 100644
--- a/app/models/ci/pipeline_artifact.rb
+++ b/app/models/ci/pipeline_artifact.rb
@@ -4,6 +4,7 @@
module Ci
class PipelineArtifact < Ci::ApplicationRecord
+ include Ci::Partitionable
include UpdateProjectStatistics
include Artifactable
include FileStoreMounter
@@ -31,6 +32,8 @@ module Ci
validates :size, presence: true, numericality: { less_than_or_equal_to: FILE_SIZE_LIMIT }
validates :file_type, presence: true
+ partitionable scope: :pipeline
+
mount_file_store_uploader Ci::PipelineArtifactUploader
update_project_statistics project_statistics_name: :pipeline_artifacts_size
diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb
index ba20c993e36..1a2bc37d17d 100644
--- a/app/models/ci/pipeline_chat_data.rb
+++ b/app/models/ci/pipeline_chat_data.rb
@@ -2,14 +2,21 @@
module Ci
class PipelineChatData < Ci::ApplicationRecord
+ include Ci::Partitionable
include Ci::NamespacedModelName
+ include SafelyChangeColumnDefault
+
+ columns_changing_default :partition_id
self.table_name = 'ci_pipeline_chat_data'
belongs_to :chat_name
+ belongs_to :pipeline
validates :pipeline_id, presence: true
validates :chat_name_id, presence: true
validates :response_url, presence: true
+
+ partitionable scope: :pipeline
end
end
diff --git a/app/models/ci/pipeline_config.rb b/app/models/ci/pipeline_config.rb
index e2dcad653d7..11decd3fc66 100644
--- a/app/models/ci/pipeline_config.rb
+++ b/app/models/ci/pipeline_config.rb
@@ -2,11 +2,15 @@
module Ci
class PipelineConfig < Ci::ApplicationRecord
+ include Ci::Partitionable
+
self.table_name = 'ci_pipelines_config'
self.primary_key = :pipeline_id
belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_config
validates :pipeline, presence: true
validates :content, presence: true
+
+ partitionable scope: :pipeline
end
end
diff --git a/app/models/ci/pipeline_metadata.rb b/app/models/ci/pipeline_metadata.rb
index 37fa3e32ad8..21d102374f0 100644
--- a/app/models/ci/pipeline_metadata.rb
+++ b/app/models/ci/pipeline_metadata.rb
@@ -2,12 +2,15 @@
module Ci
class PipelineMetadata < Ci::ApplicationRecord
+ include Ci::Partitionable
+ include Importable
+
self.primary_key = :pipeline_id
enum auto_cancel_on_new_commit: {
conservative: 0,
interruptible: 1,
- disabled: 2
+ none: 2
}, _prefix: true
enum auto_cancel_on_job_failure: {
@@ -21,5 +24,7 @@ module Ci
validates :pipeline, presence: true
validates :project, presence: true
validates :name, length: { minimum: 1, maximum: 255 }, allow_nil: true
+
+ partitionable scope: :pipeline
end
end
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
index b1831e365b1..4fddb3e053e 100644
--- a/app/models/ci/pipeline_variable.rb
+++ b/app/models/ci/pipeline_variable.rb
@@ -5,9 +5,6 @@ module Ci
include Ci::Partitionable
include Ci::HasVariable
include Ci::RawVariable
- include IgnorableColumns
-
- ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22'
belongs_to :pipeline
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 414d36da7c3..989d6337ab7 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -33,6 +33,10 @@ module Ci
where('NOT EXISTS (?)', needs)
end
+ scope :interruptible, -> do
+ joins(:metadata).merge(Ci::BuildMetadata.with_interruptible)
+ end
+
scope :not_interruptible, -> do
joins(:metadata).where.not(
Ci::BuildMetadata.table_name => { id: Ci::BuildMetadata.scoped_build.with_interruptible.select(:id) }
diff --git a/app/models/ci/project_mirror.rb b/app/models/ci/project_mirror.rb
index 23cd5d92730..c6828f827b5 100644
--- a/app/models/ci/project_mirror.rb
+++ b/app/models/ci/project_mirror.rb
@@ -7,6 +7,8 @@ module Ci
include FromUnion
belongs_to :project
+ belongs_to :namespace_mirror, primary_key: :namespace_id, foreign_key: :namespace_id, inverse_of: :project_mirrors
+ has_many :builds, primary_key: :project_id, foreign_key: :project_id, inverse_of: :project_mirror
scope :by_namespace_id, -> (namespace_id) { where(namespace_id: namespace_id) }
scope :by_project_id, -> (project_id) { where(project_id: project_id) }
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 9c30beeeb59..5fb982ee21e 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -14,6 +14,7 @@ module Ci
include Presentable
include EachBatch
include Ci::HasRunnerExecutor
+ include Ci::HasRunnerStatus
extend ::Gitlab::Utils::Override
@@ -85,22 +86,22 @@ module Ci
before_save :ensure_token
- scope :active, -> (value = true) { where(active: value) }
+ scope :active, ->(value = true) { where(active: value) }
scope :paused, -> { active(false) }
- scope :online, -> { where(arel_table[:contacted_at].gt(online_contact_time_deadline)) }
scope :recent, -> do
timestamp = stale_deadline
where(arel_table[:created_at].gteq(timestamp).or(arel_table[:contacted_at].gteq(timestamp)))
end
scope :stale, -> do
- timestamp = stale_deadline
+ stale_timestamp = stale_deadline
+
+ created_before_stale_deadline = arel_table[:created_at].lteq(stale_timestamp)
+ contacted_before_stale_deadline = arel_table[:contacted_at].lteq(stale_timestamp)
+ never_contacted = arel_table[:contacted_at].eq(nil)
- where(arel_table[:created_at].lteq(timestamp))
- .where(arel_table[:contacted_at].eq(nil).or(arel_table[:contacted_at].lteq(timestamp)))
+ where(created_before_stale_deadline).where(never_contacted.or(contacted_before_stale_deadline))
end
- scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) }
- scope :never_contacted, -> { where(contacted_at: nil) }
scope :ordered, -> { order(id: :desc) }
scope :with_recent_runner_queue, -> { where(arel_table[:contacted_at].gt(recent_queue_deadline)) }
@@ -220,6 +221,11 @@ module Ci
validate :exactly_one_group, if: :group_type?
scope :with_version_prefix, ->(value) { joins(:runner_managers).merge(RunnerManager.with_version_prefix(value)) }
+ scope :with_runner_type, ->(runner_type) do
+ return all if AVAILABLE_TYPES.exclude?(runner_type.to_s)
+
+ where(runner_type: runner_type)
+ end
acts_as_taggable
@@ -348,23 +354,6 @@ module Ci
description
end
- def online?
- contacted_at && contacted_at > self.class.online_contact_time_deadline
- end
-
- def stale?
- return false unless created_at
-
- [created_at, contacted_at].compact.max <= self.class.stale_deadline
- end
-
- def status
- return :stale if stale?
- return :never_contacted unless contacted_at
-
- online? ? :online : :offline
- end
-
# DEPRECATED
# TODO Remove in v5 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648
def deprecated_rest_status
@@ -475,6 +464,21 @@ module Ci
end
end
+ def clear_heartbeat
+ cleared_attributes = {
+ version: nil,
+ revision: nil,
+ platform: nil,
+ architecture: nil,
+ ip_address: nil,
+ executor_type: nil,
+ config: {},
+ contacted_at: nil
+ }
+ merge_cache_attributes(cleared_attributes)
+ update_columns(cleared_attributes)
+ end
+
def pick_build!(build)
tick_runner_queue if matches_build?(build)
end
diff --git a/app/models/ci/runner_manager.rb b/app/models/ci/runner_manager.rb
index e6576859827..44fe1bdd67d 100644
--- a/app/models/ci/runner_manager.rb
+++ b/app/models/ci/runner_manager.rb
@@ -5,10 +5,13 @@ module Ci
include FromUnion
include RedisCacheable
include Ci::HasRunnerExecutor
+ include Ci::HasRunnerStatus
# For legacy reasons, the table name is ci_runner_machines in the database
self.table_name = 'ci_runner_machines'
+ AVAILABLE_STATUSES = %w[online offline never_contacted stale].freeze
+
# The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner Machine DB entry can be updated
UPDATE_CONTACT_COLUMN_EVERY = (40.minutes)..(55.minutes)
@@ -36,19 +39,26 @@ module Ci
STALE_TIMEOUT = 7.days
scope :stale, -> do
- created_some_time_ago = arel_table[:created_at].lteq(STALE_TIMEOUT.ago)
- contacted_some_time_ago = arel_table[:contacted_at].lteq(STALE_TIMEOUT.ago)
+ stale_timestamp = stale_deadline
+
+ created_before_stale_deadline = arel_table[:created_at].lteq(stale_timestamp)
+ contacted_before_stale_deadline = arel_table[:contacted_at].lteq(stale_timestamp)
from_union(
- where(contacted_at: nil),
- where(contacted_some_time_ago),
- remove_duplicates: false).where(created_some_time_ago)
+ never_contacted,
+ where(contacted_before_stale_deadline),
+ remove_duplicates: false
+ ).where(created_before_stale_deadline)
end
scope :for_runner, ->(runner_id) do
where(runner_id: runner_id)
end
+ scope :with_system_xid, ->(system_xid) do
+ where(system_xid: system_xid)
+ end
+
scope :with_running_builds, -> do
where('EXISTS(?)',
Ci::Build.select(1)
@@ -114,25 +124,8 @@ module Ci
end
end
- def status
- return :stale if stale?
- return :never_contacted unless contacted_at
-
- online? ? :online : :offline
- end
-
private
- def online?
- contacted_at && contacted_at > self.class.online_contact_time_deadline
- end
-
- def stale?
- return false unless created_at
-
- [created_at, contacted_at].compact.max <= self.class.stale_deadline
- end
-
def persist_cached_data?
# Use a random threshold to prevent beating DB updates.
contacted_at_max_age = Random.rand(UPDATE_CONTACT_COLUMN_EVERY)
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index becb8f204bf..ba1a0a46247 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -7,9 +7,6 @@ module Ci
include Ci::HasStatus
include Gitlab::OptimisticLocking
include Presentable
- include IgnorableColumns
-
- ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22'
partitionable scope: :pipeline
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 886e6e9fbd7..9c8d7604031 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -359,7 +359,7 @@ class Commit
def diff_refs
Gitlab::Diff::DiffRefs.new(
- base_sha: self.parent_id || Gitlab::Git::BLANK_SHA,
+ base_sha: self.parent_id || Gitlab::Git::SHA1_BLANK_SHA,
head_sha: self.sha
)
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index f1aeb7e528f..3a9b1465682 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -86,7 +86,7 @@ class CommitStatus < Ci::ApplicationRecord
scope :for_project_paths, -> (paths) do
# Pluck is used to split this query. Splitting the query is required for database decomposition for `ci_*` tables.
# https://docs.gitlab.com/ee/development/database/transaction_guidelines.html#database-decomposition-and-sharding
- project_ids = Project.where_full_path_in(Array(paths), use_includes: false).pluck(:id)
+ project_ids = Project.where_full_path_in(Array(paths), preload_routes: false).pluck(:id)
for_project(project_ids)
end
diff --git a/app/models/compare.rb b/app/models/compare.rb
index 58279cb58aa..d80f3f72ca7 100644
--- a/app/models/compare.rb
+++ b/app/models/compare.rb
@@ -1,12 +1,12 @@
# frozen_string_literal: true
-require 'set'
+require 'set' # rubocop:disable Lint/RedundantRequireStatement -- Ruby 3.1 and earlier needs this. Drop this line after Ruby 3.2+ is only supported.
class Compare
include Gitlab::Utils::StrongMemoize
include ActsAsPaginatedDiff
- delegate :same, :head, :base, to: :@compare
+ delegate :same, :head, :base, :generated_files, to: :@compare
attr_reader :project
diff --git a/app/models/concerns/analytics/cycle_analytics/parentable.rb b/app/models/concerns/analytics/cycle_analytics/parentable.rb
index 785f6eea6bf..90a38e3c58c 100644
--- a/app/models/concerns/analytics/cycle_analytics/parentable.rb
+++ b/app/models/concerns/analytics/cycle_analytics/parentable.rb
@@ -6,16 +6,7 @@ module Analytics
extend ActiveSupport::Concern
included do
- belongs_to :namespace, class_name: 'Namespace', foreign_key: :group_id, optional: false # rubocop: disable Rails/InverseOf
-
- validate :ensure_namespace_type
-
- def ensure_namespace_type
- return if namespace.nil?
- return if namespace.is_a?(::Namespaces::ProjectNamespace) || namespace.is_a?(::Group)
-
- errors.add(:namespace, s_('CycleAnalytics|the assigned object is not supported'))
- end
+ belongs_to :namespace, class_name: 'Namespace', foreign_key: :group_id, optional: false # rubocop: disable Rails/InverseOf -- this relation is not present on Namespace
end
end
end
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index ec4ee7985fe..f51b0967968 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -219,8 +219,8 @@ module AtomicInternalId
::AtomicInternalId.scope_usage(self.class)
end
- def self.scope_usage(including_class)
- including_class.table_name.to_sym
+ def self.scope_usage(klass)
+ klass.respond_to?(:internal_id_scope_usage) ? klass.internal_id_scope_usage : klass.table_name.to_sym
end
def self.project_init(klass, column_name = :iid)
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 6a855198697..7c7fd882228 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -40,8 +40,6 @@ module CacheMarkdownField
# Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author)
- context[:markdown_engine] = Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE
-
if Feature.enabled?(:personal_snippet_reference_filters, context[:author])
context[:user] = self.parent_user
end
diff --git a/app/models/concerns/ci/has_runner_status.rb b/app/models/concerns/ci/has_runner_status.rb
new file mode 100644
index 00000000000..f6fb9940b44
--- /dev/null
+++ b/app/models/concerns/ci/has_runner_status.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Ci
+ module HasRunnerStatus
+ extend ActiveSupport::Concern
+
+ included do
+ scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) }
+ scope :never_contacted, -> { where(contacted_at: nil) }
+ scope :online, -> { where(arel_table[:contacted_at].gt(online_contact_time_deadline)) }
+
+ scope :with_status, ->(status) do
+ return all if available_statuses.exclude?(status.to_s)
+
+ public_send(status) # rubocop:disable GitlabSecurity/PublicSend -- safe to call
+ end
+ end
+
+ class_methods do
+ def available_statuses
+ self::AVAILABLE_STATUSES
+ end
+
+ def online_contact_time_deadline
+ raise NotImplementedError
+ end
+
+ def stale_deadline
+ raise NotImplementedError
+ end
+ end
+
+ def status
+ return :stale if stale?
+ return :never_contacted unless contacted_at
+
+ online? ? :online : :offline
+ end
+
+ def online?
+ contacted_at && contacted_at > self.class.online_contact_time_deadline
+ end
+
+ def stale?
+ return false unless created_at
+
+ [created_at, contacted_at].compact.max <= self.class.stale_deadline
+ end
+ end
+end
diff --git a/app/models/concerns/ci/partitionable/testing.rb b/app/models/concerns/ci/partitionable/testing.rb
index b961d72db94..9f0d55329ad 100644
--- a/app/models/concerns/ci/partitionable/testing.rb
+++ b/app/models/concerns/ci/partitionable/testing.rb
@@ -21,6 +21,10 @@ module Ci
Ci::PendingBuild
Ci::RunningBuild
Ci::RunnerManagerBuild
+ Ci::PipelineArtifact
+ Ci::PipelineChatData
+ Ci::PipelineConfig
+ Ci::PipelineMetadata
Ci::PipelineVariable
Ci::Sources::Pipeline
Ci::Stage
diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb
index 201994cb321..12e4a5a0ee0 100644
--- a/app/models/concerns/commit_signature.rb
+++ b/app/models/concerns/commit_signature.rb
@@ -9,17 +9,7 @@ module CommitSignature
sha_attribute :commit_sha
- enum verification_status: {
- unverified: 0,
- verified: 1,
- same_user_different_email: 2,
- other_user: 3,
- unverified_key: 4,
- unknown_key: 5,
- multiple_signatures: 6,
- revoked_key: 7,
- verified_system: 8
- }
+ enum verification_status: Enums::CommitSignature.verification_statuses
belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false
diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb
deleted file mode 100644
index 7e2f445189e..00000000000
--- a/app/models/concerns/database_event_tracking.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-module DatabaseEventTracking
- extend ActiveSupport::Concern
-
- included do
- after_create_commit :publish_database_create_event
- after_destroy_commit :publish_database_destroy_event
- after_update_commit :publish_database_update_event
- end
-
- def publish_database_create_event
- publish_database_event('create')
- end
-
- def publish_database_destroy_event
- publish_database_event('destroy')
- end
-
- def publish_database_update_event
- publish_database_event('update')
- end
-
- def publish_database_event(name)
- # Gitlab::Tracking#event is triggering Snowplow event
- # Snowplow events are sent with usage of
- # https://snowplow.github.io/snowplow-ruby-tracker/SnowplowTracker/AsyncEmitter.html
- # that reports data asynchronously and does not impact performance nor carries a risk of
- # rollback in case of error
-
- Gitlab::Tracking.database_event(
- self.class.to_s,
- "database_event_#{name}",
- label: self.class.table_name,
- project: try(:project),
- namespace: (try(:group) || try(:namespace)) || try(:project)&.namespace,
- property: name,
- **filtered_record_attributes
- )
- rescue StandardError => err
- # this rescue should be a dead code due to utilization of AsyncEmitter, however
- # since this concern is expected to be included in every model, it is better to
- # prevent against any unexpected outcome
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err)
- end
-
- def filtered_record_attributes
- attributes
- .with_indifferent_access
- .slice(*self.class::SNOWPLOW_ATTRIBUTES)
- end
-end
diff --git a/app/models/concerns/enums/commit_signature.rb b/app/models/concerns/enums/commit_signature.rb
new file mode 100644
index 00000000000..92625af58ef
--- /dev/null
+++ b/app/models/concerns/enums/commit_signature.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Enums
+ class CommitSignature
+ VERIFICATION_STATUSES = {
+ unverified: 0,
+ verified: 1,
+ same_user_different_email: 2,
+ other_user: 3,
+ unverified_key: 4,
+ unknown_key: 5,
+ multiple_signatures: 6,
+ revoked_key: 7,
+ verified_system: 8
+ # EE adds more values in ee/app/models/concerns/ee/enums/commit_signature.rb
+ }.freeze
+
+ def self.verification_statuses
+ VERIFICATION_STATUSES
+ end
+ end
+end
+
+Enums::CommitSignature.prepend_mod
diff --git a/app/models/concerns/integrations/enable_ssl_verification.rb b/app/models/concerns/integrations/enable_ssl_verification.rb
index cb20955488a..1dffe183475 100644
--- a/app/models/concerns/integrations/enable_ssl_verification.rb
+++ b/app/models/concerns/integrations/enable_ssl_verification.rb
@@ -9,7 +9,8 @@ module Integrations
type: :checkbox,
title: -> { s_('Integrations|SSL verification') },
checkbox_label: -> { s_('Integrations|Enable SSL verification') },
- help: -> { s_('Integrations|Clear if using a self-signed certificate.') }
+ help: -> { s_('Integrations|Clear if using a self-signed certificate.') },
+ description: -> { s_('Enable SSL verification. Defaults to `true` (enabled).') }
end
def initialize_properties
diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb
index 223191fb963..3ce1dd36a5e 100644
--- a/app/models/concerns/integrations/has_issue_tracker_fields.rb
+++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb
@@ -10,16 +10,16 @@ module Integrations
field :project_url,
required: true,
title: -> { _('Project URL') },
- help: -> do
- s_('IssueTracker|The URL to the project in the external issue tracker.')
- end
+ description: -> { s_('URL of the project.') },
+ help: -> { s_('IssueTracker|URL of the project in the external issue tracker.') }
field :issues_url,
required: true,
title: -> { s_('IssueTracker|Issue URL') },
+ description: -> { s_('URL of the issue.') },
help: -> do
ERB::Util.html_escape(
- s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.')
+ s_('IssueTracker|URL to view an issue in the external issue tracker. Must contain %{colon_id}.')
) % {
colon_id: '<code>:id</code>'.html_safe
}
@@ -28,9 +28,8 @@ module Integrations
field :new_issue_url,
required: true,
title: -> { s_('IssueTracker|New issue URL') },
- help: -> do
- s_('IssueTracker|The URL to create an issue in the external issue tracker.')
- end
+ description: -> { s_('URL of the new issue.') },
+ help: -> { s_('IssueTracker|URL to create an issue in the external issue tracker.') }
end
end
end
diff --git a/app/models/concerns/integrations/slack_mattermost_fields.rb b/app/models/concerns/integrations/slack_mattermost_fields.rb
index a8e63c4e405..08f86813cc1 100644
--- a/app/models/concerns/integrations/slack_mattermost_fields.rb
+++ b/app/models/concerns/integrations/slack_mattermost_fields.rb
@@ -7,26 +7,40 @@ module Integrations
included do
field :webhook,
help: -> { webhook_help },
+ description: -> do
+ Kernel.format(_("%{title} webhook (for example, `%{example}`)."), title: title, example: webhook_help)
+ end,
required: true,
if: -> { requires_webhook? }
field :username,
placeholder: 'GitLab-integration',
+ description: -> { Kernel.format(_("%{title} username."), title: title) },
if: -> { requires_webhook? }
+ field :channel,
+ description: -> { _('Default channel to use if no other channel is configured.') },
+ api_only: true
+
field :notify_only_broken_pipelines,
type: :checkbox,
section: Integration::SECTION_TYPE_CONFIGURATION,
+ description: -> { _('Send notifications for broken pipelines.') },
help: 'Do not send notifications for successful pipelines.'
field :branches_to_be_notified,
type: :select,
section: Integration::SECTION_TYPE_CONFIGURATION,
title: -> { s_('Integration|Branches for which notifications are to be sent') },
+ description: -> {
+ _('Branches to send notifications for. Valid options are `all`, `default`, `protected`, ' \
+ 'and `default_and_protected`. The default value is `default`.')
+ },
choices: -> { branch_choices }
field :labels_to_be_notified,
section: Integration::SECTION_TYPE_CONFIGURATION,
+ description: -> { _('Labels to send notifications for. Leave blank to receive notifications for all events.') },
placeholder: '~backend,~frontend',
help: 'Send notifications for issue, merge request, and comment events with the listed labels only. ' \
'Leave blank to receive notifications for all events.'
@@ -34,6 +48,10 @@ module Integrations
field :labels_to_be_notified_behavior,
type: :select,
section: Integration::SECTION_TYPE_CONFIGURATION,
+ description: -> {
+ _('Labels to be notified for. Valid options are `match_any` and `match_all`. ' \
+ 'The default value is `match_any`.')
+ },
choices: [
['Match any of the labels', Integrations::BaseChatNotification::MATCH_ANY_LABEL],
['Match all of the labels', Integrations::BaseChatNotification::MATCH_ALL_LABELS]
diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb
index c322a736e79..8feb162207d 100644
--- a/app/models/concerns/partitioned_table.rb
+++ b/app/models/concerns/partitioned_table.rb
@@ -9,7 +9,8 @@ module PartitionedTable
PARTITIONING_STRATEGIES = {
monthly: Gitlab::Database::Partitioning::MonthlyStrategy,
sliding_list: Gitlab::Database::Partitioning::SlidingListStrategy,
- ci_sliding_list: Gitlab::Database::Partitioning::CiSlidingListStrategy
+ ci_sliding_list: Gitlab::Database::Partitioning::CiSlidingListStrategy,
+ int_range: Gitlab::Database::Partitioning::IntRangeStrategy
}.freeze
def partitioned_by(partitioning_key, strategy:, **kwargs)
diff --git a/app/models/concerns/restricted_signup.rb b/app/models/concerns/restricted_signup.rb
index 87b62214529..8fcf0532151 100644
--- a/app/models/concerns/restricted_signup.rb
+++ b/app/models/concerns/restricted_signup.rb
@@ -31,10 +31,10 @@ module RestrictedSignup
def error_message
{
admin: {
- allowlist: html_escape_once(_("Go to the 'Admin area &gt; Sign-up restrictions', and check 'Allowed domains for sign-ups'.")).html_safe,
- denylist: html_escape_once(_("Go to the 'Admin area &gt; Sign-up restrictions', and check the 'Domain denylist'.")).html_safe,
- restricted: html_escape_once(_("Go to the 'Admin area &gt; Sign-up restrictions', and check 'Email restrictions for sign-ups'.")).html_safe,
- group_setting: html_escape_once(_("Go to the group’s 'Settings &gt; General' page, and check 'Restrict membership by email domain'.")).html_safe
+ allowlist: ERB::Util.html_escape_once(_("Go to the 'Admin area &gt; Sign-up restrictions', and check 'Allowed domains for sign-ups'.")).html_safe,
+ denylist: ERB::Util.html_escape_once(_("Go to the 'Admin area &gt; Sign-up restrictions', and check the 'Domain denylist'.")).html_safe,
+ restricted: ERB::Util.html_escape_once(_("Go to the 'Admin area &gt; Sign-up restrictions', and check 'Email restrictions for sign-ups'.")).html_safe,
+ group_setting: ERB::Util.html_escape_once(_("Go to the group’s 'Settings &gt; General' page, and check 'Restrict membership by email domain'.")).html_safe
},
nonadmin: {
allowlist: error_nonadmin,
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index 242194be440..43874d0211c 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -87,37 +87,27 @@ module Routable
# Klass.where_full_path_in(%w{gitlab-org/gitlab-foss gitlab-org/gitlab})
#
# Returns an ActiveRecord::Relation.
- def where_full_path_in(paths, use_includes: true)
+ def where_full_path_in(paths, preload_routes: true)
return none if paths.empty?
- wheres = paths.map do |path|
+ path_condition = paths.map do |path|
"(LOWER(routes.path) = LOWER(#{connection.quote(path)}))"
- end
+ end.join(' OR ')
- if Feature.enabled?(:optimize_where_full_path_in, Feature.current_request)
- route_scope = all
- source_type_condition = { source_type: route_scope.klass.base_class }
+ route_scope = all
+ source_type_condition = { source_type: route_scope.klass.base_class }
- routes_matching_condition = Route.where(source_type_condition).where(wheres.join(' OR '))
+ routes_matching_condition = Route
+ .where(source_type_condition)
+ .where(path_condition)
- result = route_scope.where(id: routes_matching_condition.pluck(:source_id))
+ source_ids = routes_matching_condition.pluck(:source_id)
+ result = route_scope.where(id: source_ids)
- if use_includes
- result.preload(:route)
- else
- result
- end
+ if preload_routes
+ result.preload(:route)
else
- route =
- if use_includes
- includes(:route).references(:routes)
- else
- joins(:route)
- end
-
- route
- .where(wheres.join(' OR '))
- .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046")
+ result
end
end
end
diff --git a/app/models/container_registry/protection/rule.rb b/app/models/container_registry/protection/rule.rb
index a7324b3b3b8..34d00bdef2f 100644
--- a/app/models/container_registry/protection/rule.rb
+++ b/app/models/container_registry/protection/rule.rb
@@ -19,6 +19,23 @@ module ContainerRegistry
validates :repository_path_pattern, presence: true, uniqueness: { scope: :project_id }, length: { maximum: 255 }
validates :delete_protected_up_to_access_level, presence: true
validates :push_protected_up_to_access_level, presence: true
+
+ scope :for_repository_path, ->(repository_path) do
+ return none if repository_path.blank?
+
+ where(
+ ":repository_path ILIKE #{::Gitlab::SQL::Glob.to_like('repository_path_pattern')}",
+ repository_path: repository_path
+ )
+ end
+
+ def self.for_push_exists?(access_level:, repository_path:)
+ return false if access_level.blank? || repository_path.blank?
+
+ where(push_protected_up_to_access_level: access_level..)
+ .for_repository_path(repository_path)
+ .exists?
+ end
end
end
end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 6bcfd23e69c..3b1c10c0259 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -482,7 +482,7 @@ class ContainerRepository < ApplicationRecord
raise 'too many pages requested' if page_count >= MAX_TAGS_PAGES
end
- def tags_page(before: nil, last: nil, sort: nil, name: nil, page_size: 100)
+ def tags_page(before: nil, last: nil, sort: nil, name: nil, page_size: 100, referrers: nil)
raise ArgumentError, 'not a migrated repository' unless migrated?
page = gitlab_api_client.tags(
@@ -491,7 +491,8 @@ class ContainerRepository < ApplicationRecord
before: before,
last: last,
sort: sort,
- name: name
+ name: name,
+ referrers: referrers
)
{
@@ -618,12 +619,11 @@ class ContainerRepository < ApplicationRecord
self.new(project: path.repository_project, name: path.repository_name)
end
- def self.find_or_create_from_path(path)
- repository = safe_find_or_create_by(
- project: path.repository_project,
+ def self.find_or_create_from_path!(path)
+ ContainerRepository.upsert({
+ project_id: path.repository_project.id,
name: path.repository_name
- )
- return repository if repository.persisted?
+ }, unique_by: %i[project_id name])
find_by_path!(path)
end
@@ -657,6 +657,8 @@ class ContainerRepository < ApplicationRecord
tag.total_size = raw_tag['size_bytes']
tag.manifest_digest = raw_tag['digest']
tag.revision = raw_tag['config_digest'].to_s.split(':')[1] || ''
+ tag.referrers = raw_tag['referrers']
+ tag.published_at = raw_tag['published_at']
tag
end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 36f4a0ef426..1fff089451d 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -9,6 +9,7 @@ class Deployment < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include FastDestroyAll
include IgnorableColumns
+ include EachBatch
StatusUpdateError = Class.new(StandardError)
StatusSyncError = Class.new(StandardError)
@@ -230,7 +231,7 @@ class Deployment < ApplicationRecord
##
# FastDestroyAll concerns
def begin_fast_destroy
- preload(:project).find_each.map do |deployment|
+ preload(:project, :environment).find_each.map do |deployment|
[deployment.project, deployment.ref_path]
end
end
diff --git a/app/models/group.rb b/app/models/group.rb
index ac843f392fd..bbf34ce21c0 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -37,8 +37,8 @@ class Group < Namespace
has_many :all_group_members, -> { non_request }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
has_many :all_owner_members, -> { non_request.all_owners }, as: :source, class_name: 'GroupMember'
- has_many :group_members, -> { non_request.where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
- has_many :namespace_members, -> { non_request.where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }).unscope(where: %i[source_id source_type]) },
+ has_many :group_members, -> { non_request.non_minimal_access }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
+ has_many :namespace_members, -> { non_request.non_minimal_access.unscope(where: %i[source_id source_type]) },
foreign_key: :member_namespace_id, inverse_of: :group, class_name: 'GroupMember'
alias_method :members, :group_members
@@ -338,6 +338,18 @@ class Group < Namespace
by_ids_or_paths(ids, paths).pluck(:id)
end
+ def descendant_groups_counts
+ left_joins(:children).group(:id).count(:children_namespaces)
+ end
+
+ def projects_counts
+ left_joins(:non_archived_projects).group(:id).count(:projects)
+ end
+
+ def group_members_counts
+ left_joins(:group_members).group(:id).count(:members)
+ end
+
private
def public_to_user_arel(user)
@@ -434,7 +446,9 @@ class Group < Namespace
end
def owned_by?(user)
- owners.include?(user)
+ return false unless user
+
+ all_owner_members.non_invite.exists?(user: user)
end
def add_members(users, access_level, current_user: nil, expires_at: nil)
@@ -593,6 +607,14 @@ class Group < Namespace
end
end
+ # Only for direct and not requested members with higher access level than MIMIMAL_ACCESS
+ # It returns true for non-active users
+ def has_user?(user)
+ return false unless user
+
+ group_members.non_invite.exists?(user: user)
+ end
+
def direct_members
GroupMember.active_without_invites_and_requests
.non_minimal_access
@@ -685,7 +707,11 @@ class Group < Namespace
end
def highest_group_member(user)
- GroupMember.where(source_id: self_and_ancestors_ids, user_id: user.id).order(:access_level).last
+ GroupMember
+ .where(source_id: self_and_ancestors_ids, user_id: user.id)
+ .non_request
+ .order(:access_level)
+ .last
end
def bots
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 618f9f986e8..8ebf24b1663 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -19,8 +19,8 @@ class Integration < ApplicationRecord
self.inheritance_column = :type_new
INTEGRATION_NAMES = %w[
- asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker datadog discord
- drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira
+ asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker
+ datadog diffblue_cover discord drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity telegram
unify_circuit webex_teams youtrack zentao
@@ -638,7 +638,9 @@ class Integration < ApplicationRecord
end
def validate_belongs_to_project_or_group
- errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_level? && group_level?
+ return unless project_level? && group_level?
+
+ errors.add(:project_id, 'The integration cannot belong to both a project and a group')
end
def validate_recipients?
diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb
index a248a1aa561..152bcf934ae 100644
--- a/app/models/integrations/apple_app_store.rb
+++ b/app/models/integrations/apple_app_store.rb
@@ -45,7 +45,7 @@ module Integrations
section: SECTION_TYPE_CONFIGURATION,
title: -> { s_('AppleAppStore|Protected branches and tags only') },
description: -> { s_('AppleAppStore|Set variables on protected branches and tags only.') },
- checkbox_label: -> { s_('AppleAppStore|Set variables on protected branches and tags only.') }
+ checkbox_label: -> { s_('AppleAppStore|Set variables on protected branches and tags only') }
def self.title
'Apple App Store Connect'
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index 9fe73f86be3..1c68d09aa2f 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -8,12 +8,14 @@ module Integrations
field :bamboo_url,
title: -> { s_('BambooService|Bamboo URL') },
placeholder: -> { s_('https://bamboo.example.com') },
- help: -> { s_('BambooService|Bamboo service root URL.') },
+ help: -> { s_('BambooService|Bamboo root URL.') },
+ description: -> { s_('Bamboo root URL (for example, `https://bamboo.example.com`).') },
exposes_secrets: true,
required: true
field :build_key,
help: -> { s_('BambooService|Bamboo build plan key.') },
+ description: -> { s_('Bamboo build plan key (for example, `KEY`).') },
non_empty_password_title: -> { s_('BambooService|Enter new build key') },
non_empty_password_help: -> { s_('BambooService|Leave blank to use your current build key.') },
placeholder: -> { _('KEY') },
@@ -21,12 +23,16 @@ module Integrations
is_secret: true
field :username,
- help: -> { s_('BambooService|The user with API access to the Bamboo server.') }
+ help: -> { s_('BambooService|User with API access to the Bamboo server.') },
+ description: -> { s_('User with API access to the Bamboo server.') },
+ required: true
field :password,
type: :password,
non_empty_password_title: -> { s_('ProjectService|Enter new password') },
- non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') }
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') },
+ description: -> { s_('Password of the user.') },
+ required: true
with_options if: :activated? do
validates :bamboo_url, presence: true, public_url: true
diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb
index 18268ed18f4..783311ca18d 100644
--- a/app/models/integrations/campfire.rb
+++ b/app/models/integrations/campfire.rb
@@ -15,6 +15,9 @@ module Integrations
field :token,
type: :password,
title: -> { _('Campfire token') },
+ description: -> do
+ _('API authentication token from Campfire. To get the token, sign in to Campfire and select **My info**.')
+ end,
help: -> { s_('CampfireService|API authentication token from Campfire.') },
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
@@ -23,18 +26,22 @@ module Integrations
field :subdomain,
title: -> { _('Campfire subdomain (optional)') },
+ description: -> do
+ _("`.campfirenow.com` subdomain when you're signed in.")
+ end,
placeholder: '',
exposes_secrets: true,
help: -> do
format(ERB::Util.html_escape(
- s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.')
+ s_('CampfireService|%{code_open}.campfirenow.com%{code_close} subdomain.')
), code_open: '<code>'.html_safe, code_close: '</code>'.html_safe)
end
field :room,
title: -> { _('Campfire room ID (optional)') },
+ description: -> { _("ID portion of the Campfire room URL.") },
placeholder: '123456',
- help: -> { s_('CampfireService|From the end of the room URL.') }
+ help: -> { s_('CampfireService|ID portion of the Campfire room URL.') }
def self.title
'Campfire'
diff --git a/app/models/integrations/clickup.rb b/app/models/integrations/clickup.rb
index 25287b53300..1737aa7ff61 100644
--- a/app/models/integrations/clickup.rb
+++ b/app/models/integrations/clickup.rb
@@ -32,8 +32,8 @@ module Integrations
'clickup'
end
- def fields
- super.select { _1.name.in?(%w[project_url issues_url]) }
+ def self.fields
+ super.select { %w[project_url issues_url].include?(_1.name) }
end
end
end
diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb
index f97f1fd25c9..fcdc908ca67 100644
--- a/app/models/integrations/confluence.rb
+++ b/app/models/integrations/confluence.rb
@@ -10,7 +10,8 @@ module Integrations
validate :validate_confluence_url_is_cloud, if: :activated?
field :confluence_url,
- title: -> { _('Confluence Cloud Workspace URL') },
+ title: -> { _('Confluence Workspace URL') },
+ description: -> { _("URL of the Confluence Workspace hosted on `atlassian.net`.") },
placeholder: 'https://example.atlassian.net/wiki',
required: true
diff --git a/app/models/integrations/diffblue_cover.rb b/app/models/integrations/diffblue_cover.rb
new file mode 100644
index 00000000000..c0e0cae2b33
--- /dev/null
+++ b/app/models/integrations/diffblue_cover.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+module Integrations
+ class DiffblueCover < Integration
+ field :diffblue_license_key,
+ section: SECTION_TYPE_CONNECTION,
+ type: :password,
+ title: -> { s_('DiffblueCover|License key') },
+ description: -> { s_('DiffblueCover|Diffblue Cover license key.') },
+ non_empty_password_title: -> { s_('DiffblueCover|License key') },
+ non_empty_password_help: -> {
+ s_(
+ 'DiffblueCover|Leave blank to use your current license key.'
+ )
+ },
+ exposes_secrets: true,
+ required: true,
+ is_secret: true,
+ placeholder: 'XXXX-XXXX-XXXX-XXXX',
+ help: -> {
+ format(
+ s_(
+ 'DiffblueCover|Enter your Diffblue Cover license key or ' \
+ 'go to %{diffblue_link} to obtain a free trial license.'
+ ),
+ diffblue_link: diffblue_link
+ )
+ }
+
+ field :diffblue_access_token_name,
+ section: SECTION_TYPE_CONFIGURATION,
+ title: -> { s_('DiffblueCover|Name') },
+ description: -> { s_('DiffblueCover|Access token name used by Diffblue Cover in pipelines.') },
+ required: true,
+ placeholder: -> { s_('DiffblueCover|My token name') }
+
+ field :diffblue_access_token_secret,
+ section: SECTION_TYPE_CONFIGURATION,
+ type: :password,
+ title: -> { s_('DiffblueCover|Secret') },
+ description: -> { s_('DiffblueCover|Access token secret used by Diffblue Cover in pipelines.') },
+ non_empty_password_title: -> { s_('DiffblueCover|Secret') },
+ non_empty_password_help: -> { s_('DiffblueCover|Leave blank to use your current secret value.') },
+ required: true,
+ is_secret: true,
+ placeholder: 'glpat-XXXXXXXXXXXXXXXXXXXX' # gitleaks:allow
+
+ with_options if: :activated? do
+ validates :diffblue_license_key, presence: true
+ validates :diffblue_access_token_name, presence: true
+ validates :diffblue_access_token_secret, presence: true
+ end
+
+ def self.title
+ 'Diffblue Cover'
+ end
+
+ def self.description
+ s_('DiffblueCover|Automatically write comprehensive, human-like Java unit tests.')
+ end
+
+ def self.to_param
+ 'diffblue_cover'
+ end
+
+ def self.help
+ s_('DiffblueCover|Automatically write comprehensive, human-like Java unit tests.')
+ end
+
+ def avatar_url
+ ActionController::Base.helpers.image_path('illustrations/third-party-logos/integrations-logos/diffblue.svg')
+ end
+
+ def self.supported_events
+ []
+ end
+
+ def sections
+ [
+ {
+ type: SECTION_TYPE_CONNECTION,
+ title: s_('DiffblueCover|Integration details'),
+ description:
+ s_(
+ 'DiffblueCover|Diffblue Cover is a generative AI platform that automatically ' \
+ 'writes comprehensive, human-like Java unit tests. Integrate Diffblue ' \
+ 'Cover into your CI/CD workflow for fully autonomous operation.'
+ )
+ },
+ {
+ type: SECTION_TYPE_CONFIGURATION,
+ title: s_('DiffblueCover|Access token'),
+ description:
+ 'You must have a GitLab access token for Diffblue Cover to access your project. ' \
+ 'Use a GitLab access token with at least the Developer role and ' \
+ 'the <code>api</code> and <code>write_repository</code> permissions.'
+ }
+ ]
+ end
+
+ def execute(_data) end
+
+ def ci_variables
+ return [] unless activated?
+
+ [
+ { key: 'DIFFBLUE_LICENSE_KEY', value: diffblue_license_key, public: false, masked: true },
+ { key: 'DIFFBLUE_ACCESS_TOKEN_NAME', value: diffblue_access_token_name, public: false, masked: true },
+ { key: 'DIFFBLUE_ACCESS_TOKEN', value: diffblue_access_token_secret, public: false, masked: true }
+ ]
+ end
+
+ def testable?
+ false
+ end
+
+ def self.diffblue_link
+ ActionController::Base.helpers.link_to(
+ s_('DiffblueCover|Try Diffblue Cover'),
+ 'https://www.diffblue.com/try-cover/gitlab/',
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ )
+ end
+ end
+end
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index 7ce597389f0..f36170f91d0 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -8,17 +8,20 @@ module Integrations
field :webhook,
section: SECTION_TYPE_CONNECTION,
+ description: -> { _('Discord webhook (for example, `https://discord.com/api/webhooks/…`).') },
help: 'e.g. https://discord.com/api/webhooks/…',
required: true
field :notify_only_broken_pipelines,
type: :checkbox,
- section: SECTION_TYPE_CONFIGURATION
+ section: SECTION_TYPE_CONFIGURATION,
+ description: -> { _('Send notifications for broken pipelines.') }
field :branches_to_be_notified,
type: :select,
section: SECTION_TYPE_CONFIGURATION,
title: -> { s_('Integrations|Branches for which notifications are to be sent') },
+ description: -> { _('Branches to send notifications for. Valid options are `all`, `default`, `protected`, and `default_and_protected`. The default value is `default`.') },
choices: -> { branch_choices }
def self.title
diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb
index 7408f86d231..e5360e58426 100644
--- a/app/models/integrations/external_wiki.rb
+++ b/app/models/integrations/external_wiki.rb
@@ -7,6 +7,7 @@ module Integrations
field :external_wiki_url,
section: SECTION_TYPE_CONNECTION,
title: -> { s_('ExternalWikiService|External wiki URL') },
+ description: -> { s_('ExternalWikiService|URL of the external wiki.') },
placeholder: -> { s_('ExternalWikiService|https://example.com/xxx/wiki/...') },
help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') },
required: true
diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb
index 746f68fdc4c..1d6d563e37f 100644
--- a/app/models/integrations/google_play.rb
+++ b/app/models/integrations/google_play.rb
@@ -18,19 +18,25 @@ module Integrations
field :package_name,
section: SECTION_TYPE_CONNECTION,
placeholder: 'com.example.myapp',
+ description: -> { _('Package name of the app in Google Play.') },
required: true
field :service_account_key_file_name,
section: SECTION_TYPE_CONNECTION,
- required: true
+ required: true,
+ description: -> { _('File name of the Google Play service account key.') }
- field :service_account_key, api_only: true
+ field :service_account_key,
+ required: true,
+ description: -> { _('Google Play service account key.') },
+ api_only: true
field :google_play_protected_refs,
type: :checkbox,
section: SECTION_TYPE_CONFIGURATION,
title: -> { s_('GooglePlayStore|Protected branches and tags only') },
- checkbox_label: -> { s_('GooglePlayStore|Only set variables on protected branches and tags') }
+ description: -> { _('Set variables on protected branches and tags only.') },
+ checkbox_label: -> { s_('GooglePlayStore|Set variables on protected branches and tags only') }
def self.title
s_('GooglePlay|Google Play')
@@ -48,10 +54,10 @@ module Integrations
# rubocop:disable Layout/LineLength
texts = [
- s_("Use the Google Play integration to connect to Google Play with fastlane in CI/CD pipelines."),
- s_("After you enable the integration, the following protected variable is created for CI/CD use:"),
+ s_("Use this integration to connect to Google Play with fastlane in CI/CD pipelines."),
+ s_("After you enable the integration, the following protected variables are created for CI/CD use:"),
variable_list.join('<br>'),
- s_(format("To generate a Google Play service account key and use this integration, see the <a href='%{url}' target='_blank'>integration documentation</a>.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/google_play'))).html_safe
+ s_(format("For more information, see the <a href='%{url}' target='_blank'>documentation</a>.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/google_play'))).html_safe
]
# rubocop:enable Layout/LineLength
diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb
index cc570e49e36..a1621588cd6 100644
--- a/app/models/integrations/harbor.rb
+++ b/app/models/integrations/harbor.rb
@@ -10,6 +10,7 @@ module Integrations
field :url,
title: -> { s_('HarborIntegration|Harbor URL') },
+ description: -> { _('The base URL to the Harbor instance linked to the GitLab project. For example, `https://demo.goharbor.io`.') },
placeholder: 'https://demo.goharbor.io',
help: -> { s_('HarborIntegration|Base URL of the Harbor instance.') },
exposes_secrets: true,
@@ -17,16 +18,19 @@ module Integrations
field :project_name,
title: -> { s_('HarborIntegration|Harbor project name') },
+ description: -> { s_('HarborIntegration|The name of the project in the Harbor instance. For example, `testproject`.') },
help: -> { s_('HarborIntegration|The name of the project in Harbor.') },
required: true
field :username,
title: -> { s_('HarborIntegration|Harbor username') },
+ description: -> { s_('HarborIntegration|The username created in the Harbor interface.') },
required: true
field :password,
type: :password,
title: -> { s_('HarborIntegration|Harbor password') },
+ description: -> { s_('HarborIntegration|The password of the user.') },
help: -> { s_('HarborIntegration|Password for your Harbor username.') },
non_empty_password_title: -> { s_('HarborIntegration|Enter new Harbor password') },
non_empty_password_help: -> { s_('HarborIntegration|Leave blank to use your current password.') },
diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb
index 361ff4afce8..e7be2b2a454 100644
--- a/app/models/integrations/mattermost.rb
+++ b/app/models/integrations/mattermost.rb
@@ -27,7 +27,7 @@ module Integrations
end
def self.webhook_help
- 'http://mattermost.example.com/hooks/'
+ 'http://mattermost.example.com/hooks/...'
end
override :configurable_channels?
diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb
index 29ed563a902..dcbda8d1ed0 100644
--- a/app/models/integrations/mattermost_slash_commands.rb
+++ b/app/models/integrations/mattermost_slash_commands.rb
@@ -8,8 +8,10 @@ module Integrations
field :token,
type: :password,
+ description: -> { _('The Mattermost token.') },
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ required: true,
placeholder: ''
def testable?
diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb
index 9f9614a84fd..0c1fd34fccf 100644
--- a/app/models/integrations/slack.rb
+++ b/app/models/integrations/slack.rb
@@ -18,7 +18,7 @@ module Integrations
end
def self.webhook_help
- 'https://hooks.slack.com/services/…'
+ 'https://hooks.slack.com/services/...'
end
private
diff --git a/app/models/integrations/squash_tm.rb b/app/models/integrations/squash_tm.rb
index 1b4ab152b1d..7aaef0c22cc 100644
--- a/app/models/integrations/squash_tm.rb
+++ b/app/models/integrations/squash_tm.rb
@@ -7,12 +7,14 @@ module Integrations
field :url,
placeholder: 'https://your-instance.squashcloud.io/squash/plugin/xsquash4gitlab/webhook/issue',
title: -> { s_('SquashTmIntegration|Squash TM webhook URL') },
+ description: -> { s_('URL of the Squash TM webhook.') },
exposes_secrets: true,
required: true
field :token,
type: :password,
title: -> { s_('SquashTmIntegration|Secret token (optional)') },
+ description: -> { s_('Secret token.') },
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
required: false
diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb
index 932e588a829..4d825adb961 100644
--- a/app/models/integrations/youtrack.rb
+++ b/app/models/integrations/youtrack.rb
@@ -31,8 +31,8 @@ module Integrations
'youtrack'
end
- def fields
- super.select { _1.name.in?(%w[project_url issues_url]) }
+ def self.fields
+ super.select { %w[project_url issues_url].include?(_1.name) }
end
end
end
diff --git a/app/models/issue_email_participant.rb b/app/models/issue_email_participant.rb
index 9d7e2afa1d9..bb03b3d72e6 100644
--- a/app/models/issue_email_participant.rb
+++ b/app/models/issue_email_participant.rb
@@ -3,6 +3,7 @@
class IssueEmailParticipant < ApplicationRecord
include BulkInsertSafe
include Presentable
+ include CaseSensitivity
belongs_to :issue
@@ -10,6 +11,8 @@ class IssueEmailParticipant < ApplicationRecord
validates :issue, presence: true
validate :validate_email_format
+ scope :with_emails, ->(emails) { iwhere(email: emails) }
+
def validate_email_format
self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
end
diff --git a/app/models/jira_connect_subscription.rb b/app/models/jira_connect_subscription.rb
index c74f75b2d8e..8ff89560f09 100644
--- a/app/models/jira_connect_subscription.rb
+++ b/app/models/jira_connect_subscription.rb
@@ -8,5 +8,5 @@ class JiraConnectSubscription < ApplicationRecord
validates :namespace, presence: true, uniqueness: { scope: :jira_connect_installation_id, message: 'has already been added' }
scope :preload_namespace_route, -> { preload(namespace: :route) }
- scope :for_project, -> (project) { where(namespace_id: project.namespace.self_and_ancestors) }
+ scope :for_project, -> (project) { where(namespace_id: project.namespace.self_and_ancestor_ids) }
end
diff --git a/app/models/label.rb b/app/models/label.rb
index d0d278b68fd..8fff42abd58 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -46,7 +46,6 @@ class Label < ApplicationRecord
scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) }
scope :with_lock_on_merge, -> { where(lock_on_merge: true) }
scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) }
- scope :on_board, ->(board_id) { with_lists_and_board.where(boards: { id: board_id }) }
scope :order_name_asc, -> { reorder(title: :asc) }
scope :order_name_desc, -> { reorder(title: :desc) }
scope :subscribed_by, ->(user_id) { joins(:subscriptions).where(subscriptions: { user_id: user_id, subscribed: true }) }
@@ -152,10 +151,6 @@ class Label < ApplicationRecord
nil
end
- def self.ids_on_board(board_id)
- on_board(board_id).pluck(:label_id)
- end
-
# Searches for labels with a matching title or description.
#
# This method uses ILIKE on PostgreSQL.
diff --git a/app/models/member.rb b/app/models/member.rb
index 25dae518406..8bec64932b3 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -276,9 +276,11 @@ class Member < ApplicationRecord
after_create :send_invite, if: :invite?, unless: :importing?
after_create :create_notification_setting, unless: [:pending?, :importing?]
after_create :post_create_hook, unless: [:pending?, :importing?], if: :hook_prerequisites_met?
+ after_create :update_two_factor_requirement, unless: :invite?
after_update :post_update_hook, unless: [:pending?, :importing?], if: :hook_prerequisites_met?
after_destroy :destroy_notification_setting
after_destroy :post_destroy_hook, unless: :pending?, if: :hook_prerequisites_met?
+ after_destroy :update_two_factor_requirement, unless: :invite?
after_save :log_invitation_token_cleanup
after_commit :send_request, if: :request?, unless: :importing?, on: [:create]
@@ -286,6 +288,14 @@ class Member < ApplicationRecord
refresh_member_authorized_projects
end
+ after_create if: :update_organization_user? do
+ Organizations::OrganizationUser.upsert(
+ { organization_id: source.organization_id, user_id: user_id, access_level: :default },
+ unique_by: [:organization_id, :user_id],
+ on_duplicate: :skip # Do not change access_level, could make :owner :default
+ )
+ end
+
attribute :notification_level, default: -> { NotificationSetting.levels[:global] }
class << self
@@ -486,7 +496,10 @@ class Member < ApplicationRecord
strong_memoize(:highest_group_member) do
next unless user_id && source&.ancestors&.any?
- GroupMember.where(source: source.ancestors, user_id: user_id).order(:access_level).last
+ GroupMember
+ .where(source: source.ancestors, user_id: user_id)
+ .non_request
+ .order(:access_level).last
end
end
@@ -498,6 +511,17 @@ class Member < ApplicationRecord
created_by&.name
end
+ def update_two_factor_requirement
+ return unless source.is_a?(Group)
+ return unless user
+
+ Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction(
+ %w[users user_details user_preferences], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424288'
+ ) do
+ user.update_two_factor_requirement
+ end
+ end
+
private
# TODO: https://gitlab.com/groups/gitlab-org/-/epics/7054
@@ -513,7 +537,7 @@ class Member < ApplicationRecord
end
def send_invite
- # override in subclass
+ run_after_commit_or_now { notification_service.invite_member(self, @raw_invite_token) }
end
def send_request
@@ -522,10 +546,26 @@ class Member < ApplicationRecord
end
def post_create_hook
+ # The creator of a personal project gets added as a `ProjectMember`
+ # with `OWNER` access during creation of a personal project,
+ # but we do not want to trigger notifications to the same person who created the personal project.
+ unless source.is_a?(Project) && source.personal_namespace_holder?(user)
+ event_service.join_source(source, user)
+ run_after_commit_or_now { notification_service.new_member(self) }
+ end
+
system_hook_service.execute_hooks_for(self, :create)
end
def post_update_hook
+ if saved_change_to_access_level?
+ run_after_commit { notification_service.updated_member_access_level(self) }
+ end
+
+ if saved_change_to_expires_at?
+ run_after_commit { notification_service.updated_member_expiration(self) }
+ end
+
system_hook_service.execute_hooks_for(self, :update)
end
@@ -548,6 +588,12 @@ class Member < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def after_accept_invite
+ run_after_commit_or_now do
+ notification_service.accept_invite(self)
+ end
+
+ update_two_factor_requirement
+
post_create_hook
end
@@ -578,7 +624,12 @@ class Member < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def notifiable_options
- {}
+ case source
+ when Group
+ { group: source }
+ when Project
+ { project: source }
+ end
end
def higher_access_level_than_group
@@ -617,12 +668,22 @@ class Member < ApplicationRecord
user&.project_bot?
end
+ def update_organization_user?
+ return false unless Feature.enabled?(:update_organization_users, source.root_ancestor, type: :gitlab_com_derisk)
+
+ !invite? && source.organization.present?
+ end
+
def log_invitation_token_cleanup
return true unless Gitlab.com? && invite? && invite_accepted_at?
error = StandardError.new("Invitation token is present but invite was already accepted!")
Gitlab::ErrorTracking.track_exception(error, attributes.slice(%w["invite_accepted_at created_at source_type source_id user_id id"]))
end
+
+ def event_service
+ EventCreateService.new # rubocop:todo CodeReuse/ServiceClass -- Legacy, convert to value object eventually
+ end
end
Member.prepend_mod_with('Member')
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index e3ead1b04d0..b04fb1f6768 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -18,25 +18,12 @@ class GroupMember < Member
default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope
- scope :of_groups, ->(groups) { where(source_id: groups&.select(:id)) }
+ scope :of_groups, ->(groups) { where(source_id: groups) }
scope :of_ldap_type, -> { where(ldap: true) }
scope :count_users_by_group_id, -> { group(:source_id).count }
- after_create :update_two_factor_requirement, unless: :invite?
- after_destroy :update_two_factor_requirement, unless: :invite?
-
attr_accessor :last_owner
- def update_two_factor_requirement
- return unless user
-
- Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction(
- %w[users user_details user_preferences], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424288'
- ) do
- user.update_two_factor_requirement
- end
- end
-
# For those who get to see a modal with a role dropdown, here are the options presented
def self.permissible_access_level_roles(_, _)
# This method is a stopgap in preparation for https://gitlab.com/gitlab-org/gitlab/-/issues/364087
@@ -56,10 +43,6 @@ class GroupMember < Member
Group.sti_name
end
- def notifiable_options
- { group: group }
- end
-
def last_owner_of_the_group?
return false unless access_level == Gitlab::Access::OWNER
return last_owner unless last_owner.nil?
@@ -87,40 +70,6 @@ class GroupMember < Member
super
end
-
- def send_invite
- run_after_commit_or_now { notification_service.invite_group_member(self, @raw_invite_token) }
-
- super
- end
-
- def post_create_hook
- run_after_commit_or_now { notification_service.new_group_member(self) }
-
- super
- end
-
- def post_update_hook
- if saved_change_to_access_level?
- run_after_commit { notification_service.update_group_member(self) }
- end
-
- if saved_change_to_expires_at?
- run_after_commit { notification_service.updated_group_member_expiration(self) }
- end
-
- super
- end
-
- def after_accept_invite
- run_after_commit_or_now do
- notification_service.accept_group_invite(self)
- end
-
- update_two_factor_requirement
-
- super
- end
end
GroupMember.prepend_mod_with('GroupMember')
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index f52fef9e247..a2927238e54 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -72,10 +72,6 @@ class ProjectMember < Member
source
end
- def notifiable_options
- { project: project }
- end
-
def holder_of_the_personal_namespace?
project.personal_namespace_holder?(user)
end
@@ -116,32 +112,6 @@ class ProjectMember < Member
self.member_namespace_id = project&.project_namespace_id
end
- def send_invite
- run_after_commit_or_now { notification_service.invite_project_member(self, @raw_invite_token) }
-
- super
- end
-
- def post_create_hook
- # The creator of a personal project gets added as a `ProjectMember`
- # with `OWNER` access during creation of a personal project,
- # but we do not want to trigger notifications to the same person who created the personal project.
- unless project.personal_namespace_holder?(user)
- event_service.join_project(self.project, self.user)
- run_after_commit_or_now { notification_service.new_project_member(self) }
- end
-
- super
- end
-
- def post_update_hook
- if saved_change_to_access_level?
- run_after_commit { notification_service.update_project_member(self) }
- end
-
- super
- end
-
def post_destroy_hook
if expired?
event_service.expired_leave_project(self.project, self.user)
@@ -151,20 +121,6 @@ class ProjectMember < Member
super
end
-
- def after_accept_invite
- run_after_commit_or_now do
- notification_service.accept_project_invite(self)
- end
-
- super
- end
-
- # rubocop: disable CodeReuse/ServiceClass
- def event_service
- EventCreateService.new
- end
- # rubocop: enable CodeReuse/ServiceClass
end
ProjectMember.prepend_mod_with('ProjectMember')
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index f9af342f47f..ae68a36c8d2 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1716,8 +1716,6 @@ class MergeRequest < ApplicationRecord
actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test))
end
- # rubocop: disable Metrics/AbcSize
- # Delete a rubocop annotation once FF truncate_ci_merge_request_description is cleaned up
def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s)
@@ -1730,14 +1728,9 @@ class MergeRequest < ApplicationRecord
variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED', value: ProtectedBranch.protected?(target_project, target_branch).to_s)
variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title)
- if ::Feature.enabled?(:truncate_ci_merge_request_description)
- mr_description, mr_description_truncated = truncate_mr_description
- variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: mr_description)
- variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION_IS_TRUNCATED', value: mr_description_truncated)
- else
- variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: description)
- end
-
+ mr_description, mr_description_truncated = truncate_mr_description
+ variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: mr_description)
+ variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION_IS_TRUNCATED', value: mr_description_truncated)
variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.present?
variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone
variables.append(key: 'CI_MERGE_REQUEST_LABELS', value: label_names.join(',')) if labels.present?
@@ -1745,8 +1738,6 @@ class MergeRequest < ApplicationRecord
variables.concat(source_project_variables)
end
end
- # rubocop: enable Metrics/AbcSize
- # Delete a rubocop annotation once FF truncate_ci_merge_request_description is cleaned up
def compare_test_reports
unless has_test_reports?
@@ -2102,8 +2093,12 @@ class MergeRequest < ApplicationRecord
true
end
+ def allows_multiple_assignees?
+ project.allows_multiple_merge_request_assignees?
+ end
+
def allows_multiple_reviewers?
- false
+ project.allows_multiple_merge_request_reviewers?
end
def supports_assignee?
@@ -2198,6 +2193,8 @@ class MergeRequest < ApplicationRecord
attr_accessor :skip_fetch_ref
def merge_base_pipelines
+ return ::Ci::Pipeline.none unless actual_head_pipeline&.target_sha
+
target_branch_pipelines_for(sha: actual_head_pipeline.target_sha)
end
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index 3c592c0008f..6d6c0ee07af 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class MergeRequest::Metrics < ApplicationRecord
- include DatabaseEventTracking
-
belongs_to :merge_request, inverse_of: :metrics
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
belongs_to :latest_closed_by, class_name: 'User'
@@ -33,8 +31,7 @@ class MergeRequest::Metrics < ApplicationRecord
RETURNING id, #{inserted_columns.join(', ')}
SQL
- result = connection.execute(sql).first
- new(result).publish_database_create_event
+ connection.execute(sql)
end
end
@@ -48,31 +45,6 @@ class MergeRequest::Metrics < ApplicationRecord
with_valid_time_to_merge
.pick(time_to_merge_expression)
end
-
- SNOWPLOW_ATTRIBUTES = %i[
- id
- merge_request_id
- latest_build_started_at
- latest_build_finished_at
- first_deployed_to_production_at
- merged_at
- created_at
- updated_at
- pipeline_id
- merged_by_id
- latest_closed_by_id
- latest_closed_at
- first_comment_at
- first_commit_at
- last_commit_at
- diff_size
- modified_paths_size
- commits_count
- first_approved_at
- first_reassigned_at
- added_lines
- removed_lines
- ].freeze
end
MergeRequest::Metrics.prepend_mod_with('MergeRequest::Metrics')
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 0b183131a47..47102418152 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -196,6 +196,7 @@ class MergeRequestDiff < ApplicationRecord
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
after_create_commit :set_as_latest_diff, unless: :importing?
+ after_create_commit :trigger_diff_generated_subscription, unless: :importing?
after_save :update_external_diff_store
after_save :set_count_columns
@@ -258,6 +259,12 @@ class MergeRequestDiff < ApplicationRecord
.update_all(latest_merge_request_diff_id: self.id)
end
+ def trigger_diff_generated_subscription
+ return unless Feature.enabled?(:merge_request_diff_generated_subscription, merge_request.project)
+
+ GraphqlTriggers.merge_request_diff_generated(merge_request)
+ end
+
def ensure_commit_shas
self.start_commit_sha ||= merge_request.target_branch_sha
@@ -439,6 +446,8 @@ class MergeRequestDiff < ApplicationRecord
)
end
+ diff_options[:generated_files] = comparison.generated_files if diff_options[:collapse_generated]
+
Gitlab::Metrics.measure(:diffs_comparison) do
comparison.diffs(diff_options)
end
@@ -452,18 +461,25 @@ class MergeRequestDiff < ApplicationRecord
fetching_repository_diffs({}) do |comparison|
reorder_diff_files!
+ collapse_generated = Feature.enabled?(:collapse_generated_diff_files, project)
+ diff_options = { collapse_generated: collapse_generated }
+
collection = Gitlab::Diff::FileCollection::PaginatedMergeRequestDiff.new(
self,
page,
- per_page
+ per_page,
+ diff_options
)
if comparison
+ diff_options[:generated_files] = comparison.generated_files if collapse_generated
+
comparison.diffs(
- paths: collection.diff_paths,
- page: collection.current_page,
- per_page: collection.limit_value,
- count: collection.total_count
+ diff_options.merge(
+ paths: collection.diff_paths,
+ page: collection.current_page,
+ per_page: collection.limit_value,
+ count: collection.total_count)
)
else
collection
diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb
index ad6c6b7b3bf..456c23df0e0 100644
--- a/app/models/ml/experiment.rb
+++ b/app/models/ml/experiment.rb
@@ -3,6 +3,7 @@
module Ml
class Experiment < ApplicationRecord
include AtomicInternalId
+ include Sortable
PACKAGE_PREFIX = 'ml_experiment_'
@@ -15,6 +16,8 @@ module Ml
has_many :candidates, class_name: 'Ml::Candidate'
has_many :metadata, class_name: 'Ml::ExperimentMetadata'
+ scope :including_project, -> { includes(:project) }
+ scope :by_project, ->(project) { where(project: project) }
scope :with_candidate_count, -> {
left_outer_joins(:candidates)
.select("ml_experiments.*, count(ml_candidates.id) as candidate_count")
diff --git a/app/models/ml/model_metadata.rb b/app/models/ml/model_metadata.rb
index 9c4273c629c..9695621e47d 100644
--- a/app/models/ml/model_metadata.rb
+++ b/app/models/ml/model_metadata.rb
@@ -3,7 +3,7 @@
module Ml
class ModelMetadata < ApplicationRecord
validates :name,
- length: { maximum: 250 },
+ length: { maximum: 255 },
presence: true,
uniqueness: { scope: :model, message: ->(metadata, _) { "'#{metadata.name}' already taken" } }
validates :value, length: { maximum: 5000 }, presence: true
diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb
index 58da57f27d6..1b3313c803a 100644
--- a/app/models/ml/model_version.rb
+++ b/app/models/ml/model_version.rb
@@ -21,12 +21,25 @@ module Ml
belongs_to :project
belongs_to :package, class_name: 'Packages::MlModel::Package', optional: true
has_one :candidate, class_name: 'Ml::Candidate'
+ has_many :metadata, class_name: 'Ml::ModelVersionMetadata'
delegate :name, to: :model
scope :order_by_model_id_id_desc, -> { order('model_id, id DESC') }
scope :latest_by_model, -> { order_by_model_id_id_desc.select('DISTINCT ON (model_id) *') }
+ def add_metadata(metadata_key_value)
+ return unless metadata_key_value.present?
+
+ metadata_key_value.each do |entry|
+ metadata.create!(
+ project_id: project_id,
+ name: entry[:key],
+ value: entry[:value]
+ )
+ end
+ end
+
class << self
def find_or_create!(model, version, package, description)
create_with(package: package, description: description)
diff --git a/app/models/ml/model_version_metadata.rb b/app/models/ml/model_version_metadata.rb
new file mode 100644
index 00000000000..61810786091
--- /dev/null
+++ b/app/models/ml/model_version_metadata.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Ml
+ class ModelVersionMetadata < ApplicationRecord
+ validates :name,
+ length: { maximum: 255 },
+ presence: true,
+ uniqueness: { scope: :model_version, message: ->(metadata, _) { "'#{metadata.name}' already taken" } }
+ validates :value, length: { maximum: 5000 }, presence: true
+
+ belongs_to :project, optional: false
+ belongs_to :model_version, class_name: 'Ml::ModelVersion', optional: false
+ end
+end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index c665c2278a5..238556f0cf0 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -12,6 +12,7 @@ class Namespace < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include Namespaces::Traversal::Recursive
include Namespaces::Traversal::Linear
+ include Namespaces::Traversal::Cached
include EachBatch
include BlocksUnsafeSerialization
include Ci::NamespaceSettings
@@ -45,6 +46,7 @@ class Namespace < ApplicationRecord
cache_markdown_field :description, pipeline: :description
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :non_archived_projects, -> { where.not(archived: true) }, class_name: 'Project'
has_many :project_statistics
has_one :namespace_settings, inverse_of: :namespace, class_name: 'NamespaceSetting', autosave: true
has_one :ci_cd_settings, inverse_of: :namespace, class_name: 'NamespaceCiCdSetting', autosave: true
@@ -55,6 +57,9 @@ class Namespace < ApplicationRecord
has_one :namespace_ldap_settings, inverse_of: :namespace, class_name: 'Namespaces::LdapSetting', autosave: true
+ has_one :namespace_descendants, class_name: 'Namespaces::Descendants'
+ accepts_nested_attributes_for :namespace_descendants, allow_destroy: true
+
has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace'
has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
has_many :pending_builds, class_name: 'Ci::PendingBuild'
@@ -263,6 +268,28 @@ class Namespace < ApplicationRecord
end
end
+ # This should be kept in sync with the frontend filtering in
+ # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L68 and
+ # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L1053
+ def gfm_autocomplete_search(query)
+ without_project_namespaces
+ .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046")
+ .joins(:route)
+ .where(
+ "REPLACE(routes.name, ' ', '') ILIKE :pattern OR routes.path ILIKE :pattern",
+ pattern: "%#{sanitize_sql_like(query)}%"
+ )
+ .order(
+ Arel.sql(sanitize_sql(
+ [
+ "CASE WHEN starts_with(REPLACE(routes.name, ' ', ''), :pattern) OR starts_with(routes.path, :pattern) THEN 1 ELSE 2 END",
+ { pattern: query }
+ ]
+ )),
+ 'routes.path'
+ )
+ end
+
def clean_path(path, limited_to: Namespace.all)
slug = Gitlab::Slug::Path.new(path).generate
path = Namespaces::RandomizedSuffixPath.new(slug)
diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb
index a5a393ad8a2..5f5bef4409c 100644
--- a/app/models/namespace/package_setting.rb
+++ b/app/models/namespace/package_setting.rb
@@ -12,7 +12,7 @@ class Namespace::PackageSetting < ApplicationRecord
PackageSettingNotImplemented = Class.new(StandardError)
- PACKAGES_WITH_SETTINGS = %w[maven generic nuget].freeze
+ PACKAGES_WITH_SETTINGS = %w[maven generic nuget terraform_module].freeze
belongs_to :namespace, inverse_of: :package_setting_relation
@@ -24,6 +24,14 @@ class Namespace::PackageSetting < ApplicationRecord
validates :nuget_duplicates_allowed, inclusion: { in: [true, false] }
validates :nuget_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 }
validates :nuget_symbol_server_enabled, inclusion: { in: [true, false] }
+ validates :terraform_module_duplicates_allowed, inclusion: { in: [true, false] }
+ validates :terraform_module_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 }
+
+ scope :namespace_id_in, ->(namespace_ids) { where(namespace_id: namespace_ids) }
+ scope :with_terraform_module_duplicates_allowed_or_exception_regex, -> do
+ where(terraform_module_duplicates_allowed: true)
+ .or(where.not(terraform_module_duplicate_exception_regex: ''))
+ end
class << self
def duplicates_allowed?(package)
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 0263942116d..e61e5a7f37e 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -4,9 +4,13 @@ class NamespaceSetting < ApplicationRecord
include CascadingNamespaceSettingAttribute
include Sanitizable
include ChronicDurationAttribute
+ include IgnorableColumns
+
+ ignore_column :project_import_level, remove_with: '16.10', remove_after: '2024-02-22'
cascading_attr :delayed_project_removal
cascading_attr :toggle_security_policy_custom_ci
+ cascading_attr :toggle_security_policies_policy_scope
belongs_to :namespace, inverse_of: :namespace_settings
diff --git a/app/models/namespaces/descendants.rb b/app/models/namespaces/descendants.rb
new file mode 100644
index 00000000000..8444cea9848
--- /dev/null
+++ b/app/models/namespaces/descendants.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class Descendants < ApplicationRecord
+ self.table_name = :namespace_descendants
+
+ belongs_to :namespace
+
+ validates :namespace_id, uniqueness: true
+
+ def self.expire_for(namespace_ids)
+ # Union:
+ # - Look up all parent ids including the given ids via traversal_ids
+ # - Include the given ids to handle the case when the namespaces records are already deleted
+ sql = <<~SQL
+ WITH namespace_ids AS MATERIALIZED (
+ (
+ SELECT ids.id
+ FROM namespaces, UNNEST(traversal_ids) ids(id)
+ WHERE namespaces.id IN (?)
+ ) UNION
+ (SELECT UNNEST(ARRAY[?]) AS id)
+ )
+ UPDATE namespace_descendants SET outdated_at = ? FROM namespace_ids WHERE namespace_descendants.namespace_id = namespace_ids.id
+ SQL
+
+ connection.execute(sanitize_sql_array([sql, namespace_ids, namespace_ids, Time.current]))
+ end
+ end
+end
diff --git a/app/models/namespaces/traversal/cached.rb b/app/models/namespaces/traversal/cached.rb
new file mode 100644
index 00000000000..55eaaa4667e
--- /dev/null
+++ b/app/models/namespaces/traversal/cached.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Namespaces
+ module Traversal
+ module Cached
+ extend ActiveSupport::Concern
+ extend Gitlab::Utils::Override
+
+ included do
+ after_destroy :invalidate_descendants_cache
+ end
+
+ private
+
+ override :sync_traversal_ids
+ def sync_traversal_ids
+ super
+ return if is_a?(Namespaces::UserNamespace)
+ return unless Feature.enabled?(:namespace_descendants_cache_expiration, self, type: :gitlab_com_derisk)
+
+ ids = [id]
+ ids.concat((saved_changes[:parent_id] - [parent_id]).compact) if saved_changes[:parent_id]
+ Namespaces::Descendants.expire_for(ids)
+ end
+
+ def invalidate_descendants_cache
+ return if is_a?(Namespaces::UserNamespace)
+ return unless Feature.enabled?(:namespace_descendants_cache_expiration, self, type: :gitlab_com_derisk)
+
+ Namespaces::Descendants.expire_for([parent_id, id].compact)
+ end
+ end
+ end
+end
diff --git a/app/models/onboarding/completion.rb b/app/models/onboarding/completion.rb
index afbd671f82e..53781e112ae 100644
--- a/app/models/onboarding/completion.rb
+++ b/app/models/onboarding/completion.rb
@@ -3,7 +3,6 @@
module Onboarding
class Completion
include Gitlab::Utils::StrongMemoize
- include Gitlab::Experiment::Dsl
ACTION_PATHS = [
:pipeline_created,
@@ -12,6 +11,7 @@ module Onboarding
:code_owners_enabled,
:issue_created,
:git_write,
+ :code_added,
:merge_request_created,
:user_added,
:license_scanning_run,
@@ -35,20 +35,11 @@ module Onboarding
end
def completed?(column)
- if column == :code_added
- repository.commit_count > 1 || repository.branch_count > 1
- else
- attributes[column].present?
- end
+ attributes[column].present?
end
private
- def repository
- project.repository
- end
- strong_memoize_attr :repository
-
def attributes
onboarding_progress.attributes.symbolize_keys
end
@@ -60,8 +51,7 @@ module Onboarding
strong_memoize_attr :onboarding_progress
def action_columns
- [:code_added] +
- ACTION_PATHS.map { |action_key| ::Onboarding::Progress.column_name(action_key) }
+ ACTION_PATHS.map { |action_key| ::Onboarding::Progress.column_name(action_key) }
end
strong_memoize_attr :action_columns
diff --git a/app/models/onboarding/progress.rb b/app/models/onboarding/progress.rb
index 83030732c6a..b6628843821 100644
--- a/app/models/onboarding/progress.rb
+++ b/app/models/onboarding/progress.rb
@@ -32,7 +32,8 @@ module Onboarding
:secure_api_fuzzing_run,
:secure_cluster_image_scanning_run,
:license_scanning_run,
- :promote_ultimate_features
+ :promote_ultimate_features,
+ :code_added
].freeze
scope :incomplete_actions, ->(actions) do
diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb
index 764378a5d19..df6f0109d57 100644
--- a/app/models/organizations/organization.rb
+++ b/app/models/organizations/organization.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Organizations
- class Organization < ApplicationRecord
+ class Organization < MainClusterwide::ApplicationRecord
DEFAULT_ORGANIZATION_ID = 1
scope :without_default, -> { where.not(id: DEFAULT_ORGANIZATION_ID) }
@@ -16,6 +16,8 @@ module Organizations
has_one :organization_detail, inverse_of: :organization, autosave: true
has_many :organization_users, inverse_of: :organization
+ # if considering disable_joins on the below see:
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/140343#note_1705047949
has_many :users, through: :organization_users, inverse_of: :organizations
validates :name,
@@ -28,7 +30,7 @@ module Organizations
'organizations/path': true,
length: { minimum: 2, maximum: 255 }
- delegate :description, :avatar, :avatar_url, to: :organization_detail
+ delegate :description, :description_html, :avatar, :avatar_url, :remove_avatar!, to: :organization_detail
accepts_nested_attributes_for :organization_detail
@@ -52,6 +54,10 @@ module Organizations
organization_users.exists?(user: user)
end
+ def owner?(user)
+ organization_users.owners.exists?(user: user)
+ end
+
def web_url(only_path: nil)
Gitlab::UrlBuilder.build(self, only_path: only_path)
end
diff --git a/app/models/organizations/organization_detail.rb b/app/models/organizations/organization_detail.rb
index b69ec5eae76..018e7579c5b 100644
--- a/app/models/organizations/organization_detail.rb
+++ b/app/models/organizations/organization_detail.rb
@@ -6,7 +6,7 @@ module Organizations
include Avatarable
include WithUploads
- cache_markdown_field :description
+ cache_markdown_field :description, pipeline: :description
belongs_to :organization, inverse_of: :organization_detail
diff --git a/app/models/organizations/organization_user.rb b/app/models/organizations/organization_user.rb
index 5aa1133b017..9e06870dcc6 100644
--- a/app/models/organizations/organization_user.rb
+++ b/app/models/organizations/organization_user.rb
@@ -4,5 +4,17 @@ module Organizations
class OrganizationUser < ApplicationRecord
belongs_to :organization, inverse_of: :organization_users, optional: false
belongs_to :user, inverse_of: :organization_users, optional: false
+
+ validates :user, uniqueness: { scope: :organization_id }
+ validates :access_level, presence: true
+
+ enum access_level: {
+ # Until we develop more access_levels, we really don't know if the default access_level will be what we think of
+ # as a guest. For now, we'll set to same value as guest, but call it default to denote the current ambivalence.
+ default: Gitlab::Access::GUEST,
+ owner: Gitlab::Access::OWNER
+ }
+
+ scope :owners, -> { where(access_level: Gitlab::Access::OWNER) }
end
end
diff --git a/app/models/pages/project_settings.rb b/app/models/pages/project_settings.rb
new file mode 100644
index 00000000000..96e5bb8e98e
--- /dev/null
+++ b/app/models/pages/project_settings.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Pages
+ class ProjectSettings
+ def initialize(project)
+ @project = project
+ end
+
+ def url = url_builder.pages_url(with_unique_domain: true)
+
+ def deployments = project.pages_deployments.active
+
+ def unique_domain_enabled? = project.project_setting.pages_unique_domain_enabled?
+
+ def force_https? = project.pages_https_only?
+
+ private
+
+ attr_reader :project
+
+ def url_builder
+ @url_builder ||= ::Gitlab::Pages::UrlBuilder.new(project)
+ end
+ end
+end
diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb
index e8b186234af..a360b705805 100644
--- a/app/models/pages_deployment.rb
+++ b/app/models/pages_deployment.rb
@@ -68,6 +68,14 @@ class PagesDeployment < ApplicationRecord
update(deleted_at: Time.now.utc)
end
+ def url
+ base_url = ::Gitlab::Pages::UrlBuilder
+ .new(project)
+ .pages_url(with_unique_domain: true)
+
+ File.join(base_url.to_s, path_prefix.to_s)
+ end
+
private
def set_size
diff --git a/app/models/project.rb b/app/models/project.rb
index 7b996457c0d..8f82a947ba6 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -208,6 +208,7 @@ class Project < ApplicationRecord
has_one :custom_issue_tracker_integration, class_name: 'Integrations::CustomIssueTracker'
has_one :datadog_integration, class_name: 'Integrations::Datadog'
has_one :container_registry_data_repair_detail, class_name: 'ContainerRegistry::DataRepairDetail'
+ has_one :diffblue_cover_integration, class_name: 'Integrations::DiffblueCover'
has_one :discord_integration, class_name: 'Integrations::Discord'
has_one :drone_ci_integration, class_name: 'Integrations::DroneCi'
has_one :emails_on_push_integration, class_name: 'Integrations::EmailsOnPush'
@@ -334,7 +335,7 @@ class Project < ApplicationRecord
has_many :authorized_users, -> { allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') },
through: :project_authorizations, source: :user, class_name: 'User'
- has_many :project_members, -> { where(requested_at: nil) },
+ has_many :project_members, -> { non_request },
as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :project_members
has_many :namespace_members, ->(project) { where(requested_at: nil).unscope(where: %i[source_id source_type]) },
@@ -508,6 +509,7 @@ class Project < ApplicationRecord
delegate :members, prefix: true
delegate :add_member, :add_members, :member?
delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role
+ delegate :has_user?
end
with_options to: :namespace do
@@ -749,6 +751,7 @@ class Project < ApplicationRecord
preload(:project_feature, :route, namespace: [:route, :owner])
}
+ scope :with_name, -> (name) { where(name: name) }
scope :created_by, -> (user) { where(creator: user) }
scope :imported_from, -> (type) { where(import_type: type) }
scope :imported, -> { where.not(import_type: nil) }
@@ -3205,6 +3208,21 @@ class Project < ApplicationRecord
end
strong_memoize_attr :code_suggestions_enabled?
+ # Overridden in EE
+ def allows_multiple_merge_request_assignees?
+ false
+ end
+
+ # Overridden in EE
+ def allows_multiple_merge_request_reviewers?
+ false
+ end
+
+ # Overridden in EE
+ def on_demand_dast_available?
+ false
+ end
+
private
# overridden in EE
@@ -3226,8 +3244,11 @@ class Project < ApplicationRecord
if @topic_list != self.topic_list
self.topics.delete_all
- self.topics = @topic_list.map do |topic|
- Projects::Topic.where('lower(name) = ?', topic.downcase).order(total_projects_count: :desc).first_or_create(name: topic, title: topic)
+ self.topics = @topic_list.map do |topic_name|
+ Projects::Topic
+ .where('lower(name) = ?', topic_name.downcase)
+ .order(total_projects_count: :desc)
+ .first_or_create(name: topic_name, title: topic_name, slug: Gitlab::Slug::Path.new(topic_name).generate)
end
end
@@ -3438,7 +3459,7 @@ class Project < ApplicationRecord
def check_project_export_limit!
return if Gitlab::CurrentSettings.current_application_settings.max_export_size == 0
- if self.statistics.storage_size > Gitlab::CurrentSettings.current_application_settings.max_export_size.megabytes
+ if self.statistics.export_size > Gitlab::CurrentSettings.current_application_settings.max_export_size.megabytes
raise ExportLimitExceeded, _('The project size exceeds the export limit.')
end
end
diff --git a/app/models/project_authorizations/changes.rb b/app/models/project_authorizations/changes.rb
index ac52bdfdb07..26f5366ad5e 100644
--- a/app/models/project_authorizations/changes.rb
+++ b/app/models/project_authorizations/changes.rb
@@ -21,6 +21,7 @@ module ProjectAuthorizations
@authorizations_to_add = []
@affected_project_ids = Set.new
@removed_user_ids = Set.new
+ @added_user_ids = Set.new
yield self
end
@@ -61,6 +62,7 @@ module ProjectAuthorizations
def add_authorizations
insert_all_in_batches(authorizations_to_add)
@affected_project_ids += authorizations_to_add.pluck(:project_id)
+ @added_user_ids += authorizations_to_add.pluck(:user_id)
end
def delete_authorizations_for_user
@@ -139,23 +141,51 @@ module ProjectAuthorizations
end
def publish_events
+ publish_changed_event
+ publish_removed_event
+ publish_added_event
+ end
+
+ def publish_changed_event
+ # This event is used to add policy approvers to approval rules by re-syncing all project policies which is costly.
+ # If the feature flag below is enabled, the policies won't be re-synced and
+ # the approvers will be added via `AuthorizationsAddedEvent`.
+ return if ::Feature.enabled?(:add_policy_approvers_to_rules)
+
@affected_project_ids.each do |project_id|
::Gitlab::EventStore.publish(
::ProjectAuthorizations::AuthorizationsChangedEvent.new(data: { project_id: project_id })
)
end
- return if ::Feature.disabled?(:user_approval_rules_removal) || @removed_user_ids.blank?
+ end
- @affected_project_ids.each do |project_id|
- @removed_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).each do |user_ids_batch|
- ::Gitlab::EventStore.publish(
- ::ProjectAuthorizations::AuthorizationsRemovedEvent.new(data: {
- project_id: project_id,
- user_ids: user_ids_batch
- })
- )
+ def publish_removed_event
+ return if @removed_user_ids.none?
+
+ events = @affected_project_ids.flat_map do |project_id|
+ @removed_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).map do |user_ids_batch|
+ ::ProjectAuthorizations::AuthorizationsRemovedEvent.new(data: {
+ project_id: project_id,
+ user_ids: user_ids_batch
+ })
+ end
+ end
+ ::Gitlab::EventStore.publish_group(events)
+ end
+
+ def publish_added_event
+ return if ::Feature.disabled?(:add_policy_approvers_to_rules)
+ return if @added_user_ids.none?
+
+ events = @affected_project_ids.flat_map do |project_id|
+ @added_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).map do |user_ids_batch|
+ ::ProjectAuthorizations::AuthorizationsAddedEvent.new(data: {
+ project_id: project_id,
+ user_ids: user_ids_batch
+ })
end
end
+ ::Gitlab::EventStore.publish_group(events)
end
end
end
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 942f20f6e5e..f89894b77a8 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -145,6 +145,11 @@ class ProjectStatistics < ApplicationRecord
bulk_increment_counter(key, increments)
end
+ # Build artifacts & packages are not included in the project export
+ def export_size
+ storage_size - build_artifacts_size - packages_size
+ end
+
private
def incrementable_attribute?(key)
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 5078642ea3a..3af9f946243 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -172,6 +172,13 @@ class ProjectTeam
max_member_access(user.id) >= min_access_level
end
+ # Only for direct and not invited members
+ def has_user?(user)
+ return false unless user
+
+ project.project_members.non_invite.exists?(user: user)
+ end
+
def human_max_access(user_id)
Gitlab::Access.human_access(max_member_access(user_id))
end
diff --git a/app/models/projects/project_topic.rb b/app/models/projects/project_topic.rb
index 7021a48646a..7833c1ebf24 100644
--- a/app/models/projects/project_topic.rb
+++ b/app/models/projects/project_topic.rb
@@ -4,5 +4,7 @@ module Projects
class ProjectTopic < ApplicationRecord
belongs_to :project
belongs_to :topic, counter_cache: :total_projects_count
+
+ validates :topic_id, uniqueness: { scope: [:project_id] }
end
end
diff --git a/app/models/projects/repository_storage_move.rb b/app/models/projects/repository_storage_move.rb
index ae815bf366d..95fd78e8941 100644
--- a/app/models/projects/repository_storage_move.rb
+++ b/app/models/projects/repository_storage_move.rb
@@ -17,11 +17,7 @@ module Projects
override :schedule_repository_storage_update_worker
def schedule_repository_storage_update_worker
- Projects::UpdateRepositoryStorageWorker.perform_async(
- project_id,
- destination_storage_name,
- id
- )
+ Projects::UpdateRepositoryStorageWorker.perform_async(id)
end
private
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index 347d65841ed..a3622150351 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -7,9 +7,18 @@ module Projects
include Avatarable
include Gitlab::SQL::Pattern
+ SLUG_ALLOWED_REGEX = %r{\A[a-zA-Z0-9_\-.]+\z}
+
validates :name, presence: true, length: { maximum: 255 }
validates :name, uniqueness: { case_sensitive: false }, if: :name_changed?
validate :validate_name_format, if: :name_changed?
+
+ validates :slug,
+ length: { maximum: 255 },
+ uniqueness: { case_sensitive: false },
+ format: { with: SLUG_ALLOWED_REGEX, message: "can contain only letters, digits, '_', '-', '.'" },
+ if: :slug_changed?
+
validates :title, presence: true, length: { maximum: 255 }, on: :create
validates :description, length: { maximum: 1024 }
diff --git a/app/models/release.rb b/app/models/release.rb
index 1cd623e1254..7bacc69f038 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -54,6 +54,7 @@ class Release < ApplicationRecord
scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) }
scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) }
scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) }
+ scope :unpublished, -> { where(release_published_at: nil) }
scope :for_projects, ->(projects) { where(project_id: projects) }
scope :by_tag, ->(tag) { where(tag: tag) }
@@ -66,6 +67,7 @@ class Release < ApplicationRecord
delegate :repository, to: :project
MAX_NUMBER_TO_DISPLAY = 3
+ MAX_NUMBER_TO_PUBLISH = 5000
class << self
# In the future, we should support `order_by=semver`;
@@ -97,6 +99,10 @@ class Release < ApplicationRecord
.from("(VALUES #{project_ids_list}) projects (id)")
.joins("INNER JOIN LATERAL (#{join_query.to_sql}) #{Release.table_name} ON TRUE")
end
+
+ def waiting_for_publish_event
+ unpublished.released_within_2hrs.joins(:project).merge(Project.with_feature_enabled(:releases)).limit(MAX_NUMBER_TO_PUBLISH)
+ end
end
def to_param
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index ad1ce740c89..e912e57f39e 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -45,7 +45,7 @@ class ResourceLabelEvent < ResourceEvent
end
def group
- issuable.group if issuable.respond_to?(:group)
+ issuable.resource_parent if issuable.resource_parent.is_a?(Group)
end
def outdated_markdown?
@@ -93,7 +93,9 @@ class ResourceLabelEvent < ResourceEvent
end
def label_url_method
- issuable.is_a?(MergeRequest) ? :project_merge_requests_url : :project_issues_url
+ return :project_merge_requests_url if issuable.is_a?(MergeRequest)
+
+ issuable.project_id.nil? ? :group_work_items_url : :project_issues_url
end
def broadcast_notes_changed
diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb
index d305a4ace51..2b93334f721 100644
--- a/app/models/resource_milestone_event.rb
+++ b/app/models/resource_milestone_event.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ResourceMilestoneEvent < ResourceTimeboxEvent
+ include EachBatch
+
belongs_to :milestone
scope :include_relations, -> { includes(:user, milestone: [:project, :group]) }
diff --git a/app/models/route.rb b/app/models/route.rb
index 652c33a673c..1fa0005ffb4 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -3,6 +3,7 @@
class Route < MainClusterwide::ApplicationRecord
include CaseSensitivity
include Gitlab::SQL::Pattern
+ include EachBatch
belongs_to :source, polymorphic: true, inverse_of: :route # rubocop:disable Cop/PolymorphicAssociations
belongs_to :namespace, inverse_of: :namespace_route
@@ -26,30 +27,39 @@ class Route < MainClusterwide::ApplicationRecord
def rename_descendants
return unless saved_change_to_path? || saved_change_to_name?
- descendant_routes = self.class.inside_path(path_before_last_save)
+ if Feature.disabled?(:batch_route_updates, Feature.current_request, type: :gitlab_com_derisk)
+ descendant_routes = self.class.inside_path(path_before_last_save)
- descendant_routes.each do |route|
- attributes = {}
+ descendant_routes.each do |route|
+ attributes = {}
- if saved_change_to_path? && route.path.present?
- attributes[:path] = route.path.sub(path_before_last_save, path)
- end
+ if saved_change_to_path? && route.path.present?
+ attributes[:path] = route.path.sub(path_before_last_save, path)
+ end
- if saved_change_to_name? && name_before_last_save.present? && route.name.present?
- attributes[:name] = route.name.sub(name_before_last_save, name)
- end
+ if saved_change_to_name? && name_before_last_save.present? && route.name.present?
+ attributes[:name] = route.name.sub(name_before_last_save, name)
+ end
- next if attributes.empty?
+ next if attributes.empty?
- old_path = route.path
+ old_path = route.path
- # Callbacks must be run manually
- route.update_columns(attributes.merge(updated_at: Time.current))
+ # Callbacks must be run manually
+ route.update_columns(attributes.merge(updated_at: Time.current))
+
+ # We are not calling route.delete_conflicting_redirects here, in hopes
+ # of avoiding deadlocks. The parent (self, in this method) already
+ # called it, which deletes conflicts for all descendants.
+ route.create_redirect(old_path) if attributes[:path]
+ end
+ else
+ changes = {
+ path: { saved: saved_change_to_path?, old_value: path_before_last_save },
+ name: { saved: saved_change_to_name?, old_value: name_before_last_save }
+ }
- # We are not calling route.delete_conflicting_redirects here, in hopes
- # of avoiding deadlocks. The parent (self, in this method) already
- # called it, which deletes conflicts for all descendants.
- route.create_redirect(old_path) if attributes[:path]
+ Routes::RenameDescendantsService.new(self).execute(changes) # rubocop: disable CodeReuse/ServiceClass -- Need a service class to encapsulate all the logic.
end
end
diff --git a/app/models/service_desk/custom_email_credential.rb b/app/models/service_desk/custom_email_credential.rb
index 7ae44ac6aa1..6955f178bea 100644
--- a/app/models/service_desk/custom_email_credential.rb
+++ b/app/models/service_desk/custom_email_credential.rb
@@ -61,12 +61,13 @@ module ServiceDesk
def validate_smtp_address
# Addressable::URI always needs a scheme otherwise it interprets the host as the path
- Gitlab::UrlBlocker.validate!("smtp://#{smtp_address}",
+ Gitlab::HTTP_V2::UrlBlocker.validate!("smtp://#{smtp_address}",
schemes: %w[smtp],
ascii_only: true,
enforce_sanitization: true,
allow_localhost: false,
- allow_local_network: !::Gitlab.com? # rubocop:disable Gitlab/AvoidGitlabInstanceChecks -- self-managed may also use local network
+ allow_local_network: !::Gitlab.com?, # rubocop:disable Gitlab/AvoidGitlabInstanceChecks -- self-managed may also use local network
+ deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?
)
rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e
errors.add(:smtp_address, e)
diff --git a/app/models/snippets/repository_storage_move.rb b/app/models/snippets/repository_storage_move.rb
index 9db25ef4fc5..794caefb77d 100644
--- a/app/models/snippets/repository_storage_move.rb
+++ b/app/models/snippets/repository_storage_move.rb
@@ -16,11 +16,7 @@ module Snippets
override :schedule_repository_storage_update_worker
def schedule_repository_storage_update_worker
- Snippets::UpdateRepositoryStorageWorker.perform_async(
- snippet_id,
- destination_storage_name,
- id
- )
+ Snippets::UpdateRepositoryStorageWorker.perform_async(id)
end
private
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb
index 672a6d64127..f0855fc9f1c 100644
--- a/app/models/ssh_host_key.rb
+++ b/app/models/ssh_host_key.rb
@@ -137,12 +137,13 @@ class SshHostKey
end
def normalize_url(url)
- url, real_hostname = Gitlab::UrlBlocker.validate!(
+ url, real_hostname = Gitlab::HTTP_V2::UrlBlocker.validate!(
url,
schemes: %w[ssh],
allow_localhost: allow_local_requests?,
allow_local_network: allow_local_requests?,
- dns_rebind_protection: Gitlab::CurrentSettings.dns_rebinding_protection_enabled?
+ dns_rebind_protection: Gitlab::CurrentSettings.dns_rebinding_protection_enabled?,
+ deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?
)
# When DNS rebinding protection is required, the hostname is replaced by the
diff --git a/app/models/time_tracking/timelog_category.rb b/app/models/time_tracking/timelog_category.rb
index 67565039acd..295304f6e99 100644
--- a/app/models/time_tracking/timelog_category.rb
+++ b/app/models/time_tracking/timelog_category.rb
@@ -9,6 +9,8 @@ module TimeTracking
belongs_to :namespace, foreign_key: 'namespace_id'
+ has_many :timelogs
+
strip_attributes! :name
validates :namespace, presence: true
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index 0ae7790eef9..ffb88b7ebea 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -20,6 +20,7 @@ class Timelog < ApplicationRecord
belongs_to :project
belongs_to :user
belongs_to :note
+ belongs_to :timelog_category, optional: true, class_name: 'TimeTracking::TimelogCategory'
scope :in_group, -> (group) do
joins(:project).where(projects: { namespace: group.self_and_descendants })
diff --git a/app/models/tree.rb b/app/models/tree.rb
index 030e7d9e85f..d62e5c1b368 100644
--- a/app/models/tree.rb
+++ b/app/models/tree.rb
@@ -18,8 +18,15 @@ class Tree
ref = ExtractsRef::RefExtractor.qualify_ref(@sha, ref_type)
- @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, rescue_not_found,
- pagination_params)
+ @entries, @cursor = Gitlab::Git::Tree.tree_entries(
+ repository: git_repo,
+ sha: ref,
+ path: @path,
+ recursive: recursive,
+ skip_flat_paths: skip_flat_paths,
+ rescue_not_found: rescue_not_found,
+ pagination_params: pagination_params
+ )
@entries.each do |entry|
entry.ref_type = self.ref_type
diff --git a/app/models/user.rb b/app/models/user.rb
index c36898aaf70..c9873975cc9 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -151,7 +151,7 @@ class User < MainClusterwide::ApplicationRecord
# Namespace for personal projects
has_one :namespace,
-> { where(type: Namespaces::UserNamespace.sti_name) },
- required: true,
+ required: false,
dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent
foreign_key: :owner_id,
inverse_of: :owner,
@@ -270,7 +270,8 @@ class User < MainClusterwide::ApplicationRecord
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
has_many :organization_users, class_name: 'Organizations::OrganizationUser', inverse_of: :user
- has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users
+ has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users,
+ disable_joins: true
has_one :status, class_name: 'UserStatus'
has_one :user_preference
@@ -284,8 +285,6 @@ class User < MainClusterwide::ApplicationRecord
has_many :reviews, foreign_key: :author_id, inverse_of: :author
- has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail'
-
has_many :timelogs
has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
@@ -304,6 +303,10 @@ class User < MainClusterwide::ApplicationRecord
# Validations
#
# Note: devise :validatable above adds validations for :email and :password
+ validates :username,
+ presence: true,
+ exclusion: { in: Gitlab::PathRegex::TOP_LEVEL_ROUTES, message: N_('%{value} is a reserved name') }
+ validates :username, uniqueness: true, unless: :namespace
validates :name, presence: true, length: { maximum: 255 }
validates :first_name, length: { maximum: 127 }
validates :last_name, length: { maximum: 127 }
@@ -314,10 +317,9 @@ class User < MainClusterwide::ApplicationRecord
validates :projects_limit,
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
- validates :username, presence: true
validate :check_password_weakness, if: :encrypted_password_changed?
- validates :namespace, presence: true
+ validates :namespace, presence: true, unless: :optional_namespace?
validate :namespace_move_dir_allowed, if: :username_changed?, unless: :new_record?
validate :unique_email, if: :email_changed?
@@ -591,6 +593,8 @@ class User < MainClusterwide::ApplicationRecord
scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) }
scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) }
scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) }
+ scope :ordered_by_id_desc, -> { reorder(arel_table[:id].desc) }
+
scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', Gitlab::CurrentSettings.deactivate_dormant_users_period.day.ago.to_date) }
scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) }
scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) }
@@ -847,6 +851,25 @@ class User < MainClusterwide::ApplicationRecord
scope.reorder(order)
end
+ # This should be kept in sync with the frontend filtering in
+ # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L68 and
+ # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L1053
+ def gfm_autocomplete_search(query)
+ where(
+ "REPLACE(users.name, ' ', '') ILIKE :pattern OR users.username ILIKE :pattern",
+ pattern: "%#{sanitize_sql_like(query)}%"
+ ).order(
+ Arel.sql(sanitize_sql(
+ [
+ "CASE WHEN starts_with(REPLACE(users.name, ' ', ''), :pattern) OR starts_with(users.username, :pattern) THEN 1 ELSE 2 END",
+ { pattern: query }
+ ]
+ )),
+ :username,
+ :id
+ )
+ end
+
# Limits the result set to users _not_ in the given query/list of IDs.
#
# users - The list of users to ignore. This can be an
@@ -1302,7 +1325,13 @@ class User < MainClusterwide::ApplicationRecord
end
def can_create_project?
- projects_limit_left > 0
+ projects_limit_left > 0 && allow_user_to_create_group_and_project?
+ end
+
+ def allow_user_to_create_group_and_project?
+ return true if Gitlab::CurrentSettings.allow_project_creation_for_guest_and_below
+
+ highest_role > Gitlab::Access::GUEST
end
def can_create_group?
@@ -1596,12 +1625,6 @@ class User < MainClusterwide::ApplicationRecord
if namespace
namespace.path = username if username_changed?
namespace.name = name if name_changed?
- elsif Feature.disabled?(:create_personal_ns_outside_model, Feature.current_request)
- # TODO: we should no longer need the `type` parameter once we can make the
- # the `has_one :namespace` association use the correct class.
- # issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070
- namespace = build_namespace(path: username, name: name, type: ::Namespaces::UserNamespace.sti_name)
- namespace.build_namespace_settings
end
end
@@ -1623,6 +1646,9 @@ class User < MainClusterwide::ApplicationRecord
self.errors.add(:base, :username_exists_as_a_different_namespace)
else
namespace_path_errors.each do |msg|
+ # Already handled by username validation.
+ next if msg.ends_with?('is a reserved name')
+
self.errors.add(:username, msg)
end
end
@@ -2300,6 +2326,10 @@ class User < MainClusterwide::ApplicationRecord
private
+ def optional_namespace?
+ Feature.enabled?(:optional_personal_namespace, self)
+ end
+
def block_or_ban
user_scores = Abuse::UserTrustScore.new(self)
if user_scores.spammer? && account_age_in_days < 7
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index c32414be312..8d330e4eb6e 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -79,7 +79,9 @@ module Users
vulnerability_report_grouping: 77, # EE-only
new_nav_for_everyone_callout: 78,
code_suggestions_ga_non_owner_alert: 79, # EE-only
- duo_chat_callout: 80 # EE-only
+ duo_chat_callout: 80, # EE-only
+ code_suggestions_ga_owner_alert: 81, # EE-only
+ product_analytics_dashboard_feedback: 82 # EE-only
}
validates :feature_name,
diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb
index 6d0a22c8b0a..33e7ba72d5a 100644
--- a/app/models/users/credit_card_validation.rb
+++ b/app/models/users/credit_card_validation.rb
@@ -8,7 +8,7 @@ module Users
self.table_name = 'user_credit_card_validations'
- ignore_columns %i[last_digits network holder_name expiration_date], remove_with: '16.8', remove_after: '2023-12-22'
+ ignore_columns %i[last_digits network holder_name expiration_date], remove_with: '16.9', remove_after: '2024-01-22'
attr_accessor :last_digits, :network, :holder_name, :expiration_date
diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb
deleted file mode 100644
index 5362a726ff5..00000000000
--- a/app/models/users/in_product_marketing_email.rb
+++ /dev/null
@@ -1,75 +0,0 @@
-# frozen_string_literal: true
-
-module Users
- class InProductMarketingEmail < ApplicationRecord
- include BulkInsertSafe
-
- belongs_to :user
-
- validates :user, presence: true
- validates :track, presence: true
- validates :series, presence: true
-
- validates :user_id, uniqueness: {
- scope: [:track, :series],
- message: 'track series email has already been sent'
- }, if: -> { track.present? }
-
- enum track: {
- create: 0,
- verify: 1,
- trial: 2,
- team: 3,
- experience: 4,
- team_short: 5,
- trial_short: 6,
- admin_verify: 7,
- invite_team: 8
- }, _suffix: true
-
- # Tracks we don't send emails for (e.g. unsuccessful experiment). These
- # are kept since we already have DB records that use the enum value.
- INACTIVE_TRACK_NAMES = %w[invite_team experience].freeze
- ACTIVE_TRACKS = tracks.except(*INACTIVE_TRACK_NAMES)
-
- scope :for_user_with_track_and_series, ->(user, track, series) do
- where(user: user, track: track, series: series)
- end
-
- scope :without_track_and_series, ->(track, series) do
- join_condition = for_user.and(for_track_and_series(track, series))
- users_without_records(join_condition)
- end
-
- def self.users_table
- User.arel_table
- end
-
- def self.distinct_users_sql
- name = users_table.name
- Arel.sql("DISTINCT ON(#{name}.id) #{name}.*")
- end
-
- def self.users_without_records(condition)
- arel_join = users_table.join(arel_table, Arel::Nodes::OuterJoin).on(condition)
- joins(arel_join.join_sources)
- .where(in_product_marketing_emails: { id: nil })
- .select(distinct_users_sql)
- end
-
- def self.for_user
- arel_table[:user_id].eq(users_table[:id])
- end
-
- def self.for_track_and_series(track, series)
- arel_table[:track].eq(ACTIVE_TRACKS[track])
- .and(arel_table[:series]).eq(series)
- end
-
- def self.save_cta_click(user, track, series)
- email = for_user_with_track_and_series(user, track, series).take
-
- email.update(cta_clicked_at: Time.zone.now) if email && email.cta_clicked_at.blank?
- end
- end
-end
diff --git a/app/models/users/phone_number_validation.rb b/app/models/users/phone_number_validation.rb
index 072b75a1c90..ffb8d3a95a2 100644
--- a/app/models/users/phone_number_validation.rb
+++ b/app/models/users/phone_number_validation.rb
@@ -4,12 +4,17 @@ module Users
class PhoneNumberValidation < ApplicationRecord
include IgnorableColumns
+ # SMS send attempts subsequent to the first one will have wait times of 1
+ # min, 3 min, 5 min after each one respectively. Wait time between the fifth
+ # attempt and so on will be 10 minutes.
+ SMS_SEND_WAIT_TIMES = [1.minute, 3.minutes, 5.minutes, 10.minutes].freeze
+
self.primary_key = :user_id
self.table_name = 'user_phone_number_validations'
ignore_column :verification_attempts, remove_with: '16.7', remove_after: '2023-11-17'
- belongs_to :user, foreign_key: :user_id
+ belongs_to :user
belongs_to :banned_user, class_name: '::Users::BannedUser', foreign_key: :user_id
validates :country, presence: true, length: { maximum: 3 }
@@ -26,13 +31,24 @@ module Users
presence: true,
format: {
with: /\A\d+\Z/,
- message: -> (object, data) { _('can contain only digits') }
+ message: ->(_object, _data) { _('can contain only digits') }
},
length: { maximum: 12 }
validates :telesign_reference_xid, length: { maximum: 255 }
- scope :for_user, -> (user_id) { where(user_id: user_id) }
+ scope :for_user, ->(user_id) { where(user_id: user_id) }
+
+ scope :similar_to, ->(phone_number_validation) do
+ where(
+ international_dial_code: phone_number_validation.international_dial_code,
+ phone_number: phone_number_validation.phone_number
+ )
+ end
+
+ def similar_records
+ self.class.similar_to(self).includes(:user)
+ end
def self.related_to_banned_user?(international_dial_code, phone_number)
joins(:banned_user)
@@ -51,5 +67,18 @@ module Users
def validated?
validated_at.present?
end
+
+ def sms_send_allowed_after
+ return unless Feature.enabled?(:sms_send_wait_time, user)
+
+ # first send is allowed anytime
+ return if sms_send_count < 1
+ return unless sms_sent_at
+
+ max_wait_time = SMS_SEND_WAIT_TIMES.last
+ wait_time = SMS_SEND_WAIT_TIMES.fetch(sms_send_count - 1, max_wait_time)
+
+ sms_sent_at + wait_time
+ end
end
end
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 77f684e3578..f1d007e8167 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -5,7 +5,7 @@ class WorkItem < Issue
COMMON_QUICK_ACTIONS_COMMANDS = [
:title, :reopen, :close, :cc, :tableflip, :shrug, :type, :promote_to, :checkin_reminder,
- :subscribe, :unsubscribe, :confidential, :award
+ :subscribe, :unsubscribe, :confidential, :award, :react
].freeze
self.table_name = 'issues'
diff --git a/app/models/work_items/hierarchy_restriction.rb b/app/models/work_items/hierarchy_restriction.rb
index a253447a8db..f74f2f037b1 100644
--- a/app/models/work_items/hierarchy_restriction.rb
+++ b/app/models/work_items/hierarchy_restriction.rb
@@ -7,8 +7,17 @@ module WorkItems
belongs_to :parent_type, class_name: 'WorkItems::Type'
belongs_to :child_type, class_name: 'WorkItems::Type'
+ after_destroy :clear_parent_type_cache!
+ after_save :clear_parent_type_cache!
+
validates :parent_type, presence: true
validates :child_type, presence: true
validates :child_type, uniqueness: { scope: :parent_type_id }
+
+ private
+
+ def clear_parent_type_cache!
+ parent_type.clear_reactive_cache!
+ end
end
end
diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb
index f25c951406f..2637a7c8185 100644
--- a/app/models/work_items/widget_definition.rb
+++ b/app/models/work_items/widget_definition.rb
@@ -32,7 +32,9 @@ module WorkItems
notifications: 14,
current_user_todos: 15,
award_emoji: 16,
- linked_items: 17
+ linked_items: 17,
+ color: 18, # EE-only
+ rolledup_dates: 19 # EE-only
}
def self.available_widgets
diff --git a/app/models/work_items/widgets/notes.rb b/app/models/work_items/widgets/notes.rb
index bde94ea8f43..67ee19f4947 100644
--- a/app/models/work_items/widgets/notes.rb
+++ b/app/models/work_items/widgets/notes.rb
@@ -4,8 +4,18 @@ module WorkItems
module Widgets
class Notes < Base
delegate :notes, to: :work_item
+ delegate :discussion_locked, to: :work_item
+
delegate_missing_to :work_item
+ def self.quick_action_commands
+ [:lock, :unlock]
+ end
+
+ def self.quick_action_params
+ [:discussion_locked]
+ end
+
def declarative_policy_delegate
work_item
end
diff --git a/app/policies/container_registry/referrer_policy.rb b/app/policies/container_registry/referrer_policy.rb
new file mode 100644
index 00000000000..96eb4c60c84
--- /dev/null
+++ b/app/policies/container_registry/referrer_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+ class ReferrerPolicy < BasePolicy
+ delegate { @subject.tag }
+ end
+end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 175f86c9673..85ddf61fbd4 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -15,6 +15,8 @@ class GlobalPolicy < BasePolicy
@user&.required_terms_not_accepted?
end
+ condition(:can_create_group_and_projects, scope: :user) { @user&.allow_user_to_create_group_and_project? }
+
condition(:password_expired, scope: :user) do
@user&.password_expired_if_applicable?
end
@@ -90,6 +92,8 @@ class GlobalPolicy < BasePolicy
enable :create_group
end
+ rule { ~can_create_group_and_projects }.prevent :create_group
+
rule { can_create_organization }.policy do
enable :create_organization
end
diff --git a/app/policies/organizations/organization_policy.rb b/app/policies/organizations/organization_policy.rb
index d538b786f78..a203a58b164 100644
--- a/app/policies/organizations/organization_policy.rb
+++ b/app/policies/organizations/organization_policy.rb
@@ -3,6 +3,7 @@
module Organizations
class OrganizationPolicy < BasePolicy
condition(:organization_user) { @subject.user?(@user) }
+ condition(:organization_owner) { @subject.owner?(@user) }
desc 'Organization is public'
condition(:public_organization, scope: :subject, score: 0) { true }
@@ -13,14 +14,19 @@ module Organizations
rule { admin }.policy do
enable :admin_organization
+ enable :create_group
enable :read_organization
enable :read_organization_user
end
- rule { organization_user }.policy do
+ rule { organization_owner }.policy do
enable :admin_organization
+ end
+
+ rule { organization_user }.policy do
enable :read_organization
enable :read_organization_user
+ enable :create_group
end
end
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 255538c538a..a26758974d6 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -914,6 +914,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:admin_project) }.policy do
enable :read_usage_quotas
+ enable :view_edit_page
end
rule { can?(:project_bot_access) }.policy do
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index c52fc168c55..087fb8bf201 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -22,14 +22,15 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
)
end
- def highlight(to: nil, plain: nil)
+ def highlight(to: nil, plain: nil, used_on: :blob)
load_all_blob_data
Gitlab::Highlight.highlight(
blob.path,
blob_data(to),
language: blob_language,
- plain: plain
+ plain: plain,
+ used_on: used_on
)
end
diff --git a/app/presenters/projects/security/configuration_presenter.rb b/app/presenters/projects/security/configuration_presenter.rb
index a0d731f0ccf..244f36f627d 100644
--- a/app/presenters/projects/security/configuration_presenter.rb
+++ b/app/presenters/projects/security/configuration_presenter.rb
@@ -85,7 +85,8 @@ module Projects
available: scan.available?,
can_enable_by_merge_request: scan.can_enable_by_merge_request?,
meta_info_path: scan.meta_info_path,
- on_demand_available: scan.on_demand_available?
+ on_demand_available: scan.on_demand_available?,
+ security_features: scan.security_features
}
end
diff --git a/app/serializers/activity_pub/activity_serializer.rb b/app/serializers/activity_pub/activity_serializer.rb
new file mode 100644
index 00000000000..71a1bfece6b
--- /dev/null
+++ b/app/serializers/activity_pub/activity_serializer.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module ActivityPub
+ # Serializer for the `Activity` ActivityStreams model.
+ # Reference: https://www.w3.org/TR/activitystreams-core/#activities
+ class ActivitySerializer < ObjectSerializer
+ MissingActorError = Class.new(StandardError)
+ MissingObjectError = Class.new(StandardError)
+ IntransitiveWithObjectError = Class.new(StandardError)
+
+ private
+
+ def validate_response(serialized, opts)
+ response = super(serialized, opts)
+
+ unless response[:actor].present?
+ raise MissingActorError, "The serializer does not provide the mandatory 'actor' field."
+ end
+
+ if opts[:intransitive] && response[:object].present?
+ raise IntransitiveWithObjectError, <<~ERROR
+ The serializer does provide both the 'object' field and the :intransitive option.
+ Intransitive activities are meant precisely for when no object is available.
+ Please remove either of those.
+ See https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
+ ERROR
+ end
+
+ unless opts[:intransitive] || response[:object].present?
+ raise MissingObjectError, <<~ERROR
+ The serializer does not provide the mandatory 'object' field.
+ Pass the :intransitive option to #represent if this is an intransitive activity.
+ See https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
+ ERROR
+ end
+
+ response
+ end
+ end
+end
diff --git a/app/serializers/activity_pub/activity_streams_serializer.rb b/app/serializers/activity_pub/activity_streams_serializer.rb
deleted file mode 100644
index 39caa4a6d10..00000000000
--- a/app/serializers/activity_pub/activity_streams_serializer.rb
+++ /dev/null
@@ -1,90 +0,0 @@
-# frozen_string_literal: true
-
-module ActivityPub
- class ActivityStreamsSerializer < ::BaseSerializer
- MissingIdentifierError = Class.new(StandardError)
- MissingTypeError = Class.new(StandardError)
- MissingOutboxError = Class.new(StandardError)
-
- alias_method :base_represent, :represent
-
- def represent(resource, opts = {}, entity_class = nil)
- response = if respond_to?(:paginated?) && paginated?
- represent_paginated(resource, opts, entity_class)
- else
- represent_whole(resource, opts, entity_class)
- end
-
- validate_response(HashWithIndifferentAccess.new(response))
- end
-
- private
-
- def validate_response(response)
- unless response[:id].present?
- raise MissingIdentifierError, "The serializer does not provide the mandatory 'id' field."
- end
-
- unless response[:type].present?
- raise MissingTypeError, "The serializer does not provide the mandatory 'type' field."
- end
-
- response
- end
-
- def represent_whole(resource, opts, entity_class)
- raise MissingOutboxError, 'Please provide an :outbox option for this actor' unless opts[:outbox].present?
-
- serialized = base_represent(resource, opts, entity_class)
-
- {
- :@context => "https://www.w3.org/ns/activitystreams",
- inbox: opts[:inbox],
- outbox: opts[:outbox]
- }.merge(serialized)
- end
-
- def represent_paginated(resources, opts, entity_class)
- if paginator.params['page'].present?
- represent_page(resources, resources.current_page, opts, entity_class)
- else
- represent_pagination_index(resources)
- end
- end
-
- def represent_page(resources, page, opts, entity_class)
- opts[:page] = page
- serialized = base_represent(resources, opts, entity_class)
-
- {
- :@context => 'https://www.w3.org/ns/activitystreams',
- type: 'OrderedCollectionPage',
- id: collection_url(page),
- prev: page > 1 ? collection_url(page - 1) : nil,
- next: page < resources.total_pages ? collection_url(page + 1) : nil,
- partOf: collection_url,
- orderedItems: serialized
- }
- end
-
- def represent_pagination_index(resources)
- {
- :@context => 'https://www.w3.org/ns/activitystreams',
- type: 'OrderedCollection',
- id: collection_url,
- totalItems: resources.total_count,
- first: collection_url(1),
- last: collection_url(resources.total_pages)
- }
- end
-
- def collection_url(page = nil)
- uri = URI.parse(paginator.request.url)
- uri.query ||= ""
- parts = uri.query.split('&').reject { |part| part =~ /^page=/ }
- parts << "page=#{page}" if page
- uri.query = parts.join('&')
- uri.to_s.sub(/\?$/, '')
- end
- end
-end
diff --git a/app/serializers/activity_pub/actor_serializer.rb b/app/serializers/activity_pub/actor_serializer.rb
new file mode 100644
index 00000000000..14ab43666ec
--- /dev/null
+++ b/app/serializers/activity_pub/actor_serializer.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module ActivityPub
+ # Serializer for the `Actor` ActivityStreams model.
+ # Reference: https://www.w3.org/TR/activitystreams-core/#actors
+ class ActorSerializer < ObjectSerializer
+ MissingOutboxError = Class.new(StandardError)
+
+ def represent(resource, opts = {}, entity_class = nil)
+ raise MissingInboxError, 'Please provide an :inbox option for this actor' unless opts[:inbox].present?
+ raise MissingOutboxError, 'Please provide an :outbox option for this actor' unless opts[:outbox].present?
+
+ super
+ end
+
+ private
+
+ def validate_response(response, _opts)
+ unless response[:id].present?
+ raise MissingIdentifierError, "The serializer does not provide the mandatory 'id' field."
+ end
+
+ unless response[:type].present?
+ raise MissingTypeError, "The serializer does not provide the mandatory 'type' field."
+ end
+
+ response
+ end
+
+ def wrap(serialized, opts)
+ parent_value = super(serialized, opts)
+
+ {
+ inbox: opts[:inbox],
+ outbox: opts[:outbox]
+ }.merge(parent_value)
+ end
+ end
+end
diff --git a/app/serializers/activity_pub/collection_serializer.rb b/app/serializers/activity_pub/collection_serializer.rb
new file mode 100644
index 00000000000..16c78eb1b7d
--- /dev/null
+++ b/app/serializers/activity_pub/collection_serializer.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module ActivityPub
+ # Serializer for the `Collection` ActivityStreams model.
+ # Reference: https://www.w3.org/TR/activitystreams-core/#collections
+ class CollectionSerializer < ::BaseSerializer
+ include WithPagination
+
+ NotPaginatedError = Class.new(StandardError)
+
+ alias_method :base_represent, :represent
+
+ def represent(resources, opts = {})
+ unless respond_to?(:paginated?) && paginated?
+ raise NotPaginatedError, 'Pass #with_pagination to the serializer or use ActivityPub::ObjectSerializer instead'
+ end
+
+ response = if paginator.params['page'].present?
+ represent_page(resources, paginator.params['page'].to_i, opts)
+ else
+ represent_pagination_index(resources)
+ end
+
+ HashWithIndifferentAccess.new(response)
+ end
+
+ private
+
+ def represent_page(resources, page, opts)
+ resources = paginator.paginate(resources)
+ opts[:page] = page
+ serialized = base_represent(resources, opts)
+
+ {
+ :@context => 'https://www.w3.org/ns/activitystreams',
+ type: 'OrderedCollectionPage',
+ id: collection_url(page),
+ prev: page > 1 ? collection_url(page - 1) : nil,
+ next: page < resources.total_pages ? collection_url(page + 1) : nil,
+ partOf: collection_url,
+ orderedItems: serialized
+ }
+ end
+
+ def represent_pagination_index(resources)
+ paginator.params['page'] = 1
+ resources = paginator.paginate(resources)
+
+ {
+ :@context => 'https://www.w3.org/ns/activitystreams',
+ type: 'OrderedCollection',
+ id: collection_url,
+ totalItems: resources.total_count,
+ first: collection_url(1),
+ last: collection_url(resources.total_pages)
+ }
+ end
+
+ def collection_url(page = nil)
+ uri = URI.parse(paginator.request.url)
+ uri.query ||= ""
+ parts = uri.query.split('&').reject { |part| part =~ /^page=/ }
+ parts << "page=#{page}" if page
+ uri.query = parts.join('&')
+ uri.to_s.sub(/\?$/, '')
+ end
+ end
+end
diff --git a/app/serializers/activity_pub/object_serializer.rb b/app/serializers/activity_pub/object_serializer.rb
new file mode 100644
index 00000000000..cdcef59cc41
--- /dev/null
+++ b/app/serializers/activity_pub/object_serializer.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module ActivityPub
+ # Serializer for the `Object` ActivityStreams model.
+ # Reference: https://www.w3.org/TR/activitystreams-core/#object
+ class ObjectSerializer < ::BaseSerializer
+ MissingIdentifierError = Class.new(StandardError)
+ MissingTypeError = Class.new(StandardError)
+
+ def represent(resource, opts = {}, entity_class = nil)
+ serialized = super(resource, opts, entity_class)
+ response = wrap(serialized, opts)
+
+ validate_response(HashWithIndifferentAccess.new(response), opts)
+ end
+
+ private
+
+ def wrap(serialized, _opts)
+ { :@context => "https://www.w3.org/ns/activitystreams" }.merge(serialized)
+ end
+
+ def validate_response(response, _opts)
+ unless response[:id].present?
+ raise MissingIdentifierError, "The serializer does not provide the mandatory 'id' field."
+ end
+
+ unless response[:type].present?
+ raise MissingTypeError, "The serializer does not provide the mandatory 'type' field."
+ end
+
+ response
+ end
+ end
+end
diff --git a/app/serializers/activity_pub/publish_release_activity_serializer.rb b/app/serializers/activity_pub/publish_release_activity_serializer.rb
new file mode 100644
index 00000000000..b70ff470af5
--- /dev/null
+++ b/app/serializers/activity_pub/publish_release_activity_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ActivityPub
+ class PublishReleaseActivitySerializer < ActivitySerializer
+ entity ReleaseEntity
+ end
+end
diff --git a/app/serializers/activity_pub/releases_actor_serializer.rb b/app/serializers/activity_pub/releases_actor_serializer.rb
index 5bae83f2dc7..f4b33e25393 100644
--- a/app/serializers/activity_pub/releases_actor_serializer.rb
+++ b/app/serializers/activity_pub/releases_actor_serializer.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module ActivityPub
- class ReleasesActorSerializer < ActivityStreamsSerializer
+ class ReleasesActorSerializer < ActorSerializer
entity ReleasesActorEntity
end
end
diff --git a/app/serializers/activity_pub/releases_outbox_serializer.rb b/app/serializers/activity_pub/releases_outbox_serializer.rb
index b6d4e633fb0..6087e713e64 100644
--- a/app/serializers/activity_pub/releases_outbox_serializer.rb
+++ b/app/serializers/activity_pub/releases_outbox_serializer.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
module ActivityPub
- class ReleasesOutboxSerializer < ActivityStreamsSerializer
- include WithPagination
-
+ class ReleasesOutboxSerializer < CollectionSerializer
entity ReleaseEntity
end
end
diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb
index a654482b989..414517dc77e 100644
--- a/app/serializers/admin/abuse_report_details_entity.rb
+++ b/app/serializers/admin/abuse_report_details_entity.rb
@@ -17,12 +17,6 @@ module Admin
admin_user_path(report.user)
end
- expose :plan do |report|
- if Gitlab::CurrentSettings.current_application_settings.try(:should_check_namespace_plan?)
- report.user.namespace&.actual_plan&.title
- end
- end
-
expose :verification_state do
expose :email do |report|
report.user.confirmed?
@@ -44,6 +38,15 @@ module Admin
end
end
+ expose :phone_number, if: ->(report) { report.user.phone_number_validation.present? } do
+ expose :similar_records_count do |report|
+ report.user.phone_number_validation.similar_records.count
+ end
+ expose :phone_matches_link do |report|
+ phone_match_admin_user_path(report.user) if Gitlab.ee?
+ end
+ end
+
expose :past_closed_reports do |report|
AbuseReportEntity.represent(report.past_closed_reports_for_user, only: [:created_at, :category, :report_path])
end
@@ -82,3 +85,5 @@ module Admin
end
end
end
+
+Admin::AbuseReportDetailsEntity.prepend_mod_with('Admin::AbuseReportDetailsEntity')
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 35063ceeb06..e166119f59d 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -155,11 +155,11 @@ class BuildDetailsEntity < Ci::JobEntity
# We do not return the invalid_dependencies for all scenarios see https://gitlab.com/gitlab-org/gitlab/-/issues/287772#note_914406387
punctuation = invalid_dependencies.empty? ? '.' : ': '
_("This job could not start because it could not retrieve the needed artifacts%{punctuation}%{invalid_dependencies}") %
- { invalid_dependencies: html_escape(invalid_dependencies), punctuation: punctuation }
+ { invalid_dependencies: ERB::Util.html_escape(invalid_dependencies), punctuation: punctuation }
end
def help_message(docs_url, troubleshooting_url)
- html_escape(_("Learn more about <a href=\"#{docs_url}\">dependencies</a> and <a href=\"#{troubleshooting_url}\">common causes</a> of this error.</a>".html_safe))
+ ERB::Util.html_escape(_("Learn more about <a href=\"#{docs_url}\">dependencies</a> and <a href=\"#{troubleshooting_url}\">common causes</a> of this error.</a>".html_safe))
end
end
diff --git a/app/serializers/ci/basic_variable_entity.rb b/app/serializers/ci/basic_variable_entity.rb
index 210c01408a6..5907bf0f29c 100644
--- a/app/serializers/ci/basic_variable_entity.rb
+++ b/app/serializers/ci/basic_variable_entity.rb
@@ -5,6 +5,7 @@ module Ci
expose :id
expose :key
expose :value
+ expose :description
expose :variable_type
expose :protected?, as: :protected
diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb
index e55f31a8376..b840f3acb88 100644
--- a/app/serializers/diffs_metadata_entity.rb
+++ b/app/serializers/diffs_metadata_entity.rb
@@ -15,6 +15,10 @@ class DiffsMetadataEntity < DiffsEntity
presenter(options[:merge_request]).conflict_resolution_path
end
+ # #cannot_be_merged? is generally indicative of conflicts, and is set via
+ # MergeRequests::MergeabilityCheckService. However, it can also indicate
+ # that either #has_no_commits? or #branch_missing? are true.
+ #
expose :has_conflicts do |_, options|
options[:merge_request].cannot_be_merged?
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 7d473f9ed89..f515fdede29 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -231,7 +231,7 @@ module Auth
return if path.has_repository?
return unless actions.include?('push')
- ContainerRepository.find_or_create_from_path(path)
+ ContainerRepository.find_or_create_from_path!(path)
end
# Overridden in EE
diff --git a/app/services/boards/base_item_move_service.rb b/app/services/boards/base_item_move_service.rb
index c9da889c536..7d202da96ce 100644
--- a/app/services/boards/base_item_move_service.rb
+++ b/app/services/boards/base_item_move_service.rb
@@ -84,20 +84,11 @@ module Boards
end
def remove_label_ids
- label_ids =
- if moving_to_list.movable?
- moving_from_list.label_id
- else
- board_label_ids
- end
+ label_ids = moving_to_list.movable? ? moving_from_list.label_id : []
Array(label_ids).compact
end
- def board_label_ids
- ::Label.ids_on_board(board.id)
- end
-
def move_params_from_list_position(position)
if position == LIST_END_POSITION
{ move_before_id: moving_to_list_items_relation.reverse_order.pick(:id), move_after_id: nil }
diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb
index 8fa438a76ce..39c27c04b8c 100644
--- a/app/services/bulk_imports/file_download_service.rb
+++ b/app/services/bulk_imports/file_download_service.rb
@@ -71,7 +71,6 @@ module BulkImports
unless @remote_content_validated
validate_content_type
- validate_content_length
@remote_content_validated = true
end
@@ -130,11 +129,12 @@ module BulkImports
end
def validate_url
- ::Gitlab::UrlBlocker.validate!(
+ ::Gitlab::HTTP_V2::UrlBlocker.validate!(
http_client.resource_url(relative_url),
allow_localhost: allow_local_requests?,
allow_local_network: allow_local_requests?,
- schemes: %w[http https]
+ schemes: %w[http https],
+ deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?
)
end
diff --git a/app/services/ci/cancel_pipeline_service.rb b/app/services/ci/cancel_pipeline_service.rb
index 38053b13921..92eead3fdd1 100644
--- a/app/services/ci/cancel_pipeline_service.rb
+++ b/app/services/ci/cancel_pipeline_service.rb
@@ -10,17 +10,20 @@ module Ci
# @cascade_to_children - if true cancels all related child pipelines for parent child pipelines
# @auto_canceled_by_pipeline - store the pipeline_id of the pipeline that triggered cancellation
# @execute_async - if true cancel the children asyncronously
+ # @safe_cancellation - if true only cancel interruptible:true jobs
def initialize(
pipeline:,
current_user:,
cascade_to_children: true,
auto_canceled_by_pipeline: nil,
- execute_async: true)
+ execute_async: true,
+ safe_cancellation: false)
@pipeline = pipeline
@current_user = current_user
@cascade_to_children = cascade_to_children
@auto_canceled_by_pipeline = auto_canceled_by_pipeline
@execute_async = execute_async
+ @safe_cancellation = safe_cancellation
end
def execute
@@ -42,13 +45,16 @@ module Ci
log_pipeline_being_canceled
pipeline.update_column(:auto_canceled_by_id, @auto_canceled_by_pipeline.id) if @auto_canceled_by_pipeline
- cancel_jobs(pipeline.cancelable_statuses)
- return ServiceResponse.success unless cascade_to_children?
+ if @safe_cancellation
+ # Only build and bridge (trigger) jobs can be interruptible.
+ # We do not cancel GenericCommitStatuses because they can't have the `interruptible` attribute.
+ cancel_jobs(pipeline.processables.cancelable.interruptible)
+ else
+ cancel_jobs(pipeline.cancelable_statuses)
+ end
- # cancel any bridges that could spin up new child pipelines
- cancel_jobs(pipeline.bridges_in_self_and_project_descendants.cancelable)
- cancel_children
+ cancel_children if cascade_to_children?
ServiceResponse.success
end
@@ -106,8 +112,15 @@ module Ci
)
end
- # For parent child-pipelines only (not multi-project)
+ # We don't handle the case when `cascade_to_children` is `true` and `safe_cancellation` is `true`
+ # because `safe_cancellation` is passed as `true` only when `cascade_to_children` is `false`
+ # from `CancelRedundantPipelinesService`.
+ # In the future, when "safe cancellation" is implemented as a regular cancellation feature,
+ # we need to handle this case.
def cancel_children
+ cancel_jobs(pipeline.bridges_in_self_and_project_descendants.cancelable)
+
+ # For parent child-pipelines only (not multi-project)
pipeline.all_child_pipelines.each do |child_pipeline|
if execute_async?
::Ci::CancelPipelineWorker.perform_async(
diff --git a/app/services/ci/catalog/resources/versions/create_service.rb b/app/services/ci/catalog/resources/versions/create_service.rb
index 863bad43271..9547db7bcf1 100644
--- a/app/services/ci/catalog/resources/versions/create_service.rb
+++ b/app/services/ci/catalog/resources/versions/create_service.rb
@@ -65,10 +65,12 @@ module Ci
end
def extract_metadata(blob)
+ component_name = components_project.extract_component_name(blob.path)
+
{
- name: components_project.extract_component_name(blob.path),
+ name: component_name,
inputs: components_project.extract_inputs(blob.data),
- path: blob.path
+ path: "#{Settings.gitlab.host}/#{project.full_path}/#{component_name}@#{release.tag}"
}
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 2231b1dd6bd..7d3e71b003e 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -116,14 +116,6 @@ module Ci
private
- def commit
- @commit ||= project.commit(origin_sha || origin_ref)
- end
-
- def sha
- commit.try(:id)
- end
-
def create_namespace_onboarding_action
Onboarding::PipelineCreatedWorker.perform_async(project.namespace_id)
end
diff --git a/app/services/ci/create_web_ide_terminal_service.rb b/app/services/ci/create_web_ide_terminal_service.rb
index db8f61c81fa..ce4400e9f4f 100644
--- a/app/services/ci/create_web_ide_terminal_service.rb
+++ b/app/services/ci/create_web_ide_terminal_service.rb
@@ -52,7 +52,7 @@ module Ci
ref: ref,
sha: sha,
tag: false,
- before_sha: Gitlab::Git::BLANK_SHA
+ before_sha: Gitlab::Git::SHA1_BLANK_SHA
)
end
diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb
index 224b2d96205..98469e82af3 100644
--- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb
+++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb
@@ -23,7 +23,7 @@ module Ci
pipelines = parent_and_child_pipelines(ids)
Gitlab::OptimisticLocking.retry_lock(pipelines, name: 'cancel_pending_pipelines') do |cancelables|
- auto_cancel_interruptible_pipelines(cancelables.ids)
+ auto_cancel_pipelines(cancelables.ids)
end
end
end
@@ -69,31 +69,66 @@ module Ci
.base_and_descendants
.alive_or_scheduled
end
- # rubocop: enable CodeReuse/ActiveRecord
- def auto_cancel_interruptible_pipelines(pipeline_ids)
+ def legacy_auto_cancel_pipelines(pipeline_ids)
::Ci::Pipeline
.id_in(pipeline_ids)
- .with_only_interruptible_builds
+ .conservative_interruptible
.each do |cancelable_pipeline|
- Gitlab::AppLogger.info(
- class: self.class.name,
- message: "Pipeline #{pipeline.id} auto-canceling pipeline #{cancelable_pipeline.id}",
- canceled_pipeline_id: cancelable_pipeline.id,
- canceled_by_pipeline_id: pipeline.id,
- canceled_by_pipeline_source: pipeline.source
- )
-
- # cascade_to_children not needed because we iterate through descendants here
- ::Ci::CancelPipelineService.new(
- pipeline: cancelable_pipeline,
- current_user: nil,
- auto_canceled_by_pipeline: pipeline,
- cascade_to_children: false
- ).force_execute
+ cancel_pipeline(cancelable_pipeline, safe_cancellation: false)
end
end
+ def auto_cancel_pipelines(pipeline_ids)
+ if Feature.disabled?(:ci_workflow_auto_cancel_on_new_commit, project)
+ return legacy_auto_cancel_pipelines(pipeline_ids)
+ end
+
+ ::Ci::Pipeline
+ .id_in(pipeline_ids)
+ .each do |cancelable_pipeline|
+ case cancelable_pipeline.auto_cancel_on_new_commit
+ when 'none'
+ # no-op
+ when 'conservative'
+ next unless conservative_cancellable_pipeline_ids(pipeline_ids).include?(cancelable_pipeline.id)
+
+ cancel_pipeline(cancelable_pipeline, safe_cancellation: false)
+ when 'interruptible'
+ cancel_pipeline(cancelable_pipeline, safe_cancellation: true)
+ else
+ raise ArgumentError,
+ "Unknown auto_cancel_on_new_commit value: #{cancelable_pipeline.auto_cancel_on_new_commit}"
+ end
+ end
+ end
+
+ def conservative_cancellable_pipeline_ids(pipeline_ids)
+ strong_memoize_with(:conservative_cancellable_pipeline_ids, pipeline_ids) do
+ ::Ci::Pipeline.id_in(pipeline_ids).conservative_interruptible.ids
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def cancel_pipeline(cancelable_pipeline, safe_cancellation:)
+ Gitlab::AppLogger.info(
+ class: self.class.name,
+ message: "Pipeline #{pipeline.id} auto-canceling pipeline #{cancelable_pipeline.id}",
+ canceled_pipeline_id: cancelable_pipeline.id,
+ canceled_by_pipeline_id: pipeline.id,
+ canceled_by_pipeline_source: pipeline.source
+ )
+
+ # cascade_to_children not needed because we iterate through descendants here
+ ::Ci::CancelPipelineService.new(
+ pipeline: cancelable_pipeline,
+ current_user: nil,
+ auto_canceled_by_pipeline: pipeline,
+ cascade_to_children: false,
+ safe_cancellation: safe_cancellation
+ ).force_execute
+ end
+
def pipelines_created_after
3.days.ago
end
diff --git a/app/services/ci/runners/unregister_runner_manager_service.rb b/app/services/ci/runners/unregister_runner_manager_service.rb
index ecf6aba09c7..9b3bd4a53e2 100644
--- a/app/services/ci/runners/unregister_runner_manager_service.rb
+++ b/app/services/ci/runners/unregister_runner_manager_service.rb
@@ -20,6 +20,8 @@ module Ci
runner_manager = runner.runner_managers.find_by_system_xid!(system_id)
runner_manager.destroy!
+ runner.clear_heartbeat if runner.runner_managers.empty?
+
ServiceResponse.success
end
diff --git a/app/services/ci/unlock_pipeline_service.rb b/app/services/ci/unlock_pipeline_service.rb
index 88d4a8fd0be..bd42871ffbe 100644
--- a/app/services/ci/unlock_pipeline_service.rb
+++ b/app/services/ci/unlock_pipeline_service.rb
@@ -84,7 +84,7 @@ module Ci
def unlock_job_artifacts
start = Time.current
- pipeline.builds.each_batch(of: BATCH_SIZE) do |builds|
+ builds_relation.each_batch(of: BATCH_SIZE) do |builds|
# rubocop: disable CodeReuse/ActiveRecord
Ci::JobArtifact.where(job_id: builds.pluck(:id)).each_batch(of: BATCH_SIZE) do |job_artifacts|
unlocked_count = Ci::JobArtifact
@@ -100,6 +100,16 @@ module Ci
end
end
+ # Removes the partition_id filter from the query until we get more data in the
+ # second partition.
+ def builds_relation
+ if Feature.enabled?(:disable_ci_partition_pruning, pipeline.project, type: :wip)
+ Ci::Build.in_pipelines(pipeline)
+ else
+ pipeline.builds
+ end
+ end
+
def unlock_pipeline_artifacts
@unlocked_pipeline_artifacts_count = pipeline.pipeline_artifacts.update_all(locked: :unlocked)
end
diff --git a/app/services/click_house/sync_strategies/base_sync_strategy.rb b/app/services/click_house/sync_strategies/base_sync_strategy.rb
new file mode 100644
index 00000000000..58c2161b83c
--- /dev/null
+++ b/app/services/click_house/sync_strategies/base_sync_strategy.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+module ClickHouse
+ module SyncStrategies
+ class BaseSyncStrategy
+ include Gitlab::ExclusiveLeaseHelpers
+ include Gitlab::Utils::StrongMemoize
+
+ # the job is scheduled every 3 minutes and we will allow maximum 2.5 minutes runtime
+ MAX_TTL = 2.5.minutes.to_i
+ MAX_RUNTIME = 120.seconds
+ BATCH_SIZE = 500
+ INSERT_BATCH_SIZE = 5000
+
+ def execute
+ return { status: :disabled } unless enabled?
+
+ metadata = { status: :processed }
+
+ begin
+ # Prevent parallel jobs
+ in_lock(self.class.to_s, ttl: MAX_TTL, retries: 0) do
+ loop { break unless next_batch }
+
+ metadata.merge!(records_inserted: context.total_record_count,
+ reached_end_of_table: context.no_more_records?)
+
+ if context.last_processed_id
+ ClickHouse::SyncCursor.update_cursor_for(model_class.table_name,
+ context.last_processed_id)
+ end
+ end
+ rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
+ # Skip retrying, just let the next worker to start after a few minutes
+ metadata = { status: :skipped }
+ end
+
+ metadata
+ end
+
+ private
+
+ def enabled?
+ ClickHouse::Client.database_configured?(:main)
+ end
+
+ def context
+ @context ||= ClickHouse::RecordSyncContext.new(
+ last_record_id: ClickHouse::SyncCursor.cursor_for(model_class.table_name),
+ max_records_per_batch: INSERT_BATCH_SIZE,
+ runtime_limiter: Analytics::CycleAnalytics::RuntimeLimiter.new(MAX_RUNTIME)
+ )
+ end
+
+ def last_id_in_postgresql
+ model_class.maximum(:id)
+ end
+
+ strong_memoize_attr :last_id_in_postgresql
+
+ def next_batch
+ context.new_batch!
+
+ CsvBuilder::Gzip.new(process_batch(context), csv_mapping).render do |tempfile, rows_written|
+ unless rows_written == 0
+ ClickHouse::Client.insert_csv(insert_query, File.open(tempfile.path),
+ :main)
+ end
+ end
+
+ !(context.over_time? || context.no_more_records?)
+ end
+
+ def process_batch(context)
+ Enumerator.new do |yielder|
+ has_more_data = false
+ batching_scope.each_batch(of: BATCH_SIZE) do |relation|
+ records = relation.select(projections).to_a
+ has_more_data = records.size == BATCH_SIZE
+ records.each do |row|
+ yielder << transform_row(row)
+ context.last_processed_id = row.id
+
+ break if context.record_limit_reached?
+ end
+
+ break if context.over_time? || context.record_limit_reached? || !has_more_data
+ end
+
+ context.no_more_records! unless has_more_data
+ end
+ end
+
+ def transform_row(row)
+ row
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord -- because model here is dynamic and is passed by child class
+ def batching_scope
+ return model_class.none unless last_id_in_postgresql
+
+ table = model_class.arel_table
+
+ model_class
+ .where(table[:id].gt(context.last_record_id))
+ .where(table[:id].lteq(last_id_in_postgresql))
+ end
+
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def projections
+ raise NotImplementedError, "Subclasses must implement `projections`"
+ end
+
+ def csv_mapping
+ raise NotImplementedError, "Subclasses must implement `csv_mapping`"
+ end
+
+ def insert_query
+ raise NotImplementedError, "Subclasses must implement `insert_query`"
+ end
+ end
+ end
+end
diff --git a/app/services/click_house/sync_strategies/event_sync_strategy.rb b/app/services/click_house/sync_strategies/event_sync_strategy.rb
new file mode 100644
index 00000000000..3e86e8c52bc
--- /dev/null
+++ b/app/services/click_house/sync_strategies/event_sync_strategy.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module ClickHouse
+ module SyncStrategies
+ class EventSyncStrategy < BaseSyncStrategy
+ # transforms the traversal_ids to a String:
+ # Example: group_id/subgroup_id/group_or_projectnamespace_id/
+ PATH_COLUMN = <<~SQL
+ (
+ CASE
+ WHEN project_id IS NOT NULL THEN (SELECT array_to_string(traversal_ids, '/') || '/' FROM namespaces WHERE id = (SELECT project_namespace_id FROM projects WHERE id = events.project_id LIMIT 1) LIMIT 1)
+ WHEN group_id IS NOT NULL THEN (SELECT array_to_string(traversal_ids, '/') || '/' FROM namespaces WHERE id = events.group_id LIMIT 1)
+ ELSE ''
+ END
+ ) AS path
+ SQL
+
+ private
+
+ def csv_mapping
+ {
+ id: :id,
+ path: :path,
+ author_id: :author_id,
+ target_id: :target_id,
+ target_type: :target_type,
+ action: :raw_action,
+ created_at: :casted_created_at,
+ updated_at: :casted_updated_at
+ }
+ end
+
+ def projections
+ [
+ :id,
+ PATH_COLUMN,
+ :author_id,
+ :target_id,
+ :target_type,
+ 'action AS raw_action',
+ 'EXTRACT(epoch FROM created_at) AS casted_created_at',
+ 'EXTRACT(epoch FROM updated_at) AS casted_updated_at'
+ ]
+ end
+
+ def insert_query
+ <<~SQL.squish
+ INSERT INTO events (#{csv_mapping.keys.join(', ')})
+ SETTINGS async_insert=1, wait_for_async_insert=1 FORMAT CSV
+ SQL
+ end
+
+ def model_class
+ ::Event
+ end
+
+ def enabled?
+ super && Feature.enabled?(:event_sync_worker_for_click_house)
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/base_service.rb b/app/services/cloud_seed/google_cloud/base_service.rb
new file mode 100644
index 00000000000..e59031c5371
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/base_service.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class BaseService < ::BaseService
+ protected
+
+ def google_oauth2_token
+ @params[:google_oauth2_token]
+ end
+
+ def gcp_project_id
+ @params[:gcp_project_id]
+ end
+
+ def environment_name
+ @params[:environment_name]
+ end
+
+ def google_api_client
+ @google_api_client_instance ||= GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil)
+ end
+
+ def unique_gcp_project_ids
+ filter_params = { key: 'GCP_PROJECT_ID' }
+ @unique_gcp_project_ids ||= ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq
+ end
+
+ def group_vars_by_environment(keys)
+ filtered_vars = project.variables.filter { |variable| keys.include? variable.key }
+ filtered_vars.each_with_object({}) do |variable, grouped|
+ grouped[variable.environment_scope] ||= {}
+ grouped[variable.environment_scope][variable.key] = variable.value
+ end
+ end
+
+ def create_or_replace_project_vars(environment_scope, key, value, is_protected, is_masked = false)
+ change_params = {
+ variable_params: {
+ key: key,
+ value: value,
+ environment_scope: environment_scope,
+ protected: is_protected,
+ masked: is_masked
+ }
+ }
+ existing_variable = find_existing_variable(environment_scope, key)
+
+ if existing_variable
+ change_params[:action] = :update
+ change_params[:variable] = existing_variable
+ else
+ change_params[:action] = :create
+ end
+
+ ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute
+ end
+
+ private
+
+ def find_existing_variable(environment_scope, key)
+ filter_params = { key: key, filter: { environment_scope: environment_scope } }
+ ::Ci::VariablesFinder.new(project, filter_params).execute.first
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/create_cloudsql_instance_service.rb b/app/services/cloud_seed/google_cloud/create_cloudsql_instance_service.rb
new file mode 100644
index 00000000000..8b967a2d551
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/create_cloudsql_instance_service.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ DEFAULT_REGION = 'us-east1'
+
+ class CreateCloudsqlInstanceService < ::CloudSeed::GoogleCloud::BaseService
+ WORKER_INTERVAL = 30.seconds
+
+ def execute
+ create_cloud_instance
+ trigger_instance_setup_worker
+ success
+ rescue Google::Apis::Error => err
+ error(err.message)
+ end
+
+ private
+
+ def create_cloud_instance
+ google_api_client.create_cloudsql_instance(
+ gcp_project_id,
+ instance_name,
+ root_password,
+ database_version,
+ region,
+ tier
+ )
+ end
+
+ def trigger_instance_setup_worker
+ ::GoogleCloud::CreateCloudsqlInstanceWorker.perform_in(
+ WORKER_INTERVAL,
+ current_user.id,
+ project.id,
+ {
+ 'google_oauth2_token': google_oauth2_token,
+ 'gcp_project_id': gcp_project_id,
+ 'instance_name': instance_name,
+ 'database_version': database_version,
+ 'environment_name': environment_name,
+ 'is_protected': protected?
+ }
+ )
+ end
+
+ def protected?
+ project.protected_for?(environment_name)
+ end
+
+ def instance_name
+ # Generates an `instance_name` for the to-be-created Cloud SQL instance
+ # Example: `gitlab-34647-postgres-14-staging`
+ environment_alias = environment_name == '*' ? 'ALL' : environment_name
+ name = "gitlab-#{project.id}-#{database_version}-#{environment_alias}"
+ name.tr("_", "-").downcase
+ end
+
+ def root_password
+ SecureRandom.hex(16)
+ end
+
+ def database_version
+ params[:database_version]
+ end
+
+ def region
+ region = ::Ci::VariablesFinder
+ .new(project, { key: Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY,
+ environment_scope: environment_name })
+ .execute.first
+ region&.value || DEFAULT_REGION
+ end
+
+ def tier
+ params[:tier]
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/create_service_accounts_service.rb b/app/services/cloud_seed/google_cloud/create_service_accounts_service.rb
new file mode 100644
index 00000000000..f15779cc14b
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/create_service_accounts_service.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class CreateServiceAccountsService < ::CloudSeed::GoogleCloud::BaseService
+ def execute
+ service_account = google_api_client.create_service_account(gcp_project_id, service_account_name, service_account_desc)
+ service_account_key = google_api_client.create_service_account_key(gcp_project_id, service_account.unique_id)
+ google_api_client.grant_service_account_roles(gcp_project_id, service_account.email)
+
+ service_accounts_service.add_for_project(
+ environment_name,
+ service_account.project_id,
+ Gitlab::Json.dump(service_account),
+ Gitlab::Json.dump(service_account_key),
+ ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name)
+ )
+
+ ServiceResponse.success(message: _('Service account generated successfully'), payload: {
+ service_account: service_account,
+ service_account_key: service_account_key
+ })
+ end
+
+ private
+
+ def service_accounts_service
+ GoogleCloud::ServiceAccountsService.new(project)
+ end
+
+ def service_account_name
+ "GitLab :: #{project.name} :: #{environment_name}"
+ end
+
+ def service_account_desc
+ "GitLab generated service account for project '#{project.name}' and environment '#{environment_name}'"
+ end
+ end
+ end
+end
+
+CloudSeed::GoogleCloud::CreateServiceAccountsService.prepend_mod
diff --git a/app/services/cloud_seed/google_cloud/enable_cloud_run_service.rb b/app/services/cloud_seed/google_cloud/enable_cloud_run_service.rb
new file mode 100644
index 00000000000..3ab5608c937
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/enable_cloud_run_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class EnableCloudRunService < ::CloudSeed::GoogleCloud::BaseService
+ def execute
+ gcp_project_ids = unique_gcp_project_ids
+
+ if gcp_project_ids.empty?
+ error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.")
+ else
+ gcp_project_ids.each do |gcp_project_id|
+ google_api_client.enable_cloud_run(gcp_project_id)
+ google_api_client.enable_artifacts_registry(gcp_project_id)
+ google_api_client.enable_cloud_build(gcp_project_id)
+ end
+
+ success({ gcp_project_ids: gcp_project_ids })
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/enable_cloudsql_service.rb b/app/services/cloud_seed/google_cloud/enable_cloudsql_service.rb
new file mode 100644
index 00000000000..d36f3ffd7c2
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/enable_cloudsql_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class EnableCloudsqlService < ::CloudSeed::GoogleCloud::BaseService
+ def execute
+ create_or_replace_project_vars(environment_name, 'GCP_PROJECT_ID', gcp_project_id, ci_var_protected?)
+
+ unique_gcp_project_ids.each do |gcp_project_id|
+ google_api_client.enable_cloud_sql_admin(gcp_project_id)
+ google_api_client.enable_compute(gcp_project_id)
+ google_api_client.enable_service_networking(gcp_project_id)
+ end
+
+ success({ gcp_project_ids: unique_gcp_project_ids })
+ rescue Google::Apis::Error => err
+ error(err.message)
+ end
+
+ private
+
+ def ci_var_protected?
+ ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name)
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/enable_vision_ai_service.rb b/app/services/cloud_seed/google_cloud/enable_vision_ai_service.rb
new file mode 100644
index 00000000000..865c11cba6a
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/enable_vision_ai_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class EnableVisionAiService < ::CloudSeed::GoogleCloud::BaseService
+ def execute
+ gcp_project_ids = unique_gcp_project_ids
+
+ if gcp_project_ids.empty?
+ error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.")
+ else
+ gcp_project_ids.each do |gcp_project_id|
+ google_api_client.enable_vision_api(gcp_project_id)
+ end
+
+ success({ gcp_project_ids: gcp_project_ids })
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/fetch_google_ip_list_service.rb b/app/services/cloud_seed/google_cloud/fetch_google_ip_list_service.rb
new file mode 100644
index 00000000000..c02b3a87352
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/fetch_google_ip_list_service.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class FetchGoogleIpListService
+ include BaseServiceUtility
+
+ GOOGLE_IP_RANGES_URL = 'https://www.gstatic.com/ipranges/cloud.json'
+ RESPONSE_BODY_LIMIT = 1.megabyte
+ EXPECTED_CONTENT_TYPE = 'application/json'
+
+ IpListNotRetrievedError = Class.new(StandardError)
+
+ def execute
+ # Prevent too many workers from hitting the same HTTP endpoint
+ if ::Gitlab::ApplicationRateLimiter.throttled?(:fetch_google_ip_list, scope: nil)
+ return error("#{self.class} was rate limited")
+ end
+
+ subnets = fetch_and_update_cache!
+
+ Gitlab::AppJsonLogger.info(
+ class: self.class.name,
+ message: 'Successfully retrieved Google IP list',
+ subnet_count: subnets.count
+ )
+
+ success({ subnets: subnets })
+ rescue IpListNotRetrievedError => err
+ Gitlab::ErrorTracking.log_exception(err)
+ error('Google IP list not retrieved')
+ end
+
+ private
+
+ # Attempts to retrieve and parse the list of IPs from Google. Updates
+ # the internal cache so that the data is accessible.
+ #
+ # Returns an array of IPAddr objects consisting of subnets.
+ def fetch_and_update_cache!
+ parsed_response = fetch_google_ip_list
+
+ parse_google_prefixes(parsed_response).tap do |subnets|
+ ::ObjectStorage::CDN::GoogleIpCache.update!(subnets)
+ end
+ end
+
+ def fetch_google_ip_list
+ response = Gitlab::HTTP.get(GOOGLE_IP_RANGES_URL, follow_redirects: false, allow_local_requests: false)
+
+ validate_response!(response)
+
+ response.parsed_response
+ end
+
+ def validate_response!(response)
+ raise IpListNotRetrievedError, "response was #{response.code}" unless response.code == 200
+ raise IpListNotRetrievedError, "response was nil" unless response.body
+
+ parsed_response = response.parsed_response
+
+ unless response.content_type == EXPECTED_CONTENT_TYPE && parsed_response.is_a?(Hash)
+ raise IpListNotRetrievedError, "response was not JSON"
+ end
+
+ if response.body&.bytesize.to_i > RESPONSE_BODY_LIMIT
+ raise IpListNotRetrievedError, "response was too large: #{response.body.bytesize}"
+ end
+
+ prefixes = parsed_response['prefixes']
+
+ raise IpListNotRetrievedError, "JSON was type #{prefixes.class}, expected Array" unless prefixes.is_a?(Array)
+ raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if prefixes.empty?
+
+ response.parsed_response
+ end
+
+ def parse_google_prefixes(parsed_response)
+ ranges = parsed_response['prefixes'].map do |prefix|
+ ip_range = prefix['ipv4Prefix'] || prefix['ipv6Prefix']
+
+ next unless ip_range
+
+ IPAddr.new(ip_range)
+ end.compact
+
+ raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if ranges.empty?
+
+ ranges
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service.rb b/app/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service.rb
new file mode 100644
index 00000000000..11a644b3e9d
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/gcp_region_add_or_replace_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class GcpRegionAddOrReplaceService < ::CloudSeed::GoogleCloud::BaseService
+ def execute(environment, region)
+ gcp_region_key = Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY
+
+ change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } }
+ filter_params = { key: gcp_region_key, filter: { environment_scope: environment } }
+
+ existing_variable = ::Ci::VariablesFinder.new(project, filter_params).execute.first
+
+ if existing_variable
+ change_params[:action] = :update
+ change_params[:variable] = existing_variable
+ else
+ change_params[:action] = :create
+ end
+
+ ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/generate_pipeline_service.rb b/app/services/cloud_seed/google_cloud/generate_pipeline_service.rb
new file mode 100644
index 00000000000..d8b45f301ec
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/generate_pipeline_service.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class GeneratePipelineService < ::CloudSeed::GoogleCloud::BaseService
+ ACTION_DEPLOY_TO_CLOUD_RUN = 'DEPLOY_TO_CLOUD_RUN'
+ ACTION_DEPLOY_TO_CLOUD_STORAGE = 'DEPLOY_TO_CLOUD_STORAGE'
+ ACTION_VISION_AI_PIPELINE = 'VISION_AI_PIPELINE'
+
+ def execute
+ commit_attributes = generate_commit_attributes
+ create_branch_response = ::Branches::CreateService.new(project, current_user)
+ .execute(commit_attributes[:branch_name], project.default_branch)
+
+ if create_branch_response[:status] == :error
+ return create_branch_response
+ end
+
+ branch = create_branch_response[:branch]
+
+ service = default_branch_gitlab_ci_yml.present? ? ::Files::UpdateService : ::Files::CreateService
+
+ commit_response = service.new(project, current_user, commit_attributes).execute
+
+ if commit_response[:status] == :error
+ return commit_response
+ end
+
+ success({ branch_name: branch.name, commit: commit_response })
+ end
+
+ private
+
+ def action
+ @params[:action]
+ end
+
+ def generate_commit_attributes
+ case action
+ when ACTION_DEPLOY_TO_CLOUD_RUN
+ branch_name = "deploy-to-cloud-run-#{SecureRandom.hex(8)}"
+ {
+ commit_message: 'Enable Cloud Run deployments',
+ file_path: '.gitlab-ci.yml',
+ file_content: pipeline_content('gcp/cloud-run.gitlab-ci.yml'),
+ branch_name: branch_name,
+ start_branch: branch_name
+ }
+ when ACTION_DEPLOY_TO_CLOUD_STORAGE
+ branch_name = "deploy-to-cloud-storage-#{SecureRandom.hex(8)}"
+ {
+ commit_message: 'Enable Cloud Storage deployments',
+ file_path: '.gitlab-ci.yml',
+ file_content: pipeline_content('gcp/cloud-storage.gitlab-ci.yml'),
+ branch_name: branch_name,
+ start_branch: branch_name
+ }
+ when ACTION_VISION_AI_PIPELINE
+ branch_name = "vision-ai-pipeline-#{SecureRandom.hex(8)}"
+ {
+ commit_message: 'Enable Vision AI Pipeline',
+ file_path: '.gitlab-ci.yml',
+ file_content: pipeline_content('gcp/vision-ai.gitlab-ci.yml'),
+ branch_name: branch_name,
+ start_branch: branch_name
+ }
+ end
+ end
+
+ def default_branch_gitlab_ci_yml
+ @default_branch_gitlab_ci_yml ||= project.ci_config_for(project.default_branch)
+ end
+
+ def pipeline_content(include_path)
+ gitlab_ci_yml = ::Gitlab::Ci::Config::Yaml::Loader.new(default_branch_gitlab_ci_yml || '{}').load
+
+ append_remote_include(
+ gitlab_ci_yml.content,
+ "https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/#{include_path}"
+ )
+ end
+
+ def append_remote_include(gitlab_ci_yml, include_url)
+ stages = gitlab_ci_yml['stages'] || []
+ gitlab_ci_yml['stages'] = if action == ACTION_VISION_AI_PIPELINE
+ (stages + %w[validate detect render]).uniq
+ else
+ (stages + %w[build test deploy]).uniq
+ end
+
+ includes = gitlab_ci_yml['include'] || []
+ includes = Array.wrap(includes)
+ includes << { 'remote' => include_url }
+ gitlab_ci_yml['include'] = includes.uniq
+
+ gitlab_ci_yml.deep_stringify_keys.to_yaml
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/get_cloudsql_instances_service.rb b/app/services/cloud_seed/google_cloud/get_cloudsql_instances_service.rb
new file mode 100644
index 00000000000..b037298c8cb
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/get_cloudsql_instances_service.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class GetCloudsqlInstancesService < ::CloudSeed::GoogleCloud::BaseService
+ CLOUDSQL_KEYS = %w[GCP_PROJECT_ID GCP_CLOUDSQL_INSTANCE_NAME GCP_CLOUDSQL_VERSION].freeze
+
+ def execute
+ group_vars_by_environment(CLOUDSQL_KEYS).map do |environment_scope, value|
+ {
+ ref: environment_scope,
+ gcp_project: value['GCP_PROJECT_ID'],
+ instance_name: value['GCP_CLOUDSQL_INSTANCE_NAME'],
+ version: value['GCP_CLOUDSQL_VERSION']
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/service_accounts_service.rb b/app/services/cloud_seed/google_cloud/service_accounts_service.rb
new file mode 100644
index 00000000000..4881c440c9c
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/service_accounts_service.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ ##
+ # GCP keys used to store Google Cloud Service Accounts
+ GCP_KEYS = %w[GCP_PROJECT_ID GCP_SERVICE_ACCOUNT GCP_SERVICE_ACCOUNT_KEY].freeze
+
+ ##
+ # This service deals with GCP Service Accounts in GitLab
+
+ class ServiceAccountsService < ::CloudSeed::GoogleCloud::BaseService
+ ##
+ # Find GCP Service Accounts in a GitLab project
+ #
+ # This method looks up GitLab project's CI vars
+ # and returns Google Cloud Service Accounts combinations
+ # aligning GitLab project and ref to GCP projects
+
+ def find_for_project
+ group_vars_by_environment(GCP_KEYS).map do |environment_scope, value|
+ {
+ ref: environment_scope,
+ gcp_project: value['GCP_PROJECT_ID'],
+ service_account_exists: value['GCP_SERVICE_ACCOUNT'].present?,
+ service_account_key_exists: value['GCP_SERVICE_ACCOUNT_KEY'].present?
+ }
+ end
+ end
+
+ def add_for_project(ref, gcp_project_id, service_account, service_account_key, is_protected)
+ create_or_replace_project_vars(
+ ref,
+ 'GCP_PROJECT_ID',
+ gcp_project_id,
+ is_protected
+ )
+ create_or_replace_project_vars(
+ ref,
+ 'GCP_SERVICE_ACCOUNT',
+ service_account,
+ is_protected
+ )
+ create_or_replace_project_vars(
+ ref,
+ 'GCP_SERVICE_ACCOUNT_KEY',
+ service_account_key,
+ is_protected
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/cloud_seed/google_cloud/setup_cloudsql_instance_service.rb b/app/services/cloud_seed/google_cloud/setup_cloudsql_instance_service.rb
new file mode 100644
index 00000000000..b8c160f0683
--- /dev/null
+++ b/app/services/cloud_seed/google_cloud/setup_cloudsql_instance_service.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+module CloudSeed
+ module GoogleCloud
+ class SetupCloudsqlInstanceService < ::CloudSeed::GoogleCloud::BaseService
+ INSTANCE_STATE_RUNNABLE = 'RUNNABLE'
+ OPERATION_STATE_DONE = 'DONE'
+ DEFAULT_DATABASE_NAME = 'main_db'
+ DEFAULT_DATABASE_USER = 'main_user'
+
+ def execute
+ return error('Unauthorized user') unless Ability.allowed?(current_user, :admin_project_google_cloud, project)
+
+ get_instance_response = google_api_client.get_cloudsql_instance(gcp_project_id, instance_name)
+
+ if get_instance_response.state != INSTANCE_STATE_RUNNABLE
+ return error("CloudSQL instance not RUNNABLE: #{Gitlab::Json.dump(get_instance_response)}")
+ end
+
+ save_instance_ci_vars(get_instance_response)
+
+ list_database_response = google_api_client.list_cloudsql_databases(gcp_project_id, instance_name)
+ list_user_response = google_api_client.list_cloudsql_users(gcp_project_id, instance_name)
+
+ existing_database = list_database_response.items.find { |database| database.name == database_name }
+ existing_user = list_user_response.items.find { |user| user.name == username }
+
+ if existing_database && existing_user
+ save_database_ci_vars
+ save_user_ci_vars(existing_user)
+ return success
+ end
+
+ database_response = execute_database_setup(existing_database)
+ return database_response if database_response[:status] == :error
+
+ save_database_ci_vars
+
+ user_response = execute_user_setup(existing_user)
+ return user_response if user_response[:status] == :error
+
+ save_user_ci_vars(existing_user)
+
+ success
+ rescue Google::Apis::Error => err
+ error(message: Gitlab::Json.dump(err))
+ end
+
+ private
+
+ def instance_name
+ @params[:instance_name]
+ end
+
+ def database_version
+ @params[:database_version]
+ end
+
+ def database_name
+ @params.fetch(:database_name, DEFAULT_DATABASE_NAME)
+ end
+
+ def username
+ @params.fetch(:username, DEFAULT_DATABASE_USER)
+ end
+
+ def password
+ @password ||= SecureRandom.hex(16)
+ end
+
+ def save_ci_var(key, value, is_masked = false)
+ create_or_replace_project_vars(environment_name, key, value, @params[:is_protected], is_masked)
+ end
+
+ def save_instance_ci_vars(cloudsql_instance)
+ primary_ip_address = cloudsql_instance.ip_addresses.first.ip_address
+ connection_name = cloudsql_instance.connection_name
+
+ save_ci_var('GCP_PROJECT_ID', gcp_project_id)
+ save_ci_var('GCP_CLOUDSQL_INSTANCE_NAME', instance_name)
+ save_ci_var('GCP_CLOUDSQL_CONNECTION_NAME', connection_name)
+ save_ci_var('GCP_CLOUDSQL_PRIMARY_IP_ADDRESS', primary_ip_address)
+ save_ci_var('GCP_CLOUDSQL_VERSION', database_version)
+ end
+
+ def save_database_ci_vars
+ save_ci_var('GCP_CLOUDSQL_DATABASE_NAME', database_name)
+ end
+
+ def save_user_ci_vars(user_exists)
+ save_ci_var('GCP_CLOUDSQL_DATABASE_USER', username)
+ save_ci_var('GCP_CLOUDSQL_DATABASE_PASS', user_exists ? user_exists.password : password, true)
+ end
+
+ def execute_database_setup(database_exists)
+ return success if database_exists
+
+ database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name)
+
+ if database_response.status != OPERATION_STATE_DONE
+ return error("Database creation failed: #{Gitlab::Json.dump(database_response)}")
+ end
+
+ success
+ end
+
+ def execute_user_setup(existing_user)
+ return success if existing_user
+
+ user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password)
+
+ if user_response.status != OPERATION_STATE_DONE
+ return error("User creation failed: #{Gitlab::Json.dump(user_response)}")
+ end
+
+ success
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/agents/authorizations/user_access/refresh_service.rb b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb
index 7efa95739fb..4c3d059777a 100644
--- a/app/services/clusters/agents/authorizations/user_access/refresh_service.rb
+++ b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb
@@ -59,7 +59,7 @@ module Clusters
return unless project_entries
- allowed_projects.where_full_path_in(project_entries.keys, use_includes: false).map do |project|
+ allowed_projects.where_full_path_in(project_entries.keys, preload_routes: false).map do |project|
{ project_id: project.id, config: user_access_as }
end
end
@@ -70,7 +70,7 @@ module Clusters
return unless group_entries
- allowed_groups.where_full_path_in(group_entries.keys, use_includes: false).map do |group|
+ allowed_groups.where_full_path_in(group_entries.keys, preload_routes: false).map do |group|
{ group_id: group.id, config: user_access_as }
end
end
diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb
index a54c4947b0b..f84793d869c 100644
--- a/app/services/concerns/users/participable_service.rb
+++ b/app/services/concerns/users/participable_service.rb
@@ -3,6 +3,9 @@
module Users
module ParticipableService
extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ SEARCH_LIMIT = 10
included do
attr_reader :noteable
@@ -25,6 +28,16 @@ module Users
sorted(users)
end
+ def filter_and_sort_users(users_relation)
+ if params[:search]
+ users_relation.gfm_autocomplete_search(params[:search]).limit(SEARCH_LIMIT).tap do |users|
+ preload_status(users)
+ end
+ else
+ sorted(users_relation)
+ end
+ end
+
def sorted(users)
users.uniq.to_a.compact.sort_by(&:username).tap do |users|
preload_status(users)
@@ -34,8 +47,15 @@ module Users
def groups
return [] unless current_user
- current_user.authorized_groups.with_route.sort_by(&:full_path)
+ relation = current_user.authorized_groups
+
+ if params[:search]
+ relation.gfm_autocomplete_search(params[:search]).limit(SEARCH_LIMIT).to_a
+ else
+ relation.with_route.sort_by(&:full_path)
+ end
end
+ strong_memoize_attr :groups
def render_participants_as_hash(participants)
participants.map { |participant| participant_as_hash(participant) }
@@ -74,11 +94,14 @@ module Users
end
def group_counts
- @group_counts ||= GroupMember
- .of_groups(current_user.authorized_groups)
+ groups_for_count = params[:search] ? groups : current_user.authorized_groups
+
+ GroupMember
+ .of_groups(groups_for_count)
.non_request
.count_users_by_group_id
end
+ strong_memoize_attr :group_counts
def preload_status(users)
users.each { |u| lazy_user_availability(u) }
diff --git a/app/services/draft_notes/destroy_service.rb b/app/services/draft_notes/destroy_service.rb
index ddca0debb03..6c7b0dfdbd7 100644
--- a/app/services/draft_notes/destroy_service.rb
+++ b/app/services/draft_notes/destroy_service.rb
@@ -15,9 +15,11 @@ module DraftNotes
private
def clear_highlight_diffs_cache(drafts)
- if drafts.any? { |draft| draft.diff_file&.unfolded? }
- merge_request.diffs.clear_cache
- end
+ merge_request.diffs.clear_cache if unfolded_drafts?(drafts)
+ end
+
+ def unfolded_drafts?(drafts)
+ drafts.any? { |draft| draft.diff_file&.unfolded? }
end
end
end
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index b755f512772..1a4e691a059 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -100,8 +100,10 @@ class EventCreateService
end
end
- def join_project(project, current_user)
- create_event(project, current_user, :joined)
+ def join_source(source, current_user)
+ return unless source.is_a?(Project)
+
+ create_event(source, current_user, :joined)
end
def leave_project(project, current_user)
diff --git a/app/services/google_cloud/base_service.rb b/app/services/google_cloud/base_service.rb
deleted file mode 100644
index 01aee2231c9..00000000000
--- a/app/services/google_cloud/base_service.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class BaseService < ::BaseService
- protected
-
- def google_oauth2_token
- @params[:google_oauth2_token]
- end
-
- def gcp_project_id
- @params[:gcp_project_id]
- end
-
- def environment_name
- @params[:environment_name]
- end
-
- def google_api_client
- @google_api_client_instance ||= GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil)
- end
-
- def unique_gcp_project_ids
- filter_params = { key: 'GCP_PROJECT_ID' }
- @unique_gcp_project_ids ||= ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq
- end
-
- def group_vars_by_environment(keys)
- filtered_vars = project.variables.filter { |variable| keys.include? variable.key }
- filtered_vars.each_with_object({}) do |variable, grouped|
- grouped[variable.environment_scope] ||= {}
- grouped[variable.environment_scope][variable.key] = variable.value
- end
- end
-
- def create_or_replace_project_vars(environment_scope, key, value, is_protected, is_masked = false)
- change_params = {
- variable_params: {
- key: key,
- value: value,
- environment_scope: environment_scope,
- protected: is_protected,
- masked: is_masked
- }
- }
- existing_variable = find_existing_variable(environment_scope, key)
-
- if existing_variable
- change_params[:action] = :update
- change_params[:variable] = existing_variable
- else
- change_params[:action] = :create
- end
-
- ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute
- end
-
- private
-
- def find_existing_variable(environment_scope, key)
- filter_params = { key: key, filter: { environment_scope: environment_scope } }
- ::Ci::VariablesFinder.new(project, filter_params).execute.first
- end
- end
-end
diff --git a/app/services/google_cloud/create_cloudsql_instance_service.rb b/app/services/google_cloud/create_cloudsql_instance_service.rb
deleted file mode 100644
index 9a1263f0796..00000000000
--- a/app/services/google_cloud/create_cloudsql_instance_service.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- DEFAULT_REGION = 'us-east1'
-
- class CreateCloudsqlInstanceService < ::GoogleCloud::BaseService
- WORKER_INTERVAL = 30.seconds
-
- def execute
- create_cloud_instance
- trigger_instance_setup_worker
- success
- rescue Google::Apis::Error => err
- error(err.message)
- end
-
- private
-
- def create_cloud_instance
- google_api_client.create_cloudsql_instance(
- gcp_project_id,
- instance_name,
- root_password,
- database_version,
- region,
- tier
- )
- end
-
- def trigger_instance_setup_worker
- GoogleCloud::CreateCloudsqlInstanceWorker.perform_in(
- WORKER_INTERVAL,
- current_user.id,
- project.id,
- {
- 'google_oauth2_token': google_oauth2_token,
- 'gcp_project_id': gcp_project_id,
- 'instance_name': instance_name,
- 'database_version': database_version,
- 'environment_name': environment_name,
- 'is_protected': protected?
- }
- )
- end
-
- def protected?
- project.protected_for?(environment_name)
- end
-
- def instance_name
- # Generates an `instance_name` for the to-be-created Cloud SQL instance
- # Example: `gitlab-34647-postgres-14-staging`
- environment_alias = environment_name == '*' ? 'ALL' : environment_name
- name = "gitlab-#{project.id}-#{database_version}-#{environment_alias}"
- name.tr("_", "-").downcase
- end
-
- def root_password
- SecureRandom.hex(16)
- end
-
- def database_version
- params[:database_version]
- end
-
- def region
- region = ::Ci::VariablesFinder
- .new(project, { key: Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY,
- environment_scope: environment_name })
- .execute.first
- region&.value || DEFAULT_REGION
- end
-
- def tier
- params[:tier]
- end
- end
-end
diff --git a/app/services/google_cloud/create_service_accounts_service.rb b/app/services/google_cloud/create_service_accounts_service.rb
deleted file mode 100644
index ca0aa7c91df..00000000000
--- a/app/services/google_cloud/create_service_accounts_service.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class CreateServiceAccountsService < ::GoogleCloud::BaseService
- def execute
- service_account = google_api_client.create_service_account(gcp_project_id, service_account_name, service_account_desc)
- service_account_key = google_api_client.create_service_account_key(gcp_project_id, service_account.unique_id)
- google_api_client.grant_service_account_roles(gcp_project_id, service_account.email)
-
- service_accounts_service.add_for_project(
- environment_name,
- service_account.project_id,
- Gitlab::Json.dump(service_account),
- Gitlab::Json.dump(service_account_key),
- ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name)
- )
-
- ServiceResponse.success(message: _('Service account generated successfully'), payload: {
- service_account: service_account,
- service_account_key: service_account_key
- })
- end
-
- private
-
- def service_accounts_service
- GoogleCloud::ServiceAccountsService.new(project)
- end
-
- def service_account_name
- "GitLab :: #{project.name} :: #{environment_name}"
- end
-
- def service_account_desc
- "GitLab generated service account for project '#{project.name}' and environment '#{environment_name}'"
- end
- end
-end
-
-GoogleCloud::CreateServiceAccountsService.prepend_mod
diff --git a/app/services/google_cloud/enable_cloud_run_service.rb b/app/services/google_cloud/enable_cloud_run_service.rb
deleted file mode 100644
index 4fd92f423c5..00000000000
--- a/app/services/google_cloud/enable_cloud_run_service.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class EnableCloudRunService < ::GoogleCloud::BaseService
- def execute
- gcp_project_ids = unique_gcp_project_ids
-
- if gcp_project_ids.empty?
- error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.")
- else
- gcp_project_ids.each do |gcp_project_id|
- google_api_client.enable_cloud_run(gcp_project_id)
- google_api_client.enable_artifacts_registry(gcp_project_id)
- google_api_client.enable_cloud_build(gcp_project_id)
- end
-
- success({ gcp_project_ids: gcp_project_ids })
- end
- end
- end
-end
diff --git a/app/services/google_cloud/enable_cloudsql_service.rb b/app/services/google_cloud/enable_cloudsql_service.rb
deleted file mode 100644
index 911cccca5ca..00000000000
--- a/app/services/google_cloud/enable_cloudsql_service.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class EnableCloudsqlService < ::GoogleCloud::BaseService
- def execute
- create_or_replace_project_vars(environment_name, 'GCP_PROJECT_ID', gcp_project_id, ci_var_protected?)
-
- unique_gcp_project_ids.each do |gcp_project_id|
- google_api_client.enable_cloud_sql_admin(gcp_project_id)
- google_api_client.enable_compute(gcp_project_id)
- google_api_client.enable_service_networking(gcp_project_id)
- end
-
- success({ gcp_project_ids: unique_gcp_project_ids })
- rescue Google::Apis::Error => err
- error(err.message)
- end
-
- private
-
- def ci_var_protected?
- ProtectedBranch.protected?(project, environment_name) || ProtectedTag.protected?(project, environment_name)
- end
- end
-end
diff --git a/app/services/google_cloud/enable_vision_ai_service.rb b/app/services/google_cloud/enable_vision_ai_service.rb
deleted file mode 100644
index f7adea706ed..00000000000
--- a/app/services/google_cloud/enable_vision_ai_service.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class EnableVisionAiService < ::GoogleCloud::BaseService
- def execute
- gcp_project_ids = unique_gcp_project_ids
-
- if gcp_project_ids.empty?
- error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.")
- else
- gcp_project_ids.each do |gcp_project_id|
- google_api_client.enable_vision_api(gcp_project_id)
- end
-
- success({ gcp_project_ids: gcp_project_ids })
- end
- end
- end
-end
diff --git a/app/services/google_cloud/fetch_google_ip_list_service.rb b/app/services/google_cloud/fetch_google_ip_list_service.rb
deleted file mode 100644
index 54af841d002..00000000000
--- a/app/services/google_cloud/fetch_google_ip_list_service.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class FetchGoogleIpListService
- include BaseServiceUtility
-
- GOOGLE_IP_RANGES_URL = 'https://www.gstatic.com/ipranges/cloud.json'
- RESPONSE_BODY_LIMIT = 1.megabyte
- EXPECTED_CONTENT_TYPE = 'application/json'
-
- IpListNotRetrievedError = Class.new(StandardError)
-
- def execute
- # Prevent too many workers from hitting the same HTTP endpoint
- if ::Gitlab::ApplicationRateLimiter.throttled?(:fetch_google_ip_list, scope: nil)
- return error("#{self.class} was rate limited")
- end
-
- subnets = fetch_and_update_cache!
-
- Gitlab::AppJsonLogger.info(
- class: self.class.name,
- message: 'Successfully retrieved Google IP list',
- subnet_count: subnets.count
- )
-
- success({ subnets: subnets })
- rescue IpListNotRetrievedError => err
- Gitlab::ErrorTracking.log_exception(err)
- error('Google IP list not retrieved')
- end
-
- private
-
- # Attempts to retrieve and parse the list of IPs from Google. Updates
- # the internal cache so that the data is accessible.
- #
- # Returns an array of IPAddr objects consisting of subnets.
- def fetch_and_update_cache!
- parsed_response = fetch_google_ip_list
-
- parse_google_prefixes(parsed_response).tap do |subnets|
- ::ObjectStorage::CDN::GoogleIpCache.update!(subnets)
- end
- end
-
- def fetch_google_ip_list
- response = Gitlab::HTTP.get(GOOGLE_IP_RANGES_URL, follow_redirects: false, allow_local_requests: false)
-
- validate_response!(response)
-
- response.parsed_response
- end
-
- def validate_response!(response)
- raise IpListNotRetrievedError, "response was #{response.code}" unless response.code == 200
- raise IpListNotRetrievedError, "response was nil" unless response.body
-
- parsed_response = response.parsed_response
-
- unless response.content_type == EXPECTED_CONTENT_TYPE && parsed_response.is_a?(Hash)
- raise IpListNotRetrievedError, "response was not JSON"
- end
-
- if response.body&.bytesize.to_i > RESPONSE_BODY_LIMIT
- raise IpListNotRetrievedError, "response was too large: #{response.body.bytesize}"
- end
-
- prefixes = parsed_response['prefixes']
-
- raise IpListNotRetrievedError, "JSON was type #{prefixes.class}, expected Array" unless prefixes.is_a?(Array)
- raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if prefixes.empty?
-
- response.parsed_response
- end
-
- def parse_google_prefixes(parsed_response)
- ranges = parsed_response['prefixes'].map do |prefix|
- ip_range = prefix['ipv4Prefix'] || prefix['ipv6Prefix']
-
- next unless ip_range
-
- IPAddr.new(ip_range)
- end.compact
-
- raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if ranges.empty?
-
- ranges
- end
- end
-end
diff --git a/app/services/google_cloud/gcp_region_add_or_replace_service.rb b/app/services/google_cloud/gcp_region_add_or_replace_service.rb
deleted file mode 100644
index f79df707a08..00000000000
--- a/app/services/google_cloud/gcp_region_add_or_replace_service.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class GcpRegionAddOrReplaceService < ::GoogleCloud::BaseService
- def execute(environment, region)
- gcp_region_key = Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY
-
- change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } }
- filter_params = { key: gcp_region_key, filter: { environment_scope: environment } }
-
- existing_variable = ::Ci::VariablesFinder.new(project, filter_params).execute.first
-
- if existing_variable
- change_params[:action] = :update
- change_params[:variable] = existing_variable
- else
- change_params[:action] = :create
- end
-
- ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute
- end
- end
-end
diff --git a/app/services/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb
deleted file mode 100644
index 97d008db76b..00000000000
--- a/app/services/google_cloud/generate_pipeline_service.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class GeneratePipelineService < ::GoogleCloud::BaseService
- ACTION_DEPLOY_TO_CLOUD_RUN = 'DEPLOY_TO_CLOUD_RUN'
- ACTION_DEPLOY_TO_CLOUD_STORAGE = 'DEPLOY_TO_CLOUD_STORAGE'
- ACTION_VISION_AI_PIPELINE = 'VISION_AI_PIPELINE'
-
- def execute
- commit_attributes = generate_commit_attributes
- create_branch_response = ::Branches::CreateService.new(project, current_user)
- .execute(commit_attributes[:branch_name], project.default_branch)
-
- if create_branch_response[:status] == :error
- return create_branch_response
- end
-
- branch = create_branch_response[:branch]
-
- service = default_branch_gitlab_ci_yml.present? ? ::Files::UpdateService : ::Files::CreateService
-
- commit_response = service.new(project, current_user, commit_attributes).execute
-
- if commit_response[:status] == :error
- return commit_response
- end
-
- success({ branch_name: branch.name, commit: commit_response })
- end
-
- private
-
- def action
- @params[:action]
- end
-
- def generate_commit_attributes
- case action
- when ACTION_DEPLOY_TO_CLOUD_RUN
- branch_name = "deploy-to-cloud-run-#{SecureRandom.hex(8)}"
- {
- commit_message: 'Enable Cloud Run deployments',
- file_path: '.gitlab-ci.yml',
- file_content: pipeline_content('gcp/cloud-run.gitlab-ci.yml'),
- branch_name: branch_name,
- start_branch: branch_name
- }
- when ACTION_DEPLOY_TO_CLOUD_STORAGE
- branch_name = "deploy-to-cloud-storage-#{SecureRandom.hex(8)}"
- {
- commit_message: 'Enable Cloud Storage deployments',
- file_path: '.gitlab-ci.yml',
- file_content: pipeline_content('gcp/cloud-storage.gitlab-ci.yml'),
- branch_name: branch_name,
- start_branch: branch_name
- }
- when ACTION_VISION_AI_PIPELINE
- branch_name = "vision-ai-pipeline-#{SecureRandom.hex(8)}"
- {
- commit_message: 'Enable Vision AI Pipeline',
- file_path: '.gitlab-ci.yml',
- file_content: pipeline_content('gcp/vision-ai.gitlab-ci.yml'),
- branch_name: branch_name,
- start_branch: branch_name
- }
- end
- end
-
- def default_branch_gitlab_ci_yml
- @default_branch_gitlab_ci_yml ||= project.ci_config_for(project.default_branch)
- end
-
- def pipeline_content(include_path)
- gitlab_ci_yml = ::Gitlab::Ci::Config::Yaml::Loader.new(default_branch_gitlab_ci_yml || '{}').load
-
- append_remote_include(
- gitlab_ci_yml.content,
- "https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/#{include_path}"
- )
- end
-
- def append_remote_include(gitlab_ci_yml, include_url)
- stages = gitlab_ci_yml['stages'] || []
- gitlab_ci_yml['stages'] = if action == ACTION_VISION_AI_PIPELINE
- (stages + %w[validate detect render]).uniq
- else
- (stages + %w[build test deploy]).uniq
- end
-
- includes = gitlab_ci_yml['include'] || []
- includes = Array.wrap(includes)
- includes << { 'remote' => include_url }
- gitlab_ci_yml['include'] = includes.uniq
-
- gitlab_ci_yml.deep_stringify_keys.to_yaml
- end
- end
-end
diff --git a/app/services/google_cloud/get_cloudsql_instances_service.rb b/app/services/google_cloud/get_cloudsql_instances_service.rb
deleted file mode 100644
index 701e83d556d..00000000000
--- a/app/services/google_cloud/get_cloudsql_instances_service.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class GetCloudsqlInstancesService < ::GoogleCloud::BaseService
- CLOUDSQL_KEYS = %w[GCP_PROJECT_ID GCP_CLOUDSQL_INSTANCE_NAME GCP_CLOUDSQL_VERSION].freeze
-
- def execute
- group_vars_by_environment(CLOUDSQL_KEYS).map do |environment_scope, value|
- {
- ref: environment_scope,
- gcp_project: value['GCP_PROJECT_ID'],
- instance_name: value['GCP_CLOUDSQL_INSTANCE_NAME'],
- version: value['GCP_CLOUDSQL_VERSION']
- }
- end
- end
- end
-end
diff --git a/app/services/google_cloud/service_accounts_service.rb b/app/services/google_cloud/service_accounts_service.rb
deleted file mode 100644
index e90fd112e2e..00000000000
--- a/app/services/google_cloud/service_accounts_service.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- ##
- # GCP keys used to store Google Cloud Service Accounts
- GCP_KEYS = %w[GCP_PROJECT_ID GCP_SERVICE_ACCOUNT GCP_SERVICE_ACCOUNT_KEY].freeze
-
- ##
- # This service deals with GCP Service Accounts in GitLab
-
- class ServiceAccountsService < ::GoogleCloud::BaseService
- ##
- # Find GCP Service Accounts in a GitLab project
- #
- # This method looks up GitLab project's CI vars
- # and returns Google Cloud Service Accounts combinations
- # aligning GitLab project and ref to GCP projects
-
- def find_for_project
- group_vars_by_environment(GCP_KEYS).map do |environment_scope, value|
- {
- ref: environment_scope,
- gcp_project: value['GCP_PROJECT_ID'],
- service_account_exists: value['GCP_SERVICE_ACCOUNT'].present?,
- service_account_key_exists: value['GCP_SERVICE_ACCOUNT_KEY'].present?
- }
- end
- end
-
- def add_for_project(ref, gcp_project_id, service_account, service_account_key, is_protected)
- create_or_replace_project_vars(
- ref,
- 'GCP_PROJECT_ID',
- gcp_project_id,
- is_protected
- )
- create_or_replace_project_vars(
- ref,
- 'GCP_SERVICE_ACCOUNT',
- service_account,
- is_protected
- )
- create_or_replace_project_vars(
- ref,
- 'GCP_SERVICE_ACCOUNT_KEY',
- service_account_key,
- is_protected
- )
- end
- end
-end
diff --git a/app/services/google_cloud/setup_cloudsql_instance_service.rb b/app/services/google_cloud/setup_cloudsql_instance_service.rb
deleted file mode 100644
index 40184b927ad..00000000000
--- a/app/services/google_cloud/setup_cloudsql_instance_service.rb
+++ /dev/null
@@ -1,118 +0,0 @@
-# frozen_string_literal: true
-
-module GoogleCloud
- class SetupCloudsqlInstanceService < ::GoogleCloud::BaseService
- INSTANCE_STATE_RUNNABLE = 'RUNNABLE'
- OPERATION_STATE_DONE = 'DONE'
- DEFAULT_DATABASE_NAME = 'main_db'
- DEFAULT_DATABASE_USER = 'main_user'
-
- def execute
- return error('Unauthorized user') unless Ability.allowed?(current_user, :admin_project_google_cloud, project)
-
- get_instance_response = google_api_client.get_cloudsql_instance(gcp_project_id, instance_name)
-
- if get_instance_response.state != INSTANCE_STATE_RUNNABLE
- return error("CloudSQL instance not RUNNABLE: #{Gitlab::Json.dump(get_instance_response)}")
- end
-
- save_instance_ci_vars(get_instance_response)
-
- list_database_response = google_api_client.list_cloudsql_databases(gcp_project_id, instance_name)
- list_user_response = google_api_client.list_cloudsql_users(gcp_project_id, instance_name)
-
- existing_database = list_database_response.items.find { |database| database.name == database_name }
- existing_user = list_user_response.items.find { |user| user.name == username }
-
- if existing_database && existing_user
- save_database_ci_vars
- save_user_ci_vars(existing_user)
- return success
- end
-
- database_response = execute_database_setup(existing_database)
- return database_response if database_response[:status] == :error
-
- save_database_ci_vars
-
- user_response = execute_user_setup(existing_user)
- return user_response if user_response[:status] == :error
-
- save_user_ci_vars(existing_user)
-
- success
- rescue Google::Apis::Error => err
- error(message: Gitlab::Json.dump(err))
- end
-
- private
-
- def instance_name
- @params[:instance_name]
- end
-
- def database_version
- @params[:database_version]
- end
-
- def database_name
- @params.fetch(:database_name, DEFAULT_DATABASE_NAME)
- end
-
- def username
- @params.fetch(:username, DEFAULT_DATABASE_USER)
- end
-
- def password
- @password ||= SecureRandom.hex(16)
- end
-
- def save_ci_var(key, value, is_masked = false)
- create_or_replace_project_vars(environment_name, key, value, @params[:is_protected], is_masked)
- end
-
- def save_instance_ci_vars(cloudsql_instance)
- primary_ip_address = cloudsql_instance.ip_addresses.first.ip_address
- connection_name = cloudsql_instance.connection_name
-
- save_ci_var('GCP_PROJECT_ID', gcp_project_id)
- save_ci_var('GCP_CLOUDSQL_INSTANCE_NAME', instance_name)
- save_ci_var('GCP_CLOUDSQL_CONNECTION_NAME', connection_name)
- save_ci_var('GCP_CLOUDSQL_PRIMARY_IP_ADDRESS', primary_ip_address)
- save_ci_var('GCP_CLOUDSQL_VERSION', database_version)
- end
-
- def save_database_ci_vars
- save_ci_var('GCP_CLOUDSQL_DATABASE_NAME', database_name)
- end
-
- def save_user_ci_vars(user_exists)
- save_ci_var('GCP_CLOUDSQL_DATABASE_USER', username)
- save_ci_var('GCP_CLOUDSQL_DATABASE_PASS', user_exists ? user_exists.password : password, true)
- end
-
- def execute_database_setup(database_exists)
- return success if database_exists
-
- database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name)
-
- if database_response.status != OPERATION_STATE_DONE
- return error("Database creation failed: #{Gitlab::Json.dump(database_response)}")
- end
-
- success
- end
-
- def execute_user_setup(existing_user)
- return success if existing_user
-
- user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password)
-
- if user_response.status != OPERATION_STATE_DONE
- return error("User creation failed: #{Gitlab::Json.dump(user_response)}")
- end
-
- success
- end
- end
-end
diff --git a/app/services/google_cloud_platform/artifact_registry/list_docker_images_service.rb b/app/services/google_cloud_platform/artifact_registry/list_docker_images_service.rb
new file mode 100644
index 00000000000..c9afa8609f9
--- /dev/null
+++ b/app/services/google_cloud_platform/artifact_registry/list_docker_images_service.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module GoogleCloudPlatform
+ module ArtifactRegistry
+ class ListDockerImagesService < BaseProjectService
+ def execute(page_token: nil)
+ return ServiceResponse.error(message: "Access denied") unless allowed?
+
+ ServiceResponse.success(payload: client.list_docker_images(page_token: page_token))
+ end
+
+ private
+
+ def allowed?
+ can?(current_user, :read_container_image, project)
+ end
+
+ def client
+ ::Integrations::GoogleCloudPlatform::ArtifactRegistry::Client.new(
+ project: project,
+ user: current_user,
+ gcp_project_id: gcp_project_id,
+ gcp_location: gcp_location,
+ gcp_repository: gcp_repository,
+ gcp_wlif: gcp_wlif
+ )
+ end
+
+ def gcp_project_id
+ params[:gcp_project_id]
+ end
+
+ def gcp_location
+ params[:gcp_location]
+ end
+
+ def gcp_repository
+ params[:gcp_repository]
+ end
+
+ def gcp_wlif
+ params[:gcp_wlif]
+ end
+ end
+ end
+end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 21d3c6499a0..06c6560f0fe 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -92,9 +92,32 @@ module Groups
end
end
+ unless organization_setting_valid?
+ # We are unsetting this here to match behavior of invalid parent_id above and protect against possible
+ # committing to the database of a value that isn't allowed.
+ @group.organization = nil
+ message = s_("CreateGroup|You don't have permission to create a group in the provided organization.")
+ @group.errors.add(:organization_id, message)
+
+ return false
+ end
+
true
end
+ def organization_setting_valid?
+ # we check for the params presence explicitly since:
+ # 1. We have a default organization_id at db level set and organization exists and may not have the entry
+ # in organization_users table to allow authorization. This shouldn't be the case longterm as we
+ # plan on populating organization_users correctly.
+ # 2. We shouldn't need to check if this is allowed if the user didn't try to set it themselves. i.e.
+ # provided in the params
+ return true if params[:organization_id].blank?
+ return true if @group.organization.blank?
+
+ can?(current_user, :create_group, @group.organization)
+ end
+
def can_use_visibility_level?
unless Gitlab::VisibilityLevel.allowed_for?(current_user, visibility_level)
deny_visibility_level(@group)
diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb
index 7b68b435f14..ae1a917f022 100644
--- a/app/services/groups/participants_service.rb
+++ b/app/services/groups/participants_service.rb
@@ -29,7 +29,9 @@ module Groups
def group_hierarchy_users
return [] unless group
- sorted(Autocomplete::GroupUsersFinder.new(group: group).execute)
+ relation = Autocomplete::GroupUsersFinder.new(group: group).execute
+
+ filter_and_sort_users(relation)
end
end
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 79557dae14a..9fc1a05476e 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -236,7 +236,7 @@ module Groups
def ensure_ownership
return if @new_parent_group
- return unless @group.owners.empty?
+ return unless @group.all_owner_members.empty?
add_owner_on_transferred_group
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index d91e09d212a..a6ef8c8743b 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -29,6 +29,8 @@ module Groups
handle_namespace_settings
+ handle_hierarchy_cache_update
+
group.assign_attributes(params)
begin
@@ -46,6 +48,28 @@ module Groups
private
+ def handle_hierarchy_cache_update
+ return unless params.key?(:enable_namespace_descendants_cache)
+
+ enabled = Gitlab::Utils.to_boolean(params.delete(:enable_namespace_descendants_cache))
+
+ return unless Feature.enabled?(:group_hierarchy_optimization, group, type: :beta)
+
+ if enabled
+ return if group.namespace_descendants
+
+ params[:namespace_descendants_attributes] = {
+ traversal_ids: group.traversal_ids,
+ all_project_ids: [],
+ self_and_descendant_group_ids: []
+ }
+ else
+ return unless group.namespace_descendants
+
+ params[:namespace_descendants_attributes] = { id: group.id, _destroy: true }
+ end
+ end
+
def valid_path_change?
return true unless group.packages_feature_enabled?
return true if params[:path].blank?
diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb
index e628e88eaa9..d8f39d7b963 100644
--- a/app/services/import/bitbucket_server_service.rb
+++ b/app/services/import/bitbucket_server_service.rb
@@ -84,11 +84,12 @@ module Import
end
def blocked_url?
- Gitlab::UrlBlocker.blocked_url?(
+ Gitlab::HTTP_V2::UrlBlocker.blocked_url?(
url,
allow_localhost: allow_local_requests?,
allow_local_network: allow_local_requests?,
- schemes: %w[http https]
+ schemes: %w[http https],
+ deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?
)
end
diff --git a/app/services/import/fogbugz_service.rb b/app/services/import/fogbugz_service.rb
index 2f63e4e6fb7..52d9cb77c0a 100644
--- a/app/services/import/fogbugz_service.rb
+++ b/app/services/import/fogbugz_service.rb
@@ -84,11 +84,12 @@ module Import
end
def blocked_url?(url)
- Gitlab::UrlBlocker.blocked_url?(
+ Gitlab::HTTP_V2::UrlBlocker.blocked_url?(
url,
allow_localhost: allow_local_requests?,
allow_local_network: allow_local_requests?,
- schemes: %w[http https]
+ schemes: %w[http https],
+ deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?
)
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index a96bfd74cd0..ffd26e2aaca 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -5,6 +5,8 @@ module Import
include ActiveSupport::NumberHelper
include Gitlab::Utils::StrongMemoize
+ COLLAB_IMPORT_SCOPES = %w[admin:org read:org].freeze
+
attr_accessor :client
attr_reader :params, :current_user
@@ -12,6 +14,9 @@ module Import
context_error = validate_context
return context_error if context_error
+ scope_error = validate_collaborators_import_scope
+ return scope_error if scope_error
+
project = create_project(access_params, provider)
track_access_level('github')
@@ -87,16 +92,33 @@ module Import
end
def blocked_url?
- Gitlab::UrlBlocker.blocked_url?(
+ Gitlab::HTTP_V2::UrlBlocker.blocked_url?(
url,
allow_localhost: allow_local_requests?,
allow_local_network: allow_local_requests?,
- schemes: %w[http https]
+ schemes: %w[http https],
+ deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?
)
end
private
+ def validate_collaborators_import_scope
+ collaborators_import = params.dig(:optional_stages, :collaborators_import)
+ # A value for `collaborators_import` may not be included in POST params
+ # and the default value is `true`
+ return unless collaborators_import == true || collaborators_import.nil?
+
+ # We need to call `#repo` to ensure the `#last_response` from the client has the headers we need.
+ repo
+ scopes = client.octokit.last_response.headers["x-oauth-scopes"]
+ scopes = scopes.split(',').map(&:strip)
+
+ return if (scopes & COLLAB_IMPORT_SCOPES).any?
+
+ log_and_return_error('Invalid scope', _('Your GitHub access token does not have the correct scope to import collaborators.'), :unprocessable_entity)
+ end
+
def validate_context
if blocked_url?
log_and_return_error("Invalid URL: #{url}", _("Invalid URL: %{url}") % { url: url }, :bad_request)
@@ -139,7 +161,8 @@ module Import
.new(project)
.write(
timeout_strategy: params[:timeout_strategy] || ProjectImportData::PESSIMISTIC_TIMEOUT,
- optional_stages: params[:optional_stages]
+ optional_stages: params[:optional_stages],
+ extended_events: Feature.enabled?(:github_import_extended_events, current_user)
)
end
end
diff --git a/app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb b/app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb
deleted file mode 100644
index 82bf9a41ae7..00000000000
--- a/app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-module Integrations
- module GoogleCloudPlatform
- module ArtifactRegistry
- class ListDockerImagesService < BaseProjectService
- def execute(page_token: nil)
- return ServiceResponse.error(message: "Access denied") unless allowed?
-
- ServiceResponse.success(payload: client.list_docker_images(page_token: page_token))
- end
-
- private
-
- def allowed?
- can?(current_user, :read_container_image, project)
- end
-
- def client
- ::Integrations::GoogleCloudPlatform::ArtifactRegistry::Client.new(
- project: project,
- user: current_user,
- gcp_project_id: gcp_project_id,
- gcp_location: gcp_location,
- gcp_repository: gcp_repository,
- gcp_wlif: gcp_wlif
- )
- end
-
- def gcp_project_id
- params[:gcp_project_id]
- end
-
- def gcp_location
- params[:gcp_location]
- end
-
- def gcp_repository
- params[:gcp_repository]
- end
-
- def gcp_wlif
- params[:gcp_wlif]
- end
- end
- end
- end
-end
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index db28be864a7..a0fa1616f7b 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -2,10 +2,11 @@
module Issuable
class CommonSystemNotesService < ::BaseProjectService
- attr_reader :issuable
+ attr_reader :issuable, :is_update
def execute(issuable, old_labels: [], old_milestone: nil, is_update: true)
@issuable = issuable
+ @is_update = is_update
# We disable touch so that created system notes do not update
# the noteable's updated_at field
@@ -17,10 +18,10 @@ module Issuable
handle_description_change_note
- handle_time_tracking_note if issuable.is_a?(TimeTrackable)
create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
end
+ handle_time_tracking_note if issuable.is_a?(TimeTrackable)
handle_start_date_or_due_date_change_note
create_milestone_change_event(old_milestone) if issuable.previous_changes.include?('milestone_id')
create_labels_note(old_labels) if old_labels && issuable.labels != old_labels
@@ -37,13 +38,11 @@ module Issuable
end
def handle_time_tracking_note
- if issuable.previous_changes.include?('time_estimate')
- create_time_estimate_note
- end
+ estimate_updated = is_update && issuable.previous_changes.include?('time_estimate')
+ estimate_set = !is_update && issuable.time_estimate != 0
- if issuable.time_spent?
- create_time_spent_note
- end
+ create_time_estimate_note if estimate_updated || estimate_set
+ create_time_spent_note if issuable.time_spent?
end
def handle_description_change_note
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 27cfaef2db2..0240d0184ac 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -79,9 +79,7 @@ class IssuableBaseService < ::BaseContainerService
# confidential attribute is a special type of metadata and needs to be allowed to be set
# by non-members on issues in public projects so that security issues can be reported as confidential.
params.delete(:confidential) unless can?(current_user, :set_confidentiality, issuable)
- params.delete(:add_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable)
- params.delete(:remove_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable)
-
+ filter_contact_params(issuable)
filter_assignees(issuable)
filter_labels
filter_severity(issuable)
@@ -118,9 +116,8 @@ class IssuableBaseService < ::BaseContainerService
return false unless user
ability_name = :"read_#{issuable.to_ability_name}"
- resource = issuable.persisted? ? issuable : project
- can?(user, ability_name, resource)
+ can?(user, ability_name, issuable.resource_parent)
end
def filter_labels
@@ -644,6 +641,13 @@ class IssuableBaseService < ::BaseContainerService
def filter_widget_params
params.delete(:widget_params)
end
+
+ def filter_contact_params(issuable)
+ return if params.slice(:add_contacts, :remove_contacts).empty?
+ return if can?(current_user, :set_issue_crm_contacts, issuable)
+
+ params.extract!(:add_contacts, :remove_contacts)
+ end
end
IssuableBaseService.prepend_mod_with('IssuableBaseService')
diff --git a/app/services/issue_email_participants/base_service.rb b/app/services/issue_email_participants/base_service.rb
new file mode 100644
index 00000000000..c9847bae537
--- /dev/null
+++ b/app/services/issue_email_participants/base_service.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module IssueEmailParticipants
+ class BaseService < ::BaseProjectService
+ MAX_NUMBER_OF_EMAILS = 6
+
+ attr_reader :target, :emails
+
+ def initialize(target:, current_user:, emails:)
+ super(project: target.project, current_user: current_user)
+
+ @target = target
+ @emails = emails
+ end
+
+ private
+
+ def response_from_guard_checks
+ return error_feature_flag unless Feature.enabled?(:issue_email_participants, target.project)
+ return error_underprivileged unless current_user.can?(:"admin_#{target.to_ability_name}", target)
+
+ nil
+ end
+
+ def add_system_note(emails)
+ message = format(system_note_text, emails: emails.to_sentence)
+ ::SystemNoteService.email_participants(target, project, current_user, message)
+
+ message
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def error_feature_flag
+ # Don't translate feature flag error because it's temporary.
+ error("Feature flag issue_email_participants is not enabled for this project.")
+ end
+
+ def error_underprivileged
+ error(_("You don't have permission to manage email participants."))
+ end
+ end
+end
diff --git a/app/services/issue_email_participants/create_service.rb b/app/services/issue_email_participants/create_service.rb
index 52c59b2b8fe..aac396ba226 100644
--- a/app/services/issue_email_participants/create_service.rb
+++ b/app/services/issue_email_participants/create_service.rb
@@ -1,25 +1,15 @@
# frozen_string_literal: true
module IssueEmailParticipants
- class CreateService < ::BaseProjectService
+ class CreateService < BaseService
include Gitlab::Utils::StrongMemoize
- MAX_NUMBER_OF_EMAILS = 6
MAX_NUMBER_OF_RECORDS = 10
- attr_reader :target, :emails
-
- def initialize(target:, current_user:, emails:)
- super(project: target.project, current_user: current_user)
-
- @target = target
- @emails = emails
- end
-
def execute
- return error_feature_flag unless Feature.enabled?(:issue_email_participants, target.project)
- return error_underprivileged unless current_user.can?(:"admin_#{target.to_ability_name}", target)
- return error_no_participants unless emails.present?
+ response = response_from_guard_checks
+ return response unless response.nil?
+ return error_no_participants_added unless emails.present?
added_emails = add_participants(deduplicate_and_limit_emails)
@@ -27,7 +17,7 @@ module IssueEmailParticipants
message = add_system_note(added_emails)
ServiceResponse.success(message: message.upcase_first << ".")
else
- error_no_participants
+ error_no_participants_added
end
end
@@ -60,13 +50,6 @@ module IssueEmailParticipants
added_emails
end
- def add_system_note(added_emails)
- message = format(_("added %{emails}"), emails: added_emails.to_sentence)
- ::SystemNoteService.add_email_participants(target, project, current_user, message)
-
- message
- end
-
def existing_emails
target.email_participants_emails_downcase
end
@@ -78,20 +61,11 @@ module IssueEmailParticipants
end
end
- def error(message)
- ServiceResponse.error(message: message)
- end
-
- def error_feature_flag
- # Don't translate feature flag error because it's temporary.
- error("Feature flag issue_email_participants is not enabled for this project.")
- end
-
- def error_underprivileged
- error(_("You don't have permission to add email participants."))
+ def system_note_text
+ _("added %{emails}")
end
- def error_no_participants
+ def error_no_participants_added
error(_("No email participants were added. Either none were provided, or they already exist."))
end
end
diff --git a/app/services/issue_email_participants/destroy_service.rb b/app/services/issue_email_participants/destroy_service.rb
new file mode 100644
index 00000000000..8cd0178da00
--- /dev/null
+++ b/app/services/issue_email_participants/destroy_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module IssueEmailParticipants
+ class DestroyService < BaseService
+ def execute
+ response = response_from_guard_checks
+ return response unless response.nil?
+ return error_no_participants_removed unless emails.present?
+
+ removed_emails = remove_participants(emails.first(MAX_NUMBER_OF_EMAILS))
+
+ if removed_emails.any?
+ message = add_system_note(removed_emails)
+ ServiceResponse.success(message: message.upcase_first << ".")
+ else
+ error_no_participants_removed
+ end
+ end
+
+ private
+
+ def remove_participants(emails_to_remove)
+ participants = target
+ .issue_email_participants
+ .with_emails(emails_to_remove)
+ .load # to avoid additional query
+
+ emails = participants.map(&:email)
+ return [] if emails.empty?
+
+ participants.delete_all
+
+ emails
+ end
+
+ def system_note_text
+ _("removed %{emails}")
+ end
+
+ def error_no_participants_removed
+ error(_("No email participants were removed. Either none were provided, or they don't exist."))
+ end
+ end
+end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index a5ae5854e33..f564914352b 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -61,12 +61,13 @@ module Issues
# Setting created_at, updated_at and iid is allowed only for admins and owners or
# when moving an issue as we preserve the original issue attributes except id and iid.
- params.delete(:iid) unless current_user.can?(:set_issue_iid, project)
- params.delete(:created_at) unless moved_issue || current_user.can?(:set_issue_created_at, project)
- params.delete(:updated_at) unless moved_issue || current_user.can?(:set_issue_updated_at, project)
+ params.delete(:iid) if params[:iid].present? && !iid_param_allowed?
+ filter_timestamp_params unless moved_issue
# Only users with permission to handle error data can add it to issues
- params.delete(:sentry_issue_attributes) unless current_user.can?(:update_sentry_issue, project)
+ if params[:sentry_issue_attributes].present? && !current_user.can?(:update_sentry_issue, project)
+ params.delete(:sentry_issue_attributes)
+ end
issue.system_note_timestamp = params[:created_at] || params[:updated_at]
end
@@ -144,6 +145,19 @@ module Issues
def log_audit_event(issue, user, event_type, message)
# defined in EE
end
+
+ def iid_param_allowed?
+ current_user.can?(:set_issue_iid, project)
+ end
+
+ def filter_timestamp_params
+ timestamp_params = params.slice(:created_at, :updated_at).keys
+ return unless timestamp_params.any?
+
+ timestamp_params.each do |param|
+ params.delete(param) unless current_user.can?(:"set_issue_#{param}", project)
+ end
+ end
end
end
diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb
index d0ca8863c29..8d7b460bf69 100644
--- a/app/services/jira/requests/base.rb
+++ b/app/services/jira/requests/base.rb
@@ -12,7 +12,8 @@ module Jira
jira_ruby: JIRA::HTTPError,
ssl: OpenSSL::SSL::SSLError,
timeout: [Timeout::Error, Errno::ETIMEDOUT],
- uri: [URI::InvalidURIError, SocketError]
+ uri: [URI::InvalidURIError, SocketError],
+ url_blocked: Gitlab::HTTP::BlockedUrlError
}.freeze
ALL_ERRORS = ERRORS.values.flatten.freeze
@@ -63,12 +64,21 @@ module Jira
def auth_docs_link_start
auth_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira/index', anchor: 'authentication-in-jira')
- '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auth_docs_link_url }
+ link_start(auth_docs_link_url)
end
def config_docs_link_start
config_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira/configure')
- '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: config_docs_link_url }
+ link_start(config_docs_link_url)
+ end
+
+ def config_integration_link_start
+ config_jira_integration_url = Rails.application.routes.url_helpers.edit_project_settings_integration_path(project, jira_integration)
+ link_start(config_jira_integration_url)
+ end
+
+ def link_start(url)
+ '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: url }
end
def error_message(error)
@@ -89,6 +99,8 @@ module Jira
s_('JiraRequest|A timeout error occurred while connecting to Jira. Try your request again.')
when *ERRORS[:connection]
s_('JiraRequest|A connection error occurred while connecting to Jira. Try your request again.')
+ when ERRORS[:url_blocked]
+ s_('JiraRequest|Unable to connect to the Jira URL. Please verify your %{config_link_start}Jira integration URL%{config_link_end} and attempt the connection again.').html_safe % { config_link_start: config_integration_link_start, config_link_end: '</a>'.html_safe }
end
end
diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb
index f9857cdad39..8458eaeaf57 100644
--- a/app/services/merge_requests/approval_service.rb
+++ b/app/services/merge_requests/approval_service.rb
@@ -4,6 +4,7 @@ module MergeRequests
class ApprovalService < MergeRequests::BaseService
def execute(merge_request)
return unless eligible_for_approval?(merge_request)
+ return if merge_request.merged?
approval = merge_request.approvals.new(
user: current_user,
diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb
index 575a6bfe95a..4e6f117e9fb 100644
--- a/app/services/merge_requests/conflicts/list_service.rb
+++ b/app/services/merge_requests/conflicts/list_service.rb
@@ -15,6 +15,10 @@ module MergeRequests
def can_be_resolved_in_ui?
return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui)
+ # #cannot_be_merged? is generally indicative of conflicts, and is set via
+ # MergeRequests::MergeabilityCheckService. However, it can also indicate
+ # that either #has_no_commits? or #branch_missing? are true.
+ #
return @conflicts_can_be_resolved_in_ui = false unless merge_request.cannot_be_merged?
return @conflicts_can_be_resolved_in_ui = false unless merge_request.has_complete_diff_refs?
return @conflicts_can_be_resolved_in_ui = false if merge_request.branch_missing?
diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb
index c0bb257eda6..b8f512bdb2c 100644
--- a/app/services/merge_requests/remove_approval_service.rb
+++ b/app/services/merge_requests/remove_approval_service.rb
@@ -5,6 +5,7 @@ module MergeRequests
# rubocop: disable CodeReuse/ActiveRecord
def execute(merge_request)
return unless merge_request.approved_by?(current_user)
+ return if merge_request.merged?
# paranoid protection against running wrong deletes
return unless merge_request.id && current_user.id
diff --git a/app/services/merge_requests/request_review_service.rb b/app/services/merge_requests/request_review_service.rb
index ebbae98352b..87b00aa088c 100644
--- a/app/services/merge_requests/request_review_service.rb
+++ b/app/services/merge_requests/request_review_service.rb
@@ -12,6 +12,7 @@ module MergeRequests
notify_reviewer(merge_request, user)
trigger_merge_request_reviewers_updated(merge_request)
+ create_system_note(merge_request, user)
success
else
@@ -25,5 +26,9 @@ module MergeRequests
notification_service.async.review_requested_of_merge_request(merge_request, current_user, reviewer)
todo_service.create_request_review_todo(merge_request, current_user, reviewer)
end
+
+ def create_system_note(merge_request, user)
+ ::SystemNoteService.request_review(merge_request, merge_request.project, current_user, user)
+ end
end
end
diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb
index 191a8711cbd..aa122b1282a 100644
--- a/app/services/milestones/destroy_service.rb
+++ b/app/services/milestones/destroy_service.rb
@@ -11,7 +11,7 @@ module Milestones
end
milestone.merge_requests.each do |merge_request|
- MergeRequests::UpdateService.new(project: parent, current_user: current_user, params: update_params).execute(merge_request)
+ MergeRequests::UpdateService.new(project: merge_request.project, current_user: current_user, params: update_params).execute(merge_request)
end
log_destroy_event_for(milestone)
diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb
index 4417f17f33e..d657b8b3255 100644
--- a/app/services/milestones/promote_service.rb
+++ b/app/services/milestones/promote_service.rb
@@ -63,9 +63,12 @@ module Milestones
def update_children(group_milestone, milestone_ids)
issues = Issue.where(project_id: group_project_ids, milestone_id: milestone_ids)
merge_requests = MergeRequest.where(source_project_id: group_project_ids, milestone_id: milestone_ids)
+ milestone_events = ResourceMilestoneEvent.where(milestone_id: milestone_ids)
- [issues, merge_requests].each do |issuable_collection|
- issuable_collection.update_all(milestone_id: group_milestone.id)
+ [issues, merge_requests, milestone_events].each do |collection|
+ collection.each_batch do |batch|
+ batch.update_all(milestone_id: group_milestone.id)
+ end
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/ml/create_model_service.rb b/app/services/ml/create_model_service.rb
index b87b13dd379..7ac9c2a2737 100644
--- a/app/services/ml/create_model_service.rb
+++ b/app/services/ml/create_model_service.rb
@@ -12,21 +12,25 @@ module Ml
def execute
ApplicationRecord.transaction do
- model = Ml::Model.create!(
+ model = Ml::Model.new(
project: @project,
name: @name,
- user: (@user.is_a?(User) ? @user : nil),
+ user: @user,
description: @description,
default_experiment: default_experiment
)
- add_metadata(model, @metadata)
+ model.save
- Gitlab::InternalEvents.track_event(
- 'model_registry_ml_model_created',
- project: @project,
- user: @user
- )
+ if model.persisted?
+ add_metadata(model, @metadata)
+
+ Gitlab::InternalEvents.track_event(
+ 'model_registry_ml_model_created',
+ project: @project,
+ user: @user
+ )
+ end
model
end
diff --git a/app/services/ml/create_model_version_service.rb b/app/services/ml/create_model_version_service.rb
index 3b8c096b5b4..4af9dd40d12 100644
--- a/app/services/ml/create_model_version_service.rb
+++ b/app/services/ml/create_model_version_service.rb
@@ -8,6 +8,7 @@ module Ml
@package = params[:package]
@description = params[:description]
@user = params[:user]
+ @metadata = params[:metadata]
end
def execute
@@ -24,6 +25,8 @@ module Ml
{ model_version: model_version }
).execute
+ model_version.add_metadata(@metadata)
+
Gitlab::InternalEvents.track_event(
'model_registry_ml_model_version_created',
project: @model.project,
diff --git a/app/services/namespaces/package_settings/update_service.rb b/app/services/namespaces/package_settings/update_service.rb
index d7ab6828346..06a15671f25 100644
--- a/app/services/namespaces/package_settings/update_service.rb
+++ b/app/services/namespaces/package_settings/update_service.rb
@@ -12,6 +12,8 @@ module Namespaces
maven_package_requests_forwarding
nuget_duplicates_allowed
nuget_duplicate_exception_regex
+ terraform_module_duplicates_allowed
+ terraform_module_duplicate_exception_regex
npm_package_requests_forwarding
pypi_package_requests_forwarding
lock_maven_package_requests_forwarding
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 5099272a212..36431c1cbde 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -522,29 +522,40 @@ class NotificationService
).deliver_later
end
- # Project invite
- def invite_project_member(project_member, token)
- return true unless project_member.notifiable?(:subscription)
+ def invite_member(member, token)
+ mailer.member_invited_email(member.real_source_type, member.id, token).deliver_later
+ end
+
+ def new_member(member)
+ notifiable_options = case member.source
+ when Group
+ {}
+ when Project
+ { skip_read_ability: true }
+ end
+
+ return true unless member.notifiable?(:mention, notifiable_options)
- mailer.member_invited_email(project_member.real_source_type, project_member.id, token).deliver_later
+ mailer.member_access_granted_email(member.real_source_type, member.id).deliver_later
end
- def accept_project_invite(project_member)
- return true unless project_member.notifiable?(:subscription)
+ def accept_invite(member)
+ return true if member.source.is_a?(Project) && !member.notifiable?(:subscription)
- mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later
+ mailer.member_invite_accepted_email(member.real_source_type, member.id).deliver_later
end
- def new_project_member(project_member)
- return true unless project_member.notifiable?(:mention, skip_read_ability: true)
+ def updated_member_access_level(member)
+ return true unless member.notifiable?(:mention)
- mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
+ mailer.member_access_granted_email(member.real_source_type, member.id).deliver_later
end
- def update_project_member(project_member)
- return true unless project_member.notifiable?(:mention)
+ def updated_member_expiration(member)
+ return true unless member.source.is_a?(Group)
+ return true unless member.notifiable?(:mention)
- mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
+ mailer.member_expiration_date_updated_email(member.real_source_type, member.id).deliver_later
end
def member_about_to_expire(member)
@@ -553,37 +564,10 @@ class NotificationService
mailer.member_about_to_expire_email(member.real_source_type, member.id).deliver_later
end
- # Group invite
- def invite_group_member(group_member, token)
- mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later
- end
-
def invite_member_reminder(group_member, token, reminder_index)
mailer.member_invited_reminder_email(group_member.real_source_type, group_member.id, token, reminder_index).deliver_later
end
- def accept_group_invite(group_member)
- mailer.member_invite_accepted_email(group_member.real_source_type, group_member.id).deliver_later
- end
-
- def new_group_member(group_member)
- return true unless group_member.notifiable?(:mention)
-
- mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
- end
-
- def update_group_member(group_member)
- return true unless group_member.notifiable?(:mention)
-
- mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
- end
-
- def updated_group_member_expiration(group_member)
- return true unless group_member.notifiable?(:mention)
-
- mailer.member_expiration_date_updated_email(group_member.real_source_type, group_member.id).deliver_later
- end
-
def project_was_moved(project, old_path_with_namespace)
recipients = project_moved_recipients(project)
recipients = notifiable_users(recipients, :custom, custom_action: :moved_project, project: project)
diff --git a/app/services/organizations/create_service.rb b/app/services/organizations/create_service.rb
index ab70799a095..f29065b8ffd 100644
--- a/app/services/organizations/create_service.rb
+++ b/app/services/organizations/create_service.rb
@@ -7,13 +7,21 @@ module Organizations
organization = Organization.create(params)
- return error_creating(organization) unless organization.persisted?
+ if organization.persisted?
+ add_organization_owner(organization)
- ServiceResponse.success(payload: { organization: organization })
+ ServiceResponse.success(payload: { organization: organization })
+ else
+ error_creating(organization)
+ end
end
private
+ def add_organization_owner(organization)
+ organization.organization_users.create(user: current_user, access_level: :owner)
+ end
+
def error_no_permissions
ServiceResponse.error(message: [_('You have insufficient permissions to create organizations')])
end
diff --git a/app/services/organizations/update_service.rb b/app/services/organizations/update_service.rb
index bc3a2d29abf..6e3a2cddddb 100644
--- a/app/services/organizations/update_service.rb
+++ b/app/services/organizations/update_service.rb
@@ -17,6 +17,10 @@ module Organizations
def execute
return error_no_permissions unless allowed?
+ if params[:organization_detail_attributes].key?(:avatar) && params[:organization_detail_attributes][:avatar].nil?
+ organization.remove_avatar!
+ end
+
if organization.update(params)
ServiceResponse.success(payload: { organization: organization })
else
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
index 0f0dc297e9a..a27f059036c 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -8,24 +8,35 @@ module Packages
PACKAGE_JSON_NOT_ALLOWED_FIELDS = %w[readme readmeFilename licenseText contributors exports].freeze
DEFAULT_LEASE_TIMEOUT = 1.hour.to_i
+ ERROR_REASON_INVALID_PARAMETER = :invalid_parameter
+ ERROR_REASON_PACKAGE_EXISTS = :package_already_exists
+ ERROR_REASON_PACKAGE_LEASE_TAKEN = :package_lease_taken
+ ERROR_REASON_PACKAGE_PROTECTED = :package_attachment_data_empty
+
def execute
- return error('Version is empty.', 400) if version.blank?
- return error('Attachment data is empty.', 400) if attachment['data'].blank?
- return error('Package already exists.', 403) if current_package_exists?
- return error('Package protected.', 403) if current_package_protected?
- return error('File is too large.', 400) if file_size_exceeded?
+ return error('Version is empty.', ERROR_REASON_INVALID_PARAMETER) if version.blank?
+ return error('Attachment data is empty.', ERROR_REASON_INVALID_PARAMETER) if attachment['data'].blank?
+ return error('Package already exists.', ERROR_REASON_PACKAGE_EXISTS) if current_package_exists?
+ return error('Package protected.', ERROR_REASON_PACKAGE_PROTECTED) if current_package_protected?
+ return error('File is too large.', ERROR_REASON_INVALID_PARAMETER) if file_size_exceeded?
package = try_obtain_lease do
ApplicationRecord.transaction { create_npm_package! }
end
- return error('Could not obtain package lease. Please try again.', 400) unless package
+ unless package
+ return error('Could not obtain package lease. Please try again.', ERROR_REASON_PACKAGE_LEASE_TAKEN)
+ end
- package
+ ServiceResponse.success(payload: { package: package })
end
private
+ def error(message, reason)
+ ServiceResponse.error(message: message, reason: reason)
+ end
+
def create_npm_package!
package = create_package!(:npm, name: name, version: version)
diff --git a/app/services/packages/terraform_module/create_package_service.rb b/app/services/packages/terraform_module/create_package_service.rb
index 9df722db529..eb48b481dd8 100644
--- a/app/services/packages/terraform_module/create_package_service.rb
+++ b/app/services/packages/terraform_module/create_package_service.rb
@@ -6,10 +6,20 @@ module Packages
include Gitlab::Utils::StrongMemoize
def execute
- return error('Version is empty.', 400) if params[:module_version].blank?
- return error('Access Denied', 403) if current_package_exists_elsewhere?
- return error('Package version already exists.', 403) if current_package_version_exists?
- return error('File is too large.', 400) if file_size_exceeded?
+ if params[:module_version].blank?
+ return ServiceResponse.error(message: 'Version is empty.', reason: :bad_request)
+ end
+
+ if duplicates_not_allowed? && current_package_exists_elsewhere?
+ return ServiceResponse.error(
+ message: 'A package with the same name already exists in the namespace',
+ reason: :forbidden
+ )
+ end
+
+ if current_package_version_exists?
+ return ServiceResponse.error(message: 'Package version already exists.', reason: :forbidden)
+ end
ApplicationRecord.transaction { create_terraform_module_package! }
end
@@ -24,6 +34,15 @@ module Packages
package
end
+ def duplicates_not_allowed?
+ return true if package_settings_with_duplicates_allowed.blank?
+
+ package_settings_with_duplicates_allowed.none? do |setting|
+ setting.terraform_module_duplicates_allowed ||
+ ::Gitlab::UntrustedRegexp.new("\\A#{setting.terraform_module_duplicate_exception_regex}\\z").match?(name)
+ end
+ end
+
def current_package_exists_elsewhere?
::Packages::Package
.for_projects(project.root_namespace.all_projects.id_not_in(project.id))
@@ -62,9 +81,13 @@ module Packages
}
end
- def file_size_exceeded?
- project.actual_limits.exceeded?(:generic_packages_max_file_size, params[:file].size)
+ def package_settings_with_duplicates_allowed
+ ::Namespace::PackageSetting
+ .select(:terraform_module_duplicates_allowed, :terraform_module_duplicate_exception_regex)
+ .namespace_id_in(project.namespace.self_and_ancestor_ids)
+ .with_terraform_module_duplicates_allowed_or_exception_regex
end
+ strong_memoize_attr :package_settings_with_duplicates_allowed
end
end
end
diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
index c11b019cee5..1733021cbb5 100644
--- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
+++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
@@ -10,9 +10,6 @@ module PagesDomains
# no particular SLA, usually takes 10-15 seconds
CERTIFICATE_PROCESSING_DELAY = 1.minute.freeze
- # Maximum domain length for Let's Encrypt
- MAX_DOMAIN_LENGTH = 64
-
attr_reader :pages_domain
def initialize(pages_domain)
@@ -20,11 +17,6 @@ module PagesDomains
end
def execute
- if pages_domain.domain.bytesize > MAX_DOMAIN_LENGTH
- log_domain_length_error
- return
- end
-
pages_domain.acme_orders.expired.delete_all
acme_order = pages_domain.acme_orders.first
@@ -67,16 +59,6 @@ module PagesDomains
NotificationService.new.pages_domain_auto_ssl_failed(pages_domain)
end
- def log_domain_length_error
- Gitlab::AppLogger.error(
- message: "Domain name too long for Let's Encrypt certificate",
- pages_domain: pages_domain.domain,
- pages_domain_bytesize: pages_domain.domain.bytesize,
- max_allowed_bytesize: MAX_DOMAIN_LENGTH,
- project_id: pages_domain.project_id
- )
- end
-
def log_error(api_order)
Gitlab::AppLogger.error(
message: "Failed to obtain Let's Encrypt certificate",
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 7ba5b6119b9..033d90abc7a 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -159,6 +159,7 @@ module Projects
destroy_web_hooks!
destroy_project_bots!
destroy_ci_records!
+ destroy_deployments!
destroy_mr_diff_relations!
destroy_merge_request_diffs!
@@ -253,6 +254,12 @@ module Projects
)
end
+ def destroy_deployments!
+ project.deployments.each_batch(of: BATCH_SIZE) do |deployments|
+ deployments.fast_destroy_all
+ end
+ end
+
# The project can have multiple webhooks with hundreds of thousands of web_hook_logs.
# By default, they are removed with "DELETE CASCADE" option defined via foreign_key.
# But such queries can exceed the statement_timeout limit and fail to delete the project.
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index fe19d1f051d..188f12a287b 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -18,13 +18,17 @@ module Projects
end
def project_members
- @project_members ||= sorted(project.authorized_users)
+ filter_and_sort_users(project_members_relation)
end
def all_members
return [] if Feature.enabled?(:disable_all_mention)
- [{ username: "all", name: "All Project and Group Members", count: project_members.count }]
+ [{ username: "all", name: "All Project and Group Members", count: project_members_relation.count }]
+ end
+
+ def project_members_relation
+ project.authorized_users
end
end
end
diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb
index cdd1870858e..dbac59dd32b 100644
--- a/app/services/projects/unlink_fork_service.rb
+++ b/app/services/projects/unlink_fork_service.rb
@@ -46,9 +46,7 @@ module Projects
end
# rubocop: enable Cop/InBatches
- if Feature.enabled?(:refresh_statistics_on_unlink_fork, @project.namespace) && refresh_statistics
- ProjectCacheWorker.perform_async(project.id, [], [:repository_size])
- end
+ ProjectCacheWorker.perform_async(project.id, [], [:repository_size]) if refresh_statistics
# When the project getting out of the network is a node with parent
# and children, both the parent and the node needs a cache refresh.
diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb
index 9b979f6ed68..0d51de4d26e 100644
--- a/app/services/projects/update_statistics_service.rb
+++ b/app/services/projects/update_statistics_service.rb
@@ -17,6 +17,8 @@ module Projects
expire_repository_caches
expire_wiki_caches
project.statistics.refresh!(only: statistics)
+
+ record_onboarding_progress
end
private
@@ -46,5 +48,11 @@ module Projects
params[:statistics]&.map(&:to_sym)
end
end
+
+ def record_onboarding_progress
+ return unless repository.commit_count > 1 || repository.branch_count > 1
+
+ Onboarding::ProgressService.new(project.namespace).execute(action: :code_added)
+ end
end
end
diff --git a/app/services/routes/rename_descendants_service.rb b/app/services/routes/rename_descendants_service.rb
new file mode 100644
index 00000000000..18a28b87dcb
--- /dev/null
+++ b/app/services/routes/rename_descendants_service.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+module Routes
+ class RenameDescendantsService
+ BATCH_SIZE = 100
+ class RouteChanges
+ attr_reader :saved_change_to_parent_path, :saved_change_to_parent_name, :old_path_of_parent, :old_name_of_parent
+
+ def initialize(changes)
+ path_details = changes.fetch(:path)
+ name_details = changes.fetch(:name)
+
+ @saved_change_to_parent_path = path_details.fetch(:saved)
+ @old_path_of_parent = path_details.fetch(:old_value)
+ @saved_change_to_parent_name = name_details.fetch(:saved)
+ @old_name_of_parent = name_details.fetch(:old_value)
+ end
+ end
+
+ def initialize(parent_route)
+ @parent_route = parent_route
+ @routes_to_update = []
+ @redirect_routes_to_insert = []
+ end
+
+ def execute(changes)
+ process_changes(changes)
+ update_routes_for_descendants
+ create_redirect_routes_for_descendants
+ end
+
+ private
+
+ def process_changes(changes)
+ changes = RouteChanges.new(changes)
+
+ saved_change_to_parent_path = changes.saved_change_to_parent_path
+ saved_change_to_parent_name = changes.saved_change_to_parent_name
+
+ return unless saved_change_to_parent_path || saved_change_to_parent_name
+
+ old_path_of_parent = changes.old_path_of_parent
+ old_name_of_parent = changes.old_name_of_parent
+
+ descendant_routes_inside(old_path_of_parent).each_batch(of: BATCH_SIZE) do |relation|
+ relation.each do |descendant_route|
+ attributes_to_update = {}
+
+ if saved_change_to_parent_path && descendant_route.path.present?
+ attributes_to_update[:path] = descendant_route.path.sub(
+ old_path_of_parent, current_path_of_parent
+ )
+ end
+
+ if saved_change_to_parent_name && old_name_of_parent.present? && descendant_route.name.present?
+ attributes_to_update[:name] = descendant_route.name.sub(
+ old_name_of_parent, current_name_of_parent
+ )
+ end
+
+ push_to_routes_data(descendant_route, attributes_to_update)
+ push_to_redirect_routes_data(descendant_route) if attributes_to_update[:path]
+ end
+ end
+ end
+
+ def push_to_routes_data(descendant_route, attributes_to_update)
+ return if attributes_to_update.empty?
+
+ # We merge updated attributes with all existing attributes of the `Route` record.
+ # This comprehensive attribute set is required for the initial attempt of `upsert_all` to function effectively.
+ # During the first phase (insertion attempt), `upsert_all` tries to insert new records into the database,
+ # necessitating the presence of all attributes, including NOT NULL attributes, to create new entries.
+ # Attributes like `source_id` and `source_type` are crucial, as they are NOT NULL attributes essential
+ # for record creation.
+ # In the event of conflicts (e.g., existing Route records with conflicting `id`s),
+ # `upsert_all` switches to an update operation for those specific conflicted records.
+ # And this is the way we get to update `path` and/or `name` of multiple, existing route records in one go.
+ @routes_to_update << descendant_route
+ .attributes.symbolize_keys
+ .merge(attributes_to_update)
+ end
+
+ def push_to_redirect_routes_data(descendant_route)
+ @redirect_routes_to_insert << {
+ source_id: descendant_route.source_id,
+ source_type: descendant_route.source_type,
+ path: descendant_route.path
+ }
+ end
+
+ def update_routes_for_descendants
+ return if @routes_to_update.blank?
+
+ @routes_to_update.each_slice(BATCH_SIZE) do |data|
+ # Utilizing `upsert_all` with `unique_by: :id` ensures that only updates occur,
+ # as the provided data contains attributes exclusively for existing `Route` records,
+ # identified by their unique `id`.
+ # This upsert operation is hence guaranteed to solely execute updates, never inserts.
+ Route.upsert_all(
+ data,
+ unique_by: :id,
+ update_only: [:path, :name], # on conflicts, we need to update only path/name.
+ record_timestamps: true # this makes sure that `updated_at` is updated.
+ )
+ end
+ end
+
+ def create_redirect_routes_for_descendants
+ return if @redirect_routes_to_insert.blank?
+
+ @redirect_routes_to_insert.each_slice(BATCH_SIZE) do |data|
+ RedirectRoute.insert_all(
+ data,
+ # We need to make sure no duplicates are inserted.
+ # We use the value of `lower(path)` to make this check,
+ # which is already a UNIQUE index on this table.
+ unique_by: :index_redirect_routes_on_path_unique_text_pattern_ops
+ )
+ end
+ end
+
+ def current_name_of_parent
+ @parent_route.name
+ end
+
+ def current_path_of_parent
+ @parent_route.path
+ end
+
+ def descendant_routes_inside(path)
+ Route.inside_path(path)
+ end
+ end
+end
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index 2d4bebc8b2b..f69ee255e01 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -70,7 +70,8 @@ module Spam
result = spamcheck_client.spam?(spammable: target, user: user, context: context, extra_features: extra_features)
if result.evaluated?
- Abuse::TrustScore.create!(user: user, score: result.score, source: :spamcheck)
+ correlation_id = Labkit::Correlation::CorrelationId.current_id || ''
+ Abuse::TrustScoreWorker.perform_async(user.id, :spamcheck, result.score, correlation_id)
end
result.verdict
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 5f71b7ac9e9..fc27303792b 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -45,6 +45,10 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_reviewers(old_reviewers)
end
+ def request_review(issuable, project, author, user)
+ ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).request_review(user)
+ end
+
def change_issuable_contacts(issuable, project, author, added_count, removed_count)
::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_contacts(added_count, removed_count)
end
@@ -282,8 +286,8 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_canonical_issue_of_duplicate(duplicate_issue)
end
- def add_email_participants(noteable, project, author, body)
- ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).add_email_participants(body)
+ def email_participants(noteable, project, author, body)
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).email_participants(body)
end
def discussion_lock(issuable, author)
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index c584d5ccca3..3f96ca9cefb 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -133,6 +133,12 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'reviewer'))
end
+ def request_review(user)
+ body = "#{self.class.issuable_events[:review_requested]} #{user.to_reference}"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'reviewer'))
+ end
+
# Called when the contacts of an issuable are changed or removed
# We intend to reference the contacts but for security we are just
# going to state how many were added/removed for now. See discussion:
@@ -431,7 +437,7 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
end
- def add_email_participants(body)
+ def email_participants(body)
create_note(NoteSummary.new(noteable, project, author, body))
end
diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb
index f9084ed67d3..6ebf1215a25 100644
--- a/app/services/system_notes/time_tracking_service.rb
+++ b/app/services/system_notes/time_tracking_service.rb
@@ -43,18 +43,11 @@ module SystemNotes
#
# Returns the created Note object
def change_time_estimate
- parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
- body = if noteable.time_estimate == 0
- "removed time estimate"
- else
- "changed time estimate to #{parsed_time}"
- end
-
if noteable.is_a?(Issue)
issue_activity_counter.track_issue_time_estimate_changed_action(author: author, project: project)
end
- create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
+ create_note(NoteSummary.new(noteable, project, author, time_estimate_system_note, action: 'time_tracking'))
end
# Called when the spent time of a Noteable is changed
@@ -160,5 +153,19 @@ module SystemNotes
def work_item_activity_counter
Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter
end
+
+ def time_estimate_system_note
+ parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
+ previous_estimate = noteable.previous_changes['time_estimate']&.at(0) || 0
+ parsed_previous_restimate = Gitlab::TimeTrackingFormatter.output(previous_estimate)
+
+ if previous_estimate == 0
+ "added time estimate of #{parsed_time}"
+ elsif noteable.time_estimate == 0
+ "removed time estimate of #{parsed_previous_restimate}"
+ else
+ "changed time estimate to #{parsed_time} from #{parsed_previous_restimate}"
+ end
+ end
end
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index be7405cc896..168b36ea4d1 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -168,7 +168,11 @@ class TodoService
def mark_todo(target, current_user)
project = target.project
attributes = attributes_for_todo(project, target, current_user, Todo::MARKED)
- create_todos(current_user, attributes, target_namespace(target), project)
+
+ todos = create_todos(current_user, attributes, target_namespace(target), project)
+ work_item_activity_counter.track_work_item_mark_todo_action(author: current_user) if target.is_a?(WorkItem)
+
+ todos
end
def todo_exist?(issuable, current_user)
@@ -475,6 +479,10 @@ class TodoService
project = target.project
project&.namespace || target.try(:namespace)
end
+
+ def work_item_activity_counter
+ Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter
+ end
end
TodoService.prepend_mod_with('TodoService')
diff --git a/app/services/work_items/callbacks/assignees.rb b/app/services/work_items/callbacks/assignees.rb
new file mode 100644
index 00000000000..14755ff0b46
--- /dev/null
+++ b/app/services/work_items/callbacks/assignees.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Callbacks
+ class Assignees < Base
+ def before_update
+ params[:assignee_ids] = [] if excluded_in_new_type?
+
+ return unless params.present? && params.has_key?(:assignee_ids)
+ return unless has_permission?(:set_work_item_metadata)
+
+ assignee_ids = filter_assignees_count(params[:assignee_ids])
+ assignee_ids = filter_assignee_permissions(assignee_ids)
+
+ return if assignee_ids.sort == work_item.assignee_ids.sort
+
+ work_item.assignee_ids = assignee_ids
+ work_item.touch
+ end
+
+ private
+
+ def filter_assignees_count(assignee_ids)
+ return assignee_ids if work_item.allows_multiple_assignees?
+
+ assignee_ids.first(1)
+ end
+
+ def filter_assignee_permissions(assignee_ids)
+ assignees = User.id_in(assignee_ids)
+
+ assignees.select { |assignee| assignee.can?(:read_work_item, work_item) }.map(&:id)
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/callbacks/current_user_todos.rb b/app/services/work_items/callbacks/current_user_todos.rb
new file mode 100644
index 00000000000..c6c74a5ce3d
--- /dev/null
+++ b/app/services/work_items/callbacks/current_user_todos.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Callbacks
+ class CurrentUserTodos < Base
+ def before_update
+ return unless params.present? && params.key?(:action)
+
+ case params[:action]
+ when "add"
+ add_todo
+ when "mark_as_done"
+ mark_as_done(params[:todo_id])
+ end
+ end
+
+ private
+
+ def add_todo
+ return unless has_permission?(:create_todo)
+
+ TodoService.new.mark_todo(work_item, current_user)&.first
+ end
+
+ def mark_as_done(todo_id)
+ todos = TodosFinder.new(current_user, state: :pending, target_id: work_item.id).execute
+ todos = todo_id ? todos.id_in(todo_id) : todos
+
+ return if todos.empty?
+
+ TodoService.new.resolve_todos(todos, current_user, resolved_by_action: :api_done)
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/callbacks/description.rb b/app/services/work_items/callbacks/description.rb
new file mode 100644
index 00000000000..b9620c65214
--- /dev/null
+++ b/app/services/work_items/callbacks/description.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Callbacks
+ class Description < Base
+ def before_update
+ params[:description] = nil if excluded_in_new_type?
+
+ return unless params.present? && params.key?(:description)
+ return unless has_permission?(:update_work_item)
+
+ work_item.description = params[:description]
+ work_item.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user)
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/callbacks/notifications.rb b/app/services/work_items/callbacks/notifications.rb
new file mode 100644
index 00000000000..233088ea188
--- /dev/null
+++ b/app/services/work_items/callbacks/notifications.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Callbacks
+ class Notifications < Base
+ def before_update
+ return unless params.present? && params.key?(:subscribed)
+ return unless has_permission?(:update_subscription)
+
+ update_subscription(work_item, params)
+ end
+
+ private
+
+ def update_subscription(work_item, subscription_params)
+ work_item.set_subscription(
+ current_user,
+ subscription_params[:subscribed],
+ work_item.project
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/callbacks/start_and_due_date.rb b/app/services/work_items/callbacks/start_and_due_date.rb
new file mode 100644
index 00000000000..b7318dcfcf4
--- /dev/null
+++ b/app/services/work_items/callbacks/start_and_due_date.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Callbacks
+ class StartAndDueDate < Base
+ def before_update
+ return work_item.assign_attributes({ start_date: nil, due_date: nil }) if excluded_in_new_type?
+
+ return if params.blank?
+ return unless has_permission?(:set_work_item_metadata)
+
+ work_item.assign_attributes(params.slice(:start_date, :due_date))
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb
index 354a33a0384..f9eadc3fb60 100644
--- a/app/services/work_items/create_service.rb
+++ b/app/services/work_items/create_service.rb
@@ -73,3 +73,5 @@ module WorkItems
end
end
end
+
+WorkItems::CreateService.prepend_mod
diff --git a/app/services/work_items/widgets/assignees_service/update_service.rb b/app/services/work_items/widgets/assignees_service/update_service.rb
deleted file mode 100644
index 7a084917ea7..00000000000
--- a/app/services/work_items/widgets/assignees_service/update_service.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module AssigneesService
- class UpdateService < WorkItems::Widgets::BaseService
- def before_update_in_transaction(params:)
- params[:assignee_ids] = [] if new_type_excludes_widget?
-
- return unless params.present? && params.has_key?(:assignee_ids)
- return unless has_permission?(:set_work_item_metadata)
-
- assignee_ids = filter_assignees_count(params[:assignee_ids])
- assignee_ids = filter_assignee_permissions(assignee_ids)
-
- return if assignee_ids.sort == work_item.assignee_ids.sort
-
- work_item.assignee_ids = assignee_ids
- work_item.touch
- end
-
- private
-
- def filter_assignees_count(assignee_ids)
- return assignee_ids if work_item.allows_multiple_assignees?
-
- assignee_ids.first(1)
- end
-
- def filter_assignee_permissions(assignee_ids)
- assignees = User.id_in(assignee_ids)
-
- assignees.select { |assignee| assignee.can?(:read_work_item, work_item) }.map(&:id)
- end
- end
- end
- end
-end
diff --git a/app/services/work_items/widgets/current_user_todos_service/update_service.rb b/app/services/work_items/widgets/current_user_todos_service/update_service.rb
deleted file mode 100644
index 38e2ae4de32..00000000000
--- a/app/services/work_items/widgets/current_user_todos_service/update_service.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module CurrentUserTodosService
- class UpdateService < WorkItems::Widgets::BaseService
- def before_update_in_transaction(params:)
- return unless params.present? && params.key?(:action)
-
- case params[:action]
- when "add"
- add_todo
- when "mark_as_done"
- mark_as_done(params[:todo_id])
- end
- end
-
- private
-
- def add_todo
- return unless has_permission?(:create_todo)
-
- TodoService.new.mark_todo(work_item, current_user)&.first
- end
-
- def mark_as_done(todo_id)
- todos = TodosFinder.new(current_user, state: :pending, target_id: work_item.id).execute
- todos = todo_id ? todos.id_in(todo_id) : todos
-
- return if todos.empty?
-
- TodoService.new.resolve_todos(todos, current_user, resolved_by_action: :api_done)
- end
- end
- end
- end
-end
diff --git a/app/services/work_items/widgets/description_service/update_service.rb b/app/services/work_items/widgets/description_service/update_service.rb
deleted file mode 100644
index 2640c6132cd..00000000000
--- a/app/services/work_items/widgets/description_service/update_service.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module DescriptionService
- class UpdateService < WorkItems::Widgets::BaseService
- def before_update_callback(params: {})
- params[:description] = nil if new_type_excludes_widget?
-
- return unless params.present? && params.key?(:description)
- return unless has_permission?(:update_work_item)
-
- work_item.description = params[:description]
- work_item.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user)
- end
- end
- end
- end
-end
diff --git a/app/services/work_items/widgets/notifications_service/update_service.rb b/app/services/work_items/widgets/notifications_service/update_service.rb
deleted file mode 100644
index b301e2ca7db..00000000000
--- a/app/services/work_items/widgets/notifications_service/update_service.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module NotificationsService
- class UpdateService < WorkItems::Widgets::BaseService
- def before_update_in_transaction(params:)
- return unless params.present? && params.key?(:subscribed)
- return unless has_permission?(:update_subscription)
-
- update_subscription(work_item, params)
- end
-
- private
-
- def update_subscription(work_item, subscription_params)
- work_item.set_subscription(
- current_user,
- subscription_params[:subscribed],
- work_item.project
- )
- end
- end
- end
- end
-end
diff --git a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb
deleted file mode 100644
index 5d47b3a1516..00000000000
--- a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module StartAndDueDateService
- class UpdateService < WorkItems::Widgets::BaseService
- def before_update_callback(params: {})
- return widget.work_item.assign_attributes({ start_date: nil, due_date: nil }) if new_type_excludes_widget?
-
- return if params.blank?
- return unless has_permission?(:set_work_item_metadata)
-
- widget.work_item.assign_attributes(params.slice(:start_date, :due_date))
- end
- end
- end
- end
-end
diff --git a/app/validators/json_schemas/application_setting_rate_limits.json b/app/validators/json_schemas/application_setting_rate_limits.json
new file mode 100644
index 00000000000..e74295291df
--- /dev/null
+++ b/app/validators/json_schemas/application_setting_rate_limits.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Application rate limits",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "members_delete_limit": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of project or group members a user can delete per minute."
+ }
+ }
+}
diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json
index ac34af3f107..e8d095e2921 100644
--- a/app/validators/json_schemas/build_metadata_secrets.json
+++ b/app/validators/json_schemas/build_metadata_secrets.json
@@ -8,33 +8,79 @@
"patternProperties": {
"^vault$": {
"type": "object",
- "required": ["path", "field", "engine"],
+ "required": [
+ "path",
+ "field",
+ "engine"
+ ],
"properties": {
- "path": { "type": "string" },
- "field": { "type": "string" },
+ "path": {
+ "type": "string"
+ },
+ "field": {
+ "type": "string"
+ },
"engine": {
"type": "object",
- "required": ["name", "path"],
+ "required": [
+ "name",
+ "path"
+ ],
"properties": {
- "path": { "type": "string" },
- "name": { "type": "string" }
+ "path": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
+ "^gcp_secret_manager$": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
"^azure_key_vault$": {
"type": "object",
- "required": ["name"],
+ "required": [
+ "name"
+ ],
"properties": {
- "name": { "type": "string" },
- "version": { "type": ["string", "null"] }
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
},
"additionalProperties": false
},
- "^file$": { "type": "boolean" },
- "^token$": { "type": "string" }
+ "^file$": {
+ "type": "boolean"
+ },
+ "^token$": {
+ "type": "string"
+ }
},
"anyOf": [
{
@@ -44,6 +90,11 @@
},
{
"required": [
+ "gcp_secret_manager"
+ ]
+ },
+ {
+ "required": [
"azure_key_vault"
]
}
diff --git a/app/validators/json_schemas/cloud_connector_access.json b/app/validators/json_schemas/cloud_connector_access.json
new file mode 100644
index 00000000000..8ebb32245d5
--- /dev/null
+++ b/app/validators/json_schemas/cloud_connector_access.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Cloud Connector Access",
+ "type": "object",
+ "available_services": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/app/validators/json_schemas/scan_result_policy_project_approval_settings.json b/app/validators/json_schemas/scan_result_policy_project_approval_settings.json
index 4885e5266c1..b4bebed3d1c 100644
--- a/app/validators/json_schemas/scan_result_policy_project_approval_settings.json
+++ b/app/validators/json_schemas/scan_result_policy_project_approval_settings.json
@@ -17,6 +17,30 @@
},
"block_branch_modification": {
"type": "boolean"
+ },
+ "block_group_branch_modification": {
+ "oneOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "exceptions": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "enabled"
+ ]
+ }
+ ]
}
}
}
diff --git a/app/views/admin/application_settings/_members_api_limits.html.haml b/app/views/admin/application_settings/_members_api_limits.html.haml
new file mode 100644
index 00000000000..3065c62b7e2
--- /dev/null
+++ b/app/views/admin/application_settings/_members_api_limits.html.haml
@@ -0,0 +1,21 @@
+%section.settings.as-members-api-limits.no-animate#js-members-api-limits-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = _('Members API rate limit')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p.gl-text-secondary
+ = _('Limit the number of project or group members a user can delete per minute through API requests.')
+ = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_members_api'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-members-api-limits-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :members_delete_limit, _('Maximum requests per minute per group / project'), class: 'label-bold'
+ = f.number_field :members_delete_limit, min: 0, class: 'form-control gl-form-input'
+ .form-text.gl-text-gray-600
+ = _("Set to 0 to disable the limit.")
+
+ = f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index 412098cfae4..5fc9db06bb2 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -5,7 +5,7 @@
.sub-section
%h4= _('Hashed repository storage paths')
.form-group
- - repository_storage_help_link_url = help_page_path('administration/repository_storage_types')
+ - repository_storage_help_link_url = help_page_path('administration/repository_storage_paths')
- repository_storage_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: repository_storage_help_link_url }
= f.gitlab_ui_checkbox_component :hashed_storage_enabled,
_('Use hashed storage'),
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 2b972a2d7f1..3d3d4ab29d1 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -24,6 +24,9 @@
_('Enforce two-factor authentication'),
help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
.form-group
+ = f.label :require_admin_two_factor_authentication, _('Enforce Two-Factor authentication for administrator users'), class: 'label-bold'
+ = f.gitlab_ui_checkbox_component :require_admin_two_factor_authentication, _('Require administrators to enable 2FA')
+ .form-group
= f.label :two_factor_authentication, _('Two-factor grace period'), class: 'label-bold'
= f.number_field :two_factor_grace_period, min: 0, class: 'form-control gl-form-input', placeholder: '0'
.form-text.text-muted
diff --git a/app/views/admin/application_settings/_user_restrictions.html.haml b/app/views/admin/application_settings/_user_restrictions.html.haml
index 4fb65c20daf..4bdc21a3695 100644
--- a/app/views/admin/application_settings/_user_restrictions.html.haml
+++ b/app/views/admin/application_settings/_user_restrictions.html.haml
@@ -8,3 +8,4 @@
= form.gitlab_ui_checkbox_component :can_create_group, _("Allow new users to create top-level groups")
= form.gitlab_ui_checkbox_component :user_defaults_to_private_profile, _("Make new users' profiles private by default")
= render_if_exists 'admin/application_settings/allow_account_deletion', form: form
+ = form.gitlab_ui_checkbox_component :allow_project_creation_for_guest_and_below, _("Allow users with up to Guest role to create groups and personal projects")
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index 672af002e5e..e8bf25b8da6 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -6,6 +6,28 @@
.settings-section
.settings-sticky-header
.settings-sticky-header-inner
+ %h4.gl-my-0 Favicon
+
+ .form-group
+ = f.label :favicon, _('Favicon'), class: 'col-form-label gl-pt-0'
+ %p
+ - if @appearance.favicon?
+ = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: favicon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') } }) do
+ = _('Remove favicon')
+ %hr
+ = f.hidden_field :favicon_cache
+ = f.file_field :favicon, class: '', accept: 'image/*'
+ .form-text.text-muted
+ = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_allowlist}.") % { favicon_extension_allowlist: favicon_extension_allowlist }
+ %br
+ = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.")
+
+ .settings-section
+ .settings-sticky-header
+ .settings-sticky-header-inner
%h4.gl-my-0= _('Navigation bar')
.form-group
@@ -26,54 +48,29 @@
.settings-section
.settings-sticky-header
.settings-sticky-header-inner
- %h4.gl-my-0 Favicon
+ %h4.gl-my-0= _('New project pages')
.form-group
- = f.label :favicon, _('Favicon'), class: 'col-form-label gl-pt-0'
+ = f.label :new_project_guidelines, class: 'col-form-label'
%p
- - if @appearance.favicon?
- = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: favicon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') } }) do
- = _('Remove favicon')
- %hr
- = f.hidden_field :favicon_cache
- = f.file_field :favicon, class: '', accept: 'image/*'
+ = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10
.form-text.text-muted
- = _("Maximum file size is 1 MB. Image size must be 32 x 32 pixels. Allowed image formats are %{favicon_extension_allowlist}.") % { favicon_extension_allowlist: favicon_extension_allowlist }
- %br
- = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.")
-
- = render partial: 'admin/application_settings/appearances/system_header_footer_form', locals: { form: f }
+ = parsed_with_gfm
.settings-section
.settings-sticky-header
.settings-sticky-header-inner
- %h4.gl-my-0= _('Sign in/Sign up pages')
+ %h4.gl-my-0= _('Profile image guidelines')
+
+ %p.gl-text-secondary
+ = _('These guidelines for public avatars are displayed on the user settings page.')
.form-group
- = f.label :title, class: 'col-form-label'
- = f.text_field :title, class: "form-control gl-form-input"
- .form-group
- = f.label :description, class: 'col-form-label'
- = f.text_area :description, class: "form-control gl-form-input", rows: 10
- .form-text.text-muted
- = parsed_with_gfm
- .form-group
- = f.label :logo, class: 'col-form-label gl-pt-0'
+ = f.label :profile_image_guidelines, class: 'col-form-label'
%p
- - if @appearance.logo?
- = image_tag @appearance.logo_path, class: 'appearance-logo-preview'
- - if @appearance.persisted?
- %br
- = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: logo_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') } }) do
- = _('Remove logo')
- %hr
- = f.hidden_field :logo_cache
- = f.file_field :logo, class: "", accept: 'image/*'
+ = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10
.form-text.text-muted
- = _('Maximum file size is 1 MB. Pages are optimized for a 128x128 px logo.')
+ = parsed_with_gfm
.settings-section
.settings-sticky-header
@@ -109,26 +106,32 @@
.settings-section
.settings-sticky-header
.settings-sticky-header-inner
- %h4.gl-my-0= _('New project pages')
+ %h4.gl-my-0= _('Sign in/Sign up pages')
.form-group
- = f.label :new_project_guidelines, class: 'col-form-label'
- %p
- = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10
- .form-text.text-muted
- = parsed_with_gfm
-
- .settings-section
- .settings-sticky-header
- .settings-sticky-header-inner
- %h4.gl-my-0= _('Profile image guideline')
-
+ = f.label :title, class: 'col-form-label'
+ = f.text_field :title, class: "form-control gl-form-input"
.form-group
- = f.label :profile_image_guidelines, class: 'col-form-label'
+ = f.label :description, class: 'col-form-label'
+ = f.text_area :description, class: "form-control gl-form-input", rows: 10
+ .form-text.text-muted
+ = parsed_with_gfm
+ .form-group
+ = f.label :logo, class: 'col-form-label gl-pt-0'
%p
- = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10
+ - if @appearance.logo?
+ = image_tag @appearance.logo_path, class: 'appearance-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: logo_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') } }) do
+ = _('Remove logo')
+ %hr
+ = f.hidden_field :logo_cache
+ = f.file_field :logo, class: "", accept: 'image/*'
.form-text.text-muted
- = parsed_with_gfm
+ = _('Maximum file size is 1 MB. Pages are optimized for a 128x128 px logo.')
+
+ = render partial: 'admin/application_settings/appearances/system_header_footer_form', locals: { form: f }
- if @appearance.persisted? || @appearance.updated_at
.settings-section
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index addd23688b4..b7a43916a30 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -7,16 +7,7 @@
.settings-header
= render 'admin/application_settings/ci/header', expanded: expanded_by_default?
.settings-content
- %p
- = _('Variables can be:')
- %ul
- %li
- = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
- %li
- = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
-
+ = render 'ci/variables/attributes'
- if ci_variable_protected_by_default?
%p.settings-message.text-center.gl-mb-0
- help_link = link_to('', help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable', target: '_blank', rel: 'noopener noreferrer'))
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 39f1ec7056c..2e16161cde4 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -120,8 +120,5 @@
= render_if_exists 'admin/application_settings/add_license'
= render 'admin/application_settings/jira_connect'
= render 'admin/application_settings/slack'
-- if Feature.enabled?(:updated_ai_powered_features_menu_for_sm)
- = render_if_exists 'admin/application_settings/ai_powered'
-- else
- = render_if_exists 'admin/application_settings/ai_access'
+= render_if_exists 'admin/application_settings/ai_powered'
= render 'admin/application_settings/security_txt', expanded: expanded_by_default?
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index ae5f7a5cec3..c2f19c11b4e 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -151,6 +151,8 @@
= render 'projects_api_limits'
+= render 'members_api_limits'
+
%section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
diff --git a/app/views/admin/dashboard/_stats_users_table.html.haml b/app/views/admin/dashboard/_stats_users_table.html.haml
index 473384b8961..a23674c79db 100644
--- a/app/views/admin/dashboard/_stats_users_table.html.haml
+++ b/app/views/admin/dashboard/_stats_users_table.html.haml
@@ -1,4 +1,4 @@
-%table.table.gl-text-gray-500
+%table.table.gl-text-gray-500.gl-w-full
%tr
%td.gl-p-5!
= s_('AdminArea|Users without a Group and Project')
diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml
index 059460ae5b2..8c096e2936f 100644
--- a/app/views/admin/dashboard/stats.html.haml
+++ b/app/views/admin/dashboard/stats.html.haml
@@ -8,7 +8,7 @@
%p.gl-font-weight-bold.gl-mt-8
= s_('AdminArea|Totals')
-%table.gl-table.gl-text-gray-500
+%table.gl-table.gl-text-gray-500.gl-w-full
= render_if_exists 'admin/dashboard/stats_active_users_row', users_statistics: @users_statistics
%tr.bg-gray-light.gl-text-gray-900
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
index b3e24d5b3ac..e2f5ef5d786 100644
--- a/app/views/admin/sessions/new.html.haml
+++ b/app/views/admin/sessions/new.html.haml
@@ -4,11 +4,9 @@
.row.gl-mt-5.justify-content-center
.col-md-5
.login-page
- #signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) }
+ .borderless
- if any_form_based_providers_enabled?
= render 'devise/shared/tabs_ldap', show_password_form: allow_admin_mode_password_authentication_for_web?, render_signup_link: false
- - else
- = render 'devise/shared/tab_single', tab_title: page_title if Feature.disabled?(:restyle_login_page, @project)
.tab-content
- if allow_admin_mode_password_authentication_for_web? || ldap_sign_in_enabled? || crowd_enabled?
= render 'admin/sessions/signin_box'
diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml
index ef004004227..898d47f446a 100644
--- a/app/views/admin/sessions/two_factor.html.haml
+++ b/app/views/admin/sessions/two_factor.html.haml
@@ -4,8 +4,7 @@
.row.justify-content-center
.col-md-5
.login-page
- #signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) }
- = render 'devise/shared/tab_single', tab_title: _('Enter admin mode') if Feature.disabled?(:restyle_login_page, @project)
+ .borderless
.login-box.gl-p-5
.login-body
- if current_user.two_factor_enabled?
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 46fe6bed05e..649ed00ea22 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -151,6 +151,8 @@
= render_if_exists 'admin/users/credit_card_info', user: @user, link_to_match_page: true
+ = render_if_exists 'admin/users/phone_info', user: @user, link_to_match_page: true
+
= render 'shared/custom_attributes', custom_attributes: @user.custom_attributes
-# Rendered on desktop only so order of cards can be different on desktop vs mobile
diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml
deleted file mode 100644
index 29c2e364c37..00000000000
--- a/app/views/ci/runner/_how_to_setup_runner.html.haml
+++ /dev/null
@@ -1,23 +0,0 @@
-- link = link_to _("Install GitLab Runner and ensure it's running."), 'https://docs.gitlab.com/runner/install/', target: '_blank', rel: 'noopener noreferrer'
-.gl-mb-3
- %h5= _("Set up a %{type} runner for a project") % { type: type }
-%ol
- %li
- = link.html_safe
- %li
- = _("Register the runner with this URL:")
- %br
- %code#coordinator_address= root_url(only_path: false)
- = deprecated_clipboard_button(target: '#coordinator_address', title: _("Copy URL"))
- %br
- %br
- = _("And this registration token:")
- %br
- %code#registration_token= registration_token
- = deprecated_clipboard_button(target: '#registration_token', title: _("Copy token"))
-
-.gl-mt-3.gl-mb-3
-= render Pajamas::ButtonComponent.new(variant: :default, method: :put, href: reset_token_url, button_options: { id: 'Reset registration token', data: { confirm: _("Are you sure you want to reset the registration token?") } }) do
- = _('Reset registration token')
-
-#js-install-runner
diff --git a/app/views/ci/variables/_attributes.html.haml b/app/views/ci/variables/_attributes.html.haml
new file mode 100644
index 00000000000..a924d92a4bb
--- /dev/null
+++ b/app/views/ci/variables/_attributes.html.haml
@@ -0,0 +1,13 @@
+%p
+ = s_('CiVariables|Variables can be accidentally exposed in a job log, or maliciously sent to a third party server. The masked variable feature can help reduce the risk of accidentally exposing variable values, but is not a guaranteed method to prevent malicious users from accessing variables.')
+ = link_to _('How can I make my variables more secure?'), help_page_path('ci/variables/index', anchor: 'cicd-variable-security'), target: '_blank', rel: 'noopener noreferrer'
+%p
+ = s_('CiVariables|Variables can have several attributes.')
+ = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'define-a-cicd-variable-in-the-ui'), target: '_blank', rel: 'noopener noreferrer'
+%ul
+ %li
+ = html_escape(s_('CiVariables|%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+ %li
+ = html_escape(s_('CiVariables|%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+ %li
+ = html_escape(s_('CiVariables|%{code_open}Expanded:%{code_close} Variables with %{code_open}$%{code_close} will be treated as the start of a reference to another variable.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 65f9e6c2342..a1567ad34e8 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -2,16 +2,7 @@
- is_group = !@group.nil?
- is_project = !@project.nil?
-%p
- = _('Variables can have several attributes.')
- = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'define-a-cicd-variable-in-the-ui'), target: '_blank', rel: 'noopener noreferrer'
-%ul
- %li
- = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- %li
- = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- %li
- = html_escape(_('%{code_open}Expanded:%{code_close} Variables with %{code_open}$%{code_close} will be treated as the start of a reference to another variable.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+= render 'ci/variables/attributes'
#js-ci-variables{ data: { endpoint: save_endpoint,
is_project: is_project.to_s,
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 4f3ca9fd71b..1b0bd10db77 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -104,7 +104,10 @@
- if todos_filter_empty?
%p
- = (s_("Todos|Are you looking for things to do? Take a look at %{strongStart}%{openIssuesLinkStart}open issues%{openIssuesLinkEnd}%{strongEnd}, contribute to %{strongStart}%{mergeRequestLinkStart}a merge request%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}, or mention someone in a comment to automatically assign them a new to-do item.") % { strongStart: '<strong>', strongEnd: '</strong>', openIssuesLinkStart: "<a href=\"#{issues_dashboard_path}\">", openIssuesLinkEnd: '</a>', mergeRequestLinkStart: "<a href=\"#{merge_requests_dashboard_path}\">", mergeRequestLinkEnd: '</a>' }).html_safe
+ = (s_("Todos|Not sure where to go next? Take a look at your %{strongStart}%{assignedIssuesLinkStart}assigned issues%{assignedIssuesLinkEnd}%{strongEnd} or %{strongStart}%{mergeRequestLinkStart}merge requests%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}.") % { strongStart: '<strong>', strongEnd: '</strong>', assignedIssuesLinkStart: "<a href=\"#{issues_dashboard_path(assignee_username: current_user.username)}\">", assignedIssuesLinkEnd: '</a>', mergeRequestLinkStart: "<a href=\"#{merge_requests_dashboard_path(assignee_username: current_user.username)}\">", mergeRequestLinkEnd: '</a>' }).html_safe
+ %p
+ = link_to s_("Todos| What actions create to-do items?"), help_page_path('user/todos', anchor: 'actions-that-create-to-do-items'), target: '_blank', rel: 'noopener noreferrer'
+
- elsif todos_has_filtered_results?
%p
= link_to s_("Todos|Do you want to remove the filters?"), todos_filter_path(without: [:project_id, :author_id, :type, :action_id])
diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml
index 00652e8574a..0ce6d9b1095 100644
--- a/app/views/devise/confirmations/new.html.haml
+++ b/app/views/devise/confirmations/new.html.haml
@@ -8,7 +8,8 @@
= f.label :email
= f.email_field :email, class: "form-control gl-form-input", required: true, autocomplete: 'off', title: _('Please provide a valid email address.'), value: nil
.form-text.gl-text-secondary
- = _('Requires your primary GitLab email address.')
+ - emails_link = link_to('', profile_emails_url, target: '_blank', rel: 'noopener noreferrer')
+ = safe_format(s_('Requires your primary GitLab email address. If you want to confirm a secondary email address, go to %{emails_link_start}Emails%{emails_link_end}'), tag_pair(emails_link, :emails_link_start, :emails_link_end))
%div
- if recaptcha_enabled?
diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml
index 227418e366d..536d4c9fd4b 100644
--- a/app/views/devise/passwords/new.html.haml
+++ b/app/views/devise/passwords/new.html.haml
@@ -16,8 +16,4 @@
= render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do
= _('Reset password')
-- if Feature.enabled?(:restyle_login_page, @project)
- = render 'devise/shared/sign_in_link'
-- else
- .gl-mt-3
- = render 'devise/shared/sign_in_link'
+= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index 29f1a1f398b..ec85c680f7f 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -11,9 +11,8 @@
= render 'devise/shared/signup_omniauth_providers'
.signup-page
- = render signup_box_template,
+ = render 'devise/shared/signup_box',
url: registration_path(resource_name, registration_path_params),
button_text: _('Register'),
- borderless: Feature.enabled?(:restyle_login_page, @project),
show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled?
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index e7ebe6d808c..728728ea653 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -9,26 +9,23 @@
= render_if_exists "layouts/google_tag_manager_body"
-#signin-container
+.js-non-oauth-login
- if any_form_based_providers_enabled?
= render 'devise/shared/tabs_ldap', render_signup_link: false
.tab-content
- if password_authentication_enabled_for_web? || ldap_sign_in_enabled? || crowd_enabled?
= render 'devise/shared/signin_box'
-
- -# Show a message if none of the mechanisms above are enabled
- - if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
- %div
- = _('No authentication methods configured.')
-
- - if Feature.enabled?(:restyle_login_page, @project) && Gitlab::CurrentSettings.current_application_settings.terms
- %p.gl-px-5
- = html_escape(s_("SignUp|By signing in you accept the %{link_start}Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}.")) % { link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe,
- link_end: '</a>'.html_safe }
-
- - if allow_signup?
- %p{ class: "gl-mt-3 #{'gl-text-center' if Feature.enabled?(:restyle_login_page, @project)}" }
- = _("Don't have an account yet?")
- = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { testid: 'register-link' }
- - if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled?
- = render 'devise/shared/omniauth_box'
+-# Show a message if none of the mechanisms above are enabled
+- if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
+ %div
+ = _('No authentication methods configured.')
+- if Gitlab::CurrentSettings.current_application_settings.terms
+ %p.gl-px-5
+ = html_escape(s_("SignUp|By signing in you accept the %{link_start}Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}.")) % { link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe,
+ link_end: '</a>'.html_safe }
+- if allow_signup?
+ %p.gl-mt-3.gl-text-center
+ = _("Don't have an account yet?")
+ = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { testid: 'register-link' }
+- if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled?
+ = render 'devise/shared/omniauth_box'
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index 96f6f5cb095..454b89e40f8 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -1,8 +1,7 @@
-= render 'devise/shared/tab_single', tab_title: _('Two-factor authentication') if Feature.disabled?(:restyle_login_page, @project)
.login-box.gl-p-5
.login-body
- if @user.two_factor_enabled?
- = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}" }) do |f|
+ = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}", aria: { live: 'assertive' }}) do |f|
.form-group
= f.label :otp_attempt, _('Enter verification code')
= f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.'), data: { testid: 'two-fa-code-field' }
diff --git a/app/views/devise/shared/_footer.html.haml b/app/views/devise/shared/_footer.html.haml
index c35e43b909e..44f34e3f342 100644
--- a/app/views/devise/shared/_footer.html.haml
+++ b/app/views/devise/shared/_footer.html.haml
@@ -7,5 +7,8 @@
= link_to _("Help"), help_path
= link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}"
= link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer'
+ - if one_trust_enabled?
+ = render Pajamas::ButtonComponent.new(category: :tertiary, size: :small, button_options: { class: 'ot-sdk-show-settings' }) do
+ = _("Cookie Preferences")
= render 'devise/shared/language_switcher'
= footer_message
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 45062745b77..8197abcc787 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -1,21 +1,16 @@
- render_remember_me = remember_me_enabled? && local_assigns.fetch(:render_remember_me, true)
-- restyle_login_page_enabled = Feature.enabled?(:restyle_login_page, @project)
-- if restyle_login_page_enabled && (any_form_based_providers_enabled? || password_authentication_enabled_for_web?)
+- if any_form_based_providers_enabled? || password_authentication_enabled_for_web?
.omniauth-divider.gl-display-flex.gl-align-items-center
= _("or sign in with")
-.gl-mt-5.gl-px-5{ class: restyle_login_page_enabled ? 'omniauth-container gl-text-center gl-ml-auto gl-mr-auto' : 'omniauth-container gl-py-5' }
- - if !restyle_login_page_enabled
- %label.gl-font-weight-bold
- = _('Sign in with')
+.gl-mt-5.gl-px-5.gl-text-center.gl-display-flex.gl-flex-direction-column.gl-gap-3.js-oauth-login
- enabled_button_based_providers.each do |provider|
- - has_icon = provider_has_icon?(provider)
- = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", data: { testid: "#{test_id_for_provider(provider)}" }, class: "btn gl-button btn-default gl-mb-2 js-oauth-login gl-w-full", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do
- - if has_icon
- = provider_image_tag(provider)
- %span.gl-button-text
- = label_for_provider(provider)
+ = render 'devise/shared/omniauth_provider_button',
+ href: omniauth_authorize_path(:user, provider),
+ provider: provider,
+ data: { testid: test_id_for_provider(provider) },
+ id: "oauth-login-#{provider}"
- if render_remember_me
= render Pajamas::CheckboxTagComponent.new(name: 'remember_me_omniauth', value: nil) do |c|
- c.with_label do
diff --git a/app/views/devise/shared/_omniauth_provider_button.haml b/app/views/devise/shared/_omniauth_provider_button.haml
new file mode 100644
index 00000000000..c33e2253bb1
--- /dev/null
+++ b/app/views/devise/shared/_omniauth_provider_button.haml
@@ -0,0 +1,7 @@
+- button_options = { class: local_assigns.fetch(:classes, nil) || nil, data: data, id: id }
+
+= render Pajamas::ButtonComponent.new(href: href, method: :post, form: true, block: true, button_options: button_options) do
+ - if provider_has_icon?(provider)
+ = provider_image_tag(provider)
+ %span.gl-button-text
+ = label_for_provider(provider)
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index fb60b8c08eb..9eb0b773ebb 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,10 +1,7 @@
-- borderless ||= false
-
-.gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') }
+.gl-mb-3.gl-p-4
= yield :omniauth_providers_top if show_omniauth_providers
= render 'devise/shared/signup_box_form',
button_text: button_text,
url: url,
show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled?
-
diff --git a/app/views/devise/shared/_signup_omniauth_provider_button.haml b/app/views/devise/shared/_signup_omniauth_provider_button.haml
index 74f009a97d3..9870e90cfff 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_button.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_button.haml
@@ -1,8 +1,6 @@
-- data = { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }
-- button_options = { class: 'js-oauth-login', data: data, id: "oauth-login-#{provider}" }
-
-= render Pajamas::ButtonComponent.new(href: href, method: :post, form: true, block: true, button_options: button_options) do
- - if provider_has_icon?(provider)
- = provider_image_tag(provider)
- %span.gl-button-text
- = label_for_provider(provider)
+= render 'devise/shared/omniauth_provider_button',
+ href: href,
+ provider: provider,
+ classes: 'js-track-omni-auth',
+ data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label },
+ id: "oauth-login-#{provider}"
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index 9916d3fa026..c1026c0f431 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -1,21 +1,9 @@
-- if Feature.enabled?(:restyle_login_page, @project)
- .gl-text-center.gl-pt-5
- %label.gl-font-weight-normal
- = _("Register with:")
- .gl-display-flex.gl-flex-direction-column.gl-gap-3
- - providers.each do |provider|
- = render 'devise/shared/signup_omniauth_provider_button',
- href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)),
- provider: provider,
- tracking_label: tracking_label
-
-
-- else
- %label.gl-font-weight-bold
- = _("Create an account using:")
+.gl-text-center.gl-pt-5
+ %label.gl-font-weight-normal
+ = _("Register with:")
.gl-display-flex.gl-flex-direction-column.gl-gap-3
- providers.each do |provider|
= render 'devise/shared/signup_omniauth_provider_button',
- href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)),
- provider: provider,
- tracking_label: tracking_label
+ href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)),
+ provider: provider,
+ tracking_label: tracking_label
diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml
index 4e62c10b258..263e11ab341 100644
--- a/app/views/devise/shared/_signup_omniauth_providers.haml
+++ b/app/views/devise/shared/_signup_omniauth_providers.haml
@@ -1,6 +1,3 @@
-- if Feature.disabled?(:restyle_login_page, @project)
- .omniauth-divider.gl-display-flex.gl-align-items-center
- = _("or")
= render 'devise/shared/signup_omniauth_provider_list',
providers: enabled_button_based_providers,
tracking_label: oauth_tracking_label
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index e6bc38ba6dd..3e9d60da228 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -1,7 +1,7 @@
- show_password_form = local_assigns.fetch(:show_password_form, password_authentication_enabled_for_web?)
- render_signup_link = local_assigns.fetch(:render_signup_link, true)
-%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: "#{'custom-provider-tabs' if any_form_based_providers_enabled?} #{'nav-links-unboxed' if Feature.enabled?(:restyle_login_page, @project)}" }
+%ul.nav-links.new-session-tabs.nav-tabs.nav.nav-links-unboxed
- if crowd_enabled?
%li.nav-item
= link_to _("Crowd"), "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab', role: 'tab'
diff --git a/app/views/devise/shared/_terms_of_service_notice.html.haml b/app/views/devise/shared/_terms_of_service_notice.html.haml
index 3749dc66a04..5d5a5a64c29 100644
--- a/app/views/devise/shared/_terms_of_service_notice.html.haml
+++ b/app/views/devise/shared/_terms_of_service_notice.html.haml
@@ -1,17 +1,9 @@
- return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms?
%p.gl-text-gray-500.gl-mt-5.gl-mb-0
- - if Feature.enabled?(:restyle_login_page, @project)
- - if Gitlab.com?
- = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the GitLab%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}")) % { button_text: button_text,
- link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe }
- - else
- = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}")) % { button_text: button_text,
- link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe }
+ - if Gitlab.com?
+ = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the GitLab%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}")) % { button_text: button_text,
+ link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe }
- else
- - if Gitlab.com?
- = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the GitLab %{link_start}Terms of Use and Privacy Statement%{link_end}")) % { button_text: button_text,
- link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe }
- - else
- = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the %{link_start}Terms of Use and Privacy Statement%{link_end}")) % { button_text: button_text,
- link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe }
+ = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}")) % { button_text: button_text,
+ link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe }
diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml
index 8bae27020c2..393f42cd197 100644
--- a/app/views/devise/unlocks/new.html.haml
+++ b/app/views/devise/unlocks/new.html.haml
@@ -11,8 +11,5 @@
= render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do
= _('Resend unlock instructions')
-- if Feature.enabled?(:restyle_login_page, @project)
- = render 'devise/shared/sign_in_link'
-- else
- .gl-mt-3
- = render 'devise/shared/sign_in_link'
+= render 'devise/shared/sign_in_link'
+
diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml
index c39f5cf87c7..43a8ccdaae4 100644
--- a/app/views/groups/_import_group_from_file_panel.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -10,14 +10,14 @@
alert_options: { class: 'gl-mb-5' },
dismissible: false) do |c|
- c.with_body do
- - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') }
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index') }
- link_end = '</a>'.html_safe
= s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end }
= render 'shared/groups/group_name_and_path_fields', f: f
.form-group
= f.label :file, s_('GroupsNew|Upload file')
.gl-font-weight-normal
- - import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/group/import/index') }
+ - import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/project/settings/import_export', anchor: 'migrate-groups-by-uploading-an-export-file-deprecated') }
= s_('GroupsNew|To import a group, navigate to the group settings for the GitLab source instance, %{link_start}generate an export file%{link_end}, and upload it here.').html_safe % { link_start: import_export_link_start, link_end: '</a>'.html_safe }
.gl-mt-3
= render 'shared/file_picker_button', f: f, field: :file, help_text: nil
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
index ff1d76f470c..c7909a7c249 100644
--- a/app/views/groups/settings/_export.html.haml
+++ b/app/views/groups/settings/_export.html.haml
@@ -10,7 +10,7 @@
- c.with_body do
= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c|
- c.with_body do
- - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') }
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index') }
- docs_link_end = '</a>'.html_safe
= s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
%p
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 4334c4996f2..fae0c41b683 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -49,6 +49,7 @@
= render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group
= render 'groups/settings/membership', f: f, group: @group
= render_if_exists 'groups/settings/security_policies_custom_ci', f: f, group: @group
+ = render_if_exists 'groups/settings/security_policies_policy_scope', f: f, group: @group
%h5= _('Customer relations')
.form-group.gl-mb-3
@@ -57,4 +58,12 @@
checkbox_options: { checked: @group.crm_enabled? },
help_text: s_('GroupSettings|Organizations and contacts can be created and associated with issues.')
+ - if Feature.enabled?(:group_hierarchy_optimization, @group, type: :beta)
+ %h5= _('Performance')
+ .form-group.gl-mb-3
+ = f.gitlab_ui_checkbox_component :enable_namespace_descendants_cache,
+ s_('GroupSettings|Enable caching of hierarchical objects (subgroups and projects) to improve the performance of group-level features within a large group.'),
+ checkbox_options: { checked: @group.namespace_descendants.present? },
+ help_text: s_('GroupSettings|Building the cache is asynchronous, happens in a background job. The cache invalidation is synchronous with strong consistency guarantees.')
+
= f.submit _('Save changes'), pajamas_button: true, class: 'gl-mt-3 js-dirty-submit', data: { testid: 'save-permissions-changes-button' }
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index 97acafe24d0..0f56ae92557 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -12,4 +12,4 @@
cancel_path: cancel_import_github_path,
details_path: details_import_github_path,
status_import_github_group_path: status_import_github_group_path(format: :json),
- optional_stages: Gitlab::GithubImport::Settings.stages_array
+ optional_stages: Gitlab::GithubImport::Settings.stages_array(current_user)
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 5f038ac467d..79fa5bfeac0 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -19,7 +19,6 @@
= yield :prefetch_asset_tags
- diffs_colors = user_diffs_colors
- = stylesheet_link_tag "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename
= render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path
- if user_application_theme == 'gl-dark'
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index a7caa797a46..3af04db4cfd 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -1,16 +1,13 @@
.layout-page{ class: page_with_sidebar_class }
- - if show_super_sidebar?
- -# Render the parent group sidebar while creating a new subgroup/project, see GroupsController#new.
- - group = @parent_group || @group
+ -# Render the parent group sidebar while creating a new subgroup/project, see GroupsController#new.
+ - group = @parent_group || @group
- - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization)
- - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json
- %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } }
+ - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization)
+ - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json
+ %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } }
- = render_if_exists "layouts/tanuki_bot_chat"
+ = render_if_exists "layouts/tanuki_bot_chat"
- - elsif defined?(nav) && nav
- = render "layouts/nav/sidebar/#{nav}"
.content-wrapper{ class: "#{@content_wrapper_class}" }
.mobile-overlay
= dispensable_render_if_exists 'layouts/header/verification_reminder'
diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index 3582deea902..503b38496f7 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -2,7 +2,7 @@
- namespace = @group || @project&.namespace || @namespace
= webpack_bundle_tag 'tracker'
-- if Gitlab.com? && Feature.enabled?(:browsersdk_tracking) && Feature.enabled?(:gl_analytics_tracking, Feature.current_request)
+- if Gitlab.com? && Feature.enabled?(:gl_analytics_tracking, Feature.current_request)
= webpack_bundle_tag 'analytics'
= javascript_tag do
:plain
@@ -13,7 +13,6 @@
namespace_id: namespace&.id,
plan_name: namespace&.actual_plan_name,
project_id: @project&.id,
- user_id: current_user&.id,
- new_nav: show_super_sidebar?
+ user_id: current_user&.id
).to_context.to_json.to_json}
gl.snowplowPseudonymizedPageUrl = #{masked_page_url(group: namespace, project: @project).to_json};
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 78fa40167f8..b9257bcedc9 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,11 +1,13 @@
- page_classes = page_class << @html_class
- page_classes = [user_application_theme, page_classes.flatten.compact]
- body_classes = [user_tab_width, @body_class, client_class_list, *custom_diff_color_classes]
+- ff_simplified_labels_enabled = Feature.enabled?(:simplified_labels) ? 'ff-simplified-labels-enabled' : ''
+- ff_simplified_badges_class = Feature.enabled?(:simplified_badges) ? 'ff-simplified-badges-enabled' : ''
!!! 5
%html{ lang: I18n.locale, class: page_classes }
= render "layouts/head"
- %body{ class: body_classes, data: body_data }
+ %body{ class: [body_classes, ff_simplified_labels_enabled, ff_simplified_badges_class], data: body_data }
= render "layouts/init_auto_complete" if @gfm_form
= render "layouts/init_client_detection_flags"
= render "layouts/visual_review" if review_apps_enabled?
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 920771bf4c2..2905ba924ca 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -6,63 +6,31 @@
%body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page, testid: 'login-page' } }
= header_message
= render "layouts/init_client_detection_flags"
- - if Feature.enabled?(:restyle_login_page, @project)
- = yield :sessions_broadcast
- .gl-h-full.borderless.gl-display-flex.gl-flex-wrap
- .container
- .content
- = render "layouts/flash"
- - if custom_text.present?
- .row
- .col-md.order-12.sm-bg-gray
- .col-sm-12
- %h1.mb-3.gl-font-size-h2
- = brand_title
- = custom_text
- .col-md.order-md-12
- .col-sm-12.bar
- .gl-text-center
- = brand_image
- = yield
- - else
- .mt-3
- .col-sm-12.gl-text-center
- = brand_image
+ = yield :sessions_broadcast
+ .gl-h-full.borderless.gl-display-flex.gl-flex-wrap
+ .container.gl-align-self-center
+ .content
+ = render "layouts/flash"
+ - if custom_text.present?
+ .row
+ .col-md.order-12.sm-bg-gray
+ .col-sm-12
%h1.mb-3.gl-font-size-h2
= brand_title
- .mb-3
- .gl-w-full.gl-sm-w-half.gl-ml-auto.gl-mr-auto.bar
+ = custom_text
+ .col-md.order-md-12
+ .col-sm-12.bar
+ .gl-text-center
+ = brand_image
= yield
-
- = render 'devise/shared/footer'
- - else
- = render "layouts/header/empty"
- = yield :sessions_broadcast
- .gl-h-full.gl-display-flex.gl-flex-wrap
- .container
- .content
- = render "layouts/flash"
- .row.mt-3
- .col-sm-12
- %h1.mb-3.font-weight-normal
- = current_appearance&.title.presence || _('GitLab')
- .row.mb-3
- .col-md-6.order-12.order-sm-1.brand-holder
- - unless recently_confirmed_com?
- = brand_image
- - if custom_text.present?
- = custom_text
- - else
- %h3.gl-sm-mt-0
- = _('A complete DevOps platform')
-
- %p
- = _('GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.')
-
- %p
- = _('This is a self-managed instance of GitLab.')
-
- .col-md-6.order-1{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' }
+ - else
+ .mt-3
+ .col-sm-12.gl-text-center
+ = brand_image
+ %h1.mb-3.gl-font-size-h2
+ = brand_title
+ .mb-3
+ .gl-w-full.gl-sm-w-half.gl-ml-auto.gl-mr-auto.bar
= yield
- = render 'devise/shared/footer'
+ = render 'devise/shared/footer'
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index 6816a64ac8f..faf45ae78ef 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -7,7 +7,7 @@
= render "layouts/init_client_detection_flags"
= render "layouts/header/empty"
.gl-h-full.gl-display-flex.gl-flex-wrap
- .container
+ .container.gl-align-self-center
.content
= render "layouts/flash"
= yield
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 498e9216894..28305960de9 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -23,5 +23,6 @@
= dispensable_render_if_exists "shared/free_user_cap_alert", source: @group
= dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group
= dispensable_render_if_exists "shared/code_suggestions_ga_non_owner_alert", resource: @group
+= dispensable_render_if_exists "shared/code_suggestions_ga_owner_alert", resource: @group
= render template: base_layout || "layouts/application"
diff --git a/app/views/layouts/header/_super_sidebar_logged_out.haml b/app/views/layouts/header/_super_sidebar_logged_out.haml
index fc63400e011..9b0424ea478 100644
--- a/app/views/layouts/header/_super_sidebar_logged_out.haml
+++ b/app/views/layouts/header/_super_sidebar_logged_out.haml
@@ -2,7 +2,7 @@
%a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content
.container-fluid
%nav.header-logged-out-nav.gl-display-flex.gl-gap-3.gl-justify-content-space-between{ 'aria-label': s_('LoggedOutMarketingHeader|Explore GitLab') }
- .header-logged-out-logo.gl-display-flex.gl-align-items-center
+ .header-logged-out-logo.gl-display-flex.gl-align-items-center.gl-gap-3
%span.gl-sr-only GitLab
= link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', aria: { label: _('Homepage') }, **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do
= brand_header_logo
diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml
index 3894501bbbb..37bf8515a8c 100644
--- a/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml
+++ b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml
@@ -1,8 +1,8 @@
- dropdown_location = local_assigns.fetch(:location, nil)
- button_tooltip = local_assigns.fetch(:title, _("Show all breadcrumbs"))
- if defined?(@breadcrumb_collapsed_links) && @breadcrumb_collapsed_links.key?(dropdown_location)
- %li.expander.gl-breadcrumb-item
- %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander{ type: "button", data: { container: "body" }, "aria-label": button_tooltip, title: button_tooltip }
+ %li.expander.gl-breadcrumb-item.gl-display-inline-flex
+ %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander.gl-ml-0{ type: "button", data: { container: "body" }, "aria-label": button_tooltip, title: button_tooltip }
= sprite_icon("ellipsis_h", size: 12)
- @breadcrumb_collapsed_links[dropdown_location].each_with_index do |link, index|
%li.gl-breadcrumb-item{ :class => "gl-display-none!" }
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index e95d645769e..5a6f45d4dd7 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -26,5 +26,6 @@
= dispensable_render_if_exists "projects/free_user_cap_alert", project: @project
= dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project
= dispensable_render_if_exists 'projects/code_suggestions_ga_non_owner_alert', project: @project
+= dispensable_render_if_exists 'projects/code_suggestions_ga_owner_alert', project: @project
= render template: "layouts/application"
diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml
index c8e15896b97..e3e071c2226 100644
--- a/app/views/layouts/signup_onboarding.html.haml
+++ b/app/views/layouts/signup_onboarding.html.haml
@@ -7,7 +7,7 @@
= header_message
= render "layouts/init_client_detection_flags"
= render "layouts/header/logo_with_title"
- .container
+ .container.gl-align-self-center
.content
= yield
diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml
index ead8e5d0a7e..b89d897a81b 100644
--- a/app/views/notify/_reassigned_issuable_email.html.haml
+++ b/app/views/notify/_reassigned_issuable_email.html.haml
@@ -1,7 +1,12 @@
-- to_names = content_tag(:strong, issuable.assignees.any? ? sanitize_name(issuable.assignee_list) : _('Unassigned'))
+- added_names = content_tag(:strong, sanitize_name(added_assignees.to_sentence(locale: I18n.locale)))
+- removed_names = content_tag(:strong, sanitize_name(removed_assignees.to_sentence(locale: I18n.locale)))
-%p
- - if previous_assignees.any?
- = html_escape(s_('Notify|Assignee changed from %{fromNames} to %{toNames}').html_safe % { fromNames: content_tag(:strong, sanitize_name(previous_assignees.map(&:name).to_sentence)), toNames: to_names })
- - else
- = html_escape(s_('Notify|Assignee changed to %{toNames}').html_safe % { toNames: to_names})
+- if added_assignees.any?
+ %p
+ = html_escape(n_(s_('Notify|%{added} was added as an assignee.'), s_('Notify|%{added} were added as assignees.'), added_assignees.length).html_safe % { added: added_names })
+- if removed_assignees.any? && issuable.assignees.any?
+ %p
+ = html_escape(n_(s_('Notify|%{removed} was removed as an assignee.'), s_('Notify|%{removed} were removed as assignees.'), removed_assignees.length).html_safe % { removed: removed_names })
+- if removed_assignees.any? && issuable.assignees.empty?
+ %p
+ = html_escape(s_('Notify|All assignees were removed.'))
diff --git a/app/views/notify/new_review_email.html.haml b/app/views/notify/new_review_email.html.haml
index 8a184aa9696..5b870fe2214 100644
--- a/app/views/notify/new_review_email.html.haml
+++ b/app/views/notify/new_review_email.html.haml
@@ -2,24 +2,41 @@
= content_for :head do
= stylesheet_link_tag 'mailers/highlighted_diff_email'
-%table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;margin:0 auto;border-collapse:separate;border-spacing:0;" }
- %tbody
- %tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;overflow:hidden;" }
- %table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" }
- %tbody
- %tr
- %td{ style: "color:#333333;border-bottom:1px solid #ededed;font-weight:bold;line-height:1.4;padding: 20px 0;" }
- - mr_link = link_to(@merge_request.to_reference(@project), project_merge_request_url(@project, @merge_request))
- - mr_author_link = link_to(@author_name, user_url(@author))
- = _('Merge request %{mr_link} was reviewed by %{mr_author}').html_safe % { mr_link: mr_link, mr_author: mr_author_link }
- %tr
- %td{ style: "overflow:hidden;line-height:1.4;display:grid;" }
- - @notes.each do |note|
- -# Get preloaded note discussion
- - discussion = @discussions[note.discussion_id] if note.part_of_discussion?
- -# Preload project for discussions first note
- - discussion.first_note.project = @project if discussion&.first_note
- - target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}")
- = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed; padding-bottom: 1em;", include_stylesheet_link: false, discussion: discussion, author: @author
- = render_if_exists 'notify/review_summary'
+- if Feature.enabled?(:enhanced_review_email, @project, type: :gitlab_com_derisk)
+ %div{ style: "color:#333333;border-bottom:8px solid #ededed;font-weight:bold;line-height:1.4;padding: 20px 0;" }
+ - mr_link = link_to(@merge_request.to_reference(@project), project_merge_request_url(@project, @merge_request))
+ - mr_author_link = link_to(@author_name, user_url(@author))
+ = _('Merge request %{mr_link} was reviewed by %{mr_author}').html_safe % { mr_link: mr_link, mr_author: mr_author_link }
+
+ - @notes.each do |note|
+ -# Get preloaded note discussion
+ - discussion = @discussions[note.discussion_id] if note.part_of_discussion?
+ -# Preload project for discussions first note
+ - discussion.first_note.project = @project if discussion&.first_note
+ - target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}")
+ = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:4px solid #ededed; padding-bottom: 1em;", include_stylesheet_link: false, discussion: discussion, author: @author
+ = render_if_exists 'notify/review_summary'
+
+- else
+
+ %table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;margin:0 auto;border-collapse:separate;border-spacing:0;" }
+ %tbody
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;overflow:hidden;" }
+ %table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" }
+ %tbody
+ %tr
+ %td{ style: "color:#333333;border-bottom:1px solid #ededed;font-weight:bold;line-height:1.4;padding: 20px 0;" }
+ - mr_link = link_to(@merge_request.to_reference(@project), project_merge_request_url(@project, @merge_request))
+ - mr_author_link = link_to(@author_name, user_url(@author))
+ = _('Merge request %{mr_link} was reviewed by %{mr_author}').html_safe % { mr_link: mr_link, mr_author: mr_author_link }
+ %tr
+ %td{ style: "overflow:hidden;line-height:1.4;display:grid;" }
+ - @notes.each do |note|
+ -# Get preloaded note discussion
+ - discussion = @discussions[note.discussion_id] if note.part_of_discussion?
+ -# Preload project for discussions first note
+ - discussion.first_note.project = @project if discussion&.first_note
+ - target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}")
+ = render 'note_email', note: note, diff_limit: 3, target_url: target_url, note_style: "border-bottom:1px solid #ededed; padding-bottom: 1em;", include_stylesheet_link: false, discussion: discussion, author: @author
+ = render_if_exists 'notify/review_summary'
diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml
index 6b088927623..e5860c1672a 100644
--- a/app/views/notify/reassigned_issue_email.html.haml
+++ b/app/views/notify/reassigned_issue_email.html.haml
@@ -1 +1 @@
-= render 'reassigned_issuable_email', issuable: @issue, previous_assignees: @previous_assignees
+= render 'reassigned_issuable_email', issuable: @issue, added_assignees: @added_assignees, removed_assignees: @removed_assignees
diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb
index f37c8ffa515..b5e5abbbccf 100644
--- a/app/views/notify/reassigned_issue_email.text.erb
+++ b/app/views/notify/reassigned_issue_email.text.erb
@@ -1,6 +1,15 @@
+<% added_names = sanitize_name(@added_assignees.to_sentence(locale: I18n.locale)) -%>
+<% removed_names = sanitize_name(@removed_assignees.to_sentence(locale: I18n.locale)) -%>
Reassigned Issue <%= @issue.iid %>
<%= url_for([@issue.project, @issue, { only_path: false }]) %>
-Assignee changed<%= " from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%>
- to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %>
+<%- if @added_assignees.any? %>
+<%= html_escape(n_(s_('Notify|%{added} was added as an assignee.'), s_('Notify|%{added} were added as assignees.'), @added_assignees.length).html_safe % { added: added_names }) %>
+<% end -%>
+<%- if @removed_assignees.any? && @issue.assignees.any? %>
+<%= html_escape(n_(s_('Notify|%{removed} was removed as an assignee.'), s_('Notify|%{removed} were removed as assignees.'), @removed_assignees.length).html_safe % { removed: removed_names }) %>
+<% end -%>
+<%- if @removed_assignees.any? && @issue.assignees.empty? %>
+<%= html_escape(s_('Notify|All assignees were removed.')) %>
+<% end -%>
diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml
index 0aefca6b14a..74de6767fe2 100644
--- a/app/views/notify/reassigned_merge_request_email.html.haml
+++ b/app/views/notify/reassigned_merge_request_email.html.haml
@@ -1 +1 @@
-= render 'reassigned_issuable_email', issuable: @merge_request, previous_assignees: @previous_assignees
+= render 'reassigned_issuable_email', issuable: @merge_request, added_assignees: @added_assignees, removed_assignees: @removed_assignees
diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb
index 888b995b67c..7929349c439 100644
--- a/app/views/notify/reassigned_merge_request_email.text.erb
+++ b/app/views/notify/reassigned_merge_request_email.text.erb
@@ -1,6 +1,15 @@
+<% added_names = sanitize_name(@added_assignees.to_sentence(locale: I18n.locale)) -%>
+<% removed_names = sanitize_name(@removed_assignees.to_sentence(locale: I18n.locale)) -%>
Reassigned merge request <%= @merge_request.iid %>
<%= url_for([@merge_request.project, @merge_request, { only_path: false }]) %>
-Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%>
- to <%= "#{@merge_request.assignees.any? ? @merge_request.assignee_list : 'Unassigned'}" %>
+<%- if @added_assignees.any? %>
+<%= html_escape(n_(s_('Notify|%{added} was added as an assignee.'), s_('Notify|%{added} were added as assignees.'), @added_assignees.length).html_safe % { added: added_names }) %>
+<% end -%>
+<%- if @removed_assignees.any? && @merge_request.assignees.any? %>
+<%= html_escape(n_(s_('Notify|%{removed} was removed as an assignee.'), s_('Notify|%{removed} were removed as assignees.'), @removed_assignees.length).html_safe % { removed: removed_names }) %>
+<% end -%>
+<%- if @removed_assignees.any? && @merge_request.assignees.empty? %>
+<%= html_escape(s_('Notify|All assignees were removed.')) %>
+<% end -%>
diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml
index 6f0c091dfdb..3ecdc3f63e5 100644
--- a/app/views/profiles/accounts/_providers.html.haml
+++ b/app/views/profiles/accounts/_providers.html.haml
@@ -3,26 +3,27 @@
%label.label-bold.gl-mb-0
= s_('Profiles|Connected Accounts')
%p= s_('Profiles|Select a service to sign in with.')
- - providers.each do |provider|
- - unlink_allowed = unlink_provider_allowed?(provider)
- - link_allowed = link_provider_allowed?(provider)
- - has_icon = provider_has_icon?(provider)
- - if unlink_allowed || link_allowed
- - if auth_active?(provider)
- - if unlink_allowed
- = link_to unlink_profile_account_path(provider: provider), method: :delete, class: button_class do
+ .gl-display-flex.gl-flex-wrap.gl-gap-3
+ - providers.each do |provider|
+ - unlink_allowed = unlink_provider_allowed?(provider)
+ - link_allowed = link_provider_allowed?(provider)
+ - has_icon = provider_has_icon?(provider)
+ - if unlink_allowed || link_allowed
+ - if auth_active?(provider)
+ - if unlink_allowed
+ = link_to unlink_profile_account_path(provider: provider), method: :delete, class: button_class do
+ - if has_icon
+ .social-provider-btn-image.gl-button-icon= provider_image_tag(provider)
+ .gl-button-text
+ = s_('Profiles|Disconnect %{provider}') % { provider: label_for_provider(provider) }
+ - else
+ %a{ class: button_class }
+ .gl-button-text
+ = s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) }
+ - elsif link_allowed
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: button_class do
- if has_icon
.social-provider-btn-image.gl-button-icon= provider_image_tag(provider)
.gl-button-text
- = s_('Profiles|Disconnect %{provider}') % { provider: label_for_provider(provider) }
- - else
- %a{ class: button_class }
- .gl-button-text
- = s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) }
- - elsif link_allowed
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: button_class do
- - if has_icon
- .social-provider-btn-image.gl-button-icon= provider_image_tag(provider)
- .gl-button-text
- = s_('Profiles|Connect %{provider}') % { provider: label_for_provider(provider) }
- = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities
+ = s_('Profiles|Connect %{provider}') % { provider: label_for_provider(provider) }
+ = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index 6dcd661ecdb..3f18a7bbda6 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -24,7 +24,7 @@
= sprite_icon('mail', css_class: 'gl-mr-2')
= @emails.load.size
.gl-new-card-actions
- = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content", data: { testid: 'toggle_email_address_field' } }) do
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-toggle-button js-toggle-content", data: { testid: 'toggle-email-address-field' } }) do
= s_('Profiles|Add new email')
- c.with_body do
.gl-new-card-add-form.gl-m-3.gl-mb-4.gl-display-none.js-toggle-content
@@ -33,9 +33,9 @@
= gitlab_ui_form_for 'email', url: profile_emails_path do |f|
.form-group
= f.label :email, s_('Profiles|Email address'), class: 'label-bold'
- = f.text_field :email, class: 'form-control gl-form-input gl-form-input-xl', data: { qa_selector: 'email_address_field' }
+ = f.text_field :email, class: 'form-control gl-form-input gl-form-input-xl', data: { testid: 'email-address-field' }
.gl-mt-3
- = f.submit s_('Profiles|Add email address'), data: { qa_selector: 'add_email_address_button' }, pajamas_button: true
+ = f.submit s_('Profiles|Add email address'), data: { testid: 'add-email-address-button' }, pajamas_button: true
= render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do
= _('Cancel')
- if @emails.any?
@@ -59,7 +59,7 @@
= s_('Profiles|Default notification email')
.gl-text-secondary.gl-font-sm= notification_message.html_safe
- @emails.reject(&:user_primary_email?).each do |email|
- %li{ class: 'gl-px-5!', data: { qa_selector: 'email_row_content' } }
+ %li{ class: 'gl-px-5!', data: { testid: 'email-row-content' } }
.gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-gap-3
%div
= render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
@@ -81,4 +81,4 @@
- confirm_title = "#{email.confirmation_sent_at ? s_('Profiles|Resend confirmation email') : s_('Profiles|Send confirmation email')}"
= link_button_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, size: :small
- = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), confirm_btn_variant: 'danger', qa_selector: 'delete_email_link'}, method: :delete, size: :small, icon: 'remove', 'aria-label': _('Remove')
+ = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), confirm_btn_variant: 'danger', testid: 'delete-email-link'}, method: :delete, size: :small, icon: 'remove', 'aria-label': _('Remove')
diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml
index 0a50ce55b50..ea7068e0484 100644
--- a/app/views/profiles/gpg_keys/_key_table.html.haml
+++ b/app/views/profiles/gpg_keys/_key_table.html.haml
@@ -3,7 +3,7 @@
- if @gpg_keys.any?
.table-holder
- %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } }
+ %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list
%thead.d-none.d-md-table-header-group
%tr
%th= s_('Profiles|Key')
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 96375412f94..0d1e911f29d 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -9,9 +9,6 @@
- data_attributes = { themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path }
- @force_desktop_expanded_sidebar = true
-- Gitlab::Themes.each do |theme|
- = stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
-
= gitlab_ui_form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f|
= render_if_exists 'profiles/preferences/code_suggestions_settings_self_assignment'
.settings-section.js-preferences-form.js-search-settings-section.application-theme#navigation-theme
@@ -173,7 +170,6 @@
.form-group
= f.gitlab_ui_checkbox_component :enabled_following,
s_('Preferences|Enable follow users')
- = render_if_exists 'profiles/preferences/code_suggestions_settings', form: f
= render_if_exists 'profiles/preferences/zoekt_settings', form: f
#js-profile-preferences-app{ data: data_attributes }
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 79b2726ed2d..9f33ad0c2d4 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -66,7 +66,7 @@
%h4.gl-my-0= s_("Profiles|Time settings")
%p.gl-text-secondary= s_("Profiles|Set your local time zone.")
= f.label :user_timezone, _("Time zone")
- .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } }
+ .js-timezone-dropdown{ data: { timezone_data: timezone_data_with_unique_identifiers.to_json, initial_value: @user.timezone, name: 'user[timezone]' } }
.settings-section.js-search-settings-section.gl-border-t.gl-pt-6
.settings-sticky-header
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 63226838166..0bf05c85f5f 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -14,8 +14,6 @@
= render partial: 'shared/ci_catalog_badge', locals: { href: explore_catalog_path(@project.catalog_resource), css_class: 'gl-mx-0' }
- if @project.group
= render_if_exists 'shared/tier_badge', source: @project, namespace_to_track: @project.namespace
- .gl-text-secondary
- = render_if_exists "projects/home_mirror"
.project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3
- if current_user
@@ -48,7 +46,7 @@
= render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link, button_options: { class: 'js-read-more-trigger gl-lg-display-none' }) do
= _("Read more")
- = render_if_exists "projects/home_mirror"
+ = render_if_exists "projects/home_mirror"
- if ff_reorg_disabled && @project.badges.present?
.project-badges.mb-2{ data: { testid: 'project-badges-content' } }
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 3e92ef25552..b9abaa07c2c 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -18,42 +18,42 @@
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body', testid: 'gitlab-import-button' } }
- = render Pajamas::ButtonComponent.new(href: '#', icon: 'tanuki', button_options: { class: 'btn_import_gitlab_project js-import-project-btn', data: { href: new_import_gitlab_project_path, platform: 'gitlab_export', **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } }) do
+ = render Pajamas::ButtonComponent.new(href: '#', icon: 'tanuki', button_options: { class: 'btn_import_gitlab_project js-import-project-btn', data: { href: new_import_gitlab_project_path, platform: 'gitlab_export', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } }) do
= _('GitLab export')
- if github_import_enabled?
%div
- = render Pajamas::ButtonComponent.new(href: new_import_github_path(namespace_id: namespace_id), icon: 'github', button_options: { class: 'js-import-github js-import-project-btn', data: { platform: 'github', **tracking_attrs_data(track_label, 'click_button', 'github') } }) do
+ = render Pajamas::ButtonComponent.new(href: new_import_github_path(namespace_id: namespace_id), icon: 'github', button_options: { class: 'js-import-github js-import-project-btn', data: { platform: 'github', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'github') } }) do
GitHub
- if bitbucket_import_enabled?
%div
- = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: "import_bitbucket js-import-project-btn #{'js-how-to-import-link' unless bitbucket_import_configured?}", data: { modal_title: _("Import projects from Bitbucket"), modal_message: import_from_bitbucket_message, platform: 'bitbucket_cloud', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_cloud') } }) do
+ = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: "import_bitbucket js-import-project-btn #{'js-how-to-import-link' unless bitbucket_import_configured?}", data: { modal_title: _("Import projects from Bitbucket"), modal_message: import_from_bitbucket_message, platform: 'bitbucket_cloud', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'bitbucket_cloud') } }) do
Bitbucket Cloud
- if bitbucket_server_import_enabled?
%div
- = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_server_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: 'import_bitbucket js-import-project-btn', data: { platform: 'bitbucket_server', **tracking_attrs_data(track_label, 'click_button', 'bitbucket_server') } }) do
+ = render Pajamas::ButtonComponent.new(href: status_import_bitbucket_server_path(namespace_id: namespace_id), icon: 'bitbucket', button_options: { class: 'import_bitbucket js-import-project-btn', data: { platform: 'bitbucket_server', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'bitbucket_server') } }) do
Bitbucket Server
- if fogbugz_import_enabled?
%div
- = render Pajamas::ButtonComponent.new(href: new_import_fogbugz_path(namespace_id: namespace_id), icon: 'bug', button_options: { class: 'import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } }) do
+ = render Pajamas::ButtonComponent.new(href: new_import_fogbugz_path(namespace_id: namespace_id), icon: 'bug', button_options: { class: 'import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } }) do
FogBugz
- if gitea_import_enabled?
%div
- = render Pajamas::ButtonComponent.new(href: new_import_gitea_path(namespace_id: namespace_id), icon: 'gitea', button_options: { class: 'import_gitea js-import-project-btn', data: { platform: 'gitea', **tracking_attrs_data(track_label, 'click_button', 'gitea') } }) do
+ = render Pajamas::ButtonComponent.new(href: new_import_gitea_path(namespace_id: namespace_id), icon: 'gitea', button_options: { class: 'import_gitea js-import-project-btn', data: { platform: 'gitea', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'gitea') } }) do
Gitea
- if git_import_enabled?
%div
- = render Pajamas::ButtonComponent.new(icon: 'link', button_options: { class: 'js-toggle-button js-import-git-toggle-button js-import-project-btn', data: { platform: 'repo_url', toggle_open_class: 'active', **tracking_attrs_data(track_label, 'click_button', 'repo_url') } }) do
+ = render Pajamas::ButtonComponent.new(icon: 'link', button_options: { class: 'js-toggle-button js-import-git-toggle-button js-import-project-btn', data: { platform: 'repo_url', toggle_open_class: 'active', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'repo_url') } }) do
= _('Repository by URL')
- if manifest_import_enabled?
%div
- = render Pajamas::ButtonComponent.new(href: new_import_manifest_path(namespace_id: namespace_id), icon: 'doc-text', button_options: { class: 'import_manifest js-import-project-btn', data: { platform: 'manifest_file', **tracking_attrs_data(track_label, 'click_button', 'manifest_file') } }) do
+ = render Pajamas::ButtonComponent.new(href: new_import_manifest_path(namespace_id: namespace_id), icon: 'doc-text', button_options: { class: 'import_manifest js-import-project-btn', data: { platform: 'manifest_file', track_experiment: local_assigns[:track_experiment], **tracking_attrs_data(track_label, 'click_button', 'manifest_file') } }) do
= _('Manifest file')
= render_if_exists "projects/gitee_import_button", namespace_id: namespace_id, track_label: track_label
@@ -63,4 +63,4 @@
= gitlab_ui_form_for @project, html: { class: 'new_project gl-show-field-errors js-project-import' } do |f|
%hr
= render "shared/import_form", f: f
- = render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label
+ = render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: 'import_project'
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
index c3d66396256..fc9ddb650e9 100644
--- a/app/views/projects/_readme.html.haml
+++ b/app/views/projects/_readme.html.haml
@@ -1,5 +1,5 @@
- if (readme = @repository.readme) && readme.rich_viewer
- .tree-holder
+ .tree-holder.gl-mt-5
.nav-block.mt-0
= render 'projects/tree/tree_header', tree: @tree
%article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) }
diff --git a/app/views/projects/_sidebar.html.haml b/app/views/projects/_sidebar.html.haml
index 565f14d01d9..7cb2f622788 100644
--- a/app/views/projects/_sidebar.html.haml
+++ b/app/views/projects/_sidebar.html.haml
@@ -2,43 +2,44 @@
- show_auto_devops_callout = show_auto_devops_callout?(@project)
%aside.project-page-sidebar
- - if @project.description.present? || @project.badges.present?
- .project-page-sidebar-block.home-panel-home-desc.gl-py-4.gl-border-b.gl-border-gray-50
- -# Project description
- - if @project.description.present?
- .gl-display-flex.gl-justify-content-space-between.gl-mt-1.gl-pr-2
- %p.gl-font-weight-bold.gl-text-gray-900.gl-m-0= s_('ProjectPage|Project information')
- = render Pajamas::ButtonComponent.new(href: edit_project_path(@project),
- category: :tertiary,
- icon: 'settings',
- size: :small,
- button_options: { class: 'has-tooltip', title: s_('ProjectPage|Project settings'), 'aria-label' => s_('ProjectPage|Project settings') })
- .home-panel-description.text-break
- .home-panel-description-markdown{ itemprop: 'description' }
- = markdown_field(@project, :description)
+ .project-page-sidebar-block.home-panel-home-desc.gl-py-4.gl-border-b.gl-border-gray-50{ class: 'gl-pt-2!' }
+ .gl-display-flex.gl-justify-content-space-between
+ %p.gl-font-weight-bold.gl-text-gray-900.gl-m-0.gl-mb-1= s_('ProjectPage|Project information')
+ -# Project settings
+ - if can?(current_user, :admin_project, @project)
+ = render Pajamas::ButtonComponent.new(href: edit_project_path(@project),
+ category: :tertiary,
+ icon: 'settings',
+ size: :small,
+ button_options: { class: 'has-tooltip gl-ml-2 gl-sm-mr-3', title: s_('ProjectPage|Project settings'), 'aria-label' => s_('ProjectPage|Project settings'), 'data-testid': 'project-settings-button' })
+ -# Project description
+ - if @project.description.present?
+ .home-panel-description.text-break
+ .home-panel-description-markdown{ itemprop: 'description' }
+ = markdown_field(@project, :description)
- -# Topics
- - if @project.topics.present?
- .gl-mb-5
- = render "shared/projects/topics", project: @project
+ -# Topics
+ - if @project.topics.present?
+ .gl-mb-5
+ = render "shared/projects/topics", project: @project
- -# Programming languages
- - if can?(current_user, :read_code, @project) && @project.repository_languages.present?
- .gl-mb-2{ class: ('gl-mb-4!' if @project.badges.present?) }
- = repository_languages_bar(@project.repository_languages)
+ -# Programming languages
+ - if can?(current_user, :read_code, @project) && @project.repository_languages.present?
+ .gl-mb-2{ class: [('gl-mb-4!' if @project.badges.present?), ('gl-mt-3' if !@project.description.present?)] }
+ = repository_languages_bar(@project.repository_languages)
- -# Badges
- - if @project.badges.present?
- .project-badges.gl-mb-2{ data: { testid: 'project-badges-content' } }
- - @project.badges.each do |badge|
- - badge_link_url = badge.rendered_link_url(@project)
- %a.gl-mr-3{ href: badge_link_url,
- target: '_blank',
- rel: 'noopener noreferrer',
- data: { testid: 'badge-image-link', qa_link_url: badge_link_url } }>
- %img.project-badge{ src: badge.rendered_image_url(@project),
- 'aria-hidden': true,
- alt: 'Project badge' }>
+ -# Badges
+ - if @project.badges.present?
+ .project-badges.gl-mb-2{ data: { testid: 'project-badges-content' } }
+ - @project.badges.each do |badge|
+ - badge_link_url = badge.rendered_link_url(@project)
+ %a.gl-mr-3{ href: badge_link_url,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ data: { testid: 'badge-image-link', qa_link_url: badge_link_url } }>
+ %img.project-badge{ src: badge.rendered_image_url(@project),
+ 'aria-hidden': true,
+ alt: 'Project badge' }>
-# Invite members
- if @project.empty_repo?
diff --git a/app/views/projects/buttons/_code.html.haml b/app/views/projects/buttons/_code.html.haml
index a78e3861e94..9cdbe1d5f6b 100644
--- a/app/views/projects/buttons/_code.html.haml
+++ b/app/views/projects/buttons/_code.html.haml
@@ -8,9 +8,9 @@
%span.js-clone-dropdown-label
= _('Code')
= sprite_icon("chevron-down", css_class: "icon")
- %ul.dropdown-menu.dropdown-menu-large.clone-options-dropdown{ class: dropdown_class, data: { testid: 'clone-dropdown-content' } }
+ %ul.dropdown-menu.dropdown-menu-large.clone-options-dropdown{ role: 'menu', class: dropdown_class, data: { testid: 'clone-dropdown-content' } }
- if ssh_enabled?
- %li.gl-dropdown-item.js-clone-links{ class: 'gl-px-4!' }
+ %li.gl-dropdown-item.js-clone-links{ role: 'menuitem', class: 'gl-px-4!' }
%label.label-bold
= _('Clone with SSH')
.input-group.btn-group
@@ -19,7 +19,7 @@
= clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), category: :primary, size: :medium)
= render_if_exists 'projects/buttons/geo'
- if http_enabled?
- %li.pt-2.gl-dropdown-item.js-clone-links{ class: 'gl-px-4!' }
+ %li.pt-2.gl-dropdown-item.js-clone-links{ role: 'menuitem', class: 'gl-px-4!' }
%label.label-bold
= _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase }
.input-group.btn-group
@@ -28,8 +28,8 @@
= clipboard_button(target: '#http_project_clone', title: _("Copy URL"), category: :primary, size: :medium)
= render_if_exists 'projects/buttons/geo'
= render_if_exists 'projects/buttons/kerberos_clone_field'
- %li.divider.mt-2
- %li.pt-2.gl-dropdown-item.js-clone-links
+ %li.divider.mt-2{ role: 'presentation' }
+ %li.pt-2.gl-dropdown-item.js-clone-links{ role: 'menuitem' }
%label.label-bold{ class: 'gl-px-4!' }
= _('Open in your IDE')
- if ssh_enabled?
@@ -55,5 +55,5 @@
.gl-dropdown-item-text-wrapper
= _("Xcode")
- if !project.empty_repo? && can?(current_user, :download_code, project)
- %li.divider.mt-2
+ %li.divider.mt-2{ role: 'presentation' }
= render 'projects/buttons/download_menu_items', project: project, ref: ref
diff --git a/app/views/projects/buttons/_download_menu_items.html.haml b/app/views/projects/buttons/_download_menu_items.html.haml
index f5f8efca073..7d7033da9cd 100644
--- a/app/views/projects/buttons/_download_menu_items.html.haml
+++ b/app/views/projects/buttons/_download_menu_items.html.haml
@@ -2,7 +2,7 @@
- ref = local_assigns.fetch(:ref)
- archive_prefix = "#{project.path}-#{ref.tr('/', '-')}"
-%li.gl-dropdown-item{ role: 'menuitem' }
- %h3.h5.m-0.dropdown-bold-header= _('Download source code')
+%li.gl-dropdown-item{ class: 'gl-pt-3!', role: 'menuitem' }
+ %label.label-bold{ class: 'gl-px-4!' }= _('Download source code')
= render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil
.js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } }
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index be0e5a428b4..fc9104f9f27 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -18,7 +18,6 @@
= commit_committer_link(@commit, avatar: true, size: 24)
#{time_ago_with_tooltip(@commit.committed_date)}
- #js-commit-comments-button{ data: { comments_count: @notes_count.to_i } }
= link_button_to _('Browse files'), project_tree_path(@project, @commit), class: 'gl-mr-3 gl-w-full gl-sm-w-auto gl-mb-3 gl-sm-mb-0'
#js-commit-options-dropdown{ data: commit_options_dropdown_data(@project, @commit) }
@@ -36,7 +35,7 @@
%span.cgray= n_('parent', 'parents', @commit.parents.count)
- @commit.parents.each do |parent|
= link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha"
- #js-commit-branches-and-tags{ data: { full_path: @project.full_path, commit_sha: @commit.short_id } }
+ #js-commit-branches-and-tags{ data: { full_path: @project.full_path, commit_sha: @commit.short_id } }
.well-segment.merge-request-info
.icon-container
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 9269369c83e..90837a1a291 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -118,8 +118,11 @@
= render 'remove_fork', project: @project
= render 'remove', project: @project
-- elsif can?(current_user, :archive_project, @project)
- = render_if_exists 'projects/settings/archive'
+- else
+ - if can?(current_user, :archive_project, @project)
+ = render_if_exists 'projects/settings/archive'
+ - if can?(current_user, :remove_project, @project)
+ = render 'remove', project: @project
.save-project-loader.hide
.center
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 684ea8242f7..ac3b67d6157 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -20,7 +20,7 @@
.project-clone-holder.d-block.d-sm-none
= render "shared/mobile_clone_panel"
- .project-clone-holder.gl-display-none.gl-sm-display-flex.gl-justify-content-end.gl-w-full.gl-mt-2
+ .project-clone-holder.gl-display-none.gl-sm-display-flex.gl-justify-content-end.gl-w-full
= render "projects/buttons/code", ref: @ref
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-new-card-body gl-bg-gray-10 gl-p-5' }) do |c|
diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml
index 2b4d19a0e1d..54855999431 100644
--- a/app/views/projects/environments/folder.html.haml
+++ b/app/views/projects/environments/folder.html.haml
@@ -3,4 +3,4 @@
- page_title _("Environments in %{name}") % { name: @folder }
- add_page_specific_style 'page_bundles/environments'
-#environments-folder-list-view{ data: { environments_data: environments_folder_list_view_data, project_path: @project.full_path } }
+#environments-folder-list-view{ data: environments_folder_list_view_data(@project, @folder) }
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index 98055534a27..10ca730ac11 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -16,7 +16,7 @@
.dropdown.gl-display-inline.gl-md-ml-3.issue-sort-dropdown.gl-mt-3.gl-md-mt-0
.btn-group{ role: 'group' }
- = gl_redirect_listbox_tag [created_at, activity], @sort
+ = gl_redirect_listbox_tag [created_at, activity], @sort, class: 'btn-group'
= forks_sort_direction_button(sort_value)
- if current_user && can?(current_user, :fork_project, @project)
diff --git a/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml b/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml
index 0118fe94810..750dea9896f 100644
--- a/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml
+++ b/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml
@@ -11,8 +11,8 @@
Full name: #{docker_image.name}
.gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1
= sprite_icon('earth', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16')
- URI:
- %a{ href: docker_image.uri, target: 'blank', rel: 'noopener noreferrer' }= docker_image.uri
+ %a{ href: docker_image.details_url, target: 'blank', rel: 'noopener noreferrer' }
+ Artifact Registry details page
.gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1
= sprite_icon('doc-code', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16')
Media Type: #{docker_image.media_type}
diff --git a/app/views/projects/merge_requests/_code_dropdown.html.haml b/app/views/projects/merge_requests/_code_dropdown.html.haml
index bfa33f26453..c03b1ac1b28 100644
--- a/app/views/projects/merge_requests/_code_dropdown.html.haml
+++ b/app/views/projects/merge_requests/_code_dropdown.html.haml
@@ -1,8 +1,8 @@
.gl-md-ml-3.dropdown.gl-dropdown{ class: "gl-display-none! gl-md-display-flex!" }
#js-check-out-modal{ data: how_merge_modal_data(@merge_request) }
- = button_tag type: 'button', class: "btn dropdown-toggle btn-confirm gl-button gl-dropdown-toggle", data: { toggle: 'dropdown', testid: 'mr-code-dropdown' } do
- %span.gl-dropdown-button-text= _('Code')
- = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon gl-ml-2 gl-mr-0!"
+ = render Pajamas::ButtonComponent.new(category: :primary, variant: :confirm, button_options: { data: { toggle: 'dropdown', testid: 'mr-code-dropdown' } }) do
+ = _('Code')
+ = sprite_icon "chevron-down", size: 16, css_class: "gl-icon gl-mr-0!"
.dropdown-menu.dropdown-menu-right
.gl-dropdown-inner
.gl-dropdown-contents
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 1b0aba8d496..03c850b7fbb 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -24,7 +24,10 @@
.detail-page-header-actions.gl-align-self-start.is-merge-request.js-issuable-actions.gl-display-flex
- if can_update_merge_request
- = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { testid: "edit-title-button" }}) do
+ - edit_action_description = _('Edit merge request')
+ - edit_action_shortcut = 'e'
+ - edit_button_title = "#{edit_action_description} <kbd class='flat ml-1' aria-hidden=true>#{edit_action_shortcut}</kbd>"
+ = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: { aria: {label: edit_action_description, keyshortcuts: edit_action_shortcut}, class: "gl-display-none gl-md-display-block has-tooltip js-issuable-edit", data: { html: "true", testid: "edit-title-button" }, title: edit_button_title }) do
= _('Edit')
- if @merge_request.source_project
diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml
index 03a1f2f3179..af8ad22fa50 100644
--- a/app/views/projects/merge_requests/_page.html.haml
+++ b/app/views/projects/merge_requests/_page.html.haml
@@ -16,6 +16,7 @@
- add_page_specific_style 'page_bundles/ci_status'
- add_page_startup_api_call @endpoint_metadata_url
+- add_page_startup_api_call @pinned_file_url if @pinned_file_url
- if mr_action == 'diffs' && !@file_by_file_default
- add_page_startup_api_call @endpoint_diff_batch_url
diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml
index f2c2700b012..4f722ba901d 100644
--- a/app/views/projects/merge_requests/creations/new.html.haml
+++ b/app/views/projects/merge_requests/creations/new.html.haml
@@ -11,7 +11,7 @@
= render 'new_submit'
- else
- if conflicting_mr
- - link_to_mr = link_to(conflicting_mr.to_reference, project_merge_request_path(@project, conflicting_mr))
+ - link_to_mr = link_to(conflicting_mr.to_reference, project_merge_request_path(conflicting_mr.target_project, conflicting_mr))
- flash.now[:alert] = safe_format(s_("These branches already have an open merge request: %{link_to_mr}. Select a different source or target branch."), link_to_mr: link_to_mr)
= render 'new_compare'
diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml
index 5e3c4889d1d..ab0786a6f5b 100644
--- a/app/views/projects/mirrors/_mirror_repos_list.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml
@@ -29,7 +29,7 @@
- if mirror.disabled?
= render 'projects/mirrors/disabled_mirror_badge'
- if mirror.last_error.present?
- = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', testid: 'mirror-error-badge-content' }, title: html_escape(mirror.last_error.try(:strip)) }
+ = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', testid: 'mirror-error-badge-content' }, title: html_escape(mirror.last_error.try(:strip)), tabindex: 0 }
%td
- if mirror_settings_enabled
.btn-group.mirror-actions-group{ role: 'group' }
diff --git a/app/views/projects/ml/models/index.html.haml b/app/views/projects/ml/models/index.html.haml
index ffe7ee3397e..ba695bce435 100644
--- a/app/views/projects/ml/models/index.html.haml
+++ b/app/views/projects/ml/models/index.html.haml
@@ -1,4 +1,4 @@
- breadcrumb_title s_('ModelRegistry|Model registry')
- page_title s_('ModelRegistry|Model registry')
-= render(Projects::Ml::ModelsIndexComponent.new(paginator: @paginator, model_count: @model_count))
+= render(Projects::Ml::ModelsIndexComponent.new(project: @project, current_user: current_user, paginator: @paginator, model_count: @model_count))
diff --git a/app/views/projects/ml/models/new.html.haml b/app/views/projects/ml/models/new.html.haml
new file mode 100644
index 00000000000..8510ffd42fd
--- /dev/null
+++ b/app/views/projects/ml/models/new.html.haml
@@ -0,0 +1,5 @@
+- breadcrumb_title s_('ModelRegistry|New model')
+- page_title s_('ModelRegistry|New model')
+- view_model = Gitlab::Json.generate({ projectPath: @project.full_path })
+
+#js-mount-new-ml-model{ data: { view_model: view_model } }
diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml
index bec35dba147..360ef01620b 100644
--- a/app/views/projects/pages_domains/_dns.html.haml
+++ b/app/views/projects/pages_domains/_dns.html.haml
@@ -9,7 +9,7 @@
.input-group
= text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true
.input-group-append
- = deprecated_clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block')
+ = clipboard_button(target: '#domain_dns', category: :primary, size: :medium)
%p.form-text.text-muted
= _("To access this domain create a new DNS record")
- if verification_enabled
@@ -25,7 +25,7 @@
.input-group
= text_field_tag :domain_verification, domain_presenter.verification_record, class: "monospace js-select-on-focus form-control", readonly: true
.input-group-append
- = deprecated_clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block')
+ = clipboard_button(target: '#domain_verification', category: :primary, size: :medium)
%p.form-text.text-muted
- link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: '4-verify-the-domains-ownership'))
= _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration within seven days.").html_safe % { link_to_help: link_to_help }
diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml
index c42367f45c5..6e11b625490 100644
--- a/app/views/search/results/_blob_data.html.haml
+++ b/app/views/search/results/_blob_data.html.haml
@@ -1,6 +1,6 @@
-.js-blob-result.gl-mt-3.gl-mb-5{ data: { qa_selector: 'result_item_content' } }
+.js-blob-result.gl-mt-3.gl-mb-5{ data: { testid: 'result-item-content' } }
.file-holder.file-holder-top-border
- .js-file-title.file-title{ data: { qa_selector: 'file_title_content' } }
+ .js-file-title.file-title{ data: { testid: 'file-title-content' } }
= link_to blob_link, data: {track_action: 'click_text', track_label: 'blob_path', track_property: 'search_result'} do
= sprite_icon('document')
%strong
@@ -8,7 +8,7 @@
= copy_file_path_button(path)
- if blob.data
- if blob.data.size > 0
- .file-content.code.term{ data: { qa_selector: 'file_text_content' } }
+ .file-content.code.term{ data: { testid: 'file-text-content' } }
= render 'search/results/blob_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link, blame_link: blame_link, highlight_line: blob.highlight_line
- else
.file-content.code
diff --git a/app/views/search/results/_blob_highlight.html.haml b/app/views/search/results/_blob_highlight.html.haml
index 37ffabad717..2a10bc1989b 100644
--- a/app/views/search/results/_blob_highlight.html.haml
+++ b/app/views/search/results/_blob_highlight.html.haml
@@ -3,7 +3,7 @@
#search-blob-content.file-content.code.js-syntax-highlight{ class: 'gl-py-3!' }
- if blob.present?
- .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } }
+ .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight } }
- blob_highlight = blob.present.highlight_and_trim(trim_length: 1024, ellipsis_svg: sprite_icon('ellipsis_h', size: 12, css_class: "gl-text-gray-700"))
- blob_highlight.lines.each_with_index do |line, index|
- i = index + offset
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index 16e4ff4d17f..3e2373446ca 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -14,6 +14,7 @@
= _("Are you sure you want to unsubscribe from the %{type}: %{link_to_noteable_text}?").html_safe % { type: noteable_type, link_to_noteable_text: link_to_noteable_text }
%p
- = link_to _('Unsubscribe'), unsubscribe_sent_notification_path(@sent_notification, force: true),
- class: 'gl-button btn btn-confirm gl-mr-3'
- = link_button_to _('Cancel'), new_user_session_path, class: 'gl-mr-3'
+ = render Pajamas::ButtonComponent.new(href: unsubscribe_sent_notification_path(@sent_notification, force: true), variant: 'confirm', button_options: { class: 'gl-mr-3'}) do
+ = _('Unsubscribe')
+ = render Pajamas::ButtonComponent.new(href: new_user_session_path, button_options: { class: 'gl-mr-3'}) do
+ = _('Cancel')
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index af09b62c229..546e92e7dcb 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -1,14 +1,13 @@
%div{ class: [container_class, @content_class, 'gl-pt-5!'] }
= render Pajamas::BannerComponent.new(button_text: s_('AutoDevOps|Enable in settings'),
button_link: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'),
- svg_path: 'illustrations/autodevops.svg',
+ svg_path: 'illustrations/devops-sm.svg',
banner_options: { class: 'js-autodevops-banner auto-devops-callout', data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } },
close_options: { 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box'), class: 'js-close-callout' }) do |c|
- c.with_title do
= s_('AutoDevOps|Auto DevOps')
- %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
-
%p
- link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
= s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
index 0ff2ee935cc..05a3fd2abc9 100644
--- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
+++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
@@ -1,5 +1,5 @@
- if show_auto_devops_implicitly_enabled_banner?(project, current_user)
- = render Pajamas::AlertComponent.new(alert_options: { class: 'auto-devops-implicitly-enabled-banner', data: { qa_selector: 'auto_devops_banner_content' } },
+ = render Pajamas::AlertComponent.new(alert_options: { class: 'auto-devops-implicitly-enabled-banner' },
close_button_options: { class: 'hide-auto-devops-implicitly-enabled-banner',
data: { project_id: project.id }}) do |c|
- c.with_body do
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index 9dfbad20726..3b53a4b4e89 100644
--- a/app/views/shared/_file_highlight.html.haml
+++ b/app/views/shared/_file_highlight.html.haml
@@ -16,7 +16,7 @@
%a.file-line-num.diff-line-num{ class: line_class, href: "#L#{i}", id: "L#{i}", 'data-line-number' => i }
= i
- .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } }
+ .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight } }
%pre.code.highlight
%code
= highlighted_blob
diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml
index 1bac75e0ff5..b4013cb5b80 100644
--- a/app/views/shared/_visibility_radios.html.haml
+++ b/app/views/shared/_visibility_radios.html.haml
@@ -6,7 +6,7 @@
= form.gitlab_ui_radio_component model_method, level,
"#{visibility_level_icon(level)} #{visibility_level_label(level)}".html_safe,
help_text: '<span class="option-description">%{visibility_level_description}</span><span class="option-disabled-reason"></span>'.html_safe % { visibility_level_description: visibility_level_description(level, form_model)},
- radio_options: { checked: (selected_level == level), data: { track_label: "blank_project", track_action: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" } },
+ radio_options: { checked: (selected_level == level), data: { track_label: "blank_project", track_action: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "" } },
label_options: { class: 'js-visibility-level-radio' }
diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml
index 5188c530672..95c99f20380 100644
--- a/app/views/shared/deploy_keys/_index.html.haml
+++ b/app/views/shared/deploy_keys/_index.html.haml
@@ -13,4 +13,9 @@
.gl-new-card-add-form.gl-m-3.gl-display-none.js-toggle-content
= render @deploy_keys.form_partial_path
- #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } }
+ #js-deploy-keys{ data: { project_id: @project.id,
+ project_path: @project.full_path,
+ enabled_endpoint: enabled_keys_project_deploy_keys_path(@project),
+ available_project_endpoint: available_project_keys_project_deploy_keys_path(@project),
+ available_public_endpoint: available_public_keys_project_deploy_keys_path(@project)
+ } }
diff --git a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml
deleted file mode 100644
index 2bc2e6c5b81..00000000000
--- a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-.created-deploy-token-container.info-well{ data: { testid: 'created-deploy-token-container' } }
- .well-segment
- %h5.gl-mt-0
- = s_('DeployTokens|Your new Deploy Token username')
-
- .form-group
- .input-group
- = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { testid: 'deploy-token-user-field' }
- .input-group-append
- = deprecated_clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username'), placement: 'left')
- %span.deploy-token-help-block.gl-mt-2.text-success
- - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/deploy_tokens/index') }
- - link_end = "</a>".html_safe
- = s_("DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}").html_safe % { link_start: link_start, link_end: link_end }
-
- .form-group
- .input-group
- = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { testid: 'deploy-token-field' }
- .input-group-append
- = deprecated_clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token'), placement: 'left')
- %span.deploy-token-help-block.gl-mt-2.text-danger
- - i_start = "<i>".html_safe
- - i_end = "</i>".html_safe
- = s_("DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.").html_safe % { i_start: i_start, i_end: i_end }
diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml
index 375e10de065..cdcbee1bb72 100644
--- a/app/views/shared/groups/_group.html.haml
+++ b/app/views/shared/groups/_group.html.haml
@@ -2,9 +2,8 @@
- access = user&.max_member_access_for_group(group.id)
%li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!" }
- .avatar-container.rect-avatar.s48.gl-flex-shrink-0
- = link_to group do
- = render Pajamas::AvatarComponent.new(group, alt: group.name, size: 48)
+ = link_to group do
+ = render Pajamas::AvatarComponent.new(group, alt: group.name, size: 48, class: 'gl-mr-5')
.gl-min-w-0.gl-flex-grow-1
.title
= link_to group.full_name, group, class: 'group-name'
@@ -23,7 +22,7 @@
%span.gl-ml-5.has-tooltip{ title: _('Users') }
= sprite_icon('users', css_class: 'gl-vertical-align-text-bottom')
- = number_with_delimiter(group.users.count)
+ = number_with_delimiter(group.group_members.non_invite.count)
%span.gl-ml-5.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) }
= visibility_level_icon(group.visibility_level)
diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index 550f079bf3b..ecd722ec53e 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -11,6 +11,7 @@
%ul.content-list
- groups.each_with_index do |group, i|
= render "shared/groups/group", group: group, user: user
+ = paginate_collection(groups)
- else
= render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
current_user_empty_message_header: current_user_empty_message_header,
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index 0a3fd4f8b9e..261a7517eda 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -5,5 +5,5 @@
.gl-ml-3
.btn-group{ role: 'group' }
- = gl_redirect_listbox_tag(items, selected, data: { placement: 'right' })
+ = gl_redirect_listbox_tag(items, selected, class: 'btn-group', data: { placement: 'right' })
= issuable_sort_direction_button(@sort)
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index c86993f5b77..42eb9e5ca19 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -9,7 +9,7 @@
-# Note this is just for individual members. For groups please see shared/members/_group
-%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } }
+%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("flex-md-row" unless force_mobile_view)], id: dom_id(member) }
%span.list-item-name.mb-2.m-md-0
- if user
= render Pajamas::AvatarComponent.new(user, size: 32, class: 'gl-mr-3 flex-shrink-0 flex-grow-0')
@@ -49,7 +49,7 @@
= _("Expires %{preposition} %{expires_at}").html_safe % { expires_at: time_ago_with_tooltip(member.expires_at), preposition: preposition }
- else
- = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: ''
+ = render Pajamas::AvatarComponent.new(Pajamas::AvatarEmail.new(member.invite_email), size: 32, class: 'gl-mr-3 flex-shrink-0 flex-grow-0')
.user-info
.member= member.invite_email
.cgray
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index 343a8597444..969ca2084d7 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -10,7 +10,7 @@
.notes.notes-form.timeline
.timeline-entry.note-form
.timeline-entry-inner
- .flash-container.timeline-content
+ .flash-container
.timeline-content.timeline-content-form
= render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete
diff --git a/app/views/shared/users/_user.html.haml b/app/views/shared/users/_user.html.haml
index e3c1ca4d9cf..4dc276a45c2 100644
--- a/app/views/shared/users/_user.html.haml
+++ b/app/views/shared/users/_user.html.haml
@@ -7,7 +7,7 @@
.user-info
.block-truncated
- = link_to user.name, user_path(user), class: 'user js-user-link', data: { user_id: user.id, qa_selector: 'user_link', qa_username: user.username }
+ = link_to user.name, user_path(user), class: 'user js-user-link', data: { user_id: user.id, testid: 'user-link', qa_username: user.username }
.block-truncated
%span.gl-text-gray-900= user.to_reference
diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml
index ccd86937e4f..c4670a3ac73 100644
--- a/app/views/shared/web_hooks/_index.html.haml
+++ b/app/views/shared/web_hooks/_index.html.haml
@@ -11,7 +11,7 @@
- c.with_body do
= gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form gl-new-card-add-form gl-m-3 gl-display-none js-toggle-content' } do |f|
= render partial: partial, locals: { form: f, hook: @hook }
- = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" }
+ = f.submit _('Add webhook'), pajamas_button: true
= render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do
= _('Cancel')
- if hooks.any?
diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml
index cd752d31643..dd4ea9e72ab 100644
--- a/app/views/shared/wikis/_sidebar.html.haml
+++ b/app/views/shared/wikis/_sidebar.html.haml
@@ -15,7 +15,7 @@
- if can?(current_user, :create_wiki, @wiki)
- edit_sidebar_url = wiki_page_path(@wiki, Wiki::SIDEBAR, action: :edit)
- link_class = (editing && @page&.slug == Wiki::SIDEBAR) ? 'active' : ''
- = link_to edit_sidebar_url, class: link_class, data: { qa_selector: 'edit_sidebar_link' } do
+ = link_to edit_sidebar_url, class: link_class do
= sprite_icon('pencil', css_class: 'gl-mr-2')
%span= _("Edit sidebar")
diff --git a/app/views/shared/wikis/git_error.html.haml b/app/views/shared/wikis/git_error.html.haml
index 12eddb4a61e..aee359b35b3 100644
--- a/app/views/shared/wikis/git_error.html.haml
+++ b/app/views/shared/wikis/git_error.html.haml
@@ -8,7 +8,7 @@
.wiki-page-header.top-area.gl-flex-direction-column.gl-lg-flex-direction-row
.gl-mt-5.gl-mb-3
.gl-display-flex.gl-justify-content-space-between
- %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page ? @page.human_title : _('Failed to retrieve page')
- .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content' } }
+ %h2.gl-mt-0.gl-mb-5{ data: { testid: 'wiki-page-title' } }= @page ? @page.human_title : _('Failed to retrieve page')
+ .js-wiki-page-content.md.gl-pt-2{ data: { testid: 'wiki-page-content' } }
= _('The page could not be displayed because it timed out.')
= html_escape(_('You can view the source or %{linkStart}%{cloneIcon} clone the repository%{linkEnd}')) % { linkStart: "<a href=\"#{git_access_url}\">".html_safe, linkEnd: '</a>'.html_safe, cloneIcon: sprite_icon('download', css_class: 'gl-mr-2').html_safe }
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index a896aa29f52..e33828b95ab 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -13,7 +13,10 @@
.nav-controls.pb-md-3.pb-lg-0
- if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding
- = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), button_options: { class: 'js-wiki-edit', data: { testid: 'wiki-edit-button' }}) do
+ - edit_action_description = _('Edit page')
+ - edit_action_shortcut = 'e'
+ - edit_button_title = "#{edit_action_description} <kbd class='flat ml-1' aria-hidden=true>#{edit_action_shortcut}</kbd>"
+ = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), button_options: { aria: {label: edit_action_description, keyshortcuts: edit_action_shortcut}, class: 'has-tooltip js-wiki-edit', data: { html: 'true', testid: 'wiki-edit-button' }, title: edit_button_title }) do
= _('Edit')
= render 'shared/wikis/main_links'
@@ -35,6 +38,6 @@
.gl-display-flex.gl-justify-content-space-between
%h2.gl-mt-0.gl-mb-5{ data: { testid: 'wiki-page-title' } }= @page.human_title
- .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } }
+ .js-async-wiki-page-content.md.gl-pt-2{ data: { testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } }
= render 'shared/wikis/sidebar'
diff --git a/app/views/user_settings/passwords/edit.html.haml b/app/views/user_settings/passwords/edit.html.haml
index afe6ee2c0b3..179f54ac45e 100644
--- a/app/views/user_settings/passwords/edit.html.haml
+++ b/app/views/user_settings/passwords/edit.html.haml
@@ -18,18 +18,18 @@
- unless @user.password_automatically_set?
.form-group
= f.label :password, _('Current password'), class: 'label-bold'
- = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input gl-max-w-80', data: { qa_selector: 'current_password_field' }
+ = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input gl-max-w-80', data: { testid: 'current-password-field' }
%p.form-text.text-muted
= _('You must provide your current password in order to change it.')
.form-group
= f.label :new_password, _('New password'), class: 'label-bold'
- = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-max-w-80', data: { qa_selector: 'new_password_field' }
+ = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-max-w-80', data: { testid: 'new-password-field' }
= render_if_exists 'shared/password_requirements_list'
.form-group
= f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
- = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-80', data: { qa_selector: 'confirm_password_field' }
+ = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-80', data: { testid: 'confirm-password-field' }
.gl-mt-3.gl-mb-3
- = f.submit _('Save password'), class: "gl-mr-3", data: { qa_selector: 'save_password_button' }, pajamas_button: true
+ = f.submit _('Save password'), class: "gl-mr-3", data: { testid: 'save-password-button' }, pajamas_button: true
- unless @user.password_automatically_set?
= render Pajamas::ButtonComponent.new(href: reset_user_settings_password_path, variant: :link, method: :put) do
= _('I forgot my password')
diff --git a/app/views/user_settings/passwords/new.html.haml b/app/views/user_settings/passwords/new.html.haml
index 3616c9ec252..4b47dfa3e83 100644
--- a/app/views/user_settings/passwords/new.html.haml
+++ b/app/views/user_settings/passwords/new.html.haml
@@ -16,17 +16,17 @@
.col-sm-2.col-form-label
= f.label :password, _('Current password')
.col-sm-10
- = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
+ = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { testid: 'current-password-field' }
.form-group.row
.col-sm-2.col-form-label
= f.label :new_password, _('New password')
.col-sm-10
- = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { qa_selector: 'new_password_field' }
+ = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { testid: 'new-password-field' }
= render_if_exists 'shared/password_requirements_list'
.form-group.row
.col-sm-2.col-form-label
= f.label :password_confirmation, _('Password confirmation')
.col-sm-10
- = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' }
+ = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { testid: 'confirm-password-field' }
.form-actions
- = f.submit _('Set new password'), data: { qa_selector: 'set_new_password_button' }, pajamas_button: true
+ = f.submit _('Set new password'), data: { testid: 'set-new-password-button' }, pajamas_button: true
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 99097ac397c..3aee73b0b96 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -26,7 +26,7 @@
= render 'users/view_user_in_admin_area'
.js-user-profile-actions{ data: user_profile_actions_data(@user) }
- .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?), ('gl-mb-4!' if show_super_sidebar?)] }
+ .profile-header.gl-mx-5.gl-mb-4{ class: [('gl-mb-6' if profile_tabs.empty?)] }
.gl-display-inline-block.gl-mx-8.gl-vertical-align-top
.avatar-holder
= link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do
@@ -113,9 +113,9 @@
= @user.bio
-# TODO: Remove this with the removal of the old navigation.
- -# See https://gitlab.com/groups/gitlab-org/-/epics/11875.
+ -# See https://gitlab.com/gitlab-org/gitlab/-/issues/435899.
- if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user)
- .scrolling-tabs-container{ class: [('gl-display-none' if show_super_sidebar?)] }
+ .scrolling-tabs-container.gl-display-none
%button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
= sprite_icon('chevron-lg-left', size: 12)
%button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index ec5156bb1d0..dfad9f7f673 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -345,6 +345,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: cronjob:click_house_event_authors_consistency_cron
+ :worker_name: ClickHouse::EventAuthorsConsistencyCronWorker
+ :feature_category: :value_stream_management
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:click_house_events_sync
:worker_name: ClickHouse::EventsSyncWorker
:feature_category: :value_stream_management
@@ -786,6 +795,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: cronjob:releases_publish_event
+ :worker_name: Releases::PublishEventWorker
+ :feature_category: :release_orchestration
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:remove_expired_group_links
:worker_name: RemoveExpiredGroupLinksWorker
:feature_category: :system_access
@@ -1353,6 +1371,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_replay_events
+ :worker_name: Gitlab::GithubImport::ReplayEventsWorker
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: github_importer:github_import_stage_finish_import
:worker_name: Gitlab::GithubImport::Stage::FinishImportWorker
:feature_category: :importers
@@ -2593,7 +2620,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: false
+ :idempotent: true
:tags: []
- :name: bulk_import
:worker_name: BulkImportWorker
diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb
index bfe561cca5c..5204db2159d 100644
--- a/app/workers/bulk_imports/export_request_worker.rb
+++ b/app/workers/bulk_imports/export_request_worker.rb
@@ -75,7 +75,7 @@ module BulkImports
::GlobalID.parse(response.dig(*entity_query.data_path, 'id')).model_id
rescue StandardError => e
- log_exception(e, message: 'Failed to fetch source entity id')
+ log_warning(e, message: 'Failed to fetch source entity id')
nil
end
@@ -92,14 +92,21 @@ module BulkImports
@logger ||= Logger.build.with_entity(entity)
end
- def log_exception(exception, payload)
+ def build_payload(exception, payload)
Gitlab::ExceptionLogFormatter.format!(exception, payload)
+ structured_payload(payload)
+ end
+
+ def log_warning(exception, payload)
+ logger.warn(build_payload(exception, payload))
+ end
- logger.error(structured_payload(payload))
+ def log_error(exception, payload)
+ logger.error(build_payload(exception, payload))
end
def log_and_fail(exception)
- log_exception(exception, message: "Request to export #{entity.source_type} failed")
+ log_error(exception, message: "Request to export #{entity.source_type} failed")
BulkImports::Failure.create(failure_attributes(exception))
@@ -107,7 +114,7 @@ module BulkImports
end
def export_url
- entity.export_relations_url_path(batched: Feature.enabled?(:bulk_imports_batched_import_export))
+ entity.export_relations_url_path
end
end
end
diff --git a/app/workers/ci/unlock_pipelines_in_queue_worker.rb b/app/workers/ci/unlock_pipelines_in_queue_worker.rb
index de579504711..01a0dff4ca0 100644
--- a/app/workers/ci/unlock_pipelines_in_queue_worker.rb
+++ b/app/workers/ci/unlock_pipelines_in_queue_worker.rb
@@ -11,6 +11,7 @@ module Ci
feature_category :build_artifacts
idempotent!
+ MAX_RUNNING_EXTRA_LOW = 10
MAX_RUNNING_LOW = 50
MAX_RUNNING_MEDIUM = 500
MAX_RUNNING_HIGH = 1500
@@ -44,6 +45,8 @@ module Ci
MAX_RUNNING_HIGH
elsif ::Feature.enabled?(:ci_unlock_pipelines_medium, type: :ops)
MAX_RUNNING_MEDIUM
+ elsif ::Feature.enabled?(:ci_unlock_pipelines_extra_low, type: :ops)
+ MAX_RUNNING_EXTRA_LOW
elsif ::Feature.enabled?(:ci_unlock_pipelines, type: :ops)
# This is the default enabled flag
MAX_RUNNING_LOW
diff --git a/app/workers/click_house/event_authors_consistency_cron_worker.rb b/app/workers/click_house/event_authors_consistency_cron_worker.rb
new file mode 100644
index 00000000000..c35aadba593
--- /dev/null
+++ b/app/workers/click_house/event_authors_consistency_cron_worker.rb
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+
+module ClickHouse
+ # rubocop: disable CodeReuse/ActiveRecord -- Building worker-specific ActiveRecord and ClickHouse queries
+ class EventAuthorsConsistencyCronWorker
+ include ApplicationWorker
+ include ClickHouseWorker
+ include Gitlab::ExclusiveLeaseHelpers
+ include Gitlab::Utils::StrongMemoize
+
+ idempotent!
+ queue_namespace :cronjob
+ data_consistency :delayed
+ worker_has_external_dependencies! # the worker interacts with a ClickHouse database
+ feature_category :value_stream_management
+
+ MAX_TTL = 5.minutes.to_i
+ MAX_RUNTIME = 150.seconds
+ MAX_AUTHOR_DELETIONS = 2000
+ CLICK_HOUSE_BATCH_SIZE = 100_000
+ POSTGRESQL_BATCH_SIZE = 2500
+
+ def perform
+ return unless enabled?
+
+ runtime_limiter = Analytics::CycleAnalytics::RuntimeLimiter.new(MAX_RUNTIME)
+
+ in_lock(self.class.to_s, ttl: MAX_TTL, retries: 0) do
+ author_records_to_delete = []
+ last_processed_id = 0
+ iterator.each_batch(column: :author_id, of: CLICK_HOUSE_BATCH_SIZE) do |scope|
+ query = scope.select(Arel.sql('DISTINCT author_id')).to_sql
+ ids_from_click_house = connection.select(query).pluck('author_id').sort
+
+ ids_from_click_house.each_slice(POSTGRESQL_BATCH_SIZE) do |ids|
+ author_records_to_delete.concat(missing_user_ids(ids))
+ last_processed_id = ids.last
+
+ to_be_deleted_size = author_records_to_delete.size
+ if to_be_deleted_size >= MAX_AUTHOR_DELETIONS
+ metadata.merge!(status: :deletion_limit_reached, deletions: to_be_deleted_size)
+ break
+ end
+
+ if runtime_limiter.over_time?
+ metadata.merge!(status: :over_time, deletions: to_be_deleted_size)
+ break
+ end
+ end
+
+ break if limit_was_reached?
+ end
+
+ delete_records_from_click_house(author_records_to_delete)
+
+ last_processed_id = 0 if table_fully_processed?
+ ClickHouse::SyncCursor.update_cursor_for(:event_authors_consistency_check, last_processed_id)
+
+ log_extra_metadata_on_done(:result, metadata)
+ end
+ end
+
+ private
+
+ def metadata
+ @metadata ||= { status: :processed, deletions: 0 }
+ end
+
+ def limit_was_reached?
+ metadata[:status] == :deletion_limit_reached || metadata[:status] == :over_time
+ end
+
+ def table_fully_processed?
+ metadata[:status] == :processed
+ end
+
+ def enabled?
+ ClickHouse::Client.database_configured?(:main) && Feature.enabled?(:event_sync_worker_for_click_house)
+ end
+
+ def previous_author_id
+ value = ClickHouse::SyncCursor.cursor_for(:event_authors_consistency_check)
+ value == 0 ? nil : value
+ end
+ strong_memoize_attr :previous_author_id
+
+ def iterator
+ builder = ClickHouse::QueryBuilder.new('event_authors')
+ ClickHouse::Iterator.new(query_builder: builder, connection: connection, min_value: previous_author_id)
+ end
+
+ def connection
+ @connection ||= ClickHouse::Connection.new(:main)
+ end
+
+ def missing_user_ids(ids)
+ value_list = Arel::Nodes::ValuesList.new(ids.map { |id| [id] })
+ User
+ .from("(#{value_list.to_sql}) AS user_ids(id)")
+ .where('NOT EXISTS (SELECT 1 FROM users WHERE id = user_ids.id)')
+ .pluck(:id)
+ end
+
+ def delete_records_from_click_house(ids)
+ query = ClickHouse::Client::Query.new(
+ raw_query: "ALTER TABLE events DELETE WHERE author_id IN ({author_ids:Array(UInt64)})",
+ placeholders: { author_ids: ids.to_json }
+ )
+
+ connection.execute(query)
+
+ query = ClickHouse::Client::Query.new(
+ raw_query: "ALTER TABLE event_authors DELETE WHERE author_id IN ({author_ids:Array(UInt64)})",
+ placeholders: { author_ids: ids.to_json }
+ )
+
+ connection.execute(query)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end
diff --git a/app/workers/click_house/events_sync_worker.rb b/app/workers/click_house/events_sync_worker.rb
index 21c10566a67..3cfd3f91a29 100644
--- a/app/workers/click_house/events_sync_worker.rb
+++ b/app/workers/click_house/events_sync_worker.rb
@@ -4,8 +4,6 @@ module ClickHouse
class EventsSyncWorker
include ApplicationWorker
include ClickHouseWorker
- include Gitlab::ExclusiveLeaseHelpers
- include Gitlab::Utils::StrongMemoize
idempotent!
queue_namespace :cronjob
@@ -13,138 +11,9 @@ module ClickHouse
worker_has_external_dependencies! # the worker interacts with a ClickHouse database
feature_category :value_stream_management
- # the job is scheduled every 3 minutes and we will allow maximum 2.5 minutes runtime
- MAX_TTL = 2.5.minutes.to_i
- MAX_RUNTIME = 120.seconds
- BATCH_SIZE = 500
- INSERT_BATCH_SIZE = 5000
- CSV_MAPPING = {
- id: :id,
- path: :path,
- author_id: :author_id,
- target_id: :target_id,
- target_type: :target_type,
- action: :raw_action,
- created_at: :casted_created_at,
- updated_at: :casted_updated_at
- }.freeze
-
- # transforms the traversal_ids to a String:
- # Example: group_id/subgroup_id/group_or_projectnamespace_id/
- PATH_COLUMN = <<~SQL
- (
- CASE
- WHEN project_id IS NOT NULL THEN (SELECT array_to_string(traversal_ids, '/') || '/' FROM namespaces WHERE id = (SELECT project_namespace_id FROM projects WHERE id = events.project_id LIMIT 1) LIMIT 1)
- WHEN group_id IS NOT NULL THEN (SELECT array_to_string(traversal_ids, '/') || '/' FROM namespaces WHERE id = events.group_id LIMIT 1)
- ELSE ''
- END
- ) AS path
- SQL
-
- EVENT_PROJECTIONS = [
- :id,
- PATH_COLUMN,
- :author_id,
- :target_id,
- :target_type,
- 'action AS raw_action',
- 'EXTRACT(epoch FROM created_at) AS casted_created_at',
- 'EXTRACT(epoch FROM updated_at) AS casted_updated_at'
- ].freeze
-
- INSERT_EVENTS_QUERY = <<~SQL.squish
- INSERT INTO events (#{CSV_MAPPING.keys.join(', ')})
- SETTINGS async_insert=1, wait_for_async_insert=1 FORMAT CSV
- SQL
-
def perform
- unless enabled?
- log_extra_metadata_on_done(:result, { status: :disabled })
-
- return
- end
-
- metadata = { status: :processed }
-
- begin
- # Prevent parallel jobs
- in_lock(self.class.to_s, ttl: MAX_TTL, retries: 0) do
- loop { break unless next_batch }
-
- metadata.merge!(records_inserted: context.total_record_count, reached_end_of_table: context.no_more_records?)
-
- ClickHouse::SyncCursor.update_cursor_for(:events, context.last_processed_id) if context.last_processed_id
- end
- rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
- # Skip retrying, just let the next worker to start after a few minutes
- metadata = { status: :skipped }
- end
-
- log_extra_metadata_on_done(:result, metadata)
- end
-
- private
-
- def context
- @context ||= ClickHouse::RecordSyncContext.new(
- last_record_id: ClickHouse::SyncCursor.cursor_for(:events),
- max_records_per_batch: INSERT_BATCH_SIZE,
- runtime_limiter: Analytics::CycleAnalytics::RuntimeLimiter.new(MAX_RUNTIME)
- )
- end
-
- def last_event_id_in_postgresql
- Event.maximum(:id)
- end
- strong_memoize_attr :last_event_id_in_postgresql
-
- def enabled?
- ClickHouse::Client.database_configured?(:main) && Feature.enabled?(:event_sync_worker_for_click_house)
- end
-
- def next_batch
- context.new_batch!
-
- CsvBuilder::Gzip.new(process_batch(context), CSV_MAPPING).render do |tempfile, rows_written|
- unless rows_written == 0
- ClickHouse::Client.insert_csv(INSERT_EVENTS_QUERY, File.open(tempfile.path),
- :main)
- end
- end
-
- !(context.over_time? || context.no_more_records?)
- end
-
- def process_batch(context)
- Enumerator.new do |yielder|
- has_more_data = false
- batching_scope.each_batch(of: BATCH_SIZE) do |relation|
- records = relation.select(*EVENT_PROJECTIONS).to_a
- has_more_data = records.size == BATCH_SIZE
- records.each do |row|
- yielder << row
- context.last_processed_id = row.id
-
- break if context.record_limit_reached?
- end
-
- break if context.over_time? || context.record_limit_reached? || !has_more_data
- end
-
- context.no_more_records! unless has_more_data
- end
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def batching_scope
- return Event.none unless last_event_id_in_postgresql
-
- table = Event.arel_table
-
- Event
- .where(table[:id].gt(context.last_record_id))
- .where(table[:id].lteq(last_event_id_in_postgresql))
+ result = ::ClickHouse::SyncStrategies::EventSyncStrategy.new.execute
+ log_extra_metadata_on_done(:result, result)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb
index 15156e1deef..da0a54c79f8 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -53,14 +53,16 @@ module Gitlab
importer_class.new(object, project, client).execute
- if increment_object_counter?(object)
- Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported)
- end
+ increment_object_counter(object, project) if increment_object_counter?(object)
info(project.id, message: 'importer finished')
rescue ActiveRecord::RecordInvalid, NotRetriableError, NoMethodError => e
# We do not raise exception to prevent job retry
track_exception(project, e)
+ rescue UserFinder::FailedToObtainLockError
+ warn(project.id, message: 'Failed to obtaing lock for user finder. Retrying later.')
+
+ raise
rescue StandardError => e
track_and_raise_exception(project, e)
end
@@ -69,6 +71,10 @@ module Gitlab
true
end
+ def increment_object_counter(_object, project)
+ Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported)
+ end
+
def object_type
raise NotImplementedError
end
@@ -92,6 +98,10 @@ module Gitlab
Logger.info(log_attributes(project_id, extra))
end
+ def warn(project_id, extra = {})
+ Logger.warn(log_attributes(project_id, extra))
+ end
+
def log_attributes(project_id, extra = {})
extra.merge(
project_id: project_id,
diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb
index 5aabc74a3d5..98fea6c1ab7 100644
--- a/app/workers/concerns/gitlab/github_import/queue.rb
+++ b/app/workers/concerns/gitlab/github_import/queue.rb
@@ -8,12 +8,6 @@ module Gitlab
included do
queue_namespace :github_importer
feature_category :importers
-
- # If a job produces an error it may block a stage from advancing
- # forever. To prevent this from happening we prevent jobs from going to
- # the dead queue. This does mean some resources may not be imported, but
- # this is better than a project being stuck in the "import" state
- # forever.
sidekiq_options dead: false
end
end
diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
index e2808f45821..61c5aff6592 100644
--- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
+++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
@@ -38,7 +38,7 @@ module Gitlab
def try_import(...)
import(...)
true
- rescue RateLimitError
+ rescue RateLimitError, UserFinder::FailedToObtainLockError
false
end
diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb
index 5f6812ab84f..69cf6f424af 100644
--- a/app/workers/concerns/gitlab/github_import/stage_methods.rb
+++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb
@@ -50,7 +50,7 @@ module Gitlab
def perform(project_id)
info(project_id, message: 'starting stage')
- return unless (project = find_project(project_id))
+ return unless (project = Project.find_by_id(project_id))
if project.import_state&.completed?
info(
@@ -62,11 +62,11 @@ module Gitlab
return
end
+ RefreshImportJidWorker.perform_in_the_future(project.id, jid)
+
client = GithubImport.new_client_for(project)
try_import(client, project)
-
- info(project_id, message: 'stage finished')
rescue StandardError => e
Gitlab::Import::ImportFailureService.track(
project_id: project_id,
@@ -79,25 +79,19 @@ module Gitlab
raise(e)
end
+ private
+
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def try_import(client, project)
- RefreshImportJidWorker.perform_in_the_future(project.id, jid)
-
import(client, project)
- rescue RateLimitError
- self.class.perform_in(client.rate_limit_resets_in, project.id)
- end
- def find_project(id)
- # If the project has been marked as failed we want to bail out
- # automatically.
- # rubocop: disable CodeReuse/ActiveRecord
- Project.joins_import_state.where(import_state: { status: :started }).find_by_id(id)
- # rubocop: enable CodeReuse/ActiveRecord
- end
+ info(project.id, message: 'stage finished')
+ rescue RateLimitError, UserFinder::FailedToObtainLockError => e
+ info(project.id, message: "stage retrying", exception_class: e.class.name)
- private
+ self.class.perform_in(client.rate_limit_resets_in, project.id)
+ end
def info(project_id, extra = {})
Gitlab::GithubImport::Logger.info(log_attributes(project_id, extra))
diff --git a/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb b/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb
index dd18139fc9e..4c323a11755 100644
--- a/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb
+++ b/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb
@@ -3,9 +3,11 @@
module Gitlab
module BitbucketServerImport
module Stage
- class ImportUsersWorker # rubocop:disable Scalability/IdempotentWorker -- ImportPullRequestsWorker is not idempotent
+ class ImportUsersWorker
include StageMethods
+ idempotent!
+
private
def import(project)
diff --git a/app/workers/gitlab/github_gists_import/start_import_worker.rb b/app/workers/gitlab/github_gists_import/start_import_worker.rb
index f7d3eb1d759..d6c637f6d49 100644
--- a/app/workers/gitlab/github_gists_import/start_import_worker.rb
+++ b/app/workers/gitlab/github_gists_import/start_import_worker.rb
@@ -17,7 +17,7 @@ module Gitlab
Gitlab::GithubGistsImport::Status.new(msg['args'][0]).fail!
user = User.find(msg['args'][0])
- Gitlab::GithubImport::PageCounter.new(user, :gists, 'github-gists-importer').expire!
+ Gitlab::Import::PageCounter.new(user, :gists, 'github-gists-importer').expire!
end
def perform(user_id, encrypted_token)
diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
index 417b8598547..8de9850298b 100644
--- a/app/workers/gitlab/github_import/advance_stage_worker.rb
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -14,22 +14,24 @@ module Gitlab
include ::Gitlab::Import::AdvanceStage
loggable_arguments 1, 2
- sidekiq_options retry: 6
-
- # TODO: Allow this class to include GithubImport::Queue and remove
- # the following two lines https://gitlab.com/gitlab-org/gitlab/-/issues/435622
+ sidekiq_options retry: 6, dead: false
feature_category :importers
- sidekiq_options dead: false
# The known importer stages and their corresponding Sidekiq workers.
+ #
+ # Note: AdvanceStageWorker is not used for the repository, base_data, and pull_requests stages.
+ # They are included in the list for us to easily see all stage workers and the order in which they are executed.
STAGES = {
+ repository: Stage::ImportRepositoryWorker,
+ base_data: Stage::ImportBaseDataWorker,
+ pull_requests: Stage::ImportPullRequestsWorker,
collaborators: Stage::ImportCollaboratorsWorker,
- pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker,
- pull_request_review_requests: Stage::ImportPullRequestsReviewRequestsWorker,
- pull_request_reviews: Stage::ImportPullRequestsReviewsWorker,
+ pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker, # Skipped on extended_events
+ pull_request_review_requests: Stage::ImportPullRequestsReviewRequestsWorker, # Skipped on extended_events
+ pull_request_reviews: Stage::ImportPullRequestsReviewsWorker, # Skipped on extended_events
issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker,
issue_events: Stage::ImportIssueEventsWorker,
- notes: Stage::ImportNotesWorker,
+ notes: Stage::ImportNotesWorker, # Skipped on extended_events
attachments: Stage::ImportAttachmentsWorker,
protected_branches: Stage::ImportProtectedBranchesWorker,
lfs_objects: Stage::ImportLfsObjectsWorker,
diff --git a/app/workers/gitlab/github_import/import_issue_event_worker.rb b/app/workers/gitlab/github_import/import_issue_event_worker.rb
index d7071d3ee09..f5e88787a77 100644
--- a/app/workers/gitlab/github_import/import_issue_event_worker.rb
+++ b/app/workers/gitlab/github_import/import_issue_event_worker.rb
@@ -16,6 +16,16 @@ module Gitlab
def object_type
:issue_event
end
+
+ def increment_object_counter(object, project)
+ counter_type = importer_class::EVENT_COUNTER_MAP[object[:event]] if import_settings.extended_events?
+ counter_type ||= object_type
+ Gitlab::GithubImport::ObjectCounter.increment(project, counter_type, :imported)
+ end
+
+ def import_settings
+ @import_settings ||= Gitlab::GithubImport::Settings.new(project)
+ end
end
end
end
diff --git a/app/workers/gitlab/github_import/replay_events_worker.rb b/app/workers/gitlab/github_import/replay_events_worker.rb
new file mode 100644
index 00000000000..680d5ec2d7d
--- /dev/null
+++ b/app/workers/gitlab/github_import/replay_events_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ReplayEventsWorker
+ include ObjectImporter
+
+ idempotent!
+
+ def representation_class
+ Representation::ReplayEvent
+ end
+
+ def importer_class
+ Importer::ReplayEventsImporter
+ end
+
+ def object_type
+ :replay_event
+ end
+
+ def increment_object_counter?(_object)
+ false
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb
index b5b1601e3ed..38e1fd52889 100644
--- a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb
@@ -42,9 +42,15 @@ module Gitlab
def move_to_next_stage(project, waiters = {})
AdvanceStageWorker.perform_async(
- project.id, waiters.deep_stringify_keys, 'pull_requests_merged_by'
+ project.id, waiters.deep_stringify_keys, next_stage(project)
)
end
+
+ def next_stage(project)
+ return 'issues_and_diff_notes' if import_settings(project).extended_events?
+
+ 'pull_requests_merged_by'
+ end
end
end
end
diff --git a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb
index 27d14a1a108..9618500604a 100644
--- a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb
@@ -15,7 +15,7 @@ module Gitlab
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
- return skip_to_next_stage(project) if import_settings(project).disabled?(:single_endpoint_issue_events_import)
+ return skip_to_next_stage(project) if skip_to_next_stage?(project)
importer = ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter
info(project.id, message: "starting importer", importer: importer.name)
@@ -25,13 +25,26 @@ module Gitlab
private
+ def skip_to_next_stage?(project)
+ # This stage is mandatory when using extended_events
+ return false if import_settings(project).extended_events?
+
+ import_settings(project).disabled?(:single_endpoint_issue_events_import)
+ end
+
def skip_to_next_stage(project)
info(project.id, message: "skipping importer", importer: "IssueEventsImporter")
move_to_next_stage(project)
end
def move_to_next_stage(project, waiters = {})
- AdvanceStageWorker.perform_async(project.id, waiters.deep_stringify_keys, 'notes')
+ AdvanceStageWorker.perform_async(project.id, waiters.deep_stringify_keys, next_stage(project))
+ end
+
+ def next_stage(project)
+ return "attachments" if import_settings(project).extended_events?
+
+ "notes"
end
end
end
diff --git a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb
index 34c31fea726..3f57b958418 100644
--- a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb
@@ -15,16 +15,8 @@ module Gitlab
# https://gitlab.com/gitlab-org/gitlab/-/blob/eabf0800/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb#L69-71
resumes_work_when_interrupted!
- def perform(project_id)
- return unless (project = find_project(project_id))
-
- import(project)
- end
-
# project - An instance of Project.
- def import(project)
- info(project.id, message: "starting importer", importer: 'Importer::LfsObjectsImporter')
-
+ def import(_client, project)
waiter = Importer::LfsObjectsImporter
.new(project, nil)
.execute
diff --git a/app/workers/google_cloud/create_cloudsql_instance_worker.rb b/app/workers/google_cloud/create_cloudsql_instance_worker.rb
index 8c4f4c83339..e0d0747e227 100644
--- a/app/workers/google_cloud/create_cloudsql_instance_worker.rb
+++ b/app/workers/google_cloud/create_cloudsql_instance_worker.rb
@@ -13,7 +13,7 @@ module GoogleCloud
project = Project.find(project_id)
params = params.with_indifferent_access
- response = ::GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute
+ response = ::CloudSeed::GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute
if response[:status] == :error
raise "Error SetupCloudsqlInstanceService: #{response.to_json}"
diff --git a/app/workers/google_cloud/fetch_google_ip_list_worker.rb b/app/workers/google_cloud/fetch_google_ip_list_worker.rb
index b14b4e735dc..de725709bea 100644
--- a/app/workers/google_cloud/fetch_google_ip_list_worker.rb
+++ b/app/workers/google_cloud/fetch_google_ip_list_worker.rb
@@ -11,7 +11,7 @@ module GoogleCloud
idempotent!
def perform
- GoogleCloud::FetchGoogleIpListService.new.execute
+ CloudSeed::GoogleCloud::FetchGoogleIpListService.new.execute
end
end
end
diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb
index 0e7f11debd2..80f9e922456 100644
--- a/app/workers/new_issue_worker.rb
+++ b/app/workers/new_issue_worker.rb
@@ -15,13 +15,14 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker
attr_reader :issuable_class
- def perform(issue_id, user_id, issuable_class = 'Issue')
+ # TODO: Add skip_notifications argument to the invocations of the worker in the next release (16.9)
+ def perform(issue_id, user_id, issuable_class = 'Issue', skip_notifications = false)
@issuable_class = issuable_class.constantize
return unless objects_found?(issue_id, user_id)
::EventCreateService.new.open_issue(issuable, user)
- ::NotificationService.new.new_issue(issuable, user)
+ ::NotificationService.new.new_issue(issuable, user) unless skip_notifications
issuable.create_cross_references!(user)
diff --git a/app/workers/releases/publish_event_worker.rb b/app/workers/releases/publish_event_worker.rb
new file mode 100644
index 00000000000..8bcc580dceb
--- /dev/null
+++ b/app/workers/releases/publish_event_worker.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Releases
+ class PublishEventWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ idempotent!
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency -- usual for EventStore jobs.
+ feature_category :release_orchestration
+
+ def perform
+ releases_published = 0
+
+ Release.waiting_for_publish_event.each_batch(of: 100) do |releases|
+ releases.each do |release|
+ with_context(project: release.project) do
+ ::Gitlab::EventStore.publish(
+ ::Projects::ReleasePublishedEvent.new(data: { release_id: release.id })
+ )
+
+ releases_published += 1
+ end
+ end
+
+ releases.touch_all(:release_published_at)
+ end
+
+ log_extra_metadata_on_done(:releases_published, releases_published) if releases_published > 0
+ end
+ end
+end