summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-08-18 08:17:02 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-08-18 08:17:02 +0000
commitb39512ed755239198a9c294b6a45e65c05900235 (patch)
treed234a3efade1de67c46b9e5a38ce813627726aa7 /app
parentd31474cf3b17ece37939d20082b07f6657cc79a9 (diff)
downloadgitlab-ce-b39512ed755239198a9c294b6a45e65c05900235.tar.gz
Add latest changes from gitlab-org/gitlab@15-3-stable-eev15.3.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/auth_buttons/jwt_64.pngbin2457 -> 824 bytes
-rw-r--r--app/assets/images/auth_buttons/salesforce_64.pngbin8774 -> 2012 bytes
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue30
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_field.vue69
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_token_selector.vue156
-rw-r--r--app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql1
-rw-r--r--app/assets/javascripts/access_tokens/index.js57
-rw-r--r--app/assets/javascripts/admin/deploy_keys/components/table.vue8
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql1
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js26
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql1
-rw-r--r--app/assets/javascripts/api/analytics_api.js3
-rw-r--r--app/assets/javascripts/api/groups_api.js7
-rw-r--r--app/assets/javascripts/attention_requests/components/navigation_popover.vue122
-rw-r--r--app/assets/javascripts/attention_requests/index.js73
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue6
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue28
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue39
-rw-r--r--app/assets/javascripts/batch_comments/i18n.js3
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js5
-rw-r--r--app/assets/javascripts/behaviors/components/json_table.vue71
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/strike.js29
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/code_block.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js44
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_json_table.js70
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js231
-rw-r--r--app/assets/javascripts/blob/blob_links_tracking.js25
-rw-r--r--app/assets/javascripts/boards/boards_util.js4
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue29
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue5
-rw-r--r--app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_members.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/lists_issues.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_members.query.graphql2
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js2
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue4
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue101
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue49
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue104
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue212
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue86
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue75
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue6
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js59
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql7
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql16
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql16
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql16
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql17
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql13
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/resolvers.js113
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js29
-rw-r--r--app/assets/javascripts/ci_variable_list/store/utils.js6
-rw-r--r--app/assets/javascripts/ci_variable_list/utils.js50
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue2
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue60
-rw-r--r--app/assets/javascripts/commons/nav/user_merge_requests.js21
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue24
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue9
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue11
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue55
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue25
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/diagram.js7
-rw-r--r--app/assets/javascripts/content_editor/extensions/frontmatter.js7
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference_definition.js29
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_of_contents.js15
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js6
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js9
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js82
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js36
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js67
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js56
-rw-r--r--app/assets/javascripts/content_editor/services/table_of_contents_utils.js67
-rw-r--r--app/assets/javascripts/contributors/stores/getters.js6
-rw-r--r--app/assets/javascripts/crm/components/form.vue20
-rw-r--r--app/assets/javascripts/crm/constants.js4
-rw-r--r--app/assets/javascripts/crm/contacts/bundle.js18
-rw-r--r--app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue9
-rw-r--r--app/assets/javascripts/crm/contacts/components/contacts_root.vue220
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql3
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql30
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql11
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql2
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql1
-rw-r--r--app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue27
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue12
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js25
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js6
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js23
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js5
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue1
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue1
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql2
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql1
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql2
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue2
-rw-r--r--app/assets/javascripts/diff.js3
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue9
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue85
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue22
-rw-r--r--app/assets/javascripts/diffs/constants.js3
-rw-r--r--app/assets/javascripts/diffs/store/actions.js12
-rw-r--r--app/assets/javascripts/dropzone_input.js17
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar_button.vue1
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js5
-rw-r--r--app/assets/javascripts/editor/graphql/typedefs.graphql14
-rw-r--r--app/assets/javascripts/editor/schema/ci.json44
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_detail_header.vue1
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql1
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js19
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js4
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue1
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js74
-rw-r--r--app/assets/javascripts/gitlab_pages/new.js39
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql7
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql4
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json9
-rw-r--r--app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql3
-rw-r--r--app/assets/javascripts/group_settings/components/shared_runners_form.vue7
-rw-r--r--app/assets/javascripts/group_settings/mount_shared_runners.js4
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue16
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue215
-rw-r--r--app/assets/javascripts/groups/components/groups.vue2
-rw-r--r--app/assets/javascripts/groups/components/transfer_group_form.vue1
-rw-r--r--app/assets/javascripts/groups/constants.js29
-rw-r--r--app/assets/javascripts/groups/create_edit_form.js10
-rw-r--r--app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql11
-rw-r--r--app/assets/javascripts/header_search/components/app.vue51
-rw-r--r--app/assets/javascripts/header_search/constants.js4
-rw-r--r--app/assets/javascripts/header_search/index.js11
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue4
-rw-r--r--app/assets/javascripts/ide/components/file_templates/bar.vue7
-rw-r--r--app/assets/javascripts/ide/components/ide_project_header.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue2
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js2
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue7
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue6
-rw-r--r--app/assets/javascripts/integrations/overrides/components/integration_overrides.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue70
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue43
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue8
-rw-r--r--app/assets/javascripts/invite_members/constants.js13
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js9
-rw-r--r--app/assets/javascripts/issuable/components/issue_milestone.vue4
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue51
-rw-r--r--app/assets/javascripts/issuable/issuable_template_selector.js16
-rw-r--r--app/assets/javascripts/issuable/popover/components/issue_popover.vue54
-rw-r--r--app/assets/javascripts/issuable/popover/queries/issue.query.graphql9
-rw-r--r--app/assets/javascripts/issues/index.js2
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue42
-rw-r--r--app/assets/javascripts/issues/list/constants.js19
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql4
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue9
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/constants.js5
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue117
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue157
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue (renamed from app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue)12
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue27
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue15
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js1
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue16
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue1
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue94
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue9
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql4
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue11
-rw-r--r--app/assets/javascripts/labels/labels_select.js2
-rw-r--r--app/assets/javascripts/lib/dompurify.js37
-rw-r--r--app/assets/javascripts/lib/gfm/index.js50
-rw-r--r--app/assets/javascripts/lib/graphql.js1
-rw-r--r--app/assets/javascripts/lib/markdown_it.js11
-rw-r--r--app/assets/javascripts/lib/prosemirror_markdown_serializer.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js242
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js25
-rw-r--r--app/assets/javascripts/lib/utils/yaml.js21
-rw-r--r--app/assets/javascripts/linked_resources/index.js11
-rw-r--r--app/assets/javascripts/main.js6
-rw-r--r--app/assets/javascripts/members/components/table/member_action_buttons.vue5
-rw-r--r--app/assets/javascripts/members/constants.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/utils.js7
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue4
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js64
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js8
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue2
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue28
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue106
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue6
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue3
-rw-r--r--app/assets/javascripts/notes/constants.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js6
-rw-r--r--app/assets/javascripts/notes/utils.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/index.js8
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue109
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue35
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue52
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue47
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js15
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue107
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue30
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue25
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js4
-rw-r--r--app/assets/javascripts/pages/groups/new/components/app.vue73
-rw-r--r--app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue56
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js11
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue2
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/ci/secure_files/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js23
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue42
-rw-r--r--app/assets/javascripts/pages/projects/init_blob.js9
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pages/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js41
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue42
-rw-r--r--app/assets/javascripts/pages/projects/tags/releases/index.js6
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js5
-rw-r--r--app/assets/javascripts/pages/sessions/new/email_format_validator.js46
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue55
-rw-r--r--app/assets/javascripts/persistent_user_callout.js4
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue4
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue4
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue53
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue20
-rw-r--r--app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue20
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js8
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql1
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql2
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js2
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue34
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue52
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue9
-rw-r--r--app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/performance_insights_modal.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_tabs.vue18
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue40
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue26
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue16
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue55
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue54
-rw-r--r--app/assets/javascripts/pipelines/constants.js2
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js13
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js33
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/actions.js1
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue20
-rw-r--r--app/assets/javascripts/projects/compare/index.js12
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js2
-rw-r--r--app/assets/javascripts/projects/project_new.js24
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue87
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue59
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue46
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue52
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue44
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue4
-rw-r--r--app/assets/javascripts/projects/settings/components/transfer_project_form.vue94
-rw-r--r--app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql24
-rw-r--r--app/assets/javascripts/projects/settings/init_transfer_project_form.js22
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue1
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue7
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue76
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue9
-rw-r--r--app/assets/javascripts/related_issues/constants.js10
-rw-r--r--app/assets/javascripts/related_issues/index.js51
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue9
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue12
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql15
-rw-r--r--app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql88
-rw-r--r--app/assets/javascripts/releases/util.js1
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue1
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue74
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue9
-rw-r--r--app/assets/javascripts/repository/constants.js1
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql1
-rw-r--r--app/assets/javascripts/right_sidebar.js32
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue34
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_cell.vue6
-rw-r--r--app/assets/javascripts/runner/components/runner_assigned_item.vue22
-rw-r--r--app/assets/javascripts/runner/components/runner_bulk_delete.vue149
-rw-r--r--app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue59
-rw-r--r--app/assets/javascripts/runner/components/runner_detail.vue9
-rw-r--r--app/assets/javascripts/runner/components/runner_details.vue25
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue8
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs.vue11
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue15
-rw-r--r--app/assets/javascripts/runner/components/runner_pagination.vue67
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue22
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_count.vue7
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_single_stat.vue41
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_stats.vue74
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_status_stat.vue65
-rw-r--r--app/assets/javascripts/runner/constants.js3
-rw-r--r--app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/list/all_runners.query.graphql13
-rw-r--r--app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql13
-rw-r--r--app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql6
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql16
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runners.query.graphql16
-rw-r--r--app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/list/local_state.js18
-rw-r--r--app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql4
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue32
-rw-r--r--app/assets/javascripts/runner/runner_search_utils.js71
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js1
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue39
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue22
-rw-r--r--app/assets/javascripts/sidebar/components/attention_requested_toggle.vue105
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue64
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue22
-rw-r--r--app/assets/javascripts/sidebar/components/severity/severity.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue8
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js17
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_participants.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_reference.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_todo.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/group_milestones.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_reference.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_todo.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/project_milestones.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql7
-rw-r--r--app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql5
-rw-r--r--app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js23
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js12
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js87
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js34
-rw-r--r--app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql35
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.js10
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.vue9
-rw-r--r--app/assets/javascripts/task_list.js8
-rw-r--r--app/assets/javascripts/test_utils/index.js2
-rw-r--r--app/assets/javascripts/user_popovers.js4
-rw-r--r--app/assets/javascripts/visibility_level/constants.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue (renamed from app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue)12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue85
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue55
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue94
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue44
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue65
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue168
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue141
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue345
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue33
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue53
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue29
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue158
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue50
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js14
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/gitlab_version_check.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue95
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue61
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql26
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js39
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue30
-rw-r--r--app/assets/javascripts/vue_shared/constants.js27
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue12
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue6
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue24
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue7
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue2
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue2
-rw-r--r--app/assets/javascripts/whats_new/components/feature.vue29
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue10
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue5
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue55
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue95
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue10
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue150
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue41
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue3
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js16
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue269
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue133
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue79
-rw-r--r--app/assets/javascripts/work_items/components/work_item_state.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_title.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_type_icon.vue44
-rw-r--r--app/assets/javascripts/work_items/components/work_item_weight.vue40
-rw-r--r--app/assets/javascripts/work_items/constants.js44
-rw-r--r--app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js28
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql26
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql6
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss1
-rw-r--r--app/assets/stylesheets/components/avatar.scss12
-rw-r--r--app/assets/stylesheets/components/batch_comments/review_bar.scss2
-rw-r--r--app/assets/stylesheets/components/rich_content_editor.scss54
-rw-r--r--app/assets/stylesheets/framework/blocks.scss83
-rw-r--r--app/assets/stylesheets/framework/calendar.scss39
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss14
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar_header.scss4
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss28
-rw-r--r--app/assets/stylesheets/framework/files.scss17
-rw-r--r--app/assets/stylesheets/framework/highlight.scss58
-rw-r--r--app/assets/stylesheets/framework/mixins.scss2
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss19
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss4
-rw-r--r--app/assets/stylesheets/framework/sortable.scss2
-rw-r--r--app/assets/stylesheets/framework/timeline.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss29
-rw-r--r--app/assets/stylesheets/framework/variables.scss4
-rw-r--r--app/assets/stylesheets/framework/vue_transitions.scss2
-rw-r--r--app/assets/stylesheets/highlight/common.scss31
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss10
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss10
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/escalation_policies.scss16
-rw-r--r--app/assets/stylesheets/page_bundles/group.scss59
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss32
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss18
-rw-r--r--app/assets/stylesheets/page_bundles/profile.scss212
-rw-r--r--app/assets/stylesheets/page_bundles/runner_details.scss3
-rw-r--r--app/assets/stylesheets/pages/issuable.scss2
-rw-r--r--app/assets/stylesheets/pages/issues.scss2
-rw-r--r--app/assets/stylesheets/pages/login.scss14
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss4
-rw-r--r--app/assets/stylesheets/pages/note_form.scss9
-rw-r--r--app/assets/stylesheets/pages/notes.scss10
-rw-r--r--app/assets/stylesheets/pages/profile.scss94
-rw-r--r--app/assets/stylesheets/pages/search.scss37
-rw-r--r--app/assets/stylesheets/pages/settings_ci_cd.scss18
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss43
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss35
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss177
-rw-r--r--app/assets/stylesheets/themes/_dark.scss2
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss16
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss8
-rw-r--r--app/channels/awareness_channel.rb1
-rw-r--r--app/components/diffs/overflow_warning_component.html.haml2
-rw-r--r--app/components/pajamas/avatar_component.html.haml12
-rw-r--r--app/components/pajamas/avatar_component.rb69
-rw-r--r--app/components/pajamas/button_component.html.haml9
-rw-r--r--app/components/pajamas/button_component.rb8
-rw-r--r--app/components/pajamas/checkbox_component.rb9
-rw-r--r--app/components/pajamas/checkbox_tag_component.html.haml6
-rw-r--r--app/components/pajamas/checkbox_tag_component.rb44
-rw-r--r--app/components/pajamas/concerns/checkbox_radio_label_with_help_text.rb4
-rw-r--r--app/components/pajamas/radio_component.rb4
-rw-r--r--app/controllers/admin/application_settings_controller.rb4
-rw-r--r--app/controllers/admin/applications_controller.rb1
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb1
-rw-r--r--app/controllers/admin/ci/variables_controller.rb4
-rw-r--r--app/controllers/admin/dev_ops_report_controller.rb17
-rw-r--r--app/controllers/admin/projects_controller.rb4
-rw-r--r--app/controllers/admin/runner_projects_controller.rb2
-rw-r--r--app/controllers/admin/system_info_controller.rb18
-rw-r--r--app/controllers/admin/topics_controller.rb20
-rw-r--r--app/controllers/admin/usage_trends_controller.rb16
-rw-r--r--app/controllers/admin/users_controller.rb2
-rw-r--r--app/controllers/autocomplete_controller.rb9
-rw-r--r--app/controllers/concerns/accepts_pending_invitations.rb14
-rw-r--r--app/controllers/concerns/creates_commit.rb3
-rw-r--r--app/controllers/concerns/issuable_actions.rb16
-rw-r--r--app/controllers/concerns/notes_actions.rb3
-rw-r--r--app/controllers/concerns/product_analytics_tracking.rb37
-rw-r--r--app/controllers/concerns/redis_tracking.rb2
-rw-r--r--app/controllers/concerns/uploads_actions.rb6
-rw-r--r--app/controllers/groups/boards_controller.rb4
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb3
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb4
-rw-r--r--app/controllers/import/bulk_imports_controller.rb10
-rw-r--r--app/controllers/oauth/token_info_controller.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb5
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb6
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb2
-rw-r--r--app/controllers/projects/alerting/notifications_controller.rb2
-rw-r--r--app/controllers/projects/analytics/cycle_analytics/stages_controller.rb21
-rw-r--r--app/controllers/projects/blob_controller.rb1
-rw-r--r--app/controllers/projects/boards_controller.rb4
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb1
-rw-r--r--app/controllers/projects/ci/secure_files_controller.rb11
-rw-r--r--app/controllers/projects/compare_controller.rb2
-rw-r--r--app/controllers/projects/feature_flags_controller.rb6
-rw-r--r--app/controllers/projects/google_cloud/base_controller.rb12
-rw-r--r--app/controllers/projects/google_cloud/configuration_controller.rb1
-rw-r--r--app/controllers/projects/google_cloud/databases_controller.rb1
-rw-r--r--app/controllers/projects/google_cloud/deployments_controller.rb7
-rw-r--r--app/controllers/projects/google_cloud/gcp_regions_controller.rb6
-rw-r--r--app/controllers/projects/google_cloud/service_accounts_controller.rb22
-rw-r--r--app/controllers/projects/incidents_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb16
-rw-r--r--app/controllers/projects/jobs_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb30
-rw-r--r--app/controllers/projects/merge_requests/drafts_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests_controller.rb5
-rw-r--r--app/controllers/projects/milestones_controller.rb4
-rw-r--r--app/controllers/projects/mirrors_controller.rb4
-rw-r--r--app/controllers/projects/pages_controller.rb22
-rw-r--r--app/controllers/projects/pipelines/stages_controller.rb3
-rw-r--r--app/controllers/projects/repositories_controller.rb4
-rw-r--r--app/controllers/projects/runner_projects_controller.rb2
-rw-r--r--app/controllers/projects/settings/integration_hook_logs_controller.rb2
-rw-r--r--app/controllers/projects/settings/integrations_controller.rb2
-rw-r--r--app/controllers/projects/settings/packages_and_registries_controller.rb11
-rw-r--r--app/controllers/projects/tags/releases_controller.rb39
-rw-r--r--app/controllers/projects/tree_controller.rb1
-rw-r--r--app/controllers/projects/variables_controller.rb2
-rw-r--r--app/controllers/projects/work_items_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb30
-rw-r--r--app/controllers/registrations_controller.rb1
-rw-r--r--app/controllers/repositories/git_http_client_controller.rb2
-rw-r--r--app/controllers/repositories/lfs_api_controller.rb12
-rw-r--r--app/controllers/repositories/lfs_locks_api_controller.rb4
-rw-r--r--app/controllers/search_controller.rb19
-rw-r--r--app/controllers/sessions_controller.rb4
-rw-r--r--app/controllers/uploads_controller.rb12
-rw-r--r--app/controllers/users/namespace_callouts_controller.rb17
-rw-r--r--app/controllers/users/project_callouts_controller.rb17
-rw-r--r--app/controllers/users_controller.rb6
-rw-r--r--app/events/groups/group_deleted_event.rb16
-rw-r--r--app/events/groups/group_path_changed_event.rb18
-rw-r--r--app/events/groups/group_transfered_event.rb17
-rw-r--r--app/events/merge_requests/approved_event.rb19
-rw-r--r--app/events/projects/project_archived_event.rb17
-rw-r--r--app/events/projects/project_transfered_event.rb25
-rw-r--r--app/experiments/security_reports_mr_widget_prompt_experiment.rb4
-rw-r--r--app/experiments/video_tutorials_continuous_onboarding_experiment.rb4
-rw-r--r--app/finders/autocomplete/deploy_keys_with_write_access_finder.rb21
-rw-r--r--app/finders/ci/daily_build_group_report_results_finder.rb10
-rw-r--r--app/finders/ci/runners_finder.rb11
-rw-r--r--app/finders/crm/contacts_finder.rb21
-rw-r--r--app/finders/fork_targets_finder.rb30
-rw-r--r--app/finders/groups/accepting_project_transfers_finder.rb43
-rw-r--r--app/finders/groups/user_groups_finder.rb4
-rw-r--r--app/finders/issuable_finder.rb3
-rw-r--r--app/finders/projects/topics_finder.rb7
-rw-r--r--app/finders/releases/group_releases_finder.rb2
-rw-r--r--app/finders/repositories/changelog_tag_finder.rb4
-rw-r--r--app/finders/tags_finder.rb2
-rw-r--r--app/graphql/gitlab_schema.rb2
-rw-r--r--app/graphql/graphql_triggers.rb4
-rw-r--r--app/graphql/mutations/award_emojis/toggle.rb7
-rw-r--r--app/graphql/mutations/ci/job/retry.rb11
-rw-r--r--app/graphql/mutations/ci/pipeline/cancel.rb1
-rw-r--r--app/graphql/mutations/ci/runner/bulk_delete.rb56
-rw-r--r--app/graphql/mutations/ci/runner/update.rb10
-rw-r--r--app/graphql/mutations/ci/runners_registration_token/reset.rb5
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb10
-rw-r--r--app/graphql/mutations/container_repositories/destroy.rb2
-rw-r--r--app/graphql/mutations/design_management/move.rb19
-rw-r--r--app/graphql/mutations/issues/move.rb4
-rw-r--r--app/graphql/mutations/issues/set_confidential.rb2
-rw-r--r--app/graphql/mutations/issues/set_severity.rb5
-rw-r--r--app/graphql/mutations/merge_requests/remove_attention_request.rb40
-rw-r--r--app/graphql/mutations/merge_requests/request_attention.rb40
-rw-r--r--app/graphql/mutations/merge_requests/set_reviewers.rb52
-rw-r--r--app/graphql/mutations/merge_requests/toggle_attention_requested.rb29
-rw-r--r--app/graphql/mutations/notes/create/base.rb10
-rw-r--r--app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb14
-rw-r--r--app/graphql/mutations/timelogs/base.rb18
-rw-r--r--app/graphql/mutations/timelogs/create.rb48
-rw-r--r--app/graphql/mutations/timelogs/delete.rb13
-rw-r--r--app/graphql/mutations/uploads/delete.rb37
-rw-r--r--app/graphql/mutations/work_items/create.rb3
-rw-r--r--app/graphql/mutations/work_items/update.rb2
-rw-r--r--app/graphql/resolvers/ci/config_resolver.rb4
-rw-r--r--app/graphql/resolvers/ci/runners_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/template_resolver.rb7
-rw-r--r--app/graphql/resolvers/crm/contact_state_counts_resolver.rb25
-rw-r--r--app/graphql/resolvers/crm/contacts_resolver.rb21
-rw-r--r--app/graphql/resolvers/environments_resolver.rb4
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb2
-rw-r--r--app/graphql/resolvers/group_milestones_resolver.rb4
-rw-r--r--app/graphql/resolvers/projects/fork_targets_resolver.rb27
-rw-r--r--app/graphql/resolvers/projects_resolver.rb4
-rw-r--r--app/graphql/resolvers/users_resolver.rb2
-rw-r--r--app/graphql/resolvers/work_items_resolver.rb2
-rw-r--r--app/graphql/types/access_level_type.rb8
-rw-r--r--app/graphql/types/admin/analytics/usage_trends/measurement_type.rb8
-rw-r--r--app/graphql/types/alert_management/domain_filter_enum.rb11
-rw-r--r--app/graphql/types/base_field.rb4
-rw-r--r--app/graphql/types/board_list_type.rb16
-rw-r--r--app/graphql/types/board_type.rb16
-rw-r--r--app/graphql/types/ci/analytics_type.rb22
-rw-r--r--app/graphql/types/ci/application_setting_type.rb2
-rw-r--r--app/graphql/types/ci/build_need_type.rb4
-rw-r--r--app/graphql/types/ci/ci_cd_setting_type.rb22
-rw-r--r--app/graphql/types/ci/config/config_type.rb12
-rw-r--r--app/graphql/types/ci/config/group_type.rb6
-rw-r--r--app/graphql/types/ci/config/job_restriction_type.rb2
-rw-r--r--app/graphql/types/ci/config/job_type.rb38
-rw-r--r--app/graphql/types/ci/config/need_type.rb2
-rw-r--r--app/graphql/types/ci/config/stage_type.rb4
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb30
-rw-r--r--app/graphql/types/ci/group_type.rb10
-rw-r--r--app/graphql/types/ci/group_variable_type.rb25
-rw-r--r--app/graphql/types/ci/instance_variable_type.rb33
-rw-r--r--app/graphql/types/ci/job_artifact_type.rb8
-rw-r--r--app/graphql/types/ci/job_token_scope_type.rb8
-rw-r--r--app/graphql/types/ci/job_type.rb72
-rw-r--r--app/graphql/types/ci/manual_variable_type.rb25
-rw-r--r--app/graphql/types/ci/pipeline_message_type.rb4
-rw-r--r--app/graphql/types/ci/pipeline_type.rb72
-rw-r--r--app/graphql/types/ci/project_variable_type.rb25
-rw-r--r--app/graphql/types/ci/recent_failures_type.rb4
-rw-r--r--app/graphql/types/ci/runner_architecture_type.rb8
-rw-r--r--app/graphql/types/ci/runner_platform_type.rb10
-rw-r--r--app/graphql/types/ci/runner_setup_type.rb4
-rw-r--r--app/graphql/types/ci/runner_type.rb76
-rw-r--r--app/graphql/types/ci/runner_upgrade_status_enum.rb (renamed from app/graphql/types/ci/runner_upgrade_status_type_enum.rb)4
-rw-r--r--app/graphql/types/ci/runner_web_url_edge.rb8
-rw-r--r--app/graphql/types/ci/stage_type.rb12
-rw-r--r--app/graphql/types/ci/status_action_type.rb16
-rw-r--r--app/graphql/types/ci/template_type.rb4
-rw-r--r--app/graphql/types/ci/test_case_type.rb26
-rw-r--r--app/graphql/types/ci/test_report_summary_type.rb8
-rw-r--r--app/graphql/types/ci/test_report_total_type.rb14
-rw-r--r--app/graphql/types/ci/test_suite_summary_type.rb30
-rw-r--r--app/graphql/types/ci/test_suite_type.rb29
-rw-r--r--app/graphql/types/ci/variable_input_type.rb13
-rw-r--r--app/graphql/types/ci/variable_interface.rb31
-rw-r--r--app/graphql/types/ci/variable_type.rb40
-rw-r--r--app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb4
-rw-r--r--app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb14
-rw-r--r--app/graphql/types/ci_configuration/sast/entity_input_type.rb6
-rw-r--r--app/graphql/types/ci_configuration/sast/entity_type.rb20
-rw-r--r--app/graphql/types/ci_configuration/sast/options_entity_type.rb4
-rw-r--r--app/graphql/types/ci_configuration/sast/type.rb18
-rw-r--r--app/graphql/types/commit_action_type.rb14
-rw-r--r--app/graphql/types/commit_type.rb30
-rw-r--r--app/graphql/types/concerns/gitlab_style_deprecations.rb5
-rw-r--r--app/graphql/types/countable_connection_type.rb2
-rw-r--r--app/graphql/types/customer_relations/contact_sort_enum.rb21
-rw-r--r--app/graphql/types/customer_relations/contact_state_counts_type.rb23
-rw-r--r--app/graphql/types/customer_relations/contact_state_enum.rb8
-rw-r--r--app/graphql/types/design_management/design_collection_type.rb4
-rw-r--r--app/graphql/types/design_management/design_fields.rb5
-rw-r--r--app/graphql/types/design_management/version_type.rb6
-rw-r--r--app/graphql/types/diff_paths_input_type.rb4
-rw-r--r--app/graphql/types/diff_refs_type.rb6
-rw-r--r--app/graphql/types/diff_stats_summary_type.rb8
-rw-r--r--app/graphql/types/diff_stats_type.rb6
-rw-r--r--app/graphql/types/environment_type.rb12
-rw-r--r--app/graphql/types/evidence_type.rb8
-rw-r--r--app/graphql/types/global_id_type.rb2
-rw-r--r--app/graphql/types/grafana_integration_type.rb10
-rw-r--r--app/graphql/types/group_invitation_type.rb2
-rw-r--r--app/graphql/types/group_member_type.rb2
-rw-r--r--app/graphql/types/group_type.rb19
-rw-r--r--app/graphql/types/invitation_interface.rb14
-rw-r--r--app/graphql/types/issue_type.rb106
-rw-r--r--app/graphql/types/issue_type_enum.rb2
-rw-r--r--app/graphql/types/jira_import_type.rb14
-rw-r--r--app/graphql/types/jira_user_type.rb14
-rw-r--r--app/graphql/types/label_type.rb16
-rw-r--r--app/graphql/types/member_interface.rb14
-rw-r--r--app/graphql/types/merge_request_connection_type.rb4
-rw-r--r--app/graphql/types/merge_request_type.rb157
-rw-r--r--app/graphql/types/merge_requests/detailed_merge_status_enum.rb44
-rw-r--r--app/graphql/types/metadata/kas_type.rb6
-rw-r--r--app/graphql/types/metadata_type.rb6
-rw-r--r--app/graphql/types/metrics/dashboard_type.rb10
-rw-r--r--app/graphql/types/metrics/dashboards/annotation_type.rb12
-rw-r--r--app/graphql/types/milestone_stats_type.rb8
-rw-r--r--app/graphql/types/milestone_type.rb36
-rw-r--r--app/graphql/types/mutation_type.rb25
-rw-r--r--app/graphql/types/namespace_type.rb41
-rw-r--r--app/graphql/types/notes/diff_image_position_input_type.rb16
-rw-r--r--app/graphql/types/notes/diff_position_base_input_type.rb6
-rw-r--r--app/graphql/types/notes/diff_position_input_type.rb4
-rw-r--r--app/graphql/types/notes/diff_position_type.rb24
-rw-r--r--app/graphql/types/notes/discussion_type.rb10
-rw-r--r--app/graphql/types/notes/note_type.rb28
-rw-r--r--app/graphql/types/packages/package_base_type.rb6
-rw-r--r--app/graphql/types/packages/package_details_type.rb2
-rw-r--r--app/graphql/types/packages/package_file_type.rb2
-rw-r--r--app/graphql/types/permission_types/group_enum.rb3
-rw-r--r--app/graphql/types/project_invitation_type.rb2
-rw-r--r--app/graphql/types/project_member_type.rb2
-rw-r--r--app/graphql/types/project_statistics_type.rb26
-rw-r--r--app/graphql/types/project_type.rb134
-rw-r--r--app/graphql/types/projects/service_type.rb6
-rw-r--r--app/graphql/types/projects/services/jira_project_type.rb8
-rw-r--r--app/graphql/types/projects/topic_type.rb12
-rw-r--r--app/graphql/types/prometheus_alert_type.rb2
-rw-r--r--app/graphql/types/query_type.rb6
-rw-r--r--app/graphql/types/release_asset_link_type.rb16
-rw-r--r--app/graphql/types/release_assets_type.rb6
-rw-r--r--app/graphql/types/release_links_type.rb26
-rw-r--r--app/graphql/types/release_source_type.rb4
-rw-r--r--app/graphql/types/release_type.rb36
-rw-r--r--app/graphql/types/repository/blob_type.rb88
-rw-r--r--app/graphql/types/repository_type.rb18
-rw-r--r--app/graphql/types/resolvable_interface.rb10
-rw-r--r--app/graphql/types/snippet_type.rb28
-rw-r--r--app/graphql/types/snippets/blob_connection_type.rb4
-rw-r--r--app/graphql/types/snippets/blob_type.rb22
-rw-r--r--app/graphql/types/subscription_type.rb11
-rw-r--r--app/graphql/types/task_completion_status.rb4
-rw-r--r--app/graphql/types/time_tracking/timelog_category_type.rb51
-rw-r--r--app/graphql/types/tree/blob_type.rb10
-rw-r--r--app/graphql/types/tree/entry_type.rb12
-rw-r--r--app/graphql/types/tree/submodule_type.rb4
-rw-r--r--app/graphql/types/tree/tree_entry_type.rb4
-rw-r--r--app/graphql/types/tree/tree_type.rb10
-rw-r--r--app/graphql/types/upload_type.rb19
-rw-r--r--app/graphql/types/user_callout_type.rb4
-rw-r--r--app/graphql/types/user_interface.rb8
-rw-r--r--app/graphql/types/user_status_type.rb8
-rw-r--r--app/graphql/types/work_item_type.rb31
-rw-r--r--app/graphql/types/work_items/type_type.rb6
-rw-r--r--app/graphql/types/work_items/widget_interface.rb30
-rw-r--r--app/graphql/types/work_items/widgets/assignees_input_type.rb16
-rw-r--r--app/graphql/types/work_items/widgets/assignees_type.rb15
-rw-r--r--app/graphql/types/work_items/widgets/description_type.rb5
-rw-r--r--app/graphql/types/work_items/widgets/hierarchy_type.rb12
-rw-r--r--app/graphql/types/work_items/widgets/labels_type.rb27
-rw-r--r--app/graphql/types/work_items/widgets/start_and_due_date_type.rb25
-rw-r--r--app/graphql/types/work_items/widgets/start_and_due_date_update_input_type.rb18
-rw-r--r--app/graphql/types/work_items/widgets/weight_input_type.rb15
-rw-r--r--app/graphql/types/work_items/widgets/weight_type.rb21
-rw-r--r--app/helpers/admin/identities_helper.rb36
-rw-r--r--app/helpers/application_helper.rb22
-rw-r--r--app/helpers/avatars_helper.rb6
-rw-r--r--app/helpers/badges_helper.rb6
-rw-r--r--app/helpers/blob_helper.rb16
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb3
-rw-r--r--app/helpers/ci/pipelines_helper.rb4
-rw-r--r--app/helpers/ci/runners_helper.rb2
-rw-r--r--app/helpers/commits_helper.rb9
-rw-r--r--app/helpers/compare_helper.rb57
-rw-r--r--app/helpers/dashboard_helper.rb4
-rw-r--r--app/helpers/environments_helper.rb44
-rw-r--r--app/helpers/events_helper.rb2
-rw-r--r--app/helpers/favicon_helper.rb6
-rw-r--r--app/helpers/form_helper.rb44
-rw-r--r--app/helpers/gitlab_script_tag_helper.rb4
-rw-r--r--app/helpers/groups/group_members_helper.rb9
-rw-r--r--app/helpers/groups_helper.rb17
-rw-r--r--app/helpers/instance_configuration_helper.rb2
-rw-r--r--app/helpers/issuables_description_templates_helper.rb29
-rw-r--r--app/helpers/issuables_helper.rb10
-rw-r--r--app/helpers/labels_helper.rb2
-rw-r--r--app/helpers/markup_helper.rb13
-rw-r--r--app/helpers/members_helper.rb18
-rw-r--r--app/helpers/merge_requests_helper.rb60
-rw-r--r--app/helpers/namespaces_helper.rb18
-rw-r--r--app/helpers/nav/new_dropdown_helper.rb2
-rw-r--r--app/helpers/nav/top_nav_helper.rb7
-rw-r--r--app/helpers/packages_helper.rb23
-rw-r--r--app/helpers/preferences_helper.rb2
-rw-r--r--app/helpers/profiles_helper.rb4
-rw-r--r--app/helpers/projects/pipeline_helper.rb9
-rw-r--r--app/helpers/projects_helper.rb59
-rw-r--r--app/helpers/search_helper.rb36
-rw-r--r--app/helpers/sorting_helper.rb178
-rw-r--r--app/helpers/storage_helper.rb86
-rw-r--r--app/helpers/system_note_helper.rb1
-rw-r--r--app/helpers/tab_helper.rb8
-rw-r--r--app/helpers/time_zone_helper.rb2
-rw-r--r--app/helpers/todos_helper.rb11
-rw-r--r--app/helpers/users_helper.rb12
-rw-r--r--app/helpers/webpack_helper.rb12
-rw-r--r--app/helpers/wiki_helper.rb8
-rw-r--r--app/mailers/abuse_report_mailer.rb4
-rw-r--r--app/mailers/emails/admin_notification.rb20
-rw-r--r--app/mailers/emails/merge_requests.rb7
-rw-r--r--app/mailers/emails/projects.rb6
-rw-r--r--app/models/application_setting.rb4
-rw-r--r--app/models/application_setting_implementation.rb2
-rw-r--r--app/models/approval.rb3
-rw-r--r--app/models/audit_event.rb12
-rw-r--r--app/models/authentication_event.rb2
-rw-r--r--app/models/blob.rb4
-rw-r--r--app/models/blob_viewer/metrics_dashboard_yml.rb12
-rw-r--r--app/models/bulk_imports/configuration.rb2
-rw-r--r--app/models/bulk_imports/entity.rb4
-rw-r--r--app/models/chat_name.rb6
-rw-r--r--app/models/ci/bridge.rb9
-rw-r--r--app/models/ci/build.rb90
-rw-r--r--app/models/ci/build_metadata.rb1
-rw-r--r--app/models/ci/build_trace_metadata.rb4
-rw-r--r--app/models/ci/deleted_object.rb4
-rw-r--r--app/models/ci/job_artifact.rb67
-rw-r--r--app/models/ci/pipeline.rb104
-rw-r--r--app/models/ci/processable.rb2
-rw-r--r--app/models/ci/runner.rb17
-rw-r--r--app/models/ci/runner_version.rb4
-rw-r--r--app/models/ci/secure_file.rb6
-rw-r--r--app/models/commit.rb2
-rw-r--r--app/models/commit_range.rb4
-rw-r--r--app/models/commit_signatures/ssh_signature.rb2
-rw-r--r--app/models/compare.rb2
-rw-r--r--app/models/concerns/ci/artifactable.rb4
-rw-r--r--app/models/concerns/ci/has_status.rb4
-rw-r--r--app/models/concerns/ci/metadatable.rb10
-rw-r--r--app/models/concerns/counter_attribute.rb47
-rw-r--r--app/models/concerns/cross_database_modification.rb36
-rw-r--r--app/models/concerns/database_event_tracking.rb53
-rw-r--r--app/models/concerns/diff_positionable_note.rb6
-rw-r--r--app/models/concerns/enums/data_visualization_palette.rb22
-rw-r--r--app/models/concerns/enums/sbom.rb13
-rw-r--r--app/models/concerns/expirable.rb5
-rw-r--r--app/models/concerns/featurable.rb8
-rw-r--r--app/models/concerns/integrations/base_data_fields.rb17
-rw-r--r--app/models/concerns/integrations/has_data_fields.rb4
-rw-r--r--app/models/concerns/integrations/has_web_hook.rb1
-rw-r--r--app/models/concerns/issuable.rb18
-rw-r--r--app/models/concerns/participable.rb8
-rw-r--r--app/models/concerns/project_features_compatibility.rb12
-rw-r--r--app/models/concerns/prometheus_adapter.rb4
-rw-r--r--app/models/concerns/repository_storage_movable.rb4
-rw-r--r--app/models/concerns/taskable.rb4
-rw-r--r--app/models/concerns/triggerable_hooks.rb30
-rw-r--r--app/models/concerns/vulnerability_finding_helpers.rb3
-rw-r--r--app/models/concerns/x509_serial_number_attribute.rb4
-rw-r--r--app/models/container_repository.rb44
-rw-r--r--app/models/custom_emoji.rb2
-rw-r--r--app/models/customer_relations/contact.rb26
-rw-r--r--app/models/customer_relations/contact_state_counts.rb42
-rw-r--r--app/models/deploy_key.rb9
-rw-r--r--app/models/deployment.rb7
-rw-r--r--app/models/design_management/design.rb4
-rw-r--r--app/models/design_management/design_action.rb2
-rw-r--r--app/models/environment.rb52
-rw-r--r--app/models/event.rb49
-rw-r--r--app/models/gpg_key.rb2
-rw-r--r--app/models/grafana_integration.rb4
-rw-r--r--app/models/group.rb20
-rw-r--r--app/models/group_group_link.rb17
-rw-r--r--app/models/hooks/web_hook.rb23
-rw-r--r--app/models/integration.rb6
-rw-r--r--app/models/integrations/bamboo.rb4
-rw-r--r--app/models/integrations/base_issue_tracker.rb4
-rw-r--r--app/models/integrations/base_slash_commands.rb2
-rw-r--r--app/models/integrations/buildkite.rb2
-rw-r--r--app/models/integrations/datadog.rb157
-rw-r--r--app/models/integrations/discord.rb39
-rw-r--r--app/models/integrations/emails_on_push.rb2
-rw-r--r--app/models/integrations/external_wiki.rb11
-rw-r--r--app/models/integrations/harbor.rb7
-rw-r--r--app/models/integrations/jenkins.rb4
-rw-r--r--app/models/integrations/jira.rb36
-rw-r--r--app/models/integrations/packagist.rb13
-rw-r--r--app/models/integrations/pipelines_email.rb4
-rw-r--r--app/models/integrations/prometheus.rb4
-rw-r--r--app/models/integrations/pumble.rb59
-rw-r--r--app/models/integrations/slack.rb17
-rw-r--r--app/models/integrations/teamcity.rb4
-rw-r--r--app/models/issuable_severity.rb16
-rw-r--r--app/models/issue.rb61
-rw-r--r--app/models/jira_connect_installation.rb4
-rw-r--r--app/models/key.rb1
-rw-r--r--app/models/lfs_object.rb2
-rw-r--r--app/models/loose_foreign_keys/deleted_record.rb37
-rw-r--r--app/models/member.rb12
-rw-r--r--app/models/members/group_member.rb23
-rw-r--r--app/models/members/last_group_owner_assigner.rb4
-rw-r--r--app/models/members/member_role.rb9
-rw-r--r--app/models/members/project_member.rb7
-rw-r--r--app/models/merge_request.rb86
-rw-r--r--app/models/merge_request/approval_removal_settings.rb39
-rw-r--r--app/models/merge_request/metrics.rb3
-rw-r--r--app/models/merge_request_diff.rb11
-rw-r--r--app/models/ml.rb6
-rw-r--r--app/models/ml/candidate.rb12
-rw-r--r--app/models/ml/candidate_metric.rb10
-rw-r--r--app/models/ml/candidate_param.rb10
-rw-r--r--app/models/ml/experiment.rb12
-rw-r--r--app/models/namespace.rb19
-rw-r--r--app/models/namespace/detail.rb9
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb61
-rw-r--r--app/models/network/graph.rb2
-rw-r--r--app/models/note.rb18
-rw-r--r--app/models/notification_reason.rb2
-rw-r--r--app/models/oauth_access_token.rb11
-rw-r--r--app/models/operations/feature_flag.rb2
-rw-r--r--app/models/packages/package.rb4
-rw-r--r--app/models/pages_domain.rb4
-rw-r--r--app/models/performance_monitoring/prometheus_dashboard.rb8
-rw-r--r--app/models/personal_access_token.rb10
-rw-r--r--app/models/preloaders/labels_preloader.rb4
-rw-r--r--app/models/project.rb61
-rw-r--r--app/models/project_feature.rb3
-rw-r--r--app/models/projects/import_export/relation_export.rb41
-rw-r--r--app/models/projects/topic.rb1
-rw-r--r--app/models/prometheus_alert.rb2
-rw-r--r--app/models/protected_branch.rb25
-rw-r--r--app/models/release.rb2
-rw-r--r--app/models/release_highlight.rb4
-rw-r--r--app/models/repository.rb18
-rw-r--r--app/models/sent_notification.rb12
-rw-r--r--app/models/serverless/domain_cluster.rb2
-rw-r--r--app/models/snippet.rb4
-rw-r--r--app/models/snippet_repository.rb6
-rw-r--r--app/models/system_note_metadata.rb2
-rw-r--r--app/models/terraform/state.rb2
-rw-r--r--app/models/todo.rb14
-rw-r--r--app/models/u2f_registration.rb34
-rw-r--r--app/models/user.rb129
-rw-r--r--app/models/user_status.rb16
-rw-r--r--app/models/users/callout.rb9
-rw-r--r--app/models/users/group_callout.rb8
-rw-r--r--app/models/users/project_callout.rb21
-rw-r--r--app/models/wiki.rb10
-rw-r--r--app/models/work_item.rb15
-rw-r--r--app/models/work_items/parent_link.rb23
-rw-r--r--app/models/work_items/type.rb16
-rw-r--r--app/models/work_items/widgets/labels.rb10
-rw-r--r--app/models/work_items/widgets/start_and_due_date.rb9
-rw-r--r--app/models/work_items/widgets/weight.rb9
-rw-r--r--app/policies/ci/runner_policy.rb2
-rw-r--r--app/policies/deployment_policy.rb2
-rw-r--r--app/policies/group_policy.rb3
-rw-r--r--app/policies/issuable_policy.rb4
-rw-r--r--app/policies/namespaces/group_project_namespace_shared_policy.rb14
-rw-r--r--app/policies/namespaces/project_namespace_policy.rb6
-rw-r--r--app/policies/namespaces/user_namespace_policy.rb1
-rw-r--r--app/policies/project_hook_policy.rb10
-rw-r--r--app/policies/project_policy.rb27
-rw-r--r--app/policies/system_hook_policy.rb8
-rw-r--r--app/policies/time_tracking/timelog_category_policy.rb7
-rw-r--r--app/policies/upload_policy.rb5
-rw-r--r--app/policies/work_item_policy.rb3
-rw-r--r--app/presenters/analytics/cycle_analytics/stage_presenter.rb2
-rw-r--r--app/presenters/ci/build_runner_presenter.rb3
-rw-r--r--app/presenters/merge_request_presenter.rb13
-rw-r--r--app/presenters/project_hook_presenter.rb2
-rw-r--r--app/presenters/project_member_presenter.rb22
-rw-r--r--app/presenters/project_presenter.rb4
-rw-r--r--app/presenters/service_hook_presenter.rb2
-rw-r--r--app/presenters/web_hook_log_presenter.rb2
-rw-r--r--app/serializers/concerns/user_status_tooltip.rb2
-rw-r--r--app/serializers/environment_serializer.rb21
-rw-r--r--app/serializers/group_access_token_entity.rb26
-rw-r--r--app/serializers/group_access_token_serializer.rb7
-rw-r--r--app/serializers/integrations/project_entity.rb1
-rw-r--r--app/serializers/issue_entity.rb4
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb13
-rw-r--r--app/serializers/merge_request_user_entity.rb4
-rw-r--r--app/serializers/personal_access_token_entity.rb11
-rw-r--r--app/serializers/personal_access_token_serializer.rb7
-rw-r--r--app/serializers/project_access_token_entity.rb27
-rw-r--r--app/serializers/project_access_token_serializer.rb7
-rw-r--r--app/serializers/rollout_status_entity.rb2
-rw-r--r--app/services/audit_events/build_service.rb87
-rw-r--r--app/services/authorized_project_update/project_recalculate_service.rb2
-rw-r--r--app/services/auto_merge/base_service.rb1
-rw-r--r--app/services/base_count_service.rb2
-rw-r--r--app/services/boards/destroy_service.rb4
-rw-r--r--app/services/boards/lists/move_service.rb2
-rw-r--r--app/services/branches/create_service.rb84
-rw-r--r--app/services/bulk_imports/create_service.rb2
-rw-r--r--app/services/bulk_imports/file_download_service.rb9
-rw-r--r--app/services/chat_names/authorize_user_service.rb12
-rw-r--r--app/services/ci/archive_trace_service.rb4
-rw-r--r--app/services/ci/deployments/destroy_service.rb18
-rw-r--r--app/services/ci/destroy_pipeline_service.rb2
-rw-r--r--app/services/ci/job_artifacts/create_service.rb2
-rw-r--r--app/services/ci/job_artifacts/destroy_batch_service.rb4
-rw-r--r--app/services/ci/list_config_variables_service.rb4
-rw-r--r--app/services/ci/parse_dotenv_artifact_service.rb2
-rw-r--r--app/services/ci/register_job_service.rb18
-rw-r--r--app/services/ci/retry_job_service.rb12
-rw-r--r--app/services/ci/runners/assign_runner_service.rb10
-rw-r--r--app/services/ci/runners/bulk_delete_runners_service.rb36
-rw-r--r--app/services/ci/runners/process_runner_version_update_service.rb27
-rw-r--r--app/services/ci/runners/reconcile_existing_runner_versions_service.rb14
-rw-r--r--app/services/ci/runners/register_runner_service.rb4
-rw-r--r--app/services/ci/runners/reset_registration_token_service.rb10
-rw-r--r--app/services/ci/runners/unassign_runner_service.rb10
-rw-r--r--app/services/ci/runners/unregister_runner_service.rb1
-rw-r--r--app/services/ci/stuck_builds/drop_helpers.rb12
-rw-r--r--app/services/ci/track_failed_build_service.rb49
-rw-r--r--app/services/ci/update_build_state_service.rb2
-rw-r--r--app/services/concerns/alert_management/alert_processing.rb21
-rw-r--r--app/services/concerns/work_items/widgetable_service.rb2
-rw-r--r--app/services/database/consistency_check_service.rb2
-rw-r--r--app/services/deployments/update_environment_service.rb8
-rw-r--r--app/services/design_management/generate_image_versions_service.rb2
-rw-r--r--app/services/error_tracking/base_service.rb2
-rw-r--r--app/services/google_cloud/base_service.rb2
-rw-r--r--app/services/google_cloud/create_cloudsql_instance_service.rb74
-rw-r--r--app/services/google_cloud/enable_cloudsql_service.rb23
-rw-r--r--app/services/google_cloud/get_cloudsql_instances_service.rb18
-rw-r--r--app/services/google_cloud/setup_cloudsql_instance_service.rb80
-rw-r--r--app/services/groups/destroy_service.rb13
-rw-r--r--app/services/groups/import_export/export_service.rb20
-rw-r--r--app/services/groups/import_export/import_service.rb8
-rw-r--r--app/services/groups/transfer_service.rb17
-rw-r--r--app/services/groups/update_service.rb28
-rw-r--r--app/services/import/prepare_service.rb31
-rw-r--r--app/services/incident_management/timeline_events/create_service.rb20
-rw-r--r--app/services/incident_management/timeline_events/update_service.rb2
-rw-r--r--app/services/issuable/clone/base_service.rb1
-rw-r--r--app/services/issuable/common_system_notes_service.rb15
-rw-r--r--app/services/issuable/import_csv/base_service.rb16
-rw-r--r--app/services/issues/clone_service.rb12
-rw-r--r--app/services/issues/create_service.rb2
-rw-r--r--app/services/issues/export_csv_service.rb36
-rw-r--r--app/services/issues/prepare_import_csv_service.rb19
-rw-r--r--app/services/issues/update_service.rb7
-rw-r--r--app/services/jira/requests/base.rb8
-rw-r--r--app/services/merge_requests/approval_service.rb53
-rw-r--r--app/services/merge_requests/base_service.rb18
-rw-r--r--app/services/merge_requests/bulk_remove_attention_requested_service.rb28
-rw-r--r--app/services/merge_requests/close_service.rb1
-rw-r--r--app/services/merge_requests/create_approval_event_service.rb11
-rw-r--r--app/services/merge_requests/create_pipeline_service.rb3
-rw-r--r--app/services/merge_requests/execute_approval_hooks_service.rb13
-rw-r--r--app/services/merge_requests/handle_assignees_change_service.rb4
-rw-r--r--app/services/merge_requests/mergeability/check_base_service.rb8
-rw-r--r--app/services/merge_requests/mergeability/check_broken_status_service.rb8
-rw-r--r--app/services/merge_requests/mergeability/check_ci_status_service.rb8
-rw-r--r--app/services/merge_requests/mergeability/check_discussions_status_service.rb8
-rw-r--r--app/services/merge_requests/mergeability/check_draft_status_service.rb8
-rw-r--r--app/services/merge_requests/mergeability/check_open_status_service.rb8
-rw-r--r--app/services/merge_requests/mergeability/run_checks_service.rb30
-rw-r--r--app/services/merge_requests/mergeability_check_service.rb4
-rw-r--r--app/services/merge_requests/post_merge_service.rb1
-rw-r--r--app/services/merge_requests/push_options_handler_service.rb2
-rw-r--r--app/services/merge_requests/remove_approval_service.rb1
-rw-r--r--app/services/merge_requests/remove_attention_requested_service.rb50
-rw-r--r--app/services/merge_requests/reopen_service.rb2
-rw-r--r--app/services/merge_requests/request_attention_service.rb60
-rw-r--r--app/services/merge_requests/toggle_attention_requested_service.rb68
-rw-r--r--app/services/merge_requests/update_assignees_service.rb23
-rw-r--r--app/services/merge_requests/update_reviewers_service.rb44
-rw-r--r--app/services/merge_requests/update_service.rb36
-rw-r--r--app/services/notes/build_service.rb8
-rw-r--r--app/services/notes/create_service.rb24
-rw-r--r--app/services/notes/destroy_service.rb3
-rw-r--r--app/services/notes/update_service.rb3
-rw-r--r--app/services/notification_recipients/build_service.rb4
-rw-r--r--app/services/notification_recipients/builder/attention_requested.rb23
-rw-r--r--app/services/notification_service.rb10
-rw-r--r--app/services/packages/conan/create_package_file_service.rb6
-rw-r--r--app/services/packages/create_package_file_service.rb10
-rw-r--r--app/services/packages/debian/create_package_file_service.rb10
-rw-r--r--app/services/packages/debian/extract_metadata_service.rb8
-rw-r--r--app/services/packages/npm/create_package_service.rb6
-rw-r--r--app/services/projects/alerting/notify_service.rb2
-rw-r--r--app/services/projects/create_service.rb28
-rw-r--r--app/services/projects/fork_service.rb22
-rw-r--r--app/services/projects/import_export/export_service.rb35
-rw-r--r--app/services/projects/import_export/relation_export_service.rb95
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb2
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb2
-rw-r--r--app/services/projects/transfer_service.rb14
-rw-r--r--app/services/projects/update_remote_mirror_service.rb10
-rw-r--r--app/services/projects/update_service.rb14
-rw-r--r--app/services/protected_branches/base_service.rb4
-rw-r--r--app/services/protected_branches/cache_service.rb68
-rw-r--r--app/services/protected_branches/create_service.rb2
-rw-r--r--app/services/protected_branches/destroy_service.rb2
-rw-r--r--app/services/protected_branches/update_service.rb2
-rw-r--r--app/services/releases/create_service.rb4
-rw-r--r--app/services/resource_events/change_labels_service.rb14
-rw-r--r--app/services/security/ci_configuration/sast_parser_service.rb6
-rw-r--r--app/services/system_note_service.rb54
-rw-r--r--app/services/system_notes/issuables_service.rb84
-rw-r--r--app/services/system_notes/time_tracking_service.rb71
-rw-r--r--app/services/timelogs/base_service.rb21
-rw-r--r--app/services/timelogs/create_service.rb45
-rw-r--r--app/services/timelogs/delete_service.rb16
-rw-r--r--app/services/todo_service.rb5
-rw-r--r--app/services/todos/destroy/destroyed_issuable_service.rb7
-rw-r--r--app/services/topics/merge_service.rb64
-rw-r--r--app/services/uploads/destroy_service.rb51
-rw-r--r--app/services/users/dismiss_namespace_callout_service.rb11
-rw-r--r--app/services/users/dismiss_project_callout_service.rb11
-rw-r--r--app/services/users/update_service.rb2
-rw-r--r--app/services/web_hooks/admin_destroy_service.rb20
-rw-r--r--app/services/web_hooks/destroy_service.rb24
-rw-r--r--app/services/web_hooks/log_execution_service.rb25
-rw-r--r--app/services/webauthn/authenticate_service.rb2
-rw-r--r--app/services/work_items/create_and_link_service.rb19
-rw-r--r--app/services/work_items/parent_links/create_service.rb4
-rw-r--r--app/services/work_items/parent_links/destroy_service.rb4
-rw-r--r--app/services/work_items/update_service.rb4
-rw-r--r--app/services/work_items/widgets/assignees_service/update_service.rb36
-rw-r--r--app/services/work_items/widgets/base_service.rb9
-rw-r--r--app/services/work_items/widgets/description_service/update_service.rb8
-rw-r--r--app/services/work_items/widgets/hierarchy_service/base_service.rb15
-rw-r--r--app/services/work_items/widgets/start_and_due_date_service/update_service.rb15
-rw-r--r--app/services/work_items/widgets/weight_service/update_service.rb15
-rw-r--r--app/uploaders/avatar_uploader.rb4
-rw-r--r--app/uploaders/design_management/design_v432x230_uploader.rb4
-rw-r--r--app/uploaders/favicon_uploader.rb8
-rw-r--r--app/uploaders/file_uploader.rb4
-rw-r--r--app/uploaders/object_storage.rb10
-rw-r--r--app/validators/json_schemas/build_metadata_id_tokens.json22
-rw-r--r--app/validators/json_schemas/cyclonedx_report.json1697
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/_default_branch.html.haml2
-rw-r--r--app/views/admin/application_settings/_diff_limits.html.haml4
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml2
-rw-r--r--app/views/admin/application_settings/_runner_registrars_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml2
-rw-r--r--app/views/admin/application_settings/_usage.html.haml4
-rw-r--r--app/views/admin/application_settings/_whats_new.html.haml2
-rw-r--r--app/views/admin/application_settings/appearances/_form.html.haml2
-rw-r--r--app/views/admin/applications/index.html.haml3
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/groups/_form.html.haml2
-rw-r--r--app/views/admin/hooks/edit.html.haml4
-rw-r--r--app/views/admin/identities/_identity.html.haml28
-rw-r--r--app/views/admin/identities/index.html.haml28
-rw-r--r--app/views/admin/labels/_label.html.haml2
-rw-r--r--app/views/admin/runners/show.html.haml1
-rw-r--r--app/views/admin/users/_head.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml7
-rw-r--r--app/views/clusters/clusters/_gitlab_integration_form.html.haml2
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml2
-rw-r--r--app/views/dashboard/_groups_head.html.haml2
-rw-r--r--app/views/devise/passwords/new.html.haml2
-rw-r--r--app/views/devise/registrations/new.html.haml1
-rw-r--r--app/views/devise/sessions/_new_base.html.haml5
-rw-r--r--app/views/devise/sessions/_new_base_user_login_label.html.haml2
-rw-r--r--app/views/devise/sessions/new.html.haml5
-rw-r--r--app/views/devise/sessions/two_factor.html.haml4
-rw-r--r--app/views/devise/shared/_footer.html.haml1
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml9
-rw-r--r--app/views/devise/shared/_signup_box.html.haml16
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml31
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers.haml5
-rw-r--r--app/views/events/event/_push.html.haml7
-rw-r--r--app/views/groups/_home_panel.html.haml5
-rw-r--r--app/views/groups/_new_group_fields.html.haml45
-rw-r--r--app/views/groups/crm/contacts/index.html.haml2
-rw-r--r--app/views/groups/dependency_proxies/show.html.haml4
-rw-r--r--app/views/groups/milestones/_form.html.haml2
-rw-r--r--app/views/groups/new.html.haml3
-rw-r--r--app/views/groups/runners/show.html.haml14
-rw-r--r--app/views/groups/settings/_advanced.html.haml2
-rw-r--r--app/views/groups/settings/access_tokens/index.html.haml2
-rw-r--r--app/views/groups/settings/ci_cd/_auto_devops_form.html.haml2
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml2
-rw-r--r--app/views/import/_githubish_status.html.haml6
-rw-r--r--app/views/layouts/_google_tag_manager_head.html.haml19
-rw-r--r--app/views/layouts/_snowplow.html.haml7
-rw-r--r--app/views/layouts/component_preview.html.haml5
-rw-r--r--app/views/layouts/devise.html.haml78
-rw-r--r--app/views/layouts/group.html.haml4
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_current_user_dropdown_item.html.haml4
-rw-r--r--app/views/layouts/header/_default.html.haml23
-rw-r--r--app/views/layouts/header/_storage_enforcement_banner.html.haml11
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml6
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml2
-rw-r--r--app/views/layouts/profile.html.haml3
-rw-r--r--app/views/layouts/project.html.haml4
-rw-r--r--app/views/notify/approved_merge_request_email.text.haml2
-rw-r--r--app/views/notify/attention_requested_merge_request_email.html.haml2
-rw-r--r--app/views/notify/attention_requested_merge_request_email.text.erb1
-rw-r--r--app/views/notify/closed_merge_request_email.text.haml2
-rw-r--r--app/views/notify/member_access_requested_email.html.haml3
-rw-r--r--app/views/notify/member_invite_accepted_email.html.haml9
-rw-r--r--app/views/notify/member_invite_accepted_email.text.erb7
-rw-r--r--app/views/notify/member_invite_declined_email.html.haml12
-rw-r--r--app/views/notify/merge_request_status_email.text.haml2
-rw-r--r--app/views/notify/merge_request_unmergeable_email.html.haml2
-rw-r--r--app/views/notify/merge_request_unmergeable_email.text.haml2
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.text.haml2
-rw-r--r--app/views/notify/merged_merge_request_email.html.haml2
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml2
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.text.erb2
-rw-r--r--app/views/notify/new_merge_request_email.html.haml2
-rw-r--r--app/views/notify/new_merge_request_email.text.erb2
-rw-r--r--app/views/notify/unapproved_merge_request_email.text.haml2
-rw-r--r--app/views/notify/user_auto_banned_email.html.haml9
-rw-r--r--app/views/notify/user_auto_banned_email.text.erb7
-rw-r--r--app/views/profiles/emails/index.html.haml39
-rw-r--r--app/views/profiles/keys/index.html.haml6
-rw-r--r--app/views/profiles/preferences/show.html.haml2
-rw-r--r--app/views/profiles/show.html.haml2
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml27
-rw-r--r--app/views/projects/_commit_button.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml8
-rw-r--r--app/views/projects/_last_push.html.haml6
-rw-r--r--app/views/projects/_new_project_fields.html.haml36
-rw-r--r--app/views/projects/_transfer.html.haml2
-rw-r--r--app/views/projects/_visibility_modal.html.haml4
-rw-r--r--app/views/projects/blob/_editor.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml4
-rw-r--r--app/views/projects/branches/_panel.html.haml13
-rw-r--r--app/views/projects/buttons/_remove_tag.html.haml2
-rw-r--r--app/views/projects/buttons/_star.html.haml4
-rw-r--r--app/views/projects/ci/secure_files/show.html.haml3
-rw-r--r--app/views/projects/commit/_same_user_different_email_signature_badge.html.haml2
-rw-r--r--app/views/projects/commits/_commit_list.html.haml21
-rw-r--r--app/views/projects/commits/_commits.html.haml2
-rw-r--r--app/views/projects/commits/show.html.haml5
-rw-r--r--app/views/projects/compare/show.html.haml8
-rw-r--r--app/views/projects/diffs/_diffs.html.haml2
-rw-r--r--app/views/projects/google_cloud/configuration/index.html.haml2
-rw-r--r--app/views/projects/google_cloud/databases/index.html.haml2
-rw-r--r--app/views/projects/google_cloud/deployments/index.html.haml2
-rw-r--r--app/views/projects/google_cloud/gcp_regions/index.html.haml2
-rw-r--r--app/views/projects/google_cloud/service_accounts/index.html.haml2
-rw-r--r--app/views/projects/graphs/charts.html.haml15
-rw-r--r--app/views/projects/hook_logs/_index.html.haml11
-rw-r--r--app/views/projects/hooks/edit.html.haml2
-rw-r--r--app/views/projects/imports/new.html.haml18
-rw-r--r--app/views/projects/issues/_new_branch.html.haml3
-rw-r--r--app/views/projects/issues/_related_issues.html.haml4
-rw-r--r--app/views/projects/issues/_work_item_links.html.haml2
-rw-r--r--app/views/projects/labels/index.html.haml6
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml7
-rw-r--r--app/views/projects/merge_requests/show.html.haml13
-rw-r--r--app/views/projects/milestones/_form.html.haml2
-rw-r--r--app/views/projects/new.html.haml2
-rw-r--r--app/views/projects/pages/_access.html.haml8
-rw-r--r--app/views/projects/pages/_header.html.haml11
-rw-r--r--app/views/projects/pages/_list.html.haml69
-rw-r--r--app/views/projects/pages/_no_domains.html.haml6
-rw-r--r--app/views/projects/pages/_use.html.haml15
-rw-r--r--app/views/projects/pages/_waiting.html.haml13
-rw-r--r--app/views/projects/pages/disabled.html.haml4
-rw-r--r--app/views/projects/pages/new.html.haml7
-rw-r--r--app/views/projects/pages/show.html.haml34
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml2
-rw-r--r--app/views/projects/pipelines/charts.html.haml1
-rw-r--r--app/views/projects/project_templates/_template.html.haml3
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml8
-rw-r--r--app/views/projects/runners/_group_runners.html.haml6
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml2
-rw-r--r--app/views/projects/settings/integrations/edit.html.haml3
-rw-r--r--app/views/projects/settings/operations/show.html.haml28
-rw-r--r--app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml6
-rw-r--r--app/views/projects/settings/packages_and_registries/show.html.haml14
-rw-r--r--app/views/projects/tags/_edit_release_button.html.haml20
-rw-r--r--app/views/projects/tags/_release_link.html.haml4
-rw-r--r--app/views/projects/tags/_tag.html.haml7
-rw-r--r--app/views/projects/tags/releases/edit.html.haml19
-rw-r--r--app/views/projects/tags/show.html.haml13
-rw-r--r--app/views/projects/triggers/_index.html.haml2
-rw-r--r--app/views/projects/triggers/_trigger.html.haml4
-rw-r--r--app/views/search/_results.html.haml2
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml2
-rw-r--r--app/views/shared/_broadcast_message.html.haml4
-rw-r--r--app/views/shared/_clone_panel.html.haml2
-rw-r--r--app/views/shared/_file_highlight.html.haml10
-rw-r--r--app/views/shared/_group_form.html.haml6
-rw-r--r--app/views/shared/_help_dropdown_forum_link.html.haml2
-rw-r--r--app/views/shared/_label_row.html.haml4
-rw-r--r--app/views/shared/_new_project_item_select.html.haml4
-rw-r--r--app/views/shared/_ref_switcher.html.haml4
-rw-r--r--app/views/shared/_remote_mirror_update_button.html.haml2
-rw-r--r--app/views/shared/_search_settings.html.haml2
-rw-r--r--app/views/shared/_sidebar_toggle_button.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml6
-rw-r--r--app/views/shared/access_tokens/_table.html.haml2
-rw-r--r--app/views/shared/admin/_admin_note.html.haml6
-rw-r--r--app/views/shared/blob/_markdown_buttons.html.haml6
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/empty_states/_labels.html.haml4
-rw-r--r--app/views/shared/empty_states/_priority_labels.html.haml2
-rw-r--r--app/views/shared/empty_states/_topics.html.haml2
-rw-r--r--app/views/shared/empty_states/_wikis.html.haml2
-rw-r--r--app/views/shared/empty_states/_wikis_layout.html.haml2
-rw-r--r--app/views/shared/groups/_group_name_and_path_fields.html.haml3
-rw-r--r--app/views/shared/groups/_search_form.html.haml2
-rw-r--r--app/views/shared/hook_logs/_index.html.haml (renamed from app/views/admin/hook_logs/_index.html.haml)0
-rw-r--r--app/views/shared/issuable/_assignees.html.haml7
-rw-r--r--app/views/shared/issuable/_form.html.haml8
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml4
-rw-r--r--app/views/shared/issuable/_merge_request_assignees.html.haml8
-rw-r--r--app/views/shared/issuable/_merge_request_reviewers.html.haml8
-rw-r--r--app/views/shared/issuable/_milestone_dropdown.html.haml4
-rw-r--r--app/views/shared/issuable/_reviewers.html.haml7
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml10
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml10
-rw-r--r--app/views/shared/issuable/_sidebar_reviewers.html.haml2
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml4
-rw-r--r--app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml2
-rw-r--r--app/views/shared/issuable/form/_title.html.haml2
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml2
-rw-r--r--app/views/shared/labels/_form.html.haml10
-rw-r--r--app/views/shared/labels/_nav.html.haml4
-rw-r--r--app/views/shared/members/_requests.html.haml15
-rw-r--r--app/views/shared/milestones/_form_dates.html.haml4
-rw-r--r--app/views/shared/milestones/_header.html.haml4
-rw-r--r--app/views/shared/notes/_hints.html.haml9
-rw-r--r--app/views/shared/projects/_search_form.html.haml2
-rw-r--r--app/views/shared/projects/_topics.html.haml12
-rw-r--r--app/views/shared/projects/protected_branches/_update_protected_branch.html.haml2
-rw-r--r--app/views/users/show.html.haml17
-rw-r--r--app/workers/all_queues.yml96
-rw-r--r--app/workers/archive_trace_worker.rb5
-rw-r--r--app/workers/build_finished_worker.rb9
-rw-r--r--app/workers/build_hooks_worker.rb6
-rw-r--r--app/workers/ci/build_finished_worker.rb3
-rw-r--r--app/workers/ci/cancel_pipeline_worker.rb25
-rw-r--r--app/workers/ci/runners/process_runner_version_update_worker.rb25
-rw-r--r--app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb18
-rw-r--r--app/workers/ci/track_failed_build_worker.rb26
-rw-r--r--app/workers/concerns/waitable_worker.rb6
-rw-r--r--app/workers/email_receiver_worker.rb3
-rw-r--r--app/workers/emails_on_push_worker.rb18
-rw-r--r--app/workers/gitlab/github_import/stage/import_issue_events_worker.rb28
-rw-r--r--app/workers/merge_requests/create_approval_event_worker.rb34
-rw-r--r--app/workers/merge_requests/create_approval_note_worker.rb32
-rw-r--r--app/workers/merge_requests/execute_approval_hooks_worker.rb37
-rw-r--r--app/workers/merge_requests/resolve_todos_after_approval_worker.rb32
-rw-r--r--app/workers/new_issue_worker.rb10
-rw-r--r--app/workers/pages/invalidate_domain_cache_worker.rb8
-rw-r--r--app/workers/post_receive.rb1
-rw-r--r--app/workers/project_cache_worker.rb3
-rw-r--r--app/workers/projects/import_export/relation_export_worker.rb26
-rw-r--r--app/workers/service_desk_email_receiver_worker.rb3
-rw-r--r--app/workers/update_project_statistics_worker.rb13
-rw-r--r--app/workers/users/deactivate_dormant_users_worker.rb36
-rw-r--r--app/workers/x509_issuer_crl_check_worker.rb26
1435 files changed, 18411 insertions, 9432 deletions
diff --git a/app/assets/images/auth_buttons/jwt_64.png b/app/assets/images/auth_buttons/jwt_64.png
index ca97ae47002..fcfecde23d3 100644
--- a/app/assets/images/auth_buttons/jwt_64.png
+++ b/app/assets/images/auth_buttons/jwt_64.png
Binary files differ
diff --git a/app/assets/images/auth_buttons/salesforce_64.png b/app/assets/images/auth_buttons/salesforce_64.png
index c8a86a0c515..b562e09c20f 100644
--- a/app/assets/images/auth_buttons/salesforce_64.png
+++ b/app/assets/images/auth_buttons/salesforce_64.png
Binary files differ
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
index 147de529eea..5516fd0daf6 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -1,7 +1,8 @@
<script>
-import { GlDatepicker, GlFormInput, GlFormGroup } from '@gitlab/ui';
+import { GlDatepicker, GlFormGroup } from '@gitlab/ui';
import { __ } from '~/locale';
+import { getDateInFuture } from '~/lib/utils/datetime_utility';
export default {
name: 'ExpiresAtField',
@@ -10,7 +11,6 @@ export default {
},
components: {
GlDatepicker,
- GlFormInput,
GlFormGroup,
MaxExpirationDateMessage: () =>
import('ee_component/access_tokens/components/max_expiration_date_message.vue'),
@@ -32,20 +32,28 @@ export default {
default: () => null,
},
},
+ computed: {
+ in30Days() {
+ const today = new Date();
+ return getDateInFuture(today, 30);
+ },
+ },
};
</script>
<template>
<gl-form-group :label="$options.i18n.label" :label-for="inputAttrs.id">
- <gl-datepicker :target="null" :min-date="minDate" :max-date="maxDate">
- <gl-form-input
- v-bind="inputAttrs"
- class="datepicker gl-datepicker-input"
- autocomplete="off"
- inputmode="none"
- data-qa-selector="expiry_date_field"
- />
- </gl-datepicker>
+ <gl-datepicker
+ :target="null"
+ :min-date="minDate"
+ :max-date="maxDate"
+ :default-date="in30Days"
+ show-clear-button
+ :input-name="inputAttrs.name"
+ :input-id="inputAttrs.id"
+ :placeholder="inputAttrs.placeholder"
+ data-qa-selector="expiry_date_field"
+ />
<template #description>
<max-expiration-date-message :max-date="maxDate" />
</template>
diff --git a/app/assets/javascripts/access_tokens/components/projects_field.vue b/app/assets/javascripts/access_tokens/components/projects_field.vue
deleted file mode 100644
index 066cea5e90c..00000000000
--- a/app/assets/javascripts/access_tokens/components/projects_field.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<script>
-import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui';
-import ProjectsTokenSelector from './projects_token_selector.vue';
-
-export default {
- name: 'ProjectsField',
- ALL_PROJECTS: 'ALL_PROJECTS',
- SELECTED_PROJECTS: 'SELECTED_PROJECTS',
- components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector },
- props: {
- inputAttrs: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- selectedRadio: !this.inputAttrs.value
- ? this.$options.ALL_PROJECTS
- : this.$options.SELECTED_PROJECTS,
- selectedProjects: [],
- };
- },
- computed: {
- allProjectsRadioSelected() {
- return this.selectedRadio === this.$options.ALL_PROJECTS;
- },
- hiddenInputValue() {
- return this.allProjectsRadioSelected
- ? null
- : this.selectedProjects.map((project) => project.id).join(',');
- },
- initialProjectIds() {
- if (!this.inputAttrs.value) {
- return [];
- }
-
- return this.inputAttrs.value.split(',');
- },
- },
- methods: {
- handleTokenSelectorFocus() {
- this.selectedRadio = this.$options.SELECTED_PROJECTS;
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-form-group :label="__('Projects')" label-class="gl-pb-0!">
- <gl-form-text class="gl-pb-3">{{
- __('Set access permissions for this token.')
- }}</gl-form-text>
- <gl-form-radio v-model="selectedRadio" :value="$options.ALL_PROJECTS">{{
- __('All projects')
- }}</gl-form-radio>
- <gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{
- __('Selected projects')
- }}</gl-form-radio>
- <input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" />
- <projects-token-selector
- v-model="selectedProjects"
- :initial-project-ids="initialProjectIds"
- @focus="handleTokenSelectorFocus"
- />
- </gl-form-group>
- </div>
-</template>
diff --git a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue
deleted file mode 100644
index 4843c52fcbb..00000000000
--- a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue
+++ /dev/null
@@ -1,156 +0,0 @@
-<script>
-import {
- GlTokenSelector,
- GlAvatar,
- GlAvatarLabeled,
- GlIntersectionObserver,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import produce from 'immer';
-
-import { convertToGraphQLIds, convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils';
-
-import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
-
-const DEBOUNCE_DELAY = 250;
-const PROJECTS_PER_PAGE = 20;
-const GRAPHQL_ENTITY_TYPE = 'Project';
-
-export default {
- name: 'ProjectsTokenSelector',
- components: {
- GlTokenSelector,
- GlAvatar,
- GlAvatarLabeled,
- GlIntersectionObserver,
- GlLoadingIcon,
- },
- model: {
- prop: 'selectedProjects',
- },
- props: {
- selectedProjects: {
- type: Array,
- required: true,
- },
- initialProjectIds: {
- type: Array,
- required: true,
- },
- },
- apollo: {
- projects: {
- query: getProjectsQuery,
- debounce: DEBOUNCE_DELAY,
- variables() {
- return {
- search: this.searchQuery,
- after: null,
- first: PROJECTS_PER_PAGE,
- };
- },
- update({ projects }) {
- return {
- list: convertNodeIdsFromGraphQLIds(projects.nodes),
- pageInfo: projects.pageInfo,
- };
- },
- result() {
- this.isLoadingMoreProjects = false;
- this.isSearching = false;
- },
- },
- initialProjects: {
- query: getProjectsQuery,
- variables() {
- return {
- ids: convertToGraphQLIds(GRAPHQL_ENTITY_TYPE, this.initialProjectIds),
- };
- },
- manual: true,
- skip() {
- return !this.initialProjectIds.length;
- },
- result({ data: { projects } }) {
- this.$emit('input', convertNodeIdsFromGraphQLIds(projects.nodes));
- },
- },
- },
- data() {
- return {
- projects: {
- list: [],
- pageInfo: {},
- },
- searchQuery: '',
- isLoadingMoreProjects: false,
- isSearching: false,
- };
- },
- methods: {
- handleSearch(query) {
- this.isSearching = true;
- this.searchQuery = query;
- },
- loadMoreProjects() {
- this.isLoadingMoreProjects = true;
-
- this.$apollo.queries.projects.fetchMore({
- variables: {
- after: this.projects.pageInfo.endCursor,
- first: PROJECTS_PER_PAGE,
- },
- updateQuery(previousResult, { fetchMoreResult: { projects: newProjects } }) {
- const { projects: previousProjects } = previousResult;
-
- return produce(previousResult, (draftData) => {
- draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes];
- draftData.projects.pageInfo = newProjects.pageInfo;
- });
- },
- });
- },
- },
-};
-</script>
-
-<template>
- <div class="gl-relative">
- <gl-token-selector
- :selected-tokens="selectedProjects"
- :dropdown-items="projects.list"
- :loading="isSearching"
- :placeholder="__('Select projects')"
- menu-class="gl-w-full! gl-max-w-full!"
- @input="$emit('input', $event)"
- @focus="$emit('focus', $event)"
- @text-input="handleSearch"
- @keydown.enter.prevent
- >
- <template #token-content="{ token: project }">
- <gl-avatar
- :entity-id="project.id"
- :entity-name="project.name"
- :src="project.avatarUrl"
- :size="16"
- />
- {{ project.nameWithNamespace }}
- </template>
- <template #dropdown-item-content="{ dropdownItem: project }">
- <gl-avatar-labeled
- :entity-id="project.id"
- :entity-name="project.name"
- :size="32"
- :src="project.avatarUrl"
- :label="project.name"
- :sub-label="project.nameWithNamespace"
- />
- </template>
- <template #dropdown-footer>
- <gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects">
- <gl-loading-icon v-if="isLoadingMoreProjects" class="gl-mb-3" size="sm" />
- </gl-intersection-observer>
- </template>
- </gl-token-selector>
- </div>
-</template>
diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
index a5fc70b9ca6..6fb17bf0ee6 100644
--- a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
+++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
@@ -22,7 +22,6 @@ query accessTokensGetProjects(
avatarUrl
}
pageInfo {
- __typename
...PageInfo
}
}
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index a7a03523e7f..9801aa08e28 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
-import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { parseRailsFormFields } from '~/lib/utils/forms';
import { __, sprintf } from '~/locale';
@@ -99,62 +98,6 @@ export const initNewAccessTokenApp = () => {
});
};
-export const initProjectsField = () => {
- const el = document.querySelector('.js-access-tokens-projects');
-
- if (!el) {
- return null;
- }
-
- const { projects: inputAttrs } = parseRailsFormFields(el);
-
- if (window.gon.features.personalAccessTokensScopedToProjects) {
- return new Promise((resolve) => {
- Promise.all([
- import('./components/projects_field.vue'),
- import('vue-apollo'),
- import('~/lib/graphql'),
- ])
- .then(
- ([
- { default: ProjectsField },
- { default: VueApollo },
- { default: createDefaultClient },
- ]) => {
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
- Vue.use(VueApollo);
-
- resolve(
- new Vue({
- el,
- apolloProvider,
- render(h) {
- return h(ProjectsField, {
- props: {
- inputAttrs,
- },
- });
- },
- }),
- );
- },
- )
- .catch(() => {
- createFlash({
- message: __(
- 'An error occurred while loading the access tokens form, please try again.',
- ),
- });
- });
- });
- }
-
- return null;
-};
-
export const initTokensApp = () => {
const el = document.getElementById('js-tokens-app');
diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue
index 46e7ac3cf28..6b140590938 100644
--- a/app/assets/javascripts/admin/deploy_keys/components/table.vue
+++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue
@@ -139,15 +139,15 @@ export default {
title,
fingerprint,
fingerprint_sha256,
- projects_with_write_access,
- created_at,
+ projects_with_write_access: projects,
+ created_at: created,
}) => ({
id,
title,
fingerprint,
fingerprint_sha256,
- projects: projects_with_write_access,
- created: created_at,
+ projects,
+ created,
}),
);
} catch (error) {
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
index ac9304391f9..3cd3f2d92f8 100644
--- a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
@@ -5,7 +5,6 @@ query getIntegrations($projectPath: ID!) {
id
alertManagementIntegrations {
nodes {
- __typename
...IntegrationItem
}
}
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
index b151e1605da..b2e554bc913 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -285,7 +285,7 @@ export default {
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
<div>
- <div data-testid="project-name">{{ project.name }}</div>
+ <div data-testid="project-name" data-qa-selector="project_name">{{ project.name }}</div>
<div class="gl-text-gray-500" data-testid="project-full-path">
{{ project.fullPath }}
</div>
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index 71b7ca29bad..1887f2affc3 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -19,24 +19,22 @@ export const toYmd = (date) => dateFormat(date, dateFormats.isoDate);
* @returns {Object}
*/
export const extractFilterQueryParameters = (url = '') => {
- /* eslint-disable camelcase */
const {
- source_branch_name = null,
- target_branch_name = null,
- author_username = null,
- milestone_title = null,
- assignee_username = [],
- label_name = [],
+ source_branch_name: selectedSourceBranch = null,
+ target_branch_name: selectedTargetBranch = null,
+ author_username: selectedAuthor = null,
+ milestone_title: selectedMilestone = null,
+ assignee_username: selectedAssigneeList = [],
+ label_name: selectedLabelList = [],
} = urlQueryToFilter(url);
- /* eslint-enable camelcase */
return {
- selectedSourceBranch: source_branch_name,
- selectedTargetBranch: target_branch_name,
- selectedAuthor: author_username,
- selectedMilestone: milestone_title,
- selectedAssigneeList: assignee_username,
- selectedLabelList: label_name,
+ selectedSourceBranch,
+ selectedTargetBranch,
+ selectedAuthor,
+ selectedMilestone,
+ selectedAssigneeList,
+ selectedLabelList,
};
};
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
index b353bcdfd0e..2bde5973600 100644
--- a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
@@ -1,5 +1,4 @@
fragment Count on UsageTrendsMeasurement {
- __typename
count
recordedAt
}
diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js
index c7a53288ae4..15457f28eff 100644
--- a/app/assets/javascripts/api/analytics_api.js
+++ b/app/assets/javascripts/api/analytics_api.js
@@ -43,9 +43,6 @@ export const getProjectValueStreamStages = (requestPath, valueStreamId) => {
export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) =>
axios.get(joinPaths(requestPath, 'events', stageId), { params });
-export const getProjectValueStreamMetrics = (requestPath, params) =>
- axios.get(requestPath, { params });
-
/**
* Dedicated project VSA paths
*/
diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js
index a563afc6abb..48cf346d0e6 100644
--- a/app/assets/javascripts/api/groups_api.js
+++ b/app/assets/javascripts/api/groups_api.js
@@ -2,6 +2,7 @@ import { DEFAULT_PER_PAGE } from '~/api';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
+const GROUP_PATH = '/api/:version/groups/:id';
const GROUPS_PATH = '/api/:version/groups.json';
const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups';
@@ -30,3 +31,9 @@ export function getDescendentGroups(parentGroupId, query, options, callback = ()
const url = buildApiUrl(DESCENDANT_GROUPS_PATH.replace(':id', parentGroupId));
return axiosGet(url, query, options, callback);
}
+
+export function updateGroup(groupId, data = {}) {
+ const url = buildApiUrl(GROUP_PATH).replace(':id', groupId);
+
+ return axios.put(url, data);
+}
diff --git a/app/assets/javascripts/attention_requests/components/navigation_popover.vue b/app/assets/javascripts/attention_requests/components/navigation_popover.vue
deleted file mode 100644
index 804eda8f321..00000000000
--- a/app/assets/javascripts/attention_requests/components/navigation_popover.vue
+++ /dev/null
@@ -1,122 +0,0 @@
-<script>
-import { GlPopover, GlSprintf, GlButton, GlLink, GlIcon } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
-
-export default {
- components: {
- GlPopover,
- GlSprintf,
- GlButton,
- GlLink,
- GlIcon,
- UserCalloutDismisser,
- },
- inject: {
- message: {
- default: '',
- },
- observerElSelector: {
- default: '',
- },
- observerElToggledClass: {
- default: '',
- },
- featureName: {
- default: '',
- },
- popoverTarget: {
- default: '',
- },
- showAttentionIcon: {
- default: false,
- },
- delay: {
- default: 0,
- },
- popoverCssClass: {
- default: '',
- },
- },
- data() {
- return {
- showPopover: false,
- popoverPlacement: this.popoverPosition(),
- };
- },
- mounted() {
- this.observeEl = document.querySelector(this.observerElSelector);
- this.observer = new MutationObserver(this.callback);
- this.observer.observe(this.observeEl, {
- attributes: true,
- });
- this.callback();
-
- window.addEventListener('resize', () => {
- this.popoverPlacement = this.popoverPosition();
- });
- },
- beforeDestroy() {
- this.observer.disconnect();
- },
- methods: {
- callback() {
- if (this.showPopover) {
- this.$root.$emit('bv::hide::popover');
- }
-
- setTimeout(() => this.toggleShowPopover(), this.delay);
- },
- toggleShowPopover() {
- this.showPopover = this.observeEl.classList.contains(this.observerElToggledClass);
- },
- getPopoverTarget() {
- return document.querySelector(this.popoverTarget);
- },
- popoverPosition() {
- if (bp.isDesktop()) {
- return 'left';
- }
-
- return 'bottom';
- },
- },
- docsPage: helpPagePath('user/project/merge_requests/index.md', {
- anchor: 'request-attention-to-a-merge-request',
- }),
-};
-</script>
-
-<template>
- <user-callout-dismisser :feature-name="featureName">
- <template #default="{ shouldShowCallout, dismiss }">
- <gl-popover
- v-if="shouldShowCallout"
- :show-close-button="false"
- :target="() => getPopoverTarget()"
- :show="showPopover"
- :delay="0"
- triggers="manual"
- :placement="popoverPlacement"
- boundary="window"
- no-fade
- :css-classes="[popoverCssClass]"
- >
- <p v-for="(m, index) in message" :key="index" class="gl-mb-5">
- <gl-sprintf :message="m">
- <template #strong="{ content }">
- <strong><gl-icon v-if="showAttentionIcon" name="attention" /> {{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <div class="gl-display-flex gl-align-items-center">
- <gl-button size="small" variant="confirm" class="gl-mr-5" @click.prevent.stop="dismiss">
- {{ __('Got it!') }}
- </gl-button>
- <gl-link :href="$options.docsPage" target="_blank">{{ __('Learn more') }}</gl-link>
- </div>
- </gl-popover>
- </template>
- </user-callout-dismisser>
-</template>
diff --git a/app/assets/javascripts/attention_requests/index.js b/app/assets/javascripts/attention_requests/index.js
deleted file mode 100644
index 2a142ab46e5..00000000000
--- a/app/assets/javascripts/attention_requests/index.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { __ } from '~/locale';
-import createDefaultClient from '~/lib/graphql';
-import NavigationPopover from './components/navigation_popover.vue';
-
-Vue.use(VueApollo);
-
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
-export const initTopNavPopover = () => {
- const el = document.getElementById('js-need-attention-nav-onboarding');
-
- if (!el) return;
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- apolloProvider,
- provide: {
- observerElSelector: '.user-counter.dropdown',
- observerElToggledClass: 'show',
- message: [
- __(
- '%{strongStart}Need your attention%{strongEnd} are the merge requests that need your help to move forward, as an assignee or reviewer.',
- ),
- ],
- featureName: 'attention_requests_top_nav',
- popoverTarget: '#js-need-attention-nav',
- },
- render(h) {
- return h(NavigationPopover);
- },
- });
-};
-
-export const initSideNavPopover = () => {
- const el = document.getElementById('js-need-attention-sidebar-onboarding');
-
- if (!el) return;
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- apolloProvider,
- provide: {
- observerElSelector: '.js-right-sidebar',
- observerElToggledClass: 'right-sidebar-expanded',
- message: [
- __(
- 'To ask someone to look at a merge request, select %{strongStart}Request attention%{strongEnd}. Select again to remove the request.',
- ),
- __(
- 'Some actions remove attention requests, like a reviewer approving or anyone merging the merge request.',
- ),
- ],
- featureName: 'attention_requests_side_nav',
- popoverTarget: '.js-attention-request-toggle',
- showAttentionIcon: true,
- delay: 500,
- popoverCssClass: 'attention-request-sidebar-popover',
- },
- render(h) {
- return h(NavigationPopover);
- },
- });
-};
-
-export default () => {
- initTopNavPopover();
-};
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index 300a81caa5c..e5408d0734a 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -116,11 +116,7 @@ export default {
class="referenced-commands draft-note-commands"
></div>
- <p
- v-if="!glFeatures.mrReviewSubmitComment"
- class="draft-note-actions d-flex"
- data-qa-selector="draft_note_content"
- >
+ <p v-if="!glFeatures.mrReviewSubmitComment" class="draft-note-actions d-flex">
<publish-button
:show-count="true"
:should-publish="false"
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index 3cd1a2525e9..111b670596b 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -2,10 +2,20 @@
import { mapActions, mapGetters } from 'vuex';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants';
+import { PREVENT_LEAVING_PENDING_REVIEW } from '../i18n';
import PreviewDropdown from './preview_dropdown.vue';
import PublishButton from './publish_button.vue';
import SubmitDropdown from './submit_dropdown.vue';
+function closeInterrupt(event) {
+ event.preventDefault();
+
+ // This is the correct way to write backwards-compatible beforeunload listeners
+ // https://developer.chrome.com/blog/page-lifecycle-api/#the-beforeunload-event
+ /* eslint-disable-next-line no-return-assign, no-param-reassign */
+ return (event.returnValue = PREVENT_LEAVING_PENDING_REVIEW);
+}
+
export default {
components: {
PreviewDropdown,
@@ -25,8 +35,26 @@ export default {
},
mounted() {
document.body.classList.add(REVIEW_BAR_VISIBLE_CLASS_NAME);
+ /*
+ * This stuff is a lot trickier than it looks.
+ *
+ * Mandatory reading: https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
+ * Some notable sentences:
+ * - "[...] browsers may not display prompts created in beforeunload event handlers unless the
+ * page has been interacted with, or may even not display them at all."
+ * - "Especially on mobile, the beforeunload event is not reliably fired."
+ * - "The beforeunload event is not compatible with the back/forward cache (bfcache) [...]
+ * It is recommended that developers listen for beforeunload only in this scenario, and only
+ * when they actually have unsaved changes, so as to minimize the effect on performance."
+ *
+ * Please ensure that this is really not working before you modify it, because there are a LOT
+ * of scenarios where browser behavior will make it _seem_ like it's not working, but it actually
+ * is under the right combination of contexts.
+ */
+ window.addEventListener('beforeunload', closeInterrupt, { capture: true });
},
beforeDestroy() {
+ window.removeEventListener('beforeunload', closeInterrupt, { capture: true });
document.body.classList.remove(REVIEW_BAR_VISIBLE_CLASS_NAME);
},
methods: {
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index b070848cae9..54b9953270b 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,8 +1,11 @@
<script>
-import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup } from '@gitlab/ui';
+import $ from 'jquery';
+import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlLink } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
+import Autosave from '~/autosave';
+import { helpPagePath } from '~/helpers/help_page_helper';
export default {
components: {
@@ -11,6 +14,7 @@ export default {
GlIcon,
GlForm,
GlFormGroup,
+ GlLink,
MarkdownField,
},
data() {
@@ -23,6 +27,11 @@ export default {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
},
mounted() {
+ this.autosave = new Autosave(
+ $(this.$refs.textarea),
+ `submit_review_dropdown/${this.getNoteableData.id}`,
+ );
+
// We override the Bootstrap Vue click outside behaviour
// to allow for clicking in the autocomplete dropdowns
// without this override the submit dropdown will close
@@ -47,6 +56,8 @@ export default {
await this.publishReview(noteData);
+ this.autosave.reset();
+
if (window.mrTabs && this.note) {
window.location.hash = `note_${this.getCurrentUserLastNote.id}`;
window.mrTabs.tabShown('show');
@@ -60,6 +71,9 @@ export default {
},
},
restrictedToolbarItems: ['full-screen'],
+ helpPagePath: helpPagePath('user/project/merge_requests/reviews/index.html', {
+ anchor: 'submit-a-review',
+ }),
};
</script>
@@ -68,19 +82,27 @@ export default {
ref="dropdown"
right
class="submit-review-dropdown"
+ data-qa-selector="submit_review_dropdown"
variant="info"
- category="secondary"
+ category="primary"
>
<template #button-content>
{{ __('Finish review') }}
<gl-icon class="dropdown-chevron" name="chevron-up" />
</template>
<gl-form data-testid="submit-gl-form" @submit.prevent="submitReview">
- <gl-form-group
- :label="__('Summary comment (optional)')"
- label-for="review-note-body"
- label-class="gl-mb-2"
- >
+ <gl-form-group label-for="review-note-body" label-class="gl-mb-2">
+ <template #label>
+ {{ __('Summary comment (optional)') }}
+ <gl-link
+ :href="$options.helpPagePath"
+ :aria-label="__('More information')"
+ target="_blank"
+ class="gl-ml-2"
+ >
+ <gl-icon name="question-o" />
+ </gl-link>
+ </template>
<div class="common-note-form gfm-form">
<div
class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100"
@@ -117,13 +139,14 @@ export default {
</div>
</div>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end gl-mt-5">
+ <div class="gl-display-flex gl-justify-content-start gl-mt-5">
<gl-button
:loading="isSubmitting"
variant="confirm"
type="submit"
class="js-no-auto-disable"
data-testid="submit-review-button"
+ data-qa-selector="submit_review_button"
>
{{ __('Submit review') }}
</gl-button>
diff --git a/app/assets/javascripts/batch_comments/i18n.js b/app/assets/javascripts/batch_comments/i18n.js
new file mode 100644
index 00000000000..6cdbf00f9ca
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/i18n.js
@@ -0,0 +1,3 @@
+import { __ } from '~/locale';
+
+export const PREVENT_LEAVING_PENDING_REVIEW = __('There are unsubmitted review comments.');
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index a44b9827fe9..863d2a99972 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -1,9 +1,12 @@
import { isEmpty } from 'lodash';
+
import createFlash from '~/flash';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+
import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants';
import service from '../../../services/drafts_service';
+
import * as types from './mutation_types';
export const saveDraft = ({ dispatch }, draft) =>
@@ -15,6 +18,7 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) =>
.then((res) => res.data)
.then((res) => {
commit(types.ADD_NEW_DRAFT, res);
+
return res;
})
.catch(() => {
@@ -29,6 +33,7 @@ export const createNewDraft = ({ commit }, { endpoint, data }) =>
.then((res) => res.data)
.then((res) => {
commit(types.ADD_NEW_DRAFT, res);
+
return res;
})
.catch(() => {
diff --git a/app/assets/javascripts/behaviors/components/json_table.vue b/app/assets/javascripts/behaviors/components/json_table.vue
new file mode 100644
index 00000000000..bb38d80c1b5
--- /dev/null
+++ b/app/assets/javascripts/behaviors/components/json_table.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlTable, GlFormInput } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlTable,
+ GlFormInput,
+ },
+ props: {
+ fields: {
+ type: Array,
+ required: true,
+ },
+ items: {
+ type: Array,
+ required: true,
+ },
+ hasFilter: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ caption: {
+ type: String,
+ required: false,
+ default: __('Generated with JSON data'),
+ },
+ },
+ data() {
+ return {
+ filterInput: '',
+ };
+ },
+ computed: {
+ cleanedFields() {
+ return this.fields.map((field) => {
+ if (typeof field === 'string') {
+ return field;
+ }
+ return {
+ key: field.key,
+ label: field.label,
+ sortable: field.sortable || false,
+ };
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-inline-block">
+ <gl-form-input
+ v-if="hasFilter"
+ v-model="filterInput"
+ :placeholder="__('Type to search')"
+ class="gl-mb-2!"
+ />
+ <gl-table
+ :fields="cleanedFields"
+ :items="items"
+ :filter="filterInput"
+ show-empty
+ class="gl-mt-0!"
+ >
+ <template v-if="caption" #table-caption>
+ <small>{{ caption }}</small>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js
index 967c0a120cd..afab266b645 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/strike.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js
@@ -2,16 +2,35 @@
export default () => ({
name: 'strike',
schema: {
- parseDOM: [
- {
- tag: 'del',
+ attrs: {
+ strike: {
+ default: false,
+ },
+ inapplicable: {
+ default: false,
},
+ },
+ parseDOM: [
+ { tag: 'li.inapplicable > s', attrs: { inapplicable: true } },
+ { tag: 'li.inapplicable > p:first-of-type > s', attrs: { inapplicable: true } },
+ { tag: 's', attrs: { strike: true } },
+ { tag: 'del' },
],
toDOM: () => ['s', 0],
},
toMarkdown: {
- open: '~~',
- close: '~~',
+ open(_, mark) {
+ if (mark.attrs.strike) {
+ return '<s>';
+ }
+ return mark.attrs.inapplicable ? '' : '~~';
+ },
+ close(_, mark) {
+ if (mark.attrs.strike) {
+ return '</s>';
+ }
+ return mark.attrs.inapplicable ? '' : '~~';
+ },
mixable: true,
expelEnclosingWhitespace: true,
},
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
index 0ff59779e7d..b862d111de7 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
@@ -37,7 +37,7 @@ export default () => ({
attrs: { lang: 'math' },
},
// Matches HTML generated by Banzai::Filter::MermaidFilter,
- // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js
+ // after being transformed by app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
{
tag: 'svg.mermaid',
preserveWhitespace: 'full',
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
index 10ffce9b1b8..095634340c1 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
@@ -5,8 +5,8 @@ export default () => ({
name: 'task_list_item',
schema: {
attrs: {
- done: {
- default: false,
+ state: {
+ default: null,
},
},
defining: true,
@@ -18,21 +18,53 @@ export default () => ({
tag: 'li.task-list-item',
getAttrs: (el) => {
const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox');
- return { done: checkbox && checkbox.checked };
+ if (checkbox?.matches('[data-inapplicable]')) {
+ return { state: 'inapplicable' };
+ } else if (checkbox?.checked) {
+ return { state: 'done' };
+ }
+
+ return {};
},
},
],
toDOM(node) {
return [
'li',
- { class: 'task-list-item' },
- ['input', { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }],
+ {
+ class: () => {
+ if (node.attrs.state === 'inapplicable') {
+ return 'task-list-item inapplicable';
+ }
+
+ return 'task-list-item';
+ },
+ },
+ [
+ 'input',
+ {
+ type: 'checkbox',
+ class: 'task-list-item-checkbox',
+ checked: node.attrs.state === 'done',
+ 'data-inapplicable': node.attrs.state === 'inapplicable',
+ },
+ ],
['div', { class: 'todo-content' }, 0],
];
},
},
toMarkdown(state, node) {
- state.write(`[${node.attrs.done ? 'x' : ' '}] `);
+ switch (node.attrs.state) {
+ case 'done':
+ state.write('[x] ');
+ break;
+ case 'inapplicable':
+ state.write('[~] ');
+ break;
+ default:
+ state.write('[ ] ');
+ break;
+ }
state.renderContent(node);
},
});
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index c9ae3706383..ee5c0fe5ef3 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -5,6 +5,7 @@ import { renderKroki } from './render_kroki';
import renderMath from './render_math';
import renderSandboxedMermaid from './render_sandboxed_mermaid';
import renderMetrics from './render_metrics';
+import { renderJSONTable } from './render_json_table';
// Render GitLab flavoured Markdown
//
@@ -15,6 +16,9 @@ $.fn.renderGFM = function renderGFM() {
renderKroki(this.find('.js-render-kroki[hidden]').get());
renderMath(this.find('.js-render-math'));
renderSandboxedMermaid(this.find('.js-render-mermaid'));
+ renderJSONTable(
+ Array.from(this.find('[lang="json"][data-lang-params="table"]').get()).map((e) => e.parentNode),
+ );
highlightCurrentUser(this.find('.gfm-project_member').get());
diff --git a/app/assets/javascripts/behaviors/markdown/render_json_table.js b/app/assets/javascripts/behaviors/markdown/render_json_table.js
new file mode 100644
index 00000000000..4d9ac1d266b
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_json_table.js
@@ -0,0 +1,70 @@
+import { memoize } from 'lodash';
+import Vue from 'vue';
+import { __ } from '~/locale';
+import { createAlert } from '~/flash';
+
+// Async import component since we might not need it...
+const JSONTable = memoize(() =>
+ import(/* webpackChunkName: 'gfm_json_table' */ '../components/json_table.vue'),
+);
+
+const mountParseError = (element) => {
+ // Let the error container be a sibling to the element.
+ // Otherwise, dismissing the alert causes the copy button to be misplaced.
+ const container = document.createElement('div');
+ element.insertAdjacentElement('beforebegin', container);
+
+ // We need to create a child element with a known selector for `createAlert`
+ const el = document.createElement('div');
+ el.classList.add('js-json-table-error');
+
+ container.insertAdjacentElement('afterbegin', el);
+
+ return createAlert({
+ message: __('Unable to parse JSON'),
+ variant: 'warning',
+ parent: container,
+ containerSelector: '.js-json-table-error',
+ });
+};
+
+const mountJSONTableVueComponent = (userData, element) => {
+ const { fields = [], items = [], filter, caption } = userData;
+
+ const container = document.createElement('div');
+ element.innerHTML = '';
+ element.appendChild(container);
+
+ return new Vue({
+ el: container,
+ render(h) {
+ return h(JSONTable, {
+ props: {
+ fields,
+ items,
+ hasFilter: filter,
+ caption,
+ },
+ });
+ },
+ });
+};
+
+const renderTable = (element) => {
+ // Avoid rendering multiple times
+ if (!element || element.classList.contains('js-json-table')) {
+ return;
+ }
+
+ element.classList.add('js-json-table');
+
+ try {
+ mountJSONTableVueComponent(JSON.parse(element.textContent), element);
+ } catch (e) {
+ mountParseError(element);
+ }
+};
+
+export const renderJSONTable = (elements) => {
+ elements.forEach(renderTable);
+};
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
deleted file mode 100644
index 2df0f7387fb..00000000000
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ /dev/null
@@ -1,231 +0,0 @@
-import $ from 'jquery';
-import { once, countBy } from 'lodash';
-import createFlash from '~/flash';
-import { darkModeEnabled } from '~/lib/utils/color_utils';
-import { __, sprintf } from '~/locale';
-import { unrestrictedPages } from './constants';
-
-// Renders diagrams and flowcharts from text using Mermaid in any element with the
-// `js-render-mermaid` class.
-//
-// Example markup:
-//
-// <pre class="js-render-mermaid">
-// graph TD;
-// A-- > B;
-// A-- > C;
-// B-- > D;
-// C-- > D;
-// </pre>
-//
-
-// This is an arbitrary number; Can be iterated upon when suitable.
-const MAX_CHAR_LIMIT = 2000;
-// Max # of mermaid blocks that can be rendered in a page.
-const MAX_MERMAID_BLOCK_LIMIT = 50;
-// Max # of `&` allowed in Chaining of links syntax
-const MAX_CHAINING_OF_LINKS_LIMIT = 30;
-// Keep a map of mermaid blocks we've already rendered.
-const elsProcessingMap = new WeakMap();
-let renderedMermaidBlocks = 0;
-
-let mermaidModule = {};
-
-export function initMermaid(mermaid) {
- let theme = 'neutral';
-
- if (darkModeEnabled()) {
- theme = 'dark';
- }
-
- mermaid.initialize({
- // mermaid core options
- mermaid: {
- startOnLoad: false,
- },
- // mermaidAPI options
- theme,
- flowchart: {
- useMaxWidth: true,
- htmlLabels: true,
- },
- secure: ['secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'htmlLabels'],
- securityLevel: 'strict',
- });
-
- return mermaid;
-}
-
-function importMermaidModule() {
- return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
- .then(({ default: mermaid }) => {
- mermaidModule = initMermaid(mermaid);
- })
- .catch((err) => {
- createFlash({
- message: sprintf(__("Can't load mermaid module: %{err}"), { err }),
- });
- // eslint-disable-next-line no-console
- console.error(err);
- });
-}
-
-function shouldLazyLoadMermaidBlock(source) {
- /**
- * If source contains `&`, which means that it might
- * contain Chaining of links a new syntax in Mermaid.
- */
- if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) {
- return true;
- }
-
- return false;
-}
-
-function fixElementSource(el) {
- // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly.
- const source = el.textContent.replace(/<br\s*\/>/g, '<br>');
-
- // Remove any extra spans added by the backend syntax highlighting.
- Object.assign(el, { textContent: source });
-
- return { source };
-}
-
-function renderMermaidEl(el) {
- mermaidModule.init(undefined, el, (id) => {
- const source = el.textContent;
- const svg = document.getElementById(id);
-
- // As of https://github.com/knsv/mermaid/commit/57b780a0d,
- // Mermaid will make two init callbacks:one to initialize the
- // flow charts, and another to initialize the Gannt charts.
- // Guard against an error caused by double initialization.
- if (svg.classList.contains('mermaid')) {
- return;
- }
-
- svg.classList.add('mermaid');
-
- // pre > code > svg
- svg.closest('pre').replaceWith(svg);
-
- // We need to add the original source into the DOM to allow Copy-as-GFM
- // to access it.
- const sourceEl = document.createElement('text');
- sourceEl.classList.add('source');
- sourceEl.setAttribute('display', 'none');
- sourceEl.textContent = source;
-
- svg.appendChild(sourceEl);
- });
-}
-
-function renderMermaids($els) {
- if (!$els.length) return;
-
- const pageName = document.querySelector('body').dataset.page;
-
- // A diagram may have been truncated in search results which will cause errors, so abort the render.
- if (pageName === 'search:show') return;
-
- importMermaidModule()
- .then(() => {
- let renderedChars = 0;
-
- $els.each((i, el) => {
- // Skipping all the elements which we've already queued in requestIdleCallback
- if (elsProcessingMap.has(el)) {
- return;
- }
-
- const { source } = fixElementSource(el);
- /**
- * Restrict the rendering to a certain amount of character
- * and mermaid blocks to prevent mermaidjs from hanging
- * up the entire thread and causing a DoS.
- */
- if (
- !unrestrictedPages.includes(pageName) &&
- ((source && source.length > MAX_CHAR_LIMIT) ||
- renderedChars > MAX_CHAR_LIMIT ||
- renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT ||
- shouldLazyLoadMermaidBlock(source))
- ) {
- const html = `
- <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert">
- <div>
- <div class="display-flex">
- <div>${__(
- 'Warning: Displaying this diagram might cause performance issues on this page.',
- )}</div>
- <div class="gl-alert-actions">
- <button class="js-lazy-render-mermaid btn gl-alert-action btn-confirm btn-md gl-button">Display</button>
- </div>
- </div>
- <button type="button" class="close" data-dismiss="alert" aria-label="Close">
- <span aria-hidden="true">&times;</span>
- </button>
- </div>
- </div>
- `;
-
- const $parent = $(el).parent();
-
- if (!$parent.hasClass('lazy-alert-shown')) {
- $parent.after(html);
- $parent.addClass('lazy-alert-shown');
- }
-
- return;
- }
-
- renderedChars += source.length;
- renderedMermaidBlocks += 1;
-
- const requestId = window.requestIdleCallback(() => {
- renderMermaidEl(el);
- });
-
- elsProcessingMap.set(el, requestId);
- });
- })
- .catch((err) => {
- createFlash({
- message: sprintf(__('Encountered an error while rendering: %{err}'), { err }),
- });
- // eslint-disable-next-line no-console
- console.error(err);
- });
-}
-
-const hookLazyRenderMermaidEvent = once(() => {
- $(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() {
- const parent = $(this).closest('.js-lazy-render-mermaid-container');
- const pre = parent.prev();
-
- const el = pre.find('.js-render-mermaid');
-
- parent.remove();
-
- renderMermaidEl(el);
- });
-});
-
-export default function renderMermaid($els) {
- if (!$els.length) return;
-
- const visibleMermaids = $els.filter(function filter() {
- return $(this).closest('details').length === 0 && $(this).is(':visible');
- });
-
- renderMermaids(visibleMermaids);
-
- $els.closest('details').one('toggle', function toggle() {
- if (this.open) {
- renderMermaids($(this).find('.js-render-mermaid'));
- }
- });
-
- hookLazyRenderMermaidEvent();
-}
diff --git a/app/assets/javascripts/blob/blob_links_tracking.js b/app/assets/javascripts/blob/blob_links_tracking.js
new file mode 100644
index 00000000000..9a49aa8b0fc
--- /dev/null
+++ b/app/assets/javascripts/blob/blob_links_tracking.js
@@ -0,0 +1,25 @@
+import Tracking from '~/tracking';
+
+function addBlobLinksTracking(containerSelector, eventsToTrack) {
+ const containerEl = document.querySelector(containerSelector);
+
+ if (!containerEl) {
+ return;
+ }
+
+ const eventName = 'click_link';
+ const label = 'file_line_action';
+
+ containerEl.addEventListener('click', (e) => {
+ eventsToTrack.forEach((event) => {
+ if (e.target.matches(event.selector)) {
+ Tracking.event(undefined, eventName, {
+ label,
+ property: event.property,
+ });
+ }
+ });
+ });
+}
+
+export default addBlobLinksTracking;
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 9fca9860282..8062460f052 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -38,10 +38,8 @@ export function formatIssue(issue) {
export function formatListIssues(listIssues) {
const boardItems = {};
- let listItemsCount;
const listData = listIssues.nodes.reduce((map, list) => {
- listItemsCount = list.issuesCount;
let sortedIssues = list.issues.edges.map((issueNode) => ({
...issueNode.node,
}));
@@ -67,7 +65,7 @@ export function formatListIssues(listIssues) {
};
}, {});
- return { listData, boardItems, listItemsCount };
+ return { listData, boardItems };
}
export function formatListsPageInfo(lists) {
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
index 10c7a3db2d3..c4a2f83ab50 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -19,6 +19,7 @@ export default {
scope: __('Scope'),
scopeDescription: __('Issues must match this scope to appear in this list.'),
selected: __('Selected'),
+ requiredFieldFeedback: __('This field is required.'),
},
components: {
GlButton,
@@ -55,12 +56,21 @@ export default {
data() {
return {
searchValue: '',
+ selectedIdValid: true,
};
},
+ computed: {
+ toggleClassList() {
+ return `gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate ${
+ this.selectedIdValid ? '' : 'gl-inset-border-1-red-400!'
+ }`;
+ },
+ },
watch: {
selectedId(val) {
if (val) {
this.$refs.dropdown.hide(true);
+ this.selectedIdValid = true;
}
},
},
@@ -74,6 +84,13 @@ export default {
this.$emit('filter-items', '');
this.$emit('hide');
},
+ onSubmit() {
+ if (!this.selectedId) {
+ this.selectedIdValid = false;
+ } else {
+ this.$emit('add-list');
+ }
+ },
},
};
</script>
@@ -103,11 +120,16 @@ export default {
<slot name="select-list-type"></slot>
- <gl-form-group class="gl-px-5 lg-mb-3 gl-max-w-full" :label="searchLabel">
+ <gl-form-group
+ class="gl-px-5 lg-mb-3 gl-max-w-full"
+ :label="searchLabel"
+ :state="selectedIdValid"
+ :invalid-feedback="$options.i18n.requiredFieldFeedback"
+ >
<gl-dropdown
ref="dropdown"
class="gl-mb-3 gl-max-w-full"
- toggle-class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate"
+ :toggle-class="toggleClassList"
boundary="viewport"
@shown="setFocus"
@hide="onHide"
@@ -147,10 +169,9 @@ export default {
<div class="gl-display-flex gl-mb-4">
<gl-button
data-testid="addNewColumnButton"
- :disabled="!selectedId"
variant="confirm"
class="gl-mr-3 gl-ml-4"
- @click="$emit('add-list')"
+ @click="onSubmit"
>{{ $options.i18n.add }}</gl-button
>
<gl-button data-testid="cancelAddNewColumn" @click="setAddColumnFormVisibility(false)">{{
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index a632f5ae0ed..8dc521317cd 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -147,6 +147,9 @@ export default {
showReferencePath() {
return !this.isProjectBoard && this.itemReferencePath;
},
+ avatarSize() {
+ return { default: 16, lg: 24 };
+ },
},
methods: {
...mapActions(['performSearch', 'setError']),
@@ -359,16 +362,17 @@ export default {
</span>
</span>
</div>
- <div class="board-card-assignee gl-display-flex">
+ <div class="board-card-assignee gl-display-flex gl-gap-3">
<user-avatar-link
v-for="assignee in cappedAssignees"
:key="assignee.id"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-src="avatarUrl(assignee)"
- :img-size="24"
+ :img-size="avatarSize"
class="js-no-trigger"
tooltip-placement="bottom"
+ :enforce-gl-avatar="true"
>
<span class="js-assignee-tooltip">
<span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 0320b4d925e..d25169b5b9d 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -138,9 +138,8 @@ export default {
<template>
<mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append>
<gl-drawer
- v-if="showSidebar"
v-bind="$attrs"
- :open="isSidebarOpen"
+ :open="showSidebar"
class="boards-sidebar gl-absolute"
variant="sidebar"
@close="handleClose"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index a65269de743..e3012f5b36d 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -117,7 +117,7 @@ export default {
return 'issues';
},
itemsTooltipLabel() {
- return n__(`%d issue`, `%d issues`, this.boardLists?.issuesCount);
+ return n__(`%d issue`, `%d issues`, this.boardList?.issuesCount);
},
chevronTooltip() {
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index c559e4cdbd3..e93edad675c 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -58,7 +58,7 @@ export default {
return ListTypeTitles[ListType.label];
},
showSidebar() {
- return this.sidebarType === LIST;
+ return this.sidebarType === LIST && this.isSidebarOpen;
},
},
created() {
@@ -87,10 +87,9 @@ export default {
<template>
<mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append>
<gl-drawer
- v-if="showSidebar"
v-bind="$attrs"
class="js-board-settings-sidebar gl-absolute"
- :open="isSidebarOpen"
+ :open="showSidebar"
variant="sidebar"
@close="unsetActiveId"
>
diff --git a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
index 4dc245660a4..01fab571733 100644
--- a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
@@ -1,9 +1,7 @@
query BoardBlockingIssues($id: IssueID!) {
issuable: issue(id: $id) {
- __typename
id
blockingIssuables: blockedByIssues {
- __typename
nodes {
id
iid
diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql
index aec674eb006..252e8c1ab06 100644
--- a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql
@@ -2,10 +2,8 @@
query GroupBoardMembers($fullPath: ID!, $search: String) {
workspace: group(fullPath: $fullPath) {
- __typename
id
assignees: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) {
- __typename
nodes {
id
user {
diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
index bf5329c4a8d..ae6394f9a2f 100644
--- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
@@ -17,7 +17,6 @@ query BoardListsEE(
lists(id: $id, issueFilters: $filters) {
nodes {
id
- issuesCount
listType
issues(first: $first, filters: $filters, after: $after) {
edges {
@@ -41,7 +40,6 @@ query BoardListsEE(
lists(id: $id, issueFilters: $filters) {
nodes {
id
- issuesCount
listType
issues(first: $first, filters: $filters, after: $after) {
edges {
diff --git a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql
index 45bec5e574b..5279680b03c 100644
--- a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql
@@ -2,10 +2,8 @@
query ProjectBoardMembers($fullPath: ID!, $search: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
assignees: projectMembers(search: $search) {
- __typename
nodes {
id
user {
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 04e7d3643e7..26a98a645b3 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -11,7 +11,7 @@ const updateListItemsCount = ({ state, listId, value }) => {
if (state.issuableType === issuableTypes.epic) {
Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value });
} else {
- Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + value });
+ Vue.set(state.boardLists, listId, { ...list });
}
};
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index 5e5d799d627..fea4b56153f 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -79,7 +79,7 @@ export default {
:title="$options.i18n.copyTrigger"
css-class="gl-border-none gl-py-0 gl-px-2"
/>
- <div class="label-container">
+ <div class="gl-display-inline-block gl-ml-3">
<gl-badge v-if="!item.canAccessProject" variant="danger">
<span
v-gl-tooltip.viewport
@@ -95,7 +95,7 @@ export default {
:title="item.description"
truncate-target="child"
placement="top"
- class="trigger-description gl-display-flex"
+ class="gl-max-w-15 gl-display-flex"
>
<div class="gl-flex-grow-1 gl-text-truncate">{{ item.description }}</div>
</tooltip-on-truncate>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
new file mode 100644
index 00000000000..83bad9eb518
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
@@ -0,0 +1,101 @@
+<script>
+import createFlash from '~/flash';
+import getAdminVariables from '../graphql/queries/variables.query.graphql';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+ genericMutationErrorText,
+ variableFetchErrorText,
+} from '../constants';
+import addAdminVariable from '../graphql/mutations/admin_add_variable.mutation.graphql';
+import deleteAdminVariable from '../graphql/mutations/admin_delete_variable.mutation.graphql';
+import updateAdminVariable from '../graphql/mutations/admin_update_variable.mutation.graphql';
+import ciVariableSettings from './ci_variable_settings.vue';
+
+export default {
+ components: {
+ ciVariableSettings,
+ },
+ inject: ['endpoint'],
+ data() {
+ return {
+ adminVariables: [],
+ isInitialLoading: true,
+ };
+ },
+ apollo: {
+ adminVariables: {
+ query: getAdminVariables,
+ update(data) {
+ return data?.ciVariables?.nodes || [];
+ },
+ error() {
+ createFlash({ message: variableFetchErrorText });
+ },
+ watchLoading(flag) {
+ if (!flag) {
+ this.isInitialLoading = false;
+ }
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.adminVariables.loading && this.isInitialLoading;
+ },
+ },
+ methods: {
+ addVariable(variable) {
+ this.variableMutation(ADD_MUTATION_ACTION, variable);
+ },
+ deleteVariable(variable) {
+ this.variableMutation(DELETE_MUTATION_ACTION, variable);
+ },
+ updateVariable(variable) {
+ this.variableMutation(UPDATE_MUTATION_ACTION, variable);
+ },
+ async variableMutation(mutationAction, variable) {
+ try {
+ const currentMutation = this.$options.mutationData[mutationAction];
+ const { data } = await this.$apollo.mutate({
+ mutation: currentMutation.action,
+ variables: {
+ endpoint: this.endpoint,
+ variable,
+ },
+ });
+
+ const { errors } = data[currentMutation.name];
+
+ if (errors.length > 0) {
+ createFlash({ message: errors[0] });
+ } else {
+ // The writing to cache for admin variable is not working
+ // because there is no ID in the cache at the top level.
+ // We therefore need to manually refetch.
+ this.$apollo.queries.adminVariables.refetch();
+ }
+ } catch {
+ createFlash({ message: genericMutationErrorText });
+ }
+ },
+ },
+ mutationData: {
+ [ADD_MUTATION_ACTION]: { action: addAdminVariable, name: 'addAdminVariable' },
+ [UPDATE_MUTATION_ACTION]: { action: updateAdminVariable, name: 'updateAdminVariable' },
+ [DELETE_MUTATION_ACTION]: { action: deleteAdminVariable, name: 'deleteAdminVariable' },
+ },
+};
+</script>
+
+<template>
+ <ci-variable-settings
+ :are-scoped-variables-available="false"
+ :is-loading="isLoading"
+ :variables="adminVariables"
+ @add-variable="addVariable"
+ @delete-variable="deleteVariable"
+ @update-variable="updateVariable"
+ />
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
index ecb39f214ec..c9002edc1ab 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
+import { convertEnvironmentScope } from '../utils';
export default {
name: 'CiEnvironmentsDropdown',
@@ -12,7 +12,11 @@ export default {
GlSearchBoxByType,
},
props: {
- value: {
+ environments: {
+ type: Array,
+ required: true,
+ },
+ selectedEnvironmentScope: {
type: String,
required: false,
default: '',
@@ -24,31 +28,36 @@ export default {
};
},
computed: {
- ...mapGetters(['joinedEnvironments']),
composedCreateButtonLabel() {
return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm });
},
+ filteredEnvironments() {
+ const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.environments.filter((environment) => {
+ return environment.toLowerCase().includes(lowerCasedSearchTerm);
+ });
+ },
shouldRenderCreateButton() {
- return this.searchTerm && !this.joinedEnvironments.includes(this.searchTerm);
+ return this.searchTerm && !this.environments.includes(this.searchTerm);
},
- filteredResults() {
- const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
- return this.joinedEnvironments.filter((resultString) =>
- resultString.toLowerCase().includes(lowerCasedSearchTerm),
- );
+ environmentScopeLabel() {
+ return convertEnvironmentScope(this.selectedEnvironmentScope);
},
},
methods: {
selectEnvironment(selected) {
- this.$emit('selectEnvironment', selected);
- this.searchTerm = '';
+ this.$emit('select-environment', selected);
+ this.clearSearch();
},
- createClicked() {
- this.$emit('createClicked', this.searchTerm);
- this.searchTerm = '';
+ convertEnvironmentScopeValue(scope) {
+ return convertEnvironmentScope(scope);
+ },
+ createEnvironmentScope() {
+ this.$emit('create-environment-scope', this.searchTerm);
+ this.selectEnvironment(this.searchTerm);
},
isSelected(env) {
- return this.value === env;
+ return this.selectedEnvironmentScope === env;
},
clearSearch() {
this.searchTerm = '';
@@ -57,23 +66,23 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="value" @show="clearSearch">
+ <gl-dropdown :text="environmentScopeLabel" @show="clearSearch">
<gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" />
<gl-dropdown-item
- v-for="environment in filteredResults"
+ v-for="environment in filteredEnvironments"
:key="environment"
:is-checked="isSelected(environment)"
is-check-item
@click="selectEnvironment(environment)"
>
- {{ environment }}
+ {{ convertEnvironmentScopeValue(environment) }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{
+ <gl-dropdown-item v-if="!filteredEnvironments.length" ref="noMatchingResults">{{
__('No matching results')
}}</gl-dropdown-item>
<template v-if="shouldRenderCreateButton">
<gl-dropdown-divider />
- <gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked">
+ <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope">
{{ composedCreateButtonLabel }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
new file mode 100644
index 00000000000..3af83ffa8ed
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
@@ -0,0 +1,104 @@
+<script>
+import createFlash from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ GRAPHQL_GROUP_TYPE,
+ UPDATE_MUTATION_ACTION,
+ genericMutationErrorText,
+ variableFetchErrorText,
+} from '../constants';
+import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql';
+import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql';
+import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql';
+import ciVariableSettings from './ci_variable_settings.vue';
+
+export default {
+ components: {
+ ciVariableSettings,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['endpoint', 'groupPath', 'groupId'],
+ data() {
+ return {
+ groupVariables: [],
+ };
+ },
+ apollo: {
+ groupVariables: {
+ query: getGroupVariables,
+ variables() {
+ return {
+ fullPath: this.groupPath,
+ };
+ },
+ update(data) {
+ return data?.group?.ciVariables?.nodes || [];
+ },
+ error() {
+ createFlash({ message: variableFetchErrorText });
+ },
+ },
+ },
+ computed: {
+ areScopedVariablesAvailable() {
+ return this.glFeatures.groupScopedCiVariables;
+ },
+ isLoading() {
+ return this.$apollo.queries.groupVariables.loading;
+ },
+ },
+ methods: {
+ addVariable(variable) {
+ this.variableMutation(ADD_MUTATION_ACTION, variable);
+ },
+ deleteVariable(variable) {
+ this.variableMutation(DELETE_MUTATION_ACTION, variable);
+ },
+ updateVariable(variable) {
+ this.variableMutation(UPDATE_MUTATION_ACTION, variable);
+ },
+ async variableMutation(mutationAction, variable) {
+ try {
+ const currentMutation = this.$options.mutationData[mutationAction];
+ const { data } = await this.$apollo.mutate({
+ mutation: currentMutation.action,
+ variables: {
+ endpoint: this.endpoint,
+ fullPath: this.groupPath,
+ groupId: convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId),
+ variable,
+ },
+ });
+
+ const { errors } = data[currentMutation.name];
+
+ if (errors.length > 0) {
+ createFlash({ message: errors[0] });
+ }
+ } catch {
+ createFlash({ message: genericMutationErrorText });
+ }
+ },
+ },
+ mutationData: {
+ [ADD_MUTATION_ACTION]: { action: addGroupVariable, name: 'addGroupVariable' },
+ [UPDATE_MUTATION_ACTION]: { action: updateGroupVariable, name: 'updateGroupVariable' },
+ [DELETE_MUTATION_ACTION]: { action: deleteGroupVariable, name: 'deleteGroupVariable' },
+ },
+};
+</script>
+
+<template>
+ <ci-variable-settings
+ :are-scoped-variables-available="areScopedVariablesAvailable"
+ :is-loading="isLoading"
+ :variables="groupVariables"
+ @add-variable="addVariable"
+ @delete-variable="deleteVariable"
+ @update-variable="updateVariable"
+ />
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 557a8d6b5ba..5ba63de8c96 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -14,22 +14,26 @@ import {
GlModal,
GlSprintf,
} from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import Tracking from '~/tracking';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { mapComputed } from '~/vuex_shared/bindings';
+
import {
+ allEnvironments,
AWS_TOKEN_CONSTANTS,
ADD_CI_VARIABLE_MODAL_ID,
AWS_TIP_DISMISSED_COOKIE_NAME,
AWS_TIP_MESSAGE,
CONTAINS_VARIABLE_REFERENCE_MESSAGE,
+ defaultVariableState,
ENVIRONMENT_SCOPE_LINK_TITLE,
EVENT_LABEL,
EVENT_ACTION,
+ EDIT_VARIABLE_ACTION,
+ VARIABLE_ACTIONS,
+ variableOptions,
} from '../constants';
+import { createJoinedEnvironments } from '../utils';
import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
@@ -58,66 +62,84 @@ export default {
GlModal,
GlSprintf,
},
- mixins: [glFeatureFlagsMixin(), trackingMixin],
+ mixins: [trackingMixin],
+ inject: [
+ 'awsLogoSvgPath',
+ 'awsTipCommandsLink',
+ 'awsTipDeployLink',
+ 'awsTipLearnLink',
+ 'containsVariableReferenceLink',
+ 'environmentScopeLink',
+ 'isProtectedByDefault',
+ 'maskedEnvironmentVariablesLink',
+ 'maskableRegex',
+ 'protectedEnvironmentVariablesLink',
+ ],
+ props: {
+ areScopedVariablesAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ environments: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ mode: {
+ type: String,
+ required: true,
+ validator(val) {
+ return VARIABLE_ACTIONS.includes(val);
+ },
+ },
+ selectedVariable: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ variables: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
data() {
return {
+ newEnvironments: [],
isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
+ typeOptions: variableOptions,
validationErrorEventProperty: '',
+ variable: { ...defaultVariableState, ...this.selectedVariable },
};
},
computed: {
- ...mapState([
- 'projectId',
- 'environments',
- 'typeOptions',
- 'variable',
- 'variableBeingEdited',
- 'isGroup',
- 'maskableRegex',
- 'selectedEnvironment',
- 'isProtectedByDefault',
- 'awsLogoSvgPath',
- 'awsTipDeployLink',
- 'awsTipCommandsLink',
- 'awsTipLearnLink',
- 'containsVariableReferenceLink',
- 'protectedEnvironmentVariablesLink',
- 'maskedEnvironmentVariablesLink',
- 'environmentScopeLink',
- ]),
- ...mapComputed(
- [
- { key: 'key', updateFn: 'updateVariableKey' },
- { key: 'secret_value', updateFn: 'updateVariableValue' },
- { key: 'variable_type', updateFn: 'updateVariableType' },
- { key: 'environment_scope', updateFn: 'setEnvironmentScope' },
- { key: 'protected_variable', updateFn: 'updateVariableProtected' },
- { key: 'masked', updateFn: 'updateVariableMasked' },
- ],
- false,
- 'variable',
- ),
- isTipVisible() {
- return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
- },
- canSubmit() {
- return (
- this.variableValidationState &&
- this.variable.key !== '' &&
- this.variable.secret_value !== ''
- );
- },
canMask() {
const regex = RegExp(this.maskableRegex);
- return regex.test(this.variable.secret_value);
+ return regex.test(this.variable.value);
+ },
+ canSubmit() {
+ return this.variableValidationState && this.variable.key !== '' && this.variable.value !== '';
},
containsVariableReference() {
const regex = /\$/;
- return regex.test(this.variable.secret_value);
+ return regex.test(this.variable.value);
},
displayMaskedError() {
return !this.canMask && this.variable.masked;
},
+ isEditing() {
+ return this.mode === EDIT_VARIABLE_ACTION;
+ },
+ isTipVisible() {
+ return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
+ },
+ joinedEnvironments() {
+ return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments);
+ },
+ maskedFeedback() {
+ return this.displayMaskedError ? __('This variable can not be masked.') : '';
+ },
maskedState() {
if (this.displayMaskedError) {
return false;
@@ -125,10 +147,7 @@ export default {
return true;
},
modalActionText() {
- return this.variableBeingEdited ? __('Update variable') : __('Add variable');
- },
- maskedFeedback() {
- return this.displayMaskedError ? __('This variable can not be masked.') : '';
+ return this.isEditing ? __('Update variable') : __('Add variable');
},
tokenValidationFeedback() {
const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage;
@@ -141,19 +160,16 @@ export default {
const validator = this.$options.tokens?.[this.variable.key]?.validation;
if (validator) {
- return validator(this.variable.secret_value);
+ return validator(this.variable.value);
}
return true;
},
- scopedVariablesAvailable() {
- return !this.isGroup || this.glFeatures.groupScopedCiVariables;
- },
variableValidationFeedback() {
return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
},
variableValidationState() {
- return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState);
+ return this.variable.value === '' || (this.tokenValidationState && this.maskedState);
},
},
watch: {
@@ -165,19 +181,18 @@ export default {
},
},
methods: {
- ...mapActions([
- 'addVariable',
- 'updateVariable',
- 'resetEditing',
- 'displayInputValue',
- 'clearModal',
- 'deleteVariable',
- 'setEnvironmentScope',
- 'addWildCardScope',
- 'resetSelectedEnvironment',
- 'setSelectedEnvironment',
- 'setVariableProtected',
- ]),
+ addVariable() {
+ this.$emit('add-variable', this.variable);
+ },
+ createEnvironmentScope(env) {
+ this.newEnvironments.push(env);
+ },
+ deleteVariable() {
+ this.$emit('delete-variable', this.variable);
+ },
+ updateVariable() {
+ this.$emit('update-variable', this.variable);
+ },
dismissTip() {
setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 });
this.isTipDismissed = true;
@@ -190,16 +205,22 @@ export default {
this.$refs.modal.hide();
},
resetModalHandler() {
- if (this.variableBeingEdited) {
- this.resetEditing();
- }
-
- this.clearModal();
- this.resetSelectedEnvironment();
+ this.resetVariableData();
this.resetValidationErrorEvents();
+
+ this.$emit('hideModal');
+ },
+ resetVariableData() {
+ this.variable = { ...defaultVariableState };
+ },
+ setEnvironmentScope(scope) {
+ this.variable = { ...this.variable, environmentScope: scope };
+ },
+ setVariableProtected() {
+ this.variable = { ...this.variable, protected: true };
},
updateOrAddVariable() {
- if (this.variableBeingEdited) {
+ if (this.isEditing) {
this.updateVariable();
} else {
this.addVariable();
@@ -207,7 +228,7 @@ export default {
this.hideModal();
},
setVariableProtectedByDefault() {
- if (this.isProtectedByDefault && !this.variableBeingEdited) {
+ if (this.isProtectedByDefault && !this.isEditing) {
this.setVariableProtected();
}
},
@@ -220,11 +241,11 @@ export default {
},
getTrackingErrorProperty() {
let property;
- if (this.variable.secret_value?.length && !property) {
+ if (this.variable.value?.length && !property) {
if (this.displayMaskedError && this.maskableRegex?.length) {
const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, '');
const regex = new RegExp(supportedChars, 'g');
- property = this.variable.secret_value.replace(regex, '');
+ property = this.variable.value.replace(regex, '');
}
if (this.containsVariableReference) {
property = '$';
@@ -237,6 +258,7 @@ export default {
this.validationErrorEventProperty = '';
},
},
+ defaultScope: allEnvironments.text,
};
</script>
@@ -252,7 +274,7 @@ export default {
>
<form>
<gl-form-combobox
- v-model="key"
+ v-model="variable.key"
:token-list="$options.tokenList"
:label-text="__('Key')"
data-qa-selector="ci_variable_key_field"
@@ -267,7 +289,7 @@ export default {
<gl-form-textarea
id="ci-variable-value"
ref="valueField"
- v-model="secret_value"
+ v-model="variable.value"
:state="variableValidationState"
rows="3"
max-rows="6"
@@ -278,7 +300,11 @@ export default {
<div class="d-flex">
<gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5">
- <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" />
+ <gl-form-select
+ id="ci-variable-type"
+ v-model="variable.variableType"
+ :options="typeOptions"
+ />
</gl-form-group>
<gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope">
@@ -294,22 +320,24 @@ export default {
</gl-link>
</template>
<ci-environments-dropdown
- v-if="scopedVariablesAvailable"
- class="w-100"
- :value="environment_scope"
- @selectEnvironment="setEnvironmentScope"
- @createClicked="addWildCardScope"
+ v-if="areScopedVariablesAvailable"
+ class="gl-w-full"
+ :selected-environment-scope="variable.environmentScope"
+ :environments="joinedEnvironments"
+ @select-environment="setEnvironmentScope"
+ @create-environment-scope="createEnvironmentScope"
/>
- <gl-form-input v-else v-model="environment_scope" class="w-100" readonly />
+ <gl-form-input v-else :value="$options.defaultScope" class="gl-w-full" readonly />
</gl-form-group>
</div>
<gl-form-group :label="__('Flags')" label-for="ci-variable-flags">
<gl-form-checkbox
- v-model="protected_variable"
- class="mb-0"
+ v-model="variable.protected"
+ class="gl-mb-0"
data-testid="ci-variable-protected-checkbox"
+ :data-is-protected-checked="variable.protected"
>
{{ __('Protect variable') }}
<gl-link target="_blank" :href="protectedEnvironmentVariablesLink">
@@ -322,7 +350,7 @@ export default {
<gl-form-checkbox
ref="masked-ci-variable"
- v-model="masked"
+ v-model="variable.masked"
data-testid="ci-variable-masked-checkbox"
>
{{ __('Mask variable') }}
@@ -403,7 +431,7 @@ export default {
<template #modal-footer>
<gl-button @click="hideModal">{{ __('Cancel') }}</gl-button>
<gl-button
- v-if="variableBeingEdited"
+ v-if="isEditing"
ref="deleteCiVariable"
variant="danger"
category="secondary"
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
index 4cc00eb01d9..81e3a983ea3 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
@@ -1,9 +1,91 @@
<script>
-export default {};
+import { ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION, VARIABLE_ACTIONS } from '../constants';
+import CiVariableTable from './ci_variable_table.vue';
+import CiVariableModal from './ci_variable_modal.vue';
+
+export default {
+ components: {
+ CiVariableTable,
+ CiVariableModal,
+ },
+ props: {
+ areScopedVariablesAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ environments: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ variables: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedVariable: {},
+ mode: null,
+ };
+ },
+ computed: {
+ showModal() {
+ return VARIABLE_ACTIONS.includes(this.mode);
+ },
+ },
+ methods: {
+ addVariable(variable) {
+ this.$emit('add-variable', variable);
+ },
+ deleteVariable(variable) {
+ this.$emit('delete-variable', variable);
+ },
+ updateVariable(variable) {
+ this.$emit('update-variable', variable);
+ },
+ hideModal() {
+ this.mode = null;
+ },
+ setSelectedVariable(variable = null) {
+ if (!variable) {
+ this.selectedVariable = {};
+ this.mode = ADD_VARIABLE_ACTION;
+ } else {
+ this.selectedVariable = variable;
+ this.mode = EDIT_VARIABLE_ACTION;
+ }
+ },
+ },
+};
</script>
<template>
<div class="row">
- <div class="col-lg-12"></div>
+ <div class="col-lg-12">
+ <ci-variable-table
+ :is-loading="isLoading"
+ :variables="variables"
+ @set-selected-variable="setSelectedVariable"
+ />
+ <ci-variable-modal
+ v-if="showModal"
+ :are-scoped-variables-available="areScopedVariablesAvailable"
+ :environments="environments"
+ :variables="variables"
+ :mode="mode"
+ :selected-variable="selectedVariable"
+ @add-variable="addVariable"
+ @delete-variable="deleteVariable"
+ @hideModal="hideModal"
+ @update-variable="updateVariable"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index f078234829a..1bb94080694 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -1,10 +1,17 @@
<script>
-import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
+import {
+ GlButton,
+ GlIcon,
+ GlLoadingIcon,
+ GlModalDirective,
+ GlTable,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
+import { ADD_CI_VARIABLE_MODAL_ID, variableText } from '../constants';
+import { convertEnvironmentScope } from '../utils';
import CiVariablePopover from './ci_variable_popover.vue';
export default {
@@ -14,7 +21,7 @@ export default {
iconSize: 16,
fields: [
{
- key: 'variable_type',
+ key: 'variableType',
label: s__('CiVariables|Type'),
customStyle: { width: '70px' },
},
@@ -41,7 +48,7 @@ export default {
customStyle: { width: '100px' },
},
{
- key: 'environment_scope',
+ key: 'environmentScope',
label: s__('CiVariables|Environments'),
customStyle: { width: '20%' },
},
@@ -56,6 +63,7 @@ export default {
CiVariablePopover,
GlButton,
GlIcon,
+ GlLoadingIcon,
GlTable,
TooltipOnTruncate,
},
@@ -64,10 +72,25 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ variables: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ areValuesHidden: true,
+ };
+ },
computed: {
- ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']),
valuesButtonText() {
- return this.valuesHidden ? __('Reveal values') : __('Hide values');
+ return this.areValuesHidden ? __('Reveal values') : __('Hide values');
},
isTableEmpty() {
return !this.variables || this.variables.length === 0;
@@ -76,18 +99,28 @@ export default {
return this.$options.fields;
},
},
- mounted() {
- this.fetchVariables();
- },
methods: {
- ...mapActions(['fetchVariables', 'toggleValues', 'editVariable']),
+ convertEnvironmentScopeValue(env) {
+ return convertEnvironmentScope(env);
+ },
+ generateTypeText(item) {
+ return variableText[item.variableType];
+ },
+ toggleHiddenState() {
+ this.areValuesHidden = !this.areValuesHidden;
+ },
+ setSelectedVariable(variable = null) {
+ this.$emit('set-selected-variable', variable);
+ },
},
};
</script>
<template>
<div class="ci-variable-table" data-testid="ci-variable-table">
+ <gl-loading-icon v-if="isLoading" />
<gl-table
+ v-else
:fields="fields"
:items="variables"
tbody-tr-class="js-ci-variable-row"
@@ -104,6 +137,11 @@ export default {
<template #table-colgroup="scope">
<col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
</template>
+ <template #cell(variableType)="{ item }">
+ <div class="gl-pt-2">
+ {{ generateTypeText(item) }}
+ </div>
+ </template>
<template #cell(key)="{ item }">
<div class="gl-display-flex gl-align-items-center">
<tooltip-on-truncate :title="item.key" truncate-target="child">
@@ -125,11 +163,12 @@ export default {
</template>
<template #cell(value)="{ item }">
<div class="gl-display-flex gl-align-items-center">
- <span v-if="valuesHidden">*********************</span>
+ <span v-if="areValuesHidden" data-testid="hiddenValue">*********************</span>
<span
v-else
:id="`ci-variable-value-${item.id}`"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ data-testid="revealedValue"
>{{ item.value }}</span
>
<gl-button
@@ -150,16 +189,16 @@ export default {
<gl-icon v-if="item.masked" :size="$options.iconSize" :name="$options.trueIcon" />
<gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" />
</template>
- <template #cell(environment_scope)="{ item }">
+ <template #cell(environmentScope)="{ item }">
<div class="gl-display-flex">
<span
:id="`ci-variable-env-${item.id}`"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- >{{ item.environment_scope }}</span
+ >{{ convertEnvironmentScopeValue(item.environmentScope) }}</span
>
<ci-variable-popover
:target="`ci-variable-env-${item.id}`"
- :value="item.environment_scope"
+ :value="convertEnvironmentScopeValue(item.environmentScope)"
:tooltip-text="__('Copy environment')"
/>
</div>
@@ -170,7 +209,7 @@ export default {
icon="pencil"
:aria-label="__('Edit')"
data-qa-selector="edit_ci_variable_button"
- @click="editVariable(item)"
+ @click="setSelectedVariable(item)"
/>
</template>
<template #empty>
@@ -186,12 +225,14 @@ export default {
data-qa-selector="add_ci_variable_button"
variant="confirm"
category="primary"
+ :aria-label="__('Add')"
+ @click="setSelectedVariable()"
>{{ __('Add variable') }}</gl-button
>
<gl-button
v-if="!isTableEmpty"
data-qa-selector="reveal_ci_variable_value_button"
- @click="toggleValues(!valuesHidden)"
+ @click="toggleHiddenState"
>{{ valuesButtonText }}</gl-button
>
</div>
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
index 7dcc5ce42d7..cebb7eb85ac 100644
--- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
@@ -30,7 +30,7 @@ import {
EVENT_LABEL,
EVENT_ACTION,
} from '../constants';
-import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
+import LegacyCiEnvironmentsDropdown from './legacy_ci_environments_dropdown.vue';
import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
const trackingMixin = Tracking.mixin({ label: EVENT_LABEL });
@@ -43,7 +43,7 @@ export default {
containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE,
environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE,
components: {
- CiEnvironmentsDropdown,
+ LegacyCiEnvironmentsDropdown,
GlAlert,
GlButton,
GlCollapse,
@@ -293,7 +293,7 @@ export default {
<gl-icon name="question" :size="12" />
</gl-link>
</template>
- <ci-environments-dropdown
+ <legacy-ci-environments-dropdown
v-if="scopedVariablesAvailable"
class="w-100"
:value="environment_scope"
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index fa55b4d9e77..5d22974ffbb 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -2,18 +2,58 @@ import { __ } from '~/locale';
export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable';
+// This const will be deprecated once we remove VueX from the section
export const displayText = {
variableText: __('Variable'),
fileText: __('File'),
allEnvironmentsText: __('All (default)'),
};
+export const variableTypes = {
+ variableType: 'ENV_VAR',
+ fileType: 'FILE',
+};
+
+// Once REST is removed, we won't need `types`
export const types = {
variableType: 'env_var',
fileType: 'file',
- allEnvironmentsType: '*',
};
+export const allEnvironments = {
+ type: '*',
+ text: __('All (default)'),
+};
+
+// Once REST is removed, we won't need `types` key
+export const variableText = {
+ [types.variableType]: __('Variable'),
+ [types.fileType]: __('File'),
+ [variableTypes.variableType]: __('Variable'),
+ [variableTypes.fileType]: __('File'),
+};
+
+export const variableOptions = [
+ { value: types.variableType, text: variableText[types.variableType] },
+ { value: types.fileType, text: variableText[types.fileType] },
+];
+
+export const defaultVariableState = {
+ environmentScope: allEnvironments.type,
+ key: '',
+ masked: false,
+ protected: false,
+ value: '',
+ variableType: types.variableType,
+};
+
+// eslint-disable-next-line @gitlab/require-i18n-strings
+export const groupString = 'Group';
+// eslint-disable-next-line @gitlab/require-i18n-strings
+export const instanceString = 'Instance';
+// eslint-disable-next-line @gitlab/require-i18n-strings
+export const projectString = 'Instance';
+
export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed';
export const AWS_TIP_MESSAGE = __(
'%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.',
@@ -33,3 +73,20 @@ export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __(
);
export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more');
+
+export const ADD_VARIABLE_ACTION = 'ADD_VARIABLE';
+export const EDIT_VARIABLE_ACTION = 'EDIT_VARIABLE';
+export const VARIABLE_ACTIONS = [ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION];
+
+export const GRAPHQL_PROJECT_TYPE = 'Project';
+export const GRAPHQL_GROUP_TYPE = 'Group';
+
+export const ADD_MUTATION_ACTION = 'add';
+export const UPDATE_MUTATION_ACTION = 'update';
+export const DELETE_MUTATION_ACTION = 'delete';
+
+export const environmentFetchErrorText = __(
+ 'There was an error fetching the environments information.',
+);
+export const genericMutationErrorText = __('Something went wrong on our end. Please try again.');
+export const variableFetchErrorText = __('There was an error fetching the variables.');
diff --git a/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql
new file mode 100644
index 00000000000..a28ca4eebc9
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql
@@ -0,0 +1,7 @@
+fragment BaseCiVariable on CiVariable {
+ __typename
+ id
+ key
+ value
+ variableType
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
new file mode 100644
index 00000000000..eba4b0c32f8
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
@@ -0,0 +1,16 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation addAdminVariable($variable: CiVariable!, $endpoint: String!) {
+ addAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiInstanceVariable {
+ protected
+ masked
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
new file mode 100644
index 00000000000..96eb8c794bc
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
@@ -0,0 +1,16 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation deleteAdminVariable($variable: CiVariable!, $endpoint: String!) {
+ deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiInstanceVariable {
+ protected
+ masked
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
new file mode 100644
index 00000000000..c0388507bb8
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
@@ -0,0 +1,16 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation updateAdminVariable($variable: CiVariable!, $endpoint: String!) {
+ updateAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiInstanceVariable {
+ protected
+ masked
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
new file mode 100644
index 00000000000..f8e4dc55fa4
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation addGroupVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $groupId: ID!
+) {
+ addGroupVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ groupId: $groupId
+ ) @client {
+ group {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiGroupVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
new file mode 100644
index 00000000000..310e4a6e551
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation deleteGroupVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $groupId: ID!
+) {
+ deleteGroupVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ groupId: $groupId
+ ) @client {
+ group {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiGroupVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
new file mode 100644
index 00000000000..5291942eb87
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation updateGroupVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $groupId: ID!
+) {
+ updateGroupVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ groupId: $groupId
+ ) @client {
+ group {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiGroupVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql
new file mode 100644
index 00000000000..c6dd6d4faaf
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql
@@ -0,0 +1,17 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+query getGroupVariables($fullPath: ID!) {
+ group(fullPath: $fullPath) {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiGroupVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql
new file mode 100644
index 00000000000..95056842b49
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql
@@ -0,0 +1,13 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+query getVariables {
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiInstanceVariable {
+ masked
+ protected
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
new file mode 100644
index 00000000000..be7e3f88cfd
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
@@ -0,0 +1,113 @@
+import axios from 'axios';
+import {
+ convertObjectPropsToCamelCase,
+ convertObjectPropsToSnakeCase,
+} from '../../lib/utils/common_utils';
+import { getIdFromGraphQLId } from '../../graphql_shared/utils';
+import { GRAPHQL_GROUP_TYPE, groupString, instanceString } from '../constants';
+import getAdminVariables from './queries/variables.query.graphql';
+import getGroupVariables from './queries/group_variables.query.graphql';
+
+const prepareVariableForApi = ({ variable, destroy = false }) => {
+ return {
+ ...convertObjectPropsToSnakeCase(variable),
+ id: getIdFromGraphQLId(variable?.id),
+ variable_type: variable.variableType.toLowerCase(),
+ secret_value: variable.value,
+ _destroy: destroy,
+ };
+};
+
+const mapVariableTypes = (variables = [], kind) => {
+ return variables.map((ciVar) => {
+ return {
+ __typename: `Ci${kind}Variable`,
+ ...convertObjectPropsToCamelCase(ciVar),
+ variableType: ciVar.variable_type ? ciVar.variable_type.toUpperCase() : ciVar.variableType,
+ };
+ });
+};
+
+const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => {
+ return {
+ errors,
+ group: {
+ __typename: GRAPHQL_GROUP_TYPE,
+ id: groupId,
+ ciVariables: {
+ __typename: 'CiVariableConnection',
+ nodes: mapVariableTypes(data.variables, groupString),
+ },
+ },
+ };
+};
+
+const prepareAdminGraphQLResponse = ({ data, errors = [] }) => {
+ return {
+ errors,
+ ciVariables: {
+ __typename: `Ci${instanceString}VariableConnection`,
+ nodes: mapVariableTypes(data.variables, instanceString),
+ },
+ };
+};
+
+const callGroupEndpoint = async ({
+ endpoint,
+ fullPath,
+ variable,
+ groupId,
+ cache,
+ destroy = false,
+}) => {
+ try {
+ const { data } = await axios.patch(endpoint, {
+ variables_attributes: [prepareVariableForApi({ variable, destroy })],
+ });
+ return prepareGroupGraphQLResponse({ data, groupId });
+ } catch (e) {
+ return prepareGroupGraphQLResponse({
+ data: cache.readQuery({ query: getGroupVariables, variables: { fullPath } }),
+ groupId,
+ errors: [...e.response.data],
+ });
+ }
+};
+
+const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false }) => {
+ try {
+ const { data } = await axios.patch(endpoint, {
+ variables_attributes: [prepareVariableForApi({ variable, destroy })],
+ });
+
+ return prepareAdminGraphQLResponse({ data });
+ } catch (e) {
+ return prepareAdminGraphQLResponse({
+ data: cache.readQuery({ query: getAdminVariables }),
+ errors: [...e.response.data],
+ });
+ }
+};
+
+export const resolvers = {
+ Mutation: {
+ addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
+ },
+ updateGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
+ },
+ deleteGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache, destroy: true });
+ },
+ addAdminVariable: async (_, { endpoint, variable }, { cache }) => {
+ return callAdminEndpoint({ endpoint, variable, cache });
+ },
+ updateAdminVariable: async (_, { endpoint, variable }, { cache }) => {
+ return callAdminEndpoint({ endpoint, variable, cache });
+ },
+ deleteAdminVariable: async (_, { endpoint, variable }, { cache }) => {
+ return callAdminEndpoint({ endpoint, variable, cache, destroy: true });
+ },
+ },
+};
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index 2b54af6a2a4..a74af8aed12 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -2,8 +2,10 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
-import CiVariableSettings from './components/ci_variable_settings.vue';
+import CiAdminVariables from './components/ci_admin_variables.vue';
+import CiGroupVariables from './components/ci_group_variables.vue';
import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue';
+import { resolvers } from './graphql/resolvers';
import createStore from './store';
const mountCiVariableListApp = (containerEl) => {
@@ -13,8 +15,12 @@ const mountCiVariableListApp = (containerEl) => {
awsTipDeployLink,
awsTipLearnLink,
containsVariableReferenceLink,
+ endpoint,
environmentScopeLink,
- group,
+ groupId,
+ groupPath,
+ isGroup,
+ isProject,
maskedEnvironmentVariablesLink,
maskableRegex,
projectFullPath,
@@ -23,13 +29,20 @@ const mountCiVariableListApp = (containerEl) => {
protectedEnvironmentVariablesLink,
} = containerEl.dataset;
- const isGroup = parseBoolean(group);
+ const parsedIsProject = parseBoolean(isProject);
+ const parsedIsGroup = parseBoolean(isGroup);
const isProtectedByDefault = parseBoolean(protectedByDefault);
+ let component = CiAdminVariables;
+
+ if (parsedIsGroup) {
+ component = CiGroupVariables;
+ }
+
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(resolvers),
});
return new Vue({
@@ -41,8 +54,12 @@ const mountCiVariableListApp = (containerEl) => {
awsTipDeployLink,
awsTipLearnLink,
containsVariableReferenceLink,
+ endpoint,
environmentScopeLink,
- isGroup,
+ groupId,
+ groupPath,
+ isGroup: parsedIsGroup,
+ isProject: parsedIsProject,
isProtectedByDefault,
maskedEnvironmentVariablesLink,
maskableRegex,
@@ -51,7 +68,7 @@ const mountCiVariableListApp = (containerEl) => {
protectedEnvironmentVariablesLink,
},
render(createElement) {
- return createElement(CiVariableSettings);
+ return createElement(component);
},
});
};
diff --git a/app/assets/javascripts/ci_variable_list/store/utils.js b/app/assets/javascripts/ci_variable_list/store/utils.js
index d9ca460a8e1..f46a671ae7b 100644
--- a/app/assets/javascripts/ci_variable_list/store/utils.js
+++ b/app/assets/javascripts/ci_variable_list/store/utils.js
@@ -1,5 +1,5 @@
import { cloneDeep } from 'lodash';
-import { displayText, types } from '../constants';
+import { displayText, types, allEnvironments } from '../constants';
const variableTypeHandler = (type) =>
type === displayText.variableText ? types.variableType : types.fileType;
@@ -15,7 +15,7 @@ export const prepareDataForDisplay = (variables) => {
}
variableCopy.secret_value = variableCopy.value;
- if (variableCopy.environment_scope === types.allEnvironmentsType) {
+ if (variableCopy.environment_scope === allEnvironments.type) {
variableCopy.environment_scope = displayText.allEnvironmentsText;
}
variableCopy.protected_variable = variableCopy.protected;
@@ -31,7 +31,7 @@ export const prepareDataForApi = (variable, destroy = false) => {
variableCopy.masked = variableCopy.masked.toString();
variableCopy.variable_type = variableTypeHandler(variableCopy.variable_type);
if (variableCopy.environment_scope === displayText.allEnvironmentsText) {
- variableCopy.environment_scope = types.allEnvironmentsType;
+ variableCopy.environment_scope = allEnvironments.type;
}
if (destroy) {
diff --git a/app/assets/javascripts/ci_variable_list/utils.js b/app/assets/javascripts/ci_variable_list/utils.js
new file mode 100644
index 00000000000..1faa97a5f73
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/utils.js
@@ -0,0 +1,50 @@
+import { uniq } from 'lodash';
+import { allEnvironments } from './constants';
+
+/**
+ * This function takes a list of variable, environments and
+ * new environments added through the scope dropdown
+ * and create a new Array that concatenate the environment list
+ * with the environment scopes find in the variable list. This is
+ * useful for variable settings so that we can render a list of all
+ * environment scopes available based on the list of envs, the ones the user
+ * added explictly and what is found under each variable.
+ * @param {Array} variables
+ * @param {Array} environments
+ * @returns {Array} - Array of environments
+ */
+
+export const createJoinedEnvironments = (
+ variables = [],
+ environments = [],
+ newEnvironments = [],
+) => {
+ const scopesFromVariables = variables.map((variable) => variable.environmentScope);
+ return uniq([...environments, ...newEnvironments, ...scopesFromVariables]).sort();
+};
+
+/**
+ * This function job is to convert the * wildcard to text when applicable
+ * in the UI. It uses a constants to compare the incoming value to that
+ * of the * and then apply the corresponding label if applicable. If there
+ * is no scope, then we return the default value as well.
+ * @param {String} scope
+ * @returns {String} - Converted value if applicable
+ */
+
+export const convertEnvironmentScope = (environmentScope = '') => {
+ if (environmentScope === allEnvironments.type || !environmentScope) {
+ return allEnvironments.text;
+ }
+
+ return environmentScope;
+};
+
+/**
+ * Gives us an array of all the environments by name
+ * @param {Array} nodes
+ * @return {Array<String>} - Array of environments strings
+ */
+export const mapEnvironmentNames = (nodes = []) => {
+ return nodes.map((env) => env.name);
+};
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 1ea5eff35d4..4b85ca2b508 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -235,7 +235,7 @@ export default {
:fields="fields"
fixed
stacked="md"
- class="qa-clusters-table gl-mb-4!"
+ class="gl-mb-4!"
data-testid="cluster_list_table"
>
<template #cell(name)="{ item }">
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 4ff49433749..95ee3a0d90e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -1,5 +1,6 @@
<script>
-import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { getParameterByName } from '~/lib/utils/url_utility';
import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import { PipelineKeyOptions } from '~/pipelines/constants';
@@ -19,6 +20,7 @@ export default {
GlLink,
GlLoadingIcon,
GlModal,
+ GlSprintf,
PipelinesTableComponent,
TablePagination,
},
@@ -32,6 +34,10 @@ export default {
type: String,
required: true,
},
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
viewType: {
type: String,
required: false,
@@ -83,6 +89,9 @@ export default {
shouldRenderErrorState() {
return this.hasError && !this.isLoading;
},
+ shouldRenderEmptyState() {
+ return this.state.pipelines.length === 0 && !this.shouldRenderErrorState;
+ },
/**
* The "Run pipeline" button can only be rendered when:
* - In MR view - we use `canCreatePipelineInTargetProject` for that purpose
@@ -185,6 +194,17 @@ export default {
},
},
},
+ i18n: {
+ runPipelinePopoverTitle: s__('Pipeline|Run merge request pipeline'),
+ runPipelinePopoverDescription: s__(
+ 'Pipeline|To run a merge request pipeline, the jobs in the CI/CD configuration file %{linkStart}must be configured%{linkEnd} to run in merge request pipelines.',
+ ),
+ runPipelineText: s__('Pipeline|Run pipeline'),
+ emptyStateTitle: s__('Pipelines|There are currently no pipelines.'),
+ },
+ mrPipelinesDocsPath: helpPagePath('ci/pipelines/merge_request_pipelines.md', {
+ anchor: 'prerequisites',
+ }),
};
</script>
<template>
@@ -203,7 +223,41 @@ export default {
s__(`Pipelines|There was an error fetching the pipelines.
Try again in a few moments or contact your support team.`)
"
+ data-testid="pipeline-error-empty-state"
/>
+ <template v-else-if="shouldRenderEmptyState">
+ <gl-empty-state
+ :svg-path="emptyStateSvgPath"
+ :title="$options.i18n.emptyStateTitle"
+ data-testid="pipeline-empty-state"
+ >
+ <template #description>
+ <gl-sprintf :message="$options.i18n.runPipelinePopoverDescription">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.mrPipelinesDocsPath"
+ target="_blank"
+ data-testid="mr-pipelines-docs-link"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </template>
+
+ <template #actions>
+ <div class="gl-vertical-align-middle">
+ <gl-button
+ variant="confirm"
+ :loading="state.isRunningMergeRequestPipeline"
+ data-testid="run_pipeline_button"
+ @click="tryRunPipeline"
+ >
+ {{ $options.i18n.runPipelineText }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-empty-state>
+ </template>
<div v-else-if="shouldRenderTable">
<gl-button
@@ -215,7 +269,7 @@ export default {
:loading="state.isRunningMergeRequestPipeline"
@click="tryRunPipeline"
>
- {{ s__('Pipeline|Run pipeline') }}
+ {{ $options.i18n.runPipelineText }}
</gl-button>
<pipelines-table-component
@@ -231,7 +285,7 @@ export default {
:loading="state.isRunningMergeRequestPipeline"
@click="tryRunPipeline"
>
- {{ s__('Pipeline|Run pipeline') }}
+ {{ $options.i18n.runPipelineText }}
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js
index 784e9cb2faa..b105273ece7 100644
--- a/app/assets/javascripts/commons/nav/user_merge_requests.js
+++ b/app/assets/javascripts/commons/nav/user_merge_requests.js
@@ -26,39 +26,20 @@ function updateMergeRequestCounts(newCount) {
mergeRequestsCountEl.classList.toggle('gl-display-none', Number(newCount) === 0);
}
-function updateAttentionRequestsCount(count) {
- const attentionCountEl = document.querySelector('.js-attention-count');
- attentionCountEl.textContent = count.toLocaleString();
-
- if (Number(count) === 0) {
- attentionCountEl.classList.replace('badge-warning', 'badge-neutral');
- } else {
- attentionCountEl.classList.replace('badge-neutral', 'badge-warning');
- }
-}
-
/**
* Refresh user counts (and broadcast if open)
*/
export function refreshUserMergeRequestCounts() {
return getUserCounts()
.then(({ data }) => {
- const attentionRequestsEnabled = window.gon?.features?.mrAttentionRequests;
const assignedMergeRequests = data.assigned_merge_requests;
const reviewerMergeRequests = data.review_requested_merge_requests;
- const attentionRequests = data.attention_requests;
- const fullCount = attentionRequestsEnabled
- ? attentionRequests
- : assignedMergeRequests + reviewerMergeRequests;
+ const fullCount = assignedMergeRequests + reviewerMergeRequests;
updateUserMergeRequestCounts(assignedMergeRequests);
updateReviewerMergeRequestCounts(reviewerMergeRequests);
updateMergeRequestCounts(fullCount);
broadcastCount(fullCount);
-
- if (attentionRequestsEnabled) {
- updateAttentionRequestsCount(attentionRequests);
- }
})
.catch((ex) => {
console.error(ex); // eslint-disable-line no-console
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
index f0726ff3e63..05ca7fd75c3 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
@@ -3,13 +3,11 @@ import { GlButtonGroup } from '@gitlab/ui';
import { BubbleMenu } from '@tiptap/vue-2';
import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants';
import trackUIControl from '../../services/track_ui_control';
-import Image from '../../extensions/image';
+import Paragraph from '../../extensions/paragraph';
+import Heading from '../../extensions/heading';
import Audio from '../../extensions/audio';
import Video from '../../extensions/video';
-import Code from '../../extensions/code';
-import CodeBlockHighlight from '../../extensions/code_block_highlight';
-import Diagram from '../../extensions/diagram';
-import Frontmatter from '../../extensions/frontmatter';
+import Image from '../../extensions/image';
import ToolbarButton from '../toolbar_button.vue';
export default {
@@ -27,17 +25,13 @@ export default {
shouldShow: ({ editor, from, to }) => {
if (from === to) return false;
- const exclude = [
- Code.name,
- CodeBlockHighlight.name,
- Diagram.name,
- Frontmatter.name,
- Image.name,
- Audio.name,
- Video.name,
- ];
+ const includes = [Paragraph.name, Heading.name];
+ const excludes = [Image.name, Audio.name, Video.name];
- return !exclude.some((type) => editor.isActive(type));
+ return (
+ includes.some((type) => editor.isActive(type)) &&
+ !excludes.some((type) => editor.isActive(type))
+ );
},
},
};
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 74ae37b6d06..c3c881d9135 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -84,7 +84,14 @@ export default {
<template>
<content-editor-provider :content-editor="contentEditor">
<div>
- <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
+ <editor-state-observer
+ @docUpdate="notifyChange"
+ @focus="focus"
+ @blur="blur"
+ @loading="$emit('loading')"
+ @loadingSuccess="$emit('loadingSuccess')"
+ @loadingError="$emit('loadingError')"
+ />
<content-editor-alert />
<div
data-testid="content-editor"
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index 6e4cde5dad6..9ad739e7358 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -33,8 +33,12 @@ export default {
this.$emit('execute', { contentType: listType });
},
- execute(command, contentType) {
- this.tiptapEditor.chain().focus()[command]().run();
+ execute(command, contentType, ...args) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ [command](...args)
+ .run();
this.$emit('execute', { contentType });
},
@@ -67,5 +71,8 @@ export default {
<gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })">
{{ __('PlantUML diagram') }}
</gl-dropdown-item>
+ <gl-dropdown-item @click="execute('insertTableOfContents', 'tableOfContents')">
+ {{ __('Table of contents') }}
+ </gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index 65d71814268..1030ebbf838 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -93,7 +93,7 @@ export default {
icon-name="list-task"
class="gl-mx-2 gl-display-none gl-sm-display-inline"
editor-command="toggleTaskList"
- :label="__('Add a task list')"
+ :label="__('Add a checklist')"
@execute="trackToolbarControlExecution"
/>
<toolbar-image-button
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 c0d6e32a739..6456540a0dd 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
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
-import { selectedRect as getSelectedRect } from 'prosemirror-tables';
+import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables';
import { __ } from '~/locale';
const TABLE_CELL_HEADER = 'th';
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue
new file mode 100644
index 00000000000..a4e1be9d95d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue
@@ -0,0 +1,55 @@
+<script>
+import { debounce } from 'lodash';
+import { NodeViewWrapper } from '@tiptap/vue-2';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { getHeadings } from '../../services/table_of_contents_utils';
+import TableOfContentsHeading from './table_of_contents_heading.vue';
+
+export default {
+ name: 'TableOfContentsWrapper',
+ components: {
+ NodeViewWrapper,
+ TableOfContentsHeading,
+ },
+ props: {
+ editor: {
+ type: Object,
+ required: true,
+ },
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ headings: [],
+ };
+ },
+ mounted() {
+ this.handleUpdate = debounce(this.handleUpdate, DEFAULT_DEBOUNCE_AND_THROTTLE_MS * 2);
+
+ this.editor.on('update', this.handleUpdate);
+ this.$nextTick(this.handleUpdate);
+ },
+ methods: {
+ handleUpdate() {
+ this.headings = getHeadings(this.editor);
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper
+ as="ul"
+ class="table-of-contents gl-border-1 gl-border-solid gl-border-gray-100 gl-mb-5 gl-p-4!"
+ data-testid="table-of-contents"
+ >
+ {{ __('Table of contents') }}
+ <table-of-contents-heading
+ v-for="(heading, index) in headings"
+ :key="index"
+ :heading="heading"
+ />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
new file mode 100644
index 00000000000..edd75d232e8
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+ name: 'TableOfContentsHeading',
+ props: {
+ heading: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <li>
+ <a v-if="heading.text" href="#" @click.prevent>
+ {{ heading.text }}
+ </a>
+ <ul v-if="heading.subHeadings.length">
+ <table-of-contents-heading
+ v-for="(child, index) in heading.subHeadings"
+ :key="index"
+ :heading="child"
+ />
+ </ul>
+ </li>
+</template>
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index edf8b3d3a0b..27432b1e18b 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,3 +1,4 @@
+import { lowlight } from 'lowlight/lib/core';
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { textblockTypeInputRule } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
@@ -66,4 +67,4 @@ export default CodeBlockLowlight.extend({
addNodeView() {
return new VueNodeViewRenderer(CodeBlockWrapper);
},
-});
+}).configure({ lowlight });
diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js
index c59ca8a28b8..d9983b8c1c5 100644
--- a/app/assets/javascripts/content_editor/extensions/diagram.js
+++ b/app/assets/javascripts/content_editor/extensions/diagram.js
@@ -1,3 +1,4 @@
+import { lowlight } from 'lowlight/lib/core';
import { textblockTypeInputRule } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import languageLoader from '../services/code_block_language_loader';
@@ -10,6 +11,12 @@ export default CodeBlockHighlight.extend({
isolating: true,
+ addOptions() {
+ return {
+ lowlight,
+ };
+ },
+
addAttributes() {
return {
language: {
diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js
index 2ec22158106..428171a9389 100644
--- a/app/assets/javascripts/content_editor/extensions/frontmatter.js
+++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js
@@ -1,9 +1,16 @@
+import { lowlight } from 'lowlight/lib/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import CodeBlockHighlight from './code_block_highlight';
export default CodeBlockHighlight.extend({
name: 'frontmatter',
+ addOptions() {
+ return {
+ lowlight,
+ };
+ },
+
addAttributes() {
return {
...this.parent?.(),
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index 25f976f524f..65849ec4d0d 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -34,6 +34,7 @@ export default Image.extend({
canonicalSrc: {
default: null,
parseHTML: (element) => element.dataset.canonicalSrc,
+ renderHTML: () => '',
},
alt: {
default: null,
@@ -51,6 +52,10 @@ export default Image.extend({
return img.getAttribute('title');
},
},
+ isReference: {
+ default: false,
+ renderHTML: () => '',
+ },
};
},
parseHTML() {
@@ -71,7 +76,6 @@ export default Image.extend({
src: HTMLAttributes.src,
alt: HTMLAttributes.alt,
title: HTMLAttributes.title,
- 'data-canonical-src': HTMLAttributes.canonicalSrc,
},
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index f9b12f631fe..e985e561fda 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -56,6 +56,11 @@ export default Link.extend({
canonicalSrc: {
default: null,
parseHTML: (element) => element.dataset.canonicalSrc,
+ renderHTML: () => '',
+ },
+ isReference: {
+ default: false,
+ renderHTML: () => '',
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/reference_definition.js b/app/assets/javascripts/content_editor/extensions/reference_definition.js
new file mode 100644
index 00000000000..e2762fe9fd9
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/reference_definition.js
@@ -0,0 +1,29 @@
+import { Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'referenceDefinition',
+
+ group: 'block',
+
+ content: 'text*',
+
+ marks: '',
+
+ addAttributes() {
+ return {
+ identifier: {
+ default: null,
+ },
+ url: {
+ default: null,
+ },
+ title: {
+ default: null,
+ },
+ };
+ },
+
+ renderHTML() {
+ return ['pre', {}, 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index 618f17b1c5e..f9de71f601b 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -6,6 +6,7 @@ import Code from './code';
import CodeBlockHighlight from './code_block_highlight';
import FootnoteReference from './footnote_reference';
import FootnoteDefinition from './footnote_definition';
+import Frontmatter from './frontmatter';
import Heading from './heading';
import HardBreak from './hard_break';
import HorizontalRule from './horizontal_rule';
@@ -16,6 +17,7 @@ import Link from './link';
import ListItem from './list_item';
import OrderedList from './ordered_list';
import Paragraph from './paragraph';
+import ReferenceDefinition from './reference_definition';
import Strike from './strike';
import TaskList from './task_list';
import TaskItem from './task_item';
@@ -36,6 +38,7 @@ export default Extension.create({
CodeBlockHighlight.name,
FootnoteReference.name,
FootnoteDefinition.name,
+ Frontmatter.name,
HardBreak.name,
Heading.name,
HorizontalRule.name,
@@ -45,6 +48,7 @@ export default Extension.create({
ListItem.name,
OrderedList.name,
Paragraph.name,
+ ReferenceDefinition.name,
Strike.name,
TaskList.name,
TaskItem.name,
diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
index a8882f9ede4..f64ed67199f 100644
--- a/app/assets/javascripts/content_editor/extensions/table_of_contents.js
+++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
@@ -1,6 +1,8 @@
import { Node, InputRule } from '@tiptap/core';
-import { s__ } from '~/locale';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { __ } from '~/locale';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import TableOfContentsWrapper from '../components/wrappers/table_of_contents.vue';
export default Node.create({
name: 'tableOfContents',
@@ -25,9 +27,18 @@ export default Node.create({
class:
'table-of-contents gl-border-1 gl-border-solid gl-text-center gl-border-gray-100 gl-mb-5',
},
- s__('ContentEditor|Table of Contents'),
+ __('Table of contents'),
];
},
+ addNodeView() {
+ return VueNodeViewRenderer(TableOfContentsWrapper);
+ },
+
+ addCommands() {
+ return {
+ insertTableOfContents: () => ({ commands }) => commands.insertContent({ type: this.name }),
+ };
+ },
addInputRules() {
const { type } = this;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 867bf0b4d55..75d8581890f 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,4 +1,3 @@
-import { TextSelection } from 'prosemirror-state';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
/* eslint-disable no-underscore-dangle */
@@ -59,7 +58,6 @@ export class ContentEditor {
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _eventHub: eventHub } = this;
const { doc, tr } = editor.state;
- const selection = TextSelection.create(doc, 0, doc.content.size);
try {
eventHub.$emit(LOADING_CONTENT_EVENT);
@@ -67,9 +65,7 @@ export class ContentEditor {
if (document) {
this._pristineDoc = document;
- tr.setSelection(selection)
- .replaceSelectionWith(document, false)
- .setMeta('preventUpdate', true);
+ tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true);
editor.view.dispatch(tr);
}
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index c5cfa9a4285..7a289df94ea 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -1,6 +1,5 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
-import { lowlight } from 'lowlight/lib/core';
import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
@@ -43,6 +42,7 @@ import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference';
+import ReferenceDefinition from '../extensions/reference_definition';
import Sourcemap from '../extensions/sourcemap';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
@@ -96,7 +96,7 @@ export const createContentEditor = ({
BulletList,
Code,
ColorChip,
- CodeBlockHighlight.configure({ lowlight }),
+ CodeBlockHighlight,
DescriptionItem,
DescriptionList,
Details,
@@ -110,7 +110,7 @@ export const createContentEditor = ({
FootnoteDefinition,
FootnoteReference,
FootnotesSection,
- Frontmatter.configure({ lowlight }),
+ Frontmatter,
Gapcursor,
HardBreak,
Heading,
@@ -127,8 +127,9 @@ export const createContentEditor = ({
MathInline,
OrderedList,
Paragraph,
- PasteMarkdown.configure({ renderMarkdown, eventHub }),
+ PasteMarkdown,
Reference,
+ ReferenceDefinition,
Sourcemap,
Strike,
Subscript,
diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
index 312ab88de4a..28a50adca6b 100644
--- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
+++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
@@ -21,7 +21,7 @@
import { Mark } from 'prosemirror-model';
import { visitParents, SKIP } from 'unist-util-visit-parents';
-import { isFunction, isString, noop } from 'lodash';
+import { isFunction, isString, noop, mapValues } from 'lodash';
const NO_ATTRIBUTES = {};
@@ -73,28 +73,48 @@ function createSourceMapAttributes(hastNode, markdown) {
}
/**
- * Compute ProseMirror node’s attributes from a Hast node.
- * By default, this function includes sourcemap position
- * information in the object returned.
- *
- * Other attributes are retrieved by invoking a getAttrs
- * function provided by the ProseMirror node factory spec.
- *
- * @param {*} proseMirrorNodeSpec ProseMirror node spec object
- * @param {HastNode} hastNode A hast node
- * @param {Array<HastNode>} hastParents All the ancestors of the hastNode
- * @param {String} markdown Markdown source file’s content
- *
- * @returns An object that contains a ProseMirror node’s attributes
+ * Creates a function that resolves the attributes
+ * of a ProseMirror node based on a hast node.
+ *
+ * @param {Object} params Parameters
+ * @param {String} params.markdown Markdown source from which the AST was generated
+ * @param {Object} params.attributeTransformer An object that allows applying a transformation
+ * function to all the attributes listed in the attributes property.
+ * @param {Array} params.attributeTransformer.attributes A list of attributes names
+ * that the getAttrs function should apply the transformation
+ * @param {Function} params.attributeTransformer.transform A function that applies
+ * a transform operation on an attribute value.
+ * @returns A `getAttrs` function
*/
-function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, markdown) {
- const { getAttrs: specGetAttrs } = proseMirrorNodeSpec;
+const getAttrsFactory = ({ attributeTransformer, markdown }) =>
+ /**
+ * Compute ProseMirror node’s attributes from a Hast node.
+ * By default, this function includes sourcemap position
+ * information in the object returned.
+ *
+ * Other attributes are retrieved by invoking a getAttrs
+ * function provided by the ProseMirror node factory spec.
+ *
+ * @param {Object} proseMirrorNodeSpec ProseMirror node spec object
+ * @param {Object} hastNode A hast node
+ * @param {Array} hastParents All the ancestors of the hastNode
+ * @param {String} markdown Markdown source file’s content
+ * @returns An object that contains a ProseMirror node’s attributes
+ */
+ function getAttrs(proseMirrorNodeSpec, hastNode, hastParents) {
+ const { getAttrs: specGetAttrs } = proseMirrorNodeSpec;
+ const attributes = {
+ ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}),
+ };
+ const { transform } = attributeTransformer;
- return {
- ...createSourceMapAttributes(hastNode, markdown),
- ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}),
+ return {
+ ...createSourceMapAttributes(hastNode, markdown),
+ ...mapValues(attributes, (attributeValue, attributeName) =>
+ transform(attributeName, attributeValue, hastNode),
+ ),
+ };
};
-}
/**
* Keeps track of the Hast -> ProseMirror conversion process.
@@ -322,7 +342,13 @@ class HastToProseMirrorConverterState {
*
* @returns An object that contains ProseMirror node factories
*/
-const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdown) => {
+const createProseMirrorNodeFactories = (
+ schema,
+ proseMirrorFactorySpecs,
+ attributeTransformer,
+ markdown,
+) => {
+ const getAttrs = getAttrsFactory({ attributeTransformer, markdown });
const factories = {
root: {
selector: 'root',
@@ -355,20 +381,20 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdow
const nodeType = schema.nodeType(proseMirrorName);
state.closeUntil(parent);
- state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent), factory);
};
} else if (factory.type === 'inline') {
const nodeType = schema.nodeType(proseMirrorName);
factory.handle = (state, hastNode, parent) => {
state.closeUntil(parent);
- state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent), factory);
// Inline nodes do not have children therefore they are immediately closed
state.closeNode();
};
} else if (factory.type === 'mark') {
const markType = schema.marks[proseMirrorName];
factory.handle = (state, hastNode, parent) => {
- state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
+ state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent), factory);
};
} else if (factory.type === 'ignore') {
factory.handle = noop;
@@ -581,9 +607,15 @@ export const createProseMirrorDocFromMdastTree = ({
factorySpecs,
wrappableTags,
tree,
+ attributeTransformer,
markdown,
}) => {
- const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, markdown);
+ const proseMirrorNodeFactories = createProseMirrorNodeFactories(
+ schema,
+ factorySpecs,
+ attributeTransformer,
+ markdown,
+ );
const state = new HastToProseMirrorConverterState();
visitParents(tree, (hastNode, ancestors) => {
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index c1c7af6b1af..472a0a4815b 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -33,6 +33,7 @@ import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import Reference from '../extensions/reference';
+import ReferenceDefinition from '../extensions/reference_definition';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
import Superscript from '../extensions/superscript';
@@ -148,10 +149,13 @@ const defaultSerializerConfig = {
state.renderInline(node);
state.ensureNewLine();
}),
- [FootnoteReference.name]: preserveUnchanged((state, node) => {
- state.write(`[^${node.attrs.identifier}]`);
+ [FootnoteReference.name]: preserveUnchanged({
+ render: (state, node) => {
+ state.write(`[^${node.attrs.identifier}]`);
+ },
+ inline: true,
}),
- [Frontmatter.name]: (state, node) => {
+ [Frontmatter.name]: preserveUnchanged((state, node) => {
const { language } = node.attrs;
const syntax = {
toml: '+++',
@@ -164,19 +168,41 @@ const defaultSerializerConfig = {
state.ensureNewLine();
state.write(syntax);
state.closeBlock(node);
- },
+ }),
[Figure.name]: renderHTMLNode('figure'),
[FigureCaption.name]: renderHTMLNode('figcaption'),
[HardBreak.name]: preserveUnchanged(renderHardBreak),
[Heading.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.heading),
[HorizontalRule.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.horizontal_rule),
- [Image.name]: preserveUnchanged(renderImage),
+ [Image.name]: preserveUnchanged({
+ render: renderImage,
+ inline: true,
+ }),
[ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item),
[OrderedList.name]: preserveUnchanged(renderOrderedList),
[Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph),
[Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text);
},
+ [ReferenceDefinition.name]: preserveUnchanged({
+ render: (state, node, parent, index, same, sourceMarkdown) => {
+ const nextSibling = parent.maybeChild(index + 1);
+
+ state.text(same ? sourceMarkdown : node.textContent, false);
+
+ /**
+ * Do not insert a blank line between reference definitions
+ * because it isn’t necessary and a more compact text format
+ * is preferred.
+ */
+ if (!nextSibling || nextSibling.type.name !== ReferenceDefinition.name) {
+ state.closeBlock(node);
+ } else {
+ state.ensureNewLine();
+ }
+ },
+ overwriteSourcePreservationStrategy: true,
+ }),
[TableOfContents.name]: (state, node) => {
state.write('[[_TOC_]]');
state.closeBlock(node);
diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
index 8e2c066e011..8a15633708f 100644
--- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
@@ -1,4 +1,5 @@
import { render } from '~/lib/gfm';
+import { isValidAttribute } from '~/lib/dompurify';
import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter';
const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del'];
@@ -125,6 +126,8 @@ const factorySpecs = {
selector: 'img',
getAttrs: (hastNode) => ({
src: hastNode.properties.src,
+ canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src,
+ isReference: hastNode.properties.isReference === 'true',
title: hastNode.properties.title,
alt: hastNode.properties.alt,
}),
@@ -154,7 +157,9 @@ const factorySpecs = {
type: 'mark',
selector: 'a',
getAttrs: (hastNode) => ({
+ canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.href,
href: hastNode.properties.href,
+ isReference: hastNode.properties.isReference === 'true',
title: hastNode.properties.title,
}),
},
@@ -170,6 +175,55 @@ const factorySpecs = {
type: 'ignore',
selector: (hastNode) => hastNode.type === 'comment',
},
+
+ referenceDefinition: {
+ type: 'block',
+ selector: 'referencedefinition',
+ getAttrs: (hastNode) => ({
+ title: hastNode.properties.title,
+ url: hastNode.properties.url,
+ identifier: hastNode.properties.identifier,
+ }),
+ },
+
+ frontmatter: {
+ type: 'block',
+ selector: 'frontmatter',
+ getAttrs: (hastNode) => ({
+ language: hastNode.properties.language,
+ }),
+ },
+};
+
+const SANITIZE_ALLOWLIST = ['level', 'identifier', 'numeric', 'language', 'url', 'isReference'];
+
+const sanitizeAttribute = (attributeName, attributeValue, hastNode) => {
+ if (!attributeValue || SANITIZE_ALLOWLIST.includes(attributeName)) {
+ return attributeValue;
+ }
+
+ /**
+ * This is a workaround to validate the value of the canonicalSrc
+ * attribute using DOMPurify without passing the attribute name. canonicalSrc
+ * is not an allowed attribute in DOMPurify therefore the library will remove
+ * it regardless of its value.
+ *
+ * We want to preserve canonicalSrc, and we also want to make sure that its
+ * value is sanitized.
+ */
+ const validateAttributeAs = attributeName === 'canonicalSrc' ? 'src' : attributeName;
+
+ if (!isValidAttribute(hastNode.tagName, validateAttributeAs, attributeValue)) {
+ return null;
+ }
+
+ return attributeValue;
+};
+
+const attributeTransformer = {
+ transform: (attributeName, attributeValue, hastNode) => {
+ return sanitizeAttribute(attributeName, attributeValue, hastNode);
+ },
};
export default () => {
@@ -183,9 +237,20 @@ export default () => {
factorySpecs,
tree,
wrappableTags,
+ attributeTransformer,
markdown,
}),
- skipRendering: ['footnoteReference', 'footnoteDefinition', 'code'],
+ skipRendering: [
+ 'footnoteReference',
+ 'footnoteDefinition',
+ 'code',
+ 'definition',
+ 'linkReference',
+ 'imageReference',
+ 'yaml',
+ 'toml',
+ 'json',
+ ],
});
return { document };
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 7d5e718b41c..41114571df7 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -1,4 +1,5 @@
-import { uniq, isString, omit } from 'lodash';
+import { uniq, isString, omit, isFunction } from 'lodash';
+import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility';
const defaultAttrs = {
td: { colspan: 1, rowspan: 1, colwidth: null },
@@ -306,12 +307,15 @@ export function renderHardBreak(state, node, parent, index) {
}
export function renderImage(state, node) {
- const { alt, canonicalSrc, src, title } = node.attrs;
+ const { alt, canonicalSrc, src, title, isReference } = node.attrs;
if (isString(src) || isString(canonicalSrc)) {
const quotedTitle = title ? ` ${state.quote(title)}` : '';
+ const sourceExpression = isReference
+ ? `[${canonicalSrc}]`
+ : `(${state.esc(canonicalSrc || src)}${quotedTitle})`;
- state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
+ state.write(`![${state.esc(alt || '')}]${sourceExpression}`);
}
}
@@ -327,16 +331,28 @@ export function renderCodeBlock(state, node) {
state.closeBlock(node);
}
-export function preserveUnchanged(render) {
+const expandPreserveUnchangedConfig = (configOrRender) =>
+ isFunction(configOrRender)
+ ? { render: configOrRender, overwriteSourcePreservationStrategy: false, inline: false }
+ : configOrRender;
+
+export function preserveUnchanged(configOrRender) {
return (state, node, parent, index) => {
+ const { render, overwriteSourcePreservationStrategy, inline } = expandPreserveUnchangedConfig(
+ configOrRender,
+ );
+
const { sourceMarkdown } = node.attrs;
const same = state.options.changeTracker.get(node);
- if (same) {
+ if (same && !overwriteSourcePreservationStrategy) {
state.write(sourceMarkdown);
- state.closeBlock(node);
+
+ if (!inline) {
+ state.closeBlock(node);
+ }
} else {
- render(state, node, parent, index);
+ render(state, node, parent, index, same, sourceMarkdown);
}
};
}
@@ -488,24 +504,16 @@ const linkType = (sourceMarkdown) => {
return LINK_HTML;
};
-const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, '');
-
-const normalizeUrl = (url) => decodeURIComponent(removeUrlProtocol(url));
+const normalizeUrl = (url) => decodeURIComponent(removeLastSlashInUrlPath(removeUrlProtocol(url)));
/**
- * Validates that the provided URL is well-formed
+ * Validates that the provided URL is a valid GFM autolink
*
* @param {String} url
- * @returns Returns true when the browser’s URL constructor
- * can successfully parse the URL string
+ * @returns Returns true when the URL is a valid GFM autolink
*/
-const isValidUrl = (url) => {
- try {
- return new URL(url) && true;
- } catch {
- return false;
- }
-};
+const isValidAutolinkURL = (url) =>
+ /(https?:\/\/)?([\w-])+\.{1}([a-zA-Z]{2,63})([/\w-]*)*\/?\??([^#\n\r]*)?#?([^\n\r]*)/.test(url);
const findChildWithMark = (mark, parent) => {
let child;
@@ -542,7 +550,7 @@ const isAutoLink = (linkMark, parent) => {
if (
!child ||
!child.isText ||
- !isValidUrl(href) ||
+ !isValidAutolinkURL(href) ||
normalizeUrl(child.text) !== normalizeUrl(href)
) {
return false;
@@ -582,7 +590,11 @@ export const link = {
return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : '';
}
- const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
+ const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs;
+
+ if (isReference) {
+ return `][${state.esc(canonicalSrc || href)}]`;
+ }
if (linkType(sourceMarkdown) === LINK_HTML) {
return closeTag('a');
diff --git a/app/assets/javascripts/content_editor/services/table_of_contents_utils.js b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js
new file mode 100644
index 00000000000..dad917b2270
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js
@@ -0,0 +1,67 @@
+export function fillEmpty(headings) {
+ for (let i = 0; i < headings.length; i += 1) {
+ let j = headings[i - 1]?.level || 0;
+ if (headings[i].level - j > 1) {
+ while (j < headings[i].level) {
+ headings.splice(i, 0, { level: j + 1, text: '' });
+ j += 1;
+ }
+ }
+ }
+
+ return headings;
+}
+
+const exitHeadingBranch = (heading, targetLevel) => {
+ let currentHeading = heading;
+
+ while (currentHeading.level > targetLevel) {
+ currentHeading = currentHeading.parent;
+ }
+
+ return currentHeading;
+};
+
+export function toTree(headings) {
+ fillEmpty(headings);
+
+ const tree = [];
+ let currentHeading;
+ for (let i = 0; i < headings.length; i += 1) {
+ const heading = headings[i];
+ if (heading.level === 1) {
+ const h = { ...heading, subHeadings: [] };
+ tree.push(h);
+ currentHeading = h;
+ } else if (heading.level > currentHeading.level) {
+ const h = { ...heading, subHeadings: [], parent: currentHeading };
+ currentHeading.subHeadings.push(h);
+ currentHeading = h;
+ } else if (heading.level <= currentHeading.level) {
+ currentHeading = exitHeadingBranch(currentHeading, heading.level - 1);
+
+ const h = { ...heading, subHeadings: [], parent: currentHeading };
+ (currentHeading?.subHeadings || headings).push(h);
+ currentHeading = h;
+ }
+ }
+
+ return tree;
+}
+
+export function getHeadings(editor) {
+ const headings = [];
+
+ editor.state.doc.descendants((node) => {
+ if (node.type.name !== 'heading') return false;
+
+ headings.push({
+ level: node.attrs.level,
+ text: node.textContent,
+ });
+
+ return true;
+ });
+
+ return toTree(headings);
+}
diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js
index 79f5c701fb8..8b432b2041a 100644
--- a/app/assets/javascripts/contributors/stores/getters.js
+++ b/app/assets/javascripts/contributors/stores/getters.js
@@ -4,15 +4,15 @@ export const parsedData = (state) => {
const byAuthorEmail = {};
const total = {};
- state.chartData.forEach(({ date, author_name, author_email }) => {
+ state.chartData.forEach(({ date, author_name: name, author_email: email }) => {
total[date] = total[date] ? total[date] + 1 : 1;
- const normalizedEmail = author_email.toLowerCase();
+ const normalizedEmail = email.toLowerCase();
const authorData = byAuthorEmail[normalizedEmail];
if (!authorData) {
byAuthorEmail[normalizedEmail] = {
- name: author_name,
+ name,
commits: 1,
dates: {
[date]: 1,
diff --git a/app/assets/javascripts/crm/components/form.vue b/app/assets/javascripts/crm/components/form.vue
index 72def54aedf..ea6a6892bbd 100644
--- a/app/assets/javascripts/crm/components/form.vue
+++ b/app/assets/javascripts/crm/components/form.vue
@@ -1,5 +1,13 @@
<script>
-import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlDrawer,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInput,
+ GlFormSelect,
+} from '@gitlab/ui';
import { get as getPropValueByPath, isEmpty } from 'lodash';
import { produce } from 'immer';
import { MountingPortal } from 'portal-vue';
@@ -26,6 +34,7 @@ export default {
GlAlert,
GlButton,
GlDrawer,
+ GlFormCheckbox,
GlFormGroup,
GlFormInput,
GlFormSelect,
@@ -113,7 +122,9 @@ export default {
const { fields, model } = this;
return fields.some((field) => {
- return field.required && isEmpty(model[field.name]);
+ return (
+ field.required && isEmpty(model[field.name]) && typeof model[field.name] !== 'boolean'
+ );
});
},
variables() {
@@ -216,6 +227,8 @@ export default {
});
},
getFieldLabel(field) {
+ if (field.bool) return null;
+
const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`;
return field.label + optionalSuffix;
},
@@ -273,6 +286,9 @@ export default {
v-model="model[field.name]"
:options="field.values"
/>
+ <gl-form-checkbox v-else-if="field.bool" :id="field.name" v-model="model[field.name]"
+ ><span class="gl-font-weight-bold">{{ field.label }}</span></gl-form-checkbox
+ >
<gl-form-input v-else :id="field.name" v-bind="field.input" v-model="model[field.name]" />
</gl-form-group>
<span class="gl-float-right">
diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js
index 3b085837aea..815289e075e 100644
--- a/app/assets/javascripts/crm/constants.js
+++ b/app/assets/javascripts/crm/constants.js
@@ -1,3 +1,7 @@
export const INDEX_ROUTE_NAME = 'index';
export const NEW_ROUTE_NAME = 'new';
export const EDIT_ROUTE_NAME = 'edit';
+export const trackViewsOptions = {
+ category: 'Customer Relations' /* eslint-disable-line @gitlab/require-i18n-strings */,
+ action: 'view_contacts_list',
+};
diff --git a/app/assets/javascripts/crm/contacts/bundle.js b/app/assets/javascripts/crm/contacts/bundle.js
index f49ec64210f..fe62b7cfbe3 100644
--- a/app/assets/javascripts/crm/contacts/bundle.js
+++ b/app/assets/javascripts/crm/contacts/bundle.js
@@ -3,6 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import CrmContactsRoot from './components/contacts_root.vue';
import routes from './routes';
@@ -21,7 +22,14 @@ export default () => {
return false;
}
- const { basePath, groupFullPath, groupIssuesPath, canAdminCrmContact, groupId } = el.dataset;
+ const {
+ basePath,
+ groupFullPath,
+ groupIssuesPath,
+ canAdminCrmContact,
+ groupId,
+ textQuery,
+ } = el.dataset;
const router = new VueRouter({
base: basePath,
@@ -33,7 +41,13 @@ export default () => {
el,
router,
apolloProvider,
- provide: { groupFullPath, groupIssuesPath, canAdminCrmContact, groupId },
+ provide: {
+ groupFullPath,
+ groupIssuesPath,
+ canAdminCrmContact: parseBoolean(canAdminCrmContact),
+ groupId,
+ textQuery,
+ },
render(createElement) {
return createElement(CrmContactsRoot);
},
diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
index f114ffedfe6..b29089519e2 100644
--- a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
+++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
@@ -57,7 +57,7 @@ export default {
getQuery() {
return {
query: getGroupContactsQuery,
- variables: { groupFullPath: this.groupFullPath },
+ variables: { groupFullPath: this.groupFullPath, ids: [this.contactGraphQLId] },
};
},
title() {
@@ -74,7 +74,7 @@ export default {
return { groupId: this.groupGraphQLId };
},
fields() {
- return [
+ const fields = [
{ name: 'firstName', label: __('First name'), required: true },
{ name: 'lastName', label: __('Last name'), required: true },
{ name: 'email', label: __('Email'), required: true },
@@ -86,6 +86,11 @@ export default {
},
{ name: 'description', label: __('Description') },
];
+
+ if (this.isEditMode)
+ fields.push({ name: 'active', label: s__('Crm|Active'), required: true, bool: true });
+
+ return fields;
},
organizationSelectValues() {
const values = this.organizations.map((o) => {
diff --git a/app/assets/javascripts/crm/contacts/components/contacts_root.vue b/app/assets/javascripts/crm/contacts/components/contacts_root.vue
index 9d6f34c73b7..562363ff88e 100644
--- a/app/assets/javascripts/crm/contacts/components/contacts_root.vue
+++ b/app/assets/javascripts/crm/contacts/components/contacts_root.vue
@@ -1,36 +1,54 @@
<script>
-import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants';
-import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql';
+import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
+import {
+ bodyTrClass,
+ initialPaginationState,
+} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
+import { convertToSnakeCase } from '~/lib/utils/text_utility';
+import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME, trackViewsOptions } from '../../constants';
+import getGroupContacts from './graphql/get_group_contacts.query.graphql';
+import getGroupContactsCountByState from './graphql/get_group_contacts_count_by_state.graphql';
export default {
components: {
- GlAlert,
GlButton,
GlLoadingIcon,
GlTable,
+ PaginatedTableWithSearchAndTabs,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath'],
+ inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath', 'textQuery'],
data() {
return {
- contacts: [],
+ contacts: { list: [] },
+ contactsCount: {},
error: false,
+ filteredByStatus: '',
+ pagination: initialPaginationState,
+ statusFilter: 'all',
+ searchTerm: this.textQuery,
+ sort: 'LAST_NAME_ASC',
+ sortDesc: false,
};
},
apollo: {
contacts: {
- query() {
- return getGroupContactsQuery;
- },
+ query: getGroupContacts,
variables() {
return {
groupFullPath: this.groupFullPath,
+ searchTerm: this.searchTerm,
+ state: this.statusFilter,
+ sort: this.sort,
+ firstPageSize: this.pagination.firstPageSize,
+ lastPageSize: this.pagination.lastPageSize,
+ prevPageCursor: this.pagination.prevPageCursor,
+ nextPageCursor: this.pagination.nextPageCursor,
};
},
update(data) {
@@ -40,19 +58,52 @@ export default {
this.error = true;
},
},
+ contactsCount: {
+ query: getGroupContactsCountByState,
+ variables() {
+ return {
+ groupFullPath: this.groupFullPath,
+ searchTerm: this.searchTerm,
+ };
+ },
+ update(data) {
+ return data?.group?.contactStateCounts;
+ },
+ error() {
+ this.error = true;
+ },
+ },
},
computed: {
isLoading() {
return this.$apollo.queries.contacts.loading;
},
- canAdmin() {
- return parseBoolean(this.canAdminCrmContact);
+ tbodyTrClass() {
+ return {
+ [bodyTrClass]: !this.loading && !this.isEmpty,
+ };
},
},
methods: {
+ errorAlertDismissed() {
+ this.error = true;
+ },
extractContacts(data) {
const contacts = data?.group?.contacts?.nodes || [];
- return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName));
+ const pageInfo = data?.group?.contacts?.pageInfo || {};
+ return {
+ list: contacts,
+ pageInfo,
+ };
+ },
+ fetchSortedData({ sortBy, sortDesc }) {
+ const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
+ const sortingDirection = sortDesc ? 'DESC' : 'ASC';
+ this.pagination = initialPaginationState;
+ this.sort = `${sortingColumn}_${sortingDirection}`;
+ },
+ filtersChanged({ searchTerm }) {
+ this.searchTerm = searchTerm;
},
getIssuesPath(path, value) {
return `${path}?crm_contact_id=${value}`;
@@ -60,6 +111,13 @@ export default {
getEditRoute(id) {
return { name: this.$options.EDIT_ROUTE_NAME, params: { id } };
},
+ pageChanged(pagination) {
+ this.pagination = pagination;
+ },
+ statusChanged({ filters, status }) {
+ this.statusFilter = filters;
+ this.filteredByStatus = status;
+ },
},
fields: [
{ key: 'firstName', sortable: true },
@@ -92,57 +150,109 @@ export default {
},
EDIT_ROUTE_NAME,
NEW_ROUTE_NAME,
+ statusTabs: [
+ {
+ title: __('Active'),
+ status: 'ACTIVE',
+ filters: 'active',
+ },
+ {
+ title: __('Inactive'),
+ status: 'INACTIVE',
+ filters: 'inactive',
+ },
+ {
+ title: __('All'),
+ status: 'ALL',
+ filters: 'all',
+ },
+ ],
+ trackViewsOptions,
+ emptyArray: [],
};
</script>
<template>
<div>
- <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false">
- {{ $options.i18n.errorText }}
- </gl-alert>
- <div
- class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
+ <paginated-table-with-search-and-tabs
+ :show-items="true"
+ :show-error-msg="false"
+ :i18n="$options.i18n"
+ :items="contacts.list"
+ :page-info="contacts.pageInfo"
+ :items-count="contactsCount"
+ :status-tabs="$options.statusTabs"
+ :track-views-options="$options.trackViewsOptions"
+ :filter-search-tokens="$options.emptyArray"
+ filter-search-key="contacts"
+ @page-changed="pageChanged"
+ @tabs-changed="statusChanged"
+ @filters-changed="filtersChanged"
+ @error-alert-dismissed="errorAlertDismissed"
>
- <h2 class="gl-font-size-h2 gl-my-0">
- {{ $options.i18n.title }}
- </h2>
- <div v-if="canAdmin">
- <router-link :to="{ name: $options.NEW_ROUTE_NAME }">
- <gl-button variant="confirm" data-testid="new-contact-button">
+ <template #header-actions>
+ <router-link v-if="canAdminCrmContact" :to="{ name: $options.NEW_ROUTE_NAME }">
+ <gl-button class="gl-my-3 gl-mr-5" variant="confirm" data-testid="new-contact-button">
{{ $options.i18n.newContact }}
</gl-button>
</router-link>
- </div>
- </div>
- <router-view />
- <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
- <gl-table
- v-else
- class="gl-mt-5"
- :items="contacts"
- :fields="$options.fields"
- :empty-text="$options.i18n.emptyText"
- show-empty
- >
- <template #cell(id)="{ value: id }">
- <gl-button
- v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
- class="gl-mr-3"
- data-testid="issues-link"
- icon="issues"
- :aria-label="$options.i18n.issuesButtonLabel"
- :href="getIssuesPath(groupIssuesPath, id)"
- />
- <router-link :to="getEditRoute(id)">
- <gl-button
- v-if="canAdmin"
- v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
- data-testid="edit-contact-button"
- icon="pencil"
- :aria-label="$options.i18n.editButtonLabel"
- />
- </router-link>
</template>
- </gl-table>
+
+ <template #title>
+ {{ $options.i18n.title }}
+ </template>
+
+ <template #table>
+ <gl-table
+ :items="contacts.list"
+ :fields="$options.fields"
+ :busy="isLoading"
+ stacked="md"
+ :tbody-tr-class="tbodyTrClass"
+ sort-direction="asc"
+ :sort-desc.sync="sortDesc"
+ sort-by="createdAt"
+ show-empty
+ no-local-sorting
+ sort-icon-left
+ fixed
+ @sort-changed="fetchSortedData"
+ >
+ <template #cell(id)="{ value: id }">
+ <gl-button
+ v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
+ class="gl-mr-3"
+ data-testid="issues-link"
+ icon="issues"
+ :aria-label="$options.i18n.issuesButtonLabel"
+ :href="getIssuesPath(groupIssuesPath, id)"
+ />
+ <router-link :to="getEditRoute(id)">
+ <gl-button
+ v-if="canAdminCrmContact"
+ v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
+ data-testid="edit-contact-button"
+ icon="pencil"
+ :aria-label="$options.i18n.editButtonLabel"
+ />
+ </router-link>
+ </template>
+
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ </template>
+
+ <template #empty>
+ <span v-if="error">
+ {{ $options.i18n.errorText }}
+ </span>
+ <span v-else>
+ {{ $options.i18n.emptyText }}
+ </span>
+ </template>
+ </gl-table>
+ </template>
+ </paginated-table-with-search-and-tabs>
+ <router-view />
</div>
</template>
diff --git a/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql
index cef4083b446..545ddbf5f72 100644
--- a/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql
+++ b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql
@@ -1,13 +1,12 @@
fragment ContactFragment on CustomerRelationsContact {
- __typename
id
firstName
lastName
email
phone
description
+ active
organization {
- __typename
id
name
}
diff --git a/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql
index 2a8150e42e3..f04d02122fc 100644
--- a/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql
+++ b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql
@@ -1,13 +1,37 @@
#import "./crm_contact_fields.fragment.graphql"
-query contacts($groupFullPath: ID!) {
+query contacts(
+ $groupFullPath: ID!
+ $state: CustomerRelationsContactState
+ $searchTerm: String
+ $sort: ContactSort
+ $firstPageSize: Int
+ $lastPageSize: Int
+ $prevPageCursor: String = ""
+ $nextPageCursor: String = ""
+ $ids: [CustomerRelationsContactID!]
+) {
group(fullPath: $groupFullPath) {
- __typename
id
- contacts {
+ contacts(
+ state: $state
+ search: $searchTerm
+ sort: $sort
+ first: $firstPageSize
+ last: $lastPageSize
+ after: $nextPageCursor
+ before: $prevPageCursor
+ ids: $ids
+ ) {
nodes {
...ContactFragment
}
+ pageInfo {
+ hasNextPage
+ endCursor
+ hasPreviousPage
+ startCursor
+ }
}
}
}
diff --git a/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql
new file mode 100644
index 00000000000..6b591240096
--- /dev/null
+++ b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql
@@ -0,0 +1,11 @@
+query contactsCountByState($groupFullPath: ID!, $searchTerm: String) {
+ group(fullPath: $groupFullPath) {
+ __typename
+ id
+ contactStateCounts(search: $searchTerm) {
+ all
+ active
+ inactive
+ }
+ }
+}
diff --git a/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql
index 4adc5742d3a..d723bf32ef5 100644
--- a/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql
+++ b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql
@@ -1,7 +1,7 @@
fragment OrganizationFragment on CustomerRelationsOrganization {
- __typename
id
name
defaultRate
description
+ active
}
diff --git a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
index e8d8109431e..97b75091cac 100644
--- a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
+++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
@@ -2,7 +2,6 @@
query organizations($groupFullPath: ID!) {
group(fullPath: $groupFullPath) {
- __typename
id
organizations {
nodes {
diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
index 38468e1f4e4..5fd0294b0ea 100644
--- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
+++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
@@ -52,16 +52,23 @@ export default {
additionalCreateParams() {
return { groupId: this.groupGraphQLId };
},
- },
- fields: [
- { name: 'name', label: __('Name'), required: true },
- {
- name: 'defaultRate',
- label: s__('Crm|Default rate'),
- input: { type: 'number', step: '0.01' },
+ fields() {
+ const fields = [
+ { name: 'name', label: __('Name'), required: true },
+ {
+ name: 'defaultRate',
+ label: s__('Crm|Default rate'),
+ input: { type: 'number', step: '0.01' },
+ },
+ { name: 'description', label: __('Description') },
+ ];
+
+ if (this.isEditMode)
+ fields.push({ name: 'active', label: s__('Crm|Active'), required: true, bool: true });
+
+ return fields;
},
- { name: 'description', label: __('Description') },
- ],
+ },
};
</script>
@@ -73,7 +80,7 @@ export default {
:mutation="mutation"
:additional-create-params="additionalCreateParams"
:existing-id="organizationGraphQLId"
- :fields="$options.fields"
+ :fields="fields"
:title="title"
:success-message="successMessage"
/>
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index 1883030e51f..f06544f50c6 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -47,15 +47,13 @@ export default {
'selectedStage',
'selectedStageEvents',
'selectedStageError',
- 'stages',
- 'summary',
- 'permissions',
'stageCounts',
'endpoints',
'features',
'createdBefore',
'createdAfter',
'pagination',
+ 'hasNoAccessError',
]),
...mapGetters(['pathNavigationData', 'filterParams']),
isLoaded() {
@@ -69,9 +67,7 @@ export default {
return !this.isLoadingStage && this.isEmptyStage;
},
displayNoAccess() {
- return (
- !this.isLoadingStage && this.selectedStage?.id && !this.isUserAllowed(this.selectedStage.id)
- );
+ return !this.isLoadingStage && this.hasNoAccessError;
},
displayPathNavigation() {
return this.isLoading || (this.selectedStage && this.pathNavigationData.length);
@@ -137,10 +133,6 @@ export default {
this.isOverviewDialogDismissed = true;
setCookie(OVERVIEW_DIALOG_COOKIE, '1');
},
- isUserAllowed(id) {
- const { permissions } = this;
- return Boolean(permissions?.[id]);
- },
onHandleUpdatePagination(data) {
this.updateStageTablePagination(data);
},
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
index e0156b24f9d..5c2e29bfa74 100644
--- a/app/assets/javascripts/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -1,7 +1,6 @@
import {
getProjectValueStreamStages,
getProjectValueStreams,
- getProjectValueStreamMetrics,
getValueStreamStageMedian,
getValueStreamStageRecords,
getValueStreamStageCounts,
@@ -52,24 +51,6 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => {
commit(types.RECEIVE_VALUE_STREAMS_ERROR, status);
});
};
-export const fetchCycleAnalyticsData = ({
- state: {
- endpoints: { requestPath },
- },
- getters: { legacyFilterParams },
- commit,
-}) => {
- commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
-
- return getProjectValueStreamMetrics(requestPath, legacyFilterParams)
- .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data))
- .catch(() => {
- commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR);
- createFlash({
- message: __('There was an error while fetching value stream summary data.'),
- });
- });
-};
export const fetchStageData = ({
getters: { requestParams, filterParams, paginationParams },
@@ -153,7 +134,6 @@ export const fetchStageCountValues = ({
export const fetchValueStreamStageData = ({ dispatch }) =>
Promise.all([
- dispatch('fetchCycleAnalyticsData'),
dispatch('fetchStageData'),
dispatch('fetchStageMedians'),
dispatch('fetchStageCountValues'),
@@ -178,6 +158,11 @@ export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore
};
export const setInitialStage = ({ dispatch, commit, state: { stages } }, stage) => {
+ if (!stages.length && !stage) {
+ commit(types.SET_NO_ACCESS_ERROR);
+ return null;
+ }
+
const selectedStage = stage || stages[0];
commit(types.SET_SELECTED_STAGE, selectedStage);
return dispatch('fetchValueStreamStageData');
diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js
index 962e1d50d12..6fe353405d4 100644
--- a/app/assets/javascripts/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/cycle_analytics/store/getters.js
@@ -49,12 +49,6 @@ const dateRangeParams = ({ createdAfter, createdBefore }) => ({
created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null,
});
-export const legacyFilterParams = ({ daysInPast }) => {
- return {
- 'cycle_analytics[start_date]': daysInPast,
- };
-};
-
export const filterParams = (state) => {
return {
...filterBarParams(state),
diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
index 0ad67d4e6bd..9376d81f317 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
@@ -5,6 +5,7 @@ export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
export const SET_PAGINATION = 'SET_PAGINATION';
+export const SET_NO_ACCESS_ERROR = 'SET_NO_ACCESS_ERROR';
export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS';
export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS';
@@ -14,10 +15,6 @@ export const REQUEST_VALUE_STREAM_STAGES = 'REQUEST_VALUE_STREAM_STAGES';
export const RECEIVE_VALUE_STREAM_STAGES_SUCCESS = 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS';
export const RECEIVE_VALUE_STREAM_STAGES_ERROR = 'RECEIVE_VALUE_STREAM_STAGES_ERROR';
-export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA';
-export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS';
-export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR';
-
export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA';
export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS';
export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
index 64930a5b51f..8567529caf2 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -41,6 +41,9 @@ export default {
direction: direction || PAGINATION_SORT_DIRECTION_DESC,
});
},
+ [types.SET_NO_ACCESS_ERROR](state) {
+ state.hasNoAccessError = true;
+ },
[types.REQUEST_VALUE_STREAMS](state) {
state.valueStreams = [];
},
@@ -59,23 +62,12 @@ export default {
[types.RECEIVE_VALUE_STREAM_STAGES_ERROR](state) {
state.stages = [];
},
- [types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
- state.isLoading = true;
- state.hasError = false;
- },
- [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
- state.permissions = data?.permissions || {};
- state.hasError = false;
- },
- [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
- state.isLoading = false;
- state.hasError = true;
- },
[types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true;
state.isEmptyStage = false;
state.selectedStageEvents = [];
- state.hasError = false;
+
+ state.hasNoAccessError = false;
},
[types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) {
state.isLoadingStage = false;
@@ -83,13 +75,14 @@ export default {
state.selectedStageEvents = events.map((ev) =>
convertObjectPropsToCamelCase(ev, { deep: true }),
);
- state.hasError = false;
+
+ state.hasNoAccessError = false;
},
[types.RECEIVE_STAGE_DATA_ERROR](state, error) {
state.isLoadingStage = false;
state.isEmptyStage = true;
state.selectedStageEvents = [];
- state.hasError = true;
+
state.selectedStageError = error;
},
[types.REQUEST_STAGE_MEDIANS](state) {
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
index 52bc01cafa4..8d662333afa 100644
--- a/app/assets/javascripts/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -10,9 +10,7 @@ export default () => ({
createdAfter: null,
createdBefore: null,
stages: [],
- summary: [],
analytics: [],
- stats: [],
valueStreams: [],
selectedValueStream: {},
selectedStage: {},
@@ -20,11 +18,10 @@ export default () => ({
selectedStageError: '',
medians: {},
stageCounts: {},
- hasError: false,
+ hasNoAccessError: false,
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
- permissions: {},
pagination: {
page: null,
hasNextPage: false,
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 618096c5bea..ac00af2ab34 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -290,7 +290,6 @@ export default {
<template v-else>
<reply-placeholder
v-if="!isFormVisible"
- class="qa-discussion-reply"
:placeholder-text="__('Reply…')"
@focus="showForm"
/>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 818299e36bd..1b6458668f5 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -90,7 +90,6 @@ export default {
<form class="new-note common-note-form" @submit.prevent>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
- :can-attach-file="false"
:enable-autocomplete="true"
:textarea-value="value"
:markdown-docs-path="markdownDocsPath"
diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql
index 3fe20705ce2..9d9e3a4ede9 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql
@@ -1,11 +1,9 @@
fragment DesignTodoItem on Design {
id
image
- __typename
currentUserTodos(state: pending) {
nodes {
id
- __typename
}
}
}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
index b715633a9f2..09a0b39e1cd 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
@@ -3,7 +3,6 @@ fragment VersionListItem on DesignVersion {
sha
createdAt
author {
- __typename
id
name
avatarUrl
diff --git a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
index 3200327e03d..6fe2cae7346 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
@@ -6,9 +6,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
designs {
...DesignItem
versions {
- __typename
nodes {
- __typename
...VersionListItem
}
}
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 51983b19677..91e35ad3764 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -372,7 +372,7 @@ export default {
</div>
<div
v-show="hasDesigns"
- class="qa-selector-toolbar gl-display-flex gl-align-items-center gl-my-2"
+ class="gl-display-flex gl-align-items-center gl-my-2"
data-testid="design-selector-toolbar"
>
<gl-button
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 8388458b11c..833fbb8789e 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -175,6 +175,7 @@ export default class Diff {
}
}
+ // eslint-disable-next-line class-methods-use-this
formatElementToObject = (element) => {
const key = element.attributes['data-file-hash'].value;
const name = element.attributes['data-diff-toggle-entity'].value;
@@ -192,6 +193,7 @@ export default class Diff {
return $elements.toArray().map(diff.formatElementToObject).reduce(merge);
};
+ // eslint-disable-next-line class-methods-use-this
showRawViewer = (fileHash, elements) => {
if (elements === undefined) return;
@@ -202,6 +204,7 @@ export default class Diff {
elements.rawViewer.classList.remove('hidden');
};
+ // eslint-disable-next-line class-methods-use-this
showRenderedViewer = (fileHash, elements) => {
if (elements === undefined) return;
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index ad163a2a615..0e5acd0928b 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -104,12 +104,9 @@ export default {
class="d-inline-flex mb-2"
/>
<gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group">
- <gl-button
- label
- class="gl-font-monospace"
- data-testid="commit-sha-short-id"
- v-text="commit.short_id"
- />
+ <gl-button label class="gl-font-monospace" data-testid="commit-sha-short-id">{{
+ commit.short_id
+ }}</gl-button>
<modal-copy-button
:text="commit.id"
:title="__('Copy commit SHA')"
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index fc5766a23ef..3082ba0f16f 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -217,52 +217,47 @@ export default {
</script>
<template>
- <div class="diff-grid-row diff-grid-row-full diff-tr line_holder match expansion">
- <div :class="{ parallel: !inline }" class="diff-grid-left diff-grid-2-col left-side">
- <div
- class="diff-td diff-line-num gl-text-center! gl-p-0! gl-w-full! gl-display-flex gl-flex-direction-column"
+ <div>
+ <div
+ class="diff-td diff-line-num gl-text-center! gl-p-0! gl-w-full! gl-display-flex gl-flex-direction-column"
+ >
+ <button
+ v-if="showExpandDown"
+ :title="s__('Diffs|Next 20 lines')"
+ :disabled="loading.down"
+ type="button"
+ class="js-unfold-down gl-rounded-0 gl-border-0 diff-line-expand-button"
+ @click="handleExpandLines($options.EXPAND_DOWN)"
>
- <button
- v-if="showExpandDown"
- v-gl-tooltip.left
- :title="s__('Diffs|Next 20 lines')"
- :disabled="loading.down"
- type="button"
- class="js-unfold-down gl-rounded-0 gl-border-0 diff-line-expand-button"
- @click="handleExpandLines($options.EXPAND_DOWN)"
- >
- <gl-loading-icon v-if="loading.down" size="sm" color="dark" inline />
- <gl-icon v-else name="expand-down" />
- </button>
- <button
- v-if="lineCountBetween !== -1 && lineCountBetween < 20"
- v-gl-tooltip.left
- :title="s__('Diffs|Expand all lines')"
- :disabled="loading.all"
- type="button"
- class="js-unfold-all gl-rounded-0 gl-border-0 diff-line-expand-button"
- @click="handleExpandLines()"
- >
- <gl-loading-icon v-if="loading.all" size="sm" color="dark" inline />
- <gl-icon v-else name="expand" />
- </button>
- <button
- v-if="showExpandUp"
- v-gl-tooltip.left
- :title="s__('Diffs|Previous 20 lines')"
- :disabled="loading.up"
- type="button"
- class="js-unfold gl-rounded-0 gl-border-0 diff-line-expand-button"
- @click="handleExpandLines($options.EXPAND_UP)"
- >
- <gl-loading-icon v-if="loading.up" size="sm" color="dark" inline />
- <gl-icon v-else name="expand-up" />
- </button>
- </div>
- <div
- v-safe-html="line.rich_text"
- class="gl-display-flex! gl-flex-direction-column gl-justify-content-center diff-td line_content left-side gl-white-space-normal!"
- ></div>
+ <gl-loading-icon v-if="loading.down" size="sm" color="dark" inline />
+ <gl-icon v-else name="expand-down" />
+ </button>
+ <button
+ v-if="lineCountBetween !== -1 && lineCountBetween < 20"
+ :title="s__('Diffs|Expand all lines')"
+ :disabled="loading.all"
+ type="button"
+ class="js-unfold-all gl-rounded-0 gl-border-0 diff-line-expand-button"
+ @click="handleExpandLines()"
+ >
+ <gl-loading-icon v-if="loading.all" size="sm" color="dark" inline />
+ <gl-icon v-else name="expand" />
+ </button>
+ <button
+ v-if="showExpandUp"
+ :title="s__('Diffs|Previous 20 lines')"
+ :disabled="loading.up"
+ type="button"
+ class="js-unfold gl-rounded-0 gl-border-0 diff-line-expand-button"
+ @click="handleExpandLines($options.EXPAND_UP)"
+ >
+ <gl-loading-icon v-if="loading.up" size="sm" color="dark" inline />
+ <gl-icon v-else name="expand-up" />
+ </button>
</div>
+ <div
+ v-safe-html="line.rich_text"
+ class="gl-display-flex! gl-flex-direction-column gl-justify-content-center diff-td line_content left-side gl-white-space-normal!"
+ ></div>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index ad406947561..ea94df1ad5b 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -197,17 +197,33 @@ export default {
@mousedown="handleParallelLineMouseDown"
>
<template v-for="(line, index) in diffLines">
- <template v-if="line.isMatchLineLeft || line.isMatchLineRight">
+ <div
+ v-if="line.isMatchLineLeft || line.isMatchLineRight"
+ :key="`expand-${index}`"
+ class="diff-grid-row diff-tr line_holder match expansion"
+ >
<diff-expansion-cell
- :key="`expand-${index}`"
:file="diffFile"
:line="line.left"
:is-top="index === 0"
:is-bottom="index + 1 === diffLinesLength"
:inline="inline"
:line-count-between="getCountBetweenIndex(index)"
+ :class="{ parallel: !inline }"
+ class="diff-grid-left diff-grid-2-col left-side"
/>
- </template>
+ <diff-expansion-cell
+ v-if="!inline"
+ :file="diffFile"
+ :line="line.left"
+ :is-top="index === 0"
+ :is-bottom="index + 1 === diffLinesLength"
+ :inline="inline"
+ :line-count-between="getCountBetweenIndex(index)"
+ :class="{ parallel: !inline }"
+ class="diff-grid-right diff-grid-2-col right-side"
+ />
+ </div>
<diff-row
v-if="!line.isMatchLineLeft && !line.isMatchLineRight"
:key="line.line_code"
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 6c0c9c4e1d0..1cc96ef3d54 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -71,12 +71,15 @@ export const DIFF_FILE_MANUAL_COLLAPSE = 'manual';
export const STATE_IDLING = 'idle';
export const STATE_LOADING = 'loading';
export const STATE_ERRORED = 'errored';
+export const STATE_PENDING_REVIEW = 'pending_comments';
// State machine transitions
export const TRANSITION_LOAD_START = 'LOAD_START';
export const TRANSITION_LOAD_ERROR = 'LOAD_ERROR';
export const TRANSITION_LOAD_SUCCEED = 'LOAD_SUCCEED';
export const TRANSITION_ACKNOWLEDGE_ERROR = 'ACKNOWLEDGE_ERROR';
+export const TRANSITION_HAS_PENDING_REVIEW = 'PENDING_REVIEW';
+export const TRANSITION_NO_REVIEW = 'NO_REVIEW';
export const RENAMED_DIFF_TRANSITIONS = {
[`${STATE_IDLING}:${TRANSITION_LOAD_START}`]: STATE_LOADING,
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index ace507f601a..5e74a7206b3 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -119,10 +119,10 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
const getBatch = (page = startPage) =>
axios
.get(mergeUrlParams({ ...urlParams, page, per_page: perPage }, state.endpointBatch))
- .then(({ data: { pagination, diff_files } }) => {
- totalLoaded += diff_files.length;
+ .then(({ data: { pagination, diff_files: diffFiles } }) => {
+ totalLoaded += diffFiles.length;
- commit(types.SET_DIFF_DATA_BATCH, { diff_files });
+ commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffFiles });
commit(types.SET_BATCH_LOADING_STATE, 'loaded');
if (!scrolledVirtualScroller) {
@@ -138,7 +138,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
}
if (!isNoteLink && !state.currentDiffFileId) {
- commit(types.SET_CURRENT_DIFF_FILE, diff_files[0]?.file_hash);
+ commit(types.SET_CURRENT_DIFF_FILE, diffFiles[0]?.file_hash);
}
if (isNoteLink) {
@@ -293,8 +293,8 @@ export const assignDiscussionsToDiff = (
};
export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
- const { file_hash, line_code, id } = removeDiscussion;
- commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
+ const { file_hash: fileHash, line_code: lineCode, id } = removeDiscussion;
+ commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash, lineCode, id });
};
export const toggleLineDiscussions = ({ commit }, options) => {
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 491c2ced358..e6f7a31e07b 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -28,7 +28,6 @@ function getErrorMessage(res) {
export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
const divHover = '<div class="div-dropzone-hover"></div>';
const iconPaperclip = spriteIcon('paperclip', 'div-dropzone-icon s24');
- const $attachButton = form.find('.button-attach-file');
const $attachingFileMessage = form.find('.attaching-file-message');
const $cancelButton = form.find('.button-cancel-uploading-files');
const $retryLink = form.find('.retry-uploading-link');
@@ -89,8 +88,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
const shouldPad = processingFileCount >= 1;
pasteText(response.link.markdown, shouldPad);
- // Show 'Attach a file' link only when all files have been uploaded.
- if (!processingFileCount) $attachButton.removeClass('hide');
addFileToForm(response.link.url);
},
error: (file, errorMessage = __('Attaching the file failed.'), xhr) => {
@@ -104,7 +101,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
$uploadingErrorContainer.removeClass('hide');
$uploadingErrorMessage.html(message);
- $attachButton.addClass('hide');
$cancelButton.addClass('hide');
},
totaluploadprogress(totalUploadProgress) {
@@ -115,13 +111,11 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
// DOM elements already exist.
// Instead of dynamically generating them,
// we just either hide or show them.
- $attachButton.addClass('hide');
$uploadingErrorContainer.addClass('hide');
$uploadingProgressContainer.removeClass('hide');
$cancelButton.removeClass('hide');
},
removedfile: () => {
- $attachButton.removeClass('hide');
$cancelButton.addClass('hide');
$uploadingProgressContainer.addClass('hide');
$uploadingErrorContainer.addClass('hide');
@@ -282,11 +276,18 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
messageContainer.text(`${attachingMessage} -`);
};
- form.find('.markdown-selector').click(function onMarkdownClick(e) {
+ function handleAttachFile(e) {
e.preventDefault();
$(this).closest('.gfm-form').find('.div-dropzone').click();
formTextarea.focus();
- });
+ }
+
+ form.find('.markdown-selector').click(handleAttachFile);
+
+ const $attachFileButton = form.find('.js-attach-file-button');
+ if ($attachFileButton.length) {
+ $attachFileButton.get(0).addEventListener('click', handleAttachFile);
+ }
return $formDropzone.get(0) ? Dropzone.forElement($formDropzone.get(0)) : null;
}
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
index 194b482c12e..6ce48ddf89a 100644
--- a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
@@ -52,6 +52,7 @@ export default {
:icon="icon"
:title="label"
:aria-label="label"
+ data-qa-selector="editor_toolbar_button"
@click="clickHandler"
/>
</template>
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
index e4ad0bf8e76..bc3cb163c39 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
@@ -1,3 +1,4 @@
+import { KeyMod, KeyCode } from 'monaco-editor';
import { debounce } from 'lodash';
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
import createFlash from '~/flash';
@@ -158,8 +159,8 @@ export class EditorMarkdownPreviewExtension {
if (instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
const actionBasis = {
keybindings: [
- // eslint-disable-next-line no-bitwise,no-undef
- monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
+ // eslint-disable-next-line no-bitwise
+ KeyMod.chord(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KEY_P),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.5,
diff --git a/app/assets/javascripts/editor/graphql/typedefs.graphql b/app/assets/javascripts/editor/graphql/typedefs.graphql
index 2433ebf6c66..49beae033f1 100644
--- a/app/assets/javascripts/editor/graphql/typedefs.graphql
+++ b/app/assets/javascripts/editor/graphql/typedefs.graphql
@@ -12,12 +12,22 @@ type Items {
nodes: [Item]!
}
+input ItemInput {
+ id: ID!
+ label: String!
+ icon: String
+ selected: Boolean
+ group: Int!
+ category: String
+ selectedLabel: String
+}
+
extend type Query {
items: Items
}
extend type Mutation {
- updateToolbarItem(id: ID!, propsToUpdate: Item!): LocalErrors
+ updateToolbarItem(id: ID!, propsToUpdate: ItemInput!): LocalErrors
removeToolbarItems(ids: [ID!]): LocalErrors
- addToolbarItems(items: [Item]): LocalErrors
+ addToolbarItems(items: [ItemInput]): LocalErrors
}
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index e8b96c25965..848ba7dbeef 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -245,6 +245,10 @@
"terraform": {
"$ref": "#/definitions/string_file_list",
"description": "Path to file or list of files with terraform plan(s)."
+ },
+ "cyclonedx": {
+ "$ref": "#/definitions/string_file_list",
+ "markdownDescription": "Path to file or list of files with cyclonedx report(s). [Learn More](https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportscyclonedx)."
}
}
}
@@ -292,7 +296,7 @@
"project": {
"description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
"type": "string",
- "pattern": "\\S/\\S"
+ "pattern": "\\S/\\S|\\$(\\S+)"
},
"ref": {
"description": "Branch/Tag/Commit-hash for the target project.",
@@ -606,11 +610,33 @@
"markdownDescription": "Expression to evaluate whether additional attributes should be provided to the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rulesif)."
},
"changes": {
- "type": "array",
"markdownDescription": "Additional attributes will be provided to job if any of the provided paths matches a modified file. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#ruleschanges).",
- "items": {
- "type": "string"
- }
+ "anyOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["paths"],
+ "properties": {
+ "paths": {
+ "type": "array",
+ "description": "List of file paths.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "compare_to": {
+ "type": "string",
+ "description": "Ref for comparing changes."
+ }
+ }
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
},
"exists": {
"type": "array",
@@ -623,11 +649,11 @@
"markdownDescription": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rulesvariables).",
"anyOf": [
{
- "type": "object",
+ "type": "object",
"additionalProperties": {
"type": ["string", "integer", "array"]
}
- },
+ },
{
"type": "array",
"items": {
@@ -1204,6 +1230,10 @@
"description": "The tag_name must be specified. It can refer to an existing Git tag or can be specified by the user.",
"minLength": 1
},
+ "tag_message": {
+ "type": "string",
+ "description": "Message to use if creating a new annotated tag."
+ },
"description": {
"type": "string",
"description": "Specifies the longer description of the Release.",
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 7ffe8140a21..1e9924246b9 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -627,7 +627,7 @@ export default {
:title="model.name"
class="environment-name table-mobile-content"
>
- <a class="qa-environment-link" :href="environmentPath">
+ <a data-qa-selector="environment_link" :href="environmentPath">
<span v-if="model.size === 1">{{ model.name }}</span>
<span v-else>{{ model.name_without_type }}</span>
</a>
diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue
index 13b9cf14f52..bd67908a6b4 100644
--- a/app/assets/javascripts/environments/components/environments_detail_header.vue
+++ b/app/assets/javascripts/environments/components/environments_detail_header.vue
@@ -135,6 +135,7 @@ export default {
>
<gl-button
v-if="shouldShowExternalUrlButton"
+ v-gl-tooltip.hover
data-testid="metrics-button"
:href="metricsPath"
:title="$options.i18n.metricsButtonTitle"
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
index 2c17c42dd6d..c3ab9cf7fca 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
@@ -4,6 +4,5 @@ query getEnvironmentApp($page: Int, $scope: String) {
stoppedCount
environments
reviewApp
- stoppedCount
}
}
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index 28a3c54cc8f..d9c627f5c93 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -1,8 +1,8 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
const reviewerToken = {
- formattedKey: __('Reviewer'),
+ formattedKey: s__('SearchToken|Reviewer'),
key: 'reviewer',
type: 'string',
param: 'username',
@@ -13,21 +13,6 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
IssuableTokenKeys.tokenKeys.splice(2, 0, reviewerToken);
IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, reviewerToken);
- if (window.gon?.features?.mrAttentionRequests) {
- const attentionRequestedToken = {
- formattedKey: __('Attention'),
- key: 'attention',
- type: 'string',
- param: '',
- symbol: '@',
- icon: 'user',
- tag: '@attention',
- hideNotEqual: true,
- };
- IssuableTokenKeys.tokenKeys.splice(2, 0, attentionRequestedToken);
- IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, attentionRequestedToken);
- }
-
const draftToken = {
token: {
formattedKey: __('Draft'),
diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
index 2c58506985a..acb7449f830 100644
--- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
@@ -1,5 +1,5 @@
import { flattenDeep } from 'lodash';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
export const tokenKeys = [
@@ -13,7 +13,7 @@ export const tokenKeys = [
tag: '@author',
},
{
- formattedKey: __('Assignee'),
+ formattedKey: s__('SearchToken|Assignee'),
key: 'assignee',
type: 'string',
param: 'username',
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 6b1676eca8a..9fb69a3cae3 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -75,6 +75,7 @@ export default {
<project-avatar
class="gl-float-left gl-mr-3"
:project-avatar-url="avatarUrl"
+ :project-id="itemId"
:project-name="itemName"
aria-hidden="true"
/>
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index d4dafbdc94f..01d218438cf 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -7,11 +7,20 @@ import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { s__, __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
+import { state } from '~/sidebar/components/reviewers/sidebar_reviewers.vue';
import AjaxCache from './lib/utils/ajax_cache';
import { spriteIcon } from './lib/utils/common_utils';
import { parsePikadayDate } from './lib/utils/datetime_utility';
import glRegexp from './lib/utils/regexp';
+const USERS_ALIAS = 'users';
+const ISSUES_ALIAS = 'issues';
+const MILESTONES_ALIAS = 'milestones';
+const MERGEREQUESTS_ALIAS = 'mergerequests';
+const LABELS_ALIAS = 'labels';
+const SNIPPETS_ALIAS = 'snippets';
+const CONTACTS_ALIAS = 'contacts';
+export const AT_WHO_ACTIVE_CLASS = 'at-who-active';
/**
* Escapes user input before we pass it to at.js, which
* renders it as HTML in the autocomplete dropdown.
@@ -29,6 +38,15 @@ function escape(string) {
return lodashEscape(string).replace(/\$/g, '&dollar;');
}
+export function showAndHideHelper($input, alias = '') {
+ $input.on(`hidden${alias ? '-' : ''}${alias}.atwho`, () => {
+ $input.removeClass(AT_WHO_ACTIVE_CLASS);
+ });
+ $input.on(`shown${alias ? '-' : ''}${alias}.atwho`, () => {
+ $input.addClass(AT_WHO_ACTIVE_CLASS);
+ });
+}
+
function createMemberSearchString(member) {
return `${member.name.replace(/ /g, '')} ${member.username}`;
}
@@ -237,10 +255,18 @@ class GfmAutoComplete {
callbacks: {
...this.getDefaultCallbacks(),
matcher(flag, subtext) {
- const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
+ const regexp = new RegExp(
+ `(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^ :][^:]*)?$`,
+ 'gi',
+ );
const match = regexp.exec(subtext);
- return match && match.length ? match[1] : null;
+ if (match && match.length) {
+ // Since we have "?" on the group, it's possible it is undefined
+ return match[1] || '';
+ }
+
+ return null;
},
filter(query, items) {
if (GfmAutoComplete.isLoading(items)) {
@@ -265,6 +291,7 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input);
}
setupMembers($input) {
@@ -276,8 +303,6 @@ class GfmAutoComplete {
UNASSIGN_REVIEWER: '/unassign_reviewer',
REASSIGN: '/reassign',
CC: '/cc',
- ATTENTION: '/attention',
- REMOVE_ATTENTION: '/remove_attention',
};
let assignees = [];
let reviewers = [];
@@ -286,7 +311,7 @@ class GfmAutoComplete {
// Team Members
$input.atwho({
at: '@',
- alias: 'users',
+ alias: USERS_ALIAS,
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
const { avatarTag, username, title, icon, availability } = value;
@@ -328,8 +353,7 @@ class GfmAutoComplete {
// Cache assignees & reviewers list for easier filtering later
assignees =
SidebarMediator.singleton?.store?.assignees?.map(createMemberSearchString) || [];
- reviewers =
- SidebarMediator.singleton?.store?.reviewers?.map(createMemberSearchString) || [];
+ reviewers = state.issuable?.reviewers?.nodes?.map(createMemberSearchString) || [];
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
return match && match.length ? match[1] : null;
@@ -356,23 +380,6 @@ class GfmAutoComplete {
} else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) {
// Only include members which are not assigned as a reviewer to Issuable currently
return data.filter((member) => reviewers.includes(member.search));
- } else if (
- command === MEMBER_COMMAND.ATTENTION ||
- command === MEMBER_COMMAND.REMOVE_ATTENTION
- ) {
- const attentionUsers = [
- ...(SidebarMediator.singleton?.store?.assignees || []),
- ...(SidebarMediator.singleton?.store?.reviewers || []),
- ];
- const attentionRequested = command === MEMBER_COMMAND.REMOVE_ATTENTION;
-
- return data.filter((member) =>
- attentionUsers.find(
- (u) =>
- createMemberSearchString(u).includes(member.search) &&
- u.attention_requested === attentionRequested,
- ),
- );
}
return data;
@@ -393,12 +400,13 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, USERS_ALIAS);
}
setupIssues($input) {
$input.atwho({
at: '#',
- alias: 'issues',
+ alias: ISSUES_ALIAS,
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
@@ -427,12 +435,13 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, ISSUES_ALIAS);
}
setupMilestones($input) {
$input.atwho({
at: '%',
- alias: 'milestones',
+ alias: MILESTONES_ALIAS,
searchKey: 'search',
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
@@ -483,12 +492,13 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, MILESTONES_ALIAS);
}
setupMergeRequests($input) {
$input.atwho({
at: '!',
- alias: 'mergerequests',
+ alias: MERGEREQUESTS_ALIAS,
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
@@ -517,6 +527,7 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, MERGEREQUESTS_ALIAS);
}
setupLabels($input) {
@@ -527,7 +538,7 @@ class GfmAutoComplete {
$input.atwho({
at: '~',
- alias: 'labels',
+ alias: LABELS_ALIAS,
searchKey: 'search',
data: GfmAutoComplete.defaultLoadingData,
displayTpl(value) {
@@ -617,12 +628,13 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, LABELS_ALIAS);
}
setupSnippets($input) {
$input.atwho({
at: '$',
- alias: 'snippets',
+ alias: SNIPPETS_ALIAS,
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
@@ -650,13 +662,14 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, SNIPPETS_ALIAS);
}
setupContacts($input) {
$input.atwho({
at: '[contact:',
suffix: ']',
- alias: 'contacts',
+ alias: CONTACTS_ALIAS,
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
@@ -686,6 +699,7 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, CONTACTS_ALIAS);
}
getDefaultCallbacks() {
diff --git a/app/assets/javascripts/gitlab_pages/new.js b/app/assets/javascripts/gitlab_pages/new.js
new file mode 100644
index 00000000000..e23b08dcd56
--- /dev/null
+++ b/app/assets/javascripts/gitlab_pages/new.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
+import createDefaultClient from '~/lib/graphql';
+import Pages from './components/pages_pipeline_wizard.vue';
+
+Vue.use(VueApollo);
+Vue.use(GlToast);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ batchMax: 1,
+ assumeImmutableResults: true,
+ },
+ ),
+});
+
+export default function initPages() {
+ const el = document.querySelector('#js-pages');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ name: 'GitlabPagesNewRoot',
+ apolloProvider,
+ render(createElement) {
+ return createElement(Pages, {
+ props: {
+ ...el.dataset,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql
deleted file mode 100644
index b202ed12f80..00000000000
--- a/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql
+++ /dev/null
@@ -1,7 +0,0 @@
-fragment BlobViewer on SnippetBlobViewer {
- collapsed
- renderError
- tooLarge
- type
- fileType
-}
diff --git a/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql
deleted file mode 100644
index 78a368089a8..00000000000
--- a/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql
+++ /dev/null
@@ -1,4 +0,0 @@
-fragment Iteration on Iteration {
- id
- title
-}
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 45c5cca68cc..eac325f184f 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -3,6 +3,12 @@
"AlertManagementHttpIntegration",
"AlertManagementPrometheusIntegration"
],
+ "CiVariable": [
+ "CiGroupVariable",
+ "CiInstanceVariable",
+ "CiManualVariable",
+ "CiProjectVariable"
+ ],
"CurrentUserTodos": [
"BoardEpic",
"Design",
@@ -134,6 +140,9 @@
"WorkItemWidgetAssignees",
"WorkItemWidgetDescription",
"WorkItemWidgetHierarchy",
+ "WorkItemWidgetLabels",
+ "WorkItemWidgetStartAndDueDate",
+ "WorkItemWidgetVerificationStatus",
"WorkItemWidgetWeight"
]
}
diff --git a/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql
index 12b391e41ac..50ed38e0492 100644
--- a/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql
@@ -1,11 +1,8 @@
query getUser {
currentUser {
id
- __typename
callouts {
- __typename
nodes {
- __typename
featureName
}
}
diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
index 06aea26830d..8011090f1cb 100644
--- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue
+++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
@@ -1,6 +1,6 @@
<script>
import { GlToggle, GlAlert } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
+import { updateGroup } from '~/api/groups_api';
import { I18N_UPDATE_ERROR_MESSAGE, I18N_REFRESH_MESSAGE } from '../constants';
export default {
@@ -9,7 +9,7 @@ export default {
GlAlert,
},
inject: [
- 'updatePath',
+ 'groupId',
'sharedRunnersSetting',
'parentSharedRunnersSetting',
'runnerEnabledValue',
@@ -54,8 +54,7 @@ export default {
this.isLoading = true;
- axios
- .put(this.updatePath, { shared_runners_setting: setting })
+ updateGroup(this.groupId, { shared_runners_setting: setting })
.then(() => {
this.value = setting;
})
diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js
index aeb6d57a11a..e7e104d61b3 100644
--- a/app/assets/javascripts/group_settings/mount_shared_runners.js
+++ b/app/assets/javascripts/group_settings/mount_shared_runners.js
@@ -5,7 +5,7 @@ export default (containerId = 'update-shared-runners-form') => {
const containerEl = document.getElementById(containerId);
const {
- updatePath,
+ groupId,
sharedRunnersSetting,
parentSharedRunnersSetting,
runnerEnabledValue,
@@ -16,7 +16,7 @@ export default (containerId = 'update-shared-runners-form') => {
return new Vue({
el: containerEl,
provide: {
- updatePath,
+ groupId,
sharedRunnersSetting,
parentSharedRunnersSetting,
runnerEnabledValue,
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 7345afb8545..2f182b86d2c 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -16,12 +16,9 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
-import {
- VISIBILITY_TYPE_ICON,
- GROUP_VISIBILITY_TYPE,
- ITEM_TYPE,
- VISIBILITY_PRIVATE,
-} from '../constants';
+import { VISIBILITY_LEVELS_ENUM } from '~/visibility_level/constants';
+import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants';
+
import eventHub from '../event_hub';
import itemActions from './item_actions.vue';
@@ -114,8 +111,8 @@ export default {
shouldShowVisibilityWarning() {
return (
this.action === 'shared' &&
- this.currentGroupVisibility === VISIBILITY_PRIVATE &&
- this.group.visibility !== VISIBILITY_PRIVATE
+ VISIBILITY_LEVELS_ENUM[this.group.visibility] >
+ VISIBILITY_LEVELS_ENUM[this.currentGroupVisibility]
);
},
},
@@ -142,7 +139,7 @@ export default {
shareProjectsWithGroupsHelpPagePath: helpPagePath(
'user/project/members/share_project_with_groups',
{
- anchor: 'share-a-public-project-with-private-group',
+ anchor: 'sharing-projects-with-groups-of-a-higher-restrictive-visibility-level',
},
),
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
@@ -182,6 +179,7 @@ export default {
>
<gl-avatar
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :entity-id="group.id"
:entity-name="group.name"
:src="group.avatarUrl"
:alt="group.name"
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 983535d3e9c..9a1ea2f1812 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -6,6 +6,13 @@ import {
GlInputGroupText,
GlLink,
GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlTruncate,
+ GlSearchBoxByType,
} from '@gitlab/ui';
import { debounce } from 'lodash';
@@ -15,6 +22,11 @@ import { createAlert } from '~/flash';
import { slugify } from '~/lib/utils/text_utility';
import axios from '~/lib/utils/axios_utils';
import { helpPagePath } from '~/helpers/help_page_helper';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+
+import searchGroupsWhereUserCanCreateSubgroups from '../queries/search_groups_where_user_can_create_subgroups.query.graphql';
const DEBOUNCE_DURATION = 1000;
@@ -22,7 +34,6 @@ export default {
i18n: {
inputs: {
name: {
- label: s__('Groups|Group name'),
placeholder: __('My awesome group'),
description: s__(
'Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.',
@@ -30,7 +41,6 @@ export default {
invalidFeedback: s__('Groups|Enter a descriptive name for your group.'),
},
path: {
- label: s__('Groups|Group URL'),
placeholder: __('my-awesome-group'),
invalidFeedbackInvalidPattern: s__(
'GroupSettings|Choose a group path that does not start with a dash or end with a period. It can also contain alphanumeric characters and underscores.',
@@ -40,9 +50,6 @@ export default {
),
validFeedback: s__('Groups|Group path is available.'),
},
- groupId: {
- label: s__('Groups|Group ID'),
- },
},
apiLoadingMessage: s__('Groups|Checking group URL availability...'),
apiErrorMessage: __(
@@ -51,7 +58,7 @@ export default {
changingUrlWarningMessage: s__('Groups|Changing group URL can have unintended side effects.'),
learnMore: s__('Groups|Learn more'),
},
- nameInputSize: { md: 'lg' },
+ inputSize: { md: 'lg' },
changingGroupPathHelpPagePath: helpPagePath('user/group/index', {
anchor: 'change-a-groups-path',
}),
@@ -63,8 +70,35 @@ export default {
GlInputGroupText,
GlLink,
GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlTruncate,
+ GlSearchBoxByType,
+ },
+ apollo: {
+ currentUserGroups: {
+ query: searchGroupsWhereUserCanCreateSubgroups,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ update(data) {
+ return data.currentUser?.groups?.nodes || [];
+ },
+ skip() {
+ const hasNotEnoughSearchCharacters =
+ this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
+
+ return this.shouldSkipQuery || hasNotEnoughSearchCharacters;
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
},
- inject: ['fields', 'basePath', 'mattermostEnabled'],
+ inject: ['fields', 'basePath', 'newSubgroup', 'mattermostEnabled'],
data() {
return {
name: this.fields.name.value,
@@ -76,9 +110,27 @@ export default {
pathFeedbackState: null,
pathInvalidFeedback: null,
activeApiRequestAbortController: null,
+ search: '',
+ currentUserGroups: {},
+ shouldSkipQuery: true,
+ selectedGroup: {
+ id: this.fields.parentId.value,
+ fullPath: this.fields.parentFullPath.value,
+ },
};
},
computed: {
+ inputLabels() {
+ return {
+ name: this.newSubgroup ? s__('Groups|Subgroup name') : s__('Groups|Group name'),
+ path: this.newSubgroup ? s__('Groups|Subgroup slug') : s__('Groups|Group URL'),
+ subgroupPath: s__('Groups|Subgroup URL'),
+ groupId: s__('Groups|Group ID'),
+ };
+ },
+ pathInputSize() {
+ return this.newSubgroup ? {} : this.$options.inputSize;
+ },
computedPath() {
return this.apiSuggestedPath || this.path;
},
@@ -129,9 +181,11 @@ export default {
try {
const {
data: { exists, suggests },
- } = await getGroupPathAvailability(this.path, this.fields.parentId?.value, {
- signal: this.activeApiRequestAbortController.signal,
- });
+ } = await getGroupPathAvailability(
+ this.path,
+ this.selectedGroup.id || this.fields.parentId.value,
+ { signal: this.activeApiRequestAbortController.signal },
+ );
this.apiLoading = false;
@@ -198,6 +252,21 @@ export default {
this.pathInvalidFeedback = this.$options.i18n.inputs.path.invalidFeedbackInvalidPattern;
this.pathFeedbackState = false;
},
+ handleDropdownShown() {
+ if (this.shouldSkipQuery) {
+ this.shouldSkipQuery = false;
+ }
+
+ this.$refs.search.focusInput();
+ },
+ handleDropdownItemClick({ id, fullPath }) {
+ this.selectedGroup = {
+ id: getIdFromGraphQLId(id),
+ fullPath,
+ };
+
+ this.debouncedValidatePath();
+ },
},
};
</script>
@@ -208,10 +277,10 @@ export default {
:id="fields.parentId.id"
type="hidden"
:name="fields.parentId.name"
- :value="fields.parentId.value"
+ :value="selectedGroup.id"
/>
<gl-form-group
- :label="$options.i18n.inputs.name.label"
+ :label="inputLabels.name"
:description="$options.i18n.inputs.name.description"
:label-for="fields.name.id"
:invalid-feedback="$options.i18n.inputs.name.invalidFeedback"
@@ -220,46 +289,102 @@ export default {
<gl-form-input
:id="fields.name.id"
v-model="name"
- class="gl-field-error-ignore"
+ class="gl-field-error-ignore gl-h-auto!"
required
:name="fields.name.name"
:placeholder="$options.i18n.inputs.name.placeholder"
data-qa-selector="group_name_field"
- :size="$options.nameInputSize"
+ :size="$options.inputSize"
:state="nameFeedbackState"
@invalid="handleInvalidName"
/>
</gl-form-group>
- <gl-form-group
- :label="$options.i18n.inputs.path.label"
- :label-for="fields.path.id"
- :description="pathDescription"
- :state="pathFeedbackState"
- :valid-feedback="$options.i18n.inputs.path.validFeedback"
- :invalid-feedback="pathInvalidFeedback"
- >
- <gl-form-input-group>
- <template #prepend>
- <gl-input-group-text class="group-root-path">{{ basePath }}</gl-input-group-text>
- </template>
- <gl-form-input
- :id="fields.path.id"
- class="gl-field-error-ignore"
- :name="fields.path.name"
- :value="computedPath"
- :placeholder="$options.i18n.inputs.path.placeholder"
- :maxlength="fields.path.maxLength"
- :pattern="fields.path.pattern"
- :state="pathFeedbackState"
- :size="$options.nameInputSize"
- required
- data-qa-selector="group_path_field"
- :data-bind-in="mattermostEnabled ? $options.mattermostDataBindName : null"
- @input="handlePathInput"
- @invalid="handleInvalidPath"
- />
- </gl-form-input-group>
- </gl-form-group>
+
+ <div :class="newSubgroup && 'row gl-mb-3'">
+ <gl-form-group v-if="newSubgroup" class="col-sm-6 gl-pr-0" :label="inputLabels.subgroupPath">
+ <div class="input-group gl-flex-nowrap">
+ <gl-button-group class="gl-w-full">
+ <gl-button class="js-group-namespace-button gl-text-truncate gl-flex-grow-0!" label>
+ {{ basePath }}
+ </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"
+ @shown="handleDropdownShown"
+ >
+ <template #button-text>
+ <gl-truncate
+ v-if="selectedGroup.fullPath"
+ :text="selectedGroup.fullPath"
+ position="start"
+ with-tooltip
+ />
+ </template>
+
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ :is-loading="$apollo.queries.currentUserGroups.loading"
+ />
+
+ <template v-if="!$apollo.queries.currentUserGroups.loading">
+ <template v-if="currentUserGroups.length">
+ <gl-dropdown-item
+ v-for="group of currentUserGroups"
+ :key="group.id"
+ data-testid="select_group_dropdown_item"
+ @click="handleDropdownItemClick(group)"
+ >
+ {{ group.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+ </gl-button-group>
+
+ <div class="gl-align-self-center gl-pl-5">
+ <span class="gl-display-none gl-md-display-inline">/</span>
+ </div>
+ </div>
+ </gl-form-group>
+
+ <gl-form-group
+ :class="newSubgroup && 'col-sm-6'"
+ :label="inputLabels.path"
+ :label-for="fields.path.id"
+ :description="pathDescription"
+ :state="pathFeedbackState"
+ :valid-feedback="$options.i18n.inputs.path.validFeedback"
+ :invalid-feedback="pathInvalidFeedback"
+ >
+ <gl-form-input-group>
+ <template v-if="!newSubgroup" #prepend>
+ <gl-input-group-text class="group-root-path">
+ {{ basePath.concat(fields.parentFullPath.value) }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-input
+ :id="fields.path.id"
+ class="gl-field-error-ignore gl-h-auto!"
+ :name="fields.path.name"
+ :value="computedPath"
+ :placeholder="$options.i18n.inputs.path.placeholder"
+ :maxlength="fields.path.maxLength"
+ :pattern="fields.path.pattern"
+ :state="pathFeedbackState"
+ :size="pathInputSize"
+ required
+ data-qa-selector="group_path_field"
+ :data-bind-in="mattermostEnabled ? $options.mattermostDataBindName : null"
+ @input="handlePathInput"
+ @invalid="handleInvalidPath"
+ />
+ </gl-form-input-group>
+ </gl-form-group>
+ </div>
+
<template v-if="isEditingGroup">
<gl-alert class="gl-mb-5" :dismissible="false" variant="warning">
{{ $options.i18n.changingUrlWarningMessage }}
@@ -267,7 +392,7 @@ export default {
>{{ $options.i18n.learnMore }}
</gl-link>
</gl-alert>
- <gl-form-group :label="$options.i18n.inputs.groupId.label" :label-for="fields.groupId.id">
+ <gl-form-group :label="inputLabels.groupId" :label-for="fields.groupId.id">
<gl-form-input
:id="fields.groupId.id"
:value="fields.groupId.value"
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 5706df0dd1b..3a05c308a2a 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -42,7 +42,7 @@ export default {
</script>
<template>
- <div class="groups-list-tree-container qa-groups-list-tree-container">
+ <div class="groups-list-tree-container" data-qa-selector="groups_list_tree_container">
<div
v-if="searchEmpty"
class="has-no-search-results gl-font-style-italic gl-text-center gl-text-gray-600 gl-p-5"
diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue
index e848f10352d..7e7282a27b0 100644
--- a/app/assets/javascripts/groups/components/transfer_group_form.vue
+++ b/app/assets/javascripts/groups/components/transfer_group_form.vue
@@ -70,7 +70,6 @@ export default {
<input type="hidden" name="new_parent_group_id" :value="selectedId" />
</gl-form-group>
<confirm-danger
- button-class="qa-transfer-button"
:disabled="disableSubmitButton"
:phrase="confirmationPhrase"
:button-text="confirmButtonText"
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 29981d09155..0d09ad9442b 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -1,4 +1,9 @@
import { __, s__ } from '~/locale';
+import {
+ VISIBILITY_LEVEL_PRIVATE,
+ VISIBILITY_LEVEL_INTERNAL,
+ VISIBILITY_LEVEL_PUBLIC,
+} from '~/visibility_level/constants';
export const MAX_CHILDREN_COUNT = 20;
@@ -28,32 +33,30 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
-export const VISIBILITY_PUBLIC = 'public';
-export const VISIBILITY_INTERNAL = 'internal';
-export const VISIBILITY_PRIVATE = 'private';
-
export const GROUP_VISIBILITY_TYPE = {
- [VISIBILITY_PUBLIC]: __(
+ [VISIBILITY_LEVEL_PUBLIC]: __(
'Public - The group and any public projects can be viewed without any authentication.',
),
- [VISIBILITY_INTERNAL]: __(
+ [VISIBILITY_LEVEL_INTERNAL]: __(
'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
),
- [VISIBILITY_PRIVATE]: __('Private - The group and its projects can only be viewed by members.'),
+ [VISIBILITY_LEVEL_PRIVATE]: __(
+ 'Private - The group and its projects can only be viewed by members.',
+ ),
};
export const PROJECT_VISIBILITY_TYPE = {
- [VISIBILITY_PUBLIC]: __('Public - The project can be accessed without any authentication.'),
- [VISIBILITY_INTERNAL]: __(
+ [VISIBILITY_LEVEL_PUBLIC]: __('Public - The project can be accessed without any authentication.'),
+ [VISIBILITY_LEVEL_INTERNAL]: __(
'Internal - The project can be accessed by any logged in user except external users.',
),
- [VISIBILITY_PRIVATE]: __(
+ [VISIBILITY_LEVEL_PRIVATE]: __(
'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
),
};
export const VISIBILITY_TYPE_ICON = {
- [VISIBILITY_PUBLIC]: 'earth',
- [VISIBILITY_INTERNAL]: 'shield',
- [VISIBILITY_PRIVATE]: 'lock',
+ [VISIBILITY_LEVEL_PUBLIC]: 'earth',
+ [VISIBILITY_LEVEL_INTERNAL]: 'shield',
+ [VISIBILITY_LEVEL_PRIVATE]: 'lock',
};
diff --git a/app/assets/javascripts/groups/create_edit_form.js b/app/assets/javascripts/groups/create_edit_form.js
index 8ca0e6077e9..330d343b776 100644
--- a/app/assets/javascripts/groups/create_edit_form.js
+++ b/app/assets/javascripts/groups/create_edit_form.js
@@ -1,8 +1,12 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import { parseRailsFormFields } from '~/lib/utils/forms';
import { parseBoolean } from '~/lib/utils/common_utils';
import GroupNameAndPath from './components/group_name_and_path.vue';
+Vue.use(VueApollo);
+
export const initGroupNameAndPath = () => {
const elements = document.querySelectorAll('.js-group-name-and-path');
@@ -12,13 +16,17 @@ export const initGroupNameAndPath = () => {
elements.forEach((element) => {
const fields = parseRailsFormFields(element);
- const { basePath, mattermostEnabled } = element.dataset;
+ const { basePath, newSubgroup, mattermostEnabled } = element.dataset;
return new Vue({
el: element,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient(),
+ }),
provide: {
fields,
basePath,
+ newSubgroup: parseBoolean(newSubgroup),
mattermostEnabled: parseBoolean(mattermostEnabled),
},
render(h) {
diff --git a/app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql b/app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql
new file mode 100644
index 00000000000..c45a31ef387
--- /dev/null
+++ b/app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql
@@ -0,0 +1,11 @@
+query searchGroupsWhereUserCanCreateSubgroups($search: String) {
+ currentUser {
+ id
+ groups(permissionScope: TRANSFER_PROJECTS, search: $search) {
+ nodes {
+ id
+ fullPath
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index 0c4f9640972..f4b939fb20f 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -23,6 +23,9 @@ import {
SEARCH_SHORTCUTS_MIN_CHARACTERS,
SCOPE_TOKEN_MAX_LENGTH,
INPUT_FIELD_PADDING,
+ IS_SEARCHING,
+ IS_FOCUSED,
+ IS_NOT_FOCUSED,
} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
@@ -65,6 +68,7 @@ export default {
data() {
return {
showDropdown: false,
+ isFocused: false,
currentFocusIndex: SEARCH_BOX_INDEX,
};
},
@@ -92,20 +96,18 @@ export default {
if (!this.showDropdown || !this.isLoggedIn) {
return false;
}
-
return this.searchOptions?.length > 0;
},
showDefaultItems() {
return !this.searchText;
},
- showScopes() {
+ searchTermOverMin() {
return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
},
defaultIndex() {
if (this.showDefaultItems) {
return SEARCH_BOX_INDEX;
}
-
return FIRST_DROPDOWN_INDEX;
},
@@ -132,12 +134,15 @@ export default {
count: this.searchOptions.length,
});
},
- searchBarStateIndicator() {
- const hasIcon =
- this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon';
- const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching';
- const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active';
- return `${isActive} ${isSearching} ${hasIcon}`;
+ searchBarClasses() {
+ return {
+ [IS_SEARCHING]: this.searchTermOverMin,
+ [IS_FOCUSED]: this.isFocused,
+ [IS_NOT_FOCUSED]: !this.isFocused,
+ };
+ },
+ showScopeHelp() {
+ return this.searchTermOverMin && this.isFocused;
},
searchBarItem() {
return this.searchOptions?.[0];
@@ -158,11 +163,22 @@ export default {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
openDropdown() {
this.showDropdown = true;
- this.$emit('toggleDropdown', this.showDropdown);
+ this.isFocused = true;
+ this.$emit('expandSearchBar', true);
},
closeDropdown() {
this.showDropdown = false;
- this.$emit('toggleDropdown', this.showDropdown);
+ },
+ collapseAndCloseSearchBar() {
+ // we need a delay on this method
+ // for the search bar not to remove
+ // the clear button from dom
+ // and register clicks on dropdown items
+ setTimeout(() => {
+ this.showDropdown = false;
+ this.isFocused = false;
+ this.$emit('collapseSearchBar');
+ }, 200);
},
submitSearch() {
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
@@ -171,6 +187,7 @@ export default {
return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
},
getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
+ this.openDropdown();
if (!searchTerm) {
this.clearAutocomplete();
} else {
@@ -201,7 +218,7 @@ export default {
role="search"
:aria-label="$options.i18n.searchGitlab"
class="header-search gl-relative gl-rounded-base gl-w-full"
- :class="searchBarStateIndicator"
+ :class="searchBarClasses"
data-testid="header-search-form"
>
<gl-search-box-by-type
@@ -217,12 +234,13 @@ export default {
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
@focus="openDropdown"
@click="openDropdown"
+ @blur="collapseAndCloseSearchBar"
@input="getAutocompleteOptions"
@keydown.enter.stop.prevent="submitSearch"
@keydown.esc.stop.prevent="closeDropdown"
/>
<gl-token
- v-if="showScopes"
+ v-if="showScopeHelp"
v-gl-resize-observer-directive="observeTokenWidth"
class="in-search-scope-help"
:view-only="true"
@@ -242,6 +260,7 @@ export default {
}}
</gl-token>
<kbd
+ v-show="!isFocused"
v-gl-tooltip.bottom.hover.html
class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
:title="$options.i18n.kbdHelp"
@@ -262,9 +281,9 @@ export default {
<div
v-if="showSearchDropdown"
data-testid="header-search-dropdown-menu"
- class="header-search-dropdown-menu gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
+ class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3"
>
- <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2">
+ <div class="header-search-dropdown-content gl-py-2">
<dropdown-keyboard-navigation
v-model="currentFocusIndex"
:max="searchOptions.length - 1"
@@ -278,7 +297,7 @@ export default {
/>
<template v-else>
<header-search-scoped-items
- v-if="showScopes"
+ v-if="searchTermOverMin"
:current-focused-option="currentFocusedOption"
/>
<header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index a026386b2bd..3a20fb0216d 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -51,3 +51,7 @@ export const SCOPE_TOKEN_MAX_LENGTH = 36;
export const INPUT_FIELD_PADDING = 52;
export const HEADER_INIT_EVENTS = ['input', 'focus'];
+
+export const IS_SEARCHING = 'is-searching';
+export const IS_FOCUSED = 'is-focused';
+export const IS_NOT_FOCUSED = 'is-not-focused';
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
index b2c505d569f..f6f5c6a14fa 100644
--- a/app/assets/javascripts/header_search/index.js
+++ b/app/assets/javascripts/header_search/index.js
@@ -26,12 +26,11 @@ export const initHeaderSearchApp = (search = '') => {
render(createElement) {
return createElement(HeaderSearchApp, {
on: {
- toggleDropdown: (isVisible = false) => {
- if (isVisible) {
- navBarEl?.classList.add('header-search-is-active');
- } else {
- navBarEl?.classList.remove('header-search-is-active');
- }
+ expandSearchBar: () => {
+ navBarEl?.classList.add('header-search-is-active');
+ },
+ collapseSearchBar: () => {
+ navBarEl?.classList.remove('header-search-is-active');
},
},
});
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 05a254d3fbf..d02dc67d933 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -156,7 +156,7 @@ export default {
category="primary"
variant="confirm"
block
- class="qa-begin-commit-button"
+ data-qa-selector="begin_commit_button"
data-testid="begin-commit-button"
@click="beginCommit"
>
@@ -184,7 +184,7 @@ export default {
:disabled="commitButtonDisabled"
:loading="submitCommitLoading"
data-testid="commit-button"
- class="qa-commit-button"
+ data-qa-selector="commit_button"
category="primary"
variant="confirm"
type="submit"
diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue
index 0921b5a5424..ba679ae7c9b 100644
--- a/app/assets/javascripts/ide/components/file_templates/bar.vue
+++ b/app/assets/javascripts/ide/components/file_templates/bar.vue
@@ -80,7 +80,9 @@ export default {
<template>
<div
- class="gl-display-flex gl-align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1"
+ class="gl-display-flex gl-align-items-center ide-file-templates gl-relative gl-z-index-1"
+ data-testid="file-templates-bar"
+ data-qa-selector="file_templates_container"
>
<strong class="gl-mr-3"> {{ $options.i18n.barLabel }} </strong>
<gl-dropdown
@@ -97,7 +99,8 @@ export default {
</gl-dropdown>
<gl-dropdown
v-if="showTemplatesDropdown"
- class="gl-mr-6 qa-file-template-dropdown"
+ class="gl-mr-6"
+ data-qa-selector="file_template_dropdown"
:text="$options.i18n.templateListDropdownLabel"
@show="fetchTemplateTypes"
>
diff --git a/app/assets/javascripts/ide/components/ide_project_header.vue b/app/assets/javascripts/ide/components/ide_project_header.vue
index 1c25a8e634d..3296dc2060c 100644
--- a/app/assets/javascripts/ide/components/ide_project_header.vue
+++ b/app/assets/javascripts/ide/components/ide_project_header.vue
@@ -18,6 +18,7 @@ export default {
<div class="context-header ide-context-header">
<a :href="project.web_url" :title="s__('IDE|Go to project')" data-testid="go-to-project-link">
<project-avatar
+ :project-id="project.id"
:project-name="project.name"
:project-avatar-url="project.avatar_url"
:size="48"
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index c9bf84be6ac..737ff49f74c 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -52,7 +52,7 @@ export default {
</script>
<template>
- <div class="ide-file-list qa-file-list">
+ <div class="ide-file-list" data-qa-selector="file_list_container">
<template v-if="showLoading">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
<gl-skeleton-loader />
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index 46128651547..5f67eee5f18 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -51,7 +51,7 @@ export default class Model {
}
get language() {
- return this.model.getModeId();
+ return this.model.getLanguageId();
}
get path() {
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index b4ceec22822..fe687ea9767 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -133,9 +133,6 @@ export default {
this.model = null;
}
},
- helpHtmlConfig: {
- ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
- },
};
</script>
@@ -147,7 +144,7 @@ export default {
:state="valid"
>
<template v-if="!isCheckbox" #description>
- <span v-safe-html:[$options.helpHtmlConfig]="help"></span>
+ <span v-safe-html="help"></span>
</template>
<template v-if="isCheckbox">
@@ -155,7 +152,7 @@ export default {
<gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting">
{{ checkboxLabel || humanizedTitle }}
<template #help>
- <span v-safe-html:[$options.helpHtmlConfig]="help"></span>
+ <span v-safe-html="help"></span>
</template>
</gl-form-checkbox>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index f1f574c6424..7a6f1a953a8 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -192,11 +192,7 @@ export default {
this.integrationActive = integrationActive;
},
},
- descriptionHtmlConfig: {
- ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
- },
helpHtmlConfig: {
- ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
ADD_TAGS: ['use'], // to support icon SVGs
FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes
},
@@ -254,7 +250,7 @@ export default {
{{ $options.billingPlanNames[section.plan] }}
</gl-badge>
</h4>
- <p v-safe-html:[$options.descriptionHtmlConfig]="section.description"></p>
+ <p v-safe-html="section.description"></p>
</div>
<div class="col-lg-8">
diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
index 1255ed01f6d..a8389e32b40 100644
--- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
+++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
@@ -125,6 +125,7 @@ export default {
>
<project-avatar
class="gl-mr-3"
+ :project-id="item.id"
:project-avatar-url="item.avatar_url"
:project-name="item.name"
aria-hidden="true"
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 b71cfbb6112..87f1ed31a7f 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -6,6 +6,9 @@ import {
GlLink,
GlSprintf,
GlFormCheckboxGroup,
+ GlButton,
+ GlCollapse,
+ GlIcon,
} from '@gitlab/ui';
import { partition, isString, uniqueId, isEmpty } from 'lodash';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
@@ -13,7 +16,7 @@ import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
-import { n__ } from '~/locale';
+import { n__, sprintf } from '~/locale';
import {
CLOSE_TO_LIMIT_COUNT,
USERS_FILTER_ALL,
@@ -38,6 +41,9 @@ export default {
GlDropdownItem,
GlSprintf,
GlFormCheckboxGroup,
+ GlButton,
+ GlCollapse,
+ GlIcon,
InviteModalBase,
MembersTokenSelect,
ModalConfetti,
@@ -110,6 +116,8 @@ export default {
mode: 'default',
// Kept in sync with "base"
selectedAccessLevel: undefined,
+ errorsLimit: 2,
+ isErrorsSectionExpanded: false,
};
},
computed: {
@@ -135,7 +143,7 @@ export default {
return n__(
"InviteMembersModal|The following member couldn't be invited",
"InviteMembersModal|The following %d members couldn't be invited",
- Object.keys(this.invalidMembers).length,
+ this.errorList.length,
);
},
tasksToBeDoneEnabled() {
@@ -187,6 +195,29 @@ export default {
? this.$options.labels.placeHolderDisabled
: this.$options.labels.placeHolder;
},
+ errorList() {
+ return Object.entries(this.invalidMembers).map(([member, error]) => {
+ return { member, displayedMemberName: this.tokenName(member), message: error };
+ });
+ },
+ errorsLimited() {
+ return this.errorList.slice(0, this.errorsLimit);
+ },
+ errorsExpanded() {
+ return this.errorList.slice(this.errorsLimit);
+ },
+ shouldErrorsSectionExpand() {
+ return Boolean(this.errorsExpanded.length);
+ },
+ errorCollapseText() {
+ if (this.isErrorsSectionExpanded) {
+ return this.$options.labels.expandedErrors;
+ }
+
+ return sprintf(this.$options.labels.collapsedErrors, {
+ count: this.errorsExpanded.length,
+ });
+ },
},
mounted() {
eventHub.$on('openModal', (options) => {
@@ -311,6 +342,9 @@ export default {
delete this.invalidMembers[memberName(token)];
this.invalidMembers = { ...this.invalidMembers };
},
+ toggleErrorExpansion() {
+ this.isErrorsSectionExpanded = !this.isErrorsSectionExpanded;
+ },
},
labels: MEMBER_MODAL_LABELS,
};
@@ -356,11 +390,37 @@ export default {
data-testid="alert-member-error"
>
{{ $options.labels.memberErrorListText }}
- <ul class="gl-pl-5">
- <li v-for="(error, member) in invalidMembers" :key="member">
- <strong>{{ tokenName(member) }}:</strong> {{ error }}
+ <ul class="gl-pl-5 gl-mb-0">
+ <li v-for="error in errorsLimited" :key="error.member" data-testid="errors-limited-item">
+ <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }}
</li>
</ul>
+ <template v-if="shouldErrorsSectionExpand">
+ <gl-collapse v-model="isErrorsSectionExpanded">
+ <ul class="gl-pl-5 gl-mb-0">
+ <li
+ v-for="error in errorsExpanded"
+ :key="error.member"
+ data-testid="errors-expanded-item"
+ >
+ <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }}
+ </li>
+ </ul>
+ </gl-collapse>
+ <gl-button
+ class="gl-text-decoration-none! gl-shadow-none! gl-mt-3"
+ data-testid="accordion-button"
+ variant="link"
+ @click="toggleErrorExpansion"
+ >
+ {{ errorCollapseText }}
+ <gl-icon
+ name="chevron-down"
+ class="gl-transition-medium"
+ :class="{ 'gl-rotate-180': isErrorsSectionExpanded }"
+ />
+ </gl-button>
+ </template>
</gl-alert>
<user-limit-notification
v-else
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index b2bcb9a5906..2ddb04e1eeb 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -1,10 +1,16 @@
<script>
import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@gitlab/ui';
-import { debounce } from 'lodash';
+import { debounce, isEmpty } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
import { memberName } from '../utils/member_utils';
-import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants';
+import {
+ SEARCH_DELAY,
+ USERS_FILTER_ALL,
+ USERS_FILTER_SAML_PROVIDER_ID,
+ VALID_TOKEN_BACKGROUND,
+ INVALID_TOKEN_BACKGROUND,
+} from '../constants';
export default {
components: {
@@ -75,6 +81,25 @@ export default {
}
return this.$options.defaultQueryOptions;
},
+ hasInvalidMembers() {
+ return !isEmpty(this.invalidMembers);
+ },
+ },
+ watch: {
+ // We might not really want this to be *reactive* since we want the "class" state to be
+ // tied to the specific `selectedToken` such that if the token is removed and re-added, this
+ // state is reset.
+ // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90076#note_1027165312
+ hasInvalidMembers: {
+ handler(updatedInvalidMembers) {
+ // Only update tokens if we receive invalid members
+ if (!updatedInvalidMembers) {
+ return;
+ }
+
+ this.updateTokenClasses();
+ },
+ },
},
methods: {
handleTextInput(query) {
@@ -83,6 +108,12 @@ export default {
this.loading = true;
this.retrieveUsers(query);
},
+ updateTokenClasses() {
+ this.selectedTokens = this.selectedTokens.map((token) => ({
+ ...token,
+ class: this.tokenClass(token),
+ }));
+ },
retrieveUsers: debounce(function debouncedRetrieveUsers() {
return getUsers(this.query, this.queryOptions)
.then((response) => {
@@ -98,6 +129,14 @@ export default {
this.loading = false;
});
}, SEARCH_DELAY),
+ tokenClass(token) {
+ if (this.hasError(token)) {
+ return INVALID_TOKEN_BACKGROUND;
+ }
+
+ // assume success for this token
+ return VALID_TOKEN_BACKGROUND;
+ },
handleInput() {
this.$emit('input', this.selectedTokens);
},
diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
index ae5c3c11386..6c9b1f8e6d0 100644
--- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -10,7 +10,6 @@ import {
CLOSE_TO_LIMIT_MESSAGE,
CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
DANGER_ALERT_TITLE_PERSONAL_NAMESPACE,
- WARNING_ALERT_TITLE_PERSONAL_NAMESPACE,
} from '../constants';
export default {
@@ -46,13 +45,6 @@ export default {
return this.usersLimitDataset.purchasePath;
},
warningAlertTitle() {
- if (this.usersLimitDataset.userNamespace) {
- return sprintf(WARNING_ALERT_TITLE_PERSONAL_NAMESPACE, {
- count: this.freeUsersLimit - this.membersCount,
- members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
- });
- }
-
return sprintf(WARNING_ALERT_TITLE, {
count: this.freeUsersLimit - this.membersCount,
members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 6141e5e9e0b..1ceb63e2146 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -2,6 +2,8 @@ import { s__ } from '~/locale';
export const CLOSE_TO_LIMIT_COUNT = 2;
export const SEARCH_DELAY = 200;
+export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100';
+export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100';
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
name: 'invite_members_for_task',
@@ -77,6 +79,8 @@ export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team memb
export const MEMBER_ERROR_LIST_TEXT = s__(
'InviteMembersModal|Review the invite errors and try again:',
);
+export const COLLAPSED_ERRORS = s__('InviteMembersModal|Show more (%{count})');
+export const EXPANDED_ERRORS = s__('InviteMembersModal|Show less');
export const MEMBER_MODAL_LABELS = {
modal: {
@@ -113,6 +117,8 @@ export const MEMBER_MODAL_LABELS = {
},
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
memberErrorListText: MEMBER_ERROR_LIST_TEXT,
+ collapsedErrors: COLLAPSED_ERRORS,
+ expandedErrors: EXPANDED_ERRORS,
};
export const GROUP_MODAL_LABELS = {
@@ -136,9 +142,6 @@ export const ON_SUBMIT_TRACK_LABEL = 'manage_members_clicked';
export const WARNING_ALERT_TITLE = s__(
'InviteMembersModal|You only have space for %{count} more %{members} in %{name}',
);
-export const WARNING_ALERT_TITLE_PERSONAL_NAMESPACE = s__(
- 'InviteMembersModal|You only have space for %{count} more %{members} in your personal projects',
-);
export const DANGER_ALERT_TITLE = s__(
"InviteMembersModal|You've reached your %{count} %{members} limit for %{name}",
);
@@ -153,12 +156,12 @@ export const REACHED_LIMIT_MESSAGE = s__(
export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.concat(
s__(
- 'InviteMembersModal| To get more members and access to additional paid features, an owner of this namespace can start a trial or upgrade to a paid tier.',
+ 'InviteMembersModal| To get more members and access to additional paid features, an owner of the group can start a trial or upgrade to a paid tier.',
),
);
export const CLOSE_TO_LIMIT_MESSAGE = s__(
- 'InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
+ 'InviteMembersModal|To get more members an owner of the group can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
);
export const CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE = s__(
'InviteMembersModal|To make more space, you can remove members who no longer need access.',
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
index a4be3f205a3..6e2c0ecb5bb 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -20,6 +20,8 @@ export default (function initInviteMembersModal() {
return false;
}
+ const usersLimitDataset = JSON.parse(el.dataset.usersLimitDataset || '{}');
+
inviteMembersModal = new Vue({
el,
name: 'InviteMembersModalRoot',
@@ -38,9 +40,10 @@ export default (function initInviteMembersModal() {
projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
- usersLimitDataset: convertObjectPropsToCamelCase(
- JSON.parse(el.dataset.usersLimitDataset || '{}'),
- ),
+ usersLimitDataset: convertObjectPropsToCamelCase({
+ ...usersLimitDataset,
+ user_namespace: parseBoolean(usersLimitDataset.user_namespace),
+ }),
},
}),
});
diff --git a/app/assets/javascripts/issuable/components/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue
index 11fc032f34f..4f1001e8c3b 100644
--- a/app/assets/javascripts/issuable/components/issue_milestone.vue
+++ b/app/assets/javascripts/issuable/components/issue_milestone.vue
@@ -73,7 +73,9 @@ export default {
<template>
<div ref="milestoneDetails" class="issue-milestone-details">
<gl-icon :size="16" class="gl-mr-2 flex-shrink-0" name="clock" />
- <span class="milestone-title d-inline-block">{{ milestone.title }}</span>
+ <span class="milestone-title gl-display-inline-block gl-text-truncate">{{
+ milestone.title
+ }}</span>
<gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
<span class="bold">{{ __('Milestone') }}</span> <br />
<span>{{ milestone.title }}</span> <br />
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index a505a988360..667c712d3be 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -2,29 +2,35 @@
import '~/commons/bootstrap';
import {
GlIcon,
+ GlLink,
GlTooltip,
GlTooltipDirective,
GlButton,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
+import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import relatedIssuableMixin from '../mixins/related_issuable_mixin';
import IssueAssignees from './issue_assignees.vue';
import IssueMilestone from './issue_milestone.vue';
export default {
- name: 'IssueItem',
components: {
IssueMilestone,
IssueAssignees,
CiIcon,
GlIcon,
+ GlLink,
GlTooltip,
IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
IssueDueDate,
GlButton,
+ WorkItemDetailModal,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -47,6 +53,11 @@ export default {
required: false,
default: '',
},
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
stateTitle() {
@@ -62,6 +73,27 @@ export default {
iconClasses() {
return `${this.iconClass} ic-${this.iconName}`;
},
+ workItemId() {
+ return convertToGraphQLId(TYPE_WORK_ITEM, this.idKey);
+ },
+ },
+ methods: {
+ handleTitleClick(event) {
+ if (this.workItemType === 'TASK') {
+ event.preventDefault();
+ this.$refs.modal.show();
+ this.updateWorkItemIdUrlQuery(this.idKey);
+ }
+ },
+ handleWorkItemDeleted(workItemId) {
+ this.$emit('relatedIssueRemoveRequest', workItemId);
+ },
+ updateWorkItemIdUrlQuery(workItemId) {
+ updateHistory({
+ url: setUrlParams({ work_item_id: workItemId }),
+ replace: true,
+ });
+ },
},
};
</script>
@@ -102,7 +134,13 @@ export default {
class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
/>
- <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a>
+ <gl-link
+ :href="computedPath"
+ class="sortable-link gl-font-weight-normal"
+ @click="handleTitleClick"
+ >
+ {{ title }}
+ </gl-link>
</div>
<!-- Info area: meta, path, and assignees -->
@@ -178,16 +216,15 @@ export default {
<span
v-if="isLocked"
- ref="lockIcon"
v-gl-tooltip
class="gl-px-3 gl-display-inline-block gl-cursor-not-allowed"
:title="lockedMessage"
+ data-testid="lockIcon"
>
<gl-icon name="lock" />
</span>
<gl-button
v-else-if="canRemove"
- ref="removeButton"
v-gl-tooltip
icon="close"
category="tertiary"
@@ -198,5 +235,11 @@ export default {
:aria-label="__('Remove')"
@click="onRemoveRequest"
/>
+ <work-item-detail-modal
+ ref="modal"
+ :work-item-id="workItemId"
+ @close="updateWorkItemIdUrlQuery"
+ @workItemDeleted="handleWorkItemDeleted"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/issuable/issuable_template_selector.js b/app/assets/javascripts/issuable/issuable_template_selector.js
index cce903d388d..6b8f3de8d49 100644
--- a/app/assets/javascripts/issuable/issuable_template_selector.js
+++ b/app/assets/javascripts/issuable/issuable_template_selector.js
@@ -17,7 +17,15 @@ export default class IssuableTemplateSelector extends TemplateSelector {
name: this.dropdown.data('selected'),
};
- if (initialQuery.name) this.requestFile(initialQuery);
+ // Only use the default template if we don't have description data from autosave
+ if (!initialQuery.name && this.dropdown.data('default') && !this.editor.getValue().length) {
+ initialQuery.name = this.dropdown.data('default');
+ }
+
+ if (initialQuery.name) {
+ this.requestFile(initialQuery);
+ this.setToggleText(initialQuery.name);
+ }
$('.reset-template', this.dropdown.parent()).on('click', () => {
this.setInputValueToTemplateContent();
@@ -53,10 +61,14 @@ export default class IssuableTemplateSelector extends TemplateSelector {
}
this.setInputValueToTemplateContent();
- $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template'));
+ this.setToggleText(__('Choose a template'));
this.previousSelectedIndex = null;
}
+ setToggleText(text) {
+ $('.dropdown-toggle-text', this.dropdown).text(text);
+ }
+
setSelectedIndex() {
this.previousSelectedIndex = this.dropdown.data('deprecatedJQueryDropdown').selectedIndex;
}
diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
index 0cafaa1e500..945a3782642 100644
--- a/app/assets/javascripts/issuable/popover/components/issue_popover.vue
+++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
@@ -1,14 +1,26 @@
<script>
-import { GlPopover, GlSkeletonLoader } from '@gitlab/ui';
+import { GlIcon, GlPopover, GlSkeletonLoader, GlTooltipDirective } from '@gitlab/ui';
+import query from 'ee_else_ce/issuable/popover/queries/issue.query.graphql';
+import IssueDueDate from '~/boards/components/issue_due_date.vue';
+import IssueMilestone from '~/issuable/components/issue_milestone.vue';
import StatusBox from '~/issuable/components/status_box.vue';
+import { IssuableStatus } from '~/issues/constants';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import query from '../queries/issue.query.graphql';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
export default {
components: {
+ GlIcon,
GlPopover,
GlSkeletonLoader,
+ IssueDueDate,
+ IssueMilestone,
+ IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
StatusBox,
+ WorkItemTypeIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
@@ -44,6 +56,9 @@ export default {
showDetails() {
return Object.keys(this.issue).length > 0;
},
+ isIssueClosed() {
+ return this.issue?.state === IssuableStatus.Closed;
+ },
},
apollo: {
issue: {
@@ -69,15 +84,46 @@ export default {
</gl-skeleton-loader>
<div v-else-if="showDetails" class="gl-display-flex gl-align-items-center">
<status-box issuable-type="issue" :initial-state="issue.state" />
+ <gl-icon
+ v-if="issue.confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ :title="__('Confidential')"
+ class="gl-text-orange-500 gl-mr-2"
+ :aria-label="__('Confidential')"
+ />
<span class="gl-text-secondary">
{{ __('Opened') }} <time :datetime="issue.createdAt">{{ formattedTime }}</time>
</span>
</div>
<h5 v-if="!$apollo.queries.issue.loading" class="gl-my-3">{{ title }}</h5>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
- <div class="gl-text-secondary">
- {{ `${projectPath}#${iid}` }}
+ <div>
+ <work-item-type-icon v-if="!$apollo.queries.issue.loading" :work-item-type="issue.type" />
+ <span class="gl-text-secondary">{{ `${projectPath}#${iid}` }}</span>
</div>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
+
+ <div v-if="!$apollo.queries.issue.loading" class="gl-display-flex gl-text-secondary gl-mt-2">
+ <issue-due-date
+ v-if="issue.dueDate"
+ :date="issue.dueDate.toString()"
+ :closed="isIssueClosed"
+ tooltip-placement="top"
+ class="gl-mr-4"
+ css-class="gl-display-flex gl-white-space-nowrap"
+ />
+ <issue-weight
+ v-if="issue.weight"
+ :weight="issue.weight"
+ tag-name="span"
+ class="gl-display-flex gl-mr-4"
+ />
+ <issue-milestone
+ v-if="issue.milestone"
+ :milestone="issue.milestone"
+ class="gl-display-flex gl-overflow-hidden"
+ />
+ </div>
</gl-popover>
</template>
diff --git a/app/assets/javascripts/issuable/popover/queries/issue.query.graphql b/app/assets/javascripts/issuable/popover/queries/issue.query.graphql
index 47a62e2b6ea..1bbe6cb6eac 100644
--- a/app/assets/javascripts/issuable/popover/queries/issue.query.graphql
+++ b/app/assets/javascripts/issuable/popover/queries/issue.query.graphql
@@ -6,6 +6,15 @@ query issue($projectPath: ID!, $iid: String!) {
title
createdAt
state
+ confidential
+ dueDate
+ milestone {
+ id
+ title
+ startDate
+ dueDate
+ }
+ type
}
}
}
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 380bb5f5346..22ac37656ea 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -9,7 +9,7 @@ import { IssueType } from '~/issues/constants';
import Issue from '~/issues/issue';
import { initTitleSuggestions, initTypePopover } from '~/issues/new';
import { initRelatedMergeRequests } from '~/issues/related_merge_requests';
-import initRelatedIssues from '~/related_issues';
+import { initRelatedIssues } from '~/related_issues';
import {
initHeaderActions,
initIncidentApp,
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 f567b0f1d68..11911adb401 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -39,13 +39,13 @@ import {
TOKEN_TITLE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
-import {
- IssuableListTabs,
- IssuableStates,
- IssuableTypes,
-} from '~/vue_shared/issuable/list/constants';
+import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { WORK_ITEM_TYPE_ENUM_TASK } from '~/work_items/constants';
import {
CREATED_DESC,
+ defaultTypeTokenOptions,
+ defaultWorkItemTypes,
i18n,
ISSUE_REFERENCE,
MAX_LIST_SIZE,
@@ -67,6 +67,7 @@ import {
TOKEN_TYPE_ORGANIZATION,
TOKEN_TYPE_RELEASE,
TOKEN_TYPE_TYPE,
+ TYPE_TOKEN_TASK_OPTION,
UPDATED_DESC,
urlSortParams,
} from '../constants';
@@ -107,7 +108,6 @@ const CrmOrganizationToken = () =>
export default {
i18n,
IssuableListTabs,
- IssuableTypes: [IssuableTypes.Issue, IssuableTypes.Incident, IssuableTypes.TestCase],
components: {
CsvImportExportButtons,
GlButton,
@@ -123,6 +123,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
inject: [
'autocompleteAwardEmojisPath',
'calendarPath',
@@ -180,9 +181,7 @@ export default {
issues: {
query: getIssuesQuery,
variables() {
- const { types } = this.queryVariables;
-
- return { ...this.queryVariables, types: types ? [types] : this.$options.IssuableTypes };
+ return this.queryVariables;
},
update(data) {
return data[this.namespace]?.issues.nodes ?? [];
@@ -206,9 +205,7 @@ export default {
issuesCounts: {
query: getIssuesCountsQuery,
variables() {
- const { types } = this.queryVariables;
-
- return { ...this.queryVariables, types: types ? [types] : this.$options.IssuableTypes };
+ return this.queryVariables;
},
update(data) {
return data[this.namespace] ?? {};
@@ -240,11 +237,22 @@ export default {
state: this.state,
...this.pageParams,
...this.apiFilterParams,
+ types: this.apiFilterParams.types || this.defaultWorkItemTypes,
};
},
namespace() {
return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
+ defaultWorkItemTypes() {
+ return this.isWorkItemsEnabled
+ ? defaultWorkItemTypes.concat(WORK_ITEM_TYPE_ENUM_TASK)
+ : defaultWorkItemTypes;
+ },
+ typeTokenOptions() {
+ return this.isWorkItemsEnabled
+ ? defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION)
+ : defaultTypeTokenOptions;
+ },
hasSearch() {
return (
this.searchQuery ||
@@ -262,6 +270,9 @@ export default {
isOpenTab() {
return this.state === IssuableStates.Opened;
},
+ isWorkItemsEnabled() {
+ return this.glFeatures.workItems;
+ },
showCsvButtons() {
return this.isProject && this.isSignedIn;
},
@@ -340,11 +351,7 @@ export default {
title: TOKEN_TITLE_TYPE,
icon: 'issues',
token: GlFilteredSearchToken,
- options: [
- { icon: 'issue-type-issue', title: 'issue', value: 'issue' },
- { icon: 'issue-type-incident', title: 'incident', value: 'incident' },
- { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' },
- ],
+ options: this.typeTokenOptions,
},
];
@@ -767,6 +774,7 @@ export default {
:show-page-size-change-controls="showPageSizeControls"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
+ show-work-item-type-icon
@click-tab="handleClickTab"
@dismiss-alert="handleDismissAlert"
@filter="handleFilter"
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index a921eb62e26..38fe4c33792 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -8,6 +8,11 @@ import {
OPERATOR_IS,
OPERATOR_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ WORK_ITEM_TYPE_ENUM_INCIDENT,
+ WORK_ITEM_TYPE_ENUM_ISSUE,
+ WORK_ITEM_TYPE_ENUM_TEST_CASE,
+} from '~/work_items/constants';
export const i18n = {
anonymousSearchingMessage: __('You must sign in to search for specific terms.'),
@@ -147,6 +152,20 @@ export const TOKEN_TYPE_WEIGHT = 'weight';
export const TOKEN_TYPE_CONTACT = 'crm_contact';
export const TOKEN_TYPE_ORGANIZATION = 'crm_organization';
+export const TYPE_TOKEN_TASK_OPTION = { icon: 'task-done', title: 'task', value: 'task' };
+
+export const defaultWorkItemTypes = [
+ WORK_ITEM_TYPE_ENUM_ISSUE,
+ WORK_ITEM_TYPE_ENUM_INCIDENT,
+ WORK_ITEM_TYPE_ENUM_TEST_CASE,
+];
+
+export const defaultTypeTokenOptions = [
+ { icon: 'issue-type-issue', title: 'issue', value: 'issue' },
+ { icon: 'issue-type-incident', title: 'incident', value: 'incident' },
+ { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' },
+];
+
export const filters = {
[TOKEN_TYPE_AUTHOR]: {
[API_PARAM]: {
diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 35762120f71..040763f2ba4 100644
--- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
@@ -1,5 +1,4 @@
fragment IssueFragment on Issue {
- __typename
id
iid
confidential
@@ -18,9 +17,9 @@ fragment IssueFragment on Issue {
userDiscussionsCount @include(if: $isSignedIn)
webPath
webUrl
+ type
assignees @skip(if: $hideUsers) {
nodes {
- __typename
id
avatarUrl
name
@@ -29,7 +28,6 @@ fragment IssueFragment on Issue {
}
}
author @skip(if: $hideUsers) {
- __typename
id
avatarUrl
name
diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
index 1d48446b083..a5cba3daafa 100644
--- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
@@ -66,7 +66,7 @@ export default {
<template>
<div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
<div class="card card-slim gl-mt-5">
- <div class="card-header">
+ <div class="card-header gl-bg-gray-10">
<div
class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
>
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 449da394841..a6747d67611 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -133,7 +133,7 @@ export default {
},
computed: {
workItemsEnabled() {
- return this.glFeatures.workItems;
+ return this.glFeatures.workItemsCreateFromMarkdown;
},
taskWorkItemType() {
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
@@ -302,7 +302,9 @@ export default {
if (taskRegexMatches) {
$tasks.text(this.taskStatus);
$tasksShort.text(
- `${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`,
+ `${taskRegexMatches[1]}/${taskRegexMatches[2]} checklist item${
+ taskRegexMatches[2] > 1 ? 's' : ''
+ }`,
);
} else {
$tasks.text('');
@@ -315,7 +317,7 @@ export default {
}
this.taskButtons = [];
- const taskListFields = this.$el.querySelectorAll('.task-list-item');
+ const taskListFields = this.$el.querySelectorAll('.task-list-item:not(.inapplicable)');
taskListFields.forEach((item, index) => {
const taskLink = item.querySelector('.gfm-issue');
@@ -326,6 +328,7 @@ export default {
}
const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue);
this.addHoverListeners(taskLink, workItemId);
+ taskLink.classList.add('gl-link');
taskLink.addEventListener('click', (e) => {
e.preventDefault();
this.openWorkItemDetailModal(taskLink);
diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js
index 9fc5027d457..77d13fe085a 100644
--- a/app/assets/javascripts/issues/show/components/incidents/constants.js
+++ b/app/assets/javascripts/issues/show/components/incidents/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const timelineTabI18n = Object.freeze({
title: s__('Incident|Timeline'),
@@ -12,6 +12,9 @@ export const timelineFormI18n = Object.freeze({
'Incident|Something went wrong while creating the incident timeline event.',
),
areaPlaceholder: s__('Incident|Timeline text...'),
+ save: __('Save'),
+ cancel: __('Cancel'),
+ description: __('Description'),
saveAndAdd: s__('Incident|Save and add another event'),
areaLabel: s__('Incident|Timeline text'),
});
diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
new file mode 100644
index 00000000000..c902895702e
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
@@ -0,0 +1,117 @@
+<script>
+import { produce } from 'immer';
+import { sortBy } from 'lodash';
+import { sprintf } from '~/locale';
+import { createAlert } from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import { timelineFormI18n } from './constants';
+import TimelineEventsForm from './timeline_events_form.vue';
+
+import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql';
+import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
+
+export default {
+ name: 'CreateTimelineEvent',
+ i18n: timelineFormI18n,
+ components: {
+ TimelineEventsForm,
+ },
+ inject: ['fullPath', 'issuableId'],
+ props: {
+ hasTimelineEvents: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return { createTimelineEventActive: false };
+ },
+ methods: {
+ clearForm() {
+ this.$refs.eventForm.clear();
+ },
+ focusDate() {
+ this.$refs.eventForm.focusDate();
+ },
+ updateCache(store, { data }) {
+ const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
+
+ if (errors.length) {
+ return;
+ }
+
+ const variables = {
+ incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ fullPath: this.fullPath,
+ };
+
+ const sourceData = store.readQuery({
+ query: getTimelineEvents,
+ variables,
+ });
+
+ const newData = produce(sourceData, (draftData) => {
+ const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents;
+ draftEventList.push(event);
+ // ISOStrings sort correctly in lexical order
+ const sortedEvents = sortBy(draftEventList, 'occurredAt');
+ draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents;
+ });
+
+ store.writeQuery({
+ query: getTimelineEvents,
+ variables,
+ data: newData,
+ });
+ },
+ createIncidentTimelineEvent(eventDetails, addAnotherEvent = false) {
+ this.createTimelineEventActive = true;
+ return this.$apollo
+ .mutate({
+ mutation: CreateTimelineEvent,
+ variables: {
+ input: {
+ incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ note: eventDetails.note,
+ occurredAt: eventDetails.occurredAt,
+ },
+ },
+ update: this.updateCache,
+ })
+ .then(({ data = {} }) => {
+ this.createTimelineEventActive = false;
+ const errors = data.timelineEventCreate?.errors;
+ if (errors.length) {
+ createAlert({
+ message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false),
+ });
+ return;
+ }
+ if (addAnotherEvent) {
+ this.$refs.eventForm.clear();
+ } else {
+ this.$emit('hide-new-timeline-events-form');
+ }
+ })
+ .catch((error) => {
+ createAlert({
+ message: this.$options.i18n.createErrorGeneric,
+ captureError: true,
+ error,
+ });
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <timeline-events-form
+ ref="eventForm"
+ :is-event-processed="createTimelineEventActive"
+ :has-timeline-events="hasTimelineEvents"
+ @save-event="createIncidentTimelineEvent"
+ @cancel="$emit('hide-new-timeline-events-form')"
+ />
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
index 36ec6362a22..0d84fabb1be 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
@@ -1,21 +1,12 @@
<script>
import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui';
-import { produce } from 'immer';
-import { sortBy } from 'lodash';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE } from '~/graphql_shared/constants';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import { createAlert } from '~/flash';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
-import { sprintf } from '~/locale';
-import { getUtcShiftedDateNow } from './utils';
import { timelineFormI18n } from './constants';
-
-import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql';
-import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
+import { getUtcShiftedDateNow } from './utils';
export default {
- name: 'IncidentTimelineEventForm',
+ name: 'TimelineEventsForm',
restrictedToolBarItems: [
'quote',
'strikethrough',
@@ -38,112 +29,55 @@ export default {
directives: {
autofocusonshow,
},
- inject: ['fullPath', 'issuableId'],
props: {
hasTimelineEvents: {
type: Boolean,
required: true,
},
+ isEventProcessed: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
- // Create shifted date to force the datepicker to format in UTC
- const utcShiftedDate = getUtcShiftedDateNow();
+ // if occurredAt is undefined, returns "now" in UTC
+ const placeholderDate = getUtcShiftedDateNow();
+
return {
- currentDate: utcShiftedDate,
- currentHour: utcShiftedDate.getHours(),
- currentMinute: utcShiftedDate.getMinutes(),
timelineText: '',
- createTimelineEventActive: false,
+ placeholderDate,
+ hourPickerInput: placeholderDate.getHours(),
+ minutePickerInput: placeholderDate.getMinutes(),
datepickerTextInput: null,
};
},
+ computed: {
+ occurredAt() {
+ const [years, months, days] = this.datepickerTextInput.split('-');
+ const utcDate = new Date(
+ Date.UTC(years, months - 1, days, this.hourPickerInput, this.minutePickerInput),
+ );
+
+ return utcDate.toISOString();
+ },
+ },
methods: {
clear() {
- const utcShiftedDate = getUtcShiftedDateNow();
- this.currentDate = utcShiftedDate;
- this.currentHour = utcShiftedDate.getHours();
- this.currentMinute = utcShiftedDate.getMinutes();
- },
- hideIncidentTimelineEventForm() {
- this.$emit('hide-incident-timeline-event-form');
+ const utcShiftedDateNow = getUtcShiftedDateNow();
+ this.placeholderDate = utcShiftedDateNow;
+ this.hourPickerInput = utcShiftedDateNow.getHours();
+ this.minutePickerInput = utcShiftedDateNow.getMinutes();
+ this.timelineText = '';
},
focusDate() {
this.$refs.datepicker.$el.focus();
},
- updateCache(store, { data }) {
- const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
-
- if (errors.length) {
- return;
- }
-
- const variables = {
- incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
- fullPath: this.fullPath,
+ handleSave(addAnotherEvent) {
+ const eventDetails = {
+ note: this.timelineText,
+ occurredAt: this.occurredAt,
};
-
- const sourceData = store.readQuery({
- query: getTimelineEvents,
- variables,
- });
-
- const newData = produce(sourceData, (draftData) => {
- const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents;
- draftEventList.push(event);
- // ISOStrings sort correctly in lexical order
- const sortedEvents = sortBy(draftEventList, 'occurredAt');
- draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents;
- });
-
- store.writeQuery({
- query: getTimelineEvents,
- variables,
- data: newData,
- });
- },
- createIncidentTimelineEvent(addOneEvent) {
- this.createTimelineEventActive = true;
- return this.$apollo
- .mutate({
- mutation: CreateTimelineEvent,
- variables: {
- input: {
- incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
- note: this.timelineText,
- occurredAt: this.createDateString(),
- },
- },
- update: this.updateCache,
- })
- .then(({ data = {} }) => {
- const errors = data.timelineEventCreate?.errors;
- if (errors.length) {
- createAlert({
- message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false),
- });
- }
- })
- .catch((error) => {
- createAlert({
- message: this.$options.i18n.createErrorGeneric,
- captureError: true,
- error,
- });
- })
- .finally(() => {
- this.createTimelineEventActive = false;
- this.timelineText = '';
- if (addOneEvent) {
- this.hideIncidentTimelineEventForm();
- }
- });
- },
- createDateString() {
- const [years, months, days] = this.datepickerTextInput.split('-');
- const utcDate = new Date(
- Date.UTC(years, months - 1, days, this.currentHour, this.currentMinute),
- );
- return utcDate.toISOString();
+ this.$emit('save-event', eventDetails, addAnotherEvent);
},
},
};
@@ -165,7 +99,7 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker"
>
<gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
- <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="currentDate">
+ <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="placeholderDate">
<gl-form-input
id="incident-date"
ref="datepicker"
@@ -184,7 +118,7 @@ export default {
<label label-for="timeline-input-hours" class="sr-only"></label>
<gl-form-input
id="timeline-input-hours"
- v-model="currentHour"
+ v-model="hourPickerInput"
data-testid="input-hours"
size="xs"
type="number"
@@ -194,7 +128,7 @@ export default {
<label label-for="timeline-input-minutes" class="sr-only"></label>
<gl-form-input
id="timeline-input-minutes"
- v-model="currentMinute"
+ v-model="minutePickerInput"
class="gl-ml-3"
data-testid="input-minutes"
size="xs"
@@ -223,9 +157,10 @@ export default {
<textarea
v-model="timelineText"
class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-testid="input-note"
dir="auto"
data-supports-quick-actions="false"
- :aria-label="__('Description')"
+ :aria-label="$options.i18n.description"
:placeholder="$options.i18n.areaPlaceholder"
>
</textarea>
@@ -238,26 +173,22 @@ export default {
variant="confirm"
category="primary"
class="gl-mr-3"
- :loading="createTimelineEventActive"
- @click="createIncidentTimelineEvent(true)"
+ :loading="isEventProcessed"
+ @click="handleSave(false)"
>
- {{ __('Save') }}
+ {{ $options.i18n.save }}
</gl-button>
<gl-button
variant="confirm"
category="secondary"
class="gl-mr-3 gl-ml-n2"
- :loading="createTimelineEventActive"
- @click="createIncidentTimelineEvent(false)"
+ :loading="isEventProcessed"
+ @click="handleSave(true)"
>
{{ $options.i18n.saveAndAdd }}
</gl-button>
- <gl-button
- class="gl-ml-n2"
- :disabled="createTimelineEventActive"
- @click="hideIncidentTimelineEventForm"
- >
- {{ __('Cancel') }}
+ <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')">
+ {{ $options.i18n.cancel }}
</gl-button>
<div class="gl-border-b gl-pt-5"></div>
</gl-form-group>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
index 62ccd696ef6..6175c9969ec 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
@@ -1,5 +1,12 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlSafeHtmlDirective,
+ GlSprintf,
+} from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { getEventIcon } from './utils';
@@ -12,6 +19,7 @@ export default {
timeUTC: __('%{time} UTC'),
},
components: {
+ GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
@@ -83,7 +91,7 @@ export default {
no-caret
>
<gl-dropdown-item @click="$emit('delete')">
- {{ $options.i18n.delete }}
+ <gl-button>{{ $options.i18n.delete }}</gl-button>
</gl-dropdown-item>
</gl-dropdown>
</div>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
index 519c0d402a0..80ac1c372cd 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
@@ -4,7 +4,7 @@ import { createAlert } from '~/flash';
import { sprintf } from '~/locale';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
-import IncidentTimelineEventListItem from './timeline_events_list_item.vue';
+import IncidentTimelineEventItem from './timeline_events_item.vue';
import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql';
import { timelineListI18n } from './constants';
@@ -12,7 +12,7 @@ export default {
name: 'IncidentTimelineEventList',
i18n: timelineListI18n,
components: {
- IncidentTimelineEventListItem,
+ IncidentTimelineEventItem,
},
props: {
timelineEventLoading: {
@@ -99,16 +99,21 @@ export default {
<div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid">
<strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong>
</div>
- <ul class="notes main-notes-list gl-pl-n3">
- <incident-timeline-event-list-item
+ <ul class="notes main-notes-list">
+ <li
v-for="(event, eventIndex) in events"
- :key="event.id"
- :action="event.action"
- :occurred-at="event.occurredAt"
- :note-html="event.noteHtml"
- :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)"
- @delete="handleDelete(event)"
- />
+ :key="eventIndex"
+ class="timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!"
+ >
+ <incident-timeline-event-item
+ :key="event.id"
+ :action="event.action"
+ :occurred-at="event.occurredAt"
+ :note-html="event.noteHtml"
+ :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)"
+ @delete="handleDelete(event)"
+ />
+ </li>
</ul>
</div>
</div>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
index e1946ef4d07..7c2a7878c58 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
@@ -7,7 +7,7 @@ import getTimelineEvents from './graphql/queries/get_timeline_events.query.graph
import { displayAndLogError } from './utils';
import { timelineTabI18n } from './constants';
-import IncidentTimelineEventForm from './timeline_events_form.vue';
+import CreateTimelineEvent from './create_timeline_event.vue';
import IncidentTimelineEventsList from './timeline_events_list.vue';
export default {
@@ -16,7 +16,7 @@ export default {
GlEmptyState,
GlLoadingIcon,
GlTab,
- IncidentTimelineEventForm,
+ CreateTimelineEvent,
IncidentTimelineEventsList,
},
i18n: timelineTabI18n,
@@ -61,10 +61,10 @@ export default {
this.isEventFormVisible = false;
},
async showEventForm() {
- this.$refs.eventForm.clear();
+ this.$refs.createEventForm.clearForm();
this.isEventFormVisible = true;
await this.$nextTick();
- this.$refs.eventForm.focusDate();
+ this.$refs.createEventForm.focusDate();
},
},
};
@@ -82,14 +82,15 @@ export default {
v-if="hasTimelineEvents"
:timeline-event-loading="timelineEventLoading"
:timeline-events="timelineEvents"
+ @hide-new-timeline-events-form="hideEventForm"
/>
- <incident-timeline-event-form
+ <create-timeline-event
v-show="isEventFormVisible"
- ref="eventForm"
+ ref="createEventForm"
:has-timeline-events="hasTimelineEvents"
class="timeline-event-note timeline-event-note-form"
:class="{ 'gl-pl-0': !hasTimelineEvents }"
- @hide-incident-timeline-event-form="hideEventForm"
+ @hide-new-timeline-events-form="hideEventForm"
/>
<gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm">
{{ $options.i18n.addEventButton }}
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
index 256e3025f19..cf790a11b67 100644
--- a/app/assets/javascripts/issues/show/components/incidents/utils.js
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -11,6 +11,7 @@ export const displayAndLogError = (error) =>
const EVENT_ICONS = {
comment: 'comment',
issues: 'issues',
+ label: 'label',
status: 'status',
default: 'comment',
};
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index da72cbeb856..4046e1ade82 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -189,25 +189,23 @@ export default {
v-if="hasEnvironment"
:href="environmentLink.link"
data-testid="job-environment-link"
- v-text="environmentLink.name"
- />
+ >{{ environmentLink.name }}</gl-link
+ >
</template>
<template #clusterNameOrLink>
<gl-link
v-if="clusterNameOrLink.path"
:href="clusterNameOrLink.path"
data-testid="job-cluster-link"
- v-text="clusterNameOrLink.name"
- />
+ >{{ clusterNameOrLink.name }}</gl-link
+ >
<template v-else>{{ clusterNameOrLink.name }}</template>
</template>
<template #kubernetesNamespace>{{ kubernetesNamespace }}</template>
<template #deploymentLink>
- <gl-link
- :href="deploymentLink.path"
- data-testid="job-deployment-link"
- v-text="deploymentLink.name"
- />
+ <gl-link :href="deploymentLink.path" data-testid="job-deployment-link">{{
+ deploymentLink.name
+ }}</gl-link>
</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index f9e6c64aad1..d5ee3423d70 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -287,6 +287,7 @@ export default {
:is-scroll-top-disabled="isScrollTopDisabled"
:is-job-log-size-visible="isJobLogSizeVisible"
:is-scrolling-down="isScrollingDown"
+ :is-complete="isJobLogComplete"
:job-log="jobLog"
@scrollJobLogTop="scrollTop"
@scrollJobLogBottom="scrollBottom"
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 5e89dd5acc2..e9809ac661b 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui';
-import { scrollToElement } from '~/lib/utils/common_utils';
+import { scrollToElement, backOff } from '~/lib/utils/common_utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, s__, sprintf } from '~/locale';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
@@ -10,6 +10,7 @@ export default {
i18n: {
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'),
+ scrollToNextFailureButtonLabel: s__('Job|Scroll to next failure'),
showRawButtonLabel: s__('Job|Show complete raw'),
searchPlaceholder: s__('Job|Search job log'),
noResults: s__('Job|No search results found'),
@@ -55,6 +56,10 @@ export default {
type: Boolean,
required: true,
},
+ isComplete: {
+ type: Boolean,
+ required: true,
+ },
jobLog: {
type: Array,
required: true,
@@ -64,6 +69,8 @@ export default {
return {
searchTerm: '',
searchResults: [],
+ failureCount: null,
+ failureIndex: 0,
};
},
computed: {
@@ -72,16 +79,49 @@ export default {
size: numberToHumanSize(this.size),
});
},
- showJobLogSearch() {
- return this.glFeatures.jobLogSearch;
+ showJumpToFailures() {
+ return this.glFeatures.jobLogJumpToFailures;
+ },
+ hasFailures() {
+ return this.failureCount > 0;
+ },
+ shouldDisableJumpToFailures() {
+ return !this.hasFailures;
},
},
+ mounted() {
+ this.checkFailureCount();
+ },
methods: {
+ checkFailureCount() {
+ if (this.glFeatures.jobLogJumpToFailures) {
+ backOff((next, stop) => {
+ this.failureCount = document.querySelectorAll('.term-fg-l-red').length;
+
+ if (this.hasFailures || (this.isComplete && !this.hasFailures)) {
+ stop();
+ } else {
+ next();
+ }
+ });
+ }
+ },
+ handleScrollToNextFailure() {
+ const failures = document.querySelectorAll('.term-fg-l-red');
+ const nextFailure = failures[this.failureIndex];
+
+ if (nextFailure) {
+ nextFailure.scrollIntoView({ block: 'center' });
+ this.failureIndex = (this.failureIndex + 1) % failures.length;
+ }
+ },
handleScrollToTop() {
this.$emit('scrollJobLogTop');
+ this.failureIndex = 0;
},
handleScrollToBottom() {
this.$emit('scrollJobLogBottom');
+ this.failureIndex = 0;
},
searchJobLog() {
this.searchResults = [];
@@ -135,10 +175,10 @@ export default {
};
</script>
<template>
- <div class="top-bar">
+ <div class="top-bar gl-display-flex gl-justify-content-space-between">
<!-- truncate information -->
<div
- class="truncated-info gl-display-none gl-sm-display-block gl-float-left"
+ class="truncated-info gl-display-none gl-sm-display-flex gl-flex-wrap gl-align-items-center"
data-testid="log-truncated-info"
>
<template v-if="isJobLogSizeVisible">
@@ -154,25 +194,23 @@ export default {
</div>
<!-- eo truncate information -->
- <div class="controllers gl-float-right">
- <template v-if="showJobLogSearch">
- <gl-search-box-by-click
- v-model="searchTerm"
- class="gl-mr-3"
- :placeholder="$options.i18n.searchPlaceholder"
- data-testid="job-log-search-box"
- @clear="$emit('searchResults', [])"
- @submit="searchJobLog"
- />
+ <div class="controllers">
+ <gl-search-box-by-click
+ v-model="searchTerm"
+ class="gl-mr-3"
+ :placeholder="$options.i18n.searchPlaceholder"
+ data-testid="job-log-search-box"
+ @clear="$emit('searchResults', [])"
+ @submit="searchJobLog"
+ />
- <help-popover class="gl-mr-3">
- <template #title>{{ $options.i18n.searchPopoverTitle }}</template>
+ <help-popover class="gl-mr-3">
+ <template #title>{{ $options.i18n.searchPopoverTitle }}</template>
- <p class="gl-mb-0">
- {{ $options.i18n.searchPopoverDescription }}
- </p>
- </help-popover>
- </template>
+ <p class="gl-mb-0">
+ {{ $options.i18n.searchPopoverDescription }}
+ </p>
+ </help-popover>
<!-- links -->
<gl-button
@@ -187,6 +225,18 @@ export default {
<!-- eo links -->
<!-- scroll buttons -->
+ <gl-button
+ v-if="showJumpToFailures"
+ v-gl-tooltip
+ :title="$options.i18n.scrollToNextFailureButtonLabel"
+ :aria-label="$options.i18n.scrollToNextFailureButtonLabel"
+ :disabled="shouldDisableJumpToFailures"
+ class="btn-scroll gl-ml-3"
+ data-testid="job-controller-scroll-to-failure"
+ icon="soft-wrap"
+ @click="handleScrollToNextFailure"
+ />
+
<div v-gl-tooltip :title="$options.i18n.scrollToTopButtonLabel" class="gl-ml-3">
<gl-button
:disabled="isScrollTopDisabled"
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
index 15c4e503685..3b1509e5be5 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -1,7 +1,6 @@
<script>
import { mapState } from 'vuex';
import { GlBadge } from '@gitlab/ui';
-import { helpPagePath } from '~/helpers/help_page_helper';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -47,11 +46,6 @@ export default {
this.job.coverage,
);
},
- runnerHelpUrl() {
- return helpPagePath('ci/runners/configure_runners.html', {
- anchor: 'set-maximum-job-timeout-for-a-runner',
- });
- },
runnerId() {
const { id, short_sha: token, description } = this.job.runner;
@@ -85,6 +79,7 @@ export default {
TAGS: __('Tags:'),
TIMEOUT: __('Timeout'),
},
+ RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html',
};
</script>
@@ -101,7 +96,7 @@ export default {
<detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" />
<detail-row
v-if="hasTimeout"
- :help-url="runnerHelpUrl"
+ :help-url="$options.RUNNER_HELP_URL"
:value="timeout"
data-testid="job-timeout"
:title="$options.i18n.TIMEOUT"
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index 5b1032c6448..98b51e8c2c4 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -1,7 +1,6 @@
query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJobStatus!]) {
project(fullPath: $fullPath) {
id
- __typename
jobs(after: $after, first: $first, statuses: $statuses) {
count
pageInfo {
@@ -9,15 +8,12 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJo
hasNextPage
hasPreviousPage
startCursor
- __typename
}
nodes {
- __typename
artifacts {
nodes {
downloadPath
fileType
- __typename
}
}
allowFailure
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
index b3db5a94ac5..c2f460cb647 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -3,7 +3,6 @@ import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from
import { __ } from '~/locale';
import createFlash from '~/flash';
import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue';
-import eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue';
@@ -108,16 +107,7 @@ export default {
}
},
},
- mounted() {
- eventHub.$on('jobActionPerformed', this.handleJobAction);
- },
- beforeDestroy() {
- eventHub.$off('jobActionPerformed', this.handleJobAction);
- },
methods: {
- handleJobAction() {
- this.$apollo.queries.jobs.refetch({ statuses: this.scope });
- },
fetchJobsByStatus(scope) {
this.infiniteScrollingTriggered = false;
@@ -169,6 +159,7 @@ export default {
v-if="shouldShowAlert"
class="gl-mt-2"
variant="danger"
+ data-testid="jobs-table-error-alert"
dismissible
@dismiss="isAlertDismissed = true"
>
diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js
index 9d8ee165df2..3e5396c5bd8 100644
--- a/app/assets/javascripts/labels/labels_select.js
+++ b/app/assets/javascripts/labels/labels_select.js
@@ -438,7 +438,7 @@ export default class LabelsSelect {
[
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
"<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
- '<br />',
+ '<br>',
'<%= escapeStr(label.description) %>',
'<% } else { %>',
'<%= escapeStr(label.description) %>',
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index a01c6df0003..3e28ca2a0f7 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -33,6 +33,22 @@ const removeUnsafeHref = (node, attr) => {
};
/**
+ * Appends 'noopener' & 'noreferrer' to rel
+ * attr values to prevent reverse tabnabbing.
+ *
+ * @param {String} rel
+ * @returns {String}
+ */
+const appendSecureRelValue = (rel) => {
+ const attributes = new Set(rel ? rel.toLowerCase().split(' ') : []);
+
+ attributes.add('noopener');
+ attributes.add('noreferrer');
+
+ return Array.from(attributes).join(' ');
+};
+
+/**
* Sanitize icons' <use> tag attributes, to safely include
* svgs such as in:
*
@@ -57,4 +73,25 @@ addHook('afterSanitizeAttributes', (node) => {
}
});
+const TEMPORARY_ATTRIBUTE = 'data-temp-href-target';
+
+addHook('beforeSanitizeAttributes', (node) => {
+ if (node.tagName === 'A' && node.hasAttribute('target')) {
+ node.setAttribute(TEMPORARY_ATTRIBUTE, node.getAttribute('target'));
+ }
+});
+
+addHook('afterSanitizeAttributes', (node) => {
+ if (node.tagName === 'A' && node.hasAttribute(TEMPORARY_ATTRIBUTE)) {
+ node.setAttribute('target', node.getAttribute(TEMPORARY_ATTRIBUTE));
+ node.removeAttribute(TEMPORARY_ATTRIBUTE);
+ if (node.getAttribute('target') === '_blank') {
+ const rel = node.getAttribute('rel');
+ node.setAttribute('rel', appendSecureRelValue(rel));
+ }
+ }
+});
+
export const sanitize = (val, config) => dompurifySanitize(val, { ...defaultConfig, ...config });
+
+export { isValidAttribute } from 'dompurify';
diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js
index 92118c8929f..eaf653e9924 100644
--- a/app/assets/javascripts/lib/gfm/index.js
+++ b/app/assets/javascripts/lib/gfm/index.js
@@ -1,10 +1,15 @@
import { pick } from 'lodash';
+import normalize from 'mdurl/encode';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
+import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import remarkRehype, { all } from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
+const skipFrontmatterHandler = (language) => (h, node) =>
+ h(node.position, 'frontmatter', { language }, [{ type: 'text', value: node.value }]);
+
const skipRenderingHandlers = {
footnoteReference: (h, node) =>
h(node.position, 'footnoteReference', { identifier: node.identifier, label: node.label }, []),
@@ -19,12 +24,57 @@ const skipRenderingHandlers = {
h(node.position, 'codeBlock', { language: node.lang, meta: node.meta }, [
{ type: 'text', value: node.value },
]),
+ definition: (h, node) => {
+ const title = node.title ? ` "${node.title}"` : '';
+
+ return h(
+ node.position,
+ 'referenceDefinition',
+ { identifier: node.identifier, url: node.url, title: node.title },
+ [{ type: 'text', value: `[${node.identifier}]: ${node.url}${title}` }],
+ );
+ },
+ linkReference: (h, node) => {
+ const definition = h.definition(node.identifier);
+
+ return h(
+ node.position,
+ 'a',
+ {
+ href: normalize(definition.url ?? ''),
+ identifier: node.identifier,
+ isReference: 'true',
+ title: definition.title,
+ },
+ all(h, node),
+ );
+ },
+ imageReference: (h, node) => {
+ const definition = h.definition(node.identifier);
+
+ return h(
+ node.position,
+ 'img',
+ {
+ src: normalize(definition.url ?? ''),
+ alt: node.alt,
+ identifier: node.identifier,
+ isReference: 'true',
+ title: definition.title,
+ },
+ all(h, node),
+ );
+ },
+ toml: skipFrontmatterHandler('toml'),
+ yaml: skipFrontmatterHandler('yaml'),
+ json: skipFrontmatterHandler('json'),
};
const createParser = ({ skipRendering = [] }) => {
return unified()
.use(remarkParse)
.use(remarkGfm)
+ .use(remarkFrontmatter, ['yaml', 'toml', { type: 'json', marker: ';' }])
.use(remarkRehype, {
allowDangerousHtml: true,
handlers: {
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index cfcce234bfb..98e45f95b38 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -221,6 +221,7 @@ export default (resolvers = {}, config = {}) => {
ac = new ApolloClient({
typeDefs,
link: appLink,
+ connectToDevTools: process.env.NODE_ENV !== 'production',
cache: new InMemoryCache({
...cacheConfig,
typePolicies: {
diff --git a/app/assets/javascripts/lib/markdown_it.js b/app/assets/javascripts/lib/markdown_it.js
new file mode 100644
index 00000000000..0b7a553737d
--- /dev/null
+++ b/app/assets/javascripts/lib/markdown_it.js
@@ -0,0 +1,11 @@
+/**
+ * This module replaces markdown-it with an empty function. markdown-it
+ * is a dependency of the prosemirror-markdown package. prosemirror-markdown
+ * uses markdown-it to parse markdown and produce an AST. However, the
+ * features that use prosemirror-markdown in the GitLab application do not
+ * require markdown parsing.
+ *
+ * Replacing markdown-it with this empty function removes unnecessary javascript
+ * from the production builds.
+ */
+export default () => {};
diff --git a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
index 6473683c3af..5e621ca3216 100644
--- a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
+++ b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
@@ -1,3 +1 @@
-// Import from `src/to_markdown` to avoid unnecessary bundling of unused libs
-// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79859
-export * from 'prosemirror-markdown/src/to_markdown';
+export { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 243de48948c..9f4e12a3010 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -4,12 +4,14 @@ import Shortcuts from '~/behaviors/shortcuts/shortcuts';
import { insertText } from '~/lib/utils/common_utils';
const LINK_TAG_PATTERN = '[{text}](url)';
+const INDENT_CHAR = ' ';
+const INDENT_LENGTH = 2;
// at the start of a line, find any amount of whitespace followed by
// a bullet point character (*+-) and an optional checkbox ([ ] [x])
// OR a number with a . after it and an optional checkbox ([ ] [x])
// followed by one or more whitespace characters
-const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX\s])\])?\s)(?<content>.)?/;
+const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX~\s])\])?\s)(?<content>.)?/;
// detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>)
const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/;
@@ -24,33 +26,104 @@ function addBlockTags(blockTag, selected) {
return `${blockTag}\n${selected}\n${blockTag}`;
}
-function lineBefore(text, textarea, trimNewlines = true) {
- let split = text.substring(0, textarea.selectionStart);
-
- if (trimNewlines) {
- split = split.trim();
- }
+/**
+ * Returns the line of text that is before the first line
+ * of the current selection
+ *
+ * @param {String} text - the text of the targeted text area
+ * @param {Object} textArea - the targeted text area
+ * @returns {String}
+ */
+function lineBeforeSelection(text, textArea) {
+ let split = text.substring(0, textArea.selectionStart);
split = split.split('\n');
- return split[split.length - 1];
-}
+ // Last item, at -1, is the line where the start of selection is.
+ // Line before selection is therefore at -2
+ const lineBefore = split[split.length - 2];
-function lineAfter(text, textarea, trimNewlines = true) {
- let split = text.substring(textarea.selectionEnd);
+ return lineBefore === undefined ? '' : lineBefore;
+}
- if (trimNewlines) {
- split = split.trim();
- } else {
- // remove possible leading newline to get at the real line
- split = split.replace(/^\n/, '');
- }
+/**
+ * Returns the line of text that is after the last line
+ * of the current selection
+ *
+ * @param {String} text - the text of the targeted text area
+ * @param {Object} textArea - the targeted text area
+ * @returns {String}
+ */
+function lineAfterSelection(text, textArea) {
+ let split = text.substring(textArea.selectionEnd);
+ // remove possible leading newline to get at the real line
+ split = split.replace(/^\n/, '');
split = split.split('\n');
return split[0];
}
+/**
+ * Returns the text lines that encompass the current selection
+ *
+ * @param {Object} textArea - the targeted text area
+ * @returns {Object}
+ */
+function linesFromSelection(textArea) {
+ const text = textArea.value;
+ const { selectionStart, selectionEnd } = textArea;
+
+ let startPos = text[selectionStart] === '\n' ? selectionStart - 1 : selectionStart;
+ startPos = text.lastIndexOf('\n', startPos) + 1;
+
+ let endPos = selectionEnd === selectionStart ? selectionEnd : selectionEnd - 1;
+ endPos = text.indexOf('\n', endPos);
+ if (endPos < 0) endPos = text.length;
+
+ const selectedRange = text.substring(startPos, endPos);
+ const lines = selectedRange.split('\n');
+
+ return {
+ lines,
+ selectionStart,
+ selectionEnd,
+ startPos,
+ endPos,
+ };
+}
+
+/**
+ * Set the selection of a textarea such that it maintains the
+ * previous selection before the lines were indented/outdented
+ *
+ * @param {Object} textArea - the targeted text area
+ * @param {Number} selectionStart - start position of original selection
+ * @param {Number} selectionEnd - end position of original selection
+ * @param {Number} lineStart - start pos of first line
+ * @param {Number} firstLineChange - number of characters changed on first line
+ * @param {Number} totalChanged - total number of characters changed
+ */
+function setNewSelectionRange(
+ textArea,
+ selectionStart,
+ selectionEnd,
+ lineStart,
+ firstLineChange,
+ totalChanged,
+) {
+ let newStart = Math.max(lineStart, selectionStart + firstLineChange);
+ let newEnd = Math.max(lineStart, selectionEnd + totalChanged);
+
+ if (selectionStart === selectionEnd) {
+ newEnd = newStart;
+ } else if (selectionStart === lineStart) {
+ newStart = lineStart;
+ }
+
+ textArea.setSelectionRange(newStart, newEnd);
+}
+
function convertMonacoSelectionToAceFormat(sel) {
return {
start: {
@@ -93,7 +166,8 @@ function editorBlockTagText(text, blockTag, selected, editor) {
function blockTagText(text, textArea, blockTag, selected) {
const shouldRemoveBlock =
- lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag;
+ lineBeforeSelection(text, textArea) === blockTag &&
+ lineAfterSelection(text, textArea) === blockTag;
if (shouldRemoveBlock) {
// To remove the block tag we have to select the line before & after
@@ -312,9 +386,100 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
});
}
+/**
+ * Indents selected lines to the right by 2 spaces
+ *
+ * @param {Object} textArea - the targeted text area
+ */
+function indentLines(textArea) {
+ const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea);
+ const shiftedLines = [];
+ let totalAdded = 0;
+
+ textArea.setSelectionRange(startPos, endPos);
+
+ lines.forEach((line) => {
+ line = INDENT_CHAR.repeat(INDENT_LENGTH) + line;
+ totalAdded += INDENT_LENGTH;
+
+ shiftedLines.push(line);
+ });
+
+ const textToInsert = shiftedLines.join('\n');
+
+ insertText(textArea, textToInsert);
+ setNewSelectionRange(textArea, selectionStart, selectionEnd, startPos, INDENT_LENGTH, totalAdded);
+}
+
+/**
+ * Outdents selected lines to the left by 2 spaces
+ *
+ * @param {Object} textArea - the targeted text area
+ */
+function outdentLines(textArea) {
+ const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea);
+ const shiftedLines = [];
+ let totalRemoved = 0;
+ let removedFromFirstline = -1;
+ let removedFromLine = 0;
+
+ textArea.setSelectionRange(startPos, endPos);
+
+ lines.forEach((line) => {
+ removedFromLine = 0;
+
+ if (line.length > 0) {
+ // need to count how many spaces are actually removed, so can't use `replace`
+ while (removedFromLine < INDENT_LENGTH && line[removedFromLine] === INDENT_CHAR) {
+ removedFromLine += 1;
+ }
+
+ if (removedFromLine > 0) {
+ line = line.slice(removedFromLine);
+ totalRemoved += removedFromLine;
+ }
+ }
+
+ if (removedFromFirstline === -1) removedFromFirstline = removedFromLine;
+ shiftedLines.push(line);
+ });
+
+ const textToInsert = shiftedLines.join('\n');
+
+ if (totalRemoved > 0) insertText(textArea, textToInsert);
+
+ setNewSelectionRange(
+ textArea,
+ selectionStart,
+ selectionEnd,
+ startPos,
+ -removedFromFirstline,
+ -totalRemoved,
+ );
+}
+
+function handleIndentOutdent(e, textArea) {
+ if (e.altKey || e.ctrlKey || e.shiftKey) return;
+ if (!e.metaKey) return;
+
+ switch (e.key) {
+ case ']':
+ e.preventDefault();
+ indentLines(textArea);
+ break;
+ case '[':
+ e.preventDefault();
+ outdentLines(textArea);
+ break;
+ default:
+ break;
+ }
+}
+
/* eslint-disable @gitlab/require-i18n-strings */
function handleSurroundSelectedText(e, textArea) {
if (!gon.markdown_surround_selection) return;
+ if (e.metaKey) return;
if (textArea.selectionStart === textArea.selectionEnd) return;
const keys = {
@@ -348,13 +513,13 @@ function handleSurroundSelectedText(e, textArea) {
/**
* Returns the content for a new line following a list item.
*
- * @param {Object} result - regex match of the current line
- * @param {Object?} nextLineResult - regex match of the next line
+ * @param {Object} listLineMatch - regex match of the current line
+ * @param {Object?} nextLineMatch - regex match of the next line
* @returns string with the new list item
*/
-function continueOlText(result, nextLineResult) {
- const { indent, leader } = result.groups;
- const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {};
+function continueOlText(listLineMatch, nextLineMatch) {
+ const { indent, leader } = listLineMatch.groups;
+ const { indent: nextIndent, isOl: nextIsOl } = nextLineMatch?.groups ?? {};
const [numStr, postfix = ''] = leader.split('.');
@@ -368,20 +533,20 @@ function handleContinueList(e, textArea) {
if (!(e.key === 'Enter')) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (textArea.selectionStart !== textArea.selectionEnd) return;
- // prevent unintended line breaks were inserted using Japanese IME on MacOS
+ // prevent unintended line breaks inserted using Japanese IME on MacOS
if (compositioningNoteText) return;
- const currentLine = lineBefore(textArea.value, textArea, false);
- const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
+ const firstSelectedLine = linesFromSelection(textArea).lines[0];
+ const listLineMatch = firstSelectedLine.match(LIST_LINE_HEAD_PATTERN);
- if (result) {
- const { leader, indent, content, isOl } = result.groups;
- const prevLineEmpty = !content;
+ if (listLineMatch) {
+ const { leader, indent, content, isOl } = listLineMatch.groups;
+ const emptyListItem = !content;
- if (prevLineEmpty) {
- // erase previous empty list item - select the text and allow the
- // natural line feed erase the text
- textArea.selectionStart = textArea.selectionStart - result[0].length;
+ if (emptyListItem) {
+ // erase empty list item - select the text and allow the
+ // natural line feed to erase the text
+ textArea.selectionStart = textArea.selectionStart - listLineMatch[0].length;
return;
}
@@ -389,17 +554,17 @@ function handleContinueList(e, textArea) {
// Behaviors specific to either `ol` or `ul`
if (isOl) {
- const nextLine = lineAfter(textArea.value, textArea, false);
- const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN);
+ const nextLine = lineAfterSelection(textArea.value, textArea);
+ const nextLineMatch = nextLine.match(LIST_LINE_HEAD_PATTERN);
- itemToInsert = continueOlText(result, nextLineResult);
+ itemToInsert = continueOlText(listLineMatch, nextLineMatch);
} else {
- if (currentLine.match(HR_PATTERN)) return;
+ if (firstSelectedLine.match(HR_PATTERN)) return;
itemToInsert = `${indent}${leader}`;
}
- itemToInsert = itemToInsert.replace(/\[x\]/i, '[ ]');
+ itemToInsert = itemToInsert.replace(/\[[x~]\]/i, '[ ]');
e.preventDefault();
@@ -419,6 +584,7 @@ export function keypressNoteText(e) {
if ($(textArea).atwho?.('isSelecting')) return;
+ handleIndentOutdent(e, textArea);
handleContinueList(e, textArea);
handleSurroundSelectedText(e, textArea);
}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index ff60fd2aecb..ca90eee69c7 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -397,6 +397,7 @@ export function relativePathToAbsolute(path, basePath) {
const absolute = isAbsolute(basePath);
const base = absolute ? basePath : `file:///${basePath}`;
const url = new URL(path, base);
+ url.pathname = url.pathname.replace(/\/\/+/g, '/');
return absolute ? url.href : decodeURIComponent(url.pathname);
}
@@ -668,3 +669,27 @@ export function constructWebIDEPath({
webIDEUrl(`/${sourceProjectFullPath}/merge_requests/${iid}`),
);
}
+
+/**
+ * Examples
+ *
+ * http://gitlab.com => gitlab.com
+ * https://gitlab.com => gitlab.com
+ *
+ * @param {String} url
+ * @returns A url without a protocol / scheme
+ */
+export const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, '');
+
+/**
+ * Examples
+ *
+ * https://www.gitlab.com/path/ => https://www.gitlab.com/path
+ * https://www.gitlab.com/?query=search => https://www.gitlab.com?query=search
+ * https://www.gitlab.com/#fragment => https://www.gitlab.com#fragment
+ *
+ * @param {String} url
+ * @returns A URL that does not have a path that ends with slash
+ */
+export const removeLastSlashInUrlPath = (url) =>
+ url.replace(/\/$/, '').replace(/\/(\?|#){1}([^/]*)$/, '$1$2');
diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js
index 9270d388342..48f34624140 100644
--- a/app/assets/javascripts/lib/utils/yaml.js
+++ b/app/assets/javascripts/lib/utils/yaml.js
@@ -16,18 +16,17 @@ function getPath(ancestry) {
function getFirstChildNode(collection) {
let firstChildKey;
- let type;
- switch (collection.constructor.name) {
- case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings
- return collection.items.find((i) => isNode(i));
- case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings
- firstChildKey = collection.items[0]?.key;
- if (!firstChildKey) return undefined;
- return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
- default:
- type = collection.constructor?.name || typeof collection;
- throw Error(`Cannot identify a child Node for type ${type}`);
+ if (isSeq(collection)) {
+ return collection.items.find((i) => isNode(i));
}
+ if (isMap(collection)) {
+ firstChildKey = collection.items[0]?.key;
+ if (!firstChildKey) return undefined;
+ return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
+ }
+ throw Error(
+ `Cannot identify a child Node for Collection. Expecting a YAMLMap or a YAMLSeq. Got: ${collection}`,
+ );
}
function moveMetaPropsToFirstChildNode(collection) {
diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js
index 244adca86c9..6d799d30b4b 100644
--- a/app/assets/javascripts/linked_resources/index.js
+++ b/app/assets/javascripts/linked_resources/index.js
@@ -1,25 +1,34 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import ResourceLinksBlock from 'ee_component/linked_resources/components/resource_links_block.vue';
+import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
+Vue.use(VueApollo);
+
export default function initLinkedResources() {
const linkedResourcesRootElement = document.querySelector('.js-linked-resources-root');
if (linkedResourcesRootElement) {
const { issuableId, canAddResourceLinks, helpPath } = linkedResourcesRootElement.dataset;
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
// eslint-disable-next-line no-new
new Vue({
el: linkedResourcesRootElement,
name: 'LinkedResourcesRoot',
+ apolloProvider,
components: {
resourceLinksBlock: ResourceLinksBlock,
},
render: (createElement) =>
createElement('resource-links-block', {
props: {
- issuableId,
helpPath,
+ issuableId: parseInt(issuableId, 10),
canAddResourceLinks: parseBoolean(canAddResourceLinks),
},
}),
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 349a28ace52..c16ed68096d 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -134,12 +134,6 @@ function deferredInitialisation() {
// Adding a helper class to activate animations only after all is rendered
setTimeout(() => $body.addClass('page-initialised'), 1000);
-
- if (window.gon?.features?.mrAttentionRequests) {
- import('~/attention_requests')
- .then((module) => module.default())
- .catch(() => {});
- }
}
// header search vue component bootstrap
diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue
index 971b1a8435e..ecc2ed82ad0 100644
--- a/app/assets/javascripts/members/components/table/member_action_buttons.vue
+++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue
@@ -1,5 +1,5 @@
<script>
-import { MEMBER_TYPES } from '../../constants';
+import { MEMBER_TYPES, EE_ACTION_BUTTONS } from 'ee_else_ce/members/constants';
import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue';
import GroupActionButtons from '../action_buttons/group_action_buttons.vue';
import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
@@ -12,6 +12,8 @@ export default {
GroupActionButtons,
InviteActionButtons,
AccessRequestActionButtons,
+ BannedActionButtons: () =>
+ import('ee_component/members/components/action_buttons/banned_action_buttons.vue'),
},
props: {
member: {
@@ -42,6 +44,7 @@ export default {
[MEMBER_TYPES.group]: 'group-action-buttons',
[MEMBER_TYPES.invite]: 'invite-action-buttons',
[MEMBER_TYPES.accessRequest]: 'access-request-action-buttons',
+ ...EE_ACTION_BUTTONS,
};
return dictionary[this.memberType];
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 2fe816c7ea2..93d113d1afe 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -9,6 +9,8 @@ export const EE_APP_OPTIONS = {};
// Overridden in EE
export const EE_TABS = [];
+export const EE_ACTION_BUTTONS = {};
+
export const FIELD_KEY_ACCOUNT = 'account';
export const FIELD_KEY_SOURCE = 'source';
export const FIELD_KEY_GRANTED = 'granted';
diff --git a/app/assets/javascripts/merge_conflicts/utils.js b/app/assets/javascripts/merge_conflicts/utils.js
index cf7a7c304e3..ca0649cc048 100644
--- a/app/assets/javascripts/merge_conflicts/utils.js
+++ b/app/assets/javascripts/merge_conflicts/utils.js
@@ -61,7 +61,7 @@ export const decorateLineForInlineView = (line, id, conflict) => {
};
export const getLineForParallelView = (line, id, lineType, isHead) => {
- const { old_line, new_line, rich_text } = line;
+ const { old_line: oldLine, new_line: newLine, rich_text: richText } = line;
const hasConflict = lineType === 'conflict';
return {
@@ -71,10 +71,9 @@ export const getLineForParallelView = (line, id, lineType, isHead) => {
isHead: hasConflict && isHead,
isOrigin: hasConflict && !isHead,
hasMatch: lineType === 'match',
- // eslint-disable-next-line camelcase
- lineNumber: isHead ? new_line : old_line,
+ lineNumber: isHead ? newLine : oldLine,
section: isHead ? 'head' : 'origin',
- richText: rich_text,
+ richText,
isSelected: false,
isUnselected: false,
};
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index a95b143920b..b74da3ee89b 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -237,10 +237,10 @@ export default {
recentDeployments() {
return this.deploymentData.reduce((acc, deployment) => {
if (deployment.created_at >= this.earliestDatapoint) {
- const { id, created_at, sha, ref, tag } = deployment;
+ const { id, created_at: createdAt, sha, ref, tag } = deployment;
acc.push({
id,
- createdAt: created_at,
+ createdAt,
sha,
commitUrl: `${this.projectPath}/-/commit/${sha}`,
tag,
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 7f75a501635..02a2435d575 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -16,8 +16,8 @@ export const gqClient = createGqClient(
);
/**
- * Metrics loaded from project-defined dashboards do not have a metric_id.
- * This method creates a unique ID combining metric_id and id, if either is present.
+ * Metrics loaded from project-defined dashboards do not have a metricId.
+ * This method creates a unique ID combining metricId and id, if either is present.
* This is hopefully a temporary solution until BE processes metrics before passing to FE
*
* Related:
@@ -25,12 +25,11 @@ export const gqClient = createGqClient(
* https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447
*
* @param {Object} metric - metric
- * @param {Number} metric.metric_id - Database metric id
+ * @param {Number} metric.metricId - Database metric id
* @param {String} metric.id - User-defined identifier
* @returns {Object} - normalized metric with a uniqueID
*/
-// eslint-disable-next-line camelcase
-export const uniqMetricsId = ({ metric_id, id }) => `${metric_id || NOT_IN_DB_PREFIX}_${id}`;
+export const uniqMetricsId = ({ metricId, id }) => `${metricId || NOT_IN_DB_PREFIX}_${id}`;
/**
* Project path has a leading slash that doesn't work well
@@ -100,19 +99,28 @@ export const parseAnnotationsResponse = (response) => {
* @returns {Object}
*/
const mapToMetricsViewModel = (metrics) =>
- metrics.map(({ label, id, metric_id, query_range, prometheus_endpoint_path, ...metric }) => ({
- label,
- queryRange: query_range,
- prometheusEndpointPath: prometheus_endpoint_path,
- metricId: uniqMetricsId({ metric_id, id }),
+ metrics.map(
+ ({
+ label,
+ id,
+ metric_id: metricId,
+ query_range: queryRange,
+ prometheus_endpoint_path: prometheusEndpointPath,
+ ...metric
+ }) => ({
+ label,
+ queryRange,
+ prometheusEndpointPath,
+ metricId: uniqMetricsId({ metricId, id }),
- // metric data
- loading: false,
- result: null,
- state: null,
+ // metric data
+ loading: false,
+ result: null,
+ state: null,
- ...metric,
- }));
+ ...metric,
+ }),
+ );
/**
* Maps X-axis view model
@@ -169,26 +177,26 @@ export const mapPanelToViewModel = ({
id = null,
title = '',
type,
- x_axis = {}, // eslint-disable-line camelcase
- x_label,
- y_label,
- y_axis = {}, // eslint-disable-line camelcase
+ x_axis: xAxisBase = {},
+ x_label: xLabel,
+ y_label: yLabel,
+ y_axis: yAxisBase = {},
field,
metrics = [],
links = [],
- min_value,
- max_value,
+ min_value: minValue,
+ max_value: maxValue,
split,
thresholds,
format,
}) => {
// Both `x_axis.name` and `x_label` are supported for now
// https://gitlab.com/gitlab-org/gitlab/issues/210521
- const xAxis = mapXAxisToViewModel({ name: x_label, ...x_axis }); // eslint-disable-line camelcase
+ const xAxis = mapXAxisToViewModel({ name: xLabel, ...xAxisBase });
// Both `y_axis.name` and `y_label` are supported for now
// https://gitlab.com/gitlab-org/gitlab/issues/208385
- const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line camelcase
+ const yAxis = mapYAxisToViewModel({ name: yLabel, ...yAxisBase });
return {
id,
@@ -199,8 +207,8 @@ export const mapPanelToViewModel = ({
yAxis,
xAxis,
field,
- minValue: min_value,
- maxValue: max_value,
+ minValue,
+ maxValue,
split,
thresholds,
format,
@@ -295,13 +303,13 @@ export const mapToDashboardViewModel = ({
dashboard = '',
templating = {},
links = [],
- panel_groups = [], // eslint-disable-line camelcase
+ panel_groups: panelGroups = [],
}) => {
return {
dashboard,
variables: mergeURLVariables(parseTemplatingVariables(templating.variables)),
links: links.map(mapLinksToViewModel),
- panelGroups: panel_groups.map(mapToPanelGroupViewModel),
+ panelGroups: panelGroups.map(mapToPanelGroupViewModel),
};
};
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index d85fd10be45..cf24d18c7b6 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -44,7 +44,13 @@ export default () => {
},
watch: {
discussionTabCounter() {
- this.updateDiscussionTabCounter();
+ if (window.gon?.features?.paginatedMrDiscussions) {
+ if (this.$store.state.notes.doneFetchingBatchDiscussions) {
+ this.updateDiscussionTabCounter();
+ }
+ } else {
+ this.updateDiscussionTabCounter();
+ }
},
isShowTabActive: {
handler(newVal) {
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 073b27605bb..8351ae7ced6 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,6 +1,6 @@
<script>
import katex from 'katex';
-import marked from 'marked';
+import { marked } from 'marked';
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { sanitize } from '~/lib/dompurify';
import { hasContent, markdownConfig } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index 0e213028c7c..3cf47f42e0c 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -1,19 +1,17 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlAvatar, GlAvatarLink } from '@gitlab/ui';
import { escape } from 'lodash';
import { mapActions } from 'vuex';
-
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
-
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import noteEditedText from './note_edited_text.vue';
import noteHeader from './note_header.vue';
export default {
name: 'DiffDiscussionHeader',
components: {
- userAvatarLink,
+ GlAvatar,
+ GlAvatarLink,
noteEditedText,
noteHeader,
},
@@ -86,6 +84,9 @@ export default {
return sprintf(text, { commitDisplay, linkStart, linkEnd }, false);
},
+ adaptiveAvatarSize() {
+ return { default: 24, md: 32 };
+ },
},
methods: {
...mapActions(['toggleDiscussion']),
@@ -100,16 +101,11 @@ export default {
<div class="discussion-header gl-display-flex gl-align-items-center gl-p-5">
<div
v-once
- class="timeline-icon gl-align-self-start gl-flex-shrink-0 gl-flex-shrink gl-ml-3 gl-mr-4"
+ class="timeline-icon gl-align-self-start gl-flex-shrink-0 gl-flex-shrink gl-mx-3 gl-md-ml-2 gl-md-mr-5"
>
- <user-avatar-link
- v-if="author"
- :link-href="author.path"
- :img-src="author.avatar_url"
- :img-alt="author.name"
- :img-size="24"
- :img-css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (https://gitlab.com/groups/gitlab-org/-/epics/7731) */"
- />
+ <gl-avatar-link v-if="author" :href="author.path">
+ <gl-avatar :src="author.avatar_url" :alt="author.name" :size="adaptiveAvatarSize" />
+ </gl-avatar-link>
</div>
<div class="timeline-content w-100">
<note-header
@@ -127,14 +123,14 @@ export default {
:edited-at="discussion.resolved_at"
:edited-by="discussion.resolved_by"
:action-text="resolvedText"
- class-name="discussion-headline-light js-discussion-headline"
+ class-name="discussion-headline-light js-discussion-headline gl-pl-2"
/>
<note-edited-text
v-else-if="lastUpdatedAt"
:edited-at="lastUpdatedAt"
:edited-by="lastUpdatedBy"
:action-text="__('Last updated')"
- class-name="discussion-headline-light js-discussion-headline"
+ class-name="discussion-headline-light js-discussion-headline gl-pl-2"
/>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 10e3f57a56d..c7f293a219a 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -170,7 +170,7 @@ export default {
return this.targetType === 'issue';
},
canAssign() {
- return this.getNoteableData.current_user?.can_update && this.isIssue;
+ return this.getNoteableData.current_user?.can_set_issue_metadata && this.isIssue;
},
displayAuthorBadgeText() {
return sprintf(__('This user is the author of this %{noteable}.'), {
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index cc74c2ee605..f1c41eea428 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -8,7 +8,6 @@ import { __ } from '~/locale';
import '~/behaviors/markdown/render_gfm';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import autosave from '../mixins/autosave';
-import { INTERNAL_NOTE_CLASSES } from '../constants';
import noteAttachment from './note_attachment.vue';
import noteAwardsList from './note_awards_list.vue';
import noteEditedText from './note_edited_text.vue';
@@ -55,11 +54,6 @@ export default {
required: false,
default: '',
},
- isInternalNote: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
...mapGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']),
@@ -101,12 +95,6 @@ export default {
return escape(suggestion);
},
- internalNoteContainerClasses() {
- if (this.isInternalNote && !this.isEditing) {
- return INTERNAL_NOTE_CLASSES;
- }
- return '';
- },
},
mounted() {
this.renderGFM();
@@ -179,54 +167,52 @@ export default {
}"
class="note-body"
>
- <div :class="internalNoteContainerClasses" data-testid="note-internal-container">
- <suggestions
- v-if="hasSuggestion && !isEditing"
- :suggestions="note.suggestions"
- :suggestions-count="suggestionsCount"
- :batch-suggestions-info="batchSuggestionsInfo"
- :note-html="note.note_html"
- :line-type="lineType"
- :help-page-path="helpPagePath"
- :default-commit-message="commitMessage"
- :failed-to-load-metadata="failedToLoadMetadata"
- @apply="applySuggestion"
- @applyBatch="applySuggestionBatch"
- @addToBatch="addSuggestionToBatch"
- @removeFromBatch="removeSuggestionFromBatch"
- />
- <div v-else v-safe-html:[$options.safeHtmlConfig]="note.note_html" class="note-text md"></div>
- <note-form
- v-if="isEditing"
- ref="noteForm"
- :note-body="noteBody"
- :note-id="note.id"
- :line="line"
- :note="note"
- :save-button-title="saveButtonTitle"
- :help-page-path="helpPagePath"
- :discussion="discussion"
- :resolve-discussion="note.resolve_discussion"
- @handleFormUpdate="handleFormUpdate"
- @cancelForm="formCancelHandler"
- />
- <!-- eslint-disable vue/no-mutating-props -->
- <textarea
- v-if="canEdit"
- v-model="note.note"
- :data-update-url="note.path"
- class="hidden js-task-list-field"
- dir="auto"
- ></textarea>
- <!-- eslint-enable vue/no-mutating-props -->
- <note-edited-text
- v-if="note.last_edited_at"
- :edited-at="note.last_edited_at"
- :edited-by="note.last_edited_by"
- action-text="Edited"
- class="note_edited_ago"
- />
- </div>
+ <suggestions
+ v-if="hasSuggestion && !isEditing"
+ :suggestions="note.suggestions"
+ :suggestions-count="suggestionsCount"
+ :batch-suggestions-info="batchSuggestionsInfo"
+ :note-html="note.note_html"
+ :line-type="lineType"
+ :help-page-path="helpPagePath"
+ :default-commit-message="commitMessage"
+ :failed-to-load-metadata="failedToLoadMetadata"
+ @apply="applySuggestion"
+ @applyBatch="applySuggestionBatch"
+ @addToBatch="addSuggestionToBatch"
+ @removeFromBatch="removeSuggestionFromBatch"
+ />
+ <div v-else v-safe-html:[$options.safeHtmlConfig]="note.note_html" class="note-text md"></div>
+ <note-form
+ v-if="isEditing"
+ ref="noteForm"
+ :note-body="noteBody"
+ :note-id="note.id"
+ :line="line"
+ :note="note"
+ :save-button-title="saveButtonTitle"
+ :help-page-path="helpPagePath"
+ :discussion="discussion"
+ :resolve-discussion="note.resolve_discussion"
+ @handleFormUpdate="handleFormUpdate"
+ @cancelForm="formCancelHandler"
+ />
+ <!-- eslint-disable vue/no-mutating-props -->
+ <textarea
+ v-if="canEdit"
+ v-model="note.note"
+ :data-update-url="note.path"
+ class="hidden js-task-list-field"
+ dir="auto"
+ ></textarea>
+ <!-- eslint-enable vue/no-mutating-props -->
+ <note-edited-text
+ v-if="note.last_edited_at"
+ :edited-at="note.last_edited_at"
+ :edited-by="note.last_edited_by"
+ action-text="Edited"
+ class="note_edited_ago"
+ />
<note-awards-list
v-if="note.award_emoji && note.award_emoji.length"
:note-id="note.id"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index a4cd20e6db8..30579a8eb0d 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -251,8 +251,10 @@ export default {
}
},
cancelHandler(shouldConfirm = false) {
- // Sends information about confirm message and if the textarea has changed
- this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
+ // check if any dropdowns are active before sending the cancelation event
+ if (!this.$refs.textarea.classList.contains('at-who-active')) {
+ this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
+ }
},
onInput() {
if (this.isSubmittingWithKeydown) {
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 095ab5ddb0f..875cfff74fe 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -406,7 +406,7 @@ export default {
<template>
<timeline-entry-item
:id="noteAnchorId"
- :class="classNameBindings"
+ :class="{ ...classNameBindings, 'internal-note': note.confidential }"
:data-award-url="note.toggle_award_path"
:data-note-id="note.id"
class="note note-wrapper"
@@ -506,7 +506,6 @@ export default {
ref="noteBody"
:note="note"
:can-edit="note.current_user.can_edit"
- :is-internal-note="note.confidential"
:line="line"
:file="diffFile"
:is-editing="isEditing"
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 3317f4e2383..a5f459c8910 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -51,5 +51,3 @@ export const toggleStateErrorMessage = {
[REOPENED]: __('Something went wrong while closing the merge request. Please try again later.'),
},
};
-
-export const INTERNAL_NOTE_CLASSES = ['gl-bg-orange-50', 'gl-px-4', 'gl-py-2'];
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 39d0a46d6d0..0823eacf1b7 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -7,7 +7,7 @@ import * as utils from './utils';
export default {
[types.ADD_NEW_NOTE](state, data) {
const note = data.discussion ? data.discussion.notes[0] : data;
- const { discussion_id, type } = note;
+ const { discussion_id: discussionId, type } = note;
const [exists] = state.discussions.filter((n) => n.id === note.discussion_id);
const isDiscussion = type === constants.DISCUSSION_NOTE || type === constants.DIFF_NOTE;
@@ -17,9 +17,9 @@ export default {
if (!discussion) {
discussion = {
expanded: true,
- id: discussion_id,
+ id: discussionId,
individual_note: !isDiscussion,
- reply_id: discussion_id,
+ reply_id: discussionId,
};
if (isDiscussion && isInMRPage()) {
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
index ec18a570960..14e97fcef46 100644
--- a/app/assets/javascripts/notes/utils.js
+++ b/app/assets/javascripts/notes/utils.js
@@ -1,5 +1,5 @@
/* eslint-disable @gitlab/require-i18n-strings */
-import marked from 'marked';
+import { marked } from 'marked';
import { sanitize } from '~/lib/dompurify';
import { markdownConfig } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
index bfa99c01c3f..ce221a274c9 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
@@ -60,7 +60,7 @@ export default {
return this.$options.i18n[`CLEANUP_STATUS_${this.status}`];
},
calculatedTimeTilNextRun() {
- return timeTilRun(this.expirationPolicy?.next_run);
+ return timeTilRun(this.expirationPolicy?.next_run_at);
},
expireIconName() {
return this.failedDelete ? 'expire' : 'clock';
@@ -90,9 +90,9 @@ export default {
{{ statusText }}
</span>
<gl-icon
- v-if="failedDelete"
+ v-if="failedDelete && calculatedTimeTilNextRun"
:id="iconId"
- :size="14"
+ :size="16"
class="gl-text-gray-500"
data-testid="extra-info"
name="information-o"
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
index aecc0bf92ea..80bca536b7c 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -95,7 +95,7 @@ export default {
if (this.showFullPath) {
return this.item.path;
}
- const projectPath = this.item?.project?.path ?? '';
+ const projectPath = this.item?.project?.path?.toLowerCase() ?? '';
if (this.item.name) {
return joinPaths(projectPath, this.item.name);
}
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
index 1faff1ff4de..45dc217b9e3 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -39,7 +39,7 @@ export default {
directives: {
GlModalDirective,
},
- inject: ['groupPath', 'groupId', 'noManifestsIllustration'],
+ inject: ['groupPath', 'groupId', 'noManifestsIllustration', 'canClearCache'],
i18n: {
proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'),
copyImagePrefixText: s__('DependencyProxy|Copy prefix'),
@@ -114,7 +114,7 @@ export default {
);
},
showDeleteDropdown() {
- return this.group.dependencyProxyManifests?.nodes.length > 0;
+ return this.group.dependencyProxyManifests?.nodes.length > 0 && this.canClearCache;
},
showDependencyProxyImagePrefix() {
return this.group.dependencyProxyImagePrefix?.length > 0;
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
index 14789aafdb7..428d6d6cd75 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import app from '~/packages_and_registries/dependency_proxy/app.vue';
import { apolloProvider } from '~/packages_and_registries/dependency_proxy/graphql';
import Translate from '~/vue_shared/translate';
@@ -10,12 +11,15 @@ export const initDependencyProxyApp = () => {
if (!el) {
return null;
}
- const { ...dataset } = el.dataset;
+ const { groupPath, groupId, noManifestsIllustration, canClearCache } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
- ...dataset,
+ groupPath,
+ groupId,
+ noManifestsIllustration,
+ canClearCache: parseBoolean(canClearCache),
},
render(createElement) {
return createElement(app);
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
index 408d34fbe93..51a38c434cb 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
@@ -26,8 +26,7 @@ export const receivePackagesListSuccess = ({ commit }, { data, headers }) => {
export const requestPackagesList = ({ dispatch, state }, params = {}) => {
dispatch('setLoading', true);
- // eslint-disable-next-line camelcase
- const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params;
+ const { page = DEFAULT_PAGE, per_page: perPage = DEFAULT_PAGE_SIZE } = params;
const { sort, orderBy } = state.sorting;
const type = state.config.forceTerraform
? TERRAFORM_SEARCH_TYPE
@@ -38,7 +37,7 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => {
const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages';
return Api[apiMethod](state.config.resourceId, {
- params: { page, per_page, sort, order_by: orderBy, ...packageFilters },
+ params: { page, per_page: perPage, sort, order_by: orderBy, ...packageFilters },
})
.then(({ data, headers }) => {
dispatch('receivePackagesListSuccess', { data, headers });
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
index a049b0eff8d..b872294d2cf 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
@@ -1,14 +1,16 @@
<script>
-import { GlLink, GlTableLite, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@gitlab/ui';
+import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui';
import { last } from 'lodash';
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue';
import Tracking from '~/tracking';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
+ REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION,
+ SELECT_PACKAGE_FILE_TRACKING_ACTION,
TRACKING_LABEL_PACKAGE_ASSET,
TRACKING_ACTION_EXPAND_PACKAGE_ASSET,
} from '~/packages_and_registries/package_registry/constants';
@@ -17,10 +19,10 @@ export default {
name: 'PackageFiles',
components: {
GlLink,
- GlTableLite,
- GlIcon,
+ GlTable,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlButton,
FileIcon,
TimeAgoTooltip,
@@ -33,13 +35,29 @@ export default {
required: false,
default: false,
},
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
packageFiles: {
type: Array,
required: false,
default: () => [],
},
},
+ data() {
+ return {
+ selectedReferences: [],
+ };
+ },
computed: {
+ areFilesSelected() {
+ return this.selectedReferences.length > 0;
+ },
+ areAllFilesSelected() {
+ return this.packageFiles.every(this.isSelected);
+ },
filesTableRows() {
return this.packageFiles.map((pf) => ({
...pf,
@@ -47,6 +65,9 @@ export default {
pipeline: last(pf.pipelines),
}));
},
+ hasSelectedSomeFiles() {
+ return this.areFilesSelected && !this.areAllFilesSelected;
+ },
showCommitColumn() {
// note that this is always false for now since we do not return
// pipelines associated to files for performance concerns
@@ -55,6 +76,12 @@ export default {
filesTableHeaderFields() {
return [
{
+ key: 'checkbox',
+ label: __('Select all'),
+ class: 'gl-w-4',
+ hide: !this.canDelete,
+ },
+ {
key: 'name',
label: __('Name'),
},
@@ -77,7 +104,7 @@ export default {
label: '',
hide: !this.canDelete,
class: 'gl-text-right',
- tdClass: 'gl-w-4',
+ tdClass: 'gl-w-4 gl-pt-3!',
},
].filter((c) => !c.hide);
},
@@ -99,21 +126,71 @@ export default {
this.track(TRACKING_ACTION_EXPAND_PACKAGE_ASSET, { label: TRACKING_LABEL_PACKAGE_ASSET });
}
},
+ updateSelectedReferences(selection) {
+ this.track(SELECT_PACKAGE_FILE_TRACKING_ACTION);
+ this.selectedReferences = selection;
+ },
+ isSelected(packageFile) {
+ return this.selectedReferences.find((reference) => reference.id === packageFile.id);
+ },
+ handleFileDeleteSelected() {
+ this.track(REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION);
+ this.$emit('delete-files', this.selectedReferences);
+ },
},
i18n: {
deleteFile: __('Delete file'),
+ deleteSelected: s__('PackageRegistry|Delete selected'),
+ moreActionsText: __('More actions'),
},
};
</script>
<template>
- <div>
- <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
- <gl-table-lite
+ <div class="gl-pt-6">
+ <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
+ <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
+ <gl-button
+ v-if="canDelete"
+ :disabled="isLoading || !areFilesSelected"
+ category="secondary"
+ variant="danger"
+ data-testid="delete-selected"
+ @click="handleFileDeleteSelected"
+ >
+ {{ $options.i18n.deleteSelected }}
+ </gl-button>
+ </div>
+ <gl-table
:fields="filesTableHeaderFields"
:items="filesTableRows"
+ show-empty
+ selectable
+ select-mode="multi"
+ selected-variant="primary"
:tbody-tr-attr="{ 'data-testid': 'file-row' }"
+ @row-selected="updateSelectedReferences"
>
+ <template #head(checkbox)="{ selectAllRows, clearSelected }">
+ <gl-form-checkbox
+ v-if="canDelete"
+ data-testid="package-files-checkbox-all"
+ :checked="areAllFilesSelected"
+ :indeterminate="hasSelectedSomeFiles"
+ @change="areAllFilesSelected ? clearSelected() : selectAllRows()"
+ />
+ </template>
+
+ <template #cell(checkbox)="{ rowSelected, selectRow, unselectRow }">
+ <gl-form-checkbox
+ v-if="canDelete"
+ class="gl-mt-1"
+ :checked="rowSelected"
+ data-testid="package-files-checkbox"
+ @change="rowSelected ? unselectRow() : selectRow()"
+ />
+ </template>
+
<template #cell(name)="{ item, toggleDetails, detailsShowing }">
<gl-button
v-if="hasDetails(item)"
@@ -156,11 +233,15 @@ export default {
</template>
<template #cell(actions)="{ item }">
- <gl-dropdown category="tertiary" right>
- <template #button-content>
- <gl-icon name="ellipsis_v" />
- </template>
- <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-file', item)">
+ <gl-dropdown
+ category="tertiary"
+ icon="ellipsis_v"
+ :text-sr-only="true"
+ :text="$options.i18n.moreActionsText"
+ no-caret
+ right
+ >
+ <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-files', [item])">
{{ $options.i18n.deleteFile }}
</gl-dropdown-item>
</gl-dropdown>
@@ -180,6 +261,6 @@ export default {
<file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" />
</div>
</template>
- </gl-table-lite>
+ </gl-table>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
index 96b82a20364..a1fc7563de1 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
@@ -5,12 +5,17 @@ import { first } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, n__ } from '~/locale';
+import Tracking from '~/tracking';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants';
import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE,
FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE,
+ TRACKING_ACTION_CLICK_PIPELINE_LINK,
+ TRACKING_ACTION_CLICK_COMMIT_LINK,
+ TRACKING_LABEL_PACKAGE_HISTORY,
} from '../../constants';
import getPackagePipelinesQuery from '../../graphql/queries/get_package_pipelines.query.graphql';
import PackageHistoryLoader from './package_history_loader.vue';
@@ -37,6 +42,9 @@ export default {
PackageHistoryLoader,
TimeAgoTooltip,
},
+ mixins: [Tracking.mixin()],
+ TRACKING_ACTION_CLICK_PIPELINE_LINK,
+ TRACKING_ACTION_CLICK_COMMIT_LINK,
props: {
packageEntity: {
type: Object,
@@ -97,6 +105,11 @@ export default {
first: GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE,
};
},
+ tracking() {
+ return {
+ category: packageTypeToTrackCategory(this.packageType),
+ };
+ },
},
methods: {
truncate(value) {
@@ -105,6 +118,12 @@ export default {
convertToBaseId(value) {
return getIdFromGraphQLId(value);
},
+ trackPipelineClick() {
+ this.track(TRACKING_ACTION_CLICK_PIPELINE_LINK, { label: TRACKING_LABEL_PACKAGE_HISTORY });
+ },
+ trackCommitClick() {
+ this.track(TRACKING_ACTION_CLICK_COMMIT_LINK, { label: TRACKING_LABEL_PACKAGE_HISTORY });
+ },
},
};
</script>
@@ -140,7 +159,9 @@ export default {
<history-item icon="commit" data-testid="first-pipeline-commit">
<gl-sprintf :message="$options.i18n.createdByCommitText">
<template #link>
- <gl-link :href="firstPipeline.commitPath">#{{ truncate(firstPipeline.sha) }}</gl-link>
+ <gl-link :href="firstPipeline.commitPath" @click="trackCommitClick"
+ >#{{ truncate(firstPipeline.sha) }}</gl-link
+ >
</template>
<template #branch>
<strong>{{ firstPipeline.ref }}</strong>
@@ -150,7 +171,9 @@ export default {
<history-item icon="pipeline" data-testid="first-pipeline-pipeline">
<gl-sprintf :message="$options.i18n.createdByPipelineText">
<template #link>
- <gl-link :href="firstPipeline.path">#{{ convertToBaseId(firstPipeline.id) }}</gl-link>
+ <gl-link :href="firstPipeline.path" @click="trackPipelineClick"
+ >#{{ convertToBaseId(firstPipeline.id) }}</gl-link
+ >
</template>
<template #datetime>
<time-ago-tooltip :time="firstPipeline.createdAt" />
@@ -189,13 +212,17 @@ export default {
>
<gl-sprintf :message="$options.i18n.combinedUpdateText">
<template #link>
- <gl-link :href="pipeline.commitPath">#{{ truncate(pipeline.sha) }}</gl-link>
+ <gl-link :href="pipeline.commitPath" @click="trackCommitClick"
+ >#{{ truncate(pipeline.sha) }}</gl-link
+ >
</template>
<template #branch>
<strong>{{ pipeline.ref }}</strong>
</template>
<template #pipeline>
- <gl-link :href="pipeline.path">#{{ convertToBaseId(pipeline.id) }}</gl-link>
+ <gl-link :href="pipeline.path" @click="trackPipelineClick"
+ >#{{ convertToBaseId(pipeline.id) }}</gl-link
+ >
</template>
<template #datetime>
<time-ago-tooltip :time="pipeline.createdAt" />
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
index f5946797626..11fd0db3106 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
@@ -23,6 +23,7 @@ export default {
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
+ inject: ['isGroupPage'],
i18n: {
packageInfo: __('v%{version} published %{timeAgo}'),
},
@@ -65,9 +66,6 @@ export default {
this.checkBreakpoints();
},
methods: {
- dynamicSlotName(index) {
- return `metadata-tag${index}`;
- },
checkBreakpoints() {
this.isDesktop = GlBreakpointInstance.isDesktop();
},
@@ -83,21 +81,38 @@ export default {
data-qa-selector="package_title"
>
<template #sub-header>
- <span data-testid="sub-header">
+ <div data-testid="sub-header" class="gl-display-flex gl-gap-3">
<gl-sprintf :message="$options.i18n.packageInfo">
<template #version>
{{ packageEntity.version }}
</template>
<template #timeAgo>
- <time-ago-tooltip
- v-if="packageEntity.createdAt"
- class="gl-ml-2"
- :time="packageEntity.createdAt"
- />
+ <time-ago-tooltip v-if="packageEntity.createdAt" :time="packageEntity.createdAt" />
</template>
</gl-sprintf>
- </span>
+
+ <package-tags
+ v-if="isDesktop && hasTagsToDisplay"
+ :tag-display-limit="2"
+ :tags="packageEntity.tags.nodes"
+ hide-label
+ />
+
+ <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap -->
+ <template v-else-if="hasTagsToDisplay">
+ <gl-badge
+ v-for="(tag, index) in packageEntity.tags.nodes"
+ :key="index"
+ class="gl-my-1"
+ data-testid="tag-badge"
+ variant="info"
+ size="sm"
+ >
+ {{ tag.name }}
+ </gl-badge>
+ </template>
+ </div>
</template>
<template v-if="packageTypeDisplay" #metadata-type>
@@ -108,7 +123,7 @@ export default {
<metadata-item data-testid="package-size" icon="disk" :text="totalSize" />
</template>
- <template v-if="packagePipeline" #metadata-pipeline>
+ <template v-if="isGroupPage && packagePipeline" #metadata-pipeline>
<metadata-item
data-testid="pipeline-project"
icon="review-list"
@@ -121,21 +136,6 @@ export default {
<metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" />
</template>
- <template v-if="isDesktop && hasTagsToDisplay" #metadata-tags>
- <package-tags :tag-display-limit="2" :tags="packageEntity.tags.nodes" hide-label />
- </template>
-
- <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap -->
- <template
- v-for="(tag, index) in packageEntity.tags.nodes"
- v-else-if="hasTagsToDisplay"
- #[dynamicSlotName(index)]
- >
- <gl-badge :key="index" class="gl-my-1" data-testid="tag-badge" variant="info" size="sm">
- {{ tag.name }}
- </gl-badge>
- </template>
-
<template #right-actions>
<slot name="delete-button"></slot>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
index a126d30f1ec..dd58f28a262 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
@@ -1,9 +1,10 @@
<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
+import { GlFormGroup, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
import {
+ PERSONAL_ACCESS_TOKEN_HELP_URL,
TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND,
TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND,
TRACKING_LABEL_CODE_INSTRUCTION,
@@ -16,6 +17,7 @@ export default {
components: {
InstallationTitle,
CodeInstruction,
+ GlFormGroup,
GlLink,
GlSprintf,
},
@@ -43,6 +45,7 @@ password = <your personal access token>`;
TRACKING_LABEL_CODE_INSTRUCTION,
},
i18n: {
+ tokenText: s__(`PackageRegistry|You will need a %{linkStart}personal access token%{linkEnd}.`),
setupText: s__(
`PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file.`,
),
@@ -50,7 +53,10 @@ password = <your personal access token>`;
'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.',
),
},
- links: { PYPI_HELP_PATH },
+ links: {
+ PERSONAL_ACCESS_TOKEN_HELP_URL,
+ PYPI_HELP_PATH,
+ },
installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }],
};
</script>
@@ -59,14 +65,28 @@ password = <your personal access token>`;
<div>
<installation-title package-type="pypi" :options="$options.installOptions" />
- <code-instruction
- :label="s__('PackageRegistry|Pip Command')"
- :instruction="pypiPipCommand"
- :copy-text="s__('PackageRegistry|Copy Pip command')"
- data-testid="pip-command"
- :tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND"
- :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION"
- />
+ <gl-form-group id="installation-pip-command-group">
+ <code-instruction
+ id="installation-pip-command"
+ :label="s__('PackageRegistry|Pip Command')"
+ :instruction="pypiPipCommand"
+ :copy-text="s__('PackageRegistry|Copy Pip command')"
+ data-testid="pip-command"
+ :tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND"
+ :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION"
+ />
+ <template #description>
+ <gl-sprintf :message="$options.i18n.tokenText">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.links.PERSONAL_ACCESS_TOKEN_HELP_URL"
+ data-testid="access-token-link"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-form-group>
<h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
<p>
@@ -87,7 +107,12 @@ password = <your personal access token>`;
/>
<gl-sprintf :message="$options.i18n.helpText">
<template #link="{ content }">
- <gl-link :href="$options.links.PYPI_HELP_PATH" target="_blank">{{ content }}</gl-link>
+ <gl-link
+ :href="$options.links.PYPI_HELP_PATH"
+ target="_blank"
+ data-testid="pypi-docs-link"
+ >{{ content }}</gl-link
+ >
</template>
</gl-sprintf>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index cea053992f8..5b2a347a4ee 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -7,9 +7,12 @@ export {
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
PULL_PACKAGE_TRACKING_ACTION,
DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DELETE_PACKAGE_FILES_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
+ SELECT_PACKAGE_FILE_TRACKING_ACTION,
} from '~/packages_and_registries/shared/constants';
export const PACKAGE_TYPE_CONAN = 'CONAN';
@@ -69,6 +72,11 @@ export const TRACKING_ACTION_DOWNLOAD_PACKAGE_ASSET = 'download_package_asset';
export const TRACKING_ACTION_EXPAND_PACKAGE_ASSET = 'expand_package_asset';
export const TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA = 'copy_package_asset_sha';
+export const TRACKING_ACTION_CLICK_PIPELINE_LINK = 'click_pipeline_link_from_package';
+export const TRACKING_ACTION_CLICK_COMMIT_LINK = 'click_commit_link_from_package';
+
+export const TRACKING_LABEL_PACKAGE_HISTORY = 'package_history';
+
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package file.',
@@ -76,6 +84,12 @@ export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
'PackageRegistry|Package file deleted successfully',
);
+export const DELETE_PACKAGE_FILES_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Something went wrong while deleting the package assets.',
+);
+export const DELETE_PACKAGE_FILES_SUCCESS_MESSAGE = s__(
+ 'PackageRegistry|Package assets deleted successfully',
+);
export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__(
'PackageRegistry|Failed to load the package data',
);
@@ -162,5 +176,6 @@ export const CONAN_HELP_PATH = helpPagePath('user/packages/conan_repository/inde
export const NUGET_HELP_PATH = helpPagePath('user/packages/nuget_repository/index');
export const PYPI_HELP_PATH = helpPagePath('user/packages/pypi_repository/index');
export const COMPOSER_HELP_PATH = helpPagePath('user/packages/composer_repository/index');
+export const PERSONAL_ACCESS_TOKEN_HELP_URL = helpPagePath('user/profile/personal_access_tokens');
export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql
deleted file mode 100644
index f016640f57d..00000000000
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql
+++ /dev/null
@@ -1,5 +0,0 @@
-mutation destroyPackageFile($id: PackagesPackageFileID!) {
- destroyPackageFile(input: { id: $id }) {
- errors
- }
-}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql
new file mode 100644
index 00000000000..8f9a3156492
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql
@@ -0,0 +1,5 @@
+mutation destroyPackageFiles($projectPath: ID!, $ids: [PackagesPackageFileID!]!) {
+ destroyPackageFiles(input: { projectPath: $projectPath, ids: $ids }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
index 5574020c9e4..f3f0d096d10 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
@@ -20,6 +20,7 @@ query getPackageDetails($id: PackagesPackageID!) {
id
path
name
+ fullPath
}
tags(first: 10) {
nodes {
@@ -39,6 +40,9 @@ query getPackageDetails($id: PackagesPackageID!) {
}
}
packageFiles(first: 100) {
+ pageInfo {
+ hasNextPage
+ }
nodes {
id
fileMd5
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
index 29438fba86b..e83962bb608 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
@@ -34,16 +34,19 @@ import {
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DELETE_PACKAGE_FILES_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
SHOW_DELETE_SUCCESS_ALERT,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ DELETE_PACKAGE_FILES_ERROR_MESSAGE,
+ DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
-import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
+import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
import Tracking from '~/tracking';
@@ -83,7 +86,8 @@ export default {
},
data() {
return {
- fileToDelete: null,
+ filesToDelete: [],
+ mutationLoading: false,
packageEntity: {},
};
},
@@ -114,6 +118,9 @@ export default {
projectName() {
return this.packageEntity.project?.name;
},
+ projectPath() {
+ return this.packageEntity.project?.fullPath;
+ },
packageId() {
return this.$route.params.id;
},
@@ -131,6 +138,9 @@ export default {
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
+ packageFilesLoading() {
+ return this.isLoading || this.mutationLoading;
+ },
isValidPackage() {
return this.isLoading || Boolean(this.packageEntity.name);
},
@@ -175,12 +185,14 @@ export default {
window.location.replace(`${returnTo}?${modalQuery}`);
},
- async deletePackageFile(id) {
+ async deletePackageFiles(ids) {
+ this.mutationLoading = true;
try {
const { data } = await this.$apollo.mutate({
- mutation: destroyPackageFileMutation,
+ mutation: destroyPackageFilesMutation,
variables: {
- id,
+ projectPath: this.projectPath,
+ ids,
},
awaitRefetchQueries: true,
refetchQueries: [
@@ -190,31 +202,53 @@ export default {
},
],
});
- if (data?.destroyPackageFile?.errors[0]) {
- throw data.destroyPackageFile.errors[0];
+ if (data?.destroyPackageFiles?.errors[0]) {
+ throw data.destroyPackageFiles.errors[0];
}
createFlash({
- message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ message:
+ ids.length === 1
+ ? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE
+ : DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
type: 'success',
});
} catch (error) {
createFlash({
- message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ message:
+ ids.length === 1
+ ? DELETE_PACKAGE_FILE_ERROR_MESSAGE
+ : DELETE_PACKAGE_FILES_ERROR_MESSAGE,
type: 'warning',
captureError: true,
error,
});
}
+ this.mutationLoading = false;
},
- handleFileDelete(file) {
+ handleFileDelete(files) {
this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION);
- this.fileToDelete = { ...file };
- this.$refs.deleteFileModal.show();
+ if (
+ files.length === this.packageFiles.length &&
+ !this.packageEntity.packageFiles?.pageInfo?.hasNextPage
+ ) {
+ this.$refs.deleteModal.show();
+ } else {
+ this.filesToDelete = files;
+ if (files.length === 1) {
+ this.$refs.deleteFileModal.show();
+ } else if (files.length > 1) {
+ this.$refs.deleteFilesModal.show();
+ }
+ }
},
- confirmFileDelete() {
- this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
- this.deletePackageFile(this.fileToDelete.id);
- this.fileToDelete = null;
+ confirmFilesDelete() {
+ if (this.filesToDelete.length === 1) {
+ this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
+ } else {
+ this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION);
+ }
+ this.deletePackageFiles(this.filesToDelete.map((file) => file.id));
+ this.filesToDelete = [];
},
},
i18n: {
@@ -240,6 +274,10 @@ export default {
text: __('Delete'),
attributes: [{ variant: 'danger' }, { category: 'primary' }],
},
+ filesDeletePrimaryAction: {
+ text: s__('PackageRegistry|Permanently delete assets'),
+ attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ },
cancelAction: {
text: __('Cancel'),
},
@@ -287,9 +325,10 @@ export default {
<package-files
v-if="showFiles"
:can-delete="packageEntity.canDestroy"
+ :is-loading="packageFilesLoading"
:package-files="packageFiles"
@download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
- @delete-file="handleFileDelete"
+ @delete-files="handleFileDelete"
/>
</gl-tab>
@@ -355,15 +394,43 @@ export default {
:action-primary="$options.modal.fileDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
data-testid="delete-file-modal"
- @primary="confirmFileDelete"
+ @primary="confirmFilesDelete"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
>
<template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template>
- <gl-sprintf v-if="fileToDelete" :message="$options.i18n.deleteFileModalContent">
+ <gl-sprintf v-if="filesToDelete.length === 1" :message="$options.i18n.deleteFileModalContent">
<template #filename>
- <strong>{{ fileToDelete.file_name }}</strong>
+ <strong>{{ filesToDelete[0].fileName }}</strong>
</template>
</gl-sprintf>
</gl-modal>
+
+ <gl-modal
+ ref="deleteFilesModal"
+ size="sm"
+ modal-id="delete-files-modal"
+ :action-primary="$options.modal.filesDeletePrimaryAction"
+ :action-cancel="$options.modal.cancelAction"
+ data-testid="delete-files-modal"
+ @primary="confirmFilesDelete"
+ @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
+ >
+ <template #modal-title>{{
+ n__(
+ `PackageRegistry|Delete 1 asset`,
+ `PackageRegistry|Delete %d assets`,
+ filesToDelete.length,
+ )
+ }}</template>
+ <span v-if="filesToDelete.length > 0">
+ {{
+ n__(
+ `PackageRegistry|You are about to delete 1 asset. This operation is irreversible.`,
+ `PackageRegistry|You are about to delete %d assets. This operation is irreversible.`,
+ filesToDelete.length,
+ )
+ }}
+ </span>
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
index 90a18d5cf5a..1c44d2bc38b 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
@@ -11,7 +11,7 @@ import {
UNAVAILABLE_ADMIN_FEATURE_TEXT,
} from '~/packages_and_registries/settings/project/constants';
import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue';
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
index 7682754fdcb..f06e3a41bd0 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
@@ -35,22 +35,34 @@ export default {
required: false,
default: '',
},
+ dropdownClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
};
</script>
<template>
<gl-form-group :id="`${name}-form-group`" :label-for="name" :label="label">
- <gl-form-select :id="name" :value="value" :disabled="disabled" @input="$emit('input', $event)">
- <option
- v-for="option in formOptions"
- :key="option.key"
- :value="option.key"
- data-testid="option"
+ <div :class="dropdownClass">
+ <gl-form-select
+ :id="name"
+ :value="value"
+ :disabled="disabled"
+ @input="$emit('input', $event)"
>
- {{ option.label }}
- </option>
- </gl-form-select>
+ <option
+ v-for="option in formOptions"
+ :key="option.key"
+ :value="option.key"
+ data-testid="option"
+ >
+ {{ option.label }}
+ </option>
+ </gl-form-select>
+ </div>
<template v-if="description" #description>
<span data-testid="description" class="gl-text-gray-400">
{{ description }}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
index 1170407a349..2f4bc35e5f7 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
@@ -6,7 +6,7 @@ import {
PACKAGES_CLEANUP_POLICY_DESCRIPTION,
} from '~/packages_and_registries/settings/project/constants';
import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
import PackagesCleanupPolicyForm from './packages_cleanup_policy_form.vue';
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
index b1751d5174a..f1f0b970b15 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
@@ -3,10 +3,10 @@ import { GlButton } from '@gitlab/ui';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
- SET_CLEANUP_POLICY_BUTTON,
KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION,
KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME,
KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL,
+ SET_CLEANUP_POLICY_BUTTON,
} from '~/packages_and_registries/settings/project/constants';
import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql';
import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils';
@@ -108,18 +108,17 @@ export default {
<template>
<form ref="form-element" @submit.prevent="submit">
- <div class="gl-md-max-w-50p">
- <expiration-dropdown
- v-model="prefilledForm.keepNDuplicatedPackageFiles"
- :disabled="isFieldDisabled"
- :form-options="$options.formOptions.keepNDuplicatedPackageFiles"
- :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL"
- :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION"
- name="keep-n-duplicated-package-files"
- data-testid="keep-n-duplicated-package-files-dropdown"
- @input="onModelChange($event, 'keepNDuplicatedPackageFiles')"
- />
- </div>
+ <expiration-dropdown
+ :value="prefilledForm.keepNDuplicatedPackageFiles"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.keepNDuplicatedPackageFiles"
+ :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL"
+ :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION"
+ dropdown-class="gl-md-max-w-50p gl-sm-pr-5"
+ name="keep-n-duplicated-package-files"
+ data-testid="keep-n-duplicated-package-files-dropdown"
+ @input="onModelChange($event, 'keepNDuplicatedPackageFiles')"
+ />
<div class="gl-mt-7 gl-display-flex gl-align-items-center">
<gl-button
data-testid="save-button"
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 948520151ce..fcb4a8ee297 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -4,7 +4,7 @@ export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up im
export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__(
`ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`,
);
-export const SET_CLEANUP_POLICY_BUTTON = __('Save');
+export const SET_CLEANUP_POLICY_BUTTON = __('Save changes');
export const UNAVAILABLE_FEATURE_TITLE = s__(
`ContainerRegistry|Cleanup policy for tags is disabled`,
);
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
index 5caf95cd050..0458b914b58 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
@@ -1,7 +1,7 @@
<template>
<section class="settings gl-py-7">
- <div class="gl-lg-display-flex">
- <div class="gl-lg-w-half gl-pr-10">
+ <div class="gl-lg-display-flex gl-gap-6">
+ <div class="gl-lg-w-40p gl-pr-10 gl-flex-shrink-0">
<h4>
<slot name="title"></slot>
</h4>
@@ -9,7 +9,7 @@
<slot name="description"></slot>
</p>
</div>
- <div class="gl-lg-w-half gl-pt-3">
+ <div class="gl-pt-3 gl-flex-grow-1">
<slot></slot>
</div>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
index 5505205cf33..6744e821565 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
@@ -9,7 +9,11 @@ export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package';
export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package';
export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package';
export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file';
+export const DELETE_PACKAGE_FILES_TRACKING_ACTION = 'delete_package_files';
+export const SELECT_PACKAGE_FILE_TRACKING_ACTION = 'select_package_file';
export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file';
+export const REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION =
+ 'request_delete_selected_package_file';
export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file';
export const DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION = 'download_package_asset';
diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue
index 713287f65b4..f01e5e595a3 100644
--- a/app/assets/javascripts/pages/groups/new/components/app.vue
+++ b/app/assets/javascripts/pages/groups/new/components/app.vue
@@ -2,51 +2,74 @@
import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg';
import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
import createGroupDescriptionDetails from './create_group_description_details.vue';
-const PANELS = [
- {
- name: 'create-group-pane',
- selector: '#create-group-pane',
- title: s__('GroupsNew|Create group'),
- description: s__(
- 'GroupsNew|Assemble related projects together and grant members access to several projects at once.',
- ),
- illustration: newGroupIllustration,
- details: createGroupDescriptionDetails,
- },
- {
- name: 'import-group-pane',
- selector: '#import-group-pane',
- title: s__('GroupsNew|Import group'),
- description: s__('GroupsNew|Import a group and related data from another GitLab instance.'),
- illustration: importGroupIllustration,
- details: 'Migrate your existing groups from another instance of GitLab.',
- },
-];
-
export default {
components: {
NewNamespacePage,
},
props: {
+ parentGroupName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ importExistingGroupPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
hasErrors: {
type: Boolean,
required: false,
default: false,
},
},
- PANELS,
+ computed: {
+ initialBreadcrumb() {
+ return this.parentGroupName || __('New group');
+ },
+ panels() {
+ return [
+ {
+ name: 'create-group-pane',
+ selector: '#create-group-pane',
+ title: this.parentGroupName
+ ? s__('GroupsNew|Create subgroup')
+ : s__('GroupsNew|Create group'),
+ description: s__(
+ 'GroupsNew|Assemble related projects together and grant members access to several projects at once.',
+ ),
+ illustration: newGroupIllustration,
+ details: createGroupDescriptionDetails,
+ detailProps: {
+ parentGroupName: this.parentGroupName,
+ importExistingGroupPath: this.importExistingGroupPath,
+ },
+ },
+ {
+ name: 'import-group-pane',
+ selector: '#import-group-pane',
+ title: s__('GroupsNew|Import group'),
+ description: s__(
+ 'GroupsNew|Import a group and related data from another GitLab instance.',
+ ),
+ illustration: importGroupIllustration,
+ details: 'Migrate your existing groups from another instance of GitLab.',
+ },
+ ];
+ },
+ },
};
</script>
<template>
<new-namespace-page
:jump-to-last-persisted-panel="hasErrors"
- :initial-breadcrumb="__('New group')"
- :panels="$options.PANELS"
+ :initial-breadcrumb="initialBreadcrumb"
+ :panels="panels"
:title="s__('GroupsNew|Create new group')"
persistence-key="new_group_last_active_tab"
/>
diff --git a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue
index 35193171fb8..be8542628c4 100644
--- a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue
+++ b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue
@@ -1,6 +1,22 @@
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+const DESCRIPTION_DETAILS = {
+ group: [
+ s__(
+ 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.',
+ ),
+ s__('GroupsNew|Groups can also be nested by creating %{linkStart}subgroups%{linkEnd}.'),
+ ],
+ subgroup: [
+ s__(
+ 'GroupsNew|%{groupsLinkStart}Groups%{groupsLinkEnd} and %{subgroupsLinkStart}subgroups%{subgroupsLinkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.',
+ ),
+ s__('GroupsNew|You can also %{linkStart}import an existing group%{linkEnd}.'),
+ ],
+};
export default {
components: {
@@ -11,30 +27,46 @@ export default {
groupsHelpPath: helpPagePath('user/group/index'),
subgroupsHelpPath: helpPagePath('user/group/subgroups/index'),
},
+ props: {
+ parentGroupName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ importExistingGroupPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ descriptionDetails: DESCRIPTION_DETAILS,
};
</script>
<template>
<div>
<p>
- <gl-sprintf
- :message="
- s__(
- 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.',
- )
- "
- >
+ <gl-sprintf v-if="parentGroupName" :message="$options.descriptionDetails.subgroup[0]">
+ <template #groupsLink="{ content }">
+ <gl-link :href="$options.paths.groupsHelpPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #subgroupsLink="{ content }">
+ <gl-link :href="$options.paths.subgroupsHelpPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="$options.descriptionDetails.group[0]">
<template #link="{ content }">
<gl-link :href="$options.paths.groupsHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<p>
- <gl-sprintf
- :message="
- s__('GroupsNew|Groups can also be nested by creating %{linkStart}subgroups%{linkEnd}.')
- "
- >
+ <gl-sprintf v-if="parentGroupName" :message="$options.descriptionDetails.subgroup[1]">
+ <template #link="{ content }">
+ <gl-link :href="importExistingGroupPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="$options.descriptionDetails.group[1]">
<template #link="{ content }">
<gl-link :href="$options.paths.subgroupsHelpPath" target="_blank">{{ content }}</gl-link>
</template>
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 7c409010510..7dab5258b24 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -16,9 +16,18 @@ BindInOut.initAll();
initFilePickers();
function initNewGroupCreation(el) {
- const { hasErrors, verificationRequired, verificationFormUrl, subscriptionsUrl } = el.dataset;
+ const {
+ hasErrors,
+ parentGroupName,
+ importExistingGroupPath,
+ verificationRequired,
+ verificationFormUrl,
+ subscriptionsUrl,
+ } = el.dataset;
const props = {
+ parentGroupName,
+ importExistingGroupPath,
hasErrors: parseBoolean(hasErrors),
};
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 6748a62e777..9cce6723bf7 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
@@ -68,7 +68,7 @@ export default {
}),
tableCell({
key: 'created_at',
- label: __('Date'),
+ label: __('Start date'),
}),
tableCell({
key: 'status',
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
index 3fae9809e51..c520042c172 100644
--- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -2,12 +2,10 @@ import {
initAccessTokenTableApp,
initExpiresAtField,
initNewAccessTokenApp,
- initProjectsField,
initTokensApp,
} from '~/access_tokens';
initAccessTokenTableApp();
initExpiresAtField();
initNewAccessTokenApp();
-initProjectsField();
initTokensApp();
diff --git a/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js b/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js
deleted file mode 100644
index 61486606665..00000000000
--- a/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import { initCiSecureFiles } from '~/ci_secure_files';
-
-initCiSecureFiles();
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index c217bc5a727..65e7f48ed24 100644
--- a/app/assets/javascripts/pages/projects/graphs/charts/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -55,18 +55,30 @@ waitForCSSLoaded(() => {
},
attrs: {
height: LANGUAGE_CHART_HEIGHT,
+ responsive: true,
},
});
},
});
+ const {
+ graphEndpoint,
+ graphEndDate,
+ graphStartDate,
+ graphRef,
+ graphCsvPath,
+ } = codeCoverageContainer.dataset;
// eslint-disable-next-line no-new
new Vue({
el: codeCoverageContainer,
render(h) {
return h(CodeCoverage, {
props: {
- graphEndpoint: codeCoverageContainer.dataset?.graphEndpoint,
+ graphEndpoint,
+ graphEndDate,
+ graphStartDate,
+ graphRef,
+ graphCsvPath,
},
});
},
@@ -92,6 +104,9 @@ waitForCSSLoaded(() => {
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
},
+ attrs: {
+ responsive: true,
+ },
});
},
});
@@ -125,6 +140,9 @@ waitForCSSLoaded(() => {
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
},
+ attrs: {
+ responsive: true,
+ },
});
},
});
@@ -149,6 +167,9 @@ waitForCSSLoaded(() => {
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
},
+ attrs: {
+ responsive: true,
+ },
});
},
});
diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
index 92ae8128285..d7e68484143 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { get } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility';
@@ -11,6 +11,7 @@ export default {
components: {
GlAlert,
GlAreaChart,
+ GlButton,
GlDropdown,
GlDropdownItem,
GlSprintf,
@@ -20,6 +21,22 @@ export default {
type: String,
required: true,
},
+ graphEndDate: {
+ type: String,
+ required: true,
+ },
+ graphStartDate: {
+ type: String,
+ required: true,
+ },
+ graphRef: {
+ type: String,
+ required: true,
+ },
+ graphCsvPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -119,6 +136,28 @@ export default {
<template>
<div>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-t gl-pt-4 gl-mb-3"
+ >
+ <h4 class="gl-m-0" sub-header>
+ <gl-sprintf
+ :message="__('Code coverage statistics for %{ref} %{start_date} - %{end_date}')"
+ >
+ <template #ref>
+ <strong> {{ graphRef }} </strong>
+ </template>
+ <template #start_date>
+ <strong> {{ graphStartDate }} </strong>
+ </template>
+ <template #end_date>
+ <strong> {{ graphEndDate }} </strong>
+ </template>
+ </gl-sprintf>
+ </h4>
+ <gl-button v-if="canShowData" size="small" data-testid="download-button" :href="graphCsvPath">
+ {{ __('Download raw data (.csv)') }}
+ </gl-button>
+ </div>
<div class="gl-mt-3 gl-mb-3">
<gl-alert
v-if="hasFetchError"
@@ -155,6 +194,7 @@ export default {
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
+ responsive
>
<template v-if="canShowData" #tooltip-title>
{{ tooltipTitle }}
diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js
index 7db34816cfe..f7849e8d588 100644
--- a/app/assets/javascripts/pages/projects/init_blob.js
+++ b/app/assets/javascripts/pages/projects/init_blob.js
@@ -4,6 +4,7 @@ import BlobForkSuggestion from '~/blob/blob_fork_suggestion';
import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
import LineHighlighter from '~/blob/line_highlighter';
import initBlobBundle from '~/blob_edit/blob_bundle';
+import addBlobLinksTracking from '~/blob/blob_links_tracking';
export default () => {
new LineHighlighter(); // eslint-disable-line no-new
@@ -11,10 +12,16 @@ export default () => {
// eslint-disable-next-line no-new
new BlobLinePermalinkUpdater(
document.querySelector('#blob-content-holder'),
- '.diff-line-num[data-line-number], .diff-line-num[data-line-number] *',
+ '.file-line-num[data-line-number], .file-line-num[data-line-number] *',
document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
);
+ const eventsToTrack = [
+ { selector: '.file-line-blame', property: 'blame' },
+ { selector: '.file-line-num', property: 'link' },
+ ];
+ addBlobLinksTracking('#blob-content-holder', eventsToTrack);
+
const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url');
const fileBlobPermalinkUrl =
fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
index ca2b1a08be8..c92958cd8c7 100644
--- a/app/assets/javascripts/pages/projects/issues/show/index.js
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -1,6 +1,6 @@
import { initShow } from '~/issues';
import { store } from '~/notes/stores';
-import initRelatedIssues from '~/related_issues';
+import { initRelatedIssues } from '~/related_issues';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initWorkItemLinks from '~/work_items/components/work_item_links';
diff --git a/app/assets/javascripts/pages/projects/pages/new/index.js b/app/assets/javascripts/pages/projects/pages/new/index.js
new file mode 100644
index 00000000000..a5157f5b01b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pages/new/index.js
@@ -0,0 +1,3 @@
+import initPages from '~/gitlab_pages/new';
+
+initPages();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
index cd4bc35e74e..9513f42d9c9 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import PipelineSchedulesTakeOwnershipModal from '~/pipeline_schedules/components/take_ownership_modal.vue';
import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue';
function initPipelineSchedules() {
@@ -23,4 +25,43 @@ function initPipelineSchedules() {
});
}
+function initTakeownershipModal() {
+ const modalId = 'pipeline-take-ownership-modal';
+ const buttonSelector = 'js-take-ownership-button';
+ const el = document.getElementById(modalId);
+ const takeOwnershipButtons = document.querySelectorAll(`.${buttonSelector}`);
+
+ if (!el) {
+ return;
+ }
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ data() {
+ return {
+ url: '',
+ };
+ },
+ mounted() {
+ takeOwnershipButtons.forEach((button) => {
+ button.addEventListener('click', () => {
+ const { url } = button.dataset;
+
+ this.url = url;
+ this.$root.$emit(BV_SHOW_MODAL, modalId, `.${buttonSelector}`);
+ });
+ });
+ },
+ render(createElement) {
+ return createElement(PipelineSchedulesTakeOwnershipModal, {
+ props: {
+ ownershipUrl: this.url,
+ },
+ });
+ },
+ });
+}
+
initPipelineSchedules();
+initTakeownershipModal();
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index f2c30870a68..c7c331c7de5 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -29,6 +29,10 @@ export default {
lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'),
mergeRequestsLabel: s__('ProjectSettings|Merge requests'),
operationsLabel: s__('ProjectSettings|Operations'),
+ environmentsLabel: s__('ProjectSettings|Environments'),
+ environmentsHelpText: s__(
+ 'ProjectSettings|Every project can make deployments to environments either via CI/CD or API calls. Non-project members have read-only access.',
+ ),
packagesHelpText: s__(
'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.',
),
@@ -209,6 +213,7 @@ export default {
requirementsAccessLevel: featureAccessLevel.EVERYONE,
securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
operationsAccessLevel: featureAccessLevel.EVERYONE,
+ environmentsAccessLevel: featureAccessLevel.EVERYONE,
containerRegistryAccessLevel: featureAccessLevel.EVERYONE,
warnAboutPotentiallyUnwantedCharacters: true,
lfsEnabled: true,
@@ -282,6 +287,9 @@ export default {
return this.operationsAccessLevel > featureAccessLevel.NOT_ENABLED;
},
+ environmentsEnabled() {
+ return this.environmentsAccessLevel > featureAccessLevel.NOT_ENABLED;
+ },
repositoryEnabled() {
return this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED;
},
@@ -318,12 +326,8 @@ export default {
packageRegistryAccessLevelEnabled() {
return this.glFeatures.packageRegistryAccessLevel;
},
- showAdditonalSettings() {
- if (this.glFeatures.enforceAuthChecksOnUploads) {
- return true;
- }
-
- return this.visibilityLevel !== this.visibilityOptions.PRIVATE;
+ splitOperationsEnabled() {
+ return this.glFeatures.splitOperationsVisibilityPermissions;
},
},
@@ -381,6 +385,10 @@ export default {
featureAccessLevel.PROJECT_MEMBERS,
this.operationsAccessLevel,
);
+ this.environmentsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.environmentsAccessLevel,
+ );
this.containerRegistryAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
this.containerRegistryAccessLevel,
@@ -422,6 +430,8 @@ export default {
this.requirementsAccessLevel = featureAccessLevel.EVERYONE;
if (this.operationsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.operationsAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
+ this.environmentsAccessLevel = featureAccessLevel.EVERYONE;
if (this.containerRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE;
@@ -545,7 +555,7 @@ export default {
</template>
</gl-sprintf>
</span>
- <div v-if="showAdditonalSettings" class="gl-mt-4">
+ <div class="gl-mt-4">
<strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong>
<label
v-if="visibilityLevel !== visibilityOptions.PRIVATE"
@@ -560,9 +570,7 @@ export default {
{{ s__('ProjectSettings|Users can request access') }}
</label>
<label
- v-if="
- visibilityLevel !== visibilityOptions.PUBLIC && glFeatures.enforceAuthChecksOnUploads
- "
+ v-if="visibilityLevel !== visibilityOptions.PUBLIC"
class="gl-line-height-28 gl-font-weight-normal gl-display-block gl-mb-0"
>
<input
@@ -866,6 +874,20 @@ export default {
/>
</project-setting-row>
</div>
+ <template v-if="splitOperationsEnabled">
+ <project-setting-row
+ ref="environments-settings"
+ :label="$options.i18n.environmentsLabel"
+ :help-text="$options.i18n.environmentsHelpText"
+ >
+ <project-feature-setting
+ v-model="environmentsAccessLevel"
+ :label="$options.i18n.environmentsLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][environments_access_level]"
+ />
+ </project-setting-row>
+ </template>
</div>
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
<label class="js-emails-disabled">
diff --git a/app/assets/javascripts/pages/projects/tags/releases/index.js b/app/assets/javascripts/pages/projects/tags/releases/index.js
deleted file mode 100644
index cafd880b4be..00000000000
--- a/app/assets/javascripts/pages/projects/tags/releases/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import $ from 'jquery';
-import GLForm from '~/gl_form';
-import ZenMode from '~/zen_mode';
-
-new ZenMode(); // eslint-disable-line no-new
-new GLForm($('.release-form')); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
index 94a5c1cb29b..897acf9b02c 100644
--- a/app/assets/javascripts/pages/registrations/new/index.js
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -3,12 +3,17 @@ import { trackNewRegistrations } from '~/google_tag_manager';
import NoEmojiValidator from '~/emoji/no_emoji_validator';
import LengthValidator from '~/pages/sessions/new/length_validator';
import UsernameValidator from '~/pages/sessions/new/username_validator';
+import EmailFormatValidator from '~/pages/sessions/new/email_format_validator';
import Tracking from '~/tracking';
new UsernameValidator(); // eslint-disable-line no-new
new LengthValidator(); // eslint-disable-line no-new
new NoEmojiValidator(); // eslint-disable-line no-new
+if (gon.features.trialEmailValidation) {
+ new EmailFormatValidator(); // eslint-disable-line no-new
+}
+
trackNewRegistrations();
Tracking.enableFormTracking({
diff --git a/app/assets/javascripts/pages/sessions/new/email_format_validator.js b/app/assets/javascripts/pages/sessions/new/email_format_validator.js
new file mode 100644
index 00000000000..6dcf3b50dca
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/email_format_validator.js
@@ -0,0 +1,46 @@
+import InputValidator from '~/validators/input_validator';
+
+// It checks if email contains at least one character, number or whatever except
+// another "@" or whitespace before "@", at least two characters except
+// another "@" or whitespace after "@" and one dot in between
+const emailRegexPattern = /[^@\s]+@[^@\s]+\.[^@\s]+/;
+const hintMessageSelector = '.validation-hint';
+const warningMessageSelector = '.validation-warning';
+
+export default class EmailFormatValidator extends InputValidator {
+ constructor(opts = {}) {
+ super();
+
+ const container = opts.container || '';
+
+ document
+ .querySelectorAll(`${container} .js-validate-email`)
+ .forEach((element) =>
+ element.addEventListener('keyup', EmailFormatValidator.eventHandler.bind(this)),
+ );
+ }
+
+ static eventHandler(event) {
+ const inputDomElement = event.target;
+
+ EmailFormatValidator.setMessageVisibility(inputDomElement, hintMessageSelector);
+ EmailFormatValidator.setMessageVisibility(inputDomElement, warningMessageSelector);
+ EmailFormatValidator.validateEmailInput(inputDomElement);
+ }
+
+ static validateEmailInput(inputDomElement) {
+ const validEmail = inputDomElement.checkValidity();
+ const validPattern = inputDomElement.value.match(emailRegexPattern);
+
+ EmailFormatValidator.setMessageVisibility(
+ inputDomElement,
+ warningMessageSelector,
+ validEmail && !validPattern,
+ );
+ }
+
+ static setMessageVisibility(inputDomElement, messageSelector, isVisible = false) {
+ const messageElement = inputDomElement.parentElement.querySelector(messageSelector);
+ messageElement.classList.toggle('hide', !isVisible);
+ }
+}
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 3c22844434d..9d7d9e376cf 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -9,6 +9,7 @@ import {
GlFormGroup,
GlFormInput,
GlFormSelect,
+ GlSegmentedControl,
} from '@gitlab/ui';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import axios from '~/lib/utils/axios_utils';
@@ -81,9 +82,11 @@ export default {
newPage: s__('WikiPage|Create page'),
},
cancel: s__('WikiPage|Cancel'),
- editSourceButtonText: s__('WikiPage|Edit source'),
- editRichTextButtonText: s__('WikiPage|Edit rich text'),
},
+ switchEditingControlOptions: [
+ { text: s__('Wiki Page|Source'), value: 'source' },
+ { text: s__('Wiki Page|Rich text'), value: 'richText' },
+ ],
components: {
GlAlert,
GlIcon,
@@ -94,6 +97,7 @@ export default {
GlSprintf,
GlLink,
GlButton,
+ GlSegmentedControl,
MarkdownField,
LocalStorageSync,
ContentEditor: () =>
@@ -105,14 +109,15 @@ export default {
inject: ['formatOptions', 'pageInfo'],
data() {
return {
+ editingMode: 'source',
title: this.pageInfo.title?.trim() || '',
format: this.pageInfo.format || 'markdown',
content: this.pageInfo.content || '',
- useContentEditor: false,
commitMessage: '',
isDirty: false,
contentEditorRenderFailed: false,
contentEditorEmpty: false,
+ switchEditingControlDisabled: false,
};
},
computed: {
@@ -177,6 +182,9 @@ export default {
isContentEditorActive() {
return this.isMarkdownFormat && this.useContentEditor;
},
+ useContentEditor() {
+ return this.editingMode === 'richText';
+ },
},
mounted() {
this.updateCommitMessage();
@@ -193,16 +201,15 @@ export default {
.then(({ data }) => data.body);
},
- toggleEditingMode() {
- if (this.useContentEditor) {
+ toggleEditingMode(editingMode) {
+ this.editingMode = editingMode;
+ if (!this.useContentEditor && this.contentEditor) {
this.content = this.contentEditor.getSerializedContent();
}
-
- this.useContentEditor = !this.useContentEditor;
},
- setUseContentEditor(value) {
- this.useContentEditor = value;
+ setEditingMode(value) {
+ this.editingMode = value;
},
async handleFormSubmit(e) {
@@ -294,6 +301,14 @@ export default {
},
});
},
+
+ enableSwitchEditingControl() {
+ this.switchEditingControlDisabled = false;
+ },
+
+ disableSwitchEditingControl() {
+ this.switchEditingControlDisabled = true;
+ },
},
};
</script>
@@ -372,20 +387,21 @@ export default {
<div class="row" data-testid="wiki-form-content-fieldset">
<div class="col-sm-12 row-sm-5">
<gl-form-group>
- <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-end gl-mb-3">
- <gl-button
+ <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-start gl-mb-3">
+ <gl-segmented-control
data-testid="toggle-editing-mode-button"
data-qa-selector="editing_mode_button"
- :data-qa-mode="toggleEditingModeButtonText"
- variant="link"
- @click="toggleEditingMode"
- >{{ toggleEditingModeButtonText }}</gl-button
- >
+ class="gl-display-flex"
+ :checked="editingMode"
+ :options="$options.switchEditingControlOptions"
+ :disabled="switchEditingControlDisabled"
+ @input="toggleEditingMode"
+ />
</div>
<local-storage-sync
storage-key="gl-wiki-content-editor-enabled"
- :value="useContentEditor"
- @input="setUseContentEditor"
+ :value="editingMode"
+ @input="setEditingMode"
/>
<markdown-field
v-if="!isContentEditorActive"
@@ -422,6 +438,9 @@ export default {
:uploads-path="pageInfo.uploadsPath"
@initialized="loadInitialContent"
@change="handleContentEditorChange"
+ @loading="disableSwitchEditingControl"
+ @loadingSuccess="enableSwitchEditingControl"
+ @loadingError="enableSwitchEditingControl"
/>
<input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
</div>
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 7c424088c8b..9cea89f4990 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -7,11 +7,12 @@ const DEFERRED_LINK_CLASS = 'deferred-link';
export default class PersistentUserCallout {
constructor(container, options = container.dataset) {
- const { dismissEndpoint, featureId, groupId, deferLinks } = options;
+ const { dismissEndpoint, featureId, groupId, namespaceId, deferLinks } = options;
this.container = container;
this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId;
this.groupId = groupId;
+ this.namespaceId = namespaceId;
this.deferLinks = parseBoolean(deferLinks);
this.closeButtons = this.container.querySelectorAll('.js-close');
@@ -56,6 +57,7 @@ export default class PersistentUserCallout {
.post(this.dismissEndpoint, {
feature_name: this.featureId,
group_id: this.groupId,
+ namespace_id: this.namespaceId,
})
.then(() => {
this.container.remove();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index f836921f5e5..ead512e3574 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -16,6 +16,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-minute-limit-banner',
'.js-submit-license-usage-data-banner',
'.js-project-usage-limitations-callout',
+ '.js-namespace-storage-alert',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
index 1f74e89f90c..0b57433e894 100644
--- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
@@ -28,13 +28,13 @@ export default {
GlSprintf,
},
mixins: [Tracking.mixin()],
- inject: ['runnerHelpPagePath'],
methods: {
trackHelpPageClick() {
const { label, actions } = pipelineEditorTrackingOptions;
this.track(actions.helpDrawerLinks.runners, { label });
},
},
+ RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html',
};
</script>
<template>
@@ -47,7 +47,7 @@ export default {
<p class="gl-mb-0">
<gl-sprintf :message="$options.i18n.note">
<template #link="{ content }">
- <gl-link :href="runnerHelpPagePath" target="_blank" @click="trackHelpPageClick()">
+ <gl-link :href="$options.RUNNER_HELP_URL" target="_blank" @click="trackHelpPageClick()">
{{ content }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
index 65a2a6b56e4..189690ce2c3 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
@@ -43,7 +43,9 @@ export default {
</script>
<template>
- <div class="gl-bg-gray-10 gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1">
+ <div
+ class="gl-bg-gray-10 gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1"
+ >
<gl-button
:href="$options.TEMPLATE_REPOSITORY_URL"
size="small"
diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
deleted file mode 100644
index f1cf5630fbf..00000000000
--- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<script>
-import { flatten } from 'lodash';
-import CiLintResults from './ci_lint_results.vue';
-
-export default {
- components: {
- CiLintResults,
- },
- inject: {
- lintHelpPagePath: {
- default: '',
- },
- },
- props: {
- isValid: {
- type: Boolean,
- required: true,
- },
- ciConfig: {
- type: Object,
- required: true,
- },
- },
- computed: {
- stages() {
- return this.ciConfig?.stages || [];
- },
- jobs() {
- const groupedJobs = this.stages.reduce((acc, { groups, name: stageName }) => {
- return acc.concat(
- groups.map(({ jobs }) => {
- return jobs.map((job) => ({
- stage: stageName,
- ...job,
- }));
- }),
- );
- }, []);
-
- return flatten(groupedJobs);
- },
- },
-};
-</script>
-
-<template>
- <ci-lint-results
- :errors="ciConfig.errors"
- :is-valid="isValid"
- :jobs="jobs"
- :lint-help-page-path="lintHelpPagePath"
- />
-</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index 99ee244577e..4941f22230b 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -2,7 +2,6 @@
import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import {
CREATE_TAB,
@@ -11,7 +10,6 @@ import {
EDITOR_APP_STATUS_LOADING,
EDITOR_APP_STATUS_VALID,
EDITOR_APP_STATUS_LINT_UNAVAILABLE,
- LINT_TAB,
MERGED_TAB,
TAB_QUERY_PARAM,
TABS_INDEX,
@@ -22,7 +20,6 @@ import {
import getAppStatus from '../graphql/queries/client/app_status.query.graphql';
import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
import CiEditorHeader from './editor/ci_editor_header.vue';
-import CiLint from './lint/ci_lint.vue';
import CiValidate from './validate/ci_validate.vue';
import TextEditor from './editor/text_editor.vue';
import EditorTab from './ui/editor_tab.vue';
@@ -56,7 +53,6 @@ export default {
},
tabConstants: {
CREATE_TAB,
- LINT_TAB,
MERGED_TAB,
VALIDATE_TAB,
VISUALIZE_TAB,
@@ -64,7 +60,6 @@ export default {
components: {
CiConfigMergedPreview,
CiEditorHeader,
- CiLint,
CiValidate,
EditorTab,
GlAlert,
@@ -74,7 +69,6 @@ export default {
TextEditor,
WalkthroughPopover,
},
- mixins: [glFeatureFlagsMixin()],
props: {
ciConfigData: {
type: Object,
@@ -212,7 +206,6 @@ export default {
<pipeline-graph v-else :pipeline-data="ciConfigData" />
</editor-tab>
<editor-tab
- v-if="glFeatures.simulatePipeline"
class="gl-mb-3"
data-testid="validate-tab"
:badge-title="validateTabBadgeTitle"
@@ -222,19 +215,6 @@ export default {
<ci-validate :ci-file-content="ciFileContent" />
</editor-tab>
<editor-tab
- v-else
- class="gl-mb-3"
- :empty-message="$options.i18n.empty.lint"
- :is-empty="isEmpty"
- :is-unavailable="isLintUnavailable"
- :title="$options.i18n.tabLint"
- data-testid="lint-tab"
- @click="setCurrentTab($options.tabConstants.LINT_TAB)"
- >
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
- <ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" />
- </editor-tab>
- <editor-tab
class="gl-mb-3"
:empty-message="$options.i18n.empty.merge"
:keep-component-mounted="false"
diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
index 47673119db9..83fcab4b343 100644
--- a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
+++ b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
@@ -11,6 +11,8 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
+import Tracking from '~/tracking';
+import { pipelineEditorTrackingOptions } from '../../constants';
import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue';
import CiLintResults from '../lint/ci_lint_results.vue';
import getBlobContent from '../../graphql/queries/blob_content.query.graphql';
@@ -70,6 +72,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [Tracking.mixin()],
inject: ['ciConfigPath', 'ciLintPath', 'projectFullPath', 'validateTabIllustrationPath'],
props: {
ciFileContent: {
@@ -110,6 +113,9 @@ export default {
};
},
computed: {
+ canResimulatePipeline() {
+ return this.hasSimulationResults && this.hasCiContentChanged;
+ },
isInitialCiContentLoading() {
return this.$apollo.queries.initialBlobContent.loading;
},
@@ -128,6 +134,10 @@ export default {
variant: this.isValid ? 'success' : 'danger',
};
},
+ trackingAction() {
+ const { actions } = pipelineEditorTrackingOptions;
+ return this.canResimulatePipeline ? actions.resimulatePipeline : actions.simulatePipeline;
+ },
},
watch: {
ciFileContent(value) {
@@ -139,7 +149,12 @@ export default {
cancelSimulation() {
this.state = VALIDATE_TAB_INIT;
},
+ trackSimulation() {
+ const { label } = pipelineEditorTrackingOptions;
+ this.track(this.trackingAction, { label });
+ },
async validateYaml() {
+ this.trackSimulation();
this.state = VALIDATE_TAB_LOADING;
try {
@@ -150,7 +165,7 @@ export default {
} = await this.$apollo.mutate({
mutation: lintCiMutation,
variables: {
- dry_run: true,
+ dry: true,
content: this.yaml,
endpoint: this.ciLintPath,
},
@@ -198,7 +213,7 @@ export default {
:aria-label="$options.i18n.help"
/>
</div>
- <div v-if="hasSimulationResults && hasCiContentChanged">
+ <div v-if="canResimulatePipeline">
<span class="gl-text-gray-400" data-testid="content-status">
{{ $options.i18n.contentChange }}
</span>
@@ -232,6 +247,7 @@ export default {
class="gl-mt-3"
:disabled="isInitialCiContentLoading"
data-testid="simulate-pipeline-button"
+ data-qa-selector="simulate_pipeline_button"
@click="validateYaml"
>
{{ $options.i18n.cta }}
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index 05db0afd15d..dd25c4d433b 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -30,7 +30,6 @@ export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
export const PIPELINE_FAILURE = 'PIPELINE_FAILURE';
export const CREATE_TAB = 'CREATE_TAB';
-export const LINT_TAB = 'LINT_TAB';
export const MERGED_TAB = 'MERGED_TAB';
export const VALIDATE_TAB = 'VALIDATE_TAB';
export const VISUALIZE_TAB = 'VISUALIZE_TAB';
@@ -38,9 +37,8 @@ export const VISUALIZE_TAB = 'VISUALIZE_TAB';
export const TABS_INDEX = {
[CREATE_TAB]: '0',
[VISUALIZE_TAB]: '1',
- [LINT_TAB]: '2',
- [VALIDATE_TAB]: '3',
- [MERGED_TAB]: '4',
+ [VALIDATE_TAB]: '2',
+ [MERGED_TAB]: '3',
};
export const TAB_QUERY_PARAM = 'tab';
@@ -77,6 +75,8 @@ export const pipelineEditorTrackingOptions = {
[CI_YAML_LINK]: 'visit_help_drawer_link_yaml',
},
openHelpDrawer: 'open_help_drawer',
+ resimulatePipeline: 'resimulate_pipeline',
+ simulatePipeline: 'simulate_pipeline',
},
};
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
index 5091d63111f..2d42ebb6ac3 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
@@ -13,7 +13,6 @@ mutation lintCI($endpoint: String, $content: String, $dry: Boolean) {
only {
refs
}
- afterScript
stage
tags
when
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
index 77a3cdf586c..3495ca51283 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
@@ -19,9 +19,7 @@ mutation commitCIFile(
]
}
) {
- __typename
commit {
- __typename
id
sha
}
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index 4f5b69107bf..13dad0b2459 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -40,7 +40,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectFullPath,
projectPath,
projectNamespace,
- runnerHelpPagePath,
simulatePipelineHelpPagePath,
totalBranches,
validateTabIllustrationPath,
@@ -132,7 +131,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectFullPath,
projectPath,
projectNamespace,
- runnerHelpPagePath,
simulatePipelineHelpPagePath,
totalBranches: parseInt(totalBranches, 10),
validateTabIllustrationPath,
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index d84fc724d38..9378b67b915 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -3,10 +3,11 @@ import {
GlAlert,
GlIcon,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlForm,
GlFormGroup,
GlFormInput,
- GlFormSelect,
GlFormTextarea,
GlLink,
GlSprintf,
@@ -43,10 +44,10 @@ const i18n = {
};
export default {
- typeOptions: [
- { value: VARIABLE_TYPE, text: __('Variable') },
- { value: FILE_TYPE, text: __('File') },
- ],
+ typeOptions: {
+ [VARIABLE_TYPE]: __('Variable'),
+ [FILE_TYPE]: __('File'),
+ },
i18n,
formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0',
// this height value is used inline on the textarea to match the input field height
@@ -56,10 +57,11 @@ export default {
GlAlert,
GlIcon,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlForm,
GlFormGroup,
GlFormInput,
- GlFormSelect,
GlFormTextarea,
GlLink,
GlSprintf,
@@ -202,6 +204,11 @@ export default {
});
}
},
+ setVariableType(key, type) {
+ const { variables } = this.form[this.refFullName];
+ const variable = variables.find((v) => v.key === key);
+ variable.variable_type = type;
+ },
setVariableParams(refValue, type, paramsObj) {
Object.entries(paramsObj).forEach(([key, value]) => {
this.setVariable(refValue, type, key, value);
@@ -401,12 +408,19 @@ export default {
<div
class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row"
>
- <gl-form-select
- v-model="variable.variable_type"
+ <gl-dropdown
+ :text="$options.typeOptions[variable.variable_type]"
:class="$options.formElementClasses"
- :options="$options.typeOptions"
data-testid="pipeline-form-ci-variable-type"
- />
+ >
+ <gl-dropdown-item
+ v-for="type in Object.keys($options.typeOptions)"
+ :key="type"
+ @click="setVariableType(variable.key, type)"
+ >
+ {{ $options.typeOptions[type] }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<gl-form-input
v-model="variable.key"
:placeholder="s__('CiVariables|Input variable key')"
diff --git a/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue b/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue
new file mode 100644
index 00000000000..7ded3945a32
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ ownershipUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ modalId: 'pipeline-take-ownership-modal',
+ i18n: {
+ takeOwnership: s__('PipelineSchedules|Take ownership'),
+ ownershipMessage: s__(
+ 'PipelineSchedules|Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?',
+ ),
+ cancelLabel: __('Cancel'),
+ },
+ computed: {
+ actionCancel() {
+ return { text: this.$options.i18n.cancelLabel };
+ },
+ actionPrimary() {
+ return {
+ text: this.$options.i18n.takeOwnership,
+ attributes: [
+ {
+ variant: 'confirm',
+ category: 'primary',
+ href: this.ownershipUrl,
+ 'data-method': 'post',
+ },
+ ],
+ };
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="$options.modalId"
+ :action-primary="actionPrimary"
+ :action-cancel="actionCancel"
+ :title="$options.i18n.takeOwnership"
+ >
+ <p>{{ $options.i18n.ownershipMessage }}</p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
index f2b159acfee..4ba5c237311 100644
--- a/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
@@ -32,6 +32,11 @@ export default {
required: false,
default: false,
},
+ id: {
+ type: String,
+ required: false,
+ default: () => uniqueId('checklist_'),
+ },
},
computed: {
checklistItems() {
@@ -62,8 +67,8 @@ export default {
</script>
<template>
- <gl-form-group #default="{ ariaDescribedby }" :label="title">
- <gl-form-checkbox-group :aria-describedby="ariaDescribedby" @input="updateValidState">
+ <gl-form-group :label="title" :label-for="id">
+ <gl-form-checkbox-group :id="id" :label="title" @input="updateValidState">
<gl-form-checkbox
v-for="item in checklistItems"
:id="item.id"
diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
index 939702fd1b5..79b1507ad0e 100644
--- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
+++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
@@ -49,7 +49,7 @@ export default {
<template>
<div>
<div class="gl-my-8">
- <h2 class="gl-mb-4" data-testid="title">{{ title }}</h2>
+ <h1 class="gl-mb-4" data-testid="title">{{ title }}</h1>
<p class="text-tertiary gl-font-lg gl-max-w-80" data-testid="description">
{{ description }}
</p>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 795ba91a164..8d764fad0c5 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -46,6 +46,9 @@ export default {
const { name, status } = this.group;
return `${name} - ${status.label}`;
},
+ jobGroupClasses() {
+ return [this.cssClassJobName, `job-${this.group.status.group}`];
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry('job_group_dropdown', `error: ${err}, info: ${info}`);
@@ -68,7 +71,7 @@ export default {
type="button"
data-toggle="dropdown"
data-display="static"
- :class="cssClassJobName"
+ :class="jobGroupClasses"
class="dropdown-menu-toggle gl-pipeline-job-width! gl-pr-4!"
>
<div class="gl-display-flex gl-align-items-stretch gl-justify-content-space-between">
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 362571930d6..377f21b299f 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -200,6 +200,9 @@ export default {
},
{ 'gl-rounded-lg': this.isBridge },
this.cssClassJobName,
+ {
+ [`job-${this.status.group}`]: this.isSingleItem,
+ },
];
},
},
diff --git a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
index ae6b9186930..fdbf0ca19bc 100644
--- a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
+++ b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
@@ -97,13 +97,16 @@ export default {
<gl-loading-icon v-if="$apollo.queries.jobs.loading" size="lg" />
<template v-else>
- <gl-alert v-if="showLimitMessage" class="gl-mb-4" :dismissible="false">
- <p>{{ $options.i18n.insightsLimit }}</p>
+ <gl-alert class="gl-mb-4" :dismissible="false">
+ <p v-if="showLimitMessage" data-testid="limit-alert-text">
+ {{ $options.i18n.insightsLimit }}
+ </p>
<gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/365902" class="gl-mt-5">
{{ $options.i18n.feeback }}
</gl-link>
</gl-alert>
- <div class="gl-display-flex gl-justify-content-space-between gl-mb-7">
+
+ <div class="gl-display-flex gl-justify-content-space-between gl-mt-2 gl-mb-7">
<gl-card class="gl-w-half gl-mr-7 gl-text-center">
<template #header>
<span class="gl-font-weight-bold">{{ $options.i18n.queuedCardHeader }}</span>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
index e1745969649..df59962569e 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
@@ -34,7 +34,13 @@ export default {
PipelineGraphWrapper,
TestReports,
},
- inject: ['defaultTabValue', 'failedJobsCount', 'failedJobsSummary', 'totalJobCount'],
+ inject: [
+ 'defaultTabValue',
+ 'failedJobsCount',
+ 'failedJobsSummary',
+ 'totalJobCount',
+ 'testsCount',
+ ],
computed: {
showFailedJobsTab() {
return this.failedJobsCount > 0;
@@ -81,11 +87,11 @@ export default {
</template>
<failed-jobs-app :failed-jobs-summary="failedJobsSummary" />
</gl-tab>
- <gl-tab
- :title="$options.i18n.tabs.testsTitle"
- :active="isActive($options.tabNames.tests)"
- data-testid="tests-tab"
- >
+ <gl-tab :active="isActive($options.tabNames.tests)" data-testid="tests-tab" lazy>
+ <template #title>
+ <span class="gl-mr-2">{{ $options.i18n.tabs.testsTitle }}</span>
+ <gl-badge size="sm" data-testid="tests-counter">{{ testsCount }}</gl-badge>
+ </template>
<test-reports />
</gl-tab>
<slot></slot>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue
index 64d4414eb94..439dc0eb253 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue
@@ -32,9 +32,10 @@ export default {
.map(({ name, logo, title }) => {
return {
name: title || name,
+ description: sprintf(this.$options.i18n.description, { name: title || name }),
+ isPng: logo.endsWith('png'),
logo,
link: mergeUrlParams({ template: name }, this.pipelineEditorPath),
- description: sprintf(this.$options.i18n.description, { name: title || name }),
};
});
@@ -48,6 +49,9 @@ export default {
label: template,
});
},
+ logoStyle(template) {
+ return template.isPng ? { objectFit: 'contain' } : '';
+ },
},
i18n: {
description: s__(
@@ -66,11 +70,13 @@ export default {
>
<div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
<gl-avatar
- :src="template.logo"
- :size="48"
+ :alt="template.name"
class="gl-mr-5 gl-bg-white dark-mode-override"
+ :class="{ 'gl-p-2': template.isPng }"
+ :style="logoStyle(template)"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
- :alt="template.name"
+ :size="48"
+ :src="template.logo"
data-testid="template-logo"
/>
<div class="gl-flex-direction-row">
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
index 9725e882d5e..05a1ceface3 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
@@ -3,16 +3,16 @@ import {
GlAlert,
GlDropdown,
GlDropdownItem,
- GlDropdownSectionHeader,
+ GlSearchBoxByType,
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
export const i18n = {
- artifacts: __('Artifacts'),
- artifactSectionHeader: __('Download artifacts'),
+ downloadArtifacts: __('Download artifacts'),
artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
emptyArtifactsMessage: __('No artifacts found'),
};
@@ -26,7 +26,7 @@ export default {
GlAlert,
GlDropdown,
GlDropdownItem,
- GlDropdownSectionHeader,
+ GlSearchBoxByType,
GlLoadingIcon,
},
inject: {
@@ -48,8 +48,16 @@ export default {
artifacts: [],
hasError: false,
isLoading: false,
+ searchQuery: '',
};
},
+ computed: {
+ filteredArtifacts() {
+ return this.searchQuery.length > 0
+ ? fuzzaldrinPlus.filter(this.artifacts, this.searchQuery, { key: 'name' })
+ : this.artifacts;
+ },
+ },
methods: {
fetchArtifacts() {
this.isLoading = true;
@@ -70,27 +78,27 @@ export default {
this.isLoading = false;
});
},
+ handleDropdownShown() {
+ this.$refs.searchInput.focusInput();
+ },
},
};
</script>
<template>
<gl-dropdown
v-gl-tooltip
- :title="$options.i18n.artifacts"
- :text="$options.i18n.artifacts"
- :aria-label="$options.i18n.artifacts"
- icon="ellipsis_v"
+ :title="$options.i18n.downloadArtifacts"
+ :text="$options.i18n.downloadArtifacts"
+ :aria-label="$options.i18n.downloadArtifacts"
+ :header-text="$options.i18n.downloadArtifacts"
+ icon="download"
data-testid="pipeline-multi-actions-dropdown"
right
lazy
text-sr-only
- no-caret
@show.once="fetchArtifacts"
+ @shown="handleDropdownShown"
>
- <gl-dropdown-section-header>{{
- $options.i18n.artifactSectionHeader
- }}</gl-dropdown-section-header>
-
<gl-alert v-if="hasError" variant="danger" :dismissible="false">
{{ $options.i18n.artifactsFetchErrorMessage }}
</gl-alert>
@@ -101,8 +109,12 @@ export default {
{{ $options.i18n.emptyArtifactsMessage }}
</gl-dropdown-item>
+ <template #header>
+ <gl-search-box-by-type v-if="artifacts.length" ref="searchInput" v-model.trim="searchQuery" />
+ </template>
+
<gl-dropdown-item
- v-for="(artifact, i) in artifacts"
+ v-for="(artifact, i) in filteredArtifacts"
:key="i"
:href="artifact.path"
rel="nofollow"
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 3fb46a4f128..e5666f7a658 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createTestReportsStore from '../../stores/test_reports';
import EmptyState from './empty_state.vue';
import TestSuiteTable from './test_suite_table.vue';
@@ -16,6 +17,7 @@ export default {
TestSummary,
TestSummaryTable,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'],
computed: {
...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']),
@@ -29,14 +31,16 @@ export default {
},
},
created() {
- this.$store.registerModule(
- 'testReports',
- createTestReportsStore({
- blobPath: this.blobPath,
- summaryEndpoint: this.summaryEndpoint,
- suiteEndpoint: this.suiteEndpoint,
- }),
- );
+ if (!this.glFeatures.pipelineTabsVue) {
+ this.$store.registerModule(
+ 'testReports',
+ createTestReportsStore({
+ blobPath: this.blobPath,
+ summaryEndpoint: this.summaryEndpoint,
+ suiteEndpoint: this.suiteEndpoint,
+ }),
+ );
+ }
this.fetchSummary();
},
@@ -74,7 +78,7 @@ export default {
<div
v-else-if="!isLoading && showTests"
ref="container"
- class="position-relative"
+ class="gl-relative"
data-testid="tests-detail"
>
<transition
@@ -82,13 +86,13 @@ export default {
@before-enter="beforeEnterTransition"
@after-leave="afterLeaveTransition"
>
- <div v-if="showSuite" key="detail" class="w-100 slide-enter-to-element">
+ <div v-if="showSuite" key="detail" class="gl-w-full slide-enter-to-element">
<test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" />
<test-suite-table />
</div>
- <div v-else key="summary" class="w-100 slide-enter-from-element">
+ <div v-else key="summary" class="gl-w-full slide-enter-from-element">
<test-summary :report="testReports" />
<test-summary-table @row-click="summaryTableRowClick" />
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 1f438c63fee..7d0f1ba4b5f 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -80,7 +80,10 @@ export default {
<h4>{{ heading }}</h4>
</div>
</div>
- <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray">
+ <div
+ role="row"
+ class="gl-responsive-table-row table-row-header gl-font-weight-bold gl-fill-gray-700"
+ >
<div role="rowheader" class="table-section section-20">
{{ __('Suite') }}
</div>
@@ -104,7 +107,7 @@ export default {
<div
v-for="(testCase, index) in getSuiteTests"
:key="index"
- class="gl-responsive-table-row rounded align-items-md-start"
+ class="gl-responsive-table-row gl-rounded-base gl-align-items-flex-start"
data-testid="test-case-row"
>
<div class="table-section section-20 section-wrap">
@@ -142,11 +145,8 @@ export default {
<div class="table-section section-10 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div>
- <div class="table-mobile-content text-center">
- <div
- class="ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center"
- :class="`ci-status-icon-${testCase.status}`"
- >
+ <div class="table-mobile-content gl-md-display-flex gl-justify-content-center">
+ <div class="ci-status-icon" :class="`ci-status-icon-${testCase.status}`">
<gl-icon :size="24" :name="testCase.icon" />
</div>
</div>
@@ -156,7 +156,7 @@ export default {
<div role="rowheader" class="table-mobile-header">
{{ __('Duration') }}
</div>
- <div class="table-mobile-content pr-sm-1">
+ <div class="table-mobile-content gl-sm-pr-2">
{{ testCase.formattedTime }}
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index 2f5301715c3..6b723ad5481 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -65,58 +65,53 @@ export default {
<template>
<div>
- <div class="row">
- <div class="col-12 d-flex gl-mt-3 align-items-center">
- <gl-button
- v-if="showBack"
- size="small"
- class="gl-mr-3 js-back-button"
- icon="chevron-lg-left"
- :aria-label="__('Go back')"
- @click="onBackClick"
- />
+ <div class="gl-w-full gl-display-flex gl-mt-3 gl-align-items-center">
+ <gl-button
+ v-if="showBack"
+ size="small"
+ class="gl-mr-3 js-back-button"
+ icon="chevron-lg-left"
+ :aria-label="__('Go back')"
+ @click="onBackClick"
+ />
- <h4>{{ heading }}</h4>
- </div>
+ <h4>{{ heading }}</h4>
</div>
- <div class="row mt-2">
- <div class="col-4 col-md">
- <span class="js-total-tests">{{
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-w-full gl-mt-3"
+ >
+ <div class="gl-display-flex gl-justify-content-space-between gl-flex-basis-half">
+ <span class="js-total-tests gl-flex-grow-1">{{
sprintf(s__('TestReports|%{count} tests'), { count: report.total_count })
}}</span>
- </div>
- <div class="col-4 col-md text-center text-md-center">
- <span class="js-failed-tests">{{
+ <span class="js-failed-tests gl-flex-grow-1">{{
sprintf(s__('TestReports|%{count} failures'), { count: report.failed_count })
}}</span>
- </div>
- <div class="col-4 col-md text-right text-md-center">
<span class="js-errored-tests">{{
sprintf(s__('TestReports|%{count} errors'), { count: report.error_count })
}}</span>
</div>
-
- <div class="col-6 mt-3 col-md mt-md-0 text-md-center">
- <span class="js-success-rate">{{
+ <div class="gl-display-flex gl-justify-content-space-between gl-flex-grow-1">
+ <div class="gl-display-none gl-md-display-block gl-flex-grow-1"></div>
+ <span class="js-success-rate gl-flex-grow-1">{{
sprintf(s__('TestReports|%{rate}%{sign} success rate'), {
rate: successPercentage,
sign: '%',
})
}}</span>
- </div>
- <div class="col-6 mt-3 col-md mt-md-0 text-right">
<span class="js-duration">{{ formattedDuration }}</span>
</div>
</div>
- <div class="row mt-3">
- <div class="col-12">
- <gl-progress-bar :value="successPercentage" :variant="progressBarVariant" height="10px" />
- </div>
- </div>
+ <gl-progress-bar
+ class="gl-mt-5"
+ :value="successPercentage"
+ :variant="progressBarVariant"
+ height="10px"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 8389c2a5104..7ab48da1a9d 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -34,33 +34,31 @@ export default {
<template>
<div>
- <div class="row gl-mt-3">
- <div class="col-12">
- <h4>{{ heading }}</h4>
- </div>
+ <div class="gl-mt-5">
+ <h4>{{ heading }}</h4>
</div>
- <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-suites-table">
- <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold">
- <div role="rowheader" class="table-section section-25 pl-3">
+ <div v-if="hasSuites" class="js-test-suites-table">
+ <div role="row" class="gl-responsive-table-row table-row-header gl-font-weight-bold">
+ <div role="rowheader" class="table-section section-25 gl-pl-5">
{{ __('Job') }}
</div>
<div role="rowheader" class="table-section section-25">
{{ __('Duration') }}
</div>
- <div role="rowheader" class="table-section section-10 text-center">
+ <div role="rowheader" class="table-section section-10 gl-text-center">
{{ __('Failed') }}
</div>
- <div role="rowheader" class="table-section section-10 text-center">
+ <div role="rowheader" class="table-section section-10 gl-text-center">
{{ __('Errors'), }}
</div>
- <div role="rowheader" class="table-section section-10 text-center">
+ <div role="rowheader" class="table-section section-10 gl-text-center">
{{ __('Skipped'), }}
</div>
- <div role="rowheader" class="table-section section-10 text-center">
+ <div role="rowheader" class="table-section section-10 gl-text-center">
{{ __('Passed'), }}
</div>
- <div role="rowheader" class="table-section section-10 pr-3 text-right">
+ <div role="rowheader" class="table-section section-10 gl-pr-5 gl-text-right">
{{ __('Total') }}
</div>
</div>
@@ -69,17 +67,17 @@ export default {
v-for="(testSuite, index) in getTestSuites"
:key="index"
role="row"
- class="gl-responsive-table-row test-reports-summary-row rounded js-suite-row"
+ class="gl-responsive-table-row gl-rounded-base js-suite-row"
:class="{
- 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error,
+ 'gl-responsive-table-row-clickable gl-cursor-pointer': !testSuite.suite_error,
}"
@click="tableRowClick(index)"
>
<div class="table-section section-25">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Suite') }}
</div>
- <div class="table-mobile-content underline cgray pl-3">
+ <div class="table-mobile-content underline gl-text-gray-900 gl-pl-5">
{{ testSuite.name }}
<gl-icon
v-if="testSuite.suite_error"
@@ -93,44 +91,44 @@ export default {
</div>
<div class="table-section section-25">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Duration') }}
</div>
- <div class="table-mobile-content text-md-left">
+ <div class="table-mobile-content gl-text-left">
{{ testSuite.formattedTime }}
</div>
</div>
- <div class="table-section section-10 text-center">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-center">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Failed') }}
</div>
<div class="table-mobile-content">{{ testSuite.failed_count }}</div>
</div>
- <div class="table-section section-10 text-center">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-center">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Errors') }}
</div>
<div class="table-mobile-content">{{ testSuite.error_count }}</div>
</div>
- <div class="table-section section-10 text-center">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-center">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Skipped') }}
</div>
<div class="table-mobile-content">{{ testSuite.skipped_count }}</div>
</div>
- <div class="table-section section-10 text-center">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-center">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Passed') }}
</div>
<div class="table-mobile-content">{{ testSuite.success_count }}</div>
</div>
- <div class="table-section section-10 text-right pr-md-3">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-right pr-md-3">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Total') }}
</div>
<div class="table-mobile-content">{{ testSuite.total_count }}</div>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 2e825016c91..7b38f870cb6 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -83,7 +83,7 @@ export const PipelineKeyOptions = [
export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.');
-export const BUTTON_TOOLTIP_RETRY = __('Retry failed jobs');
+export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs');
export const BUTTON_TOOLTIP_CANCEL = __('Cancel');
export const DEFAULT_FIELDS = [
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index c4f7665c91d..e8e49cc652e 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -1,5 +1,6 @@
import Visibility from 'visibilityjs';
-import createFlash from '~/flash';
+import createFlash, { createAlert } from '~/flash';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
@@ -198,18 +199,20 @@ export default {
})
.catch((e) => {
const unauthorized = e.response.status === httpStatusCodes.UNAUTHORIZED;
- const badRequest = e.response.status === httpStatusCodes.BAD_REQUEST;
-
let errorMessage = __(
'An error occurred while trying to run a new pipeline for this merge request.',
);
- if (unauthorized || badRequest) {
+ if (unauthorized) {
errorMessage = __('You do not have permission to run a pipeline on this branch.');
}
- createFlash({
+ createAlert({
message: errorMessage,
+ primaryButton: {
+ text: __('Learn more'),
+ link: helpPagePath('ci/pipelines/merge_request_pipelines.md'),
+ },
});
})
.finally(() => this.store.toggleIsRunningPipeline(false));
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index c0e769e2485..7051d356089 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -5,6 +5,7 @@ import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue';
import { removeParams, updateHistory } from '~/lib/utils/url_utility';
import { TAB_QUERY_PARAM } from '~/pipelines/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
+import createTestReportsStore from './stores/test_reports';
import { getPipelineDefaultTab, reportToSentry } from './utils';
Vue.use(VueApollo);
@@ -29,6 +30,17 @@ export const createAppOptions = (selector, apolloProvider) => {
pipelineIid,
pipelineProjectPath,
totalJobCount,
+ licenseManagementApiUrl,
+ licenseManagementSettingsPath,
+ licensesApiPath,
+ canManageLicenses,
+ summaryEndpoint,
+ suiteEndpoint,
+ blobPath,
+ hasTestReport,
+ emptyStateImagePath,
+ artifactsExpiredImagePath,
+ testsCount,
} = dataset;
const defaultTabValue = getPipelineDefaultTab(window.location.href);
@@ -39,7 +51,15 @@ export const createAppOptions = (selector, apolloProvider) => {
PipelineTabs,
},
apolloProvider,
- store: new Vuex.Store(),
+ store: new Vuex.Store({
+ modules: {
+ testReports: createTestReportsStore({
+ blobPath,
+ summaryEndpoint,
+ suiteEndpoint,
+ }),
+ },
+ }),
provide: {
canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports),
codequalityReportDownloadPath,
@@ -54,6 +74,17 @@ export const createAppOptions = (selector, apolloProvider) => {
pipelineIid,
pipelineProjectPath,
totalJobCount,
+ licenseManagementApiUrl,
+ licenseManagementSettingsPath,
+ licensesApiPath,
+ canManageLicenses: parseBoolean(canManageLicenses),
+ summaryEndpoint,
+ suiteEndpoint,
+ blobPath,
+ hasTestReport,
+ emptyStateImagePath,
+ artifactsExpiredImagePath,
+ testsCount,
},
errorCaptured(err, _vm, info) {
reportToSentry('pipeline_tabs', `error: ${err}, info: ${info}`);
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
index f0556f3d12e..b785fd1753c 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
@@ -30,7 +30,6 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => {
dispatch('toggleLoading');
- // eslint-disable-next-line camelcase
const { build_ids = [] } = state.testReports?.test_suites?.[index] || {};
// Replacing `/:suite_name.json` with the name of the suite. Including the extra characters
// to ensure that we replace exactly the template part of the URL string
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index bda58091b97..4ba7156b026 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -15,7 +15,11 @@ export default {
type: String,
required: true,
},
- refsProjectPath: {
+ sourceProjectRefsPath: {
+ type: String,
+ required: true,
+ },
+ targetProjectRefsPath: {
type: String,
required: true,
},
@@ -37,7 +41,11 @@ export default {
type: String,
required: true,
},
- defaultProject: {
+ sourceProject: {
+ type: Object,
+ required: true,
+ },
+ targetProject: {
type: Object,
required: true,
},
@@ -50,14 +58,14 @@ export default {
return {
from: {
projects: this.projects,
- selectedProject: this.defaultProject,
+ selectedProject: this.targetProject,
revision: this.paramsFrom,
- refsProjectPath: this.refsProjectPath,
+ refsProjectPath: this.targetProjectRefsPath,
},
to: {
- selectedProject: this.defaultProject,
+ selectedProject: this.sourceProject,
revision: this.paramsTo,
- refsProjectPath: this.refsProjectPath,
+ refsProjectPath: this.sourceProjectRefsPath,
},
};
},
diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js
index e485a086d39..074b8565c3c 100644
--- a/app/assets/javascripts/projects/compare/index.js
+++ b/app/assets/javascripts/projects/compare/index.js
@@ -5,13 +5,15 @@ export default function init() {
const el = document.getElementById('js-compare-selector');
const {
- refsProjectPath,
+ sourceProjectRefsPath,
+ targetProjectRefsPath,
paramsFrom,
paramsTo,
projectCompareIndexPath,
projectMergeRequestPath,
createMrPath,
- projectTo,
+ sourceProject,
+ targetProject,
projectsFrom,
} = el.dataset;
@@ -23,13 +25,15 @@ export default function init() {
render(createElement) {
return createElement(CompareApp, {
props: {
- refsProjectPath,
+ sourceProjectRefsPath,
+ targetProjectRefsPath,
paramsFrom,
paramsTo,
projectCompareIndexPath,
projectMergeRequestPath,
createMrPath,
- defaultProject: JSON.parse(projectTo),
+ sourceProject: JSON.parse(sourceProject),
+ targetProject: JSON.parse(targetProject),
projects: JSON.parse(projectsFrom),
},
});
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 28b77f6defd..0cfea401be6 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -17,6 +17,7 @@ const mountPipelineChartsApp = (el) => {
coverageChartPath,
defaultBranch,
testRunsEmptyStateImagePath,
+ projectQualitySummaryFeedbackImagePath,
} = el.dataset;
const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts);
@@ -37,6 +38,7 @@ const mountPipelineChartsApp = (el) => {
coverageChartPath,
defaultBranch,
testRunsEmptyStateImagePath,
+ projectQualitySummaryFeedbackImagePath,
},
render: (createElement) => createElement(ProjectPipelinesCharts, {}),
});
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index fe84660422b..424ea3b61c5 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'any_else_ce/projects/default_project_templates';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import Tracking from '~/tracking';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants';
import { ENTER_KEY } from '../lib/utils/keys';
import axios from '../lib/utils/axios_utils';
@@ -109,8 +110,31 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
);
};
+ const projectPathValueListener = () => {
+ // eslint-disable-next-line no-param-reassign
+ $projectPathInput.oldInputValue = $projectPathInput.value;
+ };
+
+ const projectPathTrackListener = () => {
+ if ($projectPathInput.oldInputValue === $projectPathInput.value) {
+ // no change made to the input
+ return;
+ }
+
+ const trackEvent = 'user_input_path_slug';
+ const trackCategory = undefined; // will be default set in event method
+
+ Tracking.event(trackCategory, trackEvent, {
+ label: 'new_project_form',
+ });
+ };
+
$projectPathInput.removeEventListener('keyup', projectPathInputListener);
$projectPathInput.addEventListener('keyup', projectPathInputListener);
+ $projectPathInput.removeEventListener('focus', projectPathValueListener);
+ $projectPathInput.addEventListener('focus', projectPathValueListener);
+ $projectPathInput.removeEventListener('blur', projectPathTrackListener);
+ $projectPathInput.addEventListener('blur', projectPathTrackListener);
$projectPathInput.removeEventListener('change', projectPathInputListener);
$projectPathInput.addEventListener('change', projectPathInputListener);
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue
index 6bbe0ab7d5f..6ba2ef7da99 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue
@@ -1,12 +1,24 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { __, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import branchesQuery from '../queries/branches.query.graphql';
export const i18n = {
- fetchBranchesError: __('An error occurred while fetching branches.'),
- noMatch: __('No matching results'),
+ fetchBranchesError: s__('BranchRules|An error occurred while fetching branches.'),
+ noMatch: s__('BranchRules|No matching results'),
+ branchHelpText: s__(
+ 'BranchRules|%{linkStart}Wildcards%{linkEnd} such as *-stable or production/* are supported.',
+ ),
+ wildCardSearchHelp: s__('BranchRules|Create wildcard: %{searchTerm}'),
};
export default {
@@ -17,6 +29,8 @@ export default {
GlDropdownItem,
GlDropdownDivider,
GlSearchBoxByType,
+ GlSprintf,
+ GlLink,
},
apollo: {
branchNames: {
@@ -39,6 +53,10 @@ export default {
},
},
},
+ searchInputDelay: 250,
+ wildcardsHelpPath: helpPagePath('user/project/protected_branches', {
+ anchor: 'configure-multiple-protected-branches-by-using-a-wildcard',
+ }),
props: {
projectPath: {
type: String,
@@ -58,7 +76,9 @@ export default {
},
computed: {
createButtonLabel() {
- return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm });
+ return sprintf(this.$options.i18n.wildCardSearchHelp, {
+ searchTerm: this.searchTerm,
+ });
},
shouldRenderCreateButton() {
return this.searchTerm && !this.branchNames.includes(this.searchTerm);
@@ -81,30 +101,37 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="value || branchNames[0]">
- <gl-search-box-by-type
- v-model.trim="searchTerm"
- data-testid="branch-search"
- debounce="250"
- :is-loading="isLoading"
- />
- <gl-dropdown-item
- v-for="branch in branchNames"
- :key="branch"
- :is-checked="isSelected(branch)"
- is-check-item
- @click="selectBranch(branch)"
- >
- {{ branch }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="!branchNames.length && !isLoading" data-testid="no-data">{{
- $options.i18n.noMatch
- }}</gl-dropdown-item>
- <template v-if="shouldRenderCreateButton">
- <gl-dropdown-divider />
- <gl-dropdown-item data-testid="create-wildcard-button" @click="createWildcard">
- {{ createButtonLabel }}
+ <div>
+ <gl-dropdown :text="value || branchNames[0]" class="gl-w-full">
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ data-testid="branch-search"
+ :debounce="$options.searchInputDelay"
+ :is-loading="isLoading"
+ />
+ <gl-dropdown-item
+ v-for="branch in branchNames"
+ :key="branch"
+ :is-checked="isSelected(branch)"
+ is-check-item
+ @click="selectBranch(branch)"
+ >
+ {{ branch }}
</gl-dropdown-item>
- </template>
- </gl-dropdown>
+ <gl-dropdown-item v-if="!branchNames.length && !isLoading" data-testid="no-data">{{
+ $options.i18n.noMatch
+ }}</gl-dropdown-item>
+ <template v-if="shouldRenderCreateButton">
+ <gl-dropdown-divider />
+ <gl-dropdown-item data-testid="create-wildcard-button" @click="createWildcard">
+ {{ createButtonLabel }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ <gl-sprintf :message="$options.i18n.branchHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.wildcardsHelpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue
new file mode 100644
index 00000000000..bcc0f64d667
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import PushProtections from './push_protections.vue';
+import MergeProtections from './merge_protections.vue';
+
+export const i18n = {
+ protections: s__('BranchRules|Protections'),
+ protectionsHelpText: s__(
+ 'BranchRules|Keep stable branches secure and force developers to use merge requests. %{linkStart}What are protected branches?%{linkEnd}',
+ ),
+};
+
+export default {
+ name: 'BranchProtections',
+ i18n,
+ components: {
+ GlSprintf,
+ GlLink,
+ PushProtections,
+ MergeProtections,
+ },
+ protectedBranchesHelpPath: helpPagePath('user/project/protected_branches'),
+ props: {
+ protections: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h4 class="gl-border-t gl-pt-4">{{ $options.i18n.protections }}</h4>
+
+ <div data-testid="protections-help-text">
+ <gl-sprintf :message="$options.i18n.protectionsHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.protectedBranchesHelpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+
+ <push-protections
+ class="gl-mt-5"
+ :members-allowed-to-push="protections.membersAllowedToPush"
+ :allow-force-push="protections.allowForcePush"
+ v-on="$listeners"
+ />
+
+ <merge-protections
+ :members-allowed-to-merge="protections.membersAllowedToMerge"
+ :require-code-owners-approval="protections.requireCodeOwnersApproval"
+ v-on="$listeners"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue
new file mode 100644
index 00000000000..85f168af4a8
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export const i18n = {
+ allowedToMerge: s__('BranchRules|Allowed to merge'),
+ requireApprovalTitle: s__('BranchRules|Require approval from code owners.'),
+ requireApprovalHelpText: s__(
+ 'BranchRules|Reject code pushes that change files listed in the CODEOWNERS file.',
+ ),
+};
+
+export default {
+ name: 'BranchMergeProtections',
+ i18n,
+ components: {
+ GlFormGroup,
+ GlFormCheckbox,
+ },
+ props: {
+ membersAllowedToMerge: {
+ type: Array,
+ required: true,
+ },
+ requireCodeOwnersApproval: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="$options.i18n.allowedToMerge">
+ <!-- TODO: add multi-select-dropdown (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) -->
+
+ <gl-form-checkbox
+ class="gl-mt-5"
+ :checked="requireCodeOwnersApproval"
+ @change="$emit('change-require-code-owners-approval', $event)"
+ >
+ <span>{{ $options.i18n.requireApprovalTitle }}</span>
+ <template #help>{{ $options.i18n.requireApprovalHelpText }}</template>
+ </gl-form-checkbox>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue
new file mode 100644
index 00000000000..541923bb735
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlFormGroup, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const i18n = {
+ allowedToPush: s__('BranchRules|Allowed to push'),
+ forcePushTitle: s__(
+ 'BranchRules|Allow all users with push access to %{linkStart}force push%{linkEnd}.',
+ ),
+};
+
+export default {
+ name: 'BranchPushProtections',
+ i18n,
+ components: {
+ GlFormGroup,
+ GlSprintf,
+ GlLink,
+ GlFormCheckbox,
+ },
+ forcePushHelpPath: helpPagePath('topics/git/git_rebase', { anchor: 'force-push' }),
+ props: {
+ membersAllowedToPush: {
+ type: Array,
+ required: true,
+ },
+ allowForcePush: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="$options.i18n.allowedToPush">
+ <!-- TODO: add multi-select-dropdown (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) -->
+
+ <gl-form-checkbox
+ class="gl-mt-5"
+ :checked="allowForcePush"
+ @change="$emit('change-allow-force-push', $event)"
+ >
+ <gl-sprintf :message="$options.i18n.forcePushTitle">
+ <template #link="{ content }">
+ <gl-link :href="$options.forcePushHelpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-form-checkbox>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue
index c2e7f4e9b1b..ad3eb7d2899 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue
@@ -1,15 +1,18 @@
<script>
import { GlFormGroup } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import { getParameterByName } from '~/lib/utils/url_utility';
import BranchDropdown from './branch_dropdown.vue';
+import Protections from './protections/index.vue';
export default {
name: 'RuleEdit',
- i18n: {
- branch: __('Branch'),
+ i18n: { branch: s__('BranchRules|Branch') },
+ components: {
+ BranchDropdown,
+ GlFormGroup,
+ Protections,
},
- components: { BranchDropdown, GlFormGroup },
props: {
projectPath: {
type: String,
@@ -19,20 +22,35 @@ export default {
data() {
return {
branch: getParameterByName('branch'),
+ protections: {
+ membersAllowedToPush: [],
+ allowForcePush: false,
+ membersAllowedToMerge: [],
+ requireCodeOwnersApproval: false,
+ },
};
},
};
</script>
<template>
- <gl-form-group :label="$options.i18n.branch">
- <branch-dropdown
- id="branches"
- v-model="branch"
- class="gl-w-half"
- :project-path="projectPath"
- @createWildcard="branch = $event"
+ <div>
+ <gl-form-group :label="$options.i18n.branch">
+ <branch-dropdown
+ id="branches"
+ v-model="branch"
+ class="gl-w-half"
+ :project-path="projectPath"
+ @createWildcard="branch = $event"
+ />
+ </gl-form-group>
+
+ <protections
+ :protections="protections"
+ @change-allowed-to-push-members="protections.membersAllowedToPush = $event"
+ @change-allow-force-push="protections.allowForcePush = $event"
+ @change-allowed-to-merge-members="protections.membersAllowedToMerge = $event"
+ @change-require-code-owners-approval="protections.requireCodeOwnersApproval = $event"
/>
- </gl-form-group>
- <!-- TODO - Add branch protections (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) -->
+ </div>
</template>
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
index fcf81c9d1f7..2209172c06d 100644
--- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -262,8 +262,8 @@ export default {
const selectedUsers = this.preselectedItems
.filter(({ type }) => type === LEVEL_TYPES.USER)
- .map(({ user_id, name, username, avatar_url, type }) => ({
- id: user_id,
+ .map(({ user_id: id, name, username, avatar_url, type }) => ({
+ id,
name,
username,
avatar_url,
diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
index fe968e74c6d..c13753da00b 100644
--- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
+++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
@@ -1,7 +1,13 @@
<script>
import { GlFormGroup } from '@gitlab/ui';
-import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
+import produce from 'immer';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
+import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchNamespacesWhereUserCanTransferProjects from '../graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql';
+
+const GROUPS_PER_PAGE = 25;
export default {
name: 'TransferProjectForm',
@@ -11,14 +17,6 @@ export default {
ConfirmDanger,
},
props: {
- groupNamespaces: {
- type: Array,
- required: true,
- },
- userNamespaces: {
- type: Array,
- required: true,
- },
confirmationPhrase: {
type: String,
required: true,
@@ -28,19 +26,88 @@ export default {
required: true,
},
},
+ apollo: {
+ currentUser: {
+ query: searchNamespacesWhereUserCanTransferProjects,
+ debounce: DEBOUNCE_DELAY,
+ variables() {
+ return {
+ search: this.searchTerm,
+ after: null,
+ first: GROUPS_PER_PAGE,
+ };
+ },
+ result() {
+ this.isLoadingMoreGroups = false;
+ this.isSearchLoading = false;
+ },
+ },
+ },
data() {
- return { selectedNamespace: null };
+ return {
+ currentUser: {},
+ selectedNamespace: null,
+ isLoadingMoreGroups: false,
+ isSearchLoading: false,
+ searchTerm: '',
+ };
},
computed: {
hasSelectedNamespace() {
return Boolean(this.selectedNamespace?.id);
},
+ groupNamespaces() {
+ return this.currentUser.groups?.nodes?.map(this.formatNamespace) || [];
+ },
+ userNamespaces() {
+ const { namespace } = this.currentUser;
+
+ return namespace ? [this.formatNamespace(namespace)] : [];
+ },
+ hasNextPageOfGroups() {
+ return this.currentUser.groups?.pageInfo?.hasNextPage || false;
+ },
},
methods: {
handleSelect(selectedNamespace) {
this.selectedNamespace = selectedNamespace;
this.$emit('selectNamespace', selectedNamespace.id);
},
+ handleLoadMoreGroups() {
+ this.isLoadingMoreGroups = true;
+
+ this.$apollo.queries.currentUser.fetchMore({
+ variables: {
+ after: this.currentUser.groups.pageInfo.endCursor,
+ first: GROUPS_PER_PAGE,
+ },
+ updateQuery(
+ previousResult,
+ {
+ fetchMoreResult: {
+ currentUser: { groups: newGroups },
+ },
+ },
+ ) {
+ const previousGroups = previousResult.currentUser.groups;
+
+ return produce(previousResult, (draftData) => {
+ draftData.currentUser.groups.nodes = [...previousGroups.nodes, ...newGroups.nodes];
+ draftData.currentUser.groups.pageInfo = newGroups.pageInfo;
+ });
+ },
+ });
+ },
+ handleSearch(searchTerm) {
+ this.isSearchLoading = true;
+ this.searchTerm = searchTerm;
+ },
+ formatNamespace({ id, fullName }) {
+ return {
+ id: getIdFromGraphQLId(id),
+ humanName: fullName,
+ };
+ },
},
};
</script>
@@ -53,11 +120,16 @@ export default {
:group-namespaces="groupNamespaces"
:user-namespaces="userNamespaces"
:selected-namespace="selectedNamespace"
+ :has-next-page-of-groups="hasNextPageOfGroups"
+ :is-loading-more-groups="isLoadingMoreGroups"
+ :is-search-loading="isSearchLoading"
+ :should-filter-namespaces="false"
@select="handleSelect"
+ @load-more-groups="handleLoadMoreGroups"
+ @search="handleSearch"
/>
</gl-form-group>
<confirm-danger
- button-class="qa-transfer-button"
:disabled="!hasSelectedNamespace"
:phrase="confirmationPhrase"
:button-text="confirmButtonText"
diff --git a/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql
new file mode 100644
index 00000000000..d4bcb8c869c
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql
@@ -0,0 +1,24 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query searchNamespacesWhereUserCanTransferProjects(
+ $search: String = ""
+ $after: String = ""
+ $first: Int = null
+) {
+ currentUser {
+ id
+ groups(permissionScope: TRANSFER_PROJECTS, search: $search, after: $after, first: $first) {
+ nodes {
+ id
+ fullName
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ namespace {
+ id
+ fullName
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
index a5f720bffaa..bc1aff640d2 100644
--- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js
+++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
@@ -1,36 +1,29 @@
import Vue from 'vue';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import TransferProjectForm from './components/transfer_project_form.vue';
-const prepareNamespaces = (rawNamespaces = '') => {
- if (!rawNamespaces) {
- return { groupNamespaces: [], userNamespaces: [] };
- }
-
- const data = JSON.parse(rawNamespaces);
- return {
- groupNamespaces: data?.group?.map(convertObjectPropsToCamelCase) || [],
- userNamespaces: data?.user?.map(convertObjectPropsToCamelCase) || [],
- };
-};
-
export default () => {
const el = document.querySelector('.js-transfer-project-form');
if (!el) {
return false;
}
+ Vue.use(VueApollo);
+
const {
targetFormId = null,
targetHiddenInputId = null,
buttonText: confirmButtonText = '',
phrase: confirmationPhrase = '',
confirmDangerMessage = '',
- namespaces = '',
} = el.dataset;
return new Vue({
el,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient(),
+ }),
provide: {
confirmDangerMessage,
},
@@ -39,7 +32,6 @@ export default () => {
props: {
confirmButtonText,
confirmationPhrase,
- ...prepareNamespaces(namespaces),
},
on: {
selectNamespace: (id) => {
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index d02526160fd..1343ad8246c 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -256,6 +256,7 @@ export default {
:error-message="i18n.branchesErrorMessage"
:show-header="showSectionHeaders"
data-testid="branches-section"
+ data-qa-selector="branches_section"
@selected="selectRef($event)"
/>
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index d765033d00b..102f1228355 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -208,7 +208,7 @@ export default {
<p v-if="hasError" class="gl-field-error">
{{ addRelatedErrorMessage }}
</p>
- <div class="gl-mt-5 gl-clearfix">
+ <div class="gl-mt-5">
<gl-button
ref="addButton"
category="primary"
@@ -216,12 +216,13 @@ export default {
:disabled="isSubmitButtonDisabled"
:loading="isSubmitting"
type="submit"
- class="gl-float-left"
+ size="small"
+ class="gl-mr-2"
data-qa-selector="add_issue_button"
>
{{ __('Add') }}
</gl-button>
- <gl-button class="gl-float-right" @click="onFormCancel">
+ <gl-button size="small" @click="onFormCancel">
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index eeb4c254a1b..5b4a6d1fe0d 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlIcon, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
import {
issuableIconMap,
issuableQaClassMap,
@@ -96,6 +97,11 @@ export default {
default: true,
},
},
+ data() {
+ return {
+ isOpen: true,
+ };
+ },
computed: {
hasRelatedIssues() {
return this.relatedIssues.length > 0;
@@ -139,6 +145,21 @@ export default {
qaClass() {
return issuableQaClassMap[this.issuableType];
},
+ toggleIcon() {
+ return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
+ },
+ toggleLabel() {
+ return this.isOpen ? __('Collapse') : __('Expand');
+ },
+ },
+ methods: {
+ handleToggle() {
+ this.isOpen = !this.isOpen;
+ },
+ addButtonClick(event) {
+ this.isOpen = true;
+ this.$emit('toggleAddRelatedIssuesForm', event);
+ },
},
linkedIssueTypesTextMap,
};
@@ -148,12 +169,10 @@ export default {
<div id="related-issues" class="related-issues-block gl-mt-5">
<div class="card card-slim gl-overflow-hidden">
<div
- :class="{ 'panel-empty-heading border-bottom-0': !hasBody }"
- class="card-header gl-display-flex gl-justify-content-space-between"
+ :class="{ 'panel-empty-heading border-bottom-0': !hasBody, 'gl-border-b-0': !isOpen }"
+ class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-py-3 gl-px-5 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
- <h3
- class="card-title h5 position-relative gl-my-0 gl-display-flex gl-align-items-center gl-h-7"
- >
+ <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1">
<gl-link
id="user-content-related-issues"
class="anchor position-absolute gl-text-decoration-none"
@@ -172,30 +191,45 @@ export default {
<gl-icon name="question" :size="12" />
</gl-link>
- <div class="gl-display-inline-flex">
- <div class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-5">
- <span class="gl-display-inline-flex gl-align-items-center">
- <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" />
- {{ badgeLabel }}
- </span>
- </div>
- <gl-button
- v-if="canAdmin"
- data-qa-selector="related_issues_plus_button"
- icon="plus"
- :aria-label="addIssuableButtonText"
- :class="qaClass"
- @click="$emit('toggleAddRelatedIssuesForm', $event)"
- />
+ <div class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-3">
+ <span class="gl-display-inline-flex gl-align-items-center">
+ <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" />
+ {{ badgeLabel }}
+ </span>
</div>
</h3>
<slot name="header-actions"></slot>
+ <gl-button
+ v-if="canAdmin"
+ size="small"
+ data-qa-selector="related_issues_plus_button"
+ data-testid="related-issues-plus-button"
+ :aria-label="addIssuableButtonText"
+ :class="qaClass"
+ class="gl-ml-3"
+ @click="addButtonClick"
+ >
+ <slot name="add-button-text">{{ __('Add') }}</slot>
+ </gl-button>
+ <div class="gl-pl-3 gl-ml-3 gl-border-l-1 gl-border-l-solid gl-border-l-gray-100">
+ <gl-button
+ category="tertiary"
+ size="small"
+ :icon="toggleIcon"
+ :aria-label="toggleLabel"
+ :disabled="!hasRelatedIssues"
+ data-testid="toggle-links"
+ @click="handleToggle"
+ />
+ </div>
</div>
<div
- class="linked-issues-card-body bg-gray-light"
+ v-if="isOpen"
+ class="linked-issues-card-body gl-bg-gray-10"
:class="{
'gl-p-5': isFormVisible || shouldShowTokenBody,
}"
+ data-testid="related-issues-body"
>
<div
v-if="isFormVisible"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 9ed895e90fb..11de734f5d4 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -5,7 +5,6 @@ import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue
import { defaultSortableOptions } from '~/sortable/constants';
export default {
- name: 'RelatedIssuesList',
components: {
GlLoadingIcon,
RelatedIssuableItem,
@@ -141,6 +140,7 @@ export default {
:path-id-separator="pathIdSeparator"
:is-locked="issue.lockIssueRemoval"
:locked-message="issue.lockedMessage"
+ :work-item-type="issue.type"
event-namespace="relatedIssue"
data-qa-selector="related_issuable_content"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue
index da049d68467..cad5037d7e4 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -24,6 +24,7 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways:
*/
import createFlash from '~/flash';
+import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import {
relatedIssuesRemoveErrorMap,
@@ -123,6 +124,14 @@ export default {
return this.state.relatedIssues.find((issue) => issue.id === id);
},
onRelatedIssueRemoveRequest(idToRemove) {
+ if (isGid(idToRemove)) {
+ const deletedId = getIdFromGraphQLId(idToRemove);
+ this.state.relatedIssues = this.state.relatedIssues.filter(
+ (issue) => issue.id !== deletedId,
+ );
+ return;
+ }
+
const issueToRemove = this.findRelatedIssueById(idToRemove);
if (issueToRemove) {
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index 3516836952f..23ea93cd258 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -114,8 +114,8 @@ export const PathIdSeparator = {
};
export const issuablesBlockHeaderTextMap = {
- [issuableTypesMap.ISSUE]: __('Linked issues'),
- [issuableTypesMap.INCIDENT]: __('Related incidents or issues'),
+ [issuableTypesMap.ISSUE]: __('Linked items'),
+ [issuableTypesMap.INCIDENT]: __('Linked incidents or issues'),
[issuableTypesMap.EPIC]: __('Linked epics'),
};
@@ -136,7 +136,7 @@ export const issuablesFormCategoryHeaderTextMap = {
};
export const issuablesFormInputTextMap = {
- [issuableTypesMap.ISSUE]: __('the following issue(s)'),
- [issuableTypesMap.INCIDENT]: __('the following incident(s) or issue(s)'),
- [issuableTypesMap.EPIC]: __('the following epic(s)'),
+ [issuableTypesMap.ISSUE]: __('the following issues'),
+ [issuableTypesMap.INCIDENT]: __('the following incidents or issues'),
+ [issuableTypesMap.EPIC]: __('the following epics'),
};
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index 655ec57bc3d..eb2f5d119b8 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -1,30 +1,33 @@
import Vue from 'vue';
+import apolloProvider from '~/issues/show/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import RelatedIssuesRoot from './components/related_issues_root.vue';
-export default function initRelatedIssues(issueType = 'issue') {
- const relatedIssuesRootElement = document.querySelector('.js-related-issues-root');
- if (relatedIssuesRootElement) {
- // eslint-disable-next-line no-new
- new Vue({
- el: relatedIssuesRootElement,
- name: 'RelatedIssuesRoot',
- components: {
- relatedIssuesRoot: RelatedIssuesRoot,
- },
- render: (createElement) =>
- createElement('related-issues-root', {
- props: {
- endpoint: relatedIssuesRootElement.dataset.endpoint,
- canAdmin: parseBoolean(relatedIssuesRootElement.dataset.canAddRelatedIssues),
- helpPath: relatedIssuesRootElement.dataset.helpPath,
- showCategorizedIssues: parseBoolean(
- relatedIssuesRootElement.dataset.showCategorizedIssues,
- ),
- issuableType: issueType,
- autoCompleteEpics: false,
- },
- }),
- });
+export function initRelatedIssues(issueType = 'issue') {
+ const el = document.querySelector('.js-related-issues-root');
+
+ if (!el) {
+ return null;
}
+
+ return new Vue({
+ el,
+ name: 'RelatedIssuesRoot',
+ apolloProvider,
+ provide: {
+ fullPath: el.dataset.fullPath,
+ hasIssueWeightsFeature: parseBoolean(el.dataset.hasIssueWeightsFeature),
+ },
+ render: (createElement) =>
+ createElement(RelatedIssuesRoot, {
+ props: {
+ endpoint: el.dataset.endpoint,
+ canAdmin: parseBoolean(el.dataset.canAddRelatedIssues),
+ helpPath: el.dataset.helpPath,
+ showCategorizedIssues: parseBoolean(el.dataset.showCategorizedIssues),
+ issuableType: issueType,
+ autoCompleteEpics: false,
+ },
+ }),
+ });
}
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 022c3224bb4..dd3f4ed636f 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -128,8 +128,13 @@ export default {
async mounted() {
await this.initializeRelease();
- // Focus the first non-disabled input or button element
- this.$el.querySelector('input:enabled, button:enabled').focus();
+ if (this.release?.tagName) {
+ // Focus the release title input if a tag was preselected
+ this.$refs.releaseTitleInput.$el.focus();
+ } else {
+ // Focus the first non-disabled input or button element otherwise
+ this.$el.querySelector('input:enabled, button:enabled').focus();
+ }
},
methods: {
...mapActions('editNew', [
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index b81da399a7b..7c6d44456d9 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -209,7 +209,7 @@ export default {
:id="`asset-type-${index}`"
ref="typeSelect"
:value="link.linkType || $options.defaultTypeOptionValue"
- class="form-control pr-4"
+ class="pr-4"
name="asset-type"
:options="$options.typeOptions"
@change="updateAssetLinkType({ linkIdToUpdate: link.id, newType: $event })"
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index def38780545..070865cf84b 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -7,6 +7,10 @@ import { BACK_URL_PARAM } from '~/releases/constants';
export default {
i18n: {
editButton: __('Edit this release'),
+ historical: __('Historical release'),
+ historicalTooltip: __(
+ 'This release was created with a date in the past. Evidence collection at the moment of the release is unavailable.',
+ ),
},
name: 'ReleaseBlockHeader',
components: {
@@ -65,6 +69,14 @@ export default {
<gl-badge v-if="release.upcomingRelease" variant="warning" class="align-middle">{{
__('Upcoming Release')
}}</gl-badge>
+ <gl-badge
+ v-else-if="release.historicalRelease"
+ v-gl-tooltip
+ :title="$options.i18n.historicalTooltip"
+ class="gl-vertical-align-middle"
+ >
+ {{ $options.i18n.historical }}
+ </gl-badge>
</h2>
<gl-button
v-if="editLink"
diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
index e0de6d12b13..e22726f27a7 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
@@ -1,5 +1,4 @@
fragment Release on Release {
- __typename
id
name
tagName
@@ -8,21 +7,17 @@ fragment Release on Release {
releasedAt
createdAt
upcomingRelease
+ historicalRelease
assets {
- __typename
count
sources {
- __typename
nodes {
- __typename
format
url
}
}
links {
- __typename
nodes {
- __typename
id
name
url
@@ -33,9 +28,7 @@ fragment Release on Release {
}
}
evidences {
- __typename
nodes {
- __typename
id
filepath
collectedAt
@@ -43,7 +36,6 @@ fragment Release on Release {
}
}
links {
- __typename
editUrl
selfUrl
openedIssuesUrl
@@ -53,29 +45,24 @@ fragment Release on Release {
closedMergeRequestsUrl
}
commit {
- __typename
id
sha
webUrl
title
}
author {
- __typename
id
webUrl
avatarUrl
username
}
milestones {
- __typename
nodes {
- __typename
id
title
description
webPath
stats {
- __typename
totalIssuesCount
closedIssuesCount
}
diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
index 61a06f268bd..1e3d31c86bf 100644
--- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
@@ -1,3 +1,5 @@
+#import "../fragments/release.fragment.graphql"
+
query allReleases(
$fullPath: ID!
$first: Int
@@ -7,96 +9,12 @@ query allReleases(
$sort: ReleaseSort
) {
project(fullPath: $fullPath) {
- __typename
id
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
- __typename
nodes {
- __typename
- id
- name
- tagName
- tagPath
- descriptionHtml
- releasedAt
- createdAt
- upcomingRelease
- assets {
- __typename
- count
- sources {
- __typename
- nodes {
- __typename
- format
- url
- }
- }
- links {
- __typename
- nodes {
- __typename
- id
- name
- url
- directAssetUrl
- linkType
- external
- }
- }
- }
- evidences {
- __typename
- nodes {
- __typename
- id
- filepath
- collectedAt
- sha
- }
- }
- links {
- __typename
- editUrl
- selfUrl
- openedIssuesUrl
- closedIssuesUrl
- openedMergeRequestsUrl
- mergedMergeRequestsUrl
- closedMergeRequestsUrl
- }
- commit {
- __typename
- id
- sha
- webUrl
- title
- }
- author {
- __typename
- id
- webUrl
- avatarUrl
- username
- }
- milestones {
- __typename
- nodes {
- __typename
- id
- title
- description
- webPath
- stats {
- __typename
- totalIssuesCount
- closedIssuesCount
- }
- }
- }
+ ...Release
}
pageInfo {
- __typename
startCursor
hasPreviousPage
hasNextPage
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
index f1f5f4bca4c..a1027ef08d7 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -12,6 +12,7 @@ const convertScalarProperties = (graphQLRelease) =>
'description',
'descriptionHtml',
'upcomingRelease',
+ 'historicalRelease',
]);
const convertDateProperties = ({ releasedAt }) => ({
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 0714d88b392..6061be465ca 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -206,7 +206,6 @@ export default {
<gl-button
v-if="isCollapsible"
- class="js-collapse-btn"
data-testid="report-section-expand-button"
data-qa-selector="expand_report_button"
@click="toggleCollapsed"
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index bf4f19504f0..7999b916e0f 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -8,7 +8,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo, getLocationHash } from '~/lib/utils/url_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import CodeIntelligence from '~/code_navigation/components/app.vue';
@@ -63,6 +63,28 @@ export default {
};
},
result() {
+ const urlHash = getLocationHash();
+ const plain = this.$route?.query?.plain;
+
+ // When the 'plain' URL param is present, its value determines which viewer to render:
+ // - when 0 and the rich viewer is available we render with it
+ // - otherwise we render the simple viewer
+ if (plain !== undefined) {
+ if (plain === '0' && this.hasRichViewer) {
+ this.switchViewer(RICH_BLOB_VIEWER);
+ } else {
+ this.switchViewer(SIMPLE_BLOB_VIEWER);
+ }
+ return;
+ }
+
+ // If there is a code line hash in the URL we render with the simple viewer
+ if (urlHash && urlHash.startsWith('L')) {
+ this.switchViewer(SIMPLE_BLOB_VIEWER);
+ return;
+ }
+
+ // By default, if present, use the rich viewer to render
this.switchViewer(this.hasRichViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER);
},
error() {
@@ -173,6 +195,21 @@ export default {
return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE;
},
},
+ watch: {
+ // Watch the URL 'plain' query value to know if the viewer needs changing.
+ // This is the case when the user switches the viewer and then goes back
+ // through the hystory.
+ '$route.query.plain': {
+ handler(plainValue) {
+ this.switchViewer(
+ this.hasRichViewer && (plainValue === undefined || plainValue === '0')
+ ? RICH_BLOB_VIEWER
+ : SIMPLE_BLOB_VIEWER,
+ plainValue !== undefined,
+ );
+ },
+ },
+ },
methods: {
onError() {
this.useFallback = true;
@@ -189,15 +226,10 @@ export default {
axios
.get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
.then(async ({ data: { html, binary } }) => {
- if (type === SIMPLE_BLOB_VIEWER) {
- this.isRenderingLegacyTextViewer = true;
+ this.isRenderingLegacyTextViewer = true;
+ if (type === SIMPLE_BLOB_VIEWER) {
this.legacySimpleViewer = html;
-
- window.requestIdleCallback(() => {
- this.isRenderingLegacyTextViewer = false;
- new LineHighlighter(); // eslint-disable-line no-new
- });
} else {
this.legacyRichViewer = html;
}
@@ -205,6 +237,14 @@ export default {
this.isBinary = binary;
this.isLoadingLegacyViewer = false;
+ window.requestIdleCallback(() => {
+ this.isRenderingLegacyTextViewer = false;
+
+ if (type === SIMPLE_BLOB_VIEWER) {
+ new LineHighlighter(); // eslint-disable-line no-new
+ }
+ });
+
await this.$nextTick();
handleLocationHash(); // Ensures that we scroll to the hash when async content is loaded
})
@@ -220,6 +260,22 @@ export default {
this.loadLegacyViewer();
}
},
+ updateRouteQuery() {
+ const plain = this.activeViewerType === SIMPLE_BLOB_VIEWER ? '1' : '0';
+
+ if (this.$route?.query?.plain === plain) {
+ return;
+ }
+
+ this.$router.push({
+ path: this.$route.path,
+ query: { ...this.$route.query, plain },
+ });
+ },
+ handleViewerChanged(newViewer) {
+ this.switchViewer(newViewer);
+ this.updateRouteQuery();
+ },
editBlob(target) {
if (this.showForkSuggestion) {
this.setForkTarget(target);
@@ -251,7 +307,7 @@ export default {
:has-render-error="hasRenderError"
:show-path="false"
:override-copy="glFeatures.highlightJs"
- @viewer-changed="switchViewer"
+ @viewer-changed="handleViewerChanged"
@copy="onCopy"
>
<template #actions>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 9f2cf8505d3..7f408485326 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -196,12 +196,9 @@ export default {
</gl-link>
</div>
<gl-button-group class="gl-ml-4 js-commit-sha-group">
- <gl-button
- label
- class="gl-font-monospace"
- data-testid="last-commit-id-label"
- v-text="showCommitId"
- />
+ <gl-button label class="gl-font-monospace" data-testid="last-commit-id-label">{{
+ showCommitId
+ }}</gl-button>
<clipboard-button
:text="commit.sha"
:title="__('Copy commit SHA')"
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 0e80f306638..77d3a517d28 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -94,7 +94,6 @@ export const LFS_STORAGE = 'lfs';
*/
export const LEGACY_FILE_TYPES = [
'gemfile',
- 'gemspec',
'composer_json',
'podfile',
'podspec',
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index 8baee80e5d6..45a7793e559 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -27,6 +27,7 @@ query getBlobInfo(
fileType
language
path
+ blamePath
editBlobPath
gitpodBlobUrl
ideEditPath
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 9de67015094..3256e13f4da 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -3,9 +3,7 @@
import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
import { hide, fixTitle } from '~/tooltips';
-import createFlash from './flash';
-import axios from './lib/utils/axios_utils';
-import { sprintf, s__, __ } from './locale';
+import { __ } from './locale';
const updateSidebarClasses = (layoutPage, rightSidebar) => {
if (window.innerWidth >= 992) {
@@ -20,7 +18,6 @@ const updateSidebarClasses = (layoutPage, rightSidebar) => {
};
function Sidebar() {
- this.toggleTodo = this.toggleTodo.bind(this);
this.sidebar = $('aside');
this.removeListeners();
@@ -54,7 +51,6 @@ Sidebar.prototype.addEventListeners = function () {
this.sidebar.on('hiddenGlDropdown', this, this.onSidebarDropdownHidden);
$document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked);
- $(document).off('click', '.js-issuable-todo').on('click', '.js-issuable-todo', this.toggleTodo);
if (window.gon?.features?.movedMrSidebar) {
const layoutPage = document.querySelector('.layout-page');
@@ -105,32 +101,6 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
}
};
-Sidebar.prototype.toggleTodo = function (e) {
- const $this = $(e.currentTarget);
- const ajaxType = $this.data('deletePath') ? 'delete' : 'post';
- const url = String($this.data('deletePath') || $this.data('createPath'));
-
- hide($this);
-
- $('.js-issuable-todo').disable().addClass('is-loading');
-
- axios[ajaxType](url, {
- issuable_id: $this.data('issuableId'),
- issuable_type: $this.data('issuableType'),
- })
- .then(({ data }) => {
- this.todoUpdateDone(data);
- })
- .catch(() =>
- createFlash({
- message: sprintf(__('There was an error %{message} to-do item.'), {
- message:
- ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'),
- }),
- }),
- );
-};
-
Sidebar.prototype.sidebarCollapseClicked = function (e) {
if ($(e.currentTarget).hasClass('js-dont-change-state')) {
return;
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index f6b7a8b46d7..777a332333d 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -12,10 +12,12 @@ import {
isSearchFiltered,
} from 'ee_else_ce/runner/runner_search_utils';
import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql';
+import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerBulkDelete from '../components/runner_bulk_delete.vue';
+import RunnerBulkDeleteCheckbox from '../components/runner_bulk_delete_checkbox.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
import RunnerName from '../components/runner_name.vue';
@@ -37,6 +39,7 @@ export default {
RegistrationDropdown,
RunnerFilteredSearchBar,
RunnerBulkDelete,
+ RunnerBulkDeleteCheckbox,
RunnerList,
RunnerListEmptyState,
RunnerName,
@@ -138,11 +141,15 @@ export default {
onToggledPaused() {
// When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
- this.$refs['runner-type-tabs'].refetch();
+ this.refetchCounts();
},
onDeleted({ message }) {
+ this.refetchCounts();
this.$root.$toast?.show(message);
},
+ refetchCounts() {
+ this.$apollo.getClient().refetchQueries({ include: [allRunnersCountQuery] });
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@@ -152,6 +159,9 @@ export default {
isChecked,
});
},
+ onPaginationInput(value) {
+ this.search.pagination = value;
+ },
},
filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE,
@@ -163,7 +173,6 @@ export default {
class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
>
<runner-type-tabs
- ref="runner-type-tabs"
v-model="search"
:count-scope="$options.INSTANCE_TYPE"
:count-variables="countVariables"
@@ -196,13 +205,20 @@ export default {
:filtered-svg-path="emptyStateFilteredSvgPath"
/>
<template v-else>
- <runner-bulk-delete v-if="isBulkDeleteEnabled" />
+ <runner-bulk-delete
+ v-if="isBulkDeleteEnabled"
+ :runners="runners.items"
+ @deleted="onDeleted"
+ />
<runner-list
:runners="runners.items"
:loading="runnersLoading"
:checkable="isBulkDeleteEnabled"
@checked="onChecked"
>
+ <template v-if="isBulkDeleteEnabled" #head-checkbox>
+ <runner-bulk-delete-checkbox :runners="runners.items" />
+ </template>
<template #runner-name="{ runner }">
<gl-link :href="runner.adminUrl">
<runner-name :runner="runner" />
@@ -217,11 +233,13 @@ export default {
/>
</template>
</runner-list>
- <runner-pagination
- v-model="search.pagination"
- class="gl-mt-3"
- :page-info="runners.pageInfo"
- />
</template>
+
+ <runner-pagination
+ class="gl-mt-3"
+ :disabled="runnersLoading"
+ :page-info="runners.pageInfo"
+ @input="onPaginationInput"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
index 1eb383a1904..1cd098d6713 100644
--- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
@@ -59,7 +59,11 @@ export default {
<tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="description">
{{ description }}
</tooltip-on-truncate>
- <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress">
+ <tooltip-on-truncate
+ v-if="ipAddress"
+ class="gl-display-block gl-text-truncate"
+ :title="ipAddress"
+ >
<span class="gl-md-display-none gl-lg-display-inline">{{ __('IP Address') }}</span>
<strong>{{ ipAddress }}</strong>
</tooltip-on-truncate>
diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue
index 38bdfecb7df..2fa87bdd776 100644
--- a/app/assets/javascripts/runner/components/runner_assigned_item.vue
+++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue
@@ -1,10 +1,11 @@
<script>
-import { GlAvatar, GlLink } from '@gitlab/ui';
+import { GlAvatar, GlBadge, GlLink } from '@gitlab/ui';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
components: {
GlAvatar,
+ GlBadge,
GlLink,
},
props: {
@@ -25,6 +26,16 @@ export default {
required: false,
default: null,
},
+ description: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isOwner: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
AVATAR_SHAPE_OPTION_RECT,
};
@@ -41,7 +52,12 @@ export default {
:size="48"
/>
</gl-link>
-
- <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link>
+ <div>
+ <div class="gl-mb-1">
+ <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link>
+ <gl-badge v-if="isOwner" variant="info">{{ s__('Runner|Owner') }}</gl-badge>
+ </div>
+ <div v-if="description">{{ description }}</div>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/runner/components/runner_bulk_delete.vue
index 50791de0bda..703da01d9c8 100644
--- a/app/assets/javascripts/runner/components/runner_bulk_delete.vue
+++ b/app/assets/javascripts/runner/components/runner_bulk_delete.vue
@@ -1,21 +1,31 @@
<script>
-import { GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
-import { n__, sprintf } from '~/locale';
-import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
-import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { __, s__, n__, sprintf } from '~/locale';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
+import BulkRunnerDelete from '../graphql/list/bulk_runner_delete.mutation.graphql';
+import { RUNNER_TYPENAME } from '../constants';
export default {
components: {
GlButton,
+ GlModal,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
},
inject: ['localMutations'],
+ props: {
+ runners: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
+ },
data() {
return {
+ isDeleting: false,
checkedRunnerIds: [],
};
},
@@ -25,8 +35,13 @@ export default {
},
},
computed: {
+ currentCheckedRunnerIds() {
+ return this.runners
+ .map(({ id }) => id)
+ .filter((id) => this.checkedRunnerIds.indexOf(id) >= 0);
+ },
checkedCount() {
- return this.checkedRunnerIds.length || 0;
+ return this.currentCheckedRunnerIds.length || 0;
},
bannerMessage() {
return sprintf(
@@ -43,48 +58,103 @@ export default {
modalTitle() {
return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount);
},
- modalHtmlMessage() {
+ modalActionPrimary() {
+ return {
+ text: n__(
+ 'Runners|Permanently delete %d runner',
+ 'Runners|Permanently delete %d runners',
+ this.checkedCount,
+ ),
+ attributes: {
+ loading: this.isDeleting,
+ variant: 'danger',
+ },
+ };
+ },
+ modalActionCancel() {
+ return {
+ text: __('Cancel'),
+ attributes: {
+ loading: this.isDeleting,
+ },
+ };
+ },
+ modalMessage() {
return sprintf(
n__(
'Runners|%{strongStart}%{count}%{strongEnd} runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
'Runners|%{strongStart}%{count}%{strongEnd} runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
this.checkedCount,
),
- {
- strongStart: '<strong>',
- strongEnd: '</strong>',
- count: this.checkedCount,
- },
- false,
+ { count: this.checkedCount },
);
},
- primaryBtnText() {
+ },
+ methods: {
+ toastConfirmationMessage(deletedCount) {
return n__(
- 'Runners|Permanently delete %d runner',
- 'Runners|Permanently delete %d runners',
- this.checkedCount,
+ 'Runners|%d selected runner deleted',
+ 'Runners|%d selected runners deleted',
+ deletedCount,
);
},
- },
- methods: {
onClearChecked() {
this.localMutations.clearChecked();
},
- onClickDelete: ignoreWhilePending(async function onClickDelete() {
- const confirmed = await confirmAction(null, {
- title: this.modalTitle,
- modalHtmlMessage: this.modalHtmlMessage,
- primaryBtnVariant: 'danger',
- primaryBtnText: this.primaryBtnText,
- });
+ async onConfirmDelete(e) {
+ this.isDeleting = true;
+ e.preventDefault(); // don't close modal until deletion is complete
+
+ try {
+ await this.$apollo.mutate({
+ mutation: BulkRunnerDelete,
+ variables: {
+ input: {
+ ids: this.currentCheckedRunnerIds,
+ },
+ },
+ update: (cache, { data }) => {
+ const { errors, deletedIds } = data.bulkRunnerDelete;
+
+ if (errors?.length) {
+ this.onError(new Error(errors.join(' ')));
+ this.$refs.modal.hide();
+ return;
+ }
+
+ this.$emit('deleted', {
+ message: this.toastConfirmationMessage(deletedIds.length),
+ });
- if (confirmed) {
- // TODO Call $apollo.mutate with list of runner
- // ids in `this.checkedRunnerIds`.
- // See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/
+ // Clean up
+
+ // Remove deleted runners from the cache
+ deletedIds.forEach((id) => {
+ const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id });
+ cache.evict({ id: cacheId });
+ });
+ cache.gc();
+
+ this.$refs.modal.hide();
+ },
+ });
+ } catch (error) {
+ this.onError(error);
+ } finally {
+ this.isDeleting = false;
}
- }),
+ },
+ onError(error) {
+ createAlert({
+ message: s__(
+ 'Runners|Something went wrong while deleting. Please refresh the page to try again.',
+ ),
+ captureError: true,
+ error,
+ });
+ },
},
+ BULK_DELETE_MODAL_ID: 'bulk-delete-modal',
};
</script>
@@ -99,13 +169,28 @@ export default {
</gl-sprintf>
</div>
<div class="gl-ml-auto">
- <gl-button data-testid="clear-btn" variant="default" @click="onClearChecked">{{
+ <gl-button variant="default" @click="onClearChecked">{{
s__('Runners|Clear selection')
}}</gl-button>
- <gl-button data-testid="delete-btn" variant="danger" @click="onClickDelete">{{
+ <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{
s__('Runners|Delete selected')
}}</gl-button>
</div>
</div>
+ <gl-modal
+ ref="modal"
+ size="sm"
+ :modal-id="$options.BULK_DELETE_MODAL_ID"
+ :title="modalTitle"
+ :action-primary="modalActionPrimary"
+ :action-cancel="modalActionCancel"
+ @primary="onConfirmDelete"
+ >
+ <gl-sprintf :message="modalMessage">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue
new file mode 100644
index 00000000000..dde5a5a4a05
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
+
+export default {
+ components: {
+ GlFormCheckbox,
+ },
+ inject: ['localMutations'],
+ props: {
+ runners: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
+ },
+ data() {
+ return {
+ checkedRunnerIds: [],
+ };
+ },
+ apollo: {
+ checkedRunnerIds: {
+ query: checkedRunnerIdsQuery,
+ },
+ },
+ computed: {
+ disabled() {
+ return !this.runners.length;
+ },
+ checked() {
+ return Boolean(this.runners.length) && this.runners.every(this.isChecked);
+ },
+ indeterminate() {
+ return !this.checked && this.runners.some(this.isChecked);
+ },
+ },
+ methods: {
+ isChecked({ id }) {
+ return this.checkedRunnerIds.indexOf(id) >= 0;
+ },
+ onChange($event) {
+ this.localMutations.setRunnersChecked({
+ runners: this.runners,
+ isChecked: $event,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-checkbox
+ :indeterminate="indeterminate"
+ :checked="checked"
+ :disabled="disabled"
+ @change="onChange"
+ />
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue
index db67acef3db..584f77b7648 100644
--- a/app/assets/javascripts/runner/components/runner_detail.vue
+++ b/app/assets/javascripts/runner/components/runner_detail.vue
@@ -38,11 +38,10 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-pb-4">
- <dt class="gl-mr-2">{{ label }}</dt>
- <dd class="gl-mb-0">
- <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
- <template v-if="value || $slots.value">
+ <div class="gl-display-contents">
+ <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">{{ label }}</dt>
+ <dd class="gl-mb-5">
+ <template v-if="value || $scopedSlots.value">
<slot name="value">{{ value }}</slot>
</template>
<span v-else class="gl-text-gray-500">{{ emptyValue }}</span>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
index 60469d26dd5..d5222f39b81 100644
--- a/app/assets/javascripts/runner/components/runner_details.vue
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -51,6 +51,9 @@ export default {
}
return null;
},
+ tagList() {
+ return this.runner.tagList || [];
+ },
isGroupRunner() {
return this.runner?.runnerType === GROUP_TYPE;
},
@@ -66,14 +69,17 @@ export default {
<div>
<runner-upgrade-status-alert class="gl-my-4" :runner="runner" />
<div class="gl-pt-4">
- <dl class="gl-mb-0" data-testid="runner-details-list">
+ <dl
+ class="gl-mb-0 gl-display-grid runner-details-grid-template"
+ data-testid="runner-details-list"
+ >
<runner-detail :label="s__('Runners|Description')" :value="runner.description" />
<runner-detail
:label="s__('Runners|Last contact')"
:empty-value="s__('Runners|Never contacted')"
>
- <template #value>
- <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
+ <template v-if="runner.contactedAt" #value>
+ <time-ago :time="runner.contactedAt" />
</template>
</runner-detail>
<runner-detail :label="s__('Runners|Version')">
@@ -87,8 +93,8 @@ export default {
<runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" />
<runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" />
<runner-detail :label="s__('Runners|Configuration')">
- <template #value>
- <gl-intersperse v-if="configTextProtected || configTextUntagged">
+ <template v-if="configTextProtected || configTextUntagged" #value>
+ <gl-intersperse>
<span v-if="configTextProtected">{{ configTextProtected }}</span>
<span v-if="configTextUntagged">{{ configTextUntagged }}</span>
</gl-intersperse>
@@ -96,13 +102,8 @@ export default {
</runner-detail>
<runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" />
<runner-detail :label="s__('Runners|Tags')">
- <template #value>
- <runner-tags
- v-if="runner.tagList && runner.tagList.length"
- class="gl-vertical-align-middle"
- :tag-list="runner.tagList"
- size="sm"
- />
+ <template v-if="tagList.length" #value>
+ <runner-tags class="gl-vertical-align-middle" :tag-list="tagList" size="sm" />
</template>
</runner-detail>
diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
index bff5ec9b238..5a9ab21a457 100644
--- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
@@ -64,19 +64,19 @@ export default {
},
methods: {
onFilter(filters) {
- // Apply new filters, from page 1
+ // Apply new filters, resetting pagination
this.$emit('input', {
...this.value,
filters,
- pagination: { page: 1 },
+ pagination: {},
});
},
onSort(sort) {
- // Apply new sort, from page 1
+ // Apply new sort, resetting pagination
this.$emit('input', {
...this.value,
sort,
- pagination: { page: 1 },
+ pagination: {},
});
},
},
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue
index 57afdc4b9be..9003eba3636 100644
--- a/app/assets/javascripts/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/runner/components/runner_jobs.vue
@@ -27,9 +27,7 @@ export default {
items: [],
pageInfo: {},
},
- pagination: {
- page: 1,
- },
+ pagination: {},
};
},
apollo: {
@@ -62,6 +60,11 @@ export default {
return this.$apollo.queries.jobs.loading;
},
},
+ methods: {
+ onPaginationInput(value) {
+ this.pagination = value;
+ },
+ },
I18N_NO_JOBS_FOUND,
};
</script>
@@ -74,6 +77,6 @@ export default {
<runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" />
<p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p>
- <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" />
+ <runner-pagination :disabled="loading" :page-info="jobs.pageInfo" @input="onPaginationInput" />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index f1f99c728c5..2e406f71792 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
+import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
@@ -23,6 +23,7 @@ const defaultFields = [
export default {
components: {
+ GlFormCheckbox,
GlTableLite,
GlSkeletonLoader,
TooltipOnTruncate,
@@ -123,19 +124,11 @@ export default {
fixed
>
<template #head(checkbox)>
- <!--
- Checkbox to select all to be added here
- See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/
- -->
- <span></span>
+ <slot name="head-checkbox"></slot>
</template>
<template #cell(checkbox)="{ item }">
- <input
- type="checkbox"
- :checked="isChecked(item)"
- @change="onCheckboxChange(item, $event.target.checked)"
- />
+ <gl-form-checkbox :checked="isChecked(item)" @change="onCheckboxChange(item, $event)" />
</template>
<template #head(status)="{ label }">
diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue
index cfc21d1407b..a5bf3074dd1 100644
--- a/app/assets/javascripts/runner/components/runner_pagination.vue
+++ b/app/assets/javascripts/runner/components/runner_pagination.vue
@@ -1,18 +1,12 @@
<script>
-import { GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination } from '@gitlab/ui';
export default {
components: {
- GlPagination,
+ GlKeysetPagination,
},
+ inheritAttrs: false,
props: {
- value: {
- required: false,
- type: Object,
- default: () => ({
- page: 1,
- }),
- },
pageInfo: {
required: false,
type: Object,
@@ -20,46 +14,37 @@ export default {
},
},
computed: {
- prevPage() {
- return this.pageInfo?.hasPreviousPage ? this.value.page - 1 : null;
+ paginationProps() {
+ return { ...this.pageInfo, ...this.$attrs };
},
- nextPage() {
- return this.pageInfo?.hasNextPage ? this.value.page + 1 : null;
+ isShown() {
+ const { hasPreviousPage, hasNextPage } = this.pageInfo;
+ return hasPreviousPage || hasNextPage;
},
},
methods: {
- handlePageChange(page) {
- if (page === 1) {
- // Small optimization for first page
- // If we have loaded using "first",
- // page is already cached.
- this.$emit('input', {
- page,
- });
- } else if (page > this.value.page) {
- this.$emit('input', {
- page,
- after: this.pageInfo.endCursor,
- });
- } else {
- this.$emit('input', {
- page,
- before: this.pageInfo.startCursor,
- });
- }
+ prevPage() {
+ this.$emit('input', {
+ before: this.pageInfo.startCursor,
+ });
+ },
+ nextPage() {
+ this.$emit('input', {
+ after: this.pageInfo.endCursor,
+ });
},
},
};
</script>
<template>
- <gl-pagination
- v-bind="$attrs"
- :value="value.page"
- :prev-page="prevPage"
- :next-page="nextPage"
- align="center"
- class="gl-pagination"
- @input="handlePageChange"
- />
+ <div v-if="isShown" class="gl-text-center">
+ <gl-keyset-pagination
+ v-bind="paginationProps"
+ :prev-text="s__('Pagination|Prev')"
+ :next-text="s__('Pagination|Next')"
+ @prev="prevPage"
+ @next="nextPage"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue
index c0c0c14e91e..2c1d2fc2b10 100644
--- a/app/assets/javascripts/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -30,13 +30,12 @@ export default {
data() {
return {
projects: {
+ ownerProjectId: null,
items: [],
pageInfo: {},
count: 0,
},
- pagination: {
- page: 1,
- },
+ pagination: {},
};
},
apollo: {
@@ -48,6 +47,7 @@ export default {
update(data) {
const { runner } = data;
return {
+ ownerProjectId: runner?.ownerProject?.id,
count: runner?.projectCount || 0,
items: runner?.projects?.nodes || [],
pageInfo: runner?.projects?.pageInfo || {},
@@ -76,6 +76,14 @@ export default {
});
},
},
+ methods: {
+ isOwner(projectId) {
+ return projectId === this.projects.ownerProjectId;
+ },
+ onPaginationInput(value) {
+ this.pagination = value;
+ },
+ },
I18N_NONE,
};
</script>
@@ -98,10 +106,16 @@ export default {
:name="project.name"
:full-name="project.nameWithNamespace"
:avatar-url="project.avatarUrl"
+ :description="project.description"
+ :is-owner="isOwner(project.id)"
/>
</template>
<span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span>
- <runner-pagination v-model="pagination" :disabled="loading" :page-info="projects.pageInfo" />
+ <runner-pagination
+ :disabled="loading"
+ :page-info="projects.pageInfo"
+ @input="onPaginationInput"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/stat/runner_count.vue b/app/assets/javascripts/runner/components/stat/runner_count.vue
index af18b203f90..37c6f922f9a 100644
--- a/app/assets/javascripts/runner/components/stat/runner_count.vue
+++ b/app/assets/javascripts/runner/components/stat/runner_count.vue
@@ -1,8 +1,9 @@
<script>
import { fetchPolicies } from '~/lib/graphql';
+import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql';
+import groupRunnersCountQuery from 'ee_else_ce/runner/graphql/list/group_runners_count.query.graphql';
+
import { captureException } from '../../sentry_utils';
-import allRunnersCountQuery from '../../graphql/list/all_runners_count.query.graphql';
-import groupRunnersCountQuery from '../../graphql/list/group_runners_count.query.graphql';
import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants';
/**
@@ -38,7 +39,7 @@ export default {
variables: {
type: Object,
required: false,
- default: () => {},
+ default: () => ({}),
},
skip: {
type: Boolean,
diff --git a/app/assets/javascripts/runner/components/stat/runner_single_stat.vue b/app/assets/javascripts/runner/components/stat/runner_single_stat.vue
new file mode 100644
index 00000000000..ae732b052ac
--- /dev/null
+++ b/app/assets/javascripts/runner/components/stat/runner_single_stat.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { formatNumber } from '~/locale';
+import RunnerCount from './runner_count.vue';
+
+export default {
+ components: {
+ GlSingleStat,
+ RunnerCount,
+ },
+ props: {
+ scope: {
+ type: String,
+ required: true,
+ },
+ variables: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ skip: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ formattedValue(value) {
+ if (typeof value === 'number') {
+ return formatNumber(value);
+ }
+ return '-';
+ },
+ },
+};
+</script>
+<template>
+ <runner-count #default="{ count }" :scope="scope" :variables="variables" :skip="skip">
+ <gl-single-stat v-bind="$attrs" :value="formattedValue(count)" />
+ </runner-count>
+</template>
diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue
index 9e1ca9ba4ee..93e54ebe7f4 100644
--- a/app/assets/javascripts/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue
@@ -1,12 +1,13 @@
<script>
+import { s__ } from '~/locale';
+import RunnerSingleStat from '~/runner/components/stat/runner_single_stat.vue';
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
-import RunnerCount from './runner_count.vue';
-import RunnerStatusStat from './runner_status_stat.vue';
export default {
components: {
- RunnerCount,
- RunnerStatusStat,
+ RunnerSingleStat,
+ RunnerUpgradeStatusStats: () =>
+ import('ee_component/runner/components/stat/runner_upgrade_status_stats.vue'),
},
props: {
scope: {
@@ -16,32 +17,67 @@ export default {
variables: {
type: Object,
required: false,
- default: () => {},
+ default: () => ({}),
},
},
- methods: {
- countVariables(vars) {
- return { ...this.variables, ...vars };
+ computed: {
+ stats() {
+ return [
+ {
+ key: STATUS_ONLINE,
+ props: {
+ skip: this.statusCountSkip(STATUS_ONLINE),
+ variables: { ...this.variables, status: STATUS_ONLINE },
+ variant: 'success',
+ title: s__('Runners|Online runners'),
+ metaText: s__('Runners|online'),
+ },
+ },
+ {
+ key: STATUS_OFFLINE,
+ props: {
+ skip: this.statusCountSkip(STATUS_OFFLINE),
+ variables: { ...this.variables, status: STATUS_OFFLINE },
+ variant: 'muted',
+ title: s__('Runners|Offline runners'),
+ metaText: s__('Runners|offline'),
+ },
+ },
+ {
+ key: STATUS_STALE,
+ props: {
+ skip: this.statusCountSkip(STATUS_STALE),
+ variables: { ...this.variables, status: STATUS_STALE },
+ variant: 'warning',
+ title: s__('Runners|Stale runners'),
+ metaText: s__('Runners|stale'),
+ },
+ },
+ ];
},
+ },
+ methods: {
statusCountSkip(status) {
// Show an empty result when we already filter by another status
return this.variables.status && this.variables.status !== status;
},
},
- STATUS_LIST: [STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE],
};
</script>
<template>
- <div class="gl-display-flex gl-py-6">
- <runner-count
- v-for="status in $options.STATUS_LIST"
- #default="{ count }"
- :key="status"
+ <div class="gl-display-flex gl-flex-wrap gl-py-6">
+ <runner-single-stat
+ v-for="stat in stats"
+ :key="stat.key"
+ :scope="scope"
+ v-bind="stat.props"
+ class="gl-px-5"
+ />
+
+ <runner-upgrade-status-stats
+ class="gl-display-contents"
:scope="scope"
- :variables="countVariables({ status })"
- :skip="statusCountSkip(status)"
- >
- <runner-status-stat class="gl-px-5" :status="status" :value="count" />
- </runner-count>
+ :variables="variables"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/stat/runner_status_stat.vue b/app/assets/javascripts/runner/components/stat/runner_status_stat.vue
deleted file mode 100644
index b77bbe15541..00000000000
--- a/app/assets/javascripts/runner/components/stat/runner_status_stat.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { s__, formatNumber } from '~/locale';
-import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
-
-export default {
- components: {
- GlSingleStat,
- },
- props: {
- value: {
- type: Number,
- required: false,
- default: null,
- },
- status: {
- type: String,
- required: true,
- },
- },
- computed: {
- formattedValue() {
- if (typeof this.value === 'number') {
- return formatNumber(this.value);
- }
- return '-';
- },
- stat() {
- switch (this.status) {
- case STATUS_ONLINE:
- return {
- variant: 'success',
- title: s__('Runners|Online runners'),
- metaText: s__('Runners|online'),
- };
- case STATUS_OFFLINE:
- return {
- variant: 'muted',
- title: s__('Runners|Offline runners'),
- metaText: s__('Runners|offline'),
- };
- case STATUS_STALE:
- return {
- variant: 'warning',
- title: s__('Runners|Stale runners'),
- metaText: s__('Runners|stale'),
- };
- default:
- return {
- title: s__('Runners|Runners'),
- };
- }
- },
- },
-};
-</script>
-<template>
- <gl-single-stat
- v-if="stat"
- :value="formattedValue"
- :variant="stat.variant"
- :title="stat.title"
- :meta-text="stat.metaText"
- />
-</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 64541729701..ed1afcbf691 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -1,5 +1,7 @@
import { __, s__ } from '~/locale';
+export const RUNNER_TYPENAME = 'CiRunner'; // __typename
+
export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
@@ -102,7 +104,6 @@ export const PARAM_KEY_TAG = 'tag';
export const PARAM_KEY_SEARCH = 'search';
export const PARAM_KEY_SORT = 'sort';
-export const PARAM_KEY_PAGE = 'page';
export const PARAM_KEY_AFTER = 'after';
export const PARAM_KEY_BEFORE = 'before';
diff --git a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql
index f900a0450e5..29abddf84f5 100644
--- a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql
@@ -1,5 +1,4 @@
fragment RunnerFieldsShared on CiRunner {
- __typename
id
shortSha
runnerType
diff --git a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
index 6bb896dda16..1160596aff3 100644
--- a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
@@ -1,5 +1,4 @@
-#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
-#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+#import "~/runner/graphql/list/all_runners_connection.fragment.graphql"
query getAllRunners(
$before: String
@@ -25,14 +24,6 @@ query getAllRunners(
search: $search
sort: $sort
) {
- nodes {
- ...ListItem
- adminUrl
- editAdminUrl
- }
- pageInfo {
- __typename
- ...PageInfo
- }
+ ...AllRunnersConnection
}
}
diff --git a/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql b/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql
new file mode 100644
index 00000000000..4440b8e98da
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql
@@ -0,0 +1,13 @@
+#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+fragment AllRunnersConnection on CiRunnerConnection {
+ nodes {
+ ...ListItem
+ adminUrl
+ editAdminUrl
+ }
+ pageInfo {
+ ...PageInfo
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql
new file mode 100644
index 00000000000..b73c016b1de
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql
@@ -0,0 +1,6 @@
+mutation bulkRunnerDelete($input: BulkRunnerDeleteInput!) {
+ bulkRunnerDelete(input: $input) {
+ deletedIds
+ errors
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql b/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql
new file mode 100644
index 00000000000..baef16a4b41
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql
@@ -0,0 +1,16 @@
+#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+fragment GroupRunnerConnection on CiRunnerConnection {
+ edges {
+ webUrl
+ editUrl
+ node {
+ ...ListItem
+ projectCount # Used to determine why some project runners can't be deleted
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
index 8755636a7ad..4c519b9b867 100644
--- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
@@ -1,5 +1,4 @@
-#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
-#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+#import "~/runner/graphql/list/group_runner_connection.fragment.graphql"
query getGroupRunners(
$groupFullPath: ID!
@@ -27,18 +26,7 @@ query getGroupRunners(
search: $search
sort: $sort
) {
- edges {
- webUrl
- editUrl
- node {
- ...ListItem
- projectCount # Used to determine why some project runners can't be deleted
- }
- }
- pageInfo {
- __typename
- ...PageInfo
- }
+ ...GroupRunnerConnection
}
}
}
diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
index cf925359ffb..ce23bddb898 100644
--- a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
@@ -1,11 +1,9 @@
fragment ListItemShared on CiRunner {
- __typename
id
description
runnerType
shortSha
version
- revision
ipAddress
active
locked
diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js
index e87bc72c86a..154af261bba 100644
--- a/app/assets/javascripts/runner/graphql/list/local_state.js
+++ b/app/assets/javascripts/runner/graphql/list/local_state.js
@@ -1,4 +1,5 @@
import { makeVar } from '@apollo/client/core';
+import { RUNNER_TYPENAME } from '../../constants';
import typeDefs from './typedefs.graphql';
/**
@@ -33,10 +34,16 @@ export const createLocalState = () => {
typePolicies: {
Query: {
fields: {
- checkedRunnerIds() {
+ checkedRunnerIds(_, { canRead, toReference }) {
return Object.entries(checkedRunnerIdsVar())
+ .filter(([id]) => {
+ // Some runners may be deleted by the user separately.
+ // Skip dangling references, those not in the cache.
+ // See: https://www.apollographql.com/docs/react/caching/garbage-collection/#dangling-references
+ return canRead(toReference({ __typename: RUNNER_TYPENAME, id }));
+ })
.filter(([, isChecked]) => isChecked)
- .map(([key]) => key);
+ .map(([id]) => id);
},
},
},
@@ -50,6 +57,13 @@ export const createLocalState = () => {
[runner.id]: isChecked,
});
},
+ setRunnersChecked({ runners, isChecked }) {
+ const newVal = runners.reduce(
+ (acc, { id }) => ({ ...acc, [id]: isChecked }),
+ checkedRunnerIdsVar(),
+ );
+ checkedRunnerIdsVar(newVal);
+ },
clearChecked() {
checkedRunnerIdsVar({});
},
diff --git a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
index b79ad4d9280..499c0156770 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
@@ -1,5 +1,4 @@
fragment RunnerDetailsShared on CiRunner {
- __typename
id
shortSha
runnerType
diff --git a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
index cb27de7c200..acc4a641565 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
+++ b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
@@ -9,11 +9,15 @@ query getRunnerProjects(
) {
runner(id: $id) {
id
+ ownerProject {
+ id
+ }
projectCount
projects(first: $first, last: $last, before: $before, after: $after) {
nodes {
id
avatarUrl
+ description
name
nameWithNamespace
webUrl
diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
index e8446dbe345..a82411a2120 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -3,6 +3,14 @@ import { GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
+import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config';
+import {
+ fromUrlQueryToSearch,
+ fromSearchToUrl,
+ fromSearchToVariables,
+ isSearchFiltered,
+} from 'ee_else_ce/runner/runner_search_utils';
+import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
@@ -22,13 +30,6 @@ import {
PROJECT_TYPE,
I18N_FETCH_ERROR,
} from '../constants';
-import groupRunnersQuery from '../graphql/list/group_runners.query.graphql';
-import {
- fromUrlQueryToSearch,
- fromSearchToUrl,
- fromSearchToVariables,
- isSearchFiltered,
-} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
export default {
@@ -123,7 +124,7 @@ export default {
return !this.runnersLoading && !this.runners.items.length;
},
searchTokens() {
- return [pausedTokenConfig, statusTokenConfig];
+ return [pausedTokenConfig, statusTokenConfig, upgradeStatusTokenConfig];
},
filteredSearchNamespace() {
return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
@@ -166,6 +167,9 @@ export default {
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
+ onPaginationInput(value) {
+ this.search.pagination = value;
+ },
},
TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE],
GROUP_TYPE,
@@ -225,11 +229,13 @@ export default {
/>
</template>
</runner-list>
- <runner-pagination
- v-model="search.pagination"
- class="gl-mt-3"
- :page-info="runners.pageInfo"
- />
</template>
+
+ <runner-pagination
+ class="gl-mt-3"
+ :disabled="runnersLoading"
+ :page-info="runners.pageInfo"
+ @input="onPaginationInput"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js
index e01878f355a..dc582ccbac1 100644
--- a/app/assets/javascripts/runner/runner_search_utils.js
+++ b/app/assets/javascripts/runner/runner_search_utils.js
@@ -1,3 +1,4 @@
+import { isEmpty } from 'lodash';
import { queryToObject, setUrlParams } from '~/lib/utils/url_utility';
import {
filterToQueryObject,
@@ -13,7 +14,6 @@ import {
PARAM_KEY_TAG,
PARAM_KEY_SEARCH,
PARAM_KEY_SORT,
- PARAM_KEY_PAGE,
PARAM_KEY_AFTER,
PARAM_KEY_BEFORE,
DEFAULT_SORT,
@@ -41,7 +41,7 @@ import { getPaginationVariables } from './utils';
* sort: 'CREATED_DESC',
*
* // Pagination information
- * pagination: { page: 1 },
+ * pagination: { "after": "..." },
* };
* ```
*
@@ -66,25 +66,16 @@ export const searchValidator = ({ runnerType, filters, sort }) => {
};
const getPaginationFromParams = (params) => {
- const page = parseInt(params[PARAM_KEY_PAGE], 10);
- const after = params[PARAM_KEY_AFTER];
- const before = params[PARAM_KEY_BEFORE];
-
- if (page && (before || after)) {
- return {
- page,
- before,
- after,
- };
- }
return {
- page: 1,
+ after: params[PARAM_KEY_AFTER],
+ before: params[PARAM_KEY_BEFORE],
};
};
// Outdated URL parameters
const STATUS_ACTIVE = 'ACTIVE';
const STATUS_PAUSED = 'PAUSED';
+const PARAM_KEY_PAGE = 'page';
/**
* Replaces params into a URL
@@ -97,6 +88,21 @@ const updateUrlParams = (url, params = {}) => {
return setUrlParams(params, url, false, true, true);
};
+const outdatedStatusParams = (status) => {
+ if (status === STATUS_ACTIVE) {
+ return {
+ [PARAM_KEY_PAUSED]: ['false'],
+ [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
+ };
+ } else if (status === STATUS_PAUSED) {
+ return {
+ [PARAM_KEY_PAUSED]: ['true'],
+ [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
+ };
+ }
+ return {};
+};
+
/**
* Returns an updated URL for old (or deprecated) admin runner URLs.
*
@@ -108,25 +114,22 @@ const updateUrlParams = (url, params = {}) => {
export const updateOutdatedUrl = (url = window.location.href) => {
const urlObj = new URL(url);
const query = urlObj.search;
-
const params = queryToObject(query, { gatherArrays: true });
- const status = params[PARAM_KEY_STATUS]?.[0] || null;
-
- switch (status) {
- case STATUS_ACTIVE:
- return updateUrlParams(url, {
- [PARAM_KEY_PAUSED]: ['false'],
- [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
- });
- case STATUS_PAUSED:
- return updateUrlParams(url, {
- [PARAM_KEY_PAUSED]: ['true'],
- [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
- });
- default:
- return null;
+ // Remove `page` completely, not needed for keyset pagination
+ const pageParams = PARAM_KEY_PAGE in params ? { [PARAM_KEY_PAGE]: null } : {};
+
+ const status = params[PARAM_KEY_STATUS]?.[0];
+ const redirectParams = {
+ // Replace paused status (active, paused) with a paused flag
+ ...outdatedStatusParams(status),
+ ...pageParams,
+ };
+
+ if (!isEmpty(redirectParams)) {
+ return updateUrlParams(url, redirectParams);
}
+ return null;
};
/**
@@ -182,13 +185,11 @@ export const fromSearchToUrl = (
}
const isDefaultSort = sort !== DEFAULT_SORT;
- const isFirstPage = pagination?.page === 1;
const otherParams = {
// Sorting & Pagination
[PARAM_KEY_SORT]: isDefaultSort ? sort : null,
- [PARAM_KEY_PAGE]: isFirstPage ? null : pagination.page,
- [PARAM_KEY_BEFORE]: isFirstPage ? null : pagination.before,
- [PARAM_KEY_AFTER]: isFirstPage ? null : pagination.after,
+ [PARAM_KEY_BEFORE]: pagination?.before || null,
+ [PARAM_KEY_AFTER]: pagination?.after || null,
};
return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true);
@@ -247,6 +248,6 @@ export const fromSearchToVariables = ({
*/
export const isSearchFiltered = ({ runnerType = null, filters = [], pagination = {} } = {}) => {
return Boolean(
- runnerType !== null || filters?.length !== 0 || (pagination && pagination?.page !== 1),
+ runnerType !== null || filters?.length !== 0 || pagination?.before || pagination?.after,
);
};
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 6efaf08a178..5a9ef832e05 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -157,6 +157,7 @@ export const SCANNER_NAMES_MAP = {
COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
SECRET_DETECTION: SECRET_DETECTION_NAME,
DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
+ GENERIC: s__('ciReport|Manually Added'),
};
export const securityFeatures = [
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index 579316f481c..2cdec8fc481 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -91,7 +91,7 @@ export default {
modalId: 'set-user-status-modal',
noEmoji: true,
availability: isUserBusy(this.currentAvailability),
- clearStatusAfter: statusTimeRanges[0].label,
+ clearStatusAfter: statusTimeRanges[0],
clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), {
date: this.currentClearStatusAfter,
}),
@@ -178,9 +178,7 @@ export default {
message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
clearStatusAfter:
- clearStatusAfter === statusTimeRanges[0].label
- ? null
- : clearStatusAfter.replace(' ', '_'),
+ clearStatusAfter.label === statusTimeRanges[0].label ? null : clearStatusAfter.shortcut,
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
@@ -279,12 +277,12 @@ export default {
<div class="form-group">
<div class="gl-display-flex gl-align-items-baseline">
<span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
- <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
+ <gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown">
<gl-dropdown-item
v-for="after in $options.statusTimeRanges"
:key="after.name"
:data-testid="after.name"
- @click="setClearStatusAfter(after.label)"
+ @click="setClearStatusAfter(after)"
>{{ after.label }}</gl-dropdown-item
>
</gl-dropdown>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index 3602b5ec4f6..29ea390a81d 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -39,9 +39,6 @@ export default {
assignSelf() {
this.$emit('assign-self');
},
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
},
};
</script>
@@ -66,12 +63,7 @@ export default {
</template>
</span>
- <uncollapsed-assignee-list
- v-else
- :users="sortedAssigness"
- :issuable-type="issuableType"
- @toggle-attention-requested="toggleAttentionRequested"
- />
+ <uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index 59a4eb54bbe..a94dd128a1a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -32,11 +32,6 @@ export default {
return this.users.length === 0;
},
},
- methods: {
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
- },
};
</script>
@@ -66,7 +61,6 @@ export default {
:users="users"
:issuable-type="issuableType"
class="gl-text-gray-800 hide-collapsed"
- @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index e596d6292bf..18b26c7d8bd 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -125,9 +125,6 @@ export default {
availability: this.assigneeAvailabilityStatus[username] || '',
}));
},
- toggleAttentionRequested(data) {
- this.mediator.toggleAttentionRequested('assignee', data);
- },
},
};
</script>
@@ -155,7 +152,6 @@ export default {
:editable="store.editable"
:issuable-type="issuableType"
@assign-self="assignSelf"
- @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</template>
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 14f6c9d3a15..5c432ca0e03 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -149,6 +149,9 @@ export default {
signedIn() {
return this.currentUser.username !== undefined;
},
+ issuableAuthor() {
+ return this.issuable?.author;
+ },
},
watch: {
iid(_, oldIid) {
@@ -266,6 +269,7 @@ export default {
:current-user="currentUser"
:issuable-type="issuableType"
:is-editing="edit"
+ :issuable-author="issuableAuthor"
class="gl-w-full dropdown-menu-user gl-mt-n3"
@toggle="collapseWidget"
@error="showError"
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index e9c68008143..0ed40f56bea 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui';
+import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
import { s__, sprintf } from '~/locale';
@@ -11,7 +11,6 @@ const AVAILABILITY_STATUS = {
export default {
components: {
GlAvatarLabeled,
- GlAvatarLink,
GlIcon,
},
props: {
@@ -47,23 +46,21 @@ export default {
</script>
<template>
- <gl-avatar-link>
- <gl-avatar-labeled
- :size="32"
- :label="userLabel"
- :sub-label="`@${user.username}`"
- :src="user.avatarUrl || user.avatar || user.avatar_url"
- class="gl-align-items-center gl-relative"
- >
- <template #meta>
- <gl-icon
- v-if="hasCannotMergeIcon"
- name="warning-solid"
- aria-hidden="true"
- class="merge-icon"
- :size="12"
- />
- </template>
- </gl-avatar-labeled>
- </gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="userLabel"
+ :sub-label="`@${user.username}`"
+ :src="user.avatarUrl || user.avatar || user.avatar_url"
+ class="gl-align-items-center gl-relative sidebar-participant"
+ >
+ <template #meta>
+ <gl-icon
+ v-if="hasCannotMergeIcon"
+ name="warning-solid"
+ aria-hidden="true"
+ class="merge-icon gl-left-6 gl-bottom-0"
+ :size="12"
+ />
+ </template>
+ </gl-avatar-labeled>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index b6260418837..0e4d4c74160 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -2,7 +2,6 @@
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issues/constants';
import { __, sprintf } from '~/locale';
-import AttentionRequestedToggle from '../attention_requested_toggle.vue';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
@@ -10,7 +9,6 @@ const DEFAULT_RENDER_COUNT = 5;
export default {
components: {
- AttentionRequestedToggle,
AssigneeAvatarLink,
UserNameWithStatus,
},
@@ -46,10 +44,6 @@ export default {
return this.users.length - DEFAULT_RENDER_COUNT;
},
uncollapsedUsers() {
- if (this.showVerticalList) {
- return this.users;
- }
-
const uncollapsedLength = this.showLess
? Math.min(this.users.length, DEFAULT_RENDER_COUNT)
: this.users.length;
@@ -58,9 +52,6 @@ export default {
username() {
return `@${this.firstUser.username}`;
},
- showVerticalList() {
- return this.glFeatures.mrAttentionRequests && this.isMergeRequest;
- },
isMergeRequest() {
return this.issuableType === IssuableType.MergeRequest;
},
@@ -75,9 +66,6 @@ export default {
}
return u?.status?.availability || '';
},
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
},
};
</script>
@@ -96,7 +84,7 @@ export default {
<assignee-avatar-link
:user="user"
:issuable-type="issuableType"
- :tooltip-has-name="!showVerticalList"
+ :tooltip-has-name="!isMergeRequest"
class="gl-word-break-word"
data-css-area="user"
>
@@ -107,14 +95,6 @@ export default {
<user-name-with-status :name="user.name" :availability="userAvailability(user)" />
</div>
</assignee-avatar-link>
- <attention-requested-toggle
- v-if="showVerticalList"
- :user="user"
- type="assignee"
- class="gl-mr-2"
- data-css-area="attention"
- @toggle-attention-requested="toggleAttentionRequested"
- />
</div>
</div>
<div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800">
diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
deleted file mode 100644
index 974ad189f32..00000000000
--- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
+++ /dev/null
@@ -1,105 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-
-export default {
- i18n: {
- addAttentionRequest: __('Add attention request'),
- removeAttentionRequest: __('Remove attention request'),
- attentionRequestedNoPermission: __('Attention requested'),
- noAttentionRequestedNoPermission: __('No attention request'),
- },
- components: {
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- type: {
- type: String,
- required: true,
- },
- user: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- loading: false,
- };
- },
- computed: {
- tooltipTitle() {
- if (this.user.attention_requested) {
- if (this.user.can_update_merge_request) {
- return this.$options.i18n.removeAttentionRequest;
- }
-
- return this.$options.i18n.attentionRequestedNoPermission;
- }
-
- if (this.user.can_update_merge_request) {
- return this.$options.i18n.addAttentionRequest;
- }
-
- return this.$options.i18n.noAttentionRequestedNoPermission;
- },
- request() {
- const state = {
- selected: false,
- icon: 'attention',
- direction: 'add',
- };
-
- if (this.user.attention_requested) {
- Object.assign(state, {
- selected: true,
- icon: 'attention-solid',
- direction: 'remove',
- });
- }
-
- return state;
- },
- },
- methods: {
- toggleAttentionRequired() {
- if (this.loading || !this.user.can_update_merge_request) return;
-
- this.$root.$emit(BV_HIDE_TOOLTIP);
- this.loading = true;
- this.$emit('toggle-attention-requested', {
- user: this.user,
- callback: this.toggleAttentionRequiredComplete,
- direction: this.request.direction,
- });
- },
- toggleAttentionRequiredComplete() {
- this.loading = false;
- },
- },
-};
-</script>
-
-<template>
- <div>
- <span
- v-gl-tooltip.left.viewport="tooltipTitle"
- class="gl-display-inline-block js-attention-request-toggle"
- >
- <gl-button
- :loading="loading"
- :selected="request.selected"
- :icon="request.icon"
- :aria-label="tooltipTitle"
- :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }"
- size="small"
- category="tertiary"
- @click="toggleAttentionRequired"
- />
- </span>
- </div>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
index c44ce8b0057..336c291d4f1 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -88,7 +88,10 @@ export default {
.then(
({
data: {
- issuableSetConfidential: { errors },
+ issuableSetConfidential: {
+ issuable: { confidential },
+ errors,
+ },
},
}) => {
if (errors.length) {
@@ -96,7 +99,7 @@ export default {
message: errors[0],
});
} else {
- this.$emit('closeForm');
+ this.$emit('closeForm', { confidential });
}
},
)
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index f234c5ea3c9..eec083f23f3 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -95,10 +95,10 @@ export default {
confidentialWidget.setConfidentiality = null;
},
methods: {
- closeForm() {
+ closeForm({ confidential } = {}) {
this.$refs.editable.collapse();
this.$el.dispatchEvent(hideDropdownEvent);
- this.$emit('closeForm');
+ this.$emit('closeForm', { confidential });
},
// synchronizing the quick action with the sidebar widget
// this is a temporary solution until we have confidentiality real-time updates
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
index 9502b2e78b3..6f82178b6fd 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
@@ -33,7 +33,7 @@ export default {
return this.users.length > 2;
},
allReviewersCanMerge() {
- return this.users.every((user) => user.can_merge);
+ return this.users.every((user) => user.mergeRequestInteraction?.canMerge);
},
sidebarAvatarCounter() {
if (this.users.length > DEFAULT_MAX_COUNTER) {
@@ -48,7 +48,7 @@ export default {
return this.users.slice(0, collapsedLength);
},
tooltipTitleMergeStatus() {
- const mergeLength = this.users.filter((u) => u.can_merge).length;
+ const mergeLength = this.users.filter((u) => u.mergeRequestInteraction?.canMerge).length;
if (mergeLength === this.users.length) {
return '';
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
index 7961b7cd679..a7db3b3d09f 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
@@ -23,10 +23,10 @@ export default {
return sprintf(__("%{userName}'s avatar"), { userName: this.user.name });
},
avatarUrl() {
- return this.user.avatar || this.user.avatar_url || gon.default_avatar_url;
+ return this.user.avatarUrl || this.user.avatar_url || gon.default_avatar_url;
},
hasMergeIcon() {
- return !this.user.can_merge;
+ return !this.user.mergeRequestInteraction?.canMerge;
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index c9b0a4ae2b3..f69c027e201 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -40,7 +40,7 @@ export default {
},
computed: {
cannotMerge() {
- return this.issuableType === 'merge_request' && !this.user.can_merge;
+ return this.issuableType === 'merge_request' && !this.user.mergeRequestInteraction?.canMerge;
},
tooltipTitle() {
if (this.cannotMerge && this.tooltipHasName) {
@@ -59,7 +59,7 @@ export default {
};
},
reviewerUrl() {
- return this.user.web_url;
+ return this.user.webUrl;
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
index b07fd944ff9..5e1172ad835 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -36,8 +36,8 @@ export default {
return !this.users.length;
},
sortedReviewers() {
- const canMergeUsers = this.users.filter((user) => user.can_merge);
- const canNotMergeUsers = this.users.filter((user) => !user.can_merge);
+ const canMergeUsers = this.users.filter((user) => user.mergeRequestInteraction?.canMerge);
+ const canNotMergeUsers = this.users.filter((user) => !user.mergeRequestInteraction?.canMerge);
return [...canMergeUsers, ...canNotMergeUsers];
},
@@ -49,9 +49,6 @@ export default {
requestReview(data) {
this.$emit('request-review', data);
},
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
},
};
</script>
@@ -73,7 +70,6 @@ export default {
:root-path="rootPath"
:issuable-type="issuableType"
@request-review="requestReview"
- @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index 2ea63219e92..b0d820ddd15 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -1,15 +1,23 @@
<script>
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import Vue from 'vue';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import createFlash from '~/flash';
import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import getMergeRequestReviewersQuery from '~/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql';
import ReviewerTitle from './reviewer_title.vue';
import Reviewers from './reviewers.vue';
+export const state = Vue.observable({
+ issuable: {},
+ loading: false,
+ initialLoading: true,
+});
+
export default {
name: 'SidebarReviewers',
components: {
@@ -40,18 +48,49 @@ export default {
required: true,
},
},
+ apollo: {
+ issuable: {
+ query: getMergeRequestReviewersQuery,
+ variables() {
+ return {
+ iid: this.issuableIid,
+ fullPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable;
+ },
+ result() {
+ this.initialLoading = false;
+ },
+ error() {
+ createFlash({ message: __('An error occurred while fetching reviewers.') });
+ },
+ },
+ },
data() {
- return {
- store: new Store(),
- loading: false,
- };
+ return state;
},
computed: {
relativeUrlRoot() {
return gon.relative_url_root ?? '';
},
+ reviewers() {
+ return this.issuable.reviewers?.nodes || [];
+ },
+ graphqlFetching() {
+ return this.$apollo.queries.issuable.loading;
+ },
+ isLoading() {
+ return this.loading || this.$apollo.queries.issuable.loading;
+ },
+ canUpdate() {
+ return this.issuable.userPermissions?.updateMergeRequest || false;
+ },
},
created() {
+ this.store = new Store();
+
this.removeReviewer = this.store.removeReviewer.bind(this.store);
this.addReviewer = this.store.addReviewer.bind(this.store);
this.removeAllReviewers = this.store.removeAllReviewers.bind(this.store);
@@ -77,6 +116,7 @@ export default {
.then(() => {
this.loading = false;
refreshUserMergeRequestCounts();
+ this.$apollo.queries.issuable.refetch();
})
.catch(() => {
this.loading = false;
@@ -88,9 +128,6 @@ export default {
requestReview(data) {
this.mediator.requestReview(data);
},
- toggleAttentionRequested(data) {
- this.mediator.toggleAttentionRequested('reviewer', data);
- },
},
};
</script>
@@ -98,18 +135,17 @@ export default {
<template>
<div>
<reviewer-title
- :number-of-reviewers="store.reviewers.length"
- :loading="loading || store.isFetching.reviewers"
- :editable="store.editable"
+ :number-of-reviewers="reviewers.length"
+ :loading="isLoading"
+ :editable="canUpdate"
/>
<reviewers
- v-if="!store.isFetching.reviewers"
+ v-if="!initialLoading"
:root-path="relativeUrlRoot"
- :users="store.reviewers"
- :editable="store.editable"
+ :users="reviewers"
+ :editable="canUpdate"
:issuable-type="issuableType"
@request-review="requestReview"
- @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index 2f58e11c00f..217ca2e2548 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -1,8 +1,6 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, sprintf, s__ } from '~/locale';
-import AttentionRequestedToggle from '../attention_requested_toggle.vue';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
@@ -16,12 +14,10 @@ export default {
GlButton,
GlIcon,
ReviewerAvatarLink,
- AttentionRequestedToggle,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
users: {
type: Array,
@@ -80,9 +76,6 @@ export default {
this.loadingStates[userId] = null;
}
},
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
},
LOADING_STATE,
SUCCESS_STATE,
@@ -96,7 +89,6 @@ export default {
:key="user.id"
:class="{
'gl-mb-3': index !== users.length - 1,
- 'attention-requests': glFeatures.mrAttentionRequests,
}"
class="gl-display-grid gl-align-items-center reviewer-grid gl-mr-2"
data-testid="reviewer"
@@ -112,16 +104,8 @@ export default {
{{ user.name }}
</div>
</reviewer-avatar-link>
- <attention-requested-toggle
- v-if="glFeatures.mrAttentionRequests"
- :user="user"
- type="reviewer"
- class="gl-mr-2"
- data-css-area="attention"
- @toggle-attention-requested="toggleAttentionRequested"
- />
<gl-icon
- v-if="user.approved"
+ v-if="user.mergeRequestInteraction.approved"
v-gl-tooltip.left
:size="16"
:title="approvedByTooltipTitle(user)"
@@ -137,9 +121,7 @@ export default {
data-testid="re-request-success"
/>
<gl-button
- v-else-if="
- user.can_update_merge_request && user.reviewed && !glFeatures.mrAttentionRequests
- "
+ v-else-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed"
v-gl-tooltip.left
:title="$options.i18n.reRequestReview"
:aria-label="$options.i18n.reRequestReview"
diff --git a/app/assets/javascripts/sidebar/components/severity/severity.vue b/app/assets/javascripts/sidebar/components/severity/severity.vue
index 0db856543d0..776dab98f01 100644
--- a/app/assets/javascripts/sidebar/components/severity/severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/severity.vue
@@ -37,10 +37,10 @@ export default {
<gl-icon
:size="iconSize"
:name="`severity-${severity.icon}`"
- :class="[`icon-${severity.icon}`, { 'gl-mr-3': !iconOnly }]"
+ :class="[`icon-${severity.icon}`, { 'gl-mr-3 gl-flex-shrink-0': !iconOnly }]"
/>
- <tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate">{{
- severity.label
- }}</tooltip-on-truncate>
+ <tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate">
+ {{ severity.label }}
+ </tooltip-on-truncate>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
index 86e46016534..bf4ba715f85 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
@@ -149,23 +149,25 @@ export default {
</div>
<div class="hide-collapsed">
- <p
- class="gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-display-flex gl-justify-content-space-between"
+ <div
+ class="gl-display-flex gl-align-items-center gl-line-height-20 gl-text-gray-900 gl-font-weight-bold"
>
{{ $options.i18n.SEVERITY }}
<gl-button
v-if="canUpdate"
category="tertiary"
size="small"
+ class="gl-ml-auto hide-collapsed gl-mr-n2"
data-testid="editButton"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
{{ $options.i18n.EDIT }}
</gl-button>
- </p>
+ </div>
<gl-dropdown
+ class="gl-mt-3"
:class="dropdownClass"
block
:header-text="__('Assign severity')"
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 3f82fe5ce87..fec4d0e346d 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -27,8 +27,6 @@ import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
-import eventHub from '~/sidebar/event_hub';
-import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
@@ -41,6 +39,7 @@ import SidebarTimeTracking from './components/time_tracking/sidebar_time_trackin
import { IssuableAttributeType } from './constants';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import CrmContacts from './components/crm_contacts/crm_contacts.vue';
+import SidebarEventHub from './event_hub';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -361,6 +360,13 @@ function mountConfidentialComponent() {
? IssuableType.Issue
: IssuableType.MergeRequest,
},
+ on: {
+ closeForm({ confidential }) {
+ if (confidential !== undefined) {
+ SidebarEventHub.$emit('confidentialityUpdated', confidential);
+ }
+ },
+ },
}),
});
}
@@ -652,13 +658,6 @@ export function mountSidebar(mediator, store) {
mountSeverityComponent();
mountEscalationStatusComponent();
-
- if (window.gon?.features?.mrAttentionRequests) {
- eventHub.$on('removeCurrentUserAttentionRequested', () => {
- mediator.removeCurrentUserAttentionRequested();
- refreshUserMergeRequestCounts();
- });
- }
}
export { getSidebarOptions };
diff --git a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
index 4998b2af666..a9d7e9878c6 100644
--- a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
@@ -1,9 +1,7 @@
query epicConfidential($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
confidential
}
diff --git a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
index 00529042e92..45c15a86961 100644
--- a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
@@ -1,9 +1,7 @@
query epicDueDate($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
dueDate
dueDateIsFixed
diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
index dada7ffc034..d665ca1e084 100644
--- a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
@@ -3,10 +3,8 @@
query epicParticipants($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
participants {
nodes {
diff --git a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
index f35ca896ef8..76d570a0f16 100644
--- a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
@@ -1,9 +1,7 @@
query epicReference($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
reference(full: true)
}
diff --git a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
index 85fc7de8d02..c85ede07fde 100644
--- a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
@@ -1,9 +1,7 @@
query epicStartDate($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
startDate
startDateIsFixed
diff --git a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
index a8fe6b8ddc3..b1973075d48 100644
--- a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
@@ -1,10 +1,8 @@
query epicSubscribed($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
emailsDisabled
issuable: epic(iid: $iid) {
- __typename
id
subscribed
}
diff --git a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
index b0ba724e727..3c035bcc6db 100644
--- a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
@@ -1,9 +1,7 @@
query epicTodos($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
currentUserTodos(state: pending) {
nodes {
diff --git a/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql
index dceab61ed26..6b15fcda2e8 100644
--- a/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql
@@ -2,7 +2,6 @@
query groupMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
workspace: group(fullPath: $fullPath) {
- __typename
id
attributes: milestones(
searchTitle: $title
diff --git a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
index e578cf3bda5..fcdc84c5a06 100644
--- a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
@@ -1,9 +1,7 @@
query issueConfidential($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
confidential
}
diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
index 48cbff252b3..4369104704a 100644
--- a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
@@ -1,9 +1,7 @@
query issueDueDate($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
dueDate
}
diff --git a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
index c3128d6d961..2c69cc04429 100644
--- a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
@@ -1,9 +1,7 @@
query issueReference($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
id
- __typename
issuable: issue(iid: $iid) {
- __typename
id
reference(full: true)
}
diff --git a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
index e2722fc86a4..419036ee15d 100644
--- a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
@@ -1,9 +1,7 @@
query issueSubscribed($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
subscribed
emailsDisabled
diff --git a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
index 059361dd370..f4d0e9b5deb 100644
--- a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
@@ -1,9 +1,7 @@
query issueTimeTracking($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
humanTimeEstimate
humanTotalTimeSpent
diff --git a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
index 5cd5d81c439..3211ded66ae 100644
--- a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
@@ -1,9 +1,7 @@
query issueTodos($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
currentUserTodos(state: pending) {
nodes {
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
index b0a16677cf2..26bf901babf 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
@@ -2,10 +2,8 @@
query mergeRequestMilestone($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
attribute: milestone {
...MilestoneFragment
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
index 7c78f812b67..e42e50ba861 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
@@ -1,9 +1,7 @@
query mergeRequestReference($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
reference(full: true)
}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
index d5e27ca7b69..d29f4d512c5 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
@@ -1,9 +1,7 @@
query mergeRequestSubscribed($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
subscribed
}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
index d480ff3d5ba..5d05cb2f34c 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
@@ -1,9 +1,7 @@
query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
humanTimeEstimate
humanTotalTimeSpent
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
index 65b9ef45260..906bfcdf9cd 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
@@ -1,9 +1,7 @@
query mergeRequestTodos($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
currentUserTodos(state: pending) {
nodes {
diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
index 721a71bef63..507221946fa 100644
--- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
@@ -2,10 +2,8 @@ mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attribute
issuableSetAttribute: updateIssue(
input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId }
) {
- __typename
errors
issuable: issue {
- __typename
id
attribute: milestone {
title
diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
index c7f3adc9aca..bcb055d4f0f 100644
--- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
@@ -2,10 +2,8 @@
query projectIssueMilestone($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
attribute: milestone {
...MilestoneFragment
diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
index d9eab18628d..b75c2138525 100644
--- a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
@@ -2,7 +2,6 @@
query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
workspace: project(fullPath: $fullPath) {
- __typename
id
attributes: milestones(
searchTitle: $title
diff --git a/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql b/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql
deleted file mode 100644
index d9b9c04fd63..00000000000
--- a/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql
+++ /dev/null
@@ -1,7 +0,0 @@
-mutation mergeRequestRemoveAttentionRequest($projectPath: ID!, $iid: String!, $userId: UserID!) {
- mergeRequestRemoveAttentionRequest(
- input: { projectPath: $projectPath, iid: $iid, userId: $userId }
- ) {
- errors
- }
-}
diff --git a/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql b/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql
deleted file mode 100644
index 99a86e4fe5c..00000000000
--- a/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql
+++ /dev/null
@@ -1,5 +0,0 @@
-mutation mergeRequestRequestAttention($projectPath: ID!, $iid: String!, $userId: UserID!) {
- mergeRequestRequestAttention(input: { projectPath: $projectPath, iid: $iid, userId: $userId }) {
- errors
- }
-}
diff --git a/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql
index 4675db9153e..51d461989e4 100644
--- a/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql
@@ -1,6 +1,5 @@
mutation issuableTodoCreate($input: TodoCreateInput!) {
todoMutation: todoCreate(input: $input) {
- __typename
todo {
id
}
diff --git a/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql
index 8253e5e82bc..4a91147c246 100644
--- a/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql
@@ -1,6 +1,5 @@
mutation issuableTodoMarkDone($input: TodoMarkDoneInput!) {
todoMutation: todoMarkDone(input: $input) {
- __typename
todo {
id
}
diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql
index 938953ccfb2..2714d815bcd 100644
--- a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql
@@ -2,10 +2,8 @@ mutation mergeRequestSetMilestone($fullPath: ID!, $iid: String!, $attributeId: M
issuableSetAttribute: mergeRequestSetMilestone(
input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId }
) {
- __typename
errors
issuable: mergeRequest {
- __typename
id
attribute: milestone {
title
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index 05268a5c89c..beacdeb559c 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -5,8 +5,6 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql';
import sidebarDetailsMRQuery from '../queries/sidebar_details_mr.query.graphql';
-import requestAttentionMutation from '../queries/request_attention.mutation.graphql';
-import removeAttentionRequestMutation from '../queries/remove_attention_request.mutation.graphql';
const queries = {
merge_request: sidebarDetailsMRQuery,
@@ -93,25 +91,4 @@ export default class SidebarService {
},
});
}
-
- requestAttention(userId) {
- return gqClient.mutate({
- mutation: requestAttentionMutation,
- variables: {
- userId: convertToGraphQLId(TYPE_USER, `${userId}`),
- projectPath: this.fullPath,
- iid: this.iid.toString(),
- },
- });
- }
- removeAttentionRequest(userId) {
- return gqClient.mutate({
- mutation: removeAttentionRequestMutation,
- variables: {
- userId: convertToGraphQLId(TYPE_USER, `${userId}`),
- projectPath: this.fullPath,
- iid: this.iid.toString(),
- },
- });
- }
}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
index 74ab65e4e04..1be670f7590 100644
--- a/app/assets/javascripts/sidebar/sidebar_bundle.js
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -3,17 +3,7 @@ import Mediator from './sidebar_mediator';
export default (store) => {
const mediator = new Mediator(getSidebarOptions());
- mediator
- .fetch()
- .then(() => {
- if (window.gon?.features?.mrAttentionRequests) {
- return import('~/attention_requests');
- }
-
- return null;
- })
- .then((module) => module?.initSideNavPopover())
- .catch(() => {});
+ mediator.fetch();
mountSidebar(mediator, store);
};
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 4df00903ab6..f7c93b6903c 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,8 +1,7 @@
import Store from '~/sidebar/stores/sidebar_store';
import createFlash from '~/flash';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
-import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { visitUrl } from '../lib/utils/url_utility';
import Service from './services/sidebar_service';
@@ -42,7 +41,6 @@ export default class SidebarMediator {
const data = { assignee_ids: assignees };
try {
- const { currentUserHasAttention } = this.store;
const res = await this.service.update(field, data);
this.store.overwrite('assignees', res.data.assignees);
@@ -51,10 +49,6 @@ export default class SidebarMediator {
this.store.overwrite('reviewers', res.data.reviewers);
}
- if (currentUserHasAttention && this.store.isAddingAssignee) {
- toast(__('Assigned user(s). Your attention request was removed.'));
- }
-
return Promise.resolve(res);
} catch (e) {
return Promise.reject(e);
@@ -70,16 +64,11 @@ export default class SidebarMediator {
const data = { reviewer_ids: reviewers };
try {
- const { currentUserHasAttention } = this.store;
const res = await this.service.update(field, data);
this.store.overwrite('reviewers', res.data.reviewers);
this.store.overwrite('assignees', res.data.assignees);
- if (currentUserHasAttention && this.store.isAddingAssignee) {
- toast(__('Requested review. Your attention request was removed.'));
- }
-
return Promise.resolve(res);
} catch (e) {
return Promise.reject();
@@ -97,80 +86,6 @@ export default class SidebarMediator {
.catch(() => callback(userId, false));
}
- removeCurrentUserAttentionRequested() {
- const currentUserId = gon.current_user_id;
-
- const currentUserReviewer = this.store.findReviewer({ id: currentUserId });
- const currentUserAssignee = this.store.findAssignee({ id: currentUserId });
-
- if (currentUserReviewer?.attention_requested || currentUserAssignee?.attention_requested) {
- // Update current users attention_requested state
- this.store.updateReviewer(currentUserId, 'attention_requested');
- this.store.updateAssignee(currentUserId, 'attention_requested');
- }
- }
-
- async toggleAttentionRequested(type, { user, callback, direction }) {
- const mutations = {
- add: (id) => this.service.requestAttention(id),
- remove: (id) => this.service.removeAttentionRequest(id),
- };
-
- try {
- const isReviewer = type === 'reviewer';
- const reviewerOrAssignee = isReviewer
- ? this.store.findReviewer(user)
- : this.store.findAssignee(user);
-
- await mutations[direction]?.(user.id);
-
- if (reviewerOrAssignee.attention_requested) {
- toast(
- sprintf(__('Removed attention request from @%{username}'), {
- username: user.username,
- }),
- );
- } else {
- const currentUserId = gon.current_user_id;
- const { currentUserHasAttention } = this.store;
-
- if (currentUserId !== user.id) {
- this.removeCurrentUserAttentionRequested();
- }
-
- toast(
- currentUserHasAttention && currentUserId !== user.id
- ? sprintf(
- __(
- 'Requested attention from @%{username}. Your own attention request was removed.',
- ),
- { username: user.username },
- )
- : sprintf(__('Requested attention from @%{username}'), { username: user.username }),
- );
- }
-
- this.store.updateReviewer(user.id, 'attention_requested');
- this.store.updateAssignee(user.id, 'attention_requested');
-
- refreshUserMergeRequestCounts();
- callback();
- } catch (error) {
- callback();
- createFlash({
- message: sprintf(__('Updating the attention request for %{username} failed.'), {
- username: user.username,
- }),
- error,
- captureError: true,
- actionConfig: {
- title: __('Try again'),
- clickHandler: () => this.toggleAttentionRequired(type, { user, callback, direction }),
- },
- });
- }
- }
-
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 971e2a15c68..e2581a8f30e 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -19,9 +19,7 @@ export default class SidebarStore {
this.humanTimeSpent = '';
this.timeTrackingLimitToHours = timeTrackingLimitToHours;
this.assignees = [];
- this.addingAssignees = [];
this.reviewers = [];
- this.addingReviewers = [];
this.isFetching = {
assignees: true,
reviewers: true,
@@ -77,20 +75,12 @@ export default class SidebarStore {
if (!this.findAssignee(assignee)) {
this.changing = true;
this.assignees.push(assignee);
-
- if (assignee.id !== this.currentUser.id) {
- this.addingAssignees.push(assignee.id);
- }
}
}
addReviewer(reviewer) {
if (!this.findReviewer(reviewer)) {
this.reviewers.push(reviewer);
-
- if (reviewer.id !== this.currentUser.id) {
- this.addingReviewers.push(reviewer.id);
- }
}
}
@@ -126,14 +116,12 @@ export default class SidebarStore {
if (assignee) {
this.changing = true;
this.assignees = this.assignees.filter(({ id }) => id !== assignee.id);
- this.addingAssignees = this.addingAssignees.filter(({ id }) => id !== assignee.id);
}
}
removeReviewer(reviewer) {
if (reviewer) {
this.reviewers = this.reviewers.filter(({ id }) => id !== reviewer.id);
- this.addingReviewers = this.addingReviewers.filter(({ id }) => id !== reviewer.id);
}
}
@@ -161,26 +149,4 @@ export default class SidebarStore {
setMoveToProjectId(moveToProjectId) {
this.moveToProjectId = moveToProjectId;
}
-
- get currentUserHasAttention() {
- if (!window.gon?.features?.mrAttentionRequests || !this.isMergeRequest) return false;
-
- const currentUserId = this.currentUser.id;
- const currentUserReviewer = this.findReviewer({ id: currentUserId });
- const currentUserAssignee = this.findAssignee({ id: currentUserId });
-
- return currentUserReviewer?.attention_requested || currentUserAssignee?.attention_requested;
- }
-
- get isAddingAssignee() {
- return this.addingAssignees.length > 0;
- }
-
- get isAddingReviewer() {
- return this.addingReviewers.length > 0;
- }
-
- get isMergeRequest() {
- return this.issuableType === 'merge_request';
- }
}
diff --git a/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql
deleted file mode 100644
index d75b4011d1c..00000000000
--- a/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql
+++ /dev/null
@@ -1,35 +0,0 @@
-#import '~/graphql_shared/fragments/blobviewer.fragment.graphql'
-
-fragment SnippetBase on Snippet {
- id
- title
- description
- descriptionHtml
- createdAt
- updatedAt
- visibilityLevel
- webUrl
- httpUrlToRepo
- sshUrlToRepo
- blobs {
- nodes {
- binary
- name
- path
- rawPath
- size
- externalStorage
- renderedAsText
- simpleViewer {
- ...BlobViewer
- }
- richViewer {
- ...BlobViewer
- }
- }
- }
- userPermissions {
- adminSnippet
- updateSnippet
- }
-}
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.js b/app/assets/javascripts/surveys/merge_request_experience/app.js
index ea5d8aef3c5..50b1c2c3f39 100644
--- a/app/assets/javascripts/surveys/merge_request_experience/app.js
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.js
@@ -8,12 +8,17 @@ Vue.use(Translate);
Vue.use(VueApollo);
export const startMrSurveyApp = () => {
+ const mountEl = document.querySelector('#js-mr-experience-survey');
+ if (!mountEl) return;
+
let channel = null;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
+ const { accountAge } = mountEl.dataset;
+
const app = new Vue({
apolloProvider,
data() {
@@ -24,6 +29,9 @@ export const startMrSurveyApp = () => {
render(h) {
if (this.hidden) return null;
return h(MergeRequestExperienceSurveyApp, {
+ props: {
+ accountAge: Number(accountAge),
+ },
on: {
close: () => {
channel?.postMessage('close');
@@ -37,7 +45,7 @@ export const startMrSurveyApp = () => {
},
});
- app.$mount('#js-mr-experience-survey');
+ app.$mount(mountEl);
if (window.BroadcastChannel) {
channel = new BroadcastChannel('mr_survey');
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue
index 85eed6ae82a..4e4ef49b1c6 100644
--- a/app/assets/javascripts/surveys/merge_request_experience/app.vue
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue
@@ -32,6 +32,12 @@ export default {
tooltip: GlTooltipDirective,
},
mixins: [Tracking.mixin()],
+ props: {
+ accountAge: {
+ type: Number,
+ required: true,
+ },
+ },
i18n: {
survey: s__('MrSurvey|Merge request experience survey'),
close: __('Close'),
@@ -68,6 +74,9 @@ export default {
this.track('survey:mr_experience', {
label: this.step.label,
value: event,
+ extra: {
+ accountAge: this.accountAge,
+ },
});
this.stepIndex += 1;
if (!this.step) {
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index 79a30340856..6e72d95c8e6 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -62,13 +62,21 @@ export default class TaskList {
.prop('disabled', true);
}
+ updateInapplicableTaskListItems(e) {
+ this.getTaskListTarget(e)
+ .find('.task-list-item-checkbox[data-inapplicable]')
+ .prop('disabled', true);
+ }
+
disableTaskListItems(e) {
this.getTaskListTarget(e).taskList('disable');
+ this.updateInapplicableTaskListItems();
}
enableTaskListItems(e) {
this.getTaskListTarget(e).taskList('enable');
this.disableNonMarkdownTaskListItems(e);
+ this.updateInapplicableTaskListItems(e);
}
enable() {
diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js
index 2727485fb95..369cf9714e8 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,8 +1,10 @@
+import { editor } from 'monaco-editor';
import { Sortable } from 'sortablejs';
import simulateDrag from './simulate_drag';
import simulateInput from './simulate_input';
// Export to global space for rspec to use
+window.localMonaco = editor;
window.simulateDrag = simulateDrag;
window.simulateInput = simulateInput;
window.Sortable = Sortable;
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 3356cada58a..c2892fb8dac 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -131,7 +131,9 @@ const lazyLaunchPopover = debounce((mountPopover, event) => {
let hasAddedLazyPopovers = false;
export default function addPopovers(mountPopover = (instance) => instance.$mount()) {
- if (!hasAddedLazyPopovers) {
+ // The web request fails for anonymous users so we don't want to show the popover when the user is not signed in.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/351395#note_1039341458
+ if (window.gon?.current_user_id && !hasAddedLazyPopovers) {
document.addEventListener('mouseover', (event) => lazyLaunchPopover(mountPopover, event));
hasAddedLazyPopovers = true;
}
diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js
new file mode 100644
index 00000000000..65f0eceae55
--- /dev/null
+++ b/app/assets/javascripts/visibility_level/constants.js
@@ -0,0 +1,10 @@
+export const VISIBILITY_LEVEL_PRIVATE = 'private';
+export const VISIBILITY_LEVEL_INTERNAL = 'internal';
+export const VISIBILITY_LEVEL_PUBLIC = 'public';
+
+// Matches `lib/gitlab/visibility_level.rb`
+export const VISIBILITY_LEVELS_ENUM = {
+ [VISIBILITY_LEVEL_PRIVATE]: 0,
+ [VISIBILITY_LEVEL_INTERNAL]: 10,
+ [VISIBILITY_LEVEL_PUBLIC]: 20,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
index b76d5d90ead..38f40e8a3c8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
@@ -14,7 +14,8 @@ export default {
props: {
widget: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
tertiaryButtons: {
type: Array,
@@ -30,6 +31,8 @@ export default {
},
computed: {
dropdownLabel() {
+ if (!this.widget) return undefined;
+
return sprintf(__('%{widget} options'), { widget: this.widget });
},
},
@@ -85,6 +88,7 @@ export default {
:href="btn.href"
:target="btn.target"
:data-clipboard-text="btn.dataClipboardText"
+ :data-method="btn.dataMethod"
@click="onClickAction(btn)"
>
{{ btn.text }}
@@ -99,11 +103,15 @@ export default {
:title="setTooltip(btn)"
:href="btn.href"
:target="btn.target"
- :class="{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }"
+ :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
:data-clipboard-text="btn.dataClipboardText"
+ :data-qa-selector="btn.dataQaSelector"
+ :data-method="btn.dataMethod"
:icon="btn.icon"
:data-testid="btn.testId || 'extension-actions-button'"
:variant="btn.variant || 'confirm'"
+ :loading="btn.loading"
+ :disabled="btn.loading"
category="tertiary"
size="small"
class="gl-display-none gl-md-display-block gl-float-left"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
index 437d035fbf5..254b280bf14 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
@@ -2,9 +2,9 @@
import { GlSprintf } from '@gitlab/ui';
import { escape } from 'lodash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { n__, s__ } from '~/locale';
+import { n__, s__, sprintf } from '~/locale';
-const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit');
+const mergeCommitCount = s__('mrWidgetCommitsAdded|%{strongStart}1%{strongEnd} merge commit');
export default {
components: {
@@ -49,40 +49,45 @@ export default {
return escape(this.targetBranch);
},
commitsCountMessage() {
- return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount);
+ const count = this.isSquashEnabled ? 1 : this.commitsCount;
+
+ return sprintf(
+ n__(
+ '%{strongStart}%{count}%{strongEnd} commit',
+ '%{strongStart}%{count}%{strongEnd} commits',
+ count,
+ ),
+ { count },
+ );
},
message() {
- if (this.glFeatures.restructuredMrWidget) {
- if (this.state === 'closed') {
- return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.');
- } else if (this.isMerged) {
- return s__(
- 'mrWidgetCommitsAdded|Changes merged into %{targetBranch} with %{mergeCommitSha}%{squashedCommits}.',
- );
- }
-
- return this.isFastForwardEnabled
- ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.')
- : s__(
- 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}%{squashedCommits}.',
- );
+ if (this.state === 'closed') {
+ return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.');
+ } else if (this.isMerged) {
+ return s__(
+ 'mrWidgetCommitsAdded|Changes merged into %{targetBranch} with %{mergeCommitSha}%{squashedCommits}.',
+ );
}
return this.isFastForwardEnabled
- ? s__('mrWidgetCommitsAdded|Adds %{commitCount} to %{targetBranch}.')
+ ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.')
: s__(
- 'mrWidgetCommitsAdded|Adds %{commitCount} and %{mergeCommitCount} to %{targetBranch}%{squashedCommits}.',
+ 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}%{squashedCommits}.',
);
},
- textDecorativeComponent() {
- return this.glFeatures.restructuredMrWidget ? 'span' : 'strong';
- },
squashCommitMessage() {
if (this.isMerged) {
- return s__('mergedCommitsAdded|(commits were squashed)');
+ return s__('mergedCommitsAdded| (commits were squashed)');
}
- return n__('(squashes %d commit)', '(squashes %d commits)', this.commitsCount);
+ return sprintf(
+ n__(
+ ' (squashes %{strongStart}%{count}%{strongEnd} commit)',
+ ' (squashes %{strongStart}%{count}%{strongEnd} commits)',
+ this.commitsCount,
+ ),
+ { count: this.commitsCount },
+ );
},
},
mergeCommitCount,
@@ -93,25 +98,33 @@ export default {
<span>
<gl-sprintf :message="message">
<template #commitCount>
- <component :is="textDecorativeComponent" class="commits-count-message">{{
- commitsCountMessage
- }}</component>
+ <gl-sprintf :message="commitsCountMessage">
+ <template #strong="{ content }">
+ <span class="gl-font-weight-bold">{{ content }}</span>
+ </template>
+ </gl-sprintf>
</template>
<template #mergeCommitCount>
- <component :is="textDecorativeComponent">{{ $options.mergeCommitCount }}</component>
+ <gl-sprintf :message="$options.mergeCommitCount">
+ <template #strong="{ content }">
+ <span class="gl-font-weight-bold">{{ content }}</span>
+ </template>
+ </gl-sprintf>
</template>
<template #targetBranch>
- <span class="label-branch">{{ targetBranchEscaped }}</span>
+ <span class="label-branch gl-font-weight-bold">{{ targetBranchEscaped }}</span>
</template>
<template #squashedCommits>
- <template v-if="glFeatures.restructuredMrWidget && isSquashEnabled">
- {{ squashCommitMessage }}</template
- ></template
- >
+ <template v-if="isSquashEnabled">
+ <gl-sprintf :message="squashCommitMessage">
+ <template #strong="{ content }">
+ <span class="gl-font-weight-bold">{{ content }}</span>
+ </template>
+ </gl-sprintf>
+ </template>
+ </template>
<template #mergeCommitSha>
- <template v-if="glFeatures.restructuredMrWidget"
- ><span class="label-branch">{{ mergeCommitSha }}</span></template
- >
+ <span class="label-branch">{{ mergeCommitSha }}</span>
</template>
</gl-sprintf>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
index 4163d195e0f..f782c28ea19 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
@@ -4,9 +4,6 @@ import createFlash from '~/flash';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__, __ } from '~/locale';
-import sidebarEventHub from '~/sidebar/event_hub';
-import showToast from '~/vue_shared/plugins/global_toast';
-import SidebarMediator from '~/sidebar/sidebar_mediator';
import eventHub from '../../event_hub';
import approvalsMixin from '../../mixins/approvals';
import MrWidgetContainer from '../mr_widget_container.vue';
@@ -192,16 +189,8 @@ export default {
.then((data) => {
this.mr.setApprovals(data);
- if (
- this.glFeatures.mrAttentionRequests &&
- SidebarMediator.singleton?.store.currentUserHasAttention
- ) {
- showToast(__('Approved. Your attention request was removed.'));
- }
-
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('ApprovalUpdated');
- sidebarEventHub.$emit('removeCurrentUserAttentionRequested');
this.$emit('updated');
})
.catch(errFn)
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
index bb1837399ed..1256b3a8e52 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
@@ -80,22 +80,26 @@ export default {
},
computeGraphData(metrics, deploymentTime) {
this.loadingMetrics = false;
- const { memory_before, memory_after, memory_values } = metrics;
+ const {
+ memory_before: memoryBefore,
+ memory_after: memoryAfter,
+ memory_values: memoryValues,
+ } = metrics;
// Both `memory_before` and `memory_after` objects
// have peculiar structure where accessing only a specific
// index yeilds correct value that we can use to show memory delta.
- if (memory_before.length > 0) {
- this.memoryFrom = this.getMegabytes(memory_before[0].value[1]);
+ if (memoryBefore.length > 0) {
+ this.memoryFrom = this.getMegabytes(memoryBefore[0].value[1]);
}
- if (memory_after.length > 0) {
- this.memoryTo = this.getMegabytes(memory_after[0].value[1]);
+ if (memoryAfter.length > 0) {
+ this.memoryTo = this.getMegabytes(memoryAfter[0].value[1]);
}
- if (memory_values.length > 0) {
+ if (memoryValues.length > 0) {
this.hasMetrics = true;
- this.memoryMetrics = memory_values[0].values;
+ this.memoryMetrics = memoryValues[0].values;
this.deploymentTime = deploymentTime;
}
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md b/app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md
new file mode 100644
index 00000000000..45ebafec8bf
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md
@@ -0,0 +1 @@
+Please see [the Widget Extensions documentation](development/merge_request_concepts/widget_extensions.md) for necessary information regarding development of new MR Widgets.
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index 410331004e4..414c5bf9691 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -12,8 +12,8 @@ import { sprintf, s__, __ } from '~/locale';
import Poll from '~/lib/utils/poll';
import { normalizeHeaders } from '~/lib/utils/common_utils';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
+import Actions from '../action_buttons.vue';
import StatusIcon from './status_icon.vue';
-import Actions from './actions.vue';
import ChildContent from './child_content.vue';
import { createTelemetryHub } from './telemetry';
import { generateText } from './utils';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
index 38f83a61b30..1eccc7de660 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
@@ -1,7 +1,7 @@
<script>
import { GlBadge, GlLink, GlSafeHtmlDirective, GlModalDirective } from '@gitlab/ui';
+import Actions from '../action_buttons.vue';
import StatusIcon from './status_icon.vue';
-import Actions from './actions.vue';
import { generateText } from './utils';
export default {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
index b551cd2fd60..bc84459e298 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
@@ -34,6 +34,36 @@ const nonStandardEvents = {
},
counter: {},
},
+ metrics: {
+ uniqueUser: {
+ expand: ['i_testing_metrics_report_widget_total'],
+ },
+ counter: {},
+ },
+ browserPerformance: {
+ uniqueUser: {
+ expand: ['i_testing_web_performance_widget_total'],
+ },
+ counter: {},
+ },
+ licenseCompliance: {
+ uniqueUser: {
+ expand: ['i_testing_license_compliance_widget_total'],
+ },
+ counter: {},
+ },
+ loadPerformance: {
+ uniqueUser: {
+ expand: ['i_testing_load_performance_widget_total'],
+ },
+ counter: {},
+ },
+ statusChecks: {
+ uniqueUser: {
+ expand: ['i_testing_status_checks_widget'],
+ },
+ counter: {},
+ },
};
function combineDeepArray(path, ...objects) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
index 913aa0e1e34..94a1b805b99 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -1,7 +1,6 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlLink } from '@gitlab/ui';
import { s__, n__ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'MRWidgetRelatedLinks',
@@ -10,9 +9,7 @@ export default {
},
components: {
GlLink,
- GlSprintf,
},
- mixins: [glFeatureFlagMixin()],
props: {
relatedLinks: {
type: Object,
@@ -67,42 +64,21 @@ export default {
</script>
<template>
<section>
- <p
- v-if="relatedLinks.closing"
- :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
- >
+ <p v-if="relatedLinks.closing" class="gl-display-inline gl-m-0">
{{ closesText }}
<span v-safe-html="relatedLinks.closing"></span>
</p>
- <p
- v-if="relatedLinks.mentioned"
- :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
- >
- <span v-if="relatedLinks.closing && glFeatures.restructuredMrWidget">&middot;</span>
+ <p v-if="relatedLinks.mentioned" class="gl-display-inline gl-m-0">
+ <span v-if="relatedLinks.closing">&middot;</span>
{{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }}
<span v-safe-html="relatedLinks.mentioned"></span>
</p>
- <p
- v-if="shouldShowAssignToMeLink"
- :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
- >
+ <p v-if="shouldShowAssignToMeLink" class="gl-display-inline gl-m-0">
<span>
<gl-link rel="nofollow" data-method="post" :href="relatedLinks.assignToMe">{{
assignIssueText
}}</gl-link>
</span>
</p>
- <div
- v-if="divergedCommitsCount > 0 && !glFeatures.restructuredMrWidget"
- class="diverged-commits-count"
- >
- <gl-sprintf :message="s__('mrWidget|The source branch is %{link} the target branch')">
- <template #link>
- <gl-link :href="targetBranchPath">{{
- n__('%d commit behind', '%d commits behind', divergedCommitsCount)
- }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 7ff1eb6e73a..5b8acb4ebf8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -1,25 +1,17 @@
<script>
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { GlLoadingIcon } from '@gitlab/ui';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
export default {
components: {
ciIcon,
- GlButton,
GlLoadingIcon,
},
- mixins: [glFeatureFlagMixin()],
props: {
status: {
type: String,
required: true,
},
- showDisabledButton: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
isLoading() {
@@ -42,15 +34,5 @@ export default {
</div>
<ci-icon v-else :status="statusObj" :size="24" />
</div>
-
- <gl-button
- v-if="!glFeatures.restructuredMrWidget && showDisabledButton"
- category="primary"
- variant="confirm"
- data-testid="disabled-merge-button"
- :disabled="true"
- >
- {{ s__('mrWidget|Merge') }}
- </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
new file mode 100644
index 00000000000..4a5a03fb598
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
@@ -0,0 +1,55 @@
+<script>
+import StatusIcon from './mr_widget_status_icon.vue';
+import Actions from './action_buttons.vue';
+
+export default {
+ components: {
+ StatusIcon,
+ Actions,
+ },
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ status: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ actions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <div v-if="isLoading" class="gl-w-full mr-conflict-loader">
+ <slot name="loading"></slot>
+ </div>
+ <template v-else>
+ <slot name="icon">
+ <status-icon :status="status" />
+ </slot>
+ <div
+ :class="{ 'gl-display-flex': actions.length, 'gl-md-display-flex': !actions.length }"
+ class="media-body"
+ >
+ <slot></slot>
+ <div
+ :class="{ 'gl-flex-direction-column-reverse': !actions.length }"
+ class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto gl-mt-1"
+ >
+ <slot name="actions">
+ <actions v-if="actions.length" :tertiary-buttons="actions" />
+ </slot>
+ </div>
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
index 18761d04c2e..515a7cf51a1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
@@ -1,8 +1,5 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
export default {
- mixins: [glFeatureFlagMixin()],
props: {
value: {
type: String,
@@ -23,10 +20,7 @@ export default {
<template>
<li>
<div class="commit-message-editor">
- <div
- :class="{ 'gl-mb-3': glFeatures.restructuredMrWidget }"
- class="d-flex flex-wrap align-items-center justify-content-between"
- >
+ <div class="d-flex flex-wrap align-items-center justify-content-between gl-mb-3">
<label class="col-form-label" :for="inputId">
<strong>{{ label }}</strong>
</label>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
index 071920856a8..f74826f95d3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -7,7 +6,6 @@ export default {
components: {
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
};
</script>
<template>
@@ -16,7 +14,7 @@ export default {
<status-icon status="warning" show-disabled-button />
</div>
<div class="media-body">
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="gl-ml-0! gl-text-body! bold">
{{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }}
</span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index aabbeac564a..690acc9a6dc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -1,15 +1,15 @@
<script>
-import { GlSkeletonLoader, GlIcon, GlButton, GlSprintf } from '@gitlab/ui';
+import { GlSkeletonLoader, GlIcon, GlSprintf } from '@gitlab/ui';
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql';
import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import MrWidgetAuthor from '../mr_widget_author.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetAutoMergeEnabled',
@@ -29,8 +29,8 @@ export default {
MrWidgetAuthor,
GlSkeletonLoader,
GlIcon,
- GlButton,
GlSprintf,
+ StateContainer,
},
mixins: [autoMergeMixin, glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
props: {
@@ -78,18 +78,25 @@ export default {
autoMergeStrategy() {
return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).autoMergeStrategy;
},
- canRemoveSourceBranch() {
- const { currentUserId } = this.mr;
- const mergeUserId = this.glFeatures.mergeRequestWidgetGraphql
- ? getIdFromGraphQLId(this.state.mergeUser?.id)
- : this.mr.mergeUserId;
- const canRemoveSourceBranch = this.glFeatures.mergeRequestWidgetGraphql
- ? this.state.userPermissions.removeSourceBranch
- : this.mr.canRemoveSourceBranch;
+ actions() {
+ const actions = [];
- return (
- !this.shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId
- );
+ if (this.loading) {
+ return actions;
+ }
+
+ if (this.mr.canCancelAutomaticMerge) {
+ actions.push({
+ text: this.cancelButtonText,
+ loading: this.isCancellingAutoMerge,
+ dataQaSelector: 'cancel_auto_merge_button',
+ class: 'js-cancel-auto-merge',
+ testId: 'cancelAutomaticMergeButton',
+ onClick: () => this.cancelAutomaticMerge(),
+ });
+ }
+
+ return actions;
},
},
methods: {
@@ -144,56 +151,25 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <div v-if="loading" class="gl-w-full mr-conflict-loader">
+ <state-container status="scheduled" :is-loading="loading" :actions="actions">
+ <template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="3" width="24" height="24" rx="4" />
<rect x="32" y="7" width="150" height="16" rx="4" />
<rect x="190" y="7" width="144" height="16" rx="4" />
</gl-skeleton-loader>
- </div>
- <template v-else>
+ </template>
+ <template v-if="!loading">
+ <h4 class="gl-mr-3" data-testid="statusText">
+ <gl-sprintf :message="statusText" data-testid="statusText">
+ <template #merge_author>
+ <mr-widget-author :author="mergeUser" />
+ </template>
+ </gl-sprintf>
+ </h4>
+ </template>
+ <template v-if="!loading" #icon>
<gl-icon name="status_scheduled" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
- <div class="media-body">
- <h4 class="gl-display-flex">
- <span class="gl-mr-3">
- <gl-sprintf :message="statusText" data-testid="statusText">
- <template #merge_author>
- <mr-widget-author :author="mergeUser" />
- </template>
- </gl-sprintf>
- </span>
- <gl-button
- v-if="mr.canCancelAutomaticMerge"
- :loading="isCancellingAutoMerge"
- size="small"
- class="js-cancel-auto-merge"
- data-qa-selector="cancel_auto_merge_button"
- data-testid="cancelAutomaticMergeButton"
- @click="cancelAutomaticMerge"
- >
- {{ cancelButtonText }}
- </gl-button>
- </h4>
- <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list">
- <p v-if="shouldRemoveSourceBranch">
- {{ s__('mrWidget|Deletes the source branch') }}
- </p>
- <p v-else class="gl-display-flex">
- <span class="gl-mr-3">{{ s__('mrWidget|Does not delete the source branch') }}</span>
- <gl-button
- v-if="canRemoveSourceBranch"
- :loading="isRemovingSourceBranch"
- size="small"
- class="js-remove-source-branch"
- data-testid="removeSourceBranchButton"
- @click="removeSourceBranch"
- >
- {{ s__('mrWidget|Delete source branch') }}
- </gl-button>
- </p>
- </section>
- </div>
</template>
- </div>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
index 1a764d3d091..b0cda85f361 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -1,17 +1,15 @@
<script>
-import { GlLoadingIcon, GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import autoMergeFailedQuery from '../../queries/states/auto_merge_failed.query.graphql';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetAutoMergeFailed',
components: {
- statusIcon,
- GlLoadingIcon,
- GlButton,
+ StateContainer,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
@@ -38,6 +36,17 @@ export default {
isRefreshing: false,
};
},
+ computed: {
+ actions() {
+ return [
+ {
+ text: s__('mrWidget|Refresh'),
+ loading: this.isRefreshing,
+ onClick: () => this.refreshWidget(),
+ },
+ ];
+ },
+ },
methods: {
refreshWidget() {
this.isRefreshing = true;
@@ -49,23 +58,10 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon status="warning" />
- <div class="media-body space-children gl-display-flex gl-flex-wrap gl-align-items-center">
- <span class="bold">
- <template v-if="mergeError">{{ mergeError }}</template>
- {{ s__('mrWidget|This merge request failed to be merged automatically') }}
- </span>
- <gl-button
- :disabled="isRefreshing"
- category="secondary"
- variant="default"
- size="small"
- @click="refreshWidget"
- >
- <gl-loading-icon v-if="isRefreshing" size="sm" :inline="true" />
- {{ s__('mrWidget|Refresh') }}
- </gl-button>
- </div>
- </div>
+ <state-container status="warning" :actions="actions">
+ <span class="bold gl-ml-0!">
+ <template v-if="mergeError">{{ mergeError }}</template>
+ {{ s__('mrWidget|This merge request failed to be merged automatically') }}
+ </span>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index fd42fa0421f..e2d87d8d536 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -7,14 +6,13 @@ export default {
components: {
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
};
</script>
<template>
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="loading" />
<div class="media-body space-children">
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="gl-ml-0! gl-text-body! bold">
{{ s__('mrWidget|Checking if merge request can be merged…') }}
</span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
index d50e52f5ac1..61f7d26f51e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -9,7 +8,6 @@ export default {
MrWidgetAuthorTime,
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
props: {
/* TODO: This is providing all store and service down when it
only needs metrics and targetBranch */
@@ -30,13 +28,6 @@ export default {
:date-title="mr.metrics.closedAt"
:date-readable="mr.metrics.readableClosedAt"
/>
-
- <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list">
- <p>
- {{ s__('mrWidget|The changes were not merged into') }}
- <a :href="mr.targetBranchPath" class="label-branch"> {{ mr.targetBranch }} </a>
- </p>
- </section>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index def30dacf8a..8abd915b93e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -4,14 +4,14 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import userPermissionsQuery from '../../queries/permissions.query.graphql';
import conflictsStateQuery from '../../queries/states/conflicts.query.graphql';
-import StatusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetConflicts',
components: {
GlSkeletonLoader,
- StatusIcon,
GlButton,
+ StateContainer,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
@@ -86,29 +86,23 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="warning" />
-
- <div v-if="isLoading" class="gl-ml-4 gl-w-full mr-conflict-loader">
+ <state-container status="warning" :is-loading="isLoading">
+ <template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="7" width="150" height="16" rx="4" />
<rect x="158" y="7" width="84" height="16" rx="4" />
<rect x="250" y="7" width="84" height="16" rx="4" />
</gl-skeleton-loader>
- </div>
- <div v-else class="media-body space-children gl-display-flex gl-align-items-center">
- <span
- v-if="shouldBeRebased"
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="bold"
- >
+ </template>
+ <template v-if="!isLoading">
+ <span v-if="shouldBeRebased" class="bold gl-ml-0! gl-text-body!">
{{
s__(`mrWidget|Merge blocked: fast-forward merge is not possible.
To merge this request, first rebase locally.`)
}}
</span>
<template v-else>
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="bold gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2">
{{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }}
<span v-if="!canMerge">
{{
@@ -118,23 +112,30 @@ export default {
}}
</span>
</span>
- <gl-button
- v-if="showResolveButton"
- :href="mr.conflictResolutionPath"
- :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'"
- data-testid="resolve-conflicts-button"
- >
- {{ s__('mrWidget|Resolve conflicts') }}
- </gl-button>
- <gl-button
- v-if="canMerge"
- :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'"
- data-testid="merge-locally-button"
- class="js-check-out-modal-trigger"
- >
- {{ s__('mrWidget|Resolve locally') }}
- </gl-button>
</template>
- </div>
- </div>
+ </template>
+ <template v-if="!isLoading && !shouldBeRebased" #actions>
+ <gl-button
+ v-if="canMerge"
+ size="small"
+ variant="confirm"
+ category="secondary"
+ data-testid="merge-locally-button"
+ class="js-check-out-modal-trigger gl-align-self-start"
+ :class="{ 'gl-mr-2': showResolveButton }"
+ >
+ {{ s__('mrWidget|Resolve locally') }}
+ </gl-button>
+ <gl-button
+ v-if="showResolveButton"
+ :href="mr.conflictResolutionPath"
+ size="small"
+ variant="confirm"
+ class="gl-mb-2 gl-md-mb-0 gl-align-self-start"
+ data-testid="resolve-conflicts-button"
+ >
+ {{ s__('mrWidget|Resolve conflicts') }}
+ </gl-button>
+ </template>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index 42e9261b82c..18103ac4a0e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -30,7 +30,7 @@ export default {
computed: {
mergeError() {
- const mergeError = this.mr.mergeError ? stripHtml(this.mr.mergeError, ' ').trim() : '';
+ const mergeError = this.prepareMergeError(this.mr.mergeError);
return sprintf(
s__('mrWidget|%{mergeError}.'),
@@ -76,6 +76,13 @@ export default {
this.refresh();
}
},
+ prepareMergeError(mergeError) {
+ return mergeError
+ ? stripHtml(mergeError, ' ')
+ .replace(/(\.$|\s+)/g, ' ')
+ .trim()
+ : '';
+ },
},
};
</script>
@@ -89,7 +96,9 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
<span class="bold">
- <span v-if="mr.mergeError" class="has-error-message"> {{ mergeError }} </span>
+ <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error">
+ {{ mergeError }}
+ </span>
<span v-else> {{ s__('mrWidget|Merge failed.') }} </span>
<span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index bf036f562ed..4416123cd51 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -1,15 +1,13 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlLoadingIcon, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import api from '~/api';
import createFlash from '~/flash';
import { s__, __ } from '~/locale';
import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants';
import modalEventHub from '~/projects/commit/event_hub';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import eventHub from '../../event_hub';
import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetMerged',
@@ -19,11 +17,8 @@ export default {
components: {
MrWidgetAuthorTime,
GlIcon,
- ClipboardButton,
- GlLoadingIcon,
- GlButton,
+ StateContainer,
},
- mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -78,6 +73,53 @@ export default {
cherryPickLabel() {
return s__('mrWidget|Cherry-pick');
},
+ actions() {
+ const actions = [];
+
+ if (this.mr.canRevertInCurrentMR) {
+ actions.push({
+ text: this.revertLabel,
+ tooltipText: this.revertTitle,
+ dataQaSelector: 'revert_button',
+ onClick: () => this.openRevertModal(),
+ });
+ } else if (this.mr.revertInForkPath) {
+ actions.push({
+ text: this.revertLabel,
+ tooltipText: this.revertTitle,
+ href: this.mr.revertInForkPath,
+ dataQaSelector: 'revert_button',
+ dataMethod: 'post',
+ });
+ }
+
+ if (this.mr.canCherryPickInCurrentMR) {
+ actions.push({
+ text: this.cherryPickLabel,
+ tooltipText: this.cherryPickTitle,
+ dataQaSelector: 'cherry_pick_button',
+ onClick: () => this.openCherryPickModal(),
+ });
+ } else if (this.mr.cherryPickInForkPath) {
+ actions.push({
+ text: this.cherryPickLabel,
+ tooltipText: this.cherryPickTitle,
+ href: this.mr.cherryPickInForkPath,
+ dataQaSelector: 'cherry_pick_button',
+ dataMethod: 'post',
+ });
+ }
+
+ if (this.shouldShowRemoveSourceBranch) {
+ actions.push({
+ text: s__('mrWidget|Delete source branch'),
+ class: 'js-remove-branch-button',
+ onClick: () => this.removeSourceBranch(),
+ });
+ }
+
+ return actions;
+ },
},
mounted() {
document.dispatchEvent(new CustomEvent('merged:UpdateActions'));
@@ -121,103 +163,15 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
- <div class="media-body">
- <div class="space-children">
- <mr-widget-author-time
- :action-text="s__('mrWidget|Merged by')"
- :author="mr.metrics.mergedBy"
- :date-title="mr.metrics.mergedAt"
- :date-readable="mr.metrics.readableMergedAt"
- />
- <gl-button
- v-if="mr.canRevertInCurrentMR"
- v-gl-tooltip.hover
- :title="revertTitle"
- size="small"
- category="secondary"
- data-qa-selector="revert_button"
- @click="openRevertModal"
- >
- {{ revertLabel }}
- </gl-button>
- <gl-button
- v-else-if="mr.revertInForkPath"
- v-gl-tooltip.hover
- :href="mr.revertInForkPath"
- :title="revertTitle"
- size="small"
- category="secondary"
- data-method="post"
- >
- {{ revertLabel }}
- </gl-button>
- <gl-button
- v-if="mr.canCherryPickInCurrentMR"
- v-gl-tooltip.hover
- :title="cherryPickTitle"
- size="small"
- data-qa-selector="cherry_pick_button"
- @click="openCherryPickModal"
- >
- {{ cherryPickLabel }}
- </gl-button>
- <gl-button
- v-else-if="mr.cherryPickInForkPath"
- v-gl-tooltip.hover
- :href="mr.cherryPickInForkPath"
- :title="cherryPickTitle"
- size="small"
- data-method="post"
- >
- {{ cherryPickLabel }}
- </gl-button>
- <gl-button
- v-if="shouldShowRemoveSourceBranch"
- :disabled="isMakingRequest"
- size="small"
- class="js-remove-branch-button"
- @click="removeSourceBranch"
- >
- {{ s__('mrWidget|Delete source branch') }}
- </gl-button>
- </div>
- <section
- v-if="!glFeatures.restructuredMrWidget"
- class="mr-info-list"
- data-qa-selector="merged_status_content"
- >
- <p>
- {{ s__('mrWidget|The changes were merged into') }}
- <span class="label-branch">
- <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a>
- </span>
- <template v-if="mr.mergeCommitSha">
- with
- <a
- :href="mr.mergeCommitPath"
- class="commit-sha js-mr-merged-commit-sha"
- v-text="mr.shortMergeCommitSha"
- >
- </a>
- <clipboard-button
- :title="__('Copy commit SHA')"
- :text="mr.mergeCommitSha"
- css-class="js-mr-merged-copy-sha"
- category="tertiary"
- size="small"
- />
- </template>
- </p>
- <p v-if="mr.sourceBranchRemoved">
- {{ s__('mrWidget|The source branch has been deleted') }}
- </p>
- <p v-if="shouldShowSourceBranchRemoving">
- <gl-loading-icon size="sm" :inline="true" />
- <span> {{ s__('mrWidget|The source branch is being deleted') }} </span>
- </p>
- </section>
- </div>
- </div>
+ <state-container :actions="actions">
+ <template #icon>
+ <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
+ </template>
+ <mr-widget-author-time
+ :action-text="s__('mrWidget|Merged by')"
+ :author="mr.metrics.mergedBy"
+ :date-title="mr.metrics.mergedAt"
+ :date-readable="mr.metrics.readableMergedAt"
+ />
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index b86ab69af3f..c7574a41bb8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -1,6 +1,5 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import simplePoll from '~/lib/utils/simple_poll';
import MergeRequest from '~/merge_request';
import eventHub from '../../event_hub';
@@ -15,7 +14,6 @@ export default {
components: {
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -90,14 +88,6 @@ export default {
{{ mergeStatus.message }}
<gl-emoji :data-name="mergeStatus.emoji" />
</h4>
- <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list">
- <p>
- {{ s__('mrWidget|Merges changes into') }}
- <span class="label-branch">
- <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a>
- </span>
- </p>
- </section>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index cadbd9c28a9..659d12d1160 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -74,13 +74,7 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span
- :class="{
- 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget,
- }"
- class="bold js-branch-text"
- data-testid="widget-content"
- >
+ <span class="gl-ml-0! gl-text-body! bold js-branch-text" data-testid="widget-content">
<gl-sprintf :message="warning">
<template #code="{ content }">
<code>{{ content }}</code>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
index 34c5a2ff2c8..e99ee59b877 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
@@ -14,7 +14,7 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="gl-ml-0! gl-text-body! bold">
{{
s__(
`mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`,
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 59767eb2e6e..6c5fc916799 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
@@ -8,7 +8,7 @@ import simplePoll from '~/lib/utils/simple_poll';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import rebaseQuery from '../../queries/states/rebase.query.graphql';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetRebase',
@@ -25,9 +25,9 @@ export default {
},
},
components: {
- statusIcon,
GlSkeletonLoader,
GlButton,
+ StateContainer,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
props: {
@@ -51,9 +51,6 @@ export default {
isLoading() {
return this.glFeatures.mergeRequestWidgetGraphql && this.$apollo.queries.state.loading;
},
- showRebaseWithoutCi() {
- return this.glFeatures?.rebaseWithoutCiUi;
- },
rebaseInProgress() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
return this.state.rebaseInProgress;
@@ -76,6 +73,10 @@ export default {
return this.mr.targetBranch;
},
status() {
+ if (this.isLoading) {
+ return undefined;
+ }
+
if (this.rebaseInProgress || this.isMakingRequest) {
return 'loading';
}
@@ -148,92 +149,70 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <div v-if="isLoading" class="gl-w-full mr-conflict-loader">
+ <state-container :status="status" :is-loading="isLoading">
+ <template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="3" width="24" height="24" rx="4" />
<rect x="32" y="5" width="302" height="20" rx="4" />
</gl-skeleton-loader>
- </div>
- <template v-else>
- <status-icon :status="status" :show-disabled-button="showDisabledButton" />
-
- <div class="rebase-state-find-class-convention media media-body space-children">
+ </template>
+ <template v-if="!isLoading">
+ <span
+ v-if="rebaseInProgress || isMakingRequest"
+ class="gl-ml-0! gl-text-body! gl-font-weight-bold"
+ data-testid="rebase-message"
+ >{{ __('Rebase in progress') }}</span
+ >
+ <span
+ v-if="!rebaseInProgress && !canPushToSourceBranch"
+ class="gl-text-body! gl-font-weight-bold gl-ml-0!"
+ data-testid="rebase-message"
+ >{{ fastForwardMergeText }}</span
+ >
+ <div
+ v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
+ class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1"
+ >
<span
- v-if="rebaseInProgress || isMakingRequest"
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold"
+ v-if="!rebasingError"
+ class="gl-font-weight-bold gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3"
data-testid="rebase-message"
- >{{ __('Rebase in progress') }}</span
+ data-qa-selector="no_fast_forward_message_content"
+ >{{
+ __('Merge blocked: the source branch must be rebased onto the target branch.')
+ }}</span
>
<span
- v-if="!rebaseInProgress && !canPushToSourceBranch"
- :class="{ 'gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold gl-ml-0!"
+ v-else
+ class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3"
data-testid="rebase-message"
- >{{ fastForwardMergeText }}</span
- >
- <div
- v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
- class="accept-merge-holder clearfix js-toggle-container accept-action media space-children gl-align-items-center"
+ >{{ rebasingError }}</span
>
- <gl-button
- v-if="!glFeatures.restructuredMrWidget"
- :loading="isMakingRequest"
- variant="confirm"
- data-qa-selector="mr_rebase_button"
- data-testid="standard-rebase-button"
- @click="rebase"
- >
- {{ __('Rebase') }}
- </gl-button>
- <gl-button
- v-if="!glFeatures.restructuredMrWidget && showRebaseWithoutCi"
- :loading="isMakingRequest"
- variant="confirm"
- category="secondary"
- data-testid="rebase-without-ci-button"
- @click="rebaseWithoutCi"
- >
- {{ __('Rebase without pipeline') }}
- </gl-button>
- <span
- v-if="!rebasingError"
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold"
- data-testid="rebase-message"
- data-qa-selector="no_fast_forward_message_content"
- >{{
- __('Merge blocked: the source branch must be rebased onto the target branch.')
- }}</span
- >
- <span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{
- rebasingError
- }}</span>
- <gl-button
- v-if="glFeatures.restructuredMrWidget"
- :loading="isMakingRequest"
- variant="confirm"
- size="small"
- data-qa-selector="mr_rebase_button"
- class="gl-ml-3!"
- @click="rebase"
- >
- {{ __('Rebase') }}
- </gl-button>
- <gl-button
- v-if="glFeatures.restructuredMrWidget && showRebaseWithoutCi"
- :loading="isMakingRequest"
- variant="confirm"
- size="small"
- category="secondary"
- data-testid="rebase-without-ci-button"
- @click="rebaseWithoutCi"
- >
- {{ __('Rebase without pipeline') }}
- </gl-button>
- </div>
</div>
</template>
- </div>
+ <template v-if="!isLoading" #actions>
+ <gl-button
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ category="secondary"
+ data-testid="rebase-without-ci-button"
+ class="gl-align-self-start gl-mr-2"
+ @click="rebaseWithoutCi"
+ >
+ {{ __('Rebase without pipeline') }}
+ </gl-button>
+ <gl-button
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ data-qa-selector="mr_rebase_button"
+ data-testid="standard-rebase-button"
+ class="gl-mb-2 gl-md-mb-0 gl-align-self-start"
+ @click="rebase"
+ >
+ {{ __('Rebase') }}
+ </gl-button>
+ </template>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
index d204befef58..d507e5f232b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
@@ -1,7 +1,6 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__ } from '~/locale';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -12,7 +11,6 @@ export default {
GlSprintf,
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
computed: {
troubleshootingDocsPath() {
return helpPagePath('ci/troubleshooting', { anchor: 'merge-request-status-messages' });
@@ -30,7 +28,7 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="gl-ml-0! gl-text-body! bold">
<gl-sprintf :message="$options.i18n.failedMessage">
<template #link="{ content }">
<gl-link :href="troubleshootingDocsPath" target="_blank">
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 cf482410bef..d2c85b14999 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
@@ -31,12 +31,10 @@ import {
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import MergeRequestStore from '../../stores/mr_widget_store';
-import statusIcon from '../mr_widget_status_icon.vue';
import AddedCommitMessage from '../added_commit_message.vue';
import RelatedLinks from '../mr_widget_related_links.vue';
import CommitEdit from './commit_edit.vue';
import CommitMessageDropdown from './commit_message_dropdown.vue';
-import CommitsHeader from './commits_header.vue';
import SquashBeforeMerge from './squash_before_merge.vue';
import MergeFailedPipelineConfirmationDialog from './merge_failed_pipeline_confirmation_dialog.vue';
@@ -96,9 +94,7 @@ export default {
},
},
components: {
- statusIcon,
SquashBeforeMerge,
- CommitsHeader,
CommitEdit,
CommitMessageDropdown,
GlIcon,
@@ -320,34 +316,27 @@ export default {
showDangerMessageForMergeTrain() {
return this.preferredAutoMergeStrategy === MT_MERGE_STRATEGY && this.isPipelineFailed;
},
- restructuredWidgetShowMergeButtons() {
- if (this.glFeatures.restructuredMrWidget) {
- return (
- (this.isMergeAllowed || this.isAutoMergeAvailable) &&
- this.state.userPermissions.canMerge &&
- !this.mr.mergeOngoing &&
- !this.mr.autoMergeEnabled
- );
- }
-
- return true;
+ shouldShowMergeControls() {
+ return (
+ (this.isMergeAllowed || this.isAutoMergeAvailable) &&
+ (this.stateData.userPermissions?.canMerge || this.mr.canMerge) &&
+ !this.mr.mergeOngoing &&
+ !this.mr.autoMergeEnabled
+ );
},
sourceBranchDeletedText() {
- if (this.glFeatures.restructuredMrWidget) {
- if (this.removeSourceBranch) {
- return this.mr.state === 'merged'
- ? __('Deleted the source branch.')
- : __('Source branch will be deleted.');
- }
-
+ if (this.removeSourceBranch) {
return this.mr.state === 'merged'
- ? __('Did not delete the source branch.')
- : __('Source branch will not be deleted.');
+ ? __('Deleted the source branch.')
+ : __('Source branch will be deleted.');
}
- return this.removeSourceBranch
- ? __('Deletes the source branch.')
- : __('Does not delete the source branch.');
+ return this.mr.state === 'merged'
+ ? __('Did not delete the source branch.')
+ : __('Source branch will not be deleted.');
+ },
+ showMergeDetailsHeader() {
+ return ['readyToMerge'].indexOf(this.mr.state) >= 0;
},
},
mounted() {
@@ -525,10 +514,7 @@ export default {
<template>
<div
data-testid="ready_to_merge_state"
- :class="{
- 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base':
- glFeatures.restructuredMrWidget,
- }"
+ class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
>
<div v-if="loading" class="mr-widget-body">
<div class="gl-w-full mr-ready-to-merge-loader">
@@ -541,16 +527,10 @@ export default {
</div>
</div>
<template v-else>
- <div
- class="mr-widget-body media"
- :class="{
- 'mr-widget-body-line-height-1': glFeatures.restructuredMrWidget,
- }"
- >
- <status-icon v-if="!glFeatures.restructuredMrWidget" :status="iconClass" />
+ <div class="mr-widget-body mr-widget-body-ready-merge media mr-widget-body-line-height-1">
<div class="media-body">
<div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap">
- <gl-button-group v-if="restructuredWidgetShowMergeButtons" class="gl-align-self-start">
+ <gl-button-group v-if="shouldShowMergeControls" class="gl-align-self-start">
<gl-button
size="medium"
category="primary"
@@ -603,19 +583,14 @@ export default {
<merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
<div
v-if="shouldShowMergeControls"
- :class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }"
- class="gl-display-flex gl-align-items-center gl-flex-wrap"
+ class="gl-display-flex gl-align-items-center gl-flex-wrap gl-w-full gl-order-n1 gl-mb-5"
>
<gl-form-checkbox
v-if="canRemoveSourceBranch"
id="remove-source-branch-input"
v-model="removeSourceBranch"
:disabled="isRemoveSourceBranchButtonDisabled"
- :class="{
- 'gl-mx-3': !glFeatures.restructuredMrWidget,
- 'gl-mr-5': glFeatures.restructuredMrWidget,
- }"
- class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center"
+ class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5"
>
{{ __('Delete source branch') }}
</gl-form-checkbox>
@@ -626,16 +601,11 @@ export default {
v-model="squashBeforeMerge"
:help-path="mr.squashBeforeMergeHelpPath"
:is-disabled="isSquashReadOnly"
- :class="{
- 'gl-mx-3': !glFeatures.restructuredMrWidget,
- 'gl-mr-5': glFeatures.restructuredMrWidget,
- }"
+ class="gl-mr-5"
/>
<gl-form-checkbox
- v-if="
- glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit)
- "
+ v-if="shouldShowSquashEdit || shouldShowMergeEdit"
v-model="editCommitMessage"
data-testid="widget_edit_commit_message"
class="gl-display-flex gl-align-items-center"
@@ -644,198 +614,113 @@ export default {
</gl-form-checkbox>
</div>
<div
- v-else-if="!glFeatures.restructuredMrWidget"
- class="bold js-resolve-mr-widget-items-message gl-ml-3"
+ v-if="editCommitMessage"
+ class="gl-w-full gl-order-n1"
+ data-testid="edit_commit_message"
>
- <div
- v-if="hasPipelineMustSucceedConflict"
- class="gl-display-flex gl-align-items-center"
- data-testid="pipeline-succeed-conflict"
- >
- <gl-sprintf :message="pipelineMustSucceedConflictText" />
- <gl-link
- :href="mr.pipelineMustSucceedDocsPath"
- target="_blank"
- class="gl-display-flex gl-ml-2"
+ <ul class="border-top commits-list flex-list gl-list-style-none gl-p-0 gl-pt-4">
+ <commit-edit
+ v-if="shouldShowSquashEdit"
+ :value="squashCommitMessage"
+ :label="__('Squash commit message')"
+ input-id="squash-message-edit"
+ class="gl-m-0! gl-p-0!"
+ @input="setSquashCommitMessage"
>
- <gl-icon name="question" />
- </gl-link>
- </div>
- <gl-sprintf v-else :message="mergeDisabledText" />
+ <template #header>
+ <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
+ </template>
+ </commit-edit>
+ <commit-edit
+ v-if="shouldShowMergeEdit"
+ :value="commitMessage"
+ :label="__('Merge commit message')"
+ input-id="merge-message-edit"
+ class="gl-m-0! gl-p-0!"
+ @input="setCommitMessage"
+ />
+ <li class="gl-m-0! gl-p-0!">
+ <p class="form-text text-muted">
+ <gl-sprintf :message="commitTemplateHintText">
+ <template #link="{ content }">
+ <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </li>
+ </ul>
</div>
- <template v-if="glFeatures.restructuredMrWidget">
- <div
- v-if="editCommitMessage"
- class="gl-w-full gl-order-n1"
- data-testid="edit_commit_message"
- >
- <ul
- :class="{
- 'content-list': !glFeatures.restructuredMrWidget,
- 'gl-list-style-none gl-p-0 gl-pt-4': glFeatures.restructuredMrWidget,
- }"
- class="border-top commits-list flex-list"
- >
- <commit-edit
- v-if="shouldShowSquashEdit"
- :value="squashCommitMessage"
- :label="__('Squash commit message')"
- input-id="squash-message-edit"
- class="gl-m-0! gl-p-0!"
- @input="setSquashCommitMessage"
+ <div
+ v-if="!shouldShowMergeControls"
+ class="gl-w-full gl-order-n1 mr-widget-merge-details"
+ data-qa-selector="merged_status_content"
+ >
+ <p v-if="showMergeDetailsHeader" class="gl-mb-3 gl-text-gray-900">
+ {{ __('Merge details') }}
+ </p>
+ <ul class="gl-pl-4 gl-mb-0 gl-ml-3 gl-text-gray-600">
+ <li v-if="mr.divergedCommitsCount > 0" class="gl-line-height-normal">
+ <gl-sprintf
+ :message="s__('mrWidget|The source branch is %{link} the target branch')"
>
- <template #header>
- <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
+ <template #link>
+ <gl-link :href="mr.targetBranchPath">{{
+ n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount)
+ }}</gl-link>
</template>
- </commit-edit>
- <commit-edit
- v-if="shouldShowMergeEdit"
- :value="commitMessage"
- :label="__('Merge commit message')"
- input-id="merge-message-edit"
- class="gl-m-0! gl-p-0!"
- @input="setCommitMessage"
+ </gl-sprintf>
+ </li>
+ <li class="gl-line-height-normal">
+ <added-commit-message
+ :state="mr.state"
+ :merge-commit-sha="mr.shortMergeCommitSha"
+ :is-squash-enabled="squashBeforeMerge"
+ :is-fast-forward-enabled="!shouldShowMergeEdit"
+ :commits-count="commitsCount"
+ :target-branch="stateData.targetBranch"
/>
- <li class="gl-m-0! gl-p-0!">
- <p class="form-text text-muted">
- <gl-sprintf :message="commitTemplateHintText">
- <template #link="{ content }">
- <gl-link
- :href="commitTemplateHelpPage"
- class="inline-link"
- target="_blank"
- >
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
- </li>
- </ul>
- </div>
- <div
- v-if="!restructuredWidgetShowMergeButtons"
- class="gl-w-full gl-order-n1 gl-text-gray-500"
- data-qa-selector="merged_status_content"
- >
- <strong v-if="mr.state !== 'closed'">
- {{ __('Merge details') }}
- </strong>
- <ul class="gl-pl-4 gl-m-0">
- <li v-if="mr.divergedCommitsCount > 0" class="gl-line-height-normal">
- <gl-sprintf
- :message="s__('mrWidget|The source branch is %{link} the target branch')"
- >
- <template #link>
- <gl-link :href="mr.targetBranchPath">{{
- n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount)
- }}</gl-link>
- </template>
- </gl-sprintf>
- </li>
- <li class="gl-line-height-normal">
- <added-commit-message
- :state="mr.state"
- :merge-commit-sha="mr.shortMergeCommitSha"
- :is-squash-enabled="squashBeforeMerge"
- :is-fast-forward-enabled="!shouldShowMergeEdit"
- :commits-count="commitsCount"
- :target-branch="stateData.targetBranch"
- />
- </li>
- <li v-if="mr.state !== 'closed'" class="gl-line-height-normal">
- {{ sourceBranchDeletedText }}
- </li>
- <li v-if="mr.relatedLinks" class="gl-line-height-normal">
- <related-links
- :state="mr.state"
- :related-links="mr.relatedLinks"
- :show-assign-to-me="false"
- class="mr-ready-merge-related-links gl-display-inline"
- />
- </li>
- </ul>
- </div>
- <div
- v-else
- :class="{ 'gl-mb-5': restructuredWidgetShowMergeButtons }"
- class="gl-w-full gl-order-n1 gl-text-gray-500"
- >
- <added-commit-message
- :is-squash-enabled="squashBeforeMerge"
- :is-fast-forward-enabled="!shouldShowMergeEdit"
- :commits-count="commitsCount"
- :target-branch="stateData.targetBranch"
- />
- <template v-if="mr.relatedLinks">
- &middot;
+ </li>
+ <li v-if="mr.state !== 'closed'" class="gl-line-height-normal">
+ {{ sourceBranchDeletedText }}
+ </li>
+ <li v-if="mr.relatedLinks" class="gl-line-height-normal">
<related-links
:state="mr.state"
:related-links="mr.relatedLinks"
:show-assign-to-me="false"
- :diverged-commits-count="mr.divergedCommitsCount"
- :target-branch-path="mr.targetBranchPath"
class="mr-ready-merge-related-links gl-display-inline"
/>
- </template>
- </div>
- </template>
- </div>
- <div
- v-if="showDangerMessageForMergeTrain && !glFeatures.restructuredMrWidget"
- class="gl-mt-5 gl-text-gray-500"
- data-testid="failed-pipeline-merge-train-text"
- >
- {{ __('The latest pipeline for this merge request did not complete successfully.') }}
+ </li>
+ </ul>
+ </div>
+ <div
+ v-else
+ :class="{ 'gl-mb-5': shouldShowMergeControls }"
+ class="gl-w-full gl-order-n1 gl-text-gray-500"
+ >
+ <added-commit-message
+ :is-squash-enabled="squashBeforeMerge"
+ :is-fast-forward-enabled="!shouldShowMergeEdit"
+ :commits-count="commitsCount"
+ :target-branch="stateData.targetBranch"
+ />
+ <template v-if="mr.relatedLinks">
+ &middot;
+ <related-links
+ :state="mr.state"
+ :related-links="mr.relatedLinks"
+ :show-assign-to-me="false"
+ :diverged-commits-count="mr.divergedCommitsCount"
+ :target-branch-path="mr.targetBranchPath"
+ class="mr-ready-merge-related-links gl-display-inline"
+ />
+ </template>
+ </div>
</div>
</div>
</div>
- <template v-if="shouldShowMergeControls && !glFeatures.restructuredMrWidget">
- <div v-if="!shouldShowMergeEdit" class="mr-fast-forward-message">
- {{ __('Fast-forward merge without a merge commit') }}
- </div>
- <commits-header
- v-if="!glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit)"
- :is-squash-enabled="squashBeforeMerge"
- :commits-count="commitsCount"
- :target-branch="stateData.targetBranch"
- :is-fast-forward-enabled="!shouldShowMergeEdit"
- :class="{ 'border-bottom': stateData.mergeError }"
- >
- <ul class="border-top content-list commits-list flex-list">
- <commit-edit
- v-if="shouldShowSquashEdit"
- :value="squashCommitMessage"
- :label="__('Squash commit message')"
- input-id="squash-message-edit"
- squash
- @input="setSquashCommitMessage"
- >
- <template #header>
- <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
- </template>
- </commit-edit>
- <commit-edit
- v-if="shouldShowMergeEdit"
- :value="commitMessage"
- :label="__('Merge commit message')"
- input-id="merge-message-edit"
- @input="setCommitMessage"
- />
- <li>
- <p class="form-text text-muted">
- <gl-sprintf :message="commitTemplateHintText">
- <template #link="{ content }">
- <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
- </li>
- </ul>
- </commits-header>
- </template>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index b1fbe150fcf..d149f5208fc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -1,19 +1,17 @@
<script>
import { GlButton } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { I18N_SHA_MISMATCH } from '../../i18n';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'ShaMismatch',
components: {
- statusIcon,
GlButton,
+ StateContainer,
},
i18n: {
I18N_SHA_MISMATCH,
},
- mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -24,25 +22,24 @@ export default {
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon :show-disabled-button="false" status="warning" />
- <div class="media-body">
- <span
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold"
- data-qa-selector="head_mismatch_content"
- >
- {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }}
- </span>
+ <state-container status="warning">
+ <span
+ class="gl-font-weight-bold gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!"
+ data-qa-selector="head_mismatch_content"
+ >
+ {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }}
+ </span>
+ <template #actions>
<gl-button
- class="gl-ml-3"
data-testid="action-button"
size="small"
category="primary"
variant="confirm"
+ class="gl-align-self-start"
:href="mr.mergeRequestDiffsPath"
- >{{ $options.i18n.I18N_SHA_MISMATCH.actionButtonLabel }}</gl-button
>
- </div>
- </div>
+ {{ $options.i18n.I18N_SHA_MISMATCH.actionButtonLabel }}
+ </gl-button>
+ </template>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index c6227c4394d..1413a46b4b9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -1,6 +1,5 @@
<script>
import { GlIcon, GlTooltipDirective, GlFormCheckbox, GlLink } from '@gitlab/ui';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SQUASH_BEFORE_MERGE } from '../../i18n';
export default {
@@ -12,7 +11,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
i18n: {
...SQUASH_BEFORE_MERGE,
},
@@ -36,9 +34,6 @@ export default {
tooltipTitle() {
return this.isDisabled ? this.$options.i18n.tooltipTitle : null;
},
- helpIconName() {
- return this.glFeatures.restructuredMrWidget ? 'question-o' : 'question';
- },
},
};
</script>
@@ -62,10 +57,10 @@ export default {
v-gl-tooltip
:href="helpPath"
:title="$options.i18n.helpLabel"
- :class="{ 'gl-text-blue-600': glFeatures.restructuredMrWidget }"
+ class="gl-text-blue-600"
target="_blank"
>
- <gl-icon :name="helpIconName" />
+ <gl-icon name="question-o" />
<span class="sr-only">
{{ $options.i18n.helpLabel }}
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 25ba4bf12af..035d62eaa59 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -1,16 +1,14 @@
<script>
import { GlButton } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '~/notes/event_hub';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'UnresolvedDiscussions',
components: {
- statusIcon,
GlButton,
+ StateContainer,
},
- mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -26,38 +24,33 @@ export default {
</script>
<template>
- <div class="mr-widget-body media gl-flex-wrap">
- <status-icon show-disabled-button status="warning" />
- <div class="media-body">
- <span
- :class="{
- 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget,
- 'gl-display-block': !glFeatures.restructuredMrWidget,
- }"
- class="gl-ml-3 gl-font-weight-bold gl-w-100"
- >
- {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }}
- </span>
+ <state-container status="warning">
+ <span
+ class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"
+ >
+ {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }}
+ </span>
+ <template #actions>
<gl-button
- data-testid="jump-to-first"
- class="gl-ml-3"
+ v-if="mr.createIssueToResolveDiscussionsPath"
+ :href="mr.createIssueToResolveDiscussionsPath"
+ class="js-create-issue gl-align-self-start gl-vertical-align-top gl-mr-2"
size="small"
- :icon="glFeatures.restructuredMrWidget ? undefined : 'comment-next'"
- :variant="glFeatures.restructuredMrWidget ? 'confirm' : 'default'"
- :category="glFeatures.restructuredMrWidget ? 'secondary' : 'primary'"
- @click="jumpToFirstUnresolvedDiscussion"
+ variant="confirm"
+ category="secondary"
>
- {{ s__('mrWidget|Jump to first unresolved thread') }}
+ {{ s__('mrWidget|Create issue to resolve all threads') }}
</gl-button>
<gl-button
- v-if="mr.createIssueToResolveDiscussionsPath"
- :href="mr.createIssueToResolveDiscussionsPath"
- class="js-create-issue gl-ml-3"
+ data-testid="jump-to-first"
+ class="gl-mb-2 gl-md-mb-0 gl-align-self-start gl-vertical-align-top"
size="small"
- :icon="glFeatures.restructuredMrWidget ? undefined : 'issue-new'"
+ variant="confirm"
+ category="primary"
+ @click="jumpToFirstUnresolvedDiscussion"
>
- {{ s__('mrWidget|Create issue to resolve all threads') }}
+ {{ s__('mrWidget|Jump to first unresolved thread') }}
</gl-button>
- </div>
- </div>
+ </template>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 5bd7745d704..cf7f83c014a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -12,13 +12,13 @@ import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_va
import getStateQuery from '../../queries/get_state.query.graphql';
import draftQuery from '../../queries/states/draft.query.graphql';
import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql';
-import StatusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'WorkInProgress',
components: {
- StatusIcon,
GlButton,
+ StateContainer,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
@@ -163,29 +163,22 @@ export default {
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon :show-disabled-button="canUpdate" status="warning" />
- <div class="media-body">
- <div class="float-left">
- <span
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold"
- >
- {{
- __("Merge blocked: merge request must be marked as ready. It's still marked as draft.")
- }}
- </span>
- </div>
+ <state-container status="warning">
+ <span class="gl-font-weight-bold gl-ml-0! gl-text-body! gl-flex-grow-1">
+ {{ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") }}
+ </span>
+ <template #actions>
<gl-button
v-if="canUpdate"
size="small"
:disabled="isMakingRequest"
:loading="isMakingRequest"
- class="js-remove-draft gl-ml-3"
+ variant="confirm"
+ class="js-remove-draft gl-md-ml-3 gl-align-self-start"
@click="handleRemoveDraft"
>
{{ s__('mrWidget|Mark as ready') }}
</gl-button>
- </div>
- </div>
+ </template>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
new file mode 100644
index 00000000000..f1c1bde256f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
@@ -0,0 +1,27 @@
+<script>
+export default {
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ widgets() {
+ return [].filter((w) => w);
+ },
+ },
+};
+</script>
+
+<template>
+ <section role="region" :aria-label="__('Merge request reports')" data-testid="mr-widget-app">
+ <component
+ :is="widget"
+ v-for="(widget, index) in widgets"
+ :key="widget.name || index"
+ :mr="mr"
+ :class="{ 'mr-widget-border-top': index === 0 }"
+ />
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
new file mode 100644
index 00000000000..9c8819327e6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
@@ -0,0 +1,158 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import Poll from '~/lib/utils/poll';
+import StatusIcon from '../extensions/status_icon.vue';
+import { EXTENSION_ICON_NAMES } from '../../constants';
+
+const FETCH_TYPE_COLLAPSED = 'collapsed';
+
+export default {
+ components: {
+ StatusIcon,
+ },
+ props: {
+ /**
+ * @param {value.collapsed} Object
+ * @param {value.extended} Object
+ */
+ value: {
+ type: Object,
+ required: true,
+ },
+ loadingText: {
+ type: String,
+ required: false,
+ default: __('Loading'),
+ },
+ errorText: {
+ type: String,
+ required: false,
+ default: __('Failed to load'),
+ },
+ fetchCollapsedData: {
+ type: Function,
+ required: true,
+ },
+ fetchExtendedData: {
+ type: Function,
+ required: false,
+ default: undefined,
+ },
+ // If the summary slot is not used, this value will be used as a fallback.
+ summary: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ // If the content slot is not used, this value will be used as a fallback.
+ content: {
+ type: Object,
+ required: false,
+ default: undefined,
+ },
+ multiPolling: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ statusIconName: {
+ type: String,
+ default: 'neutral',
+ required: false,
+ validator: (value) => Object.keys(EXTENSION_ICON_NAMES).indexOf(value) > -1,
+ },
+ widgetName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ error: null,
+ };
+ },
+ watch: {
+ isLoading(newValue) {
+ this.$emit('is-loading', newValue);
+ },
+ },
+ async mounted() {
+ this.isLoading = true;
+
+ try {
+ await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED);
+ } catch {
+ this.error = this.errorText;
+ }
+
+ this.isLoading = false;
+ },
+ methods: {
+ fetch(handler, dataType) {
+ const requests = this.multiPolling ? handler() : [handler];
+
+ const promises = requests.map((request) => {
+ return new Promise((resolve, reject) => {
+ const poll = new Poll({
+ resource: {
+ fetchData: () => request(),
+ },
+ method: 'fetchData',
+ successCallback: (response) => {
+ const headers = normalizeHeaders(response.headers);
+
+ if (headers['POLL-INTERVAL']) {
+ return;
+ }
+
+ resolve(response.data);
+ },
+ errorCallback: (e) => {
+ Sentry.captureException(e);
+ reject(e);
+ },
+ });
+
+ poll.makeRequest();
+ });
+ });
+
+ return Promise.all(promises).then((data) => {
+ this.$emit('input', { ...this.value, [dataType]: this.multiPolling ? data : data[0] });
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="media-section" data-testid="widget-extension">
+ <div class="media gl-p-5">
+ <status-icon
+ :level="1"
+ :name="widgetName"
+ :is-loading="isLoading"
+ :icon-name="statusIconName"
+ />
+ <div
+ class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center"
+ data-testid="widget-extension-top-level"
+ >
+ <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary">
+ <slot name="summary">{{ isLoading ? loadingText : summary }}</slot>
+ </div>
+ <!-- actions will go here -->
+ <!-- toggle button will go here -->
+ </div>
+ </div>
+ <div
+ class="mr-widget-grouped-section gl-relative"
+ data-testid="widget-extension-collapsed-section"
+ >
+ <slot name="content">{{ content }}</slot>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
index 22e907f7e48..0fb5e13ad82 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
@@ -6,7 +6,6 @@ import { EXTENSION_ICONS } from '../../constants';
export default {
name: 'WidgetAccessibility',
enablePolling: true,
- telemetry: false,
i18n: {
loading: s__('Reports|Accessibility scanning results are being parsed'),
error: s__('Reports|Accessibility scanning failed loading results'),
@@ -76,9 +75,9 @@ export default {
return sprintf(s__('AccessibilityReport|Message: %{message}'), { message });
},
prepareReports() {
- const { new_errors, existing_errors, resolved_errors } = this.collapsedData;
+ const { collapsedData } = this;
- const newErrors = new_errors.map((error) => {
+ const newErrors = collapsedData.new_errors.map((error) => {
return {
header: __('New'),
id: uniqueId('new-error-'),
@@ -92,7 +91,7 @@ export default {
};
});
- const existingErrors = existing_errors.map((error) => {
+ const existingErrors = collapsedData.existing_errors.map((error) => {
return {
id: uniqueId('existing-error-'),
text: this.formatText(error.code),
@@ -105,7 +104,7 @@ export default {
};
});
- const resolvedErrors = resolved_errors.map((error) => {
+ const resolvedErrors = collapsedData.resolved_errors.map((error) => {
return {
id: uniqueId('resolved-error-'),
text: this.formatText(error.code),
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
index 4ffd06de61f..6896f8831e8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
@@ -51,14 +51,14 @@ export const recentFailuresTextBuilder = (summary = {}) => {
return i18n.recentFailureSummary(recentlyFailed, failed);
};
-export const reportSubTextBuilder = ({ suite_errors, summary }) => {
- if (suite_errors?.head || suite_errors?.base) {
+export const reportSubTextBuilder = ({ suite_errors: suiteErrors, summary }) => {
+ if (suiteErrors?.head || suiteErrors?.base) {
const errors = [];
- if (suite_errors?.head) {
- errors.push(`${i18n.headReportParsingError} ${suite_errors.head}`);
+ if (suiteErrors?.head) {
+ errors.push(`${i18n.headReportParsingError} ${suiteErrors.head}`);
}
- if (suite_errors?.base) {
- errors.push(`${i18n.baseReportParsingError} ${suite_errors.base}`);
+ if (suiteErrors?.base) {
+ errors.push(`${i18n.baseReportParsingError} ${suiteErrors.base}`);
}
return errors.join('<br />');
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 627ddb0445e..d964b4bacac 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -20,13 +20,6 @@ export default {
this.mr.preventMerge,
);
},
- shouldShowMergeControls() {
- if (this.glFeatures.restructuredMrWidget) {
- return this.restructuredWidgetShowMergeButtons;
- }
-
- return this.isMergeAllowed || this.isAutoMergeAvailable;
- },
mergeDisabledText() {
if (this.pipeline?.status === PIPELINE_SKIPPED_STATUS) {
return MERGE_DISABLED_SKIPPED_PIPELINE_TEXT;
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 3e0ac236fdf..1e25143e15c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -1,7 +1,6 @@
<script>
import { GlSafeHtmlDirective } from '@gitlab/ui';
import { isEmpty } from 'lodash';
-import securityReportExtension from 'ee_else_ce/vue_merge_request_widget/extensions/security_reports';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
@@ -17,7 +16,6 @@ import { setFaviconOverlay } from '../lib/utils/favicon';
import Loading from './components/loading.vue';
import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
-import WidgetRelatedLinks from './components/mr_widget_related_links.vue';
import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue';
import ArchivedState from './components/states/mr_widget_archived.vue';
@@ -40,6 +38,7 @@ import ShaMismatch from './components/states/sha_mismatch.vue';
import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue';
import WorkInProgressState from './components/states/work_in_progress.vue';
import ExtensionsContainer from './components/extensions/container';
+import WidgetContainer from './components/widget/app.vue';
import { STATE_MACHINE, stateToComponentMap } from './constants';
import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
@@ -59,9 +58,9 @@ export default {
components: {
Loading,
ExtensionsContainer,
+ WidgetContainer,
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
MrWidgetPipelineContainer,
- 'mr-widget-related-links': WidgetRelatedLinks,
MrWidgetAlertMessage,
'mr-widget-merged': MergedState,
'mr-widget-closed': ClosedState,
@@ -73,9 +72,7 @@ export default {
'mr-widget-nothing-to-merge': NothingToMergeState,
'mr-widget-not-allowed': NotAllowedState,
'mr-widget-missing-branch': MissingBranchState,
- 'mr-widget-ready-to-merge': window.gon?.features?.restructuredMrWidget
- ? () => import('./components/states/new_ready_to_merge.vue')
- : ReadyToMergeState,
+ 'mr-widget-ready-to-merge': () => import('./components/states/new_ready_to_merge.vue'),
'sha-mismatch': ShaMismatch,
'mr-widget-checking': CheckingState,
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
@@ -163,12 +160,6 @@ export default {
shouldRenderCodeQuality() {
return this.mr?.codequalityReportsPath;
},
- shouldRenderRelatedLinks() {
- return (
- (Boolean(this.mr.relatedLinks) || this.mr.divergedCommitsCount > 0) &&
- !this.mr.isNothingToMergeState
- );
- },
shouldRenderSourceBranchRemovalStatus() {
return (
!this.mr.canRemoveSourceBranch &&
@@ -239,9 +230,6 @@ export default {
shouldShowCodeQualityExtension() {
return window.gon?.features?.refactorCodeQualityExtension;
},
- isRestructuredMrWidgetEnabled() {
- return window.gon?.features?.restructuredMrWidget;
- },
},
watch: {
'mr.machineValue': {
@@ -275,11 +263,6 @@ export default {
this.registerTestReportExtension();
}
},
- shouldRenderSecurityReport(newVal) {
- if (newVal) {
- this.registerSecurityReportExtension();
- }
- },
},
mounted() {
MRWidgetService.fetchInitialData()
@@ -535,11 +518,6 @@ export default {
registerExtension(testReportExtension);
}
},
- registerSecurityReportExtension() {
- if (this.shouldRenderSecurityReport && this.shouldShowSecurityExtension) {
- registerExtension(securityReportExtension);
- }
- },
},
};
</script>
@@ -600,7 +578,11 @@ export default {
</template>
</mr-widget-alert-message>
</div>
+
<extensions-container :mr="mr" />
+
+ <widget-container v-if="mr" :mr="mr" />
+
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality && !shouldShowCodeQualityExtension"
:head-blob-path="mr.headBlobPath"
@@ -638,23 +620,7 @@ export default {
<div class="mr-widget-section" data-qa-selector="mr_widget_content">
<component :is="componentName" :mr="mr" :service="service" />
- <ready-to-merge
- v-if="isRestructuredMrWidgetEnabled && mr.commitsCount"
- :mr="mr"
- :service="service"
- />
- <div v-else class="mr-widget-info">
- <mr-widget-related-links
- v-if="shouldRenderRelatedLinks"
- :state="mr.state"
- :related-links="mr.relatedLinks"
- :diverged-commits-count="mr.divergedCommitsCount"
- :target-branch-path="mr.targetBranchPath"
- class="mr-info-list gl-ml-7 gl-pb-5"
- />
-
- <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
- </div>
+ <ready-to-merge v-if="mr.commitsCount" :mr="mr" :service="service" />
</div>
</div>
<mr-widget-pipeline-container
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
index efc0673bc26..54770e6579a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
@@ -1,11 +1,9 @@
fragment ReadyToMerge on Project {
- __typename
id
onlyAllowMergeIfPipelineSucceeds
mergeRequestsFfOnlyEnabled
squashReadOnly
mergeRequest(iid: $iid) {
- __typename
id
autoMergeEnabled
shouldRemoveSourceBranch
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 18d955652ba..7a458f9ce7e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -27,8 +27,6 @@ export default function deviseState() {
return stateKey.shaMismatch;
} else if (this.autoMergeEnabled && !this.mergeError) {
return stateKey.autoMergeEnabled;
- } else if (!this.canMerge && !window.gon?.features?.restructuredMrWidget) {
- return stateKey.notAllowedToMerge;
} else if (this.canBeMerged) {
return stateKey.readyToMerge;
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 03c9a01cc7a..146cf7e11a7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -33,7 +33,6 @@ export default class MergeRequestStore {
this.setData(data);
this.initCodeQualityReport(data);
- this.initSecurityReport(data);
this.setGitpodData(data);
}
@@ -42,19 +41,6 @@ export default class MergeRequestStore {
this.codeQuality = data.codequality_reports_path;
}
- initSecurityReport(data) {
- // TODO: check if gl.mrWidgetData can be safely removed after we migrate to the
- // widget extension.
- this.securityReportPaths = {
- apiFuzzingReportPath: data.api_fuzzing_comparison_path,
- coverageFuzzingReportPath: data.coverage_fuzzing_comparison_path,
- sastReportPath: data.sast_comparison_path,
- dastReportPath: data.dast_comparison_path,
- secretDetectionReportPath: data.secret_detection_comparison_path,
- dependencyScanningReportPath: data.dependency_scanning_comparison_path,
- };
- }
-
setData(data, isRebased) {
this.initApprovals();
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
index e12e06a2454..5b9efff1c06 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
@@ -53,6 +53,7 @@ export default {
:variant="buttonVariant"
:disabled="disabled"
:data-testid="buttonTestid"
+ data-qa-selector="confirm_danger_button"
>{{ buttonText }}</gl-button
>
<confirm-danger-modal
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
index 37e480f7e41..7a982bc035a 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
@@ -66,7 +66,13 @@ export default {
actionPrimary() {
return {
text: this.confirmButtonText,
- attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }],
+ attributes: [
+ {
+ variant: 'danger',
+ disabled: !this.isValid,
+ 'data-qa-selector': 'confirm_danger_modal_button',
+ },
+ ],
};
},
actionCancel() {
@@ -122,7 +128,8 @@ export default {
<gl-form-input
id="confirm_name_input"
v-model="confirmationPhrase"
- class="form-control qa-confirm-input"
+ class="form-control"
+ data-qa-selector="confirm_danger_field"
data-testid="confirm-danger-input"
type="text"
/>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
index f4317ba90a2..7c4e372dda1 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
@@ -30,12 +30,12 @@ export function fetchBranches({ commit, state }, search = '') {
});
}
-export const fetchMilestones = ({ commit, state }, search_title = '') => {
+export const fetchMilestones = ({ commit, state }, searchTitle = '') => {
commit(types.REQUEST_MILESTONES);
const { milestonesEndpoint } = state;
return axios
- .get(milestonesEndpoint, { params: { search_title } })
+ .get(milestonesEndpoint, { params: { search_title: searchTitle } })
.then((response) => {
commit(types.RECEIVE_MILESTONES_SUCCESS, response.data);
return response;
diff --git a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue
index acddf16bd27..72148a0aa7c 100644
--- a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue
+++ b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue
@@ -2,6 +2,7 @@
import { GlBadge } from '@gitlab/ui';
import { s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
const STATUS_TYPES = {
SUCCESS: 'success',
@@ -45,7 +46,7 @@ export default {
methods: {
checkGitlabVersion() {
axios
- .get('/admin/version_check.json')
+ .get(joinPaths('/', gon.relative_url_root, '/admin/version_check.json'))
.then((res) => {
if (res.data) {
this.status = res.data.severity;
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 4fdf7f45643..1d1b65aa1af 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -156,6 +156,14 @@ export default {
})
.catch(() => {});
},
+ handleAttachFile(e) {
+ e.preventDefault();
+ const $gfmForm = $(this.$el).closest('.gfm-form');
+ const $gfmTextarea = $gfmForm.find('.js-gfm-input');
+
+ $gfmForm.find('.div-dropzone').click();
+ $gfmTextarea.focus();
+ },
},
shortcuts: {
bold: keysFor(BOLD_TEXT),
@@ -195,6 +203,44 @@ export default {
:class="{ 'gl-display-none!': previewMarkdown }"
class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center"
>
+ <template v-if="canSuggest">
+ <toolbar-button
+ ref="suggestButton"
+ :tag="mdSuggestion"
+ :prepend="true"
+ :button-title="__('Insert suggestion')"
+ :cursor-offset="4"
+ :tag-content="lineContent"
+ icon="doc-code"
+ data-qa-selector="suggestion_button"
+ class="js-suggestion-btn"
+ @click="handleSuggestDismissed"
+ />
+ <gl-popover
+ v-if="suggestPopoverVisible"
+ :target="$refs.suggestButton.$el"
+ :css-classes="['diff-suggest-popover']"
+ placement="bottom"
+ :show="suggestPopoverVisible"
+ >
+ <strong>{{ __('New! Suggest changes directly') }}</strong>
+ <p class="mb-2">
+ {{
+ __(
+ 'Suggest code changes which can be immediately applied in one click. Try it out!',
+ )
+ }}
+ </p>
+ <gl-button
+ variant="confirm"
+ category="primary"
+ size="small"
+ @click="handleSuggestDismissed"
+ >
+ {{ __('Got it') }}
+ </gl-button>
+ </gl-popover>
+ </template>
<toolbar-button
tag="**"
:button-title="
@@ -237,44 +283,6 @@ export default {
icon="quote"
@click="handleQuote"
/>
- <template v-if="canSuggest">
- <toolbar-button
- ref="suggestButton"
- :tag="mdSuggestion"
- :prepend="true"
- :button-title="__('Insert suggestion')"
- :cursor-offset="4"
- :tag-content="lineContent"
- icon="doc-code"
- data-qa-selector="suggestion_button"
- class="js-suggestion-btn"
- @click="handleSuggestDismissed"
- />
- <gl-popover
- v-if="suggestPopoverVisible"
- :target="$refs.suggestButton.$el"
- :css-classes="['diff-suggest-popover']"
- placement="bottom"
- :show="suggestPopoverVisible"
- >
- <strong>{{ __('New! Suggest changes directly') }}</strong>
- <p class="mb-2">
- {{
- __(
- 'Suggest code changes which can be immediately applied in one click. Try it out!',
- )
- }}
- </p>
- <gl-button
- variant="confirm"
- category="primary"
- size="small"
- @click="handleSuggestDismissed"
- >
- {{ __('Got it') }}
- </gl-button>
- </gl-popover>
- </template>
<toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
<toolbar-button
tag="[{text}](url)"
@@ -306,7 +314,7 @@ export default {
v-if="!restrictedToolBarItems.includes('task-list')"
:prepend="true"
tag="- [ ] "
- :button-title="__('Add a task list')"
+ :button-title="__('Add a checklist')"
icon="list-task"
/>
<toolbar-button
@@ -324,6 +332,15 @@ export default {
:button-title="__('Add a table')"
icon="table"
/>
+ <gl-button
+ v-if="!restrictedToolBarItems.includes('attach-file')"
+ v-gl-tooltip
+ :title="__('Attach a file or image')"
+ data-testid="button-attach-file"
+ category="tertiary"
+ icon="paperclip"
+ @click="handleAttachFile"
+ />
<toolbar-button
v-if="!restrictedToolBarItems.includes('full-screen')"
class="js-zen-enter"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 6c99a749edc..aa325862f06 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -74,7 +74,7 @@ export default {
</div>
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
- <gl-icon name="media" />
+ <gl-icon name="paperclip" />
<span class="attaching-file-message"></span>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<span class="uploading-progress">0%</span>
@@ -82,7 +82,7 @@ export default {
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
- <gl-icon name="media" />
+ <gl-icon name="paperclip" />
</span>
<span class="uploading-error-message"></span>
@@ -114,14 +114,6 @@ export default {
</gl-sprintf>
</span>
<gl-button
- icon="media"
- variant="link"
- category="primary"
- class="markdown-selector button-attach-file gl-vertical-align-text-bottom"
- >
- {{ __('Attach a file') }}
- </gl-button>
- <gl-button
variant="link"
category="primary"
class="button-cancel-uploading-files gl-vertical-align-baseline hide"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index 6a83939795c..49217e38a1b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -88,6 +88,6 @@ export default {
category="tertiary"
class="js-md"
data-container="body"
- @click="() => $emit('click')"
+ @click="$emit('click', $event)"
/>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
index 521b1a1075a..e9f278a5db5 100644
--- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
+++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
@@ -5,6 +5,8 @@ import {
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
+ GlIntersectionObserver,
+ GlLoadingIcon,
} from '@gitlab/ui';
import { __ } from '~/locale';
@@ -32,6 +34,8 @@ export default {
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
+ GlIntersectionObserver,
+ GlLoadingIcon,
},
props: {
groupNamespaces: {
@@ -69,6 +73,26 @@ export default {
required: false,
default: false,
},
+ hasNextPageOfGroups: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLoadingMoreGroups: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isSearchLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ shouldFilterNamespaces: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -84,10 +108,12 @@ export default {
return this.groupNamespaces.length;
},
filteredGroupNamespaces() {
+ if (!this.shouldFilterNamespaces) return this.groupNamespaces;
if (!this.hasGroupNamespaces) return [];
return filterByName(this.groupNamespaces, this.searchTerm);
},
filteredUserNamespaces() {
+ if (!this.shouldFilterNamespaces) return this.userNamespaces;
if (!this.hasUserNamespaces) return [];
return filterByName(this.userNamespaces, this.searchTerm);
},
@@ -107,9 +133,15 @@ export default {
return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase());
},
},
+ watch: {
+ searchTerm() {
+ this.$emit('search', this.searchTerm);
+ },
+ },
methods: {
handleSelect(item) {
this.selectedNamespace = item;
+ this.searchTerm = '';
this.$emit('select', item);
},
handleSelectEmptyNamespace() {
@@ -122,7 +154,11 @@ export default {
<template>
<gl-dropdown :text="selectedNamespaceText" :block="fullWidth" data-qa-selector="namespaces_list">
<template #header>
- <gl-search-box-by-type v-model.trim="searchTerm" />
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :is-loading="isSearchLoading"
+ data-qa-selector="namespaces_list_search"
+ />
</template>
<div v-if="filteredEmptyNamespaceTitle">
<gl-dropdown-item
@@ -133,29 +169,40 @@ export default {
</gl-dropdown-item>
<gl-dropdown-divider />
</div>
- <div v-if="hasGroupNamespaces" data-qa-selector="namespaces_list_groups">
+ <div
+ v-if="hasUserNamespaces"
+ data-qa-selector="namespaces_list_users"
+ data-testid="namespace-list-users"
+ >
<gl-dropdown-section-header v-if="includeHeaders">{{
- $options.i18n.GROUPS
+ $options.i18n.USERS
}}</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="item in filteredGroupNamespaces"
+ v-for="item in filteredUserNamespaces"
:key="item.id"
data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
- <div v-if="hasUserNamespaces" data-qa-selector="namespaces_list_users">
+ <div
+ v-if="hasGroupNamespaces"
+ data-qa-selector="namespaces_list_groups"
+ data-testid="namespace-list-groups"
+ >
<gl-dropdown-section-header v-if="includeHeaders">{{
- $options.i18n.USERS
+ $options.i18n.GROUPS
}}</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="item in filteredUserNamespaces"
+ v-for="item in filteredGroupNamespaces"
:key="item.id"
data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
+ <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')">
+ <gl-loading-icon v-if="isLoadingMoreGroups" class="gl-mb-3" size="sm" />
+ </gl-intersection-observer>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue
index 402e75962d2..f65cc8bf2f3 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatar } from '@gitlab/ui';
+import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
@@ -7,6 +8,14 @@ export default {
GlAvatar,
},
props: {
+ projectId: {
+ type: [Number, String],
+ default: 0,
+ required: false,
+ validator(value) {
+ return typeof value === 'string' ? isGid(value) : true;
+ },
+ },
projectName: {
type: String,
required: true,
@@ -31,6 +40,9 @@ export default {
avatarAlt() {
return this.alt ?? this.projectName;
},
+ entityId() {
+ return isGid(this.projectId) ? getIdFromGraphQLId(this.projectId) : this.projectId;
+ },
},
AVATAR_SHAPE_OPTION_RECT,
};
@@ -39,6 +51,7 @@ export default {
<template>
<gl-avatar
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :entity-id="entityId"
:entity-name="projectName"
:src="projectAvatarUrl"
:alt="avatarAlt"
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 19ffbe37ce7..66643ff4026 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -53,6 +53,7 @@ export default {
>
<gl-icon v-if="selected" class="js-selected-icon" name="mobile-issue-close" />
<project-avatar
+ :project-id="project.id"
:project-avatar-url="projectAvatarUrl"
:project-name="projectNameWithNamespace"
class="gl-mr-3"
diff --git a/app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue b/app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue
new file mode 100644
index 00000000000..424a11bf88b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlTooltip } from '@gitlab/ui';
+
+import { formatDate } from '~/lib/utils/datetime_utility';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: {
+ GlTooltip,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ target: {
+ type: [Object, HTMLElement, SVGElement, String, Function],
+ required: true,
+ },
+ rawTimestamp: {
+ type: String,
+ required: true,
+ },
+ timestampTypeText: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ timestampInWords() {
+ return this.rawTimestamp ? this.timeFormatted(this.rawTimestamp) : '';
+ },
+ timestamp() {
+ return this.rawTimestamp ? formatDate(new Date(this.rawTimestamp)) : '';
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-tooltip :target="target">
+ <div class="bold" data-testid="header-text">{{ timestampTypeText }} {{ timestampInWords }}</div>
+ <div class="text-tertiary" data-testid="body-text">{{ timestamp }}</div>
+ </gl-tooltip>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
index be270e440ed..4af07366a6d 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
@@ -3,11 +3,13 @@
query issueAssignees($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
+ author {
+ ...User
+ ...UserAvailability
+ }
assignees {
nodes {
...User
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 96a40e597ee..445817d3e52 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -3,10 +3,8 @@
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
participants {
nodes {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
index dffcc053fac..b127b8ec5a9 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
@@ -2,7 +2,6 @@
query issueTimeTrackingReport($id: IssueID!) {
issuable: issue(id: $id) {
- __typename
id
title
timelogs {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql
new file mode 100644
index 00000000000..05de680ab05
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql
@@ -0,0 +1,26 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query mergeRequestReviewers($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: mergeRequest(iid: $iid) {
+ id
+ reviewers {
+ nodes {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ canUpdate
+ approved
+ reviewed
+ }
+ }
+ }
+ userPermissions {
+ updateMergeRequest
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
index 7127940bb05..f70cd723f2e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
@@ -6,6 +6,13 @@ query getMrAssignees($fullPath: ID!, $iid: String!) {
id
issuable: mergeRequest(iid: $iid) {
id
+ author {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
+ }
assignees {
nodes {
...User
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
index ede9b75d765..17f548b44b5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
@@ -2,7 +2,6 @@
query mrTimeTrackingReport($id: MergeRequestID!) {
issuable: mergeRequest(id: $id) {
- __typename
id
title
timelogs {
diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index 6a0bf07c8b4..1925c5d4064 100644
--- a/app/assets/javascripts/vue_shared/components/source_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -1,5 +1,5 @@
<script>
-import { debounce } from 'lodash';
+import { debounce, isEmpty } from 'lodash';
import { CONTENT_UPDATE_DEBOUNCE, EDITOR_READY_EVENT } from '~/editor/constants';
import Editor from '~/editor/source_editor';
@@ -37,9 +37,9 @@ export default {
default: '',
},
extensions: {
- type: [String, Array],
+ type: [Object, Array],
required: false,
- default: () => null,
+ default: () => ({}),
},
editorOptions: {
type: Object,
@@ -74,11 +74,13 @@ export default {
blobPath: this.fileName,
blobContent: this.value,
blobGlobalId: this.fileGlobalId,
- extensions: this.extensions,
...this.editorOptions,
});
this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), this.debounceValue));
+ if (!isEmpty(this.extensions)) {
+ this.editor.use(this.extensions);
+ }
},
beforeDestroy() {
this.editor.dispose();
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
index 6babbca58c3..9683288f937 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -51,6 +51,10 @@ export default {
required: false,
default: null,
},
+ blamePath: {
+ type: String,
+ required: true,
+ },
},
computed: {
lines() {
@@ -76,6 +80,7 @@ export default {
:number="startingFrom + index + 1"
:content="line"
:language="language"
+ :blame-path="blamePath"
/>
</div>
<div v-else class="gl-display-flex">
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
index 7b62f0cdb7d..257b9f57222 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
@@ -1,15 +1,14 @@
<script>
-import { GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlSafeHtmlDirective } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { setAttributes } from '~/lib/utils/dom_utils';
import { BIDI_CHARS, BIDI_CHARS_CLASS_LIST, BIDI_CHAR_TOOLTIP } from '../constants';
export default {
- components: {
- GlLink,
- },
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
number: {
type: Number,
@@ -23,6 +22,10 @@ export default {
type: String,
required: true,
},
+ blamePath: {
+ type: String,
+ required: true,
+ },
},
computed: {
formattedContent() {
@@ -36,9 +39,6 @@ export default {
return content;
},
- firstLineClass() {
- return { 'gl-mt-3!': this.number === 1 };
- },
},
methods: {
wrapBidiChar(bidiChar) {
@@ -59,21 +59,26 @@ export default {
</script>
<template>
<div class="gl-display-flex">
- <div class="gl-p-0! gl-absolute gl-z-index-3 gl-border-r diff-line-num line-numbers">
- <gl-link
+ <div
+ class="gl-p-0! gl-absolute gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
+ >
+ <a
+ v-if="glFeatures.fileLineBlame"
+ class="gl-user-select-none gl-shadow-none! file-line-blame"
+ :href="`${blamePath}#L${number}`"
+ ></a>
+ <a
:id="`L${number}`"
- class="gl-user-select-none gl-ml-5 gl-pr-3 gl-shadow-none! file-line-num diff-line-num"
- :class="firstLineClass"
- :to="`#L${number}`"
+ class="gl-user-select-none gl-shadow-none! file-line-num"
+ :href="`#L${number}`"
:data-line-number="number"
>
{{ number }}
- </gl-link>
+ </a>
</div>
<pre
- class="gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11! gl-border-none! code highlight gl-line-height-normal"
- :class="firstLineClass"
+ class="gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-normal"
><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 3ac35abcf3a..cc930d67fa4 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -147,3 +147,4 @@ export const HLJS_COMMENT_SELECTOR = 'hljs-comment';
export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
export const NPM_URL = 'https://npmjs.com/package';
+export const GEM_URL = 'https://rubygems.org/gems';
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
index 5b7650c56ae..d957990fe7f 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
@@ -1,7 +1,9 @@
import packageJsonLinker from './utils/package_json_linker';
+import gemspecLinker from './utils/gemspec_linker';
const DEPENDENCY_LINKERS = {
package_json: packageJsonLinker,
+ gemspec: gemspecLinker,
};
/**
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
index 56ad55ef553..dbe6812cf16 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
@@ -7,9 +7,10 @@ export const createLink = (href, innerText) => {
const link = document.createElement('a');
setAttributes(link, { href: escape(href), rel });
- link.innerText = escape(innerText);
+ link.textContent = innerText;
return link.outerHTML;
};
-export const generateHLJSOpenTag = (type) => `<span class="hljs-${escape(type)}">&quot;`;
+export const generateHLJSOpenTag = (type, delimiter = '&quot;') =>
+ `<span class="hljs-${escape(type)}">${delimiter}`;
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js
new file mode 100644
index 00000000000..35de8fd13d6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js
@@ -0,0 +1,39 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+import { GEM_URL } from '../../constants';
+import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
+
+const methodRegex = '.*add_dependency.*|.*add_runtime_dependency.*|.*add_development_dependency.*';
+const openTagRegex = generateHLJSOpenTag('string', '(&.*;)');
+const closeTagRegex = '&.*</span>';
+
+const DEPENDENCY_REGEX = new RegExp(
+ /*
+ * Detects gemspec dependencies inside of content that is highlighted by Highlight.js
+ * Example: s.add_dependency(<span class="hljs-string">&#x27;rugged&#x27;</span>, <span class="hljs-string">&#x27;~&gt; 0.24.0&#x27;</span>)
+ *
+ * Group 1 (method) : s.add_dependency(
+ * Group 2 (delimiter) : &#x27;
+ * Group 3 (packageName): rugged
+ * Group 4 (closeTag) : &#x27;</span>
+ * Group 5 (rest) : , <span class="hljs-string">&#x27;~&gt; 0.24.0&#x27;</span>)
+ */
+ `(${methodRegex})${openTagRegex}(.*)(${closeTagRegex})(.*${closeTagRegex})`,
+ 'gm',
+);
+
+const handleReplace = (method, delimiter, packageName, closeTag, rest) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ const openTag = generateHLJSOpenTag('string linked', delimiter);
+ const href = joinPaths(GEM_URL, packageName);
+ const packageLink = createLink(href, packageName);
+
+ return `${method}${openTag}${packageLink}${closeTag}${rest}`;
+};
+
+export default (result) => {
+ return result.value.replace(
+ DEPENDENCY_REGEX,
+ (_, method, delimiter, packageName, closeTag, rest) =>
+ handleReplace(method, delimiter, packageName, closeTag, rest),
+ );
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
index d013d077ba3..3c6fc23c138 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
@@ -1,3 +1,4 @@
+import { unescape } from 'lodash';
import { joinPaths } from '~/lib/utils/url_utility';
import { NPM_URL } from '../../constants';
import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
@@ -17,13 +18,15 @@ const DEPENDENCY_REGEX = new RegExp(
);
const handleReplace = (original, packageName, version, dependenciesToLink) => {
- const href = joinPaths(NPM_URL, packageName);
- const packageLink = createLink(href, packageName);
- const versionLink = createLink(href, version);
+ const unescapedPackageName = unescape(packageName);
+ const unescapedVersion = unescape(version);
+ const href = joinPaths(NPM_URL, unescapedPackageName);
+ const packageLink = createLink(href, unescapedPackageName);
+ const versionLink = createLink(href, unescapedVersion);
const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`;
- const dependencyToLink = dependenciesToLink[packageName];
+ const dependencyToLink = dependenciesToLink[unescapedPackageName];
- if (dependencyToLink && dependencyToLink === version) {
+ if (dependencyToLink && dependencyToLink === unescapedVersion) {
return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`;
}
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 1bdae40332f..ccc8b44942a 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -199,6 +199,7 @@ export default {
:starting-from="firstChunk.startingFrom"
:is-highlighted="firstChunk.isHighlighted"
:language="firstChunk.language"
+ :blame-path="blob.blamePath"
/>
<gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
@@ -213,6 +214,7 @@ export default {
:is-highlighted="chunk.isHighlighted"
:chunk-index="index"
:language="chunk.language"
+ :blame-path="blob.blamePath"
@appear="highlightChunk"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index d07f65cf5c1..c1e618620d8 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -50,7 +50,7 @@ export default {
default: __('user avatar'),
},
size: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
@@ -64,12 +64,19 @@ export default {
required: false,
default: 'top',
},
+ enforceGlAvatar: {
+ type: Boolean,
+ required: false,
+ },
},
};
</script>
<template>
- <user-avatar-image-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <user-avatar-image-new
+ v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar"
+ v-bind="$props"
+ >
<slot></slot>
</user-avatar-image-new>
<user-avatar-image-old v-else v-bind="$props">
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
index 707b0bbec67..cd610314292 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
@@ -16,6 +16,7 @@
*/
import { GlTooltip, GlAvatar } from '@gitlab/ui';
+import { isObject } from 'lodash';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
import { placeholderImage } from '~/lazy_loader';
@@ -48,7 +49,7 @@ export default {
default: __('user avatar'),
},
size: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
@@ -71,9 +72,16 @@ export default {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
// Only adds the width to the URL if its not a base64 data image
if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
- baseSrc += `?width=${this.size}`;
+ baseSrc += `?width=${this.maximumSize}`;
return baseSrc;
},
+ maximumSize() {
+ if (isObject(this.size)) {
+ return Math.max(...Object.values(this.size));
+ }
+
+ return this.size;
+ },
resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource;
},
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
index 887deff17c9..f80abed4d69 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -55,7 +55,7 @@ export default {
default: '',
},
imgSize: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
@@ -74,12 +74,19 @@ export default {
required: false,
default: '',
},
+ enforceGlAvatar: {
+ type: Boolean,
+ required: false,
+ },
},
};
</script>
<template>
- <user-avatar-link-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <user-avatar-link-new
+ v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar"
+ v-bind="$props"
+ >
<slot></slot>
<template #avatar-badge>
<slot name="avatar-badge"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
index 3b459569274..83551c689c4 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
@@ -56,7 +56,7 @@ export default {
default: '',
},
imgSize: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
@@ -75,6 +75,10 @@ export default {
required: false,
default: '',
},
+ enforceGlAvatar: {
+ type: Boolean,
+ required: false,
+ },
},
computed: {
shouldShowUsername() {
@@ -97,6 +101,7 @@ export default {
:tooltip-text="avatarTooltipText"
:tooltip-placement="tooltipPlacement"
:lazy="lazy"
+ :enforce-gl-avatar="enforceGlAvatar"
>
<slot></slot>
</user-avatar-image>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
index 60b26d688b2..9da298ad705 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -21,7 +21,7 @@ export default {
default: 10,
},
imgSize: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index a0d8ca117a4..2b9804796ae 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -14,6 +14,7 @@ import { glEmojiTag } from '~/emoji';
import createFlash from '~/flash';
import { followUser, unfollowUser } from '~/rest_api';
import { isUserBusy } from '~/set_status_modal/utils';
+import Tracking from '~/tracking';
import { USER_POPOVER_DELAY } from './constants';
const MAX_SKELETON_LINES = 4;
@@ -37,6 +38,7 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ mixins: [Tracking.mixin()],
props: {
target: {
type: HTMLElement,
@@ -117,6 +119,11 @@ export default {
},
async follow() {
this.toggleFollowLoading = true;
+
+ this.track('click_button', {
+ label: 'follow_from_user_popover',
+ });
+
try {
await followUser(this.user.id);
this.$emit('follow');
@@ -132,6 +139,11 @@ export default {
},
async unfollow() {
this.toggleFollowLoading = true;
+
+ this.track('click_button', {
+ label: 'unfollow_from_user_popover',
+ });
+
try {
await unfollowUser(this.user.id);
this.$emit('unfollow');
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 91f20863089..43a590c2367 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
@@ -77,6 +77,11 @@ export default {
required: false,
default: null,
},
+ issuableAuthor: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -178,7 +183,7 @@ export default {
[],
);
- return this.moveCurrentUserToStart(mergedSearchResults);
+ return this.moveCurrentUserAndAuthorToStart(mergedSearchResults);
},
isSearchEmpty() {
return this.search === '';
@@ -196,14 +201,21 @@ export default {
showCurrentUser() {
return this.currentUser.username && !this.isCurrentUserInList && this.isSearchEmpty;
},
+ showAuthor() {
+ return (
+ this.issuableAuthor &&
+ !this.users.some((user) => user.id === this.issuableAuthor.id) &&
+ this.isSearchEmpty
+ );
+ },
selectedFiltered() {
if (this.shouldShowParticipants) {
- return this.moveCurrentUserToStart(this.value);
+ return this.moveCurrentUserAndAuthorToStart(this.value);
}
const foundUsernames = this.users.map(({ username }) => username);
const filtered = this.value.filter(({ username }) => foundUsernames.includes(username));
- return this.moveCurrentUserToStart(filtered);
+ return this.moveCurrentUserAndAuthorToStart(filtered);
},
selectedUserNames() {
return this.value.map(({ username }) => username);
@@ -254,20 +266,22 @@ export default {
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
- moveCurrentUserToStart(users) {
- if (!users) {
- return [];
+ moveCurrentUserAndAuthorToStart(users = []) {
+ let sortedUsers = [...users];
+
+ const author = sortedUsers.find((user) => user.id === this.issuableAuthor?.id);
+ if (author) {
+ sortedUsers = [author, ...sortedUsers.filter((user) => user.id !== author.id)];
}
- const usersCopy = [...users];
- const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
+
+ const currentUser = sortedUsers.find((user) => user.username === this.currentUser.username);
if (currentUser) {
currentUser.canMerge = this.currentUser.canMerge;
- const index = usersCopy.indexOf(currentUser);
- usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
+ sortedUsers = [currentUser, ...sortedUsers.filter((user) => user.id !== currentUser.id)];
}
- return usersCopy;
+ return sortedUsers;
},
setSearchKey(value) {
this.search = value.trim();
@@ -298,7 +312,7 @@ export default {
<gl-loading-icon
v-if="isLoading"
data-testid="loading-participants"
- size="lg"
+ size="md"
class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
/>
<template v-else>
@@ -312,8 +326,8 @@ export default {
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned
- }}</span></gl-dropdown-item
- >
+ }}</span>
+ </gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item
@@ -342,7 +356,17 @@ export default {
/>
</gl-dropdown-item>
</template>
- <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
+ <gl-dropdown-item
+ v-if="showAuthor"
+ data-testid="issuable-author"
+ @click.native.capture.stop="selectAssignee(issuableAuthor)"
+ >
+ <sidebar-participant
+ :user="issuableAuthor"
+ :issuable-type="issuableType"
+ class="gl-pl-6!"
+ />
+ </gl-dropdown-item>
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
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 cac0d5a45c9..6d179b3dc92 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -10,6 +10,21 @@ const KEY_WEB_IDE = 'webide';
const KEY_GITPOD = 'gitpod';
const KEY_PIPELINE_EDITOR = 'pipeline_editor';
+export const i18n = {
+ modal: {
+ title: __('Enable Gitpod?'),
+ content: s__(
+ 'Gitpod|To use Gitpod you must first enable the feature in the integrations section of your %{linkStart}user preferences%{linkEnd}.',
+ ),
+ actionCancelText: __('Cancel'),
+ actionPrimaryText: __('Enable Gitpod'),
+ },
+ webIdeText: s__('WebIDE|Quickly and easily edit multiple files in your project.'),
+ webIdeTooltip: s__(
+ 'WebIDE|Quickly and easily edit multiple files in your project. Press . to open',
+ ),
+};
+
export default {
components: {
ActionsButton,
@@ -19,16 +34,7 @@ export default {
GlLink,
ConfirmForkModal,
},
- i18n: {
- modal: {
- title: __('Enable Gitpod?'),
- content: s__(
- 'Gitpod|To use Gitpod you must first enable the feature in the integrations section of your %{linkStart}user preferences%{linkEnd}.',
- ),
- actionCancelText: __('Cancel'),
- actionPrimaryText: __('Enable Gitpod'),
- },
- },
+ i18n,
props: {
isFork: {
type: Boolean,
@@ -207,8 +213,8 @@ export default {
return {
key: KEY_WEB_IDE,
text: this.webIdeActionText,
- secondaryText: __('Quickly and easily edit multiple files in your project.'),
- tooltip: '',
+ secondaryText: this.$options.i18n.webIdeText,
+ tooltip: this.$options.i18n.webIdeTooltip,
attrs: {
'data-qa-selector': 'web_ide_button',
'data-track-action': 'click_consolidated_edit_ide',
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 14328b1f25f..b6d69faebb5 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -1,4 +1,4 @@
-import { __, sprintf } from '~/locale';
+import { __, n__, sprintf } from '~/locale';
import { IssuableType, WorkspaceType } from '~/issues/constants';
const INTERVALS = {
@@ -15,51 +15,62 @@ export const ISO_SHORT_FORMAT = 'yyyy-mm-dd';
export const DATE_FORMATS = [SHORT_DATE_FORMAT, ISO_SHORT_FORMAT];
+const getTimeLabel = (days) => n__('1 day', '%d days', days);
+
+/* eslint-disable @gitlab/require-i18n-strings */
export const timeRanges = [
{
- label: __('30 minutes'),
+ label: n__('1 minute', '%d minutes', 30),
+ shortcut: '30_minutes',
duration: { seconds: 60 * 30 },
name: 'thirtyMinutes',
interval: INTERVALS.minute,
},
{
- label: __('3 hours'),
+ label: n__('1 hour', '%d hours', 3),
+ shortcut: '3_hours',
duration: { seconds: 60 * 60 * 3 },
name: 'threeHours',
interval: INTERVALS.hour,
},
{
- label: __('8 hours'),
+ label: n__('1 hour', '%d hours', 8),
+ shortcut: '8_hours',
duration: { seconds: 60 * 60 * 8 },
name: 'eightHours',
default: true,
interval: INTERVALS.hour,
},
{
- label: __('1 day'),
+ label: getTimeLabel(1),
+ shortcut: '1_day',
duration: { seconds: 60 * 60 * 24 * 1 },
name: 'oneDay',
interval: INTERVALS.hour,
},
{
- label: __('3 days'),
+ label: getTimeLabel(3),
+ shortcut: '3_days',
duration: { seconds: 60 * 60 * 24 * 3 },
name: 'threeDays',
interval: INTERVALS.hour,
},
{
- label: __('7 days'),
+ label: getTimeLabel(7),
+ shortcut: '7_days',
duration: { seconds: 60 * 60 * 24 * 7 * 1 },
name: 'oneWeek',
interval: INTERVALS.day,
},
{
- label: __('30 days'),
+ label: getTimeLabel(30),
+ shortcut: '30_days',
duration: { seconds: 60 * 60 * 24 * 30 },
name: 'oneMonth',
interval: INTERVALS.day,
},
];
+/* eslint-enable @gitlab/require-i18n-strings */
export const defaultTimeRange = timeRanges.find((tr) => tr.default);
export const getTimeWindow = (timeWindowName) =>
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 b616b390032..38083327593 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
@@ -7,6 +7,7 @@ import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/dat
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -17,6 +18,7 @@ export default {
GlFormCheckbox,
GlSprintf,
IssuableAssignees,
+ WorkItemTypeIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -50,6 +52,11 @@ export default {
required: false,
default: false,
},
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
issuableId() {
@@ -118,8 +125,8 @@ export default {
return sprintf(
n__(
- '%{completedCount} of %{count} task completed',
- '%{completedCount} of %{count} tasks completed',
+ '%{completedCount} of %{count} checklist item completed',
+ '%{completedCount} of %{count} checklist items completed',
count,
),
{ completedCount, count },
@@ -225,6 +232,7 @@ export default {
</span>
</div>
<div class="issuable-info">
+ <work-item-type-icon v-if="showWorkItemTypeIcon" :work-item-type="issuable.type" />
<slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference">
{{ reference }}
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 189bbb56432..bc10f84b819 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
@@ -182,6 +182,11 @@ export default {
required: false,
default: false,
},
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -344,6 +349,7 @@ export default {
:label-filter-param="labelFilterParam"
:show-checkbox="showBulkEditSidebar"
:checked="issuableChecked(issuable)"
+ :show-work-item-type-icon="showWorkItemTypeIcon"
@checked-input="handleIssuableCheckedInput(issuable, $event)"
>
<template #reference>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
index 507f333a34e..f6b864dfde0 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -46,13 +46,6 @@ export const AvailableSortOptions = [
},
];
-export const IssuableTypes = {
- Issue: 'ISSUE',
- Incident: 'INCIDENT',
- TestCase: 'TEST_CASE',
- Requirement: 'REQUIREMENT',
-};
-
export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_SKELETON_COUNT = 5;
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index cdc5903b934..1f23fdfaafd 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -12,6 +12,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isExternal } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import { IssuableStates } from '~/vue_shared/issuable/list/constants';
export default {
@@ -22,6 +23,7 @@ export default {
GlAvatarLink,
GlAvatarLabeled,
TimeAgoTooltip,
+ WorkItemTypeIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -65,6 +67,16 @@ export default {
required: false,
default: null,
},
+ issuableType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
badgeVariant() {
@@ -81,8 +93,8 @@ export default {
return sprintf(
n__(
- '%{completedCount} of %{count} task completed',
- '%{completedCount} of %{count} tasks completed',
+ '%{completedCount} of %{count} checklist item completed',
+ '%{completedCount} of %{count} checklist items completed',
count,
),
{ completedCount, count },
@@ -122,7 +134,13 @@ export default {
</div>
</div>
<span>
- {{ __('Created') }}
+ <template v-if="showWorkItemTypeIcon">
+ <work-item-type-icon :work-item-type="issuableType" show-text />
+ {{ __('created') }}
+ </template>
+ <template v-else>
+ {{ __('Created') }}
+ </template>
<time-ago-tooltip data-testid="startTimeItem" :time="createdAt" />
{{ __('by') }}
</span>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
index 7ed93c042f8..2bc57ecba55 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
@@ -87,6 +87,11 @@ export default {
required: false,
default: 0,
},
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
methods: {
handleKeydownTitle(e, issuableMeta) {
@@ -110,6 +115,8 @@ export default {
:created-at="issuable.createdAt"
:author="issuable.author"
:task-completion-status="taskCompletionStatus"
+ :issuable-type="issuable.type"
+ :show-work-item-type-icon="showWorkItemTypeIcon"
>
<template #status-badge>
<slot name="status-badge"></slot>
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
index 8e9b8ef3e6f..232749a2d01 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -125,7 +125,7 @@ export default {
<h4>{{ activePanel.title }}</h4>
<p v-if="hasTextDetails">{{ details }}</p>
- <component :is="details" v-else />
+ <component :is="details" v-else v-bind="activePanel.detailProps || {}" />
<slot name="extra-description"></slot>
</div>
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index 0c55cc2f8a6..9e5361e8302 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -79,7 +79,7 @@ export default {
@bottomReached="bottomReached"
>
<template #items>
- <feature v-for="feature in features" :key="feature.title" :feature="feature" />
+ <feature v-for="feature in features" :key="feature.name" :feature="feature" />
</template>
</gl-infinite-scroll>
</template>
diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue
index 90f6230ef72..c954a86e593 100644
--- a/app/assets/javascripts/whats_new/components/feature.vue
+++ b/app/assets/javascripts/whats_new/components/feature.vue
@@ -30,7 +30,6 @@ export default {
return dateInWords(date);
},
},
- safeHtmlConfig: { ADD_ATTR: ['target'] },
};
</script>
@@ -38,35 +37,35 @@ export default {
<div class="gl-py-6 gl-px-6 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
<gl-link
v-if="feature.image_url"
- :href="feature.url"
+ :href="feature.documentation_link"
target="_blank"
class="gl-display-block"
data-testid="whats-new-image-link"
data-track-action="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
+ :data-track-label="feature.name"
+ :data-track-property="feature.documentation_link"
>
<div
class="whats-new-item-image gl-bg-size-cover"
:style="`background-image: url(${feature.image_url});`"
>
- <span class="gl-sr-only">{{ feature.title }}</span>
+ <span class="gl-sr-only">{{ feature.name }}</span>
</div>
</gl-link>
<gl-link
- :href="feature.url"
+ :href="feature.documentation_link"
target="_blank"
class="whats-new-item-title-link gl-display-block gl-mt-4 gl-mb-1"
data-track-action="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
+ :data-track-label="feature.name"
+ :data-track-property="feature.documentation_link"
>
- <h5 class="gl-font-lg gl-my-0" data-test-id="feature-title">{{ feature.title }}</h5>
+ <h5 class="gl-font-lg gl-my-0" data-test-id="feature-name">{{ feature.name }}</h5>
</gl-link>
<div v-if="releaseDate" class="gl-mb-3" data-testid="release-date">{{ releaseDate }}</div>
- <div v-if="feature.packages" class="gl-mb-3">
+ <div v-if="feature.available_in" class="gl-mb-3">
<gl-badge
- v-for="packageName in feature.packages"
+ v-for="packageName in feature.available_in"
:key="packageName"
size="md"
variant="tier"
@@ -77,15 +76,15 @@ export default {
</gl-badge>
</div>
<div
- v-safe-html:[$options.safeHtmlConfig]="feature.body"
+ v-safe-html:[$options.safeHtmlConfig]="feature.description"
class="gl-pt-3 gl-line-height-20"
></div>
<gl-button
- :href="feature.url"
+ :href="feature.documentation_link"
target="_blank"
data-track-action="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
+ :data-track-label="feature.name"
+ :data-track-property="feature.documentation_link"
>
{{ __('Learn more') }} <gl-icon name="arrow-right" />
</gl-button>
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
index 2dc8e3a1101..2a0913e380a 100644
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ b/app/assets/javascripts/work_items/components/item_state.vue
@@ -26,7 +26,7 @@ export default {
type: String,
required: true,
},
- loading: {
+ disabled: {
type: Boolean,
required: false,
default: false,
@@ -61,15 +61,17 @@ export default {
:id="$options.labelId"
:value="state"
:options="$options.states"
- :disabled="loading"
- class="gl-w-auto hide-select-decoration"
+ :disabled="disabled"
+ class="gl-w-auto hide-select-decoration gl-pl-3"
+ :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }"
@change="setState"
/>
</gl-form-group>
</template>
<style>
-.hide-select-decoration:not(:focus, :hover) {
+.hide-select-decoration:not(:focus, :hover),
+.hide-select-decoration:disabled {
background-image: none;
box-shadow: none;
}
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 1cdc9c28f05..551ebbadb21 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -36,7 +36,7 @@ export default {
<template>
<h2
class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full"
- :class="{ 'gl-cursor-not-allowed': disabled }"
+ :class="{ 'gl-cursor-text': disabled }"
aria-labelledby="item-title"
>
<div
@@ -46,7 +46,8 @@ export default {
:aria-label="__('Title')"
:data-placeholder="placeholder"
:contenteditable="!disabled"
- class="gl-pseudo-placeholder gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base"
+ class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base"
+ :class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }"
@blur="handleBlur"
@keyup="handleInput"
@keydown.enter.exact="handleSubmit"
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 77002eeaf55..2753c3fa388 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -1,15 +1,24 @@
<script>
-import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlModal,
+ GlModalDirective,
+} from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
export default {
i18n: {
deleteTask: s__('WorkItem|Delete task'),
+ enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
+ disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
},
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
GlModal,
},
directives: {
@@ -22,14 +31,33 @@ export default {
required: false,
default: null,
},
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
canDelete: {
type: Boolean,
required: false,
default: false,
},
+ isConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isParentConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- emits: ['deleteWorkItem'],
+ emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
methods: {
+ handleToggleWorkItemConfidentiality() {
+ this.track('click_toggle_work_item_confidentiality');
+ this.$emit('toggleWorkItemConfidentiality', !this.isConfidential);
+ },
handleDeleteWorkItem() {
this.track('click_delete_work_item');
this.$emit('deleteWorkItem');
@@ -44,7 +72,7 @@ export default {
</script>
<template>
- <div v-if="canDelete">
+ <div>
<gl-dropdown
icon="ellipsis_v"
text-sr-only
@@ -53,9 +81,24 @@ export default {
no-caret
right
>
- <gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{
- $options.i18n.deleteTask
- }}</gl-dropdown-item>
+ <template v-if="canUpdate && !isParentConfidential">
+ <gl-dropdown-item
+ data-testid="confidentiality-toggle-action"
+ @click="handleToggleWorkItemConfidentiality"
+ >{{
+ isConfidential
+ ? $options.i18n.disableTaskConfidentiality
+ : $options.i18n.enableTaskConfidentiality
+ }}</gl-dropdown-item
+ >
+ <gl-dropdown-divider v-if="canDelete" />
+ </template>
+ <gl-dropdown-item
+ v-if="canDelete"
+ v-gl-modal="'work-item-confirm-delete'"
+ data-testid="delete-action"
+ >{{ $options.i18n.deleteTask }}</gl-dropdown-item
+ >
</gl-dropdown>
<gl-modal
modal-id="work-item-confirm-delete"
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index 9ff424aa20f..7342f215b5e 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -18,11 +18,17 @@ import { n__, s__ } from '~/locale';
import Tracking from '~/tracking';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
function isTokenSelectorElement(el) {
- return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item');
+ return (
+ el?.classList.contains('gl-token-close') ||
+ el?.classList.contains('dropdown-item') ||
+ // TODO: replace this logic when we have a class added to clear-all button in GitLab UI
+ (el?.classList.contains('gl-button') &&
+ el?.closest('.form-control')?.classList.contains('gl-token-selector'))
+ );
}
function addClass(el) {
@@ -69,6 +75,11 @@ export default {
required: false,
default: false,
},
+ canInviteMembers: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -130,7 +141,7 @@ export default {
if (this.searchUsers.some((user) => user.username === this.currentUser.username)) {
return this.moveCurrentUserToStart(this.searchUsers);
}
- return [this.currentUser, ...this.searchUsers];
+ return [addClass(this.currentUser), ...this.searchUsers];
}
return this.searchUsers;
},
@@ -138,16 +149,25 @@ export default {
return this.searchKey.length === 0;
},
addAssigneesText() {
+ if (!this.canUpdate) {
+ return s__('WorkItem|None');
+ }
return this.allowsMultipleAssignees
? s__('WorkItem|Add assignees')
: s__('WorkItem|Add assignee');
},
+ assigneeIds() {
+ return this.localAssignees.map(({ id }) => id);
+ },
},
watch: {
- assignees(newVal) {
- if (!this.isEditing) {
- this.localAssignees = newVal.map(addClass);
- }
+ assignees: {
+ handler(newVal) {
+ if (!this.isEditing) {
+ this.localAssignees = newVal.map(addClass);
+ }
+ },
+ deep: true,
},
},
created() {
@@ -169,19 +189,33 @@ export default {
handleBlur(e) {
if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return;
this.isEditing = false;
- this.setAssignees(this.localAssignees);
+ this.setAssignees(this.assigneeIds);
},
- setAssignees(assignees) {
- this.$apollo.mutate({
- mutation: localUpdateWorkItemMutation,
- variables: {
- input: {
- id: this.workItemId,
- assignees,
+ async setAssignees(assigneeIds) {
+ try {
+ const {
+ data: {
+ workItemUpdate: { errors },
},
- },
- });
- this.track('updated_assignees');
+ } = await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ assigneesWidget: {
+ assigneeIds,
+ },
+ },
+ },
+ });
+ if (errors.length > 0) {
+ this.throwUpdateError();
+ return;
+ }
+ this.track('updated_assignees');
+ } catch {
+ this.throwUpdateError();
+ }
},
handleFocus() {
this.isEditing = true;
@@ -205,13 +239,25 @@ export default {
},
moveCurrentUserToStart(users = []) {
if (this.currentUser) {
- return [this.currentUser, ...users.filter((user) => user.id !== this.currentUser.id)];
+ return [
+ addClass(this.currentUser),
+ ...users.filter((user) => user.id !== this.currentUser.id),
+ ];
}
return users;
},
closeDropdown() {
this.$refs.tokenSelector.closeDropdown();
},
+ assignToCurrentUser() {
+ this.setAssignees([this.currentUser.id]);
+ this.localAssignees = [addClass(this.currentUser)];
+ },
+ throwUpdateError() {
+ this.$emit('error', i18n.updateError);
+ // If mutation is rejected, we're rolling back to initial state
+ this.localAssignees = this.assignees.map(addClass);
+ },
},
};
</script>
@@ -227,11 +273,12 @@ export default {
ref="tokenSelector"
:selected-tokens="localAssignees"
:container-class="containerClass"
- class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0!"
:class="{ 'gl-hover-border-gray-200': canUpdate }"
:dropdown-items="dropdownItems"
:loading="isLoadingUsers"
:view-only="!canUpdate"
+ :allow-clear-all="isEditing"
+ class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2"
@input="handleAssigneesInput"
@text-input="debouncedSearchKeyUpdate"
@focus="handleFocus"
@@ -241,7 +288,7 @@ export default {
>
<template #empty-placeholder>
<div
- class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-top-2"
+ class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-pl-2 gl-top-2"
data-testid="empty-state"
>
<gl-icon name="profile" />
@@ -251,7 +298,7 @@ export default {
size="small"
class="assign-myself"
data-testid="assign-self"
- @click.stop="setAssignees([currentUser])"
+ @click.stop="assignToCurrentUser"
>{{ __('Assign myself') }}</gl-button
>
</div>
@@ -262,7 +309,7 @@ export default {
:title="token.name"
:data-user-id="getUserId(token.id)"
data-placement="top"
- class="gl-text-decoration-none! gl-text-body! gl-display-flex gl-md-display-inline-flex! gl-align-items-center js-user-link"
+ class="gl-ml-n2 gl-text-decoration-none! gl-text-body! gl-display-flex gl-md-display-inline-flex! gl-align-items-center js-user-link"
>
<gl-avatar :size="24" :src="token.avatarUrl" />
<span class="gl-pl-2">{{ token.name }}</span>
@@ -279,7 +326,7 @@ export default {
<rect width="280" height="20" x="10" y="130" rx="4" />
</gl-skeleton-loader>
</template>
- <template #dropdown-footer>
+ <template v-if="canInviteMembers" #dropdown-footer>
<gl-dropdown-divider />
<gl-dropdown-item @click="closeDropdown">
<invite-members-trigger
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 90e3cd45cb4..cf59789ce2d 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -172,7 +172,7 @@ export default {
<template>
<gl-form-group
v-if="isEditing"
- class="gl-my-5"
+ class="gl-my-5 gl-border-t gl-pt-6"
:label="__('Description')"
label-for="work-item-description"
>
@@ -182,7 +182,7 @@ export default {
:is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="$options.markdownDocsPath"
- class="gl-p-3 bordered-box"
+ class="gl-p-3 bordered-box gl-mt-5"
>
<template #textarea>
<textarea
@@ -217,9 +217,9 @@ export default {
}}</gl-button>
</div>
</gl-form-group>
- <div v-else class="gl-mb-5">
- <div class="gl-display-flex gl-align-items-center gl-mb-5">
- <h3 class="gl-font-base gl-my-0">{{ __('Description') }}</h3>
+ <div v-else class="gl-mb-5 gl-border-t">
+ <div class="gl-display-inline-flex gl-align-items-center gl-mb-5">
+ <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
<gl-button
v-if="canEdit"
class="gl-ml-auto"
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 ad90fe88947..a5580c14a7a 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -1,7 +1,16 @@
<script>
-import { GlAlert, GlSkeletonLoader, GlIcon, GlButton } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlSkeletonLoader,
+ GlLoadingIcon,
+ GlIcon,
+ GlBadge,
+ GlButton,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import {
i18n,
WIDGET_TYPE_ASSIGNEES,
@@ -11,8 +20,12 @@ import {
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_VIEWED_STORAGE_KEY,
} from '../constants';
+
import workItemQuery from '../graphql/work_item.query.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
+
import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
@@ -24,9 +37,14 @@ import WorkItemInformation from './work_item_information.vue';
export default {
i18n,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
components: {
GlAlert,
+ GlBadge,
GlButton,
+ GlLoadingIcon,
GlSkeletonLoader,
GlIcon,
WorkItemAssignees,
@@ -38,6 +56,7 @@ export default {
WorkItemWeight,
WorkItemInformation,
LocalStorageSync,
+ WorkItemTypeIcon,
},
mixins: [glFeatureFlagMixin()],
props: {
@@ -62,6 +81,7 @@ export default {
error: undefined,
workItem: {},
showInfoBanner: true,
+ updateInProgress: false,
};
},
apollo: {
@@ -114,7 +134,7 @@ export default {
return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
},
workItemWeight() {
- return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
+ return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
},
workItemHierarchy() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
@@ -122,9 +142,15 @@ export default {
parentWorkItem() {
return this.workItemHierarchy?.parent;
},
+ parentWorkItemConfidentiality() {
+ return this.parentWorkItem?.confidential;
+ },
parentUrl() {
return `../../issues/${this.parentWorkItem?.iid}`;
},
+ workItemIconName() {
+ return this.workItem?.workItemType?.iconName;
+ },
},
beforeDestroy() {
/** make sure that if the user has not even dismissed the alert ,
@@ -135,6 +161,54 @@ export default {
dismissBanner() {
this.showInfoBanner = false;
},
+ toggleConfidentiality(confidentialStatus) {
+ this.updateInProgress = true;
+ let updateMutation = updateWorkItemMutation;
+ let inputVariables = {
+ id: this.workItemId,
+ confidential: confidentialStatus,
+ };
+
+ if (this.parentWorkItem) {
+ updateMutation = updateWorkItemTaskMutation;
+ inputVariables = {
+ id: this.parentWorkItem.id,
+ taskData: {
+ id: this.workItemId,
+ confidential: confidentialStatus,
+ },
+ };
+ }
+
+ this.$apollo
+ .mutate({
+ mutation: updateMutation,
+ variables: {
+ input: inputVariables,
+ },
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors, workItem, task },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+
+ this.$emit('workItemUpdated', {
+ confidential: workItem?.confidential || task?.confidential,
+ });
+ },
+ )
+ .catch((error) => {
+ this.error = error.message;
+ })
+ .finally(() => {
+ this.updateInProgress = false;
+ });
+ },
},
WORK_ITEM_VIEWED_STORAGE_KEY,
};
@@ -142,7 +216,7 @@ export default {
<template>
<section class="gl-pt-5">
- <gl-alert v-if="error" variant="danger" @dismiss="error = undefined">
+ <gl-alert v-if="error" class="gl-mb-3" variant="danger" @dismiss="error = undefined">
{{ error }}
</gl-alert>
@@ -153,33 +227,61 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <div class="gl-display-flex gl-align-items-center">
+ <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body">
<ul
v-if="parentWorkItem"
- class="list-unstyled gl-display-flex gl-mr-auto"
+ class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0"
data-testid="work-item-parent"
>
- <li class="gl-ml-n4">
- <gl-button icon="issues" category="tertiary" :href="parentUrl">{{
- parentWorkItem.title
- }}</gl-button>
- <gl-icon name="chevron-right" :size="16" />
+ <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden">
+ <gl-button
+ v-gl-tooltip.hover
+ class="gl-text-truncate gl-max-w-full"
+ icon="issues"
+ category="tertiary"
+ :href="parentUrl"
+ :title="parentWorkItem.title"
+ >{{ parentWorkItem.title }}</gl-button
+ >
+ <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
</li>
- <li class="gl-px-4 gl-py-3 gl-line-height-0">
- <gl-icon name="task-done" />
+ <li
+ class="gl-px-4 gl-py-3 gl-line-height-0 gl-display-flex gl-align-items-center gl-overflow-hidden gl-flex-shrink-0"
+ >
+ <work-item-type-icon
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType && workItemType.toUpperCase()"
+ />
{{ workItemType }}
</li>
</ul>
- <span
+ <work-item-type-icon
v-else
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType && workItemType.toUpperCase()"
+ show-text
class="gl-font-weight-bold gl-text-secondary gl-mr-auto"
data-testid="work-item-type"
- >{{ workItemType }}</span
+ />
+ <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
+ <gl-badge
+ v-if="workItem.confidential"
+ v-gl-tooltip.bottom
+ :title="$options.i18n.confidentialTooltip"
+ variant="warning"
+ icon="eye-slash"
+ class="gl-mr-3 gl-cursor-help"
+ >{{ __('Confidential') }}</gl-badge
>
<work-item-actions
+ v-if="canUpdate || canDelete"
:work-item-id="workItem.id"
:can-delete="canDelete"
+ :can-update="canUpdate"
+ :is-confidential="workItem.confidential"
+ :is-parent-confidential="parentWorkItemConfidentiality"
@deleteWorkItem="$emit('deleteWorkItem')"
+ @toggleWorkItemConfidentiality="toggleConfidentiality"
@error="error = $event"
/>
<gl-button
@@ -206,11 +308,13 @@ export default {
:work-item-title="workItem.title"
:work-item-type="workItemType"
:work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
@error="error = $event"
/>
<work-item-state
:work-item="workItem"
:work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
@error="error = $event"
/>
<template v-if="workItemsMvc2Enabled">
@@ -221,6 +325,7 @@ export default {
:assignees="workItemAssignees.assignees.nodes"
:allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
:work-item-type="workItemType"
+ :can-invite-members="workItemAssignees.canInviteMembers"
@error="error = $event"
/>
<work-item-labels
@@ -229,15 +334,16 @@ export default {
:can-update="canUpdate"
@error="error = $event"
/>
- <work-item-weight
- v-if="workItemWeight"
- class="gl-mb-5"
- :can-update="canUpdate"
- :weight="workItemWeight.weight"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- />
</template>
+ <work-item-weight
+ v-if="workItemWeight"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :weight="workItemWeight.weight"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ @error="error = $event"
+ />
<work-item-description
v-if="hasDescriptionWidget"
:work-item-id="workItem.id"
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index df7c6cab7ef..39a662a6c54 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -2,9 +2,13 @@
import { GlAlert, GlModal } from '@gitlab/ui';
import { s__ } from '~/locale';
import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql';
+import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
import WorkItemDetail from './work_item_detail.vue';
export default {
+ i18n: {
+ errorMessage: s__('WorkItem|Something went wrong when deleting the task. Please try again.'),
+ },
components: {
GlAlert,
GlModal,
@@ -45,6 +49,13 @@ export default {
},
methods: {
deleteWorkItem() {
+ if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) {
+ this.deleteWorkItemWithTaskData();
+ } else {
+ this.deleteWorkItemWithoutTaskData();
+ }
+ },
+ deleteWorkItemWithTaskData() {
this.$apollo
.mutate({
mutation: deleteWorkItemFromTaskMutation,
@@ -70,17 +81,33 @@ export default {
},
}) => {
if (errors?.length) {
- throw new Error(errors[0].message);
+ throw new Error(errors[0]);
}
this.$emit('workItemDeleted', descriptionHtml);
- this.$refs.modal.hide();
+ this.hide();
},
)
- .catch((e) => {
- this.error =
- e.message ||
- s__('WorkItem|Something went wrong when deleting the task. Please try again.');
+ .catch((error) => {
+ this.setErrorMessage(error.message);
+ });
+ },
+ deleteWorkItemWithoutTaskData() {
+ this.$apollo
+ .mutate({
+ mutation: deleteWorkItemMutation,
+ variables: { input: { id: this.workItemId } },
+ })
+ .then(({ data }) => {
+ if (data.workItemDelete.errors?.length) {
+ throw new Error(data.workItemDelete.errors[0]);
+ }
+
+ this.$emit('workItemDeleted', this.workItemId);
+ this.hide();
+ })
+ .catch((error) => {
+ this.setErrorMessage(error.message);
});
},
closeModal() {
@@ -91,7 +118,7 @@ export default {
this.$refs.modal.hide();
},
setErrorMessage(message) {
- this.error = message;
+ this.error = message || this.$options.i18n.errorMessage;
},
show() {
this.$refs.modal.show();
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
index 78ed67998d7..e73488bbd70 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -202,7 +202,8 @@ export default {
:dropdown-items="searchLabels"
:loading="isLoading"
:view-only="!canUpdate"
- class="gl-flex-grow-1 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!"
+ class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!"
+ :class="{ 'gl-hover-border-gray-200': canUpdate }"
@input="focusTokenSelector"
@text-input="debouncedSearchKeyUpdate"
@focus="handleFocus"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index 176f84f6c1a..86f03583ea3 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -1,16 +1,10 @@
import Vue from 'vue';
-import VueApollo from 'vue-apollo';
import { GlToast } from '@gitlab/ui';
-import createDefaultClient from '~/lib/graphql';
+import { createApolloProvider } from '../../graphql/provider';
import WorkItemLinks from './work_item_links.vue';
-Vue.use(VueApollo);
Vue.use(GlToast);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
export default function initWorkItemLinks() {
if (!window.gon.features.workItemsHierarchy) {
return;
@@ -22,16 +16,20 @@ export default function initWorkItemLinks() {
return;
}
+ const { projectPath, wiHasIssueWeightsFeature } = workItemLinksRoot.dataset;
+
// eslint-disable-next-line no-new
new Vue({
el: workItemLinksRoot,
name: 'WorkItemLinksRoot',
- apolloProvider,
+ apolloProvider: createApolloProvider(),
components: {
workItemLinks: WorkItemLinks,
},
provide: {
- projectPath: workItemLinksRoot.dataset.projectPath,
+ projectPath,
+ fullPath: projectPath,
+ hasIssueWeightsFeature: wiHasIssueWeightsFeature,
},
render: (createElement) =>
createElement('work-item-links', {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index 89f086cfca5..534ebabee08 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -1,8 +1,14 @@
<script>
-import { GlButton, GlBadge, GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlAlert, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { produce } from 'immer';
import { s__ } from '~/locale';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { isMetaKey } from '~/lib/utils/common_utils';
+import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
+import SidebarEventHub from '~/sidebar/event_hub';
+
import {
STATE_OPEN,
WIDGET_ICONS,
@@ -10,18 +16,26 @@ import {
WIDGET_TYPE_HIERARCHY,
} from '../../constants';
import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
+import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
+import workItemQuery from '../../graphql/work_item.query.graphql';
+import WorkItemDetailModal from '../work_item_detail_modal.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
import WorkItemLinksMenu from './work_item_links_menu.vue';
export default {
components: {
GlButton,
- GlBadge,
GlIcon,
+ GlAlert,
GlLoadingIcon,
WorkItemLinksForm,
WorkItemLinksMenu,
+ WorkItemDetailModal,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
+ inject: ['projectPath'],
props: {
workItemId: {
type: String,
@@ -35,32 +49,44 @@ export default {
},
},
apollo: {
- children: {
+ workItem: {
query: getWorkItemLinksQuery,
variables() {
return {
id: this.issuableGid,
};
},
- update(data) {
- return (
- data.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
- .nodes ?? []
- );
- },
skip() {
return !this.issuableId;
},
+ error(e) {
+ this.error = e.message || this.$options.i18n.fetchError;
+ },
},
},
data() {
return {
isShownAddForm: false,
isOpen: true,
- children: [],
+ activeChildId: null,
+ activeToast: null,
+ prefetchedWorkItem: null,
+ error: undefined,
};
},
computed: {
+ children() {
+ return (
+ this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
+ .nodes ?? []
+ );
+ },
+ canUpdate() {
+ return this.workItem?.userPermissions.updateWorkItem || false;
+ },
+ confidential() {
+ return this.workItem?.confidential || false;
+ },
// Only used for children for now but should be extended later to support parents and siblings
isChildrenEmpty() {
return this.children?.length === 0;
@@ -77,28 +103,149 @@ export default {
return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null;
},
isLoading() {
- return this.$apollo.queries.children.loading;
+ return this.$apollo.queries.workItem.loading;
},
childrenIds() {
return this.children.map((c) => c.id);
},
+ childrenCountLabel() {
+ return this.isLoading && this.children.length === 0 ? '...' : this.children.length;
+ },
+ },
+ mounted() {
+ SidebarEventHub.$on('confidentialityUpdated', this.refetchWorkItems);
+ },
+ destroyed() {
+ SidebarEventHub.$off('confidentialityUpdated', this.refetchWorkItems);
},
methods: {
- badgeVariant(state) {
- return state === STATE_OPEN ? 'success' : 'info';
+ refetchWorkItems() {
+ this.$apollo.queries.workItem.refetch();
+ },
+ iconClass(state) {
+ return state === STATE_OPEN ? 'gl-text-green-500' : 'gl-text-blue-500';
+ },
+ iconName(state) {
+ return state === STATE_OPEN ? 'issue-open-m' : 'issue-close';
},
toggle() {
this.isOpen = !this.isOpen;
},
- toggleAddForm() {
- this.isShownAddForm = !this.isShownAddForm;
+ showAddForm() {
+ this.isOpen = true;
+ this.isShownAddForm = true;
+ this.$nextTick(() => {
+ this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus();
+ });
+ },
+ hideAddForm() {
+ this.isShownAddForm = false;
},
addChild(child) {
- this.children = [child, ...this.children];
+ const { defaultClient: client } = this.$apollo.provider.clients;
+ this.toggleChildFromCache(child, child.id, client);
+ },
+ openChild(childItemId, e) {
+ if (isMetaKey(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.activeChildId = childItemId;
+ this.$refs.modal.show();
+ this.updateWorkItemIdUrlQuery(childItemId);
+ },
+ closeModal() {
+ this.activeChildId = null;
+ this.updateWorkItemIdUrlQuery(undefined);
+ },
+ handleWorkItemDeleted(childId) {
+ const { defaultClient: client } = this.$apollo.provider.clients;
+ this.toggleChildFromCache(null, childId, client);
+ this.activeToast = this.$toast.show(s__('WorkItem|Task deleted'));
+ },
+ updateWorkItemIdUrlQuery(childItemId) {
+ updateHistory({
+ url: setUrlParams({ work_item_id: getIdFromGraphQLId(childItemId) }),
+ replace: true,
+ });
+ },
+ childPath(childItemId) {
+ return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`;
+ },
+ toggleChildFromCache(workItem, childId, store) {
+ const sourceData = store.readQuery({
+ query: getWorkItemLinksQuery,
+ variables: { id: this.issuableGid },
+ });
+
+ const newData = produce(sourceData, (draftState) => {
+ const widgetHierarchy = draftState.workItem.widgets.find(
+ (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
+ );
+
+ const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId);
+
+ if (index >= 0) {
+ widgetHierarchy.children.nodes.splice(index, 1);
+ } else {
+ widgetHierarchy.children.nodes.push(workItem);
+ }
+ });
+
+ store.writeQuery({
+ query: getWorkItemLinksQuery,
+ variables: { id: this.issuableGid },
+ data: newData,
+ });
+ },
+ async updateWorkItem(workItem, childId, parentId) {
+ return this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: { input: { id: childId, hierarchyWidget: { parentId } } },
+ update: (store) => this.toggleChildFromCache(workItem, childId, store),
+ });
+ },
+ async undoChildRemoval(workItem, childId) {
+ const { data } = await this.updateWorkItem(workItem, childId, this.issuableGid);
+
+ if (data.workItemUpdate.errors.length === 0) {
+ this.activeToast?.hide();
+ }
+ },
+ async removeChild(childId) {
+ const { data } = await this.updateWorkItem(null, childId, null);
+
+ if (data.workItemUpdate.errors.length === 0) {
+ this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId),
+ },
+ });
+ }
+ },
+ prefetchWorkItem(id) {
+ this.prefetch = setTimeout(
+ () =>
+ this.$apollo.addSmartQuery('prefetchedWorkItem', {
+ query: workItemQuery,
+ variables: {
+ id,
+ },
+ update: (data) => data.workItem,
+ }),
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ );
+ },
+ clearPrefetching() {
+ clearTimeout(this.prefetch);
},
},
i18n: {
title: s__('WorkItem|Child items'),
+ fetchError: s__(
+ 'WorkItem|Something went wrong when fetching the items list. Please refresh this page.',
+ ),
emptyStateMessage: s__(
'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!',
),
@@ -112,21 +259,32 @@ export default {
<template>
<div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10">
<div
- class="gl-p-4 gl-display-flex gl-justify-content-space-between"
+ class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between"
:class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
>
- <h5 class="gl-m-0 gl-line-height-32 gl-flex-grow-1">{{ $options.i18n.title }}</h5>
+ <div class="gl-display-flex gl-flex-grow-1">
+ <h5 class="gl-m-0 gl-line-height-24">{{ $options.i18n.title }}</h5>
+ <span
+ class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3"
+ data-testid="children-count"
+ >
+ <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-gray-500" />
+ {{ childrenCountLabel }}
+ </span>
+ </div>
<gl-button
- v-if="!isShownAddForm"
+ v-if="canUpdate"
category="secondary"
+ size="small"
data-testid="toggle-add-form"
- @click="toggleAddForm"
+ @click="showAddForm"
>
{{ $options.i18n.addChildButtonLabel }}
</gl-button>
- <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4 gl-ml-3">
+ <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3">
<gl-button
category="tertiary"
+ size="small"
:icon="toggleIcon"
:aria-label="toggleLabel"
data-testid="toggle-links"
@@ -134,48 +292,81 @@ export default {
/>
</div>
</div>
+ <gl-alert v-if="error && !isLoading" variant="danger" @dismiss="error = undefined">
+ {{ error }}
+ </gl-alert>
<div
v-if="isOpen"
- class="gl-bg-gray-10 gl-p-4 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ :class="{ 'gl-p-5 gl-pb-3': !error }"
data-testid="links-body"
>
<gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" />
<template v-else>
- <div v-if="isChildrenEmpty && !isShownAddForm" data-testid="links-empty">
- <p class="gl-my-3">
+ <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty">
+ <p class="gl-mt-3 gl-mb-4">
{{ $options.i18n.emptyStateMessage }}
</p>
</div>
<work-item-links-form
v-if="isShownAddForm"
+ ref="wiLinksForm"
data-testid="add-links-form"
:issuable-gid="issuableGid"
:children-ids="childrenIds"
- @cancel="toggleAddForm"
+ :parent-confidential="confidential"
+ @cancel="hideAddForm"
@addWorkItemChild="addChild"
/>
<div
v-for="child in children"
:key="child.id"
- class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
+ class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
data-testid="links-child"
>
- <div>
- <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" />
- <span class="gl-word-break-all">{{ child.title }}</span>
+ <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1">
+ <gl-icon
+ :name="iconName(child.state)"
+ class="gl-mr-3"
+ :class="iconClass(child.state)"
+ />
+ <gl-icon
+ v-if="child.confidential"
+ v-gl-tooltip.top
+ name="eye-slash"
+ class="gl-mr-2 gl-text-orange-500"
+ data-testid="confidential-icon"
+ :title="__('Confidential')"
+ />
+ <gl-button
+ :href="childPath(child.id)"
+ category="tertiary"
+ variant="link"
+ class="gl-text-truncate gl-max-w-80 gl-text-black-normal!"
+ @click="openChild(child.id, $event)"
+ @mouseover="prefetchWorkItem(child.id)"
+ @mouseout="clearPrefetching"
+ >
+ {{ child.title }}
+ </gl-button>
</div>
- <div
- class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0 gl-display-inline-flex gl-align-items-center"
- >
- <gl-badge :variant="badgeVariant(child.state)">
- <span class="gl-sm-display-block">{{
- $options.WORK_ITEM_STATUS_TEXT[child.state]
- }}</span>
- </gl-badge>
- <work-item-links-menu :work-item-id="child.id" :parent-work-item-id="issuableGid" />
+ <div class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center">
+ <work-item-links-menu
+ v-if="canUpdate"
+ :work-item-id="child.id"
+ :parent-work-item-id="issuableGid"
+ data-testid="links-menu"
+ @removeChild="removeChild(child.id)"
+ />
</div>
</div>
+ <work-item-detail-modal
+ ref="modal"
+ :work-item-id="activeChildId"
+ @close="closeModal"
+ @workItemDeleted="handleWorkItemDeleted(activeChildId)"
+ />
</template>
</div>
</div>
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 fadba0753db..8b848995d44 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
@@ -1,9 +1,11 @@
<script>
-import { GlAlert, GlForm, GlFormCombobox, GlButton } from '@gitlab/ui';
+import { GlAlert, GlFormGroup, GlForm, GlFormCombobox, GlButton, GlFormInput } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
-import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
+import projectWorkItemTypesQuery from '~/work_items/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';
+import { TASK_TYPE_NAME } from '../../constants';
export default {
components: {
@@ -11,6 +13,8 @@ export default {
GlForm,
GlFormCombobox,
GlButton,
+ GlFormGroup,
+ GlFormInput,
},
inject: ['projectPath'],
props: {
@@ -24,24 +28,22 @@ export default {
required: false,
default: () => [],
},
+ parentConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
apollo: {
- availableWorkItems: {
- query: projectWorkItemsQuery,
+ workItemTypes: {
+ query: projectWorkItemTypesQuery,
variables() {
return {
- projectPath: this.projectPath,
- searchTerm: this.search?.title || this.search,
- types: ['TASK'],
+ fullPath: this.projectPath,
};
},
- skip() {
- return this.search.length === 0;
- },
update(data) {
- return data.workspace.workItems.edges
- .filter((wi) => !this.childrenIds.includes(wi.node.id))
- .map((wi) => wi.node);
+ return data.workspace?.workItemTypes?.nodes;
},
},
},
@@ -50,8 +52,32 @@ export default {
availableWorkItems: [],
search: '',
error: null,
+ childToCreateTitle: null,
};
},
+ computed: {
+ actionsList() {
+ return [
+ {
+ label: this.$options.i18n.createChildOptionLabel,
+ fn: () => {
+ this.childToCreateTitle = this.search?.title || this.search;
+ },
+ },
+ ];
+ },
+ addOrCreateButtonLabel() {
+ return this.childToCreateTitle
+ ? this.$options.i18n.createChildOptionLabel
+ : this.$options.i18n.addTaskButtonLabel;
+ },
+ addOrCreateMethod() {
+ return this.childToCreateTitle ? this.createChild : this.addChild;
+ },
+ taskWorkItemType() {
+ return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
+ },
+ },
methods: {
getIdFromGraphQLId,
unsetError() {
@@ -79,35 +105,78 @@ export default {
}
})
.catch(() => {
- this.error = this.$options.i18n.errorMessage;
+ this.error = this.$options.i18n.addChildErrorMessage;
})
.finally(() => {
this.search = '';
});
},
+ createChild() {
+ this.$apollo
+ .mutate({
+ mutation: createWorkItemMutation,
+ variables: {
+ input: {
+ title: this.search?.title || this.search,
+ projectPath: this.projectPath,
+ workItemTypeId: this.taskWorkItemType,
+ hierarchyWidget: {
+ parentId: this.issuableGid,
+ },
+ confidential: this.parentConfidential,
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.workItemCreate?.errors?.length) {
+ [this.error] = data.workItemCreate.errors;
+ } else {
+ this.unsetError();
+ this.$emit('addWorkItemChild', data.workItemCreate.workItem);
+ }
+ })
+ .catch(() => {
+ this.error = this.$options.i18n.createChildErrorMessage;
+ })
+ .finally(() => {
+ this.search = '';
+ this.childToCreateTitle = null;
+ });
+ },
},
i18n: {
- inputLabel: __('Children'),
- errorMessage: s__(
+ inputLabel: __('Title'),
+ addTaskButtonLabel: s__('WorkItem|Add task'),
+ addChildErrorMessage: s__(
'WorkItem|Something went wrong when trying to add a child. Please try again.',
),
+ createChildOptionLabel: s__('WorkItem|Create task'),
+ createChildErrorMessage: s__(
+ 'WorkItem|Something went wrong when trying to create a child. Please try again.',
+ ),
+ placeholder: s__('WorkItem|Add a title'),
+ fieldValidationMessage: __('Maximum of 255 characters'),
},
};
</script>
<template>
<gl-form
- class="gl-mb-3 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base"
+ class="gl-bg-white gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base"
+ @submit.prevent="createChild"
>
<gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
{{ error }}
</gl-alert>
+ <!-- Follow up issue to turn this functionality back on https://gitlab.com/gitlab-org/gitlab/-/issues/368757 -->
<gl-form-combobox
+ v-if="false"
v-model="search"
:token-list="availableWorkItems"
match-value-to-attr="title"
class="gl-mb-4"
:label-text="$options.i18n.inputLabel"
+ :action-list="actionsList"
label-sr-only
autofocus
>
@@ -117,11 +186,35 @@ export default {
<div>{{ item.title }}</div>
</div>
</template>
+ <template #action="{ item }">
+ <span class="gl-text-blue-500">{{ item.label }}</span>
+ </template>
</gl-form-combobox>
- <gl-button category="secondary" data-testid="add-child-button" @click="addChild">
- {{ s__('WorkItem|Add task') }}
+ <gl-form-group
+ :label="$options.i18n.inputLabel"
+ :description="$options.i18n.fieldValidationMessage"
+ >
+ <gl-form-input
+ ref="wiTitleInput"
+ v-model="search"
+ :placeholder="$options.i18n.placeholder"
+ maxlength="255"
+ class="gl-mb-3"
+ autofocus
+ />
+ </gl-form-group>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ size="small"
+ type="submit"
+ :disabled="search.length === 0"
+ data-testid="add-child-button"
+ class="gl-mr-2"
+ >
+ {{ $options.i18n.createChildOptionLabel }}
</gl-button>
- <gl-button category="tertiary" @click="$emit('cancel')">
+ <gl-button category="secondary" size="small" @click="$emit('cancel')">
{{ s__('WorkItem|Cancel') }}
</gl-button>
</gl-form>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
index 6deb87c5dca..1aa4a433a58 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
@@ -1,10 +1,5 @@
<script>
import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { produce } from 'immer';
-import { s__ } from '~/locale';
-import changeWorkItemParentMutation from '../../graphql/change_work_item_parent_link.mutation.graphql';
-import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
-import { WIDGET_TYPE_HIERARCHY } from '../../constants';
export default {
components: {
@@ -12,78 +7,6 @@ export default {
GlDropdown,
GlIcon,
},
- props: {
- workItemId: {
- type: String,
- required: true,
- },
- parentWorkItemId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- activeToast: null,
- };
- },
- methods: {
- toggleChildFromCache(data, store) {
- const sourceData = store.readQuery({
- query: getWorkItemLinksQuery,
- variables: { id: this.parentWorkItemId },
- });
-
- const newData = produce(sourceData, (draftState) => {
- const widgetHierarchy = draftState.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
- );
-
- const index = widgetHierarchy.children.nodes.findIndex(
- (child) => child.id === this.workItemId,
- );
-
- if (index >= 0) {
- widgetHierarchy.children.nodes.splice(index, 1);
- } else {
- widgetHierarchy.children.nodes.push(data.workItemUpdate.workItem);
- }
- });
-
- store.writeQuery({
- query: getWorkItemLinksQuery,
- variables: { id: this.parentWorkItemId },
- data: newData,
- });
- },
- async addChild(data) {
- const { data: resp } = await this.$apollo.mutate({
- mutation: changeWorkItemParentMutation,
- variables: { id: this.workItemId, parentId: this.parentWorkItemId },
- update: this.toggleChildFromCache.bind(this, data),
- });
-
- if (resp.workItemUpdate.errors.length === 0) {
- this.activeToast?.hide();
- }
- },
- async removeChild() {
- const { data } = await this.$apollo.mutate({
- mutation: changeWorkItemParentMutation,
- variables: { id: this.workItemId, parentId: null },
- update: this.toggleChildFromCache.bind(this, null),
- });
-
- if (data.workItemUpdate.errors.length === 0) {
- this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
- action: {
- text: s__('WorkItem|Undo'),
- onClick: this.addChild.bind(this, data),
- },
- });
- }
- },
- },
};
</script>
@@ -93,7 +16,7 @@ export default {
<template #button-content>
<gl-icon name="ellipsis_v" :size="14" />
</template>
- <gl-dropdown-item @click="removeChild">
+ <gl-dropdown-item @click="$emit('removeChild')">
{{ s__('WorkItem|Remove') }}
</gl-dropdown-item>
</gl-dropdown>
diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state.vue
index 87f4a8822b1..080d4025cc3 100644
--- a/app/assets/javascripts/work_items/components/work_item_state.vue
+++ b/app/assets/javascripts/work_items/components/work_item_state.vue
@@ -27,6 +27,11 @@ export default {
required: false,
default: null,
},
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -102,7 +107,7 @@ export default {
<item-state
v-if="workItem.state"
:state="workItem.state"
- :loading="updateInProgress"
+ :disabled="updateInProgress || !canUpdate"
@changed="updateWorkItemState"
/>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue
index b4c13037038..cd5cc3894f6 100644
--- a/app/assets/javascripts/work_items/components/work_item_title.vue
+++ b/app/assets/javascripts/work_items/components/work_item_title.vue
@@ -31,6 +31,11 @@ export default {
required: false,
default: null,
},
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
tracking() {
@@ -84,5 +89,5 @@ export default {
</script>
<template>
- <item-title :title="workItemTitle" @title-changed="updateTitle" />
+ <item-title :title="workItemTitle" :disabled="!canUpdate" @title-changed="updateTitle" />
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
new file mode 100644
index 00000000000..fd914fa350b
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { WORK_ITEMS_TYPE_MAP } from '../constants';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showText: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ workItemIconName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ iconName() {
+ return (
+ this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue'
+ );
+ },
+ workItemTypeName() {
+ return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-icon :name="iconName" class="gl-mr-2" />
+ <span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue
index 30e2c1e56b8..b0ad7c97bb1 100644
--- a/app/assets/javascripts/work_items/components/work_item_weight.vue
+++ b/app/assets/javascripts/work_items/components/work_item_weight.vue
@@ -1,9 +1,10 @@
<script>
import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { __ } from '~/locale';
import Tracking from '~/tracking';
-import { TRACKING_CATEGORY_SHOW } from '../constants';
-import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
/* eslint-disable @gitlab/require-i18n-strings */
const allowedKeys = [
@@ -97,17 +98,36 @@ export default {
}
},
updateWeight(event) {
+ if (!this.canUpdate) return;
this.isEditing = false;
+
+ const weight = Number(event.target.value);
+ if (this.weight === weight) {
+ return;
+ }
+
this.track('updated_weight');
- this.$apollo.mutate({
- mutation: localUpdateWorkItemMutation,
- variables: {
- input: {
- id: this.workItemId,
- weight: event.target.value === '' ? null : Number(event.target.value),
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ weightWidget: {
+ weight: event.target.value === '' ? null : weight,
+ },
+ },
},
- },
- });
+ })
+ .then(({ data }) => {
+ if (data.workItemUpdate.errors.length) {
+ throw new Error(data.workItemUpdate.errors.join('\n'));
+ }
+ })
+ .catch((error) => {
+ this.$emit('error', i18n.updateError);
+ Sentry.captureException(error);
+ });
},
},
};
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 2140b418e6d..a2aea3cd327 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -8,11 +8,6 @@ export const STATE_EVENT_CLOSE = 'CLOSE';
export const TRACKING_CATEGORY_SHOW = 'workItems:show';
-export const i18n = {
- fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
- updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
-};
-
export const TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
@@ -22,13 +17,48 @@ export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner';
-export const WIDGET_TYPE_TASK_ICON = 'task-done';
+export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT';
+export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE';
+export const WORK_ITEM_TYPE_ENUM_TASK = 'TASK';
+export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE';
+export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS';
+
+export const i18n = {
+ fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
+ updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
+ confidentialTooltip: s__(
+ 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.',
+ ),
+};
export const WIDGET_ICONS = {
- TASK: 'task-done',
+ TASK: 'issue-type-task',
};
export const WORK_ITEM_STATUS_TEXT = {
CLOSED: s__('WorkItem|Closed'),
OPEN: s__('WorkItem|Open'),
};
+
+export const WORK_ITEMS_TYPE_MAP = {
+ [WORK_ITEM_TYPE_ENUM_INCIDENT]: {
+ icon: `issue-type-incident`,
+ name: s__('WorkItem|Incident'),
+ },
+ [WORK_ITEM_TYPE_ENUM_ISSUE]: {
+ icon: `issue-type-issue`,
+ name: s__('WorkItem|Issue'),
+ },
+ [WORK_ITEM_TYPE_ENUM_TASK]: {
+ icon: `issue-type-task`,
+ name: s__('WorkItem|Task'),
+ },
+ [WORK_ITEM_TYPE_ENUM_TEST_CASE]: {
+ icon: `issue-type-test-case`,
+ name: s__('WorkItem|Test case'),
+ },
+ [WORK_ITEM_TYPE_ENUM_REQUIREMENTS]: {
+ icon: `issue-type-requirements`,
+ name: s__('WorkItem|Requirements'),
+ },
+};
diff --git a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql
deleted file mode 100644
index dc5286174d8..00000000000
--- a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-mutation changeWorkItemParentLink($id: WorkItemID!, $parentId: WorkItemID) {
- workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) {
- workItem {
- id
- workItemType {
- id
- }
- title
- state
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
index 7f9aaf43068..4cc23fa0071 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
@@ -1,9 +1,10 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation createWorkItem($input: WorkItemCreateInput!) {
workItemCreate(input: $input) {
workItem {
...WorkItem
}
+ errors
}
}
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
index ccfe62cc585..1f98cd4fa2b 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) {
workItemCreateFromTask(input: $input) {
diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
index 43c92cf89ec..790b8e60b6a 100644
--- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) {
localUpdateWorkItem(input: $input) @client {
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 8788ad21e7b..b70c06fddea 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -2,7 +2,7 @@ import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_WEIGHT } from '../constants';
+import { WIDGET_TYPE_LABELS } from '../constants';
import typeDefs from './typedefs.graphql';
import workItemQuery from './work_item.query.graphql';
@@ -10,7 +10,7 @@ export const temporaryConfig = {
typeDefs,
cacheConfig: {
possibleTypes: {
- LocalWorkItemWidget: ['LocalWorkItemLabels', 'LocalWorkItemWeight'],
+ LocalWorkItemWidget: ['LocalWorkItemLabels'],
},
typePolicies: {
WorkItem: {
@@ -25,15 +25,15 @@ export const temporaryConfig = {
allowScopedLabels: true,
nodes: [],
},
- {
- __typename: 'LocalWorkItemWeight',
- type: 'WEIGHT',
- weight: null,
- },
]
);
},
},
+ widgets: {
+ merge(_, incoming) {
+ return incoming;
+ },
+ },
},
},
},
@@ -49,20 +49,6 @@ export const resolvers = {
});
const data = produce(sourceData, (draftData) => {
- if (input.assignees) {
- const assigneesWidget = draftData.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_ASSIGNEES,
- );
- assigneesWidget.assignees.nodes = [...input.assignees];
- }
-
- if (input.weight != null) {
- const weightWidget = draftData.workItem.mockWidgets.find(
- (widget) => widget.type === WIDGET_TYPE_WEIGHT,
- );
- weightWidget.weight = input.weight;
- }
-
if (input.labels) {
const labelsWidget = draftData.workItem.mockWidgets.find(
(widget) => widget.type === WIDGET_TYPE_LABELS,
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index 48228b15a53..36ffba8a540 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -1,7 +1,6 @@
enum LocalWidgetType {
ASSIGNEES
LABELS
- WEIGHT
}
interface LocalWorkItemWidget {
@@ -19,20 +18,29 @@ type LocalWorkItemLabels implements LocalWorkItemWidget {
nodes: [Label!]
}
-type LocalWorkItemWeight implements LocalWorkItemWidget {
- type: LocalWidgetType!
- weight: Int
-}
-
extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
+input LocalUserInput {
+ id: ID!
+ name: String
+ username: String
+ webUrl: String
+ avatarUrl: String
+}
+
+input LocalLabelInput {
+ id: ID!
+ title: String!
+ color: String
+ description: String
+}
+
input LocalUpdateWorkItemInput {
id: WorkItemID!
- assignees: [UserCore!]
- labels: [Label]
- weight: Int
+ assignees: [LocalUserInput!]
+ labels: [LocalLabelInput]
}
type LocalWorkItemPayload {
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
index 25eb8099251..0a887fcfc00 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItemUpdate(input: $input) {
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
index ad861a60d15..fad5a9fa5bc 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) {
workItemUpdate: workItemUpdateTask(input: $input) {
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql
index 148b340b439..6a94c96b347 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation workItemUpdateWidgets($input: WorkItemUpdateWidgetsInput!) {
workItemUpdateWidgets(input: $input) {
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 5f64eda96aa..e8ef27ec778 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -5,9 +5,11 @@ fragment WorkItem on WorkItem {
title
state
description
+ confidential
workItemType {
id
name
+ iconName
}
userPermissions {
deleteWorkItem
@@ -22,6 +24,7 @@ fragment WorkItem on WorkItem {
... on WorkItemWidgetAssignees {
type
allowsMultipleAssignees
+ canInviteMembers
assignees {
nodes {
...User
@@ -34,12 +37,11 @@ fragment WorkItem on WorkItem {
id
iid
title
+ confidential
}
children {
- edges {
- node {
- id
- }
+ nodes {
+ id
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
index 61cb8802187..a9f7b714551 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
query workItem($id: WorkItemID!) {
workItem(id: $id) {
@@ -12,10 +12,6 @@ query workItem($id: WorkItemID!) {
...Label
}
}
- ... on LocalWorkItemWeight {
- type
- weight
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
index c2496f53cc8..df62ca1c143 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
@@ -5,6 +5,11 @@ query workItemQuery($id: WorkItemID!) {
id
}
title
+ userPermissions {
+ deleteWorkItem
+ updateWorkItem
+ }
+ confidential
widgets {
type
... on WorkItemWidgetHierarchy {
@@ -15,6 +20,7 @@ query workItemQuery($id: WorkItemID!) {
children {
nodes {
id
+ confidential
workItemType {
id
}
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index be72ec33465..004dc22c9b8 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -27,7 +27,6 @@
@import './pages/registry';
@import './pages/search';
@import './pages/service_desk';
-@import './pages/settings_ci_cd';
@import './pages/settings';
@import './pages/storage_quota';
@import './pages/tree';
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
index ceac5da7f80..6a6febbf7b4 100644
--- a/app/assets/stylesheets/components/avatar.scss
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -37,7 +37,7 @@ $avatar-sizes: (
),
60: (
font-size: 32px,
- line-height: 58px,
+ line-height: 60px,
border-radius: $border-radius-large
),
64: (
@@ -47,7 +47,7 @@ $avatar-sizes: (
),
90: (
font-size: 36px,
- line-height: 88px,
+ line-height: 90px,
border-radius: $border-radius-large
),
96: (
@@ -72,7 +72,6 @@ $avatar-sizes: (
float: left;
margin-right: $gl-padding;
border-radius: $avatar-radius;
- border: 1px solid $t-gray-a-08;
@each $size, $size-config in $avatar-sizes {
&.s#{$size} {
@@ -83,13 +82,12 @@ $avatar-sizes: (
.avatar {
transition-property: none;
-
width: 40px;
height: 40px;
padding: 0;
background: $gray-lightest;
overflow: hidden;
- border-color: rgba($black, $gl-avatar-border-opacity);
+ box-shadow: inset 0 0 0 1px rgba($gray-950, $gl-avatar-border-opacity);
&.avatar-inline {
float: none;
@@ -180,6 +178,10 @@ $avatar-sizes: (
@each $size, $size-config in $avatar-sizes {
&.s#{$size} {
border-radius: map-get($size-config, 'border-radius');
+
+ .avatar {
+ border-radius: map-get($size-config, 'border-radius');
+ }
}
}
}
diff --git a/app/assets/stylesheets/components/batch_comments/review_bar.scss b/app/assets/stylesheets/components/batch_comments/review_bar.scss
index 6f5c5c5a080..5e1128dc4ce 100644
--- a/app/assets/stylesheets/components/batch_comments/review_bar.scss
+++ b/app/assets/stylesheets/components/batch_comments/review_bar.scss
@@ -11,7 +11,7 @@
padding-right: $gutter_collapsed_width;
background: $white;
border-top: 1px solid $border-color;
- transition: padding $sidebar-transition-duration;
+ transition: padding $gl-transition-duration-medium;
.page-with-icon-sidebar & {
padding-left: $contextual-sidebar-collapsed-width;
diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss
deleted file mode 100644
index 59bd69955d3..00000000000
--- a/app/assets/stylesheets/components/rich_content_editor.scss
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
-* Overrides styles from ToastUI editor
-*/
-
-.tui-editor-defaultUI {
-
- // Toolbar buttons
- .tui-editor-defaultUI-toolbar .toolbar-button {
- color: $gray-500;
- border: 0;
-
- &:hover,
- &:active {
- color: $blue-500;
- border: 0;
- }
- }
-
- // Contextual menu's & popups
- .tui-popup-wrapper {
- @include gl-overflow-hidden;
- @include gl-rounded-base;
- @include gl-border-gray-200;
-
- hr {
- @include gl-m-0;
- @include gl-bg-gray-200;
- }
-
- button {
- @include gl-text-gray-700;
- }
- }
-
- /**
- * Overrides styles from ToastUI's Code Mirror (markdown mode) editor.
- * Toast UI internally overrides some of these using the `.tui-md-` prefix.
- * https://codemirror.net/doc/manual.html#styling
- */
-
- .te-md-container .CodeMirror * {
- @include gl-font-monospace;
- @include gl-font-size-monospace;
- @include gl-line-height-20;
- }
-}
-
-/**
-* Styling below ensures that YouTube videos are displayed in the editor the same as they would in about.gitlab.com
-* https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/main/source/stylesheets/_base.scss#L977
-*/
-.video_container {
- padding-bottom: 56.25%;
-}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 549289450a4..f947042ba51 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -84,89 +84,6 @@
border-bottom: 1px solid $white-dark;
padding: 11px 0;
margin-bottom: 11px;
-
- &.no-bottom-space {
- border-bottom: 0;
- margin-bottom: 0;
- }
-}
-
-.cover-block {
- text-align: center;
- background: $gray-light;
- padding-top: 44px;
- position: relative;
-
- .avatar-holder {
- .avatar,
- .identicon {
- margin: 0 auto;
- float: none;
- }
-
- .identicon {
- border-radius: 50%;
- }
- }
-
- .cover-title {
- color: $gl-text-color;
- font-size: 23px;
-
- h1 {
- color: $gl-text-color;
- margin-bottom: 6px;
- font-size: 23px;
- }
-
- .visibility-icon {
- display: inline-block;
- margin-left: 5px;
- font-size: 18px;
- color: color('gray');
- }
-
- p {
- padding: 0 $gl-padding;
- color: $gl-text-color;
- }
- }
-
- .cover-controls {
- @include media-breakpoint-up(sm) {
- position: absolute;
- top: 1rem;
- right: 1.25rem;
- }
-
- &.left {
- @include media-breakpoint-up(sm) {
- left: 1.25rem;
- right: auto;
- }
- }
- }
-
- &.user-cover-block {
- padding: 24px 0 0;
-
- .nav-links {
- width: 100%;
- float: none;
-
- &.scrolling-tabs {
- float: none;
- }
- }
-
- li:first-child {
- margin-left: auto;
- }
-
- li:last-child {
- margin-right: auto;
- }
- }
}
.content-block {
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index 1fa03d66f32..b1e5ca50a8b 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -1,30 +1,3 @@
-.calendar-block {
- padding-left: 0;
- padding-right: 0;
- border-top: 0;
-
- @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) {
- overflow-x: auto;
- }
-}
-
-.user-calendar-activities {
- direction: ltr;
-
- .str-truncated {
- max-width: 70%;
- }
-}
-
-.user-calendar {
- text-align: center;
- min-height: 172px;
-
- .calendar {
- display: inline-block;
- }
-}
-
.user-contrib-cell {
&:hover {
cursor: pointer;
@@ -42,18 +15,6 @@
}
}
-.user-contrib-text {
- font-size: 12px;
- fill: $calendar-user-contrib-text;
-}
-
-.calendar-hint {
- font-size: 12px;
- direction: ltr;
- margin-top: -23px;
- float: right;
-}
-
.pika-single.gitlab-theme {
.pika-label {
color: $gl-text-color-secondary;
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 036cec15935..ad0036df607 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -134,16 +134,6 @@
.avatar-container {
@include gl-font-weight-normal;
flex: none;
- box-shadow: $avatar-box-shadow;
-
- &.rect-avatar {
- @include gl-border-none;
-
- .avatar.s32 {
- border-radius: $border-radius-default;
- box-shadow: $avatar-box-shadow;
- }
- }
}
}
@@ -214,7 +204,7 @@
//
.page-with-contextual-sidebar {
- transition: padding-left $sidebar-transition-duration;
+ transition: padding-left $gl-transition-duration-medium;
@include media-breakpoint-up(md) {
padding-left: $contextual-sidebar-collapsed-width;
@@ -243,7 +233,7 @@
@include gl-fixed;
@include gl-bottom-0;
@include gl-left-0;
- transition: width $sidebar-transition-duration, left $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium, left $gl-transition-duration-medium;
z-index: 600;
width: $contextual-sidebar-width;
top: $header-height;
diff --git a/app/assets/stylesheets/framework/contextual_sidebar_header.scss b/app/assets/stylesheets/framework/contextual_sidebar_header.scss
index 7159dadf7cc..a3d752dcc3d 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar_header.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar_header.scss
@@ -5,7 +5,7 @@
> a,
> button {
- transition: padding $sidebar-transition-duration;
+ transition: padding $gl-transition-duration-medium;
font-weight: $gl-font-weight-bold;
display: flex;
width: 100%;
@@ -25,7 +25,7 @@
}
.avatar-container {
- flex: 0 0 40px;
+ flex: 0 0 32px;
background-color: $white;
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 43e14a63f9d..d91524d99e6 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -125,10 +125,6 @@
padding-right: 25px;
}
- .fa {
- color: $gray-darkest;
- }
-
&:hover {
border-color: $gray-darkest;
}
@@ -148,10 +144,6 @@
text-overflow: ellipsis;
width: 160px;
- .fa {
- position: absolute;
- }
-
.gl-spinner {
position: absolute;
top: 9px;
@@ -387,10 +379,6 @@
margin: 0;
text-align: left;
text-overflow: inherit;
-
- &.btn .fa:not(:last-child) {
- margin-left: 5px;
- }
}
> button.dropdown-epic-button {
@@ -477,6 +465,12 @@
height: 2 * $gl-padding;
margin: 0 10px 0 0;
}
+
+ .sidebar-participant {
+ .merge-icon {
+ top: calc(50% + 5px);
+ }
+ }
}
.dropdown-menu-user-full-name {
@@ -645,14 +639,12 @@
border-color: $blue-300;
box-shadow: 0 0 4px $dropdown-input-focus-shadow;
- ~ .fa,
~ .dropdown-input-clear {
color: $gray-700;
}
}
&:hover {
- ~ .fa,
~ .dropdown-input-clear {
color: $gray-700;
}
@@ -710,14 +702,6 @@
z-index: 9;
background-color: $dropdown-loading-bg;
font-size: 28px;
-
- .fa {
- position: absolute;
- top: 50%;
- left: 50%;
- margin-top: -14px;
- margin-left: -14px;
- }
}
.dropdown-label-box {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index f322c6c8929..b980d7fdaa7 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -202,6 +202,10 @@
float: none;
border-left: 1px solid $gray-100;
+ .file-line-num {
+ @include gl-min-w-9;
+ }
+
i {
float: none;
margin-right: 0;
@@ -478,6 +482,11 @@ span.idiff {
background-color: transparent;
border: transparent;
}
+
+ .gl-dark & {
+ background: transparent;
+ filter: invert(1) hue-rotate(180deg);
+ }
}
.code-navigation-line:hover {
@@ -575,3 +584,11 @@ span.idiff {
@include gl-text-center;
}
}
+
+// *:nth-of-type(1n+30) - makes sure we do not render elements 30+ right away when
+// viewing a file. Even though the HTML is injected in the DOM, as long as we do
+// not render those elements, the browser doesn't need to spend resources
+// calculating and repainting what's hidden.
+.file-holder [data-loading] .file-content *:nth-of-type(1n+30) {
+ @include gl-display-none;
+}
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 1c43212f501..2b76e70fa17 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -31,7 +31,8 @@
width: 100%;
padding-left: 10px;
padding-right: 10px;
- white-space: pre;
+ white-space: break-spaces;
+ word-break: break-word;
&:empty::before {
content: '\200b';
@@ -48,8 +49,9 @@
a {
font-family: $monospace-font;
- display: block;
white-space: nowrap;
+ @include gl-display-flex;
+ @include gl-justify-content-end;
i,
svg {
@@ -90,3 +92,55 @@ td.line-numbers {
cursor: pointer;
text-decoration: underline wavy $red-500;
}
+
+.blob-viewer {
+ .line-numbers {
+ // for server-side-rendering
+ .line-links {
+ @include gl-display-flex;
+
+
+ &:first-child {
+ margin-top: 10px;
+ }
+
+ &:last-child {
+ margin-bottom: 10px;
+ }
+ }
+
+ // for client
+ &.line-links {
+ min-width: 6rem;
+ border-bottom-left-radius: 0;
+
+ + pre {
+ margin-left: 6rem;
+ }
+ }
+ }
+
+ .line-links {
+ &:hover a::before,
+ &:focus-within a::before {
+ @include gl-visibility-visible;
+ }
+ }
+
+ .file-line-num {
+ min-width: 4.5rem;
+ @include gl-justify-content-end;
+ @include gl-flex-grow-1;
+ @include gl-pr-3;
+ }
+
+ .file-line-blame {
+ @include gl-ml-3;
+ }
+
+ .file-line-num,
+ .file-line-blame {
+ @include gl-align-items-center;
+ @include gl-display-flex;
+ }
+}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 2cea3b96ff7..47856f1a0d3 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -478,7 +478,7 @@
}
@mixin side-panel-toggle {
- transition: width $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium;
height: $toggle-sidebar-height;
padding: 0 $gl-padding;
background-color: $gray-light;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 74aed1bd984..92ca8654287 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -135,27 +135,8 @@
}
@include media-breakpoint-down(md) {
- $controls-margin: $btn-margin-5 - 2px;
flex: 0 0 100%;
margin-top: $gl-padding-8;
-
- .controls-item,
- .controls-item-full,
- .controls-item:last-child {
- flex: 1 1 35%;
- display: block;
- width: 100%;
- margin: $controls-margin;
-
- .btn,
- .dropdown {
- margin: 0;
- }
- }
-
- .controls-item-full {
- flex: 1 1 100%;
- }
}
@include media-breakpoint-down(sm) {
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 13201d43fd0..ae0f18753ad 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -13,7 +13,7 @@
}
.page-initialised .content-wrapper {
- transition: padding $sidebar-transition-duration;
+ transition: padding $gl-transition-duration-medium;
}
.right-sidebar-collapsed {
@@ -109,7 +109,7 @@
@include maintain-sidebar-dimensions;
width: 0;
padding: 0;
- transition: width $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium;
&.right-sidebar-expanded {
@include maintain-sidebar-dimensions;
diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss
index 953c42219a9..f9e95d16f63 100644
--- a/app/assets/stylesheets/framework/sortable.scss
+++ b/app/assets/stylesheets/framework/sortable.scss
@@ -1,6 +1,4 @@
.sortable-container {
- background-color: $gray-light;
-
.flex-list {
padding: 5px;
margin-bottom: 0;
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 086b83b13e0..43effbdd7d7 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -35,6 +35,10 @@
background-color: $white;
}
+ &:not(.note-form).internal-note {
+ background-color: $orange-50;
+ }
+
.timeline-entry-inner {
position: relative;
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index b5e0dcd875a..031f5dc45ca 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -435,6 +435,35 @@
}
}
+ li.inapplicable {
+ // for a single line list item, no paragraph (tight list)
+ > s {
+ color: $gl-text-color-disabled;
+ }
+
+ // additional blocks, other than paragraphs
+ > div {
+ text-decoration: line-through;
+ color: $gl-text-color-disabled;
+ }
+
+ // because of the embedded checkbox, putting line-through on the entire
+ // paragraph causes the space between the checkbox and the text to have the
+ // line-through. Targeting just the `s` fixes this
+ > p:first-of-type > s {
+ color: $gl-text-color-disabled;
+ }
+
+ > p:not(:first-of-type) {
+ text-decoration: line-through;
+ color: $gl-text-color-disabled;
+ }
+
+ .drag-icon {
+ color: $gl-text-color;
+ }
+ }
+
a.with-attachment-icon,
a[href*='/uploads/'],
a[href*='storage.googleapis.com/google-code-attachments/'] {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 1e921b4234e..e9ad930ef2b 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -5,7 +5,6 @@ $grid-size: 8px;
$gutter-collapsed-width: 62px;
$gutter-width: 290px;
$gutter-inner-width: 250px;
-$sidebar-transition-duration: 0.3s;
$sidebar-breakpoint: 1024px;
$default-transition-duration: 0.15s;
$contextual-sidebar-width: 256px;
@@ -454,7 +453,6 @@ $default-icon-size: 16px;
$layout-link-gray: #7e7c7c;
$btn-side-margin: $grid-size;
$btn-sm-side-margin: 7px;
-$btn-margin-5: 5px;
$count-arrow-border: #dce0e5;
$general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
@@ -880,8 +878,6 @@ $image-comment-cursor-top-offset: 12;
Security & Compliance Carousel
*/
$security-and-compliance-carousel-image-carousel-width: 1000px;
-$security-and-compliance-carousel-image-discover-button-width: 45%;
-$security-and-compliance-carousel-image-discover-buttons-max-width: 280px;
$security-and-compliance-carousel-image-discover-footer-max-width: 500px;
$security-and-compliance-carousel-image-discover-text-carousel-max-width: 650px;
$security-and-compliance-carousel-image-discover-text-carousel-caption-height: 100%;
diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss
index 1a536b97142..e3ac615234c 100644
--- a/app/assets/stylesheets/framework/vue_transitions.scss
+++ b/app/assets/stylesheets/framework/vue_transitions.scss
@@ -2,7 +2,7 @@
.fade-leave-active,
.fade-in-enter-active,
.fade-out-leave-active {
- transition: opacity $sidebar-transition-duration $general-hover-transition-curve;
+ transition: opacity $gl-transition-duration-medium $general-hover-transition-curve;
}
.fade-enter,
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index fcbd05141b9..96df8487c0e 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -98,32 +98,33 @@
}
}
-@mixin line-number-link($color) {
- min-width: $gl-spacing-scale-9;
+@mixin line-link($color, $icon) {
&::before {
- @include gl-display-none;
+ @include gl-visibility-hidden;
@include gl-align-self-center;
- @include gl-mt-2;
- @include gl-mr-2;
- @include gl-w-4;
- @include gl-h-4;
- @include gl-absolute;
- @include gl-left-3;
- background-color: $color;
- mask-image: asset_url('icons-stacked.svg#link');
+ @include gl-mr-1;
+ @include gl-w-5;
+ @include gl-h-5;
+ background-color: rgba($color, 0.3);
+ mask-image: asset_url('icons-stacked.svg##{$icon}');
mask-repeat: no-repeat;
mask-size: cover;
mask-position: center;
content: '';
}
- &:hover::before {
- @include gl-display-inline-block;
+ &:hover {
+ &::before {
+ background-color: rgba($color, 0.6);
+ }
}
+}
- &:focus::before {
- @include gl-display-inline-block;
+@mixin line-hover-bg($color: $white-normal) {
+ &:hover,
+ &:focus-within {
+ background-color: darken($color, 10);
}
}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 709e7f5ae18..5e6e10e44be 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -127,7 +127,15 @@ $dark-il: #de935f;
.code.dark {
// Line numbers
.file-line-num {
- @include line-number-link($dark-line-num-color);
+ @include line-link($white, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($white, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg($dark-main-bg);
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 0ed9c209417..19c3d6926e7 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -120,7 +120,15 @@ $monokai-gh: #75715e;
// Line numbers
.file-line-num {
- @include line-number-link($monokai-line-num-color);
+ @include line-link($white, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($white, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg($monokai-bg);
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index 868e466b1f8..4c716d20ddf 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -25,7 +25,15 @@
// Line numbers
.file-line-num {
- @include line-number-link($black-transparent);
+ @include line-link($black, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($black, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg;
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index 6260339a48d..70086be1606 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -123,7 +123,15 @@ $solarized-dark-il: #2aa198;
// Line numbers
.file-line-num {
- @include line-number-link($solarized-dark-line-color);
+ @include line-link($white, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($white, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg($solarized-dark-pre-bg);
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index e6f098f4cdf..8d223d1fdb1 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -109,7 +109,15 @@ $solarized-light-il: #2aa198;
@include hljs-override('title.class_.inherited__', $solarized-light-no);
// Line numbers
.file-line-num {
- @include line-number-link($solarized-light-line-color);
+ @include line-link($black, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($black, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg($solarized-light-pre-bg);
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 770a90bbc57..9761e3961dd 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -95,7 +95,15 @@ $white-gc-bg: #eaf2f5;
// Line numbers
.file-line-num {
- @include line-number-link($black-transparent);
+ @include line-link($black, 'link');
+}
+
+.file-line-blame {
+ @include line-link($black, 'git');
+}
+
+.line-links {
+ @include line-hover-bg;
}
.line-numbers,
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 81d35b8bc7b..197073412e8 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -35,7 +35,7 @@
.boards-app {
@include media-breakpoint-up(sm) {
- transition: width $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium;
width: 100%;
&.is-compact {
@@ -349,7 +349,7 @@
.right-sidebar.right-sidebar-expanded {
&.boards-sidebar-slide-enter-active,
&.boards-sidebar-slide-leave-active {
- transition: width $sidebar-transition-duration, padding $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium, padding $gl-transition-duration-medium;
}
&.boards-sidebar-slide-enter,
diff --git a/app/assets/stylesheets/page_bundles/escalation_policies.scss b/app/assets/stylesheets/page_bundles/escalation_policies.scss
index 6f3873fea0c..84c62ba93dd 100644
--- a/app/assets/stylesheets/page_bundles/escalation_policies.scss
+++ b/app/assets/stylesheets/page_bundles/escalation_policies.scss
@@ -15,17 +15,11 @@
$stroke-size: 1px;
.right-arrow {
- @include gl-relative;
height: $stroke-size;
- background-color: var(--gray-900, $gray-900);
- min-width: $gl-spacing-scale-7;
&-head {
- @include gl-absolute;
- top: -2*$stroke-size;
- left: calc(100% - #{5*$stroke-size});
- @include gl-p-1;
- @include gl-border-solid;
+ top: -2 * $stroke-size;
+ left: calc(100% - #{5 * $stroke-size});
border-width: 0 $stroke-size $stroke-size 0;
border-color: var(--gray-900, $gray-900);
transform: rotate(-45deg);
@@ -41,14 +35,10 @@ $stroke-size: 1px;
.rule-condition {
@media (min-width: $breakpoint-lg) {
flex-basis: 25%;
- flex-shrink: 0;
+ @include gl-flex-shrink-0;
}
@media (max-width: $breakpoint-lg) {
@include gl-w-full;
}
}
-
-.rule-action {
- min-width: 0;
-}
diff --git a/app/assets/stylesheets/page_bundles/group.scss b/app/assets/stylesheets/page_bundles/group.scss
index 38dd07f617c..71dbb855103 100644
--- a/app/assets/stylesheets/page_bundles/group.scss
+++ b/app/assets/stylesheets/page_bundles/group.scss
@@ -72,36 +72,43 @@
}
}
-.group-nav-container .nav-controls {
- .group-filter-form {
- flex: 1 1 auto;
- margin-right: $gl-padding-8;
- }
-
- .dropdown-menu-right {
- margin-top: 0;
- }
-
- @include media-breakpoint-down(sm) {
- .dropdown,
- .dropdown .dropdown-toggle,
- .btn-success {
- display: block;
+.group-nav-container {
+ .nav-controls {
+ .group-filter-form {
+ flex: 1 1 auto;
+ margin-right: $gl-padding-8;
}
- .group-filter-form,
- .dropdown {
- margin-bottom: 10px;
- margin-right: 0;
+ .dropdown-menu-right {
+ margin-top: 0;
}
- &,
- .group-filter-form,
- .group-filter-form-field,
- .dropdown,
- .dropdown .dropdown-toggle,
- .btn-success {
- width: 100%;
+ @include media-breakpoint-down(sm) {
+ .dropdown,
+ .dropdown .dropdown-toggle,
+ .btn-success {
+ display: block;
+ }
+
+ .group-filter-form,
+ .dropdown {
+ margin-bottom: 10px;
+ margin-right: 0;
+ }
+
+ &,
+ .group-filter-form,
+ .group-filter-form-field,
+ .dropdown,
+ .dropdown .dropdown-toggle,
+ .btn-success {
+ width: 100%;
+ }
}
}
+
+ // Remove this selector once https://gitlab.com/gitlab-org/gitlab/-/issues/370050 is addressed.
+ .scrolling-tabs-container {
+ width: 100%;
+ }
}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 1b27e51e793..b7a75884425 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -416,8 +416,6 @@ $tabs-holder-z-index: 250;
.label-branch {
@include gl-font-monospace;
font-size: 95%;
- color: var(--gl-text-color, $gl-text-color);
- font-weight: normal;
overflow: hidden;
word-break: break-all;
}
@@ -477,8 +475,7 @@ $tabs-holder-z-index: 250;
margin: 0 0 0 10px;
}
- .bold,
- .gl-font-weight-bold {
+ .bold {
font-weight: $gl-font-weight-bold;
color: var(--gray-600, $gray-600);
margin-left: 10px;
@@ -494,8 +491,7 @@ $tabs-holder-z-index: 250;
}
.spacing,
- .bold,
- .gl-font-weight-bold {
+ .bold {
vertical-align: middle;
}
@@ -602,6 +598,12 @@ $tabs-holder-z-index: 250;
padding: $gl-padding;
}
+.mr-widget-body-ready-merge {
+ @include media-breakpoint-down(sm) {
+ @include gl-p-3;
+ }
+}
+
.mr-widget-border-top {
border-top: 1px solid var(--border-color, $border-color);
}
@@ -820,3 +822,21 @@ $tabs-holder-z-index: 250;
height: 180px;
}
}
+
+.mr-widget-merge-details {
+ li:not(:last-child) {
+ @include gl-mb-3;
+ }
+}
+
+.mr-ready-merge-related-links,
+.mr-widget-merge-details {
+ a {
+ @include gl-text-decoration-underline;
+
+ &:hover,
+ &:focus {
+ @include gl-text-decoration-none;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index e6afc70acbb..98e9e2b3c27 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -225,12 +225,20 @@
}
}
-.test-reports-table {
- .build-log {
- @include build-log();
+.progress-bar.bg-primary {
+ background-color: var(--blue-500, $blue-500) !important;
+}
+
+.ci-job-component {
+ .job-failed {
+ background-color: var(--red-50, $red-50);
}
}
-.progress-bar.bg-primary {
- background-color: var(--blue-500, $blue-500) !important;
+.gl-dark {
+ .ci-job-component {
+ .job-failed {
+ background-color: var(--gray-200, $gray-200);
+ }
+ }
}
diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss
new file mode 100644
index 00000000000..59b8823c113
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/profile.scss
@@ -0,0 +1,212 @@
+@import 'mixins_and_variables_and_functions';
+
+.calendar-block {
+ padding-left: 0;
+ padding-right: 0;
+ border-top: 0;
+
+ @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) {
+ overflow-x: auto;
+ }
+}
+
+.calendar-hint {
+ font-size: 12px;
+ direction: ltr;
+ margin-top: -23px;
+ float: right;
+}
+
+.cover-block {
+ text-align: center;
+ background: var(--gray-50, $gray-light);
+ padding-top: 44px;
+ position: relative;
+
+ .avatar-holder {
+ .avatar,
+ .identicon {
+ margin: 0 auto;
+ float: none;
+ }
+
+ .identicon {
+ border-radius: 50%;
+ }
+ }
+
+ .cover-title {
+ color: var(--gl-text-color, $gl-text-color);
+ font-size: 23px;
+
+ h1 {
+ color: var(--gl-text-color, $gl-text-color);
+ margin-bottom: 6px;
+ font-size: 23px;
+ }
+
+ .visibility-icon {
+ display: inline-block;
+ margin-left: 5px;
+ font-size: 18px;
+ color: color('gray');
+ }
+
+ p {
+ padding: 0 $gl-padding;
+ color: var(--gl-text-color, $gl-text-color);
+ }
+ }
+
+ .cover-controls {
+ @include media-breakpoint-up(sm) {
+ position: absolute;
+ top: 1rem;
+ right: 1.25rem;
+ }
+
+ &.left {
+ @include media-breakpoint-up(sm) {
+ left: 1.25rem;
+ right: auto;
+ }
+ }
+ }
+
+ &.user-cover-block {
+ padding: 24px 0 0;
+
+ .nav-links {
+ width: 100%;
+ float: none;
+
+ &.scrolling-tabs {
+ float: none;
+ }
+ }
+
+ li:first-child {
+ margin-left: auto;
+ }
+
+ li:last-child {
+ margin-right: auto;
+ }
+ }
+}
+
+// Middle dot divider between each element in a list of items.
+.middle-dot-divider {
+ @include middle-dot-divider;
+}
+
+.middle-dot-divider-sm {
+ @include media-breakpoint-up(sm) {
+ @include middle-dot-divider;
+ }
+}
+
+.profile-user-bio {
+ // Limits the width of the user bio for readability.
+ max-width: 600px;
+ margin: 10px auto;
+}
+
+.user-calendar {
+ text-align: center;
+ min-height: 172px;
+
+ .calendar {
+ display: inline-block;
+ }
+}
+
+.user-calendar-activities {
+ direction: ltr;
+
+ .str-truncated {
+ max-width: 70%;
+ }
+}
+
+.user-contrib-text {
+ font-size: 12px;
+ fill: $calendar-user-contrib-text;
+}
+
+.user-profile {
+ .profile-header {
+ margin: 0 $gl-padding;
+
+ &.with-no-profile-tabs {
+ margin-bottom: $gl-padding-24;
+ }
+
+ .avatar-holder {
+ width: 90px;
+ margin: 0 auto 10px;
+ }
+ }
+
+ .user-profile-nav {
+ font-size: 0;
+ }
+
+ .fade-right {
+ right: 0;
+ }
+
+ .fade-left {
+ left: 0;
+ }
+
+ .activities-block {
+ .event-item {
+ padding-left: 40px;
+ }
+
+ .gl-label-scoped {
+ --label-inset-border: inset 0 0 0 1px currentColor;
+ }
+
+ @include media-breakpoint-up(lg) {
+ margin-right: 5px;
+ }
+ }
+
+ .projects-block {
+ @include media-breakpoint-up(lg) {
+ margin-left: 5px;
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ .cover-block {
+ padding-top: 20px;
+ }
+
+ .user-profile-nav {
+ a {
+ margin-right: 0;
+ }
+ }
+
+ .activities-block {
+ .event-item {
+ padding-left: 0;
+ }
+ }
+ }
+}
+
+.linkedin-icon {
+ color: $linkedin;
+}
+
+.skype-icon {
+ color: $skype;
+}
+
+.twitter-icon {
+ color: $twitter;
+}
diff --git a/app/assets/stylesheets/page_bundles/runner_details.scss b/app/assets/stylesheets/page_bundles/runner_details.scss
new file mode 100644
index 00000000000..6e5580a18e4
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/runner_details.scss
@@ -0,0 +1,3 @@
+.runner-details-grid-template {
+ grid-template-columns: auto 1fr;
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 51f964a4b70..69797c6b303 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -132,7 +132,7 @@
// stylelint-disable-next-line length-zero-no-unit
bottom: var(--review-bar-height, 0px);
right: 0;
- transition: width $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium;
background-color: $white;
z-index: 200;
overflow: hidden;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index c0a283ec643..a151c28fe93 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -318,7 +318,7 @@ ul.related-merge-requests > li gl-emoji {
.issuable-header-slide-enter-active,
.issuable-header-slide-leave-active {
- @include gl-transition-slow;
+ @include gl-transition-medium;
}
.issuable-header-slide-enter,
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 7f0bdadd2bc..1beb9f05b6c 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -41,6 +41,20 @@
font-size: 13px;
}
+ .borderless {
+ .login-box,
+ .omniauth-container {
+ box-shadow: none;
+ }
+
+ .g-recaptcha {
+ > div {
+ margin-left: auto;
+ margin-right: auto;
+ }
+ }
+ }
+
.login-box,
.omniauth-container {
box-shadow: 0 0 0 1px $border-color;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 96fe6caeea2..b016d0a1068 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -342,10 +342,10 @@ $comparison-empty-state-height: 62px;
.mr-compare {
.diff-file .file-title-flex-parent {
- top: calc(#{$header-height} + #{$mr-tabs-height} + 36px);
+ top: calc(#{$header-height} + #{$mr-tabs-height});
.with-performance-bar & {
- top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height} + 36px);
+ top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height});
}
}
}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 645f145328b..9692becef4f 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -121,15 +121,6 @@
border-radius: $label-border-radius;
padding-top: $gl-vert-padding;
padding-bottom: $gl-vert-padding;
-
- .icon svg {
- position: relative;
- top: 2px;
- margin-right: $btn-margin-5;
- width: $gl-font-size;
- height: $gl-font-size;
- fill: $orange-600;
- }
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 4d0cf30a3b2..db07f16dfd0 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -4,7 +4,7 @@ $system-note-svg-size: 16px;
@mixin vertical-line($left) {
&::before {
content: '';
- border-left: 2px solid $gray-50;
+ border-left: 2px solid $gray-10;
position: absolute;
top: 0;
bottom: 0;
@@ -29,7 +29,7 @@ $system-note-svg-size: 16px;
.issuable-discussion {
.main-notes-list {
- @include vertical-line(36px);
+ @include vertical-line(35px);
}
}
@@ -300,17 +300,17 @@ $system-note-svg-size: 16px;
.timeline-icon {
display: flex;
align-items: center;
- background-color: $white;
+ background-color: $gray-10;
width: $system-note-icon-size;
height: $system-note-icon-size;
- border: 1px solid $border-color;
+ border: 1px solid $gray-10;
border-radius: $system-note-icon-size;
margin: -6px 20px 0 0;
svg {
width: $system-note-svg-size;
height: $system-note-svg-size;
- fill: $gray-darkest;
+ fill: $gray-400;
display: block;
margin: 0 auto;
}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 812cc6ab4e6..951e31ef768 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -29,23 +29,6 @@
}
}
-// Middle dot divider between each element in a list of items.
-.middle-dot-divider {
- @include middle-dot-divider;
-}
-
-.middle-dot-divider-sm {
- @include media-breakpoint-up(sm) {
- @include middle-dot-divider;
- }
-}
-
-.profile-user-bio {
- // Limits the width of the user bio for readability.
- max-width: 600px;
- margin: 10px auto;
-}
-
.user-avatar-button {
.file-name {
display: inline-block;
@@ -156,71 +139,6 @@
}
}
-.user-profile {
- .profile-header {
- margin: 0 $gl-padding;
-
- &.with-no-profile-tabs {
- margin-bottom: $gl-padding-24;
- }
-
- .avatar-holder {
- width: 90px;
- margin: 0 auto 10px;
- }
- }
-
- .user-profile-nav {
- font-size: 0;
- }
-
- .fade-right {
- right: 0;
- }
-
- .fade-left {
- left: 0;
- }
-
- .activities-block {
- .event-item {
- padding-left: 40px;
- }
-
- .gl-label-scoped {
- --label-inset-border: inset 0 0 0 1px currentColor;
- }
-
- @include media-breakpoint-up(lg) {
- margin-right: 5px;
- }
- }
-
- .projects-block {
- @include media-breakpoint-up(lg) {
- margin-left: 5px;
- }
- }
-
- @include media-breakpoint-down(xs) {
- .cover-block {
- padding-top: 20px;
- }
-
- .user-profile-nav {
- a {
- margin-right: 0;
- }
- }
-
- .activities-block {
- .event-item {
- padding-left: 0;
- }
- }
- }
-}
-
table.u2f-registrations {
th:not(:last-child),
td:not(:last-child) {
@@ -366,15 +284,3 @@ table.u2f-registrations {
.gitlab-slack-slack-logo {
transform: scale(200%); // Slack logo SVG is scaled down 50% and has empty space around it
}
-
-.skype-icon {
- color: $skype;
-}
-
-.linkedin-icon {
- color: $linkedin;
-}
-
-.twitter-icon {
- color: $twitter;
-}
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index f1865a7dc40..6c909b8d9fa 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -82,19 +82,17 @@ input[type='checkbox']:hover {
min-width: $search-input-field-x-min-width;
}
- &.is-active {
- &.is-searching {
- .in-search-scope-help {
- position: absolute;
- top: $gl-spacing-scale-2;
- right: 2.125rem;
- z-index: 2;
- }
+ &.is-searching {
+ .in-search-scope-help {
+ position: absolute;
+ top: $gl-spacing-scale-2;
+ right: 2.125rem;
+ z-index: 2;
}
}
- &.is-not-searching {
- .in-search-scope-help {
+ &.is-not-focused {
+ .gl-search-box-by-type-clear {
display: none;
}
}
@@ -104,28 +102,11 @@ input[type='checkbox']:hover {
box-shadow: none;
border-color: transparent;
}
-
- &.is-active {
- .keyboard-shortcut-helper {
- display: none;
- }
- }
-
- &.is-not-active {
- .btn.gl-clear-icon-button,
- .in-search-scope-help {
- display: none;
- }
- }
}
.header-search-dropdown-menu {
max-height: $dropdown-max-height;
- top: $header-height;
-}
-
-.header-search-dropdown-content {
- max-height: $dropdown-max-height;
+ top: 100%;
}
.search {
diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss
deleted file mode 100644
index 7d74070b4f2..00000000000
--- a/app/assets/stylesheets/pages/settings_ci_cd.scss
+++ /dev/null
@@ -1,18 +0,0 @@
-.triggers-container {
- .label-container {
- display: inline-block;
- margin-left: 10px;
- }
-}
-
-.trigger-description {
- max-width: 100px;
-}
-
-.trigger-actions {
- white-space: nowrap;
-}
-
-.auto-devops-card {
- margin-bottom: $gl-vert-padding;
-}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 801c9ea828f..ffe4d5dde9d 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -1043,7 +1043,7 @@ kbd {
text-align: left;
}
.context-header .avatar-container {
- flex: 0 0 40px;
+ flex: 0 0 32px;
background-color: #333;
}
.context-header .sidebar-context-title {
@@ -1376,18 +1376,6 @@ kbd {
.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
-}
-.nav-sidebar-inner-scroll > div.context-header a .avatar-container.rect-avatar {
- border-style: none;
-}
-.nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
- border-radius: 4px;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items {
margin-bottom: 60px;
@@ -1400,18 +1388,6 @@ kbd {
.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
-}
-.sidebar-top-level-items .context-header a .avatar-container.rect-avatar {
- border-style: none;
-}
-.sidebar-top-level-items
- .context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
- border-radius: 4px;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items
> li.active
@@ -1628,7 +1604,6 @@ svg.s16 {
float: left;
margin-right: 16px;
border-radius: 50%;
- border: 1px solid rgba(0, 0, 0, 0.08);
}
.avatar.s16,
.avatar-container.s16 {
@@ -1649,7 +1624,7 @@ svg.s16 {
padding: 0;
background: #222;
overflow: hidden;
- border-color: rgba(255, 255, 255, 0.1);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.avatar.avatar-tile {
border-radius: 0;
@@ -1676,7 +1651,7 @@ svg.s16 {
background-color: #232150;
}
.identicon.bg3 {
- background-color: #f1f1ff;
+ background-color: #1a1a40;
}
.identicon.bg4 {
background-color: #033464;
@@ -1714,9 +1689,15 @@ svg.s16 {
.rect-avatar.s16 {
border-radius: 2px;
}
+.rect-avatar.s16 .avatar {
+ border-radius: 2px;
+}
.rect-avatar.s32 {
border-radius: 4px;
}
+.rect-avatar.s32 .avatar {
+ border-radius: 4px;
+}
:root {
color-scheme: dark;
}
@@ -1817,6 +1798,10 @@ body.gl-dark {
background-color: #262626;
border-right: 1px solid #303030;
}
+.avatar-container,
+.avatar {
+ background: rgba(255, 255, 255, 0.04);
+}
.nav-sidebar li a {
color: var(--gray-600);
}
@@ -1907,7 +1892,7 @@ body.gl-dark .header-search input::placeholder {
body.gl-dark .header-search input:active::placeholder {
color: #868686;
}
-body.gl-dark .header-search.is-not-active .keyboard-shortcut-helper {
+body.gl-dark .header-search .keyboard-shortcut-helper {
color: #fafafa;
background-color: rgba(250, 250, 250, 0.2);
}
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 43ca5a512d5..00ca98bfd27 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -1022,7 +1022,7 @@ kbd {
text-align: left;
}
.context-header .avatar-container {
- flex: 0 0 40px;
+ flex: 0 0 32px;
background-color: #fff;
}
.context-header .sidebar-context-title {
@@ -1355,18 +1355,6 @@ kbd {
.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
-}
-.nav-sidebar-inner-scroll > div.context-header a .avatar-container.rect-avatar {
- border-style: none;
-}
-.nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
- border-radius: 4px;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items {
margin-bottom: 60px;
@@ -1379,18 +1367,6 @@ kbd {
.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
-}
-.sidebar-top-level-items .context-header a .avatar-container.rect-avatar {
- border-style: none;
-}
-.sidebar-top-level-items
- .context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
- border-radius: 4px;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items
> li.active
@@ -1607,7 +1583,6 @@ svg.s16 {
float: left;
margin-right: 16px;
border-radius: 50%;
- border: 1px solid rgba(0, 0, 0, 0.08);
}
.avatar.s16,
.avatar-container.s16 {
@@ -1628,7 +1603,7 @@ svg.s16 {
padding: 0;
background: #fdfdfd;
overflow: hidden;
- border-color: rgba(0, 0, 0, 0.1);
+ box-shadow: inset 0 0 0 1px rgba(31, 31, 31, 0.1);
}
.avatar.avatar-tile {
border-radius: 0;
@@ -1693,9 +1668,15 @@ svg.s16 {
.rect-avatar.s16 {
border-radius: 2px;
}
+.rect-avatar.s16 .avatar {
+ border-radius: 2px;
+}
.rect-avatar.s32 {
border-radius: 4px;
}
+.rect-avatar.s32 .avatar {
+ border-radius: 4px;
+}
.tab-width-8 {
tab-size: 8;
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 3090edfb123..c0e2d8d44d4 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -11,9 +11,6 @@ html {
font-family: sans-serif;
line-height: 1.15;
}
-header {
- display: block;
-}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
@@ -31,8 +28,7 @@ hr {
height: 0;
overflow: visible;
}
-h1,
-h3 {
+h1 {
margin-top: 0;
margin-bottom: 0.25rem;
}
@@ -53,10 +49,6 @@ img {
vertical-align: middle;
border-style: none;
}
-svg {
- overflow: hidden;
- vertical-align: middle;
-}
label {
display: inline-block;
margin-bottom: 0.5rem;
@@ -86,8 +78,7 @@ fieldset {
[hidden] {
display: none !important;
}
-h1,
-h3 {
+h1 {
margin-bottom: 0.25rem;
font-weight: 600;
line-height: 1.2;
@@ -96,9 +87,6 @@ h3 {
h1 {
font-size: 2.1875rem;
}
-h3 {
- font-size: 1.53125rem;
-}
hr {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@@ -132,13 +120,6 @@ hr {
max-width: 1140px;
}
}
-.row {
- display: flex;
- flex-wrap: wrap;
- margin-right: -15px;
- margin-left: -15px;
-}
-.col-md-6,
.col-sm-12,
.col {
position: relative;
@@ -151,29 +132,11 @@ hr {
flex-grow: 1;
max-width: 100%;
}
-.order-1 {
- order: 1;
-}
-.order-12 {
- order: 12;
-}
@media (min-width: 576px) {
.col-sm-12 {
flex: 0 0 100%;
max-width: 100%;
}
- .order-sm-1 {
- order: 1;
- }
- .order-sm-12 {
- order: 12;
- }
-}
-@media (min-width: 768px) {
- .col-md-6 {
- flex: 0 0 50%;
- max-width: 50%;
- }
}
.form-control {
display: block;
@@ -241,39 +204,18 @@ hr {
fieldset:disabled a.btn {
pointer-events: none;
}
-.navbar {
- position: relative;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
- padding: 0.25rem 0.5rem;
-}
-.navbar .container {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
-}
-.fixed-top {
- position: fixed;
- top: 0;
- right: 0;
- left: 0;
- z-index: 1030;
-}
.mt-3 {
margin-top: 1rem !important;
}
.mb-3 {
margin-bottom: 1rem !important;
}
+.text-nowrap {
+ white-space: nowrap !important;
+}
.text-center {
text-align: center !important;
}
-.font-weight-normal {
- font-weight: 400 !important;
-}
.gl-form-input,
.gl-form-input.form-control {
background-color: #fff;
@@ -373,8 +315,7 @@ body {
[type="submit"] {
cursor: pointer;
}
-h1,
-h3 {
+h1 {
margin-top: 20px;
margin-bottom: 10px;
}
@@ -384,9 +325,6 @@ a {
hr {
overflow: hidden;
}
-svg {
- vertical-align: baseline;
-}
.form-control {
font-size: 0.875rem;
}
@@ -442,13 +380,6 @@ body.navless {
border-color: #e3e3e3;
color: #303030;
}
-.btn svg {
- height: 15px;
- width: 15px;
-}
-.btn svg:not(:last-child) {
- margin-right: 5px;
-}
.light {
color: #303030;
}
@@ -504,26 +435,6 @@ label.label-bold {
.gl-show-field-errors .gl-field-hint {
color: #303030;
}
-.navbar-empty {
- justify-content: center;
- height: var(--header-height, 48px);
- background: #fff;
- border-bottom: 1px solid #dbdbdb;
-}
-.navbar-empty .tanuki-logo,
-.navbar-empty .brand-header-logo {
- max-height: 100%;
-}
-.tanuki-logo .tanuki {
- fill: #e24329;
-}
-.tanuki-logo .left-cheek,
-.tanuki-logo .right-cheek {
- fill: #fc6d26;
-}
-.tanuki-logo .chin {
- fill: #fca326;
-}
input::-moz-placeholder {
color: #868686;
opacity: 1;
@@ -534,9 +445,6 @@ input::-ms-input-placeholder {
input:-ms-input-placeholder {
color: #868686;
}
-svg {
- fill: currentColor;
-}
.login-page .container {
max-width: 960px;
}
@@ -569,6 +477,14 @@ svg {
.login-page p {
font-size: 13px;
}
+.login-page .borderless .login-box,
+.login-page .borderless .omniauth-container {
+ box-shadow: none;
+}
+.login-page .borderless .g-recaptcha > div {
+ margin-left: auto;
+ margin-right: auto;
+}
.login-page .login-box,
.login-page .omniauth-container {
box-shadow: 0 0 0 1px #dbdbdb;
@@ -732,61 +648,76 @@ svg {
}
}
-.gl-border-solid {
- border-style: solid;
-}
-.gl-border-gray-100 {
- border-color: #dbdbdb;
-}
-.gl-border-1 {
- border-width: 1px;
-}
-.gl-rounded-base {
- border-radius: 0.25rem;
-}
.gl-text-green-600 {
color: #217645;
}
.gl-text-red-500 {
color: #dd2b0e;
}
-.gl-display-flex {
- display: flex;
-}
.gl-display-block {
display: block;
}
-.gl-align-items-center {
- align-items: center;
+.gl-w-10 {
+ width: 3.5rem;
}
-.gl-flex-wrap {
- flex-wrap: wrap;
+.gl-w-half {
+ width: 50%;
+}
+.gl-w-90p {
+ width: 90%;
}
.gl-w-full {
width: 100%;
}
+@media (max-width: 575.98px) {
+ .gl-xs-w-full {
+ width: 100%;
+ }
+}
.gl-p-4 {
padding: 0.75rem;
}
+.gl-pt-5 {
+ padding-top: 1rem;
+}
.gl-mt-2 {
margin-top: 0.25rem;
}
.gl-mt-5 {
margin-top: 1rem;
}
+.gl-mr-auto {
+ margin-right: auto;
+}
+.gl-mr-2 {
+ margin-right: 0.25rem;
+}
+.gl-mb-1 {
+ margin-bottom: 0.125rem;
+}
+.gl-mb-2 {
+ margin-bottom: 0.25rem;
+}
.gl-mb-3 {
margin-bottom: 0.5rem;
}
.gl-mb-5 {
margin-bottom: 1rem;
}
-@media (min-width: 576px) {
- .gl-sm-mt-0 {
- margin-top: 0;
- }
+.gl-ml-auto {
+ margin-left: auto;
}
-.gl-font-weight-bold {
- font-weight: 600;
+.gl-ml-2 {
+ margin-left: 0.25rem;
+}
+.gl-text-center {
+ text-align: center;
+}
+.gl-font-size-h2 {
+ font-size: 1.1875rem;
+}
+.gl-font-weight-normal {
+ font-weight: 400;
}
@import "startup/cloaking";
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index e6e736ef47c..eeb4604f32a 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -98,6 +98,8 @@ $white-light: #2b2b2b;
$white-normal: #333;
$white-dark: #444;
+$theme-indigo-50: #1a1a40;
+
$border-color: #4f4f4f;
$nav-active-bg: rgba(255, 255, 255, 0.08);
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index 34bb4925249..92740aaf89e 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -48,6 +48,17 @@
border-right: 1px solid $gray-50;
}
+.gl-avatar:not(.gl-avatar-identicon),
+.avatar-container,
+.avatar {
+ background: rgba($gray-950, 0.04);
+}
+
+.gl-avatar {
+ @include gl-border-none;
+ box-shadow: inset 0 0 0 1px rgba($gray-950, $gl-avatar-border-opacity);
+}
+
.nav-sidebar {
li {
a {
@@ -149,3 +160,8 @@ body.gl-dark {
background-color: $gray-200;
}
}
+
+.timeline-entry.internal-note:not(.note-form) {
+ // soften on darkmode
+ background-color: mix($gray-50, $orange-50, 75%);
+}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 2b6221a6c87..042e21cebd6 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -176,11 +176,9 @@
}
}
- &.is-not-active {
- .keyboard-shortcut-helper {
- color: $search-and-nav-links;
- background-color: rgba($search-and-nav-links, 0.2);
- }
+ .keyboard-shortcut-helper {
+ color: $search-and-nav-links;
+ background-color: rgba($search-and-nav-links, 0.2);
}
}
diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb
index 554e057ca83..cf85e4b3d33 100644
--- a/app/channels/awareness_channel.rb
+++ b/app/channels/awareness_channel.rb
@@ -66,6 +66,7 @@ class AwarenessChannel < ApplicationCable::Channel # rubocop:disable Gitlab/Name
{
id: user.id,
name: user.name,
+ username: user.username,
avatar_url: user.avatar_url(size: 36),
last_activity: last_activity,
last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words(
diff --git a/app/components/diffs/overflow_warning_component.html.haml b/app/components/diffs/overflow_warning_component.html.haml
index b334bfbcd89..551d995cb22 100644
--- a/app/components/diffs/overflow_warning_component.html.haml
+++ b/app/components/diffs/overflow_warning_component.html.haml
@@ -1,6 +1,6 @@
= render Pajamas::AlertComponent.new(title: _('Too many changes to show.'),
variant: :warning,
- alert_options: { class: 'gl-mb-5' }) do |c|
+ alert_options: { class: 'gl-mb-5', data: { testid: "too-many-changes-alert" } }) do |c|
= c.body do
= message
diff --git a/app/components/pajamas/avatar_component.html.haml b/app/components/pajamas/avatar_component.html.haml
new file mode 100644
index 00000000000..502f673fe2c
--- /dev/null
+++ b/app/components/pajamas/avatar_component.html.haml
@@ -0,0 +1,12 @@
+- if src
+ = image_tag src,
+ srcset: srcset,
+ alt: alt,
+ class: avatar_classes,
+ height: @size,
+ width: @size,
+ loading: "lazy",
+ **@avatar_options
+- else
+ %div{ @avatar_options, alt: alt, class: avatar_classes }
+ = initial
diff --git a/app/components/pajamas/avatar_component.rb b/app/components/pajamas/avatar_component.rb
new file mode 100644
index 00000000000..073968e0491
--- /dev/null
+++ b/app/components/pajamas/avatar_component.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Pajamas
+ class AvatarComponent < Pajamas::Component
+ include Gitlab::Utils::StrongMemoize
+
+ # @param record [User, Project, Group]
+ # @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
+ @alt = alt
+ @class = binding.local_variable_get(:class)
+ @size = filter_attribute(size.to_i, SIZE_OPTIONS, default: 64)
+ @avatar_options = avatar_options
+ end
+
+ private
+
+ SIZE_OPTIONS = [16, 24, 32, 48, 64, 96].freeze
+
+ def avatar_classes
+ classes = ["gl-avatar", "gl-avatar-s#{@size}", @class]
+ classes.push("gl-avatar-circle") if @record.is_a?(User)
+
+ unless src
+ classes.push("gl-avatar-identicon")
+ classes.push("gl-avatar-identicon-bg#{((@record.id || 0) % 7) + 1}")
+ end
+
+ classes.join(' ')
+ end
+
+ def src
+ strong_memoize(:src) do
+ if @record.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.
+ current_user = begin
+ helpers.current_user
+ 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}"
+ end
+ end
+ end
+
+ def srcset
+ return unless src
+
+ retina_src = src.gsub(/(?<=width=)#{@size}+/, (@size * 2).to_s)
+ "#{src} 1x, #{retina_src} 2x"
+ end
+
+ def alt
+ @alt || @record.name
+ end
+
+ def initial
+ @record.name[0, 1].upcase
+ end
+ end
+end
diff --git a/app/components/pajamas/button_component.html.haml b/app/components/pajamas/button_component.html.haml
index 8ce7d9e0315..5cf57deb7f1 100644
--- a/app/components/pajamas/button_component.html.haml
+++ b/app/components/pajamas/button_component.html.haml
@@ -1,4 +1,4 @@
-= content_tag tag, {**@button_options, **base_attributes, class: button_class, href: @href, target: @target } do
+= content_for :pajamas_button_content, flush: true do
- if @loading
= gl_loading_icon(inline: true, css_class: 'gl-button-icon gl-button-loading-indicator')
- if @icon && (!@loading || content)
@@ -6,3 +6,10 @@
- if content
%span.gl-button-text{ class: @button_text_classes }
= content
+
+- if link?
+ = link_to @href, { **@button_options, **base_attributes, class: button_class, target: @target, method: @method } do
+ = content_for :pajamas_button_content
+- else
+ = content_tag 'button', { **@button_options, **base_attributes, class: button_class } do
+ = content_for :pajamas_button_content
diff --git a/app/components/pajamas/button_component.rb b/app/components/pajamas/button_component.rb
index c6193d1ae05..4233e446d5b 100644
--- a/app/components/pajamas/button_component.rb
+++ b/app/components/pajamas/button_component.rb
@@ -13,6 +13,7 @@ module Pajamas
# @param [String] icon
# @param [String] href
# @param [String] target
+ # @param [Symbol] method
# @param [Hash] button_options
# @param [String] button_text_classes
# @param [String] icon_classes
@@ -28,6 +29,7 @@ module Pajamas
icon: nil,
href: nil,
target: nil,
+ method: nil,
button_options: {},
button_text_classes: nil,
icon_classes: nil
@@ -43,6 +45,7 @@ module Pajamas
@icon = icon
@href = href
@target = filter_attribute(target, TARGET_OPTIONS)
+ @method = filter_attribute(method, METHOD_OPTIONS)
@button_options = button_options
@button_text_classes = button_text_classes
@icon_classes = icon_classes
@@ -75,6 +78,7 @@ module Pajamas
SIZE_OPTIONS = [:small, :medium].freeze
TYPE_OPTIONS = [:button, :reset, :submit].freeze
TARGET_OPTIONS = %w[_self _blank _parent _top].freeze
+ METHOD_OPTIONS = [:get, :post, :put, :delete, :patch].freeze
CATEGORY_CLASSES = {
primary: '',
@@ -101,8 +105,8 @@ module Pajamas
delegate :sprite_icon, to: :helpers
delegate :gl_loading_icon, to: :helpers
- def tag
- @href ? 'a' : 'button'
+ def link?
+ @href.present?
end
def base_attributes
diff --git a/app/components/pajamas/checkbox_component.rb b/app/components/pajamas/checkbox_component.rb
index ae78d0453f8..d9987b7653c 100644
--- a/app/components/pajamas/checkbox_component.rb
+++ b/app/components/pajamas/checkbox_component.rb
@@ -1,7 +1,10 @@
# frozen_string_literal: true
# Renders a Pajamas compliant checkbox element
-# Must be used in an instance of `ActionView::Helpers::FormBuilder`
+# An instance of `ActionView::Helpers::FormBuilder` must be passed as the `form` argument.
+# The easiest way to use this component is by using the `gitlab_ui_checkbox_component` helper.
+# See https://docs.gitlab.com/ee/development/fe_guide/haml.html#gitlab_ui_checkbox_component
+# To use a checkbox without an instance of `ActionView::Helpers::FormBuilder` use `CheckboxTagComponent`.
module Pajamas
class CheckboxComponent < Pajamas::Component
include Pajamas::Concerns::CheckboxRadioLabelWithHelpText
@@ -31,6 +34,8 @@ module Pajamas
@value = checked_value if checkbox_options[:multiple]
end
+ private
+
attr_reader(
:form,
:method,
@@ -43,8 +48,6 @@ module Pajamas
:value
)
- private
-
def label_content
label? ? label : label_argument
end
diff --git a/app/components/pajamas/checkbox_tag_component.html.haml b/app/components/pajamas/checkbox_tag_component.html.haml
new file mode 100644
index 00000000000..ad02c966fad
--- /dev/null
+++ b/app/components/pajamas/checkbox_tag_component.html.haml
@@ -0,0 +1,6 @@
+.gl-form-checkbox.custom-control.custom-checkbox
+ = check_box_tag(name,
+ value,
+ checked,
+ formatted_input_options)
+ = render_label_tag_with_help_text
diff --git a/app/components/pajamas/checkbox_tag_component.rb b/app/components/pajamas/checkbox_tag_component.rb
new file mode 100644
index 00000000000..45e88588059
--- /dev/null
+++ b/app/components/pajamas/checkbox_tag_component.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+# Renders a Pajamas compliant checkbox element
+module Pajamas
+ class CheckboxTagComponent < Pajamas::Component
+ include Pajamas::Concerns::CheckboxRadioLabelWithHelpText
+ include Pajamas::Concerns::CheckboxRadioOptions
+
+ renders_one :label
+ renders_one :help_text
+
+ def initialize(
+ name:,
+ label_options: {},
+ checkbox_options: {},
+ value: '1',
+ checked: false
+ )
+ @name = name
+ @label_options = label_options
+ @input_options = checkbox_options
+ @value = value
+ @checked = checked
+ end
+
+ private
+
+ attr_reader(
+ :name,
+ :label_options,
+ :input_options,
+ :value,
+ :checked
+ )
+
+ def label_content
+ label
+ end
+
+ def help_text_content
+ help_text
+ end
+ end
+end
diff --git a/app/components/pajamas/concerns/checkbox_radio_label_with_help_text.rb b/app/components/pajamas/concerns/checkbox_radio_label_with_help_text.rb
index 4ece904fb85..298ed200101 100644
--- a/app/components/pajamas/concerns/checkbox_radio_label_with_help_text.rb
+++ b/app/components/pajamas/concerns/checkbox_radio_label_with_help_text.rb
@@ -7,6 +7,10 @@ module Pajamas
form.label(method, formatted_label_options) { label_entry }
end
+ def render_label_tag_with_help_text
+ label_tag(name, formatted_label_options) { label_entry }
+ end
+
private
def label_entry
diff --git a/app/components/pajamas/radio_component.rb b/app/components/pajamas/radio_component.rb
index 52a761b9d7d..7a3d95c8565 100644
--- a/app/components/pajamas/radio_component.rb
+++ b/app/components/pajamas/radio_component.rb
@@ -28,6 +28,8 @@ module Pajamas
@value = value
end
+ private
+
attr_reader(
:form,
:method,
@@ -38,8 +40,6 @@ module Pajamas
:value
)
- private
-
def label_content
label? ? label : label_argument
end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index e05e87ffd89..6f21b123eb0 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -13,6 +13,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
before_action :disable_query_limiting, only: [:usage_data]
+ before_action do
+ push_frontend_feature_flag(:ci_variable_settings_graphql)
+ end
+
feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
:general, :reporting, :metrics_and_profiling, :network,
:preferences, :update, :reset_health_check_token
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index a6a21cf3649..b0d7c8cb8f2 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -11,7 +11,6 @@ class Admin::ApplicationsController < Admin::ApplicationController
def index
applications = ApplicationsFinder.new.execute
@applications = Kaminari.paginate_array(applications).page(params[:page])
- @application_counts = OauthAccessToken.distinct_resource_owner_counts(@applications)
end
def show
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index bf573d45852..a53e832329f 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -58,7 +58,6 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
def broadcast_message_params
params.require(:broadcast_message).permit(%i(
- color
theme
ends_at
message
diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb
index d4b7d750759..7d643435ddb 100644
--- a/app/controllers/admin/ci/variables_controller.rb
+++ b/app/controllers/admin/ci/variables_controller.rb
@@ -31,7 +31,7 @@ class Admin::Ci::VariablesController < Admin::ApplicationController
def render_instance_variables
render status: :ok,
- json: {
+ json: {
variables: Ci::InstanceVariableSerializer.new.represent(variables)
}
end
@@ -41,7 +41,7 @@ class Admin::Ci::VariablesController < Admin::ApplicationController
end
def variables_params
- params.permit(variables_attributes: [*variable_params_attributes])
+ params.permit(variables_attributes: Array(variable_params_attributes))
end
def variable_params_attributes
diff --git a/app/controllers/admin/dev_ops_report_controller.rb b/app/controllers/admin/dev_ops_report_controller.rb
index 47e3337aed7..71ee19ddf39 100644
--- a/app/controllers/admin/dev_ops_report_controller.rb
+++ b/app/controllers/admin/dev_ops_report_controller.rb
@@ -1,11 +1,16 @@
# frozen_string_literal: true
class Admin::DevOpsReportController < Admin::ApplicationController
- include RedisTracking
+ include ProductAnalyticsTracking
helper_method :show_adoption?
- track_redis_hll_event :show, name: 'i_analytics_dev_ops_score', if: -> { should_track_devops_score? }
+ track_custom_event :show,
+ name: 'i_analytics_dev_ops_score',
+ action: 'perform_analytics_usage_action',
+ label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
+ destinations: %i[redis_hll snowplow],
+ conditions: -> { should_track_devops_score? }
feature_category :devops_reports
@@ -24,6 +29,14 @@ class Admin::DevOpsReportController < Admin::ApplicationController
def should_track_devops_score?
true
end
+
+ def tracking_namespace_source
+ nil
+ end
+
+ def tracking_project_source
+ nil
+ end
end
Admin::DevOpsReportController.prepend_mod_with('Admin::DevOpsReportController')
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index 6fd1e9bb70e..3f3c3581555 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -48,8 +48,8 @@ class Admin::ProjectsController < Admin::ApplicationController
flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name }
redirect_to admin_projects_path, status: :found
- rescue Projects::DestroyService::DestroyError => ex
- redirect_to admin_projects_path, status: :found, alert: ex.message
+ rescue Projects::DestroyService::DestroyError => e
+ redirect_to admin_projects_path, status: :found, alert: e.message
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb
index 0165c6471db..7dbae565d07 100644
--- a/app/controllers/admin/runner_projects_controller.rb
+++ b/app/controllers/admin/runner_projects_controller.rb
@@ -9,7 +9,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
def create
@runner = Ci::Runner.find(params[:runner_project][:runner_id])
- if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute
+ if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute.success?
redirect_to edit_admin_runner_url(@runner), notice: s_('Runners|Runner assigned to project.')
else
redirect_to edit_admin_runner_url(@runner), alert: 'Failed adding runner to project'
diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb
index f81b02ad31f..41f95addc66 100644
--- a/app/controllers/admin/system_info_controller.rb
+++ b/app/controllers/admin/system_info_controller.rb
@@ -37,8 +37,16 @@ class Admin::SystemInfoController < Admin::ApplicationController
].freeze
def show
- @cpus = Vmstat.cpu rescue nil
- @memory = Vmstat.memory rescue nil
+ @cpus = begin
+ Vmstat.cpu
+ rescue StandardError
+ nil
+ end
+ @memory = begin
+ Vmstat.memory
+ rescue StandardError
+ nil
+ end
mounts = Sys::Filesystem.mounts
@disks = []
@@ -52,9 +60,9 @@ class Admin::SystemInfoController < Admin::ApplicationController
disk = Sys::Filesystem.stat(mount.mount_point)
@disks.push({
bytes_total: disk.bytes_total,
- bytes_used: disk.bytes_used,
- disk_name: mount.name,
- mount_path: disk.path
+ bytes_used: disk.bytes_used,
+ disk_name: mount.name,
+ mount_path: disk.path
})
rescue Sys::Filesystem::Error
end
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
index b451928e591..69bcfdf4791 100644
--- a/app/controllers/admin/topics_controller.rb
+++ b/app/controllers/admin/topics_controller.rb
@@ -45,6 +45,22 @@ class Admin::TopicsController < Admin::ApplicationController
notice: _('Topic %{topic_name} was successfully removed.') % { topic_name: @topic.title_or_name }
end
+ def merge
+ source_topic = Projects::Topic.find(merge_params[:source_topic_id])
+ target_topic = Projects::Topic.find(merge_params[:target_topic_id])
+
+ begin
+ ::Topics::MergeService.new(source_topic, target_topic).execute
+ rescue ArgumentError => e
+ return render status: :bad_request, json: { type: :alert, message: e.message }
+ end
+
+ message = _('Topic %{source_topic} was successfully merged into topic %{target_topic}.')
+ redirect_to admin_topics_path,
+ status: :found,
+ notice: message % { source_topic: source_topic.name, target_topic: target_topic.name }
+ end
+
private
def topic
@@ -63,4 +79,8 @@ class Admin::TopicsController < Admin::ApplicationController
:title
]
end
+
+ def merge_params
+ params.permit([:source_topic_id, :target_topic_id])
+ end
end
diff --git a/app/controllers/admin/usage_trends_controller.rb b/app/controllers/admin/usage_trends_controller.rb
index 2cede1aec05..082b38ac3a8 100644
--- a/app/controllers/admin/usage_trends_controller.rb
+++ b/app/controllers/admin/usage_trends_controller.rb
@@ -1,9 +1,13 @@
# frozen_string_literal: true
class Admin::UsageTrendsController < Admin::ApplicationController
- include RedisTracking
+ include ProductAnalyticsTracking
- track_redis_hll_event :index, name: 'i_analytics_instance_statistics'
+ track_custom_event :index,
+ name: 'i_analytics_instance_statistics',
+ action: 'perform_analytics_usage_action',
+ label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
+ destinations: %i[redis_hll snowplow]
feature_category :devops_reports
@@ -11,4 +15,12 @@ class Admin::UsageTrendsController < Admin::ApplicationController
def index
end
+
+ def tracking_namespace_source
+ @group
+ end
+
+ def tracking_project_source
+ nil
+ end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 874eb8985fb..5cc0c8f3970 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -88,7 +88,7 @@ class Admin::UsersController < Admin::ApplicationController
result = Users::RejectService.new(current_user).execute(user)
if result[:status] == :success
- redirect_to admin_users_path, status: :found, notice: _("You've rejected %{user}" % { user: user.name })
+ redirect_back_or_admin_user(notice: _("You've rejected %{user}" % { user: user.name }))
else
redirect_back_or_admin_user(alert: result[:message])
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 6d1ffc1f2e8..88592efcec7 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -5,7 +5,6 @@ class AutocompleteController < ApplicationController
skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches]
before_action :check_search_rate_limit!, only: [:users, :projects]
- before_action :authorize_admin_project, only: :deploy_keys_with_owners
feature_category :users, [:users, :user]
feature_category :projects, [:projects]
@@ -61,7 +60,9 @@ class AutocompleteController < ApplicationController
end
def deploy_keys_with_owners
- deploy_keys = DeployKey.with_write_access_for_project(project)
+ deploy_keys = Autocomplete::DeployKeysWithWriteAccessFinder
+ .new(current_user, project)
+ .execute
render json: DeployKeys::BasicDeployKeySerializer.new.represent(
deploy_keys, { with_owner: true, user: current_user }
@@ -70,10 +71,6 @@ class AutocompleteController < ApplicationController
private
- def authorize_admin_project
- render_403 unless Ability.allowed?(current_user, :admin_project, project)
- end
-
def project
@project ||= Autocomplete::ProjectFinder
.new(current_user, params)
diff --git a/app/controllers/concerns/accepts_pending_invitations.rb b/app/controllers/concerns/accepts_pending_invitations.rb
index 5601b7a7f79..53dec698fa0 100644
--- a/app/controllers/concerns/accepts_pending_invitations.rb
+++ b/app/controllers/concerns/accepts_pending_invitations.rb
@@ -3,12 +3,12 @@
module AcceptsPendingInvitations
extend ActiveSupport::Concern
- def accept_pending_invitations
- return unless resource.active_for_authentication?
+ def accept_pending_invitations(user: resource)
+ return unless user.active_for_authentication?
- if resource.pending_invitations.load.any?
- resource.accept_pending_invitations!
- clear_stored_location_for_resource
+ if user.pending_invitations.load.any?
+ user.accept_pending_invitations!
+ clear_stored_location_for(user: user)
after_pending_invitations_hook
end
end
@@ -17,8 +17,8 @@ module AcceptsPendingInvitations
# no-op
end
- def clear_stored_location_for_resource
- session_key = stored_location_key_for(resource)
+ def clear_stored_location_for(user:)
+ session_key = stored_location_key_for(user)
session.delete(session_key)
end
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index 0fb77e2aaf4..b6ba1b13cc3 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -98,8 +98,7 @@ module CreatesCommit
project_new_merge_request_path(
@project_to_commit_into,
merge_request: {
- source_project_id: @project_to_commit_into.id,
- target_project_id: target_project.id,
+ target_project_id: @project_to_commit_into.default_merge_request_target.id,
source_branch: @branch_name,
target_branch: @start_branch
}
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index a5e49b1b16a..f1d80e37674 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -171,7 +171,7 @@ module IssuableActions
discussions = Discussion.build_collection(notes, issuable)
if issuable.is_a?(MergeRequest)
- render_cached(discussions, with: discussion_serializer, cache_context: -> (_) { discussion_cache_context }, context: self)
+ render_mr_discussions(discussions, discussion_serializer, discussion_cache_context)
elsif issuable.is_a?(Issue)
render json: discussion_serializer.represent(discussions, context: self) if stale?(etag: [discussion_cache_context, discussions])
else
@@ -182,6 +182,20 @@ module IssuableActions
private
+ def render_mr_discussions(discussions, serializer, cache_context)
+ return unless stale?(etag: [cache_context, discussions])
+
+ if Feature.enabled?(:disabled_mr_discussions_redis_cache, project)
+ render json: serializer.represent(discussions, context: self)
+ else
+ render_cached_discussions(discussions, serializer, cache_context)
+ end
+ end
+
+ def render_cached_discussions(discussions, serializer, cache_context)
+ render_cached(discussions, with: serializer, cache_context: -> (_) { cache_context }, context: self)
+ end
+
def paginated_discussions
return if params[:per_page].blank?
return if issuable.instance_of?(MergeRequest) && Feature.disabled?(:paginated_mr_discussions, project)
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 928c617471b..b595c3c6790 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -217,7 +217,8 @@ module NotesActions
:note,
:line_code, # LegacyDiffNote
:position, # DiffNote
- :confidential
+ :confidential,
+ :internal
).tap do |create_params|
create_params.merge!(
params.permit(:merge_request_diff_head_sha, :in_reply_to_discussion_id)
diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb
index dc7ba8295b9..260b433cc6f 100644
--- a/app/controllers/concerns/product_analytics_tracking.rb
+++ b/app/controllers/concerns/product_analytics_tracking.rb
@@ -13,6 +13,14 @@ module ProductAnalyticsTracking
route_events_to(destinations, name, &block)
end
end
+
+ def track_custom_event(*controller_actions, name:, conditions: nil, action:, label:, destinations: [:redis_hll], &block)
+ custom_conditions = [:trackable_html_request?, *conditions]
+
+ after_action only: controller_actions, if: custom_conditions do
+ route_custom_events_to(destinations, name, action, label, &block)
+ end
+ end
end
private
@@ -25,13 +33,40 @@ module ProductAnalyticsTracking
end
end
+ def route_custom_events_to(destinations, name, action, label, &block)
+ track_unique_redis_hll_event(name, &block) if destinations.include?(:redis_hll)
+
+ return unless destinations.include?(:snowplow) && event_enabled?(name)
+
+ optional_arguments = {
+ namespace: tracking_namespace_source,
+ project: tracking_project_source
+ }.compact
+
+ Gitlab::Tracking.event(
+ self.class.to_s,
+ action,
+ user: current_user,
+ property: name,
+ label: label,
+ **optional_arguments
+ )
+ end
+
def event_enabled?(event)
events_to_ff = {
g_analytics_valuestream: :route_hll_to_snowplow,
i_search_paid: :route_hll_to_snowplow_phase2,
i_search_total: :route_hll_to_snowplow_phase2,
- i_search_advanced: :route_hll_to_snowplow_phase2
+ i_search_advanced: :route_hll_to_snowplow_phase2,
+ i_ecosystem_jira_service_list_issues: :route_hll_to_snowplow_phase2,
+ users_viewing_analytics_group_devops_adoption: :route_hll_to_snowplow_phase2,
+ i_analytics_dev_ops_adoption: :route_hll_to_snowplow_phase2,
+ i_analytics_dev_ops_score: :route_hll_to_snowplow_phase2,
+ p_analytics_merge_request: :route_hll_to_snowplow_phase2,
+ i_analytics_instance_statistics: :route_hll_to_snowplow_phase2,
+ g_analytics_contribution: :route_hll_to_snowplow_phase2
}
Feature.enabled?(events_to_ff[event.to_sym], tracking_namespace_source)
diff --git a/app/controllers/concerns/redis_tracking.rb b/app/controllers/concerns/redis_tracking.rb
index c1135d2f759..445e72b8266 100644
--- a/app/controllers/concerns/redis_tracking.rb
+++ b/app/controllers/concerns/redis_tracking.rb
@@ -29,7 +29,7 @@ module RedisTracking
private
def track_unique_redis_hll_event(event_name, &block)
- custom_id = block_given? ? yield(self) : nil
+ custom_id = block ? yield(self) : nil
unique_id = custom_id || visitor_id
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index f914e804e18..e98d36854f1 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -143,10 +143,8 @@ module UploadsActions
end
def bypass_auth_checks_on_uploads?
- if ::Feature.enabled?(:enforce_auth_checks_on_uploads, target_project)
- if target_project && !target_project.public? && target_project.enforce_auth_checks_on_uploads?
- return false
- end
+ if target_project && !target_project.public? && target_project.enforce_auth_checks_on_uploads?
+ return false
end
action_name == 'show' && embeddable?
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 0fbceb43be1..e64d838b7d1 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -10,8 +10,8 @@ class Groups::BoardsController < Groups::ApplicationController
push_frontend_feature_flag(:board_multi_select, group)
push_frontend_feature_flag(:realtime_labels, group)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
- e.control { }
- e.candidate { }
+ e.control {}
+ e.candidate {}
end.run
end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index b1afac1f1c7..e164a834519 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -10,6 +10,9 @@ module Groups
before_action :define_variables, only: [:show]
before_action :push_licensed_features, only: [:show]
before_action :assign_variables_to_gon, only: [:show]
+ before_action do
+ push_frontend_feature_flag(:ci_variable_settings_graphql, @group)
+ end
feature_category :continuous_integration
urgency :low
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 1e23db9f32b..220b0b4509c 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -46,7 +46,7 @@ module Groups
end
def group_variables_params
- params.permit(variables_attributes: [*variable_params_attributes])
+ params.permit(variables_attributes: Array(variable_params_attributes))
end
def variable_params_attributes
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 327b4832f31..32b187c3260 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -34,6 +34,10 @@ class GroupsController < Groups::ApplicationController
before_action :track_experiment_event, only: [:new]
+ before_action only: :issues do
+ push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?)
+ end
+
helper_method :captcha_required?
skip_cross_project_access_check :index, :new, :create, :edit, :update,
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index 2d607fb7ff7..893c0b6ac54 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -47,7 +47,14 @@ class Import::BulkImportsController < ApplicationController
end
def create
- responses = create_params.map { |entry| ::BulkImports::CreateService.new(current_user, entry, credentials).execute }
+ responses = create_params.map do |entry|
+ if entry[:destination_name]
+ entry[:destination_slug] ||= entry[:destination_name]
+ entry.delete(:destination_name)
+ end
+
+ ::BulkImports::CreateService.new(current_user, entry, credentials).execute
+ end
render json: responses.map { |response| { success: response.success?, id: response.payload[:id], message: response.message } }
end
@@ -100,6 +107,7 @@ class Import::BulkImportsController < ApplicationController
source_type
source_full_path
destination_name
+ destination_slug
destination_namespace
]
end
diff --git a/app/controllers/oauth/token_info_controller.rb b/app/controllers/oauth/token_info_controller.rb
index 789356f4410..626184150bd 100644
--- a/app/controllers/oauth/token_info_controller.rb
+++ b/app/controllers/oauth/token_info_controller.rb
@@ -9,7 +9,7 @@ class Oauth::TokenInfoController < Doorkeeper::TokenInfoController
# maintain backwards compatibility
render json: token_json.merge(
- 'scopes' => token_json[:scope],
+ 'scopes' => token_json[:scope],
'expires_in_seconds' => token_json[:expires_in]
), status: :ok
else
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 45decccfc36..817f272d458 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -6,6 +6,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthHelper
include InitializesCurrentUserMode
include KnownSignIn
+ include AcceptsPendingInvitations
after_action :verify_known_sign_in
@@ -25,7 +26,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
# the number of failed sign in attempts
def failure
if params[:username].present? && AuthHelper.form_based_provider?(failed_strategy.name)
- user = User.by_login(params[:username])
+ user = User.find_by_login(params[:username])
user&.increment_failed_attempts!
log_failed_login(params[:username], failed_strategy.name)
@@ -159,6 +160,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def sign_in_user_flow(auth_user_class)
auth_user = build_auth_user(auth_user_class)
+ new_user = auth_user.new?
user = auth_user.find_and_update!
if auth_user.valid_sign_in?
@@ -178,6 +180,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.')
end
+ accept_pending_invitations(user: user) if new_user
store_after_sign_up_path_for_user if intent_to_register?
sign_in_and_redirect(user, event: :authentication)
end
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 1a8908e8571..07d786ab060 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -3,10 +3,6 @@
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
feature_category :authentication_and_authorization
- before_action do
- push_frontend_feature_flag(:personal_access_tokens_scoped_to_projects, current_user)
- end
-
def index
set_index_vars
scopes = params[:scopes].split(',').map(&:squish).select(&:present?).map(&:to_sym) unless params[:scopes].nil?
@@ -62,7 +58,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
end
def active_personal_access_tokens
- tokens = finder(state: 'active', sort: 'expires_at_asc').execute
+ tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute
if Feature.enabled?('access_token_pagination')
tokens = tokens.page(page)
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index 2e71b4801ed..0b7d4626c6d 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -234,7 +234,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def groups_notification(groups)
group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence
- leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete}.to_sentence
+ leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete }.to_sentence
s_(%{The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.})
.html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe }
diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb
index 82fff287c4a..f3283c88740 100644
--- a/app/controllers/projects/alerting/notifications_controller.rb
+++ b/app/controllers/projects/alerting/notifications_controller.rb
@@ -13,8 +13,6 @@ module Projects
prepend_before_action :repository, :project_without_auth
feature_category :incident_management
- # Goal is to increase the urgency to medium.
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/361310.
urgency :low, [:create]
def create
diff --git a/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb
index 7b38c069a60..ab2cf3abdde 100644
--- a/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb
+++ b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb
@@ -2,6 +2,7 @@
class Projects::Analytics::CycleAnalytics::StagesController < Projects::ApplicationController
include ::Analytics::CycleAnalytics::StageActions
+ include Gitlab::Utils::StrongMemoize
extend ::Gitlab::Utils::Override
respond_to :json
@@ -10,6 +11,7 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::Applicat
before_action :authorize_read_cycle_analytics!
before_action :only_default_value_stream_is_allowed!
+ before_action :authorize_stage!, only: [:median, :count, :average, :records]
urgency :low
@@ -25,7 +27,26 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::Applicat
Analytics::CycleAnalytics::ProjectValueStream
end
+ override :cycle_analytics_configuration
+ def cycle_analytics_configuration(stages)
+ super(stages.select { |stage| permitted_stage?(stage) })
+ end
+
def only_default_value_stream_is_allowed!
render_404 if params[:value_stream_id] != Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME
end
+
+ def permitted_stage?(stage)
+ permissions[stage.name.to_sym] # name matches the permission key (only when default stages are used)
+ end
+
+ def permissions
+ strong_memoize(:permissions) do
+ Gitlab::CycleAnalytics::Permissions.new(user: current_user, project: parent).get
+ end
+ end
+
+ def authorize_stage!
+ render_403 unless permitted_stage?(stage)
+ end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 97aae56c4ec..f5188e28b81 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -43,6 +43,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
+ push_frontend_feature_flag(:file_line_blame, @project)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 36986a714fb..82b35a22669 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -10,8 +10,8 @@ class Projects::BoardsController < Projects::ApplicationController
push_frontend_feature_flag(:board_multi_select, project)
push_frontend_feature_flag(:realtime_labels, project&.group)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
- e.control { }
- e.candidate { }
+ e.control {}
+ e.candidate {}
end.run
end
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 85e258b62e8..84e5d59a2c3 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -4,7 +4,6 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
before_action do
push_frontend_feature_flag(:schema_linting, @project)
- push_frontend_feature_flag(:simulate_pipeline, @project)
end
feature_category :pipeline_authoring
diff --git a/app/controllers/projects/ci/secure_files_controller.rb b/app/controllers/projects/ci/secure_files_controller.rb
deleted file mode 100644
index 59ddca19081..00000000000
--- a/app/controllers/projects/ci/secure_files_controller.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::Ci::SecureFilesController < Projects::ApplicationController
- before_action :authorize_read_secure_files!
-
- feature_category :pipeline_authoring
-
- def show
- render_404 unless Feature.enabled?(:ci_secure_files, project)
- end
-end
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 09a06aaed8c..d7fd65f02a8 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -88,7 +88,7 @@ class Projects::CompareController < Projects::ApplicationController
# target == start_ref == from
def target_project
strong_memoize(:target_project) do
- next source_project unless compare_params.key?(:from_project_id)
+ next source_project.default_merge_request_target unless compare_params.key?(:from_project_id)
next source_project if compare_params[:from_project_id].to_i == source_project.id
target_project = target_projects(source_project).find_by_id(compare_params[:from_project_id])
diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb
index 1d1fe91ad70..16392775c09 100644
--- a/app/controllers/projects/feature_flags_controller.rb
+++ b/app/controllers/projects/feature_flags_controller.rb
@@ -111,9 +111,9 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
.permit(:name, :description, :active,
scopes_attributes: [:id, :environment_scope, :active, :_destroy,
strategies: [:name, parameters: [:groupId, :percentage, :userIds]]],
- strategies_attributes: [:id, :name, :user_list_id, :_destroy,
- parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
- scopes_attributes: [:id, :environment_scope, :_destroy]])
+ strategies_attributes: [:id, :name, :user_list_id, :_destroy,
+ parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
+ scopes_attributes: [:id, :environment_scope, :_destroy]])
end
def feature_flag_json(feature_flag)
diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb
index 050b26a40c7..d1eb86c5e49 100644
--- a/app/controllers/projects/google_cloud/base_controller.rb
+++ b/app/controllers/projects/google_cloud/base_controller.rb
@@ -80,4 +80,16 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
Gitlab::Tracking.event('Projects::GoogleCloud', action, **options)
end
+
+ def gcp_projects
+ google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+ google_api_client.list_projects
+ end
+
+ def refs
+ params = { per_page: 50 }
+ branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true)
+ tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true)
+ (branches + tags).map(&:name)
+ end
end
diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb
index fa672058247..8d252c35031 100644
--- a/app/controllers/projects/google_cloud/configuration_controller.rb
+++ b/app/controllers/projects/google_cloud/configuration_controller.rb
@@ -4,7 +4,6 @@ module Projects
module GoogleCloud
class ConfigurationController < Projects::GoogleCloud::BaseController
def index
- @google_cloud_path = project_google_cloud_configuration_path(project)
js_data = {
configurationUrl: project_google_cloud_configuration_path(project),
deploymentsUrl: project_google_cloud_deployments_path(project),
diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb
index 711409e7550..7b1cf6e5ce1 100644
--- a/app/controllers/projects/google_cloud/databases_controller.rb
+++ b/app/controllers/projects/google_cloud/databases_controller.rb
@@ -4,7 +4,6 @@ module Projects
module GoogleCloud
class DatabasesController < Projects::GoogleCloud::BaseController
def index
- @google_cloud_path = project_google_cloud_configuration_path(project)
js_data = {
configurationUrl: project_google_cloud_configuration_path(project),
deploymentsUrl: project_google_cloud_deployments_path(project),
diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb
index 4aa17b36fad..1ac4697a63f 100644
--- a/app/controllers/projects/google_cloud/deployments_controller.rb
+++ b/app/controllers/projects/google_cloud/deployments_controller.rb
@@ -4,7 +4,6 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base
before_action :validate_gcp_token!
def index
- @google_cloud_path = project_google_cloud_configuration_path(project)
js_data = {
configurationUrl: project_google_cloud_configuration_path(project),
deploymentsUrl: project_google_cloud_deployments_path(project),
@@ -40,9 +39,9 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base
redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params)
end
end
- rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
- track_event('deployments#cloud_run', 'error_gcp', error)
- flash[:warning] = _('Google Cloud Error - %{error}') % { error: error }
+ rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e
+ track_event('deployments#cloud_run', 'error_gcp', e)
+ flash[:warning] = _('Google Cloud Error - %{error}') % { error: e }
redirect_to project_google_cloud_deployments_path(project)
end
diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
index 3fbe9a96284..39f33624804 100644
--- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb
+++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
@@ -9,13 +9,7 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC
GCP_REGION_CI_VAR_KEY = 'GCP_REGION'
def index
- @google_cloud_path = project_google_cloud_configuration_path(project)
- params = { per_page: 50 }
- branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true)
- tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true)
- refs = (branches + tags).map(&:name)
js_data = {
- screen: 'gcp_regions_form',
availableRegions: AVAILABLE_REGIONS,
refs: refs,
cancelPath: project_google_cloud_configuration_path(project)
diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb
index dbd83be19db..7f25054177e 100644
--- a/app/controllers/projects/google_cloud/service_accounts_controller.rb
+++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb
@@ -4,22 +4,12 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
before_action :validate_gcp_token!
def index
- @google_cloud_path = project_google_cloud_configuration_path(project)
- google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
- gcp_projects = google_api_client.list_projects
-
if gcp_projects.empty?
- @js_data = { screen: 'no_gcp_projects' }.to_json
track_event('service_accounts#index', 'error_form', 'no_gcp_projects')
flash[:warning] = _('No Google Cloud projects - You need at least one Google Cloud project')
redirect_to project_google_cloud_configuration_path(project)
else
- params = { per_page: 50 }
- branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true)
- tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true)
- refs = (branches + tags).map(&:name)
js_data = {
- screen: 'service_accounts_form',
gcpProjects: gcp_projects,
refs: refs,
cancelPath: project_google_cloud_configuration_path(project)
@@ -28,9 +18,9 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
track_event('service_accounts#index', 'success', js_data)
end
- rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
- track_event('service_accounts#index', 'error_gcp', error)
- flash[:warning] = _('Google Cloud Error - %{error}') % { error: error }
+ rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e
+ track_event('service_accounts#index', 'error_gcp', e)
+ flash[:warning] = _('Google Cloud Error - %{error}') % { error: e }
redirect_to project_google_cloud_configuration_path(project)
end
@@ -47,9 +37,9 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
track_event('service_accounts#create', 'success', response)
redirect_to project_google_cloud_configuration_path(project), notice: response.message
- rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
- track_event('service_accounts#create', 'error_gcp', error)
- flash[:warning] = _('Google Cloud Error - %{error}') % { error: error }
+ rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e
+ track_event('service_accounts#create', 'error_gcp', e)
+ flash[:warning] = _('Google Cloud Error - %{error}') % { error: e }
redirect_to project_google_cloud_configuration_path(project)
end
end
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index f9fa8046962..36b52533e78 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -9,7 +9,7 @@ class Projects::IncidentsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:incident_timeline, @project)
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
- push_frontend_feature_flag(:work_items_mvc_2)
+ push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:work_items_hierarchy, @project)
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index f1c9e2b2653..d19db2b11ab 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -44,12 +44,16 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:incident_timeline, project)
end
+ before_action only: [:index, :show] do
+ push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
+ end
+
before_action only: :show do
push_frontend_feature_flag(:issue_assignees_widget, project)
push_frontend_feature_flag(:realtime_labels, project)
- push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
- push_frontend_feature_flag(:work_items_mvc_2)
+ push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:work_items_hierarchy, project)
+ push_force_frontend_feature_flag(:work_items_create_from_markdown, project&.work_items_create_from_markdown_feature_flag_enabled?)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -239,12 +243,12 @@ class Projects::IssuesController < Projects::ApplicationController
end
def import_csv
- if uploader = UploadService.new(project, params[:file]).execute
- ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker
+ result = Issues::PrepareImportCsvService.new(project, current_user, file: params[:file]).execute
- flash[:notice] = _("Your issues are being imported. Once finished, you'll get a confirmation email.")
+ if result.success?
+ flash[:notice] = result.message
else
- flash[:alert] = _("File upload error.")
+ flash[:alert] = result.message
end
redirect_to project_issues_path(project)
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index ad59f421c06..7878ace5015 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -18,7 +18,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
- before_action :push_job_log_search, only: [:show]
+ before_action :push_job_log_jump_to_failures, only: [:show]
before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase]
layout 'project'
@@ -249,7 +249,7 @@ class Projects::JobsController < Projects::ApplicationController
::Gitlab::Workhorse.channel_websocket(service)
end
- def push_job_log_search
- push_frontend_feature_flag(:job_log_search, @project)
+ def push_job_log_jump_to_failures
+ push_frontend_feature_flag(:job_log_jump_to_failures, @project)
end
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 0dcc2bc3181..279fd4c457e 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -48,20 +48,24 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
allow_tree_conflicts: display_merge_conflicts_in_diff?
}
- if diff_options_hash[:paths].blank?
- # NOTE: Any variables that would affect the resulting json needs to be added to the cache_context to avoid stale cache issues.
- cache_context = [
- current_user&.cache_key,
- unfoldable_positions.map(&:to_h),
- diff_view,
- params[:w],
- params[:expanded],
- params[:page],
- params[:per_page],
- options[:merge_ref_head_diff],
- options[:allow_tree_conflicts]
- ]
+ # NOTE: Any variables that would affect the resulting json needs to be added to the cache_context to avoid stale cache issues.
+ cache_context = [
+ current_user&.cache_key,
+ unfoldable_positions.map(&:to_h),
+ diff_view,
+ params[:w],
+ params[:expanded],
+ params[:page],
+ params[:per_page],
+ options[:merge_ref_head_diff],
+ options[:allow_tree_conflicts]
+ ]
+
+ if Feature.enabled?(:etag_merge_request_diff_batches, @merge_request.project)
+ return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs])
+ end
+ if diff_options_hash[:paths].blank?
render_cached(
diffs,
with: PaginatedDiffSerializer.new(current_user: current_user),
diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb
index db7557674b2..ff6b6bfaf27 100644
--- a/app/controllers/projects/merge_requests/drafts_controller.rb
+++ b/app/controllers/projects/merge_requests/drafts_controller.rb
@@ -72,9 +72,9 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
strong_memoize(:draft_note) do
draft_notes.find(params[:id])
end
- rescue ActiveRecord::RecordNotFound => ex
+ rescue ActiveRecord::RecordNotFound => e
# draft_note is allowed to be nil in #publish
- raise ex unless allow_nil
+ raise e unless allow_nil
end
def draft_notes
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index a2f018c013b..870c57fd6f3 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -34,16 +34,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action only: [:show] do
push_frontend_feature_flag(:merge_request_widget_graphql, project)
push_frontend_feature_flag(:core_security_mr_widget_counts, project)
- push_frontend_feature_flag(:restructured_mr_widget, project)
push_frontend_feature_flag(:refactor_mr_widgets_extensions, project)
push_frontend_feature_flag(:refactor_code_quality_extension, project)
push_frontend_feature_flag(:refactor_mr_widget_test_summary, project)
- push_frontend_feature_flag(:rebase_without_ci_ui, project)
push_frontend_feature_flag(:issue_assignees_widget, @project)
push_frontend_feature_flag(:realtime_labels, project)
push_frontend_feature_flag(:refactor_security_extension, @project)
push_frontend_feature_flag(:refactor_code_quality_inline_findings, project)
- push_frontend_feature_flag(:mr_attention_requests, current_user)
push_frontend_feature_flag(:moved_mr_sidebar, project)
push_frontend_feature_flag(:paginated_mr_discussions, project)
push_frontend_feature_flag(:mr_review_submit_comment, project)
@@ -367,7 +364,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def rebase
- @merge_request.rebase_async(current_user.id)
+ @merge_request.rebase_async(current_user.id, skip_ci: Gitlab::Utils.to_boolean(merge_params[:skip_ci], default: false))
head :ok
rescue MergeRequest::RebaseLockTimeout => e
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 744e45a0f9c..cfb67b7b4ff 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -92,8 +92,8 @@ class Projects::MilestonesController < Projects::ApplicationController
render json: { url: project_milestones_path(project) }
end
end
- rescue Milestones::PromoteService::PromoteMilestoneError => error
- redirect_to milestone, alert: error.message
+ rescue Milestones::PromoteService::PromoteMilestoneError => e
+ redirect_to milestone, alert: e.message
end
def flash_notice_for(milestone, group)
diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb
index bcb6b574d5a..acbd26cbdf6 100644
--- a/app/controllers/projects/mirrors_controller.rb
+++ b/app/controllers/projects/mirrors_controller.rb
@@ -58,8 +58,8 @@ class Projects::MirrorsController < Projects::ApplicationController
else
render json: lookup
end
- rescue ArgumentError => err
- render json: { message: err.message }, status: :bad_request
+ rescue ArgumentError => e
+ render json: { message: e.message }, status: :bad_request
end
private
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 4bd33882eee..0e990b64cd6 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -10,11 +10,29 @@ class Projects::PagesController < Projects::ApplicationController
feature_category :pages
- # rubocop: disable CodeReuse/ActiveRecord
+ def new
+ @pipeline_wizard_data = {
+ project_path: @project.full_path,
+ default_branch: @project.repository.root_ref,
+ redirect_to_when_done: project_pages_path(@project)
+ }
+ end
+
def show
+ unless @project.pages_enabled?
+ render :disabled
+ return
+ end
+
+ if @project.pages_show_onboarding?
+ redirect_to action: 'new'
+ return
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
@domains = @project.pages_domains.order(:domain).present(current_user: current_user)
+ # rubocop: enable CodeReuse/ActiveRecord
end
- # rubocop: enable CodeReuse/ActiveRecord
def destroy
::Pages::DeleteService.new(@project, current_user).execute
diff --git a/app/controllers/projects/pipelines/stages_controller.rb b/app/controllers/projects/pipelines/stages_controller.rb
index 0447bbf29e7..c94d468cf2e 100644
--- a/app/controllers/projects/pipelines/stages_controller.rb
+++ b/app/controllers/projects/pipelines/stages_controller.rb
@@ -4,6 +4,7 @@ module Projects
module Pipelines
class StagesController < Projects::Pipelines::ApplicationController
before_action :authorize_update_pipeline!
+ before_action :stage, only: [:play_manual]
urgency :low, [
:play_manual
@@ -26,7 +27,7 @@ module Projects
private
def stage
- @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name])
+ @stage ||= pipeline.stage(params[:stage_name]).presence || render_404
end
end
end
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 9fc75fff807..33ce37ef4fb 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -35,8 +35,8 @@ class Projects::RepositoriesController < Projects::ApplicationController
return if archive_not_modified?
send_git_archive @repository, **repo_params
- rescue StandardError => ex
- logger.error("#{self.class.name}: #{ex}")
+ rescue StandardError => e
+ logger.error("#{self.class.name}: #{e}")
git_not_found!
end
diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb
index 34ce8df202b..5946c43b134 100644
--- a/app/controllers/projects/runner_projects_controller.rb
+++ b/app/controllers/projects/runner_projects_controller.rb
@@ -15,7 +15,7 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
path = project_runners_path(project)
- if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute
+ if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute.success?
redirect_to path, notice: s_('Runners|Runner assigned to project.')
else
assign_to_messages = @runner.errors.messages[:assign_to]
diff --git a/app/controllers/projects/settings/integration_hook_logs_controller.rb b/app/controllers/projects/settings/integration_hook_logs_controller.rb
index b3b5a292d42..1e42fbce4c4 100644
--- a/app/controllers/projects/settings/integration_hook_logs_controller.rb
+++ b/app/controllers/projects/settings/integration_hook_logs_controller.rb
@@ -20,7 +20,7 @@ module Projects
override :hook
def hook
- @hook ||= integration.service_hook || not_found
+ @hook ||= integration.try(:service_hook) || not_found
end
end
end
diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb
index cee9e9feb7b..03ef434456f 100644
--- a/app/controllers/projects/settings/integrations_controller.rb
+++ b/app/controllers/projects/settings/integrations_controller.rb
@@ -122,7 +122,7 @@ module Projects
end
def web_hook_logs
- return unless integration.service_hook.present?
+ return unless integration.try(:service_hook).present?
@web_hook_logs ||= integration.service_hook.web_hook_logs.recent.page(params[:page])
end
diff --git a/app/controllers/projects/settings/packages_and_registries_controller.rb b/app/controllers/projects/settings/packages_and_registries_controller.rb
index d3c08bef808..76c9cead360 100644
--- a/app/controllers/projects/settings/packages_and_registries_controller.rb
+++ b/app/controllers/projects/settings/packages_and_registries_controller.rb
@@ -14,11 +14,22 @@ module Projects
def show
end
+ def cleanup_tags
+ registry_settings_enabled!
+
+ @hide_search_settings = true
+ end
+
private
def packages_and_registries_settings_enabled!
render_404 unless can?(current_user, :view_package_registry_project_settings, project)
end
+
+ def registry_settings_enabled!
+ render_404 unless Gitlab.config.registry.enabled &&
+ can?(current_user, :admin_container_image, project)
+ end
end
end
end
diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb
deleted file mode 100644
index adeadf2133e..00000000000
--- a/app/controllers/projects/tags/releases_controller.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: remove this file together with FF https://gitlab.com/gitlab-org/gitlab/-/issues/366244
-# also delete view/routes
-class Projects::Tags::ReleasesController < Projects::ApplicationController
- # Authorize
- before_action :require_non_empty_project
- before_action :authorize_download_code!
- before_action :authorize_push_code!
- before_action :tag
- before_action :release
-
- feature_category :release_evidence
- urgency :low
-
- def edit
- end
-
- def update
- release.update(release_params) if release.persisted? || release_params[:description].present?
-
- redirect_to project_tag_path(@project, tag.name)
- end
-
- private
-
- def tag
- @tag ||= @repository.find_tag(params[:tag_id])
- end
-
- def release
- @release ||= Releases::CreateService.new(project, current_user, tag: @tag.name)
- .find_or_build_release
- end
-
- def release_params
- params.require(:release).permit(:description)
- end
-end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index ce51cbb6677..fea2689db14 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -19,6 +19,7 @@ class Projects::TreeController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:lazy_load_commits, @project)
push_frontend_feature_flag(:highlight_js, @project)
+ push_frontend_feature_flag(:file_line_blame, @project)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index e7bccf5a243..a8f062bd7c1 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -43,7 +43,7 @@ class Projects::VariablesController < Projects::ApplicationController
end
def variables_params
- params.permit(variables_attributes: [*variable_params_attributes])
+ params.permit(variables_attributes: Array(variable_params_attributes))
end
def variable_params_attributes
diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb
index ba23af41bb0..b794785f285 100644
--- a/app/controllers/projects/work_items_controller.rb
+++ b/app/controllers/projects/work_items_controller.rb
@@ -3,7 +3,7 @@
class Projects::WorkItemsController < Projects::ApplicationController
before_action do
push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
- push_frontend_feature_flag(:work_items_mvc_2)
+ push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:work_items_hierarchy, project)
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 37e472050a0..8a6bcb4b3fc 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -37,17 +37,18 @@ class ProjectsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:lazy_load_commits, @project)
push_frontend_feature_flag(:highlight_js, @project)
+ push_frontend_feature_flag(:file_line_blame, @project)
push_frontend_feature_flag(:increase_page_size_exponentially, @project)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies)
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
- push_frontend_feature_flag(:work_items_mvc_2)
+ push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:package_registry_access_level)
push_frontend_feature_flag(:work_items_hierarchy, @project)
end
before_action only: :edit do
- push_frontend_feature_flag(:enforce_auth_checks_on_uploads, @project)
+ push_frontend_feature_flag(:split_operations_visibility_permissions, @project)
end
layout :determine_layout
@@ -197,8 +198,8 @@ class ProjectsController < Projects::ApplicationController
flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name }
redirect_to dashboard_projects_path, status: :found
- rescue Projects::DestroyService::DestroyError => ex
- redirect_to edit_project_path(@project), status: :found, alert: ex.message
+ rescue Projects::DestroyService::DestroyError => e
+ redirect_to edit_project_path(@project), status: :found, alert: e.message
end
def new_issuable_address
@@ -231,10 +232,10 @@ class ProjectsController < Projects::ApplicationController
project_path(@project),
notice: _("Housekeeping successfully started")
)
- rescue ::Repositories::HousekeepingService::LeaseTaken => ex
+ rescue ::Repositories::HousekeepingService::LeaseTaken => e
redirect_to(
edit_project_path(@project, anchor: 'js-project-advanced-settings'),
- alert: ex.to_s
+ alert: e.to_s
)
end
@@ -245,10 +246,10 @@ class ProjectsController < Projects::ApplicationController
edit_project_path(@project, anchor: 'js-export-project'),
notice: _("Project export started. A download link will be sent by email and made available on this page.")
)
- rescue Project::ExportLimitExceeded => ex
+ rescue Project::ExportLimitExceeded => e
redirect_to(
edit_project_path(@project, anchor: 'js-export-project'),
- alert: ex.to_s
+ alert: e.to_s
)
end
@@ -420,10 +421,19 @@ class ProjectsController < Projects::ApplicationController
pages_access_level
metrics_dashboard_access_level
analytics_access_level
- operations_access_level
security_and_compliance_access_level
container_registry_access_level
- ]
+ ] + operations_feature_attributes
+ end
+
+ def operations_feature_attributes
+ if Feature.enabled?(:split_operations_visibility_permissions, project)
+ %i[
+ environments_access_level feature_flags_access_level releases_access_level
+ ]
+ else
+ %i[operations_access_level]
+ end
end
def project_setting_attributes
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index bb16c2d2098..33d2c482795 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -21,6 +21,7 @@ class RegistrationsController < Devise::RegistrationsController
before_action only: [:new] do
push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops)
+ push_frontend_feature_flag(:trial_email_validation, type: :development)
end
feature_category :authentication_and_authorization
diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb
index 24520a187e3..8d7ba3e38c0 100644
--- a/app/controllers/repositories/git_http_client_controller.rb
+++ b/app/controllers/repositories/git_http_client_controller.rb
@@ -107,7 +107,7 @@ module Repositories
render plain: "HTTP Basic: Access denied\n" \
"You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}",
- status: :unauthorized
+ status: :unauthorized
end
def repository
diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb
index 7deda473b9d..83973d07a17 100644
--- a/app/controllers/repositories/lfs_api_controller.rb
+++ b/app/controllers/repositories/lfs_api_controller.rb
@@ -173,12 +173,12 @@ module Repositories
LfsObjectsProject.link_to_project!(lfs_object, project)
Gitlab::AppJsonLogger.info(message: "LFS object auto-linked to forked project",
- lfs_object_oid: lfs_object.oid,
- lfs_object_size: lfs_object.size,
- source_project_id: project.fork_source.id,
- source_project_path: project.fork_source.full_path,
- target_project_id: project.project_id,
- target_project_path: project.full_path)
+ lfs_object_oid: lfs_object.oid,
+ lfs_object_size: lfs_object.size,
+ source_project_id: project.fork_source.id,
+ source_project_path: project.fork_source.full_path,
+ target_project_id: project.project_id,
+ target_project_path: project.full_path)
end
end
end
diff --git a/app/controllers/repositories/lfs_locks_api_controller.rb b/app/controllers/repositories/lfs_locks_api_controller.rb
index 1d091a5bfcd..f36126d67ff 100644
--- a/app/controllers/repositories/lfs_locks_api_controller.rb
+++ b/app/controllers/repositories/lfs_locks_api_controller.rb
@@ -38,8 +38,8 @@ module Repositories
def render_json(data, process = true)
render json: build_payload(data, process),
- content_type: LfsRequest::CONTENT_TYPE,
- status: @result[:http_status]
+ content_type: LfsRequest::CONTENT_TYPE,
+ status: @result[:http_status]
end
def build_payload(data, process)
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 7a7e63f5fc4..5843e13c7cd 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -7,10 +7,14 @@ class SearchController < ApplicationController
include ProductAnalyticsTracking
include SearchRateLimitable
- RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete].freeze
+ RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete, :aggregations].freeze
track_event :show, name: 'i_search_total', destinations: [:redis_hll, :snowplow]
+ def self.search_rate_limited_endpoints
+ %i[show count autocomplete]
+ end
+
around_action :allow_gitaly_ref_name_caching
before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch
@@ -19,7 +23,7 @@ class SearchController < ApplicationController
search_term_present = params[:search].present? || params[:term].present?
search_term_present && !params[:project_id].present?
end
- before_action :check_search_rate_limit!, only: [:show, :count, :autocomplete]
+ before_action :check_search_rate_limit!, only: search_rate_limited_endpoints
rescue_from ActiveRecord::QueryCanceled, with: :render_timeout
@@ -32,8 +36,6 @@ class SearchController < ApplicationController
@project = search_service.project
@group = search_service.group
- return if params[:search].blank?
-
return unless search_term_valid?
return if check_single_commit_result?
@@ -53,7 +55,6 @@ class SearchController < ApplicationController
@search_results = @search_service.search_results
@search_objects = @search_service.search_objects
@search_highlight = @search_service.search_highlight
- @aggregations = @search_service.search_aggregations
end
increment_search_counters
@@ -83,8 +84,9 @@ class SearchController < ApplicationController
@project = search_service.project
@ref = params[:project_ref] if params[:project_ref].present?
+ @filter = params[:filter]
- render json: search_autocomplete_opts(term).to_json
+ render json: search_autocomplete_opts(term, filter: @filter).to_json
end
def opensearch
@@ -98,6 +100,8 @@ class SearchController < ApplicationController
end
def search_term_valid?
+ return false if params[:search].blank?
+
unless search_service.valid_query_length?
flash[:alert] = t('errors.messages.search_chars_too_long', count: Gitlab::Search::Params::SEARCH_CHAR_LIMIT)
return false
@@ -150,6 +154,7 @@ class SearchController < ApplicationController
payload[:metadata]['meta.search.filters.state'] = params[:state]
payload[:metadata]['meta.search.force_search_results'] = params[:force_search_results]
payload[:metadata]['meta.search.project_ids'] = params[:project_ids]
+ payload[:metadata]['meta.search.filters.language'] = params[:language]
payload[:metadata]['meta.search.type'] = @search_type if @search_type.present?
payload[:metadata]['meta.search.level'] = @search_level if @search_level.present?
payload[:metadata][:global_search_duration_s] = @global_search_duration_s if @global_search_duration_s.present?
@@ -205,7 +210,7 @@ class SearchController < ApplicationController
case action_name.to_sym
when :count
render json: {}, status: :request_timeout
- when :autocomplete
+ when :autocomplete, :aggregations
render json: [], status: :request_timeout
else
render status: :request_timeout
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 6195d152f00..fe3b8d9b8b4 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -215,11 +215,11 @@ class SessionsController < Devise::SessionsController
def find_user
strong_memoize(:find_user) do
if session[:otp_user_id] && user_params[:login]
- User.by_id_and_login(session[:otp_user_id], user_params[:login]).first
+ User.by_login(user_params[:login]).find_by_id(session[:otp_user_id])
elsif session[:otp_user_id]
User.find(session[:otp_user_id])
elsif user_params[:login]
- User.by_login(user_params[:login])
+ User.find_by_login(user_params[:login])
end
end
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 97bbb96eae6..09419a4589d 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -7,13 +7,13 @@ class UploadsController < ApplicationController
UnknownUploadModelError = Class.new(StandardError)
MODEL_CLASSES = {
- "user" => User,
- "project" => Project,
- "note" => Note,
- "group" => Group,
- "appearance" => Appearance,
+ "user" => User,
+ "project" => Project,
+ "note" => Note,
+ "group" => Group,
+ "appearance" => Appearance,
"personal_snippet" => PersonalSnippet,
- "projects/topic" => Projects::Topic,
+ "projects/topic" => Projects::Topic,
'alert_management_metric_image' => ::AlertManagement::MetricImage,
nil => PersonalSnippet
}.freeze
diff --git a/app/controllers/users/namespace_callouts_controller.rb b/app/controllers/users/namespace_callouts_controller.rb
new file mode 100644
index 00000000000..d4876382dfe
--- /dev/null
+++ b/app/controllers/users/namespace_callouts_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Users
+ class NamespaceCalloutsController < Users::CalloutsController
+ private
+
+ def callout
+ Users::DismissNamespaceCalloutService.new(
+ container: nil, current_user: current_user, params: callout_params
+ ).execute
+ end
+
+ def callout_params
+ params.permit(:namespace_id).merge(feature_name: feature_name)
+ end
+ end
+end
diff --git a/app/controllers/users/project_callouts_controller.rb b/app/controllers/users/project_callouts_controller.rb
new file mode 100644
index 00000000000..64d89630021
--- /dev/null
+++ b/app/controllers/users/project_callouts_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Users
+ class ProjectCalloutsController < Users::CalloutsController
+ private
+
+ def callout
+ Users::DismissProjectCalloutService.new(
+ container: nil, current_user: current_user, params: callout_params
+ ).execute
+ end
+
+ def callout_params
+ params.permit(:project_id).merge(feature_name: feature_name)
+ end
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index eaf08cd421b..3c1a3534912 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -155,7 +155,11 @@ class UsersController < ApplicationController
end
def calendar_activities
- @calendar_date = Date.parse(params[:date]) rescue Date.today
+ @calendar_date = begin
+ Date.parse(params[:date])
+ rescue StandardError
+ Date.today
+ end
@events = contributions_calendar.events_by_date(@calendar_date).map(&:present)
render 'calendar_activities', layout: false
diff --git a/app/events/groups/group_deleted_event.rb b/app/events/groups/group_deleted_event.rb
new file mode 100644
index 00000000000..d89cce17be9
--- /dev/null
+++ b/app/events/groups/group_deleted_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Groups
+ class GroupDeletedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'group_id' => { 'type' => 'integer' },
+ 'root_namespace_id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[group_id root_namespace_id]
+ }
+ end
+ end
+end
diff --git a/app/events/groups/group_path_changed_event.rb b/app/events/groups/group_path_changed_event.rb
new file mode 100644
index 00000000000..e8d9b733a0a
--- /dev/null
+++ b/app/events/groups/group_path_changed_event.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Groups
+ class GroupPathChangedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'group_id' => { 'type' => 'integer' },
+ 'root_namespace_id' => { 'type' => 'integer' },
+ 'old_path' => { 'type' => 'string' },
+ 'new_path' => { 'type' => 'string' }
+ },
+ 'required' => %w[group_id root_namespace_id old_path new_path]
+ }
+ end
+ end
+end
diff --git a/app/events/groups/group_transfered_event.rb b/app/events/groups/group_transfered_event.rb
new file mode 100644
index 00000000000..da573892108
--- /dev/null
+++ b/app/events/groups/group_transfered_event.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Groups
+ class GroupTransferedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'group_id' => { 'type' => 'integer' },
+ 'old_root_namespace_id' => { 'type' => 'integer' },
+ 'new_root_namespace_id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[group_id old_root_namespace_id new_root_namespace_id]
+ }
+ end
+ end
+end
diff --git a/app/events/merge_requests/approved_event.rb b/app/events/merge_requests/approved_event.rb
new file mode 100644
index 00000000000..c68a002dcc3
--- /dev/null
+++ b/app/events/merge_requests/approved_event.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class ApprovedEvent < Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'required' => %w[
+ current_user_id
+ merge_request_id
+ ],
+ 'properties' => {
+ 'current_user_id' => { 'type' => 'integer' },
+ 'merge_request_id' => { 'type' => 'integer' }
+ }
+ }
+ end
+ end
+end
diff --git a/app/events/projects/project_archived_event.rb b/app/events/projects/project_archived_event.rb
new file mode 100644
index 00000000000..9ac83fd791b
--- /dev/null
+++ b/app/events/projects/project_archived_event.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Projects
+ class ProjectArchivedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' },
+ 'namespace_id' => { 'type' => 'integer' },
+ 'root_namespace_id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[project_id namespace_id root_namespace_id]
+ }
+ end
+ end
+end
diff --git a/app/events/projects/project_transfered_event.rb b/app/events/projects/project_transfered_event.rb
new file mode 100644
index 00000000000..14cc53daabb
--- /dev/null
+++ b/app/events/projects/project_transfered_event.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Projects
+ class ProjectTransferedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' },
+ 'old_namespace_id' => { 'type' => 'integer' },
+ 'old_root_namespace_id' => { 'type' => 'integer' },
+ 'new_namespace_id' => { 'type' => 'integer' },
+ 'new_root_namespace_id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[
+ project_id
+ old_namespace_id
+ old_root_namespace_id
+ new_namespace_id
+ new_root_namespace_id
+ ]
+ }
+ end
+ end
+end
diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
index 51b81be672d..1bf3e15ba3b 100644
--- a/app/experiments/security_reports_mr_widget_prompt_experiment.rb
+++ b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment
- control { }
- candidate { }
+ control {}
+ candidate {}
def publish(_result = nil)
super
diff --git a/app/experiments/video_tutorials_continuous_onboarding_experiment.rb b/app/experiments/video_tutorials_continuous_onboarding_experiment.rb
index 3cb676b25f2..2c5790f83d1 100644
--- a/app/experiments/video_tutorials_continuous_onboarding_experiment.rb
+++ b/app/experiments/video_tutorials_continuous_onboarding_experiment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
class VideoTutorialsContinuousOnboardingExperiment < ApplicationExperiment
- control { }
- candidate { }
+ control {}
+ candidate {}
end
diff --git a/app/finders/autocomplete/deploy_keys_with_write_access_finder.rb b/app/finders/autocomplete/deploy_keys_with_write_access_finder.rb
new file mode 100644
index 00000000000..a123a0dc4f0
--- /dev/null
+++ b/app/finders/autocomplete/deploy_keys_with_write_access_finder.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Autocomplete
+ # Finder for retrieving deploy keys to use for autocomplete data sources.
+ class DeployKeysWithWriteAccessFinder
+ attr_reader :current_user, :project
+
+ def initialize(current_user, project)
+ @current_user = current_user
+ @project = project
+ end
+
+ def execute
+ return DeployKey.none if project.nil?
+
+ raise Gitlab::Access::AccessDeniedError unless current_user.can?(:admin_project, project)
+
+ project.deploy_keys.merge(DeployKeysProject.with_write_access)
+ end
+ end
+end
diff --git a/app/finders/ci/daily_build_group_report_results_finder.rb b/app/finders/ci/daily_build_group_report_results_finder.rb
index 33aefe29392..b93b7dbe0c5 100644
--- a/app/finders/ci/daily_build_group_report_results_finder.rb
+++ b/app/finders/ci/daily_build_group_report_results_finder.rb
@@ -82,14 +82,20 @@ module Ci
end
def start_date
- start_date = Date.strptime(params[:start_date], DATE_FORMAT_ALLOWED) rescue REPORT_WINDOW.ago.to_date
+ start_date = begin
+ Date.strptime(params[:start_date], DATE_FORMAT_ALLOWED)
+ rescue StandardError
+ REPORT_WINDOW.ago.to_date
+ end
# The start_date cannot be older than `end_date - 90 days`
[start_date, end_date - REPORT_WINDOW].max
end
def end_date
- Date.strptime(params[:end_date], DATE_FORMAT_ALLOWED) rescue Date.current
+ Date.strptime(params[:end_date], DATE_FORMAT_ALLOWED)
+ rescue StandardError
+ Date.current
end
end
end
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index 4f9244d9825..774947a35b7 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -69,10 +69,15 @@ module Ci
end
def filter_by_upgrade_status!
- return unless @params.key?(:upgrade_status)
- return unless Ci::RunnerVersion.statuses.key?(@params[:upgrade_status])
+ upgrade_status = @params[:upgrade_status]
- @runners = @runners.with_upgrade_status(@params[:upgrade_status])
+ return 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)
end
def filter_by_runner_type!
diff --git a/app/finders/crm/contacts_finder.rb b/app/finders/crm/contacts_finder.rb
index 29f3d6f0f16..58ec4cf8a47 100644
--- a/app/finders/crm/contacts_finder.rb
+++ b/app/finders/crm/contacts_finder.rb
@@ -16,6 +16,11 @@ module Crm
attr_reader :params, :current_user
+ def self.counts_by_state(current_user, params = {})
+ params = params.merge(sort: nil)
+ new(current_user, params).execute.counts_by_state
+ end
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params
@@ -28,11 +33,25 @@ module Crm
contacts = by_ids(contacts)
contacts = by_state(contacts)
contacts = by_search(contacts)
- contacts.sort_by_name
+ sort_contacts(contacts)
end
private
+ def sort_contacts(contacts)
+ return contacts.sort_by_name unless @params.key?(:sort)
+ return contacts if @params[:sort].nil?
+
+ field = @params[:sort][:field]
+ direction = @params[:sort][:direction]
+
+ if field == 'organization'
+ contacts.sort_by_organization(direction)
+ else
+ contacts.sort_by_field(field, direction)
+ end
+ end
+
def root_group
strong_memoize(:root_group) do
group = params[:group]&.root_ancestor
diff --git a/app/finders/fork_targets_finder.rb b/app/finders/fork_targets_finder.rb
index 0b5dfb16572..e129fde3748 100644
--- a/app/finders/fork_targets_finder.rb
+++ b/app/finders/fork_targets_finder.rb
@@ -6,17 +6,39 @@ class ForkTargetsFinder
@user = user
end
- # rubocop: disable CodeReuse/ActiveRecord
def execute(options = {})
- return ::Namespace.where(id: user.forkable_namespaces).sort_by_type unless options[:only_groups]
+ return previous_execute(options) unless Feature.enabled?(:searchable_fork_targets)
- ::Group.where(id: user.manageable_groups(include_groups_with_developer_maintainer_access: true))
+ items = fork_targets(options)
+
+ by_search(items, options)
end
- # rubocop: enable CodeReuse/ActiveRecord
private
attr_reader :project, :user
+
+ def by_search(items, options)
+ return items if options[:search].blank?
+
+ items.search(options[:search])
+ end
+
+ def fork_targets(options)
+ if options[:only_groups]
+ user.manageable_groups(include_groups_with_developer_maintainer_access: true)
+ else
+ user.forkable_namespaces.sort_by_type
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def previous_execute(options = {})
+ return ::Namespace.where(id: user.forkable_namespaces).sort_by_type unless options[:only_groups]
+
+ ::Group.where(id: user.manageable_groups(include_groups_with_developer_maintainer_access: true))
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
ForkTargetsFinder.prepend_mod_with('ForkTargetsFinder')
diff --git a/app/finders/groups/accepting_project_transfers_finder.rb b/app/finders/groups/accepting_project_transfers_finder.rb
new file mode 100644
index 00000000000..09d3c430641
--- /dev/null
+++ b/app/finders/groups/accepting_project_transfers_finder.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Groups
+ class AcceptingProjectTransfersFinder
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute
+ if Feature.disabled?(:include_groups_from_group_shares_in_project_transfer_locations)
+ return current_user.manageable_groups
+ end
+
+ groups_accepting_project_transfers =
+ [
+ current_user.manageable_groups,
+ managable_groups_originating_from_group_shares
+ ]
+
+ groups = ::Group.from_union(groups_accepting_project_transfers)
+
+ groups.project_creation_allowed
+ end
+
+ private
+
+ attr_reader :current_user
+
+ def managable_groups_originating_from_group_shares
+ GroupGroupLink
+ .with_owner_or_maintainer_access
+ .groups_accessible_via(
+ groups_that_user_has_owner_or_maintainer_access_via_direct_membership
+ .select(:id)
+ )
+ end
+
+ def groups_that_user_has_owner_or_maintainer_access_via_direct_membership
+ # Only maintainers or above in a group has access to transfer projects to that group
+ current_user.owned_or_maintainers_groups
+ end
+ end
+end
diff --git a/app/finders/groups/user_groups_finder.rb b/app/finders/groups/user_groups_finder.rb
index 90367638dcf..bda8b7cc1e0 100644
--- a/app/finders/groups/user_groups_finder.rb
+++ b/app/finders/groups/user_groups_finder.rb
@@ -41,14 +41,14 @@ module Groups
def by_search(items)
return items if params[:search].blank?
- items.search(params[:search])
+ items.search(params[:search], include_parents: true)
end
def by_permission_scope
if permission_scope_create_projects?
target_user.manageable_groups(include_groups_with_developer_maintainer_access: true)
elsif permission_scope_transfer_projects?
- target_user.manageable_groups(include_groups_with_developer_maintainer_access: false)
+ Groups::AcceptingProjectTransfersFinder.new(target_user).execute # rubocop: disable CodeReuse/Finder
else
target_user.groups
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 47b2a460e6f..1088d53c9a0 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -46,7 +46,8 @@ class IssuableFinder
requires_cross_project_access unless: -> { params.project? }
- FULL_TEXT_SEARCH_TERM_REGEX = /\A[\p{ASCII}|\p{Latin}]+\z/.freeze
+ FULL_TEXT_SEARCH_TERM_PATTERN = '[\u0000-\u218F]*'
+ FULL_TEXT_SEARCH_TERM_REGEX = /\A#{FULL_TEXT_SEARCH_TERM_PATTERN}\z/.freeze
NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze
attr_accessor :current_user, :params
diff --git a/app/finders/projects/topics_finder.rb b/app/finders/projects/topics_finder.rb
index c26b166a786..fb0a77db548 100644
--- a/app/finders/projects/topics_finder.rb
+++ b/app/finders/projects/topics_finder.rb
@@ -13,6 +13,7 @@ module Projects
def execute
topics = Projects::Topic.order_by_non_private_projects_count
+ topics = by_without_projects(topics)
by_search(topics)
end
@@ -25,5 +26,11 @@ module Projects
topics.search(params[:search]).reorder_by_similarity(params[:search])
end
+
+ def by_without_projects(topics)
+ return topics unless params[:without_projects]
+
+ topics.without_assigned_projects
+ end
end
end
diff --git a/app/finders/releases/group_releases_finder.rb b/app/finders/releases/group_releases_finder.rb
index 8b1b0c552fd..08530f63ea6 100644
--- a/app/finders/releases/group_releases_finder.rb
+++ b/app/finders/releases/group_releases_finder.rb
@@ -32,7 +32,7 @@ module Releases
def get_releases
Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new(
scope: releases_scope,
- array_scope: Project.for_group_and_its_subgroups(parent).select(:id),
+ array_scope: Project.for_group_and_its_subgroups(parent).select(:id),
array_mapping_scope: -> (project_id_expression) { Release.where(Release.arel_table[:project_id].eq(project_id_expression)) },
finder_query: -> (order_by, id_expression) { Release.where(Release.arel_table[:id].eq(id_expression)) }
)
diff --git a/app/finders/repositories/changelog_tag_finder.rb b/app/finders/repositories/changelog_tag_finder.rb
index 3c110e6c65d..7dd7404730f 100644
--- a/app/finders/repositories/changelog_tag_finder.rb
+++ b/app/finders/repositories/changelog_tag_finder.rb
@@ -37,14 +37,14 @@ module Repositories
begin
regex = Gitlab::UntrustedRegexp.new(@regex)
- rescue RegexpError => ex
+ rescue RegexpError => e
# The error messages produced by default are not very helpful, so we
# raise a better one here. We raise the specific error here so its
# message is displayed in the API (where we catch this specific
# error).
raise(
Gitlab::Changelog::Error,
- "The regular expression to use for finding the previous tag for a version is invalid: #{ex.message}"
+ "The regular expression to use for finding the previous tag for a version is invalid: #{e.message}"
)
end
diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb
index 16bba62f766..52b1fff4883 100644
--- a/app/finders/tags_finder.rb
+++ b/app/finders/tags_finder.rb
@@ -2,7 +2,7 @@
class TagsFinder < GitRefsFinder
def execute(gitaly_pagination: false)
- tags = if gitaly_pagination
+ tags = if gitaly_pagination && search.blank?
repository.tags_sorted_by(sort, pagination_params)
else
repository.tags_sorted_by(sort)
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index b399f0490ee..c0e063a34d5 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -53,6 +53,7 @@ class GitlabSchema < GraphQL::Schema
def get_type(type_name, context = GraphQL::Query::NullContext)
type_name = Gitlab::GlobalId::Deprecations.apply_to_graphql_name(type_name)
+ type_name = Gitlab::Graphql::TypeNameDeprecations.apply_to_graphql_name(type_name)
super(type_name, context)
end
@@ -163,6 +164,7 @@ class GitlabSchema < GraphQL::Schema
def get_type(type_name)
type_name = Gitlab::GlobalId::Deprecations.apply_to_graphql_name(type_name)
+ type_name = Gitlab::Graphql::TypeNameDeprecations.apply_to_graphql_name(type_name)
super(type_name)
end
diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb
index 342cff83e90..b39875b83a9 100644
--- a/app/graphql/graphql_triggers.rb
+++ b/app/graphql/graphql_triggers.rb
@@ -16,4 +16,8 @@ module GraphqlTriggers
def self.issuable_labels_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableLabelsUpdated', { issuable_id: issuable.to_gid }, issuable)
end
+
+ def self.issuable_dates_updated(issuable)
+ GitlabSchema.subscriptions.trigger('issuableDatesUpdated', { issuable_id: issuable.to_gid }, issuable)
+ end
end
diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb
index 5da2731d562..a419a8df64e 100644
--- a/app/graphql/mutations/award_emojis/toggle.rb
+++ b/app/graphql/mutations/award_emojis/toggle.rb
@@ -5,9 +5,10 @@ module Mutations
class Toggle < Base
graphql_name 'AwardEmojiToggle'
- field :toggled_on, GraphQL::Types::Boolean, null: false,
- description: 'Indicates the status of the emoji. ' \
- 'True if the toggle awarded the emoji, and false if the toggle removed the emoji.'
+ field :toggled_on, GraphQL::Types::Boolean,
+ null: false,
+ description: 'Indicates the status of the emoji. ' \
+ 'True if the toggle awarded the emoji, and false if the toggle removed the emoji.'
def resolve(args)
awardable = authorized_find!(id: args[:awardable_id])
diff --git a/app/graphql/mutations/ci/job/retry.rb b/app/graphql/mutations/ci/job/retry.rb
index 50e9c51c9e7..bfb9b902cc5 100644
--- a/app/graphql/mutations/ci/job/retry.rb
+++ b/app/graphql/mutations/ci/job/retry.rb
@@ -11,13 +11,20 @@ module Mutations
null: true,
description: 'Job after the mutation.'
+ argument :variables, [::Types::Ci::VariableInputType],
+ required: false,
+ default_value: [],
+ replace_null_with_default: true,
+ description: 'Variables to use when retrying a manual job.'
+
authorize :update_build
- def resolve(id:)
+ def resolve(id:, variables:)
job = authorized_find!(id: id)
project = job.project
+ variables = variables.map(&:to_h)
- response = ::Ci::RetryJobService.new(project, current_user).execute(job)
+ response = ::Ci::RetryJobService.new(project, current_user).execute(job, variables: variables)
if response.success?
{
diff --git a/app/graphql/mutations/ci/pipeline/cancel.rb b/app/graphql/mutations/ci/pipeline/cancel.rb
index 3ec6eee9f54..c52e3b4f4b8 100644
--- a/app/graphql/mutations/ci/pipeline/cancel.rb
+++ b/app/graphql/mutations/ci/pipeline/cancel.rb
@@ -13,7 +13,6 @@ module Mutations
if pipeline.cancelable?
pipeline.cancel_running
- pipeline.cancel
{ success: true, errors: [] }
else
diff --git a/app/graphql/mutations/ci/runner/bulk_delete.rb b/app/graphql/mutations/ci/runner/bulk_delete.rb
new file mode 100644
index 00000000000..4c1c2967799
--- /dev/null
+++ b/app/graphql/mutations/ci/runner/bulk_delete.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module Runner
+ class BulkDelete < BaseMutation
+ graphql_name 'BulkRunnerDelete'
+
+ RunnerID = ::Types::GlobalIDType[::Ci::Runner]
+
+ argument :ids, [RunnerID],
+ required: false,
+ description: 'IDs of the runners to delete.'
+
+ field :deleted_count,
+ ::GraphQL::Types::Int,
+ null: true,
+ description: 'Number of records effectively deleted. ' \
+ 'Only present if operation was performed synchronously.'
+
+ field :deleted_ids,
+ [RunnerID],
+ null: true,
+ description: 'IDs of records effectively deleted. ' \
+ 'Only present if operation was performed synchronously.'
+
+ def resolve(**runner_attrs)
+ raise_resource_not_available_error! unless Ability.allowed?(current_user, :delete_runners)
+
+ if ids = runner_attrs[:ids]
+ runners = find_all_runners_by_ids(model_ids_of(ids))
+
+ result = ::Ci::Runners::BulkDeleteRunnersService.new(runners: runners).execute
+ result.payload.slice(:deleted_count, :deleted_ids).merge(errors: [])
+ else
+ { errors: [] }
+ end
+ end
+
+ private
+
+ def model_ids_of(ids)
+ ids.map do |gid|
+ gid.model_id.to_i
+ end.compact
+ end
+
+ def find_all_runners_by_ids(ids)
+ return ::Ci::Runner.none if ids.blank?
+
+ ::Ci::Runner.id_in(ids)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb
index b6d8c20c40b..1c6cf6989bf 100644
--- a/app/graphql/mutations/ci/runner/update.rb
+++ b/app/graphql/mutations/ci/runner/update.rb
@@ -39,15 +39,17 @@ module Mutations
required: false,
description: 'Indicates the runner is not allowed to receive jobs.'
- argument :locked, GraphQL::Types::Boolean, required: false,
- description: 'Indicates the runner is locked.'
+ argument :locked, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Indicates the runner is locked.'
argument :run_untagged, GraphQL::Types::Boolean,
required: false,
description: 'Indicates the runner is able to run untagged jobs.'
- argument :tag_list, [GraphQL::Types::String], required: false,
- description: 'Tags associated with the runner.'
+ argument :tag_list, [GraphQL::Types::String],
+ required: false,
+ description: 'Tags associated with the runner.'
field :runner,
Types::Ci::RunnerType,
diff --git a/app/graphql/mutations/ci/runners_registration_token/reset.rb b/app/graphql/mutations/ci/runners_registration_token/reset.rb
index 8c49b682ab0..c9fe7ea47f0 100644
--- a/app/graphql/mutations/ci/runners_registration_token/reset.rb
+++ b/app/graphql/mutations/ci/runners_registration_token/reset.rb
@@ -49,7 +49,10 @@ module Mutations
end
def reset_token(scope)
- ::Ci::Runners::ResetRegistrationTokenService.new(scope, current_user).execute if scope
+ return unless scope
+
+ result = ::Ci::Runners::ResetRegistrationTokenService.new(scope, current_user).execute
+ result.payload[:new_registration_token] if result.success?
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 cbe1cfb4099..1f90f394521 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
@@ -15,15 +15,21 @@ module Mutations
argument :title, GraphQL::Types::String,
required: false,
description: copy_field_description(Types::WorkItemType, :title)
+ argument :confidential, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Sets the work item confidentiality.'
argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType,
required: false,
description: 'Input for description widget.'
- argument :weight_widget, ::Types::WorkItems::Widgets::WeightInputType,
+ argument :assignees_widget, ::Types::WorkItems::Widgets::AssigneesInputType,
required: false,
- description: 'Input for weight widget.'
+ description: 'Input for assignees widget.'
argument :hierarchy_widget, ::Types::WorkItems::Widgets::HierarchyUpdateInputType,
required: false,
description: 'Input for hierarchy widget.'
+ argument :start_and_due_date_widget, ::Types::WorkItems::Widgets::StartAndDueDateUpdateInputType,
+ required: false,
+ description: 'Input for start and due date widget.'
end
end
end
diff --git a/app/graphql/mutations/container_repositories/destroy.rb b/app/graphql/mutations/container_repositories/destroy.rb
index 1d8f7b22f88..2a45291be22 100644
--- a/app/graphql/mutations/container_repositories/destroy.rb
+++ b/app/graphql/mutations/container_repositories/destroy.rb
@@ -21,7 +21,9 @@ module Mutations
container_repository = authorized_find!(id: id)
container_repository.delete_scheduled!
+ # rubocop:disable CodeReuse/Worker
DeleteContainerRepositoryWorker.perform_async(current_user.id, container_repository.id)
+ # rubocop:enable CodeReuse/Worker
track_event(:delete_repository, :container)
{
diff --git a/app/graphql/mutations/design_management/move.rb b/app/graphql/mutations/design_management/move.rb
index b19d9b4ce61..cf7b7a7b99b 100644
--- a/app/graphql/mutations/design_management/move.rb
+++ b/app/graphql/mutations/design_management/move.rb
@@ -7,18 +7,21 @@ module Mutations
DesignID = ::Types::GlobalIDType[::DesignManagement::Design]
- argument :id, DesignID, required: true, as: :current_design,
- description: "ID of the design to move."
+ argument :id, DesignID,
+ required: true, as: :current_design,
+ description: "ID of the design to move."
- argument :previous, DesignID, required: false, as: :previous_design,
- description: "ID of the immediately preceding design."
+ argument :previous, DesignID,
+ required: false, as: :previous_design,
+ description: "ID of the immediately preceding design."
- argument :next, DesignID, required: false, as: :next_design,
- description: "ID of the immediately following design."
+ argument :next, DesignID,
+ required: false, as: :next_design,
+ description: "ID of the immediately following design."
field :design_collection, Types::DesignManagement::DesignCollectionType,
- null: true,
- description: "Current state of the collection."
+ null: true,
+ description: "Current state of the collection."
def resolve(**args)
service = ::DesignManagement::MoveDesignsService.new(current_user, parameters(**args))
diff --git a/app/graphql/mutations/issues/move.rb b/app/graphql/mutations/issues/move.rb
index fb22a2d891c..63bc9dabbf9 100644
--- a/app/graphql/mutations/issues/move.rb
+++ b/app/graphql/mutations/issues/move.rb
@@ -19,8 +19,8 @@ module Mutations
begin
moved_issue = ::Issues::MoveService.new(project: source_project, current_user: current_user).execute(issue, target_project)
- rescue ::Issues::MoveService::MoveError => error
- errors = error.message
+ rescue ::Issues::MoveService::MoveError => e
+ errors = e.message
end
{
diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb
index abfd6fec0bd..b795d66c16f 100644
--- a/app/graphql/mutations/issues/set_confidential.rb
+++ b/app/graphql/mutations/issues/set_confidential.rb
@@ -24,7 +24,7 @@ module Mutations
check_spam_action_response!(issue)
{
- issue: issue,
+ issue: issue.reset,
errors: errors_on_object(issue)
}
end
diff --git a/app/graphql/mutations/issues/set_severity.rb b/app/graphql/mutations/issues/set_severity.rb
index 872a0e7b33d..4a24bfd18ef 100644
--- a/app/graphql/mutations/issues/set_severity.rb
+++ b/app/graphql/mutations/issues/set_severity.rb
@@ -5,8 +5,9 @@ module Mutations
class SetSeverity < Base
graphql_name 'IssueSetSeverity'
- argument :severity, Types::IssuableSeverityEnum, required: true,
- description: 'Set the incident severity level.'
+ argument :severity, Types::IssuableSeverityEnum,
+ required: true,
+ description: 'Set the incident severity level.'
authorize :admin_issue
diff --git a/app/graphql/mutations/merge_requests/remove_attention_request.rb b/app/graphql/mutations/merge_requests/remove_attention_request.rb
deleted file mode 100644
index 3b12b09528b..00000000000
--- a/app/graphql/mutations/merge_requests/remove_attention_request.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module MergeRequests
- class RemoveAttentionRequest < Base
- graphql_name 'MergeRequestRemoveAttentionRequest'
-
- argument :user_id, ::Types::GlobalIDType[::User],
- loads: Types::UserType,
- required: true,
- description: <<~DESC
- User ID of the user for attention request removal.
- DESC
-
- def resolve(project_path:, iid:, user:)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled?
-
- merge_request = authorized_find!(project_path: project_path, iid: iid)
-
- result = ::MergeRequests::RemoveAttentionRequestedService.new(
- project: merge_request.project,
- current_user: current_user,
- merge_request: merge_request,
- user: user
- ).execute
-
- {
- merge_request: merge_request,
- errors: Array(result[:message])
- }
- end
-
- private
-
- def feature_enabled?
- current_user&.mr_attention_requests_enabled?
- end
- end
- end
-end
diff --git a/app/graphql/mutations/merge_requests/request_attention.rb b/app/graphql/mutations/merge_requests/request_attention.rb
deleted file mode 100644
index 5f5565285f6..00000000000
--- a/app/graphql/mutations/merge_requests/request_attention.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module MergeRequests
- class RequestAttention < Base
- graphql_name 'MergeRequestRequestAttention'
-
- argument :user_id, ::Types::GlobalIDType[::User],
- loads: Types::UserType,
- required: true,
- description: <<~DESC
- User ID of the user to request attention.
- DESC
-
- def resolve(project_path:, iid:, user:)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled?
-
- merge_request = authorized_find!(project_path: project_path, iid: iid)
-
- result = ::MergeRequests::RequestAttentionService.new(
- project: merge_request.project,
- current_user: current_user,
- merge_request: merge_request,
- user: user
- ).execute
-
- {
- merge_request: merge_request,
- errors: Array(result[:message])
- }
- end
-
- private
-
- def feature_enabled?
- current_user&.mr_attention_requests_enabled?
- end
- end
- end
-end
diff --git a/app/graphql/mutations/merge_requests/set_reviewers.rb b/app/graphql/mutations/merge_requests/set_reviewers.rb
new file mode 100644
index 00000000000..8d3f8601597
--- /dev/null
+++ b/app/graphql/mutations/merge_requests/set_reviewers.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Mutations
+ module MergeRequests
+ class SetReviewers < Base
+ graphql_name 'MergeRequestSetReviewers'
+
+ argument :reviewer_usernames,
+ [GraphQL::Types::String],
+ required: true,
+ description: 'Usernames of reviewers to assign. Replaces existing reviewers by default.'
+
+ argument :operation_mode,
+ Types::MutationOperationModeEnum,
+ required: false,
+ default_value: Types::MutationOperationModeEnum.default_mode,
+ description: 'Operation to perform. Defaults to REPLACE.'
+
+ def resolve(project_path:, iid:, reviewer_usernames:, operation_mode:)
+ resource = authorized_find!(project_path: project_path, iid: iid)
+
+ ::MergeRequests::UpdateReviewersService.new(
+ project: resource.project,
+ current_user: current_user,
+ params: { reviewer_ids: reviewer_ids(resource, reviewer_usernames, operation_mode) }
+ ).execute(resource)
+
+ {
+ resource.class.name.underscore.to_sym => resource,
+ errors: errors_on_object(resource)
+ }
+ end
+
+ private
+
+ def reviewer_ids(resource, usernames, mode)
+ new_reviewers = UsersFinder.new(current_user, username: usernames).execute.to_a
+ new_reviewer_ids = user_ids(new_reviewers)
+
+ case mode
+ when 'REPLACE' then new_reviewer_ids
+ when 'APPEND' then user_ids(resource.reviewers) | new_reviewer_ids
+ when 'REMOVE' then user_ids(resource.reviewers) - new_reviewer_ids
+ end
+ end
+
+ def user_ids(users)
+ users.map(&:id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/merge_requests/toggle_attention_requested.rb b/app/graphql/mutations/merge_requests/toggle_attention_requested.rb
deleted file mode 100644
index 8913ca48531..00000000000
--- a/app/graphql/mutations/merge_requests/toggle_attention_requested.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module MergeRequests
- class ToggleAttentionRequested < Base
- graphql_name 'MergeRequestToggleAttentionRequested'
-
- argument :user_id, ::Types::GlobalIDType[::User],
- loads: Types::UserType,
- required: true,
- description: <<~DESC
- User ID for the user to toggle attention requested.
- DESC
-
- def resolve(project_path:, iid:, user:)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless current_user&.mr_attention_requests_enabled?
-
- merge_request = authorized_find!(project_path: project_path, iid: iid)
-
- result = ::MergeRequests::ToggleAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: user).execute
-
- {
- merge_request: merge_request,
- errors: Array(result[:message])
- }
- end
- end
- end
-end
diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb
index 1b673204213..f48e62af767 100644
--- a/app/graphql/mutations/notes/create/base.rb
+++ b/app/graphql/mutations/notes/create/base.rb
@@ -21,7 +21,13 @@ module Mutations
argument :confidential,
GraphQL::Types::Boolean,
required: false,
- description: 'Confidentiality flag of a note. Default is false.'
+ description: 'Confidentiality flag of a note. Default is false.',
+ deprecated: { reason: :renamed, replacement: 'internal', milestone: '15.3' }
+
+ argument :internal,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: 'Internal flag for a note. Default is false.'
def resolve(args)
noteable = authorized_find!(id: args[:noteable_id])
@@ -49,7 +55,7 @@ module Mutations
{
noteable: noteable,
note: args[:body],
- confidential: args[:confidential]
+ internal: args[:internal] || args[:confidential]
}
end
diff --git a/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb b/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb
index e5bb5b6d573..3ccc90c16ae 100644
--- a/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb
+++ b/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb
@@ -7,14 +7,16 @@ module Mutations
include FindsProject
argument :project_path, GraphQL::Types::ID,
- required: true,
- description: 'Full path of the project.'
+ required: true,
+ description: 'Full path of the project.'
- field :success_path, GraphQL::Types::String, null: true,
- description: 'Redirect path to use when the response is successful.'
+ field :success_path, GraphQL::Types::String,
+ null: true,
+ description: 'Redirect path to use when the response is successful.'
- field :branch, GraphQL::Types::String, null: true,
- description: 'Branch that has the new/modified `.gitlab-ci.yml` file.'
+ field :branch, GraphQL::Types::String,
+ null: true,
+ description: 'Branch that has the new/modified `.gitlab-ci.yml` file.'
authorize :push_code
diff --git a/app/graphql/mutations/timelogs/base.rb b/app/graphql/mutations/timelogs/base.rb
new file mode 100644
index 00000000000..9859f0e7d79
--- /dev/null
+++ b/app/graphql/mutations/timelogs/base.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Timelogs
+ class Base < Mutations::BaseMutation
+ field :timelog,
+ Types::TimelogType,
+ null: true,
+ description: 'Timelog.'
+
+ private
+
+ def response(result)
+ { timelog: result.payload[:timelog], errors: result.errors }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/timelogs/create.rb b/app/graphql/mutations/timelogs/create.rb
new file mode 100644
index 00000000000..bab7508454e
--- /dev/null
+++ b/app/graphql/mutations/timelogs/create.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Timelogs
+ class Create < Base
+ graphql_name 'TimelogCreate'
+
+ argument :time_spent,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Amount of time spent.'
+
+ argument :spent_at,
+ Types::DateType,
+ required: true,
+ description: 'When the time was spent.'
+
+ argument :summary,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Summary of time spent.'
+
+ argument :issuable_id,
+ ::Types::GlobalIDType[::Issuable],
+ required: true,
+ description: 'Global ID of the issuable (Issue, WorkItem or MergeRequest).'
+
+ authorize :create_timelog
+
+ def resolve(issuable_id:, time_spent:, spent_at:, summary:, **args)
+ issuable = authorized_find!(id: issuable_id)
+ parsed_time_spent = Gitlab::TimeTrackingFormatter.parse(time_spent)
+
+ result = ::Timelogs::CreateService.new(
+ issuable, parsed_time_spent, spent_at, summary, current_user
+ ).execute
+
+ response(result)
+ end
+
+ private
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: [::Issue, ::WorkItem, ::MergeRequest]).sync
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/timelogs/delete.rb b/app/graphql/mutations/timelogs/delete.rb
index 8fd41c27b88..61588d839a7 100644
--- a/app/graphql/mutations/timelogs/delete.rb
+++ b/app/graphql/mutations/timelogs/delete.rb
@@ -2,14 +2,9 @@
module Mutations
module Timelogs
- class Delete < Mutations::BaseMutation
+ class Delete < Base
graphql_name 'TimelogDelete'
- field :timelog,
- Types::TimelogType,
- null: true,
- description: 'Deleted timelog.'
-
argument :id,
::Types::GlobalIDType[::Timelog],
required: true,
@@ -22,11 +17,13 @@ module Mutations
result = ::Timelogs::DeleteService.new(timelog, current_user).execute
# Return the result payload, not the loaded timelog, so that it returns null in case of unauthorized access
- { timelog: result.payload, errors: result.errors }
+ response(result)
end
+ private
+
def find_object(id:)
- GitlabSchema.find_by_gid(id)
+ GitlabSchema.object_from_id(id, expected_type: ::Timelog).sync
end
end
end
diff --git a/app/graphql/mutations/uploads/delete.rb b/app/graphql/mutations/uploads/delete.rb
new file mode 100644
index 00000000000..e2fb967cd2c
--- /dev/null
+++ b/app/graphql/mutations/uploads/delete.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Uploads
+ class Delete < BaseMutation
+ graphql_name 'UploadDelete'
+ description 'Deletes an upload.'
+
+ include Mutations::ResolvesResourceParent
+
+ authorize :destroy_upload
+
+ argument :secret, GraphQL::Types::String,
+ required: true,
+ description: 'Secret part of upload path.'
+
+ argument :filename, GraphQL::Types::String,
+ required: true,
+ description: 'Upload filename.'
+
+ field :upload, Types::UploadType,
+ null: true,
+ description: 'Deleted upload.'
+
+ def resolve(args)
+ parent = authorized_resource_parent_find!(args)
+
+ result = ::Uploads::DestroyService.new(parent, current_user).execute(args[:secret], args[:filename])
+
+ {
+ upload: result[:status] == :success ? result[:upload] : nil,
+ errors: Array(result[:message])
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index 350153eaf19..ece00e04ed9 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -13,6 +13,9 @@ module Mutations
authorize :create_work_item
+ argument :confidential, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Sets the work item confidentiality.'
argument :description, GraphQL::Types::String,
required: false,
description: copy_field_description(Types::WorkItemType, :description)
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index 5d8c574877a..b4ed0a1a3ca 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -51,3 +51,5 @@ module Mutations
end
end
end
+
+Mutations::WorkItems::Update.prepend_mod
diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb
index 1f486c47771..ec6ede58cf5 100644
--- a/app/graphql/resolvers/ci/config_resolver.rb
+++ b/app/graphql/resolvers/ci/config_resolver.rb
@@ -38,8 +38,8 @@ module Resolvers
.validate(content, dry_run: dry_run)
response(result)
- rescue GRPC::InvalidArgument => error
- Gitlab::ErrorTracking.track_and_raise_exception(error, sha: sha)
+ rescue GRPC::InvalidArgument => e
+ Gitlab::ErrorTracking.track_and_raise_exception(e, sha: sha)
end
private
diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
index 64738608b60..b52a4cc0ab4 100644
--- a/app/graphql/resolvers/ci/runners_resolver.rb
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -36,7 +36,7 @@ module Resolvers
required: false,
description: 'Sort order of results.'
- argument :upgrade_status, ::Types::Ci::RunnerUpgradeStatusTypeEnum,
+ argument :upgrade_status, ::Types::Ci::RunnerUpgradeStatusEnum,
required: false,
description: 'Filter by upgrade status.'
diff --git a/app/graphql/resolvers/ci/template_resolver.rb b/app/graphql/resolvers/ci/template_resolver.rb
index 17f2668df11..f2531d877c7 100644
--- a/app/graphql/resolvers/ci/template_resolver.rb
+++ b/app/graphql/resolvers/ci/template_resolver.rb
@@ -5,8 +5,11 @@ module Resolvers
class TemplateResolver < BaseResolver
type Types::Ci::TemplateType, null: true
- argument :name, GraphQL::Types::String, required: true,
- description: 'Name of the CI/CD template to search for. Template must be formatted as `Name.gitlab-ci.yml`.'
+ argument :name,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Name of the CI/CD template to search for. ' \
+ 'Template must be formatted as `Name.gitlab-ci.yml`.'
alias_method :project, :object
diff --git a/app/graphql/resolvers/crm/contact_state_counts_resolver.rb b/app/graphql/resolvers/crm/contact_state_counts_resolver.rb
new file mode 100644
index 00000000000..f696400d44e
--- /dev/null
+++ b/app/graphql/resolvers/crm/contact_state_counts_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Crm
+ class ContactStateCountsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ authorize :read_crm_contact
+
+ type Types::CustomerRelations::ContactStateCountsType, null: true
+
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Search term to find contacts with.'
+
+ argument :state, Types::CustomerRelations::ContactStateEnum,
+ required: false,
+ description: 'State of the contacts to search for.'
+
+ def resolve(**args)
+ CustomerRelations::ContactStateCounts.new(context[:current_user], object, args)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/crm/contacts_resolver.rb b/app/graphql/resolvers/crm/contacts_resolver.rb
index 58d0e2ce13d..a93942cf93b 100644
--- a/app/graphql/resolvers/crm/contacts_resolver.rb
+++ b/app/graphql/resolvers/crm/contacts_resolver.rb
@@ -10,6 +10,11 @@ module Resolvers
type Types::CustomerRelations::ContactType, null: true
+ argument :sort, Types::CustomerRelations::ContactSortEnum,
+ description: 'Criteria to sort contacts by.',
+ required: false,
+ default_value: { field: 'last_name', direction: :asc }
+
argument :search, GraphQL::Types::String,
required: false,
description: 'Search term to find contacts with.'
@@ -24,13 +29,25 @@ module Resolvers
def resolve(**args)
args[:ids] = resolve_ids(args.delete(:ids))
-
- ::Crm::ContactsFinder.new(current_user, { group: group }.merge(args)).execute
+ args.delete(:state) if args[:state] == :all
+
+ contacts = ::Crm::ContactsFinder.new(current_user, { group: group }.merge(args)).execute
+ if needs_offset?(args)
+ offset_pagination(contacts)
+ else
+ contacts
+ end
end
def group
object.respond_to?(:sync) ? object.sync : object
end
+
+ private
+
+ def needs_offset?(args)
+ args.key?(:sort) && args[:sort][:field] == 'organization'
+ end
end
end
end
diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb
index 1823eb65d44..934c1ba2738 100644
--- a/app/graphql/resolvers/environments_resolver.rb
+++ b/app/graphql/resolvers/environments_resolver.rb
@@ -22,8 +22,8 @@ module Resolvers
return unless project.present?
Environments::EnvironmentsFinder.new(project, context[:current_user], args).execute
- rescue Environments::EnvironmentsFinder::InvalidStatesError => exception
- raise Gitlab::Graphql::Errors::ArgumentError, exception.message
+ rescue Environments::EnvironmentsFinder::InvalidStatesError => e
+ raise Gitlab::Graphql::Errors::ArgumentError, e.message
end
end
end
diff --git a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
index 27bba6c8144..187063bb8c3 100644
--- a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
@@ -14,7 +14,7 @@ module Resolvers
response = ::ErrorTracking::IssueDetailsService.new(
project,
current_user,
- { issue_id: id.model_id }
+ { issue_id: id.model_id, tracking_event: :error_tracking_view_details }
).execute
issue = response[:issue]
issue.gitlab_project = project if issue
diff --git a/app/graphql/resolvers/group_milestones_resolver.rb b/app/graphql/resolvers/group_milestones_resolver.rb
index 319ff9f68c4..9242be7f684 100644
--- a/app/graphql/resolvers/group_milestones_resolver.rb
+++ b/app/graphql/resolvers/group_milestones_resolver.rb
@@ -45,5 +45,9 @@ module Resolvers
options: { include_subgroups: true }
).execute
end
+
+ def preloads
+ super.merge({ subgroup_milestone: :group })
+ end
end
end
diff --git a/app/graphql/resolvers/projects/fork_targets_resolver.rb b/app/graphql/resolvers/projects/fork_targets_resolver.rb
new file mode 100644
index 00000000000..5e8be325d43
--- /dev/null
+++ b/app/graphql/resolvers/projects/fork_targets_resolver.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class ForkTargetsResolver < BaseResolver
+ include ResolvesGroups
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::NamespaceType.connection_type, null: true
+
+ authorize :fork_project
+ authorizes_object!
+
+ alias_method :project, :object
+
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Search query for path or name.'
+
+ private
+
+ def resolve_groups(**args)
+ ForkTargetsFinder.new(project, current_user).execute(args)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb
index b846248458f..facf8ffe36f 100644
--- a/app/graphql/resolvers/projects_resolver.rb
+++ b/app/graphql/resolvers/projects_resolver.rb
@@ -25,8 +25,8 @@ module Resolvers
description: 'Sort order of results.'
argument :topics, type: [GraphQL::Types::String],
- required: false,
- description: 'Filters projects by topics.'
+ required: false,
+ description: 'Filters projects by topics.'
def resolve(**args)
ProjectsFinder
diff --git a/app/graphql/resolvers/users_resolver.rb b/app/graphql/resolvers/users_resolver.rb
index b0d704d09fc..90a6bd3e6b2 100644
--- a/app/graphql/resolvers/users_resolver.rb
+++ b/app/graphql/resolvers/users_resolver.rb
@@ -12,7 +12,7 @@ module Resolvers
description: 'List of user Global IDs.'
argument :usernames, [GraphQL::Types::String], required: false,
- description: 'List of usernames.'
+ description: 'List of usernames.'
argument :sort, Types::SortEnum,
description: 'Sort users by this criteria.',
diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb
index 1bc74131b9e..055984db3cb 100644
--- a/app/graphql/resolvers/work_items_resolver.rb
+++ b/app/graphql/resolvers/work_items_resolver.rb
@@ -58,3 +58,5 @@ module Resolvers
end
end
end
+
+Resolvers::WorkItemsResolver.prepend_mod_with('Resolvers::WorkItemsResolver')
diff --git a/app/graphql/types/access_level_type.rb b/app/graphql/types/access_level_type.rb
index 2d97f6b30e8..4a709aa4711 100644
--- a/app/graphql/types/access_level_type.rb
+++ b/app/graphql/types/access_level_type.rb
@@ -7,11 +7,11 @@ module Types
description 'Represents the access level of a relationship between a User and object that it is related to'
field :integer_value, GraphQL::Types::Int, null: true,
- description: 'Integer representation of access level.',
- method: :to_i
+ description: 'Integer representation of access level.',
+ method: :to_i
field :string_value, Types::AccessLevelEnum, null: true,
- description: 'String representation of access level.',
- method: :to_i
+ description: 'String representation of access level.',
+ method: :to_i
end
end
diff --git a/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
index 1fc47303d67..0da0a6bcd1a 100644
--- a/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
+++ b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
@@ -13,12 +13,14 @@ module Types
authorize :read_usage_trends_measurement
field :recorded_at, Types::TimeType, null: true,
- description: 'Time the measurement was recorded.'
+ description: 'Time the measurement was recorded.'
field :count, GraphQL::Types::Int, null: false,
- description: 'Object count.'
+ description: 'Object count.'
- field :identifier, Types::Admin::Analytics::UsageTrends::MeasurementIdentifierEnum, null: false,
+ field :identifier,
+ Types::Admin::Analytics::UsageTrends::MeasurementIdentifierEnum,
+ null: false,
description: 'Type of objects being measured.'
end
end
diff --git a/app/graphql/types/alert_management/domain_filter_enum.rb b/app/graphql/types/alert_management/domain_filter_enum.rb
index cd70cdd8ecf..86da345fd69 100644
--- a/app/graphql/types/alert_management/domain_filter_enum.rb
+++ b/app/graphql/types/alert_management/domain_filter_enum.rb
@@ -7,11 +7,12 @@ module Types
description 'Filters the alerts based on given domain'
value 'operations', description: 'Alerts for operations domain.'
- value 'threat_monitoring', description: 'Alerts for threat monitoring domain.',
- deprecated: {
- reason: 'Network policies are deprecated and will be removed in GitLab 16.0',
- milestone: '15.0'
- }
+ value 'threat_monitoring',
+ description: 'Alerts for threat monitoring domain.',
+ deprecated: {
+ reason: 'Network policies are deprecated and will be removed in GitLab 16.0',
+ milestone: '15.0'
+ }
end
end
end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 6aee9a5c052..1c43432594a 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -17,7 +17,7 @@ module Types
@requires_argument = !!kwargs.delete(:requires_argument)
@authorize = Array.wrap(kwargs.delete(:authorize))
kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity])
- @feature_flag = kwargs[:feature_flag]
+ @feature_flag = kwargs[:_deprecated_feature_flag]
kwargs = check_feature_flag(kwargs)
@deprecation = gitlab_deprecation(kwargs)
after_connection_extensions = kwargs.delete(:late_extensions) || []
@@ -136,7 +136,7 @@ module Types
end
def check_feature_flag(args)
- ff = args.delete(:feature_flag)
+ ff = args.delete(:_deprecated_feature_flag)
return args unless ff.present?
args[:description] = feature_documentation_message(ff, args[:description])
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
index 7f4c49df429..2352a21bd87 100644
--- a/app/graphql/types/board_list_type.rb
+++ b/app/graphql/types/board_list_type.rb
@@ -15,19 +15,21 @@ module Types
description: 'ID (global ID) of the list.'
field :collapsed, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the list is collapsed for this user.'
+ description: 'Indicates if the list is collapsed for this user.'
field :issues_count, GraphQL::Types::Int, null: true,
- description: 'Count of issues in the list.'
+ description: 'Count of issues in the list.'
field :label, Types::LabelType, null: true,
- description: 'Label of the list.'
+ description: 'Label of the list.'
field :list_type, GraphQL::Types::String, null: false,
- description: 'Type of the list.'
+ description: 'Type of the list.'
field :position, GraphQL::Types::Int, null: true,
- description: 'Position of list within the board.'
+ description: 'Position of list within the board.'
field :title, GraphQL::Types::String, null: false,
- description: 'Title of the list.'
+ description: 'Title of the list.'
- field :issues, ::Types::IssueType.connection_type, null: true,
+ field :issues,
+ ::Types::IssueType.connection_type,
+ null: true,
description: 'Board issues.',
late_extensions: [Gitlab::Graphql::Board::IssuesConnectionExtension],
resolver: ::Resolvers::BoardListIssuesResolver
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
index 4ec9a8a9c63..00638988989 100644
--- a/app/graphql/types/board_type.rb
+++ b/app/graphql/types/board_type.rb
@@ -10,21 +10,21 @@ module Types
present_using BoardPresenter
field :id, type: GraphQL::Types::ID, null: false,
- description: 'ID (global ID) of the board.'
+ description: 'ID (global ID) of the board.'
field :name, type: GraphQL::Types::String, null: true,
- description: 'Name of the board.'
+ description: 'Name of the board.'
field :hide_backlog_list, type: GraphQL::Types::Boolean, null: true,
- description: 'Whether or not backlog list is hidden.'
+ description: 'Whether or not backlog list is hidden.'
field :hide_closed_list, type: GraphQL::Types::Boolean, null: true,
- description: 'Whether or not closed list is hidden.'
+ description: 'Whether or not closed list is hidden.'
field :created_at, Types::TimeType, null: false,
- description: 'Timestamp of when the board was created.'
+ description: 'Timestamp of when the board was created.'
field :updated_at, Types::TimeType, null: false,
- description: 'Timestamp of when the board was last updated.'
+ description: 'Timestamp of when the board was last updated.'
field :lists,
Types::BoardListType.connection_type,
@@ -34,10 +34,10 @@ module Types
extras: [:lookahead]
field :web_path, GraphQL::Types::String, null: false,
- description: 'Web path of the board.'
+ description: 'Web path of the board.'
field :web_url, GraphQL::Types::String, null: false,
- description: 'Web URL of the board.'
+ description: 'Web URL of the board.'
end
end
diff --git a/app/graphql/types/ci/analytics_type.rb b/app/graphql/types/ci/analytics_type.rb
index a77b8026f86..6a55a6138ea 100644
--- a/app/graphql/types/ci/analytics_type.rb
+++ b/app/graphql/types/ci/analytics_type.rb
@@ -7,27 +7,27 @@ module Types
graphql_name 'PipelineAnalytics'
field :month_pipelines_labels, [GraphQL::Types::String], null: true,
- description: 'Labels for the monthly pipeline count.'
+ description: 'Labels for the monthly pipeline count.'
field :month_pipelines_successful, [GraphQL::Types::Int], null: true,
- description: 'Total monthly successful pipeline count.'
+ description: 'Total monthly successful pipeline count.'
field :month_pipelines_totals, [GraphQL::Types::Int], null: true,
- description: 'Total monthly pipeline count.'
+ description: 'Total monthly pipeline count.'
field :pipeline_times_labels, [GraphQL::Types::String], null: true,
- description: 'Pipeline times labels.'
+ description: 'Pipeline times labels.'
field :pipeline_times_values, [GraphQL::Types::Int], null: true,
- description: 'Pipeline times.'
+ description: 'Pipeline times.'
field :week_pipelines_labels, [GraphQL::Types::String], null: true,
- description: 'Labels for the weekly pipeline count.'
+ description: 'Labels for the weekly pipeline count.'
field :week_pipelines_successful, [GraphQL::Types::Int], null: true,
- description: 'Total weekly successful pipeline count.'
+ description: 'Total weekly successful pipeline count.'
field :week_pipelines_totals, [GraphQL::Types::Int], null: true,
- description: 'Total weekly pipeline count.'
+ description: 'Total weekly pipeline count.'
field :year_pipelines_labels, [GraphQL::Types::String], null: true,
- description: 'Labels for the yearly pipeline count.'
+ description: 'Labels for the yearly pipeline count.'
field :year_pipelines_successful, [GraphQL::Types::Int], null: true,
- description: 'Total yearly successful pipeline count.'
+ description: 'Total yearly successful pipeline count.'
field :year_pipelines_totals, [GraphQL::Types::Int], null: true,
- description: 'Total yearly pipeline count.'
+ description: 'Total yearly pipeline count.'
end
end
end
diff --git a/app/graphql/types/ci/application_setting_type.rb b/app/graphql/types/ci/application_setting_type.rb
index 2322778d159..53202c56f03 100644
--- a/app/graphql/types/ci/application_setting_type.rb
+++ b/app/graphql/types/ci/application_setting_type.rb
@@ -8,7 +8,7 @@ module Types
authorize :read_application_setting
field :keep_latest_artifact, GraphQL::Types::Boolean, null: true,
- description: 'Whether to keep the latest jobs artifacts.'
+ description: 'Whether to keep the latest jobs artifacts.'
end
end
end
diff --git a/app/graphql/types/ci/build_need_type.rb b/app/graphql/types/ci/build_need_type.rb
index b71d10c4c06..4ab711881fe 100644
--- a/app/graphql/types/ci/build_need_type.rb
+++ b/app/graphql/types/ci/build_need_type.rb
@@ -8,9 +8,9 @@ module Types
graphql_name 'CiBuildNeed'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the BuildNeed.'
+ description: 'ID of the BuildNeed.'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the job we need to complete.'
+ description: 'Name of the job we need to complete.'
end
end
end
diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb
index e43af6f3e78..bec8c72e783 100644
--- a/app/graphql/types/ci/ci_cd_setting_type.rb
+++ b/app/graphql/types/ci/ci_cd_setting_type.rb
@@ -7,20 +7,22 @@ module Types
authorize :admin_project
- field :job_token_scope_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates CI job tokens generated in this project have restricted access to resources.',
- method: :job_token_scope_enabled?
+ field :job_token_scope_enabled,
+ GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates CI job tokens generated in this project have restricted access to resources.',
+ method: :job_token_scope_enabled?
field :keep_latest_artifact, GraphQL::Types::Boolean, null: true,
- description: 'Whether to keep the latest builds artifacts.',
- method: :keep_latest_artifacts_available?
+ description: 'Whether to keep the latest builds artifacts.',
+ method: :keep_latest_artifacts_available?
field :merge_pipelines_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Whether merge pipelines are enabled.',
- method: :merge_pipelines_enabled?
+ description: 'Whether merge pipelines are enabled.',
+ method: :merge_pipelines_enabled?
field :merge_trains_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Whether merge trains are enabled.',
- method: :merge_trains_enabled?
+ description: 'Whether merge trains are enabled.',
+ method: :merge_trains_enabled?
field :project, Types::ProjectType, null: true,
- description: 'Project the CI/CD settings belong to.'
+ description: 'Project the CI/CD settings belong to.'
end
end
end
diff --git a/app/graphql/types/ci/config/config_type.rb b/app/graphql/types/ci/config/config_type.rb
index a7a6927136d..6ccc62331df 100644
--- a/app/graphql/types/ci/config/config_type.rb
+++ b/app/graphql/types/ci/config/config_type.rb
@@ -8,17 +8,17 @@ module Types
graphql_name 'CiConfig'
field :errors, [GraphQL::Types::String], null: true,
- description: 'Linting errors.'
+ description: 'Linting errors.'
field :includes, [Types::Ci::Config::IncludeType], null: true,
- description: 'List of included files.'
+ description: 'List of included files.'
field :merged_yaml, GraphQL::Types::String, null: true,
- description: 'Merged CI configuration YAML.'
+ description: 'Merged CI configuration YAML.'
field :stages, Types::Ci::Config::StageType.connection_type, null: true,
- description: 'Stages of the pipeline.'
+ description: 'Stages of the pipeline.'
field :status, Types::Ci::Config::StatusEnum, null: true,
- description: 'Status of linting, can be either valid or invalid.'
+ description: 'Status of linting, can be either valid or invalid.'
field :warnings, [GraphQL::Types::String], null: true,
- description: 'Linting warnings.'
+ description: 'Linting warnings.'
end
end
end
diff --git a/app/graphql/types/ci/config/group_type.rb b/app/graphql/types/ci/config/group_type.rb
index 19076fe9c20..8c4a0c04a2a 100644
--- a/app/graphql/types/ci/config/group_type.rb
+++ b/app/graphql/types/ci/config/group_type.rb
@@ -8,11 +8,11 @@ module Types
graphql_name 'CiConfigGroup'
field :jobs, Types::Ci::Config::JobType.connection_type, null: true,
- description: 'Jobs in group.'
+ description: 'Jobs in group.'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the job group.'
+ description: 'Name of the job group.'
field :size, GraphQL::Types::Int, null: true,
- description: 'Size of the job group.'
+ description: 'Size of the job group.'
end
end
end
diff --git a/app/graphql/types/ci/config/job_restriction_type.rb b/app/graphql/types/ci/config/job_restriction_type.rb
index 8cf0e210def..bb9c03f7c1e 100644
--- a/app/graphql/types/ci/config/job_restriction_type.rb
+++ b/app/graphql/types/ci/config/job_restriction_type.rb
@@ -8,7 +8,7 @@ module Types
graphql_name 'CiConfigJobRestriction'
field :refs, [GraphQL::Types::String], null: true,
- description: 'Git refs the job restriction applies to.'
+ description: 'Git refs the job restriction applies to.'
end
end
end
diff --git a/app/graphql/types/ci/config/job_type.rb b/app/graphql/types/ci/config/job_type.rb
index 20279143635..fb92a37dee6 100644
--- a/app/graphql/types/ci/config/job_type.rb
+++ b/app/graphql/types/ci/config/job_type.rb
@@ -7,33 +7,41 @@ module Types
class JobType < BaseObject
graphql_name 'CiConfigJob'
- field :after_script, [GraphQL::Types::String], null: true,
+ field :after_script,
+ [GraphQL::Types::String],
+ null: true,
description: 'Override a set of commands that are executed after the job.'
field :allow_failure, GraphQL::Types::Boolean, null: true,
- description: 'Allow job to fail.'
- field :before_script, [GraphQL::Types::String], null: true,
+ description: 'Allow job to fail.'
+ field :before_script,
+ [GraphQL::Types::String],
+ null: true,
description: 'Override a set of commands that are executed before the job.'
field :environment, GraphQL::Types::String, null: true,
- description: 'Name of an environment to which the job deploys.'
+ description: 'Name of an environment to which the job deploys.'
field :except, Types::Ci::Config::JobRestrictionType, null: true,
- description: 'Limit when jobs are not created.'
+ description: 'Limit when jobs are not created.'
field :group_name, GraphQL::Types::String, null: true,
- description: 'Name of the job group.'
+ description: 'Name of the job group.'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the job.'
- field :needs, Types::Ci::Config::NeedType.connection_type, null: true,
+ description: 'Name of the job.'
+ field :needs,
+ Types::Ci::Config::NeedType.connection_type,
+ null: true,
description: 'Builds that must complete before the jobs run.'
- field :only, Types::Ci::Config::JobRestrictionType, null: true,
- description: 'Jobs are created when these conditions do not apply.'
+ field :only,
+ Types::Ci::Config::JobRestrictionType,
+ null: true,
+ description: 'Jobs are created when these conditions do not apply.'
field :script, [GraphQL::Types::String], null: true,
- description: 'Shell script that is executed by a runner.'
+ description: 'Shell script that is executed by a runner.'
field :stage, GraphQL::Types::String, null: true,
- description: 'Name of the job stage.'
+ description: 'Name of the job stage.'
field :tags, [GraphQL::Types::String], null: true,
- description: 'List of tags that are used to select a runner.'
+ description: 'List of tags that are used to select a runner.'
field :when, GraphQL::Types::String, null: true,
- description: 'When to run the job.',
- resolver_method: :restrict_when_to_run_jobs
+ description: 'When to run the job.',
+ resolver_method: :restrict_when_to_run_jobs
def restrict_when_to_run_jobs
object[:when]
diff --git a/app/graphql/types/ci/config/need_type.rb b/app/graphql/types/ci/config/need_type.rb
index 6e9aea8eb64..dd262923246 100644
--- a/app/graphql/types/ci/config/need_type.rb
+++ b/app/graphql/types/ci/config/need_type.rb
@@ -8,7 +8,7 @@ module Types
graphql_name 'CiConfigNeed'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the need.'
+ description: 'Name of the need.'
end
end
end
diff --git a/app/graphql/types/ci/config/stage_type.rb b/app/graphql/types/ci/config/stage_type.rb
index 5b1163edac2..4dacba2f1ed 100644
--- a/app/graphql/types/ci/config/stage_type.rb
+++ b/app/graphql/types/ci/config/stage_type.rb
@@ -8,9 +8,9 @@ module Types
graphql_name 'CiConfigStage'
field :groups, Types::Ci::Config::GroupType.connection_type, null: true,
- description: 'Groups of jobs for the stage.'
+ description: 'Groups of jobs for the stage.'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the stage.'
+ description: 'Name of the stage.'
end
end
end
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
index 3fab040cc0b..8bc50e974bb 100644
--- a/app/graphql/types/ci/detailed_status_type.rb
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -6,31 +6,33 @@ module Types
class DetailedStatusType < BaseObject
graphql_name 'DetailedStatus'
- field :action, Types::Ci::StatusActionType, null: true,
+ field :action,
+ Types::Ci::StatusActionType,
+ null: true,
calls_gitaly: true,
description: 'Action information for the status. This includes method, button title, icon, path, and title.'
field :details_path, GraphQL::Types::String, null: true,
- description: 'Path of the details for the status.'
+ description: 'Path of the details for the status.'
field :favicon, GraphQL::Types::String, null: true,
- description: 'Favicon of the status.'
+ description: 'Favicon of the status.'
field :group, GraphQL::Types::String, null: true,
- description: 'Group of the status.'
+ description: 'Group of the status.'
field :has_details, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the status has further details.',
- method: :has_details?
+ description: 'Indicates if the status has further details.',
+ method: :has_details?
field :icon, GraphQL::Types::String, null: true,
- description: 'Icon of the status.'
+ description: 'Icon of the status.'
field :id, GraphQL::Types::String, null: false,
- description: 'ID for a detailed status.',
- extras: [:parent]
+ description: 'ID for a detailed status.',
+ extras: [:parent]
field :label, GraphQL::Types::String, null: true,
- calls_gitaly: true,
- description: 'Label of the status.'
+ calls_gitaly: true,
+ description: 'Label of the status.'
field :text, GraphQL::Types::String, null: true,
- description: 'Text of the status.'
+ description: 'Text of the status.'
field :tooltip, GraphQL::Types::String, null: true,
- description: 'Tooltip associated with the status.',
- method: :status_tooltip
+ description: 'Tooltip associated with the status.',
+ method: :status_tooltip
def id(parent:)
"#{object.id}-#{parent.id}"
diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb
index c3c73ef170c..f2150fa5e1a 100644
--- a/app/graphql/types/ci/group_type.rb
+++ b/app/graphql/types/ci/group_type.rb
@@ -7,15 +7,15 @@ module Types
graphql_name 'CiGroup'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the group.'
+ description: 'Detailed status of the group.'
field :id, GraphQL::Types::String, null: false,
- description: 'ID for a group.'
+ description: 'ID for a group.'
field :jobs, Ci::JobType.connection_type, null: true,
- description: 'Jobs in group.'
+ description: 'Jobs in group.'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the job group.'
+ description: 'Name of the job group.'
field :size, GraphQL::Types::Int, null: true,
- description: 'Size of the group.'
+ description: 'Size of the group.'
def detailed_status
object.detailed_status(context[:current_user])
diff --git a/app/graphql/types/ci/group_variable_type.rb b/app/graphql/types/ci/group_variable_type.rb
new file mode 100644
index 00000000000..3322f741342
--- /dev/null
+++ b/app/graphql/types/ci/group_variable_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class GroupVariableType < BaseObject
+ graphql_name 'CiGroupVariable'
+ description 'CI/CD variables for a group.'
+
+ implements(VariableInterface)
+
+ field :environment_scope, GraphQL::Types::String,
+ null: true,
+ description: 'Scope defining the environments that can use the variable.'
+
+ field :protected, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is protected.'
+
+ field :masked, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is masked.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/instance_variable_type.rb b/app/graphql/types/ci/instance_variable_type.rb
new file mode 100644
index 00000000000..f564a2f59a0
--- /dev/null
+++ b/app/graphql/types/ci/instance_variable_type.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class InstanceVariableType < BaseObject
+ graphql_name 'CiInstanceVariable'
+ description 'CI/CD variables for a GitLab instance.'
+
+ implements(VariableInterface)
+
+ field :environment_scope, GraphQL::Types::String,
+ null: true,
+ deprecated: {
+ reason: 'No longer used, only available for GroupVariableType and ProjectVariableType',
+ milestone: '15.3'
+ },
+ description: 'Scope defining the environments that can use the variable.'
+
+ field :protected, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is protected.'
+
+ field :masked, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is masked.'
+
+ def environment_scope
+ nil
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb
index 69bb5325dba..a6ab445702c 100644
--- a/app/graphql/types/ci/job_artifact_type.rb
+++ b/app/graphql/types/ci/job_artifact_type.rb
@@ -7,14 +7,14 @@ module Types
graphql_name 'CiJobArtifact'
field :download_path, GraphQL::Types::String, null: true,
- description: "URL for downloading the artifact's file."
+ description: "URL for downloading the artifact's file."
field :file_type, ::Types::Ci::JobArtifactFileTypeEnum, null: true,
- description: 'File type of the artifact.'
+ description: 'File type of the artifact.'
field :name, GraphQL::Types::String, null: true,
- description: 'File name of the artifact.',
- method: :filename
+ description: 'File name of the artifact.',
+ method: :filename
def download_path
::Gitlab::Routing.url_helpers.download_project_job_artifacts_path(
diff --git a/app/graphql/types/ci/job_token_scope_type.rb b/app/graphql/types/ci/job_token_scope_type.rb
index 9f48298e1d3..37c0af944a7 100644
--- a/app/graphql/types/ci/job_token_scope_type.rb
+++ b/app/graphql/types/ci/job_token_scope_type.rb
@@ -7,9 +7,11 @@ module Types
class JobTokenScopeType < BaseObject
graphql_name 'CiJobTokenScopeType'
- field :projects, Types::ProjectType.connection_type, null: false,
- description: 'Allow list of projects that can be accessed by CI Job tokens created by this project.',
- method: :all_projects
+ field :projects,
+ Types::ProjectType.connection_type,
+ null: false,
+ description: 'Allow list of projects that can be accessed by CI Job tokens created by this project.',
+ method: :all_projects
end
end
# rubocop: enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 42b55f47f92..4ea9a016e74 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -12,39 +12,39 @@ module Types
expose_permissions Types::PermissionTypes::Ci::Job
field :allow_failure, ::GraphQL::Types::Boolean, null: false,
- description: 'Whether the job is allowed to fail.'
+ description: 'Whether the job is allowed to fail.'
field :duration, GraphQL::Types::Int, null: true,
- description: 'Duration of the job in seconds.'
+ description: 'Duration of the job in seconds.'
field :id, ::Types::GlobalIDType[::CommitStatus].as('JobID'), null: true,
- description: 'ID of the job.'
+ description: 'ID of the job.'
field :kind, type: ::Types::Ci::JobKindEnum, null: false,
- description: 'Indicates the type of job.'
+ description: 'Indicates the type of job.'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the job.'
+ description: 'Name of the job.'
field :needs, BuildNeedType.connection_type, null: true,
- description: 'References to builds that must complete before the jobs run.'
+ description: 'References to builds that must complete before the jobs run.'
field :pipeline, Types::Ci::PipelineType, null: true,
- description: 'Pipeline the job belongs to.'
+ description: 'Pipeline the job belongs to.'
field :stage, Types::Ci::StageType, null: true,
- description: 'Stage of the job.'
+ description: 'Stage of the job.'
field :status,
type: ::Types::Ci::JobStatusEnum,
null: true,
description: "Status of the job."
field :tags, [GraphQL::Types::String], null: true,
- description: 'Tags for the current job.'
+ description: 'Tags for the current job.'
# Life-cycle timestamps:
field :created_at, Types::TimeType, null: false,
- description: "When the job was created."
+ description: "When the job was created."
field :finished_at, Types::TimeType, null: true,
- description: 'When a job has finished running.'
+ description: 'When a job has finished running.'
field :queued_at, Types::TimeType, null: true,
- description: 'When the job was enqueued and marked as pending.'
+ description: 'When the job was enqueued and marked as pending.'
field :scheduled_at, Types::TimeType, null: true,
- description: 'Schedule for the build.'
+ description: 'Schedule for the build.'
field :started_at, Types::TimeType, null: true,
- description: 'When the job was started.'
+ description: 'When the job was started.'
# Life-cycle durations:
field :queued_duration,
@@ -53,45 +53,45 @@ module Types
description: 'How long the job was enqueued before starting.'
field :active, GraphQL::Types::Boolean, null: false, method: :active?,
- description: 'Indicates the job is active.'
+ description: 'Indicates the job is active.'
field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true,
- description: 'Artifacts generated by the job.'
+ description: 'Artifacts generated by the job.'
field :cancelable, GraphQL::Types::Boolean, null: false, method: :cancelable?,
- description: 'Indicates the job can be canceled.'
+ description: 'Indicates the job can be canceled.'
field :commit_path, GraphQL::Types::String, null: true,
- description: 'Path to the commit that triggered the job.'
+ description: 'Path to the commit that triggered the job.'
field :coverage, GraphQL::Types::Float, null: true,
- description: 'Coverage level of the job.'
+ description: 'Coverage level of the job.'
field :created_by_tag, GraphQL::Types::Boolean, null: false,
- description: 'Whether the job was created by a tag.', method: :tag?
+ description: 'Whether the job was created by a tag.', method: :tag?
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the job.'
+ description: 'Detailed status of the job.'
field :downstream_pipeline, Types::Ci::PipelineType, null: true,
- description: 'Downstream pipeline for a bridge.'
+ description: 'Downstream pipeline for a bridge.'
field :manual_job, GraphQL::Types::Boolean, null: true,
- description: 'Whether the job has a manual action.'
- field :manual_variables, VariableType.connection_type, null: true,
- description: 'Variables added to a manual job when the job is triggered.'
+ description: 'Whether the job has a manual action.'
+ field :manual_variables, ManualVariableType.connection_type, null: true,
+ description: 'Variables added to a manual job when the job is triggered.'
field :playable, GraphQL::Types::Boolean, null: false, method: :playable?,
- description: 'Indicates the job can be played.'
+ description: 'Indicates the job can be played.'
field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true,
- description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.'
+ description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.'
field :ref_name, GraphQL::Types::String, null: true,
- description: 'Ref name of the job.'
+ description: 'Ref name of the job.'
field :ref_path, GraphQL::Types::String, null: true,
- description: 'Path to the ref.'
+ description: 'Path to the ref.'
field :retried, GraphQL::Types::Boolean, null: true,
- description: 'Indicates that the job has been retried.'
+ description: 'Indicates that the job has been retried.'
field :retryable, GraphQL::Types::Boolean, null: false, method: :retryable?,
- description: 'Indicates the job can be retried.'
+ description: 'Indicates the job can be retried.'
field :scheduling_type, GraphQL::Types::String, null: true,
- description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.'
+ description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.'
field :short_sha, type: GraphQL::Types::String, null: false,
- description: 'Short SHA1 ID of the commit.'
+ description: 'Short SHA1 ID of the commit.'
field :stuck, GraphQL::Types::Boolean, null: false, method: :stuck?,
- description: 'Indicates the job is stuck.'
+ description: 'Indicates the job is stuck.'
field :triggered, GraphQL::Types::Boolean, null: true,
- description: 'Whether the job was triggered.'
+ description: 'Whether the job was triggered.'
def kind
return ::Ci::Build unless [::Ci::Build, ::Ci::Bridge].include?(object.class)
@@ -194,7 +194,7 @@ module Types
end
def manual_variables
- if object.manual? && object.respond_to?(:job_variables)
+ if object.action? && object.respond_to?(:job_variables)
object.job_variables
else
[]
diff --git a/app/graphql/types/ci/manual_variable_type.rb b/app/graphql/types/ci/manual_variable_type.rb
new file mode 100644
index 00000000000..d6f59c1d249
--- /dev/null
+++ b/app/graphql/types/ci/manual_variable_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class ManualVariableType < BaseObject
+ graphql_name 'CiManualVariable'
+ description 'CI/CD variables given to a manual job.'
+
+ implements(VariableInterface)
+
+ field :environment_scope, GraphQL::Types::String,
+ null: true,
+ deprecated: {
+ reason: 'No longer used, only available for GroupVariableType and ProjectVariableType',
+ milestone: '15.3'
+ },
+ description: 'Scope defining the environments that can use the variable.'
+
+ def environment_scope
+ nil
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/pipeline_message_type.rb b/app/graphql/types/ci/pipeline_message_type.rb
index 7edea1901a1..35164b0894a 100644
--- a/app/graphql/types/ci/pipeline_message_type.rb
+++ b/app/graphql/types/ci/pipeline_message_type.rb
@@ -7,10 +7,10 @@ module Types
graphql_name 'PipelineMessage'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the pipeline message.'
+ description: 'ID of the pipeline message.'
field :content, GraphQL::Types::String, null: false,
- description: 'Content of the pipeline message.'
+ description: 'Content of the pipeline message.'
end
end
end
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index 60418fec6c5..4a523f2edd9 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -13,14 +13,14 @@ module Types
expose_permissions Types::PermissionTypes::Ci::Pipeline
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the pipeline.'
+ description: 'ID of the pipeline.'
field :iid, GraphQL::Types::String, null: false,
- description: 'Internal ID of the pipeline.'
+ description: 'Internal ID of the pipeline.'
field :sha, GraphQL::Types::String, null: true,
- method: :sha,
- description: "SHA of the pipeline's commit." do
+ method: :sha,
+ description: "SHA of the pipeline's commit." do
argument :format,
type: Types::ShaFormatEnum,
required: false,
@@ -28,46 +28,46 @@ module Types
end
field :before_sha, GraphQL::Types::String, null: true,
- description: 'Base SHA of the source branch.'
+ description: 'Base SHA of the source branch.'
field :complete, GraphQL::Types::Boolean, null: false, method: :complete?,
- description: 'Indicates if a pipeline is complete.'
+ description: 'Indicates if a pipeline is complete.'
field :status, PipelineStatusEnum, null: false,
- description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})"
+ description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})"
field :warnings, GraphQL::Types::Boolean, null: false, method: :has_warnings?,
- description: "Indicates if a pipeline has warnings."
+ description: "Indicates if a pipeline has warnings."
field :detailed_status, Types::Ci::DetailedStatusType, null: false,
- description: 'Detailed status of the pipeline.'
+ description: 'Detailed status of the pipeline.'
field :config_source, PipelineConfigSourceEnum, null: true,
- description: "Configuration source of the pipeline (#{::Enums::Ci::Pipeline.config_sources.keys.join(', ').upcase})"
+ description: "Configuration source of the pipeline (#{::Enums::Ci::Pipeline.config_sources.keys.join(', ').upcase})"
field :duration, GraphQL::Types::Int, null: true,
- description: 'Duration of the pipeline in seconds.'
+ description: 'Duration of the pipeline in seconds.'
field :queued_duration, Types::DurationType, null: true,
- description: 'How long the pipeline was queued before starting.'
+ description: 'How long the pipeline was queued before starting.'
field :coverage, GraphQL::Types::Float, null: true,
- description: 'Coverage percentage.'
+ description: 'Coverage percentage.'
field :created_at, Types::TimeType, null: false,
- description: "Timestamp of the pipeline's creation."
+ description: "Timestamp of the pipeline's creation."
field :updated_at, Types::TimeType, null: false,
- description: "Timestamp of the pipeline's last activity."
+ description: "Timestamp of the pipeline's last activity."
field :started_at, Types::TimeType, null: true,
- description: 'Timestamp when the pipeline was started.'
+ description: 'Timestamp when the pipeline was started.'
field :finished_at, Types::TimeType, null: true,
- description: "Timestamp of the pipeline's completion."
+ description: "Timestamp of the pipeline's completion."
field :committed_at, Types::TimeType, null: true,
- description: "Timestamp of the pipeline's commit."
+ description: "Timestamp of the pipeline's commit."
field :stages,
type: Types::Ci::StageType.connection_type,
@@ -126,32 +126,32 @@ module Types
description: 'Job where pipeline was triggered from.'
field :downstream, Types::Ci::PipelineType.connection_type, null: true,
- description: 'Pipelines this pipeline will trigger.',
- method: :triggered_pipelines_with_preloads
+ description: 'Pipelines this pipeline will trigger.',
+ method: :triggered_pipelines_with_preloads
field :upstream, Types::Ci::PipelineType, null: true,
- description: 'Pipeline that triggered the pipeline.',
- method: :triggered_by_pipeline
+ description: 'Pipeline that triggered the pipeline.',
+ method: :triggered_by_pipeline
field :path, GraphQL::Types::String, null: true,
- description: "Relative path to the pipeline's page."
+ description: "Relative path to the pipeline's page."
field :commit, Types::CommitType, null: true,
- description: "Git commit of the pipeline.",
- calls_gitaly: true
+ description: "Git commit of the pipeline.",
+ calls_gitaly: true
field :commit_path, GraphQL::Types::String, null: true,
- description: 'Path to the commit that triggered the pipeline.'
+ description: 'Path to the commit that triggered the pipeline.'
field :project, Types::ProjectType, null: true,
- description: 'Project the pipeline belongs to.'
+ description: 'Project the pipeline belongs to.'
field :active, GraphQL::Types::Boolean, null: false, method: :active?,
- description: 'Indicates if the pipeline is active.'
+ description: 'Indicates if the pipeline is active.'
field :uses_needs, GraphQL::Types::Boolean, null: true,
- method: :uses_needs?,
- description: 'Indicates if the pipeline has jobs with `needs` dependencies.'
+ method: :uses_needs?,
+ description: 'Indicates if the pipeline has jobs with `needs` dependencies.'
field :test_report_summary,
Types::Ci::TestReportSummaryType,
@@ -166,17 +166,17 @@ module Types
resolver: Resolvers::Ci::TestSuiteResolver
field :ref, GraphQL::Types::String, null: true,
- description: 'Reference to the branch from which the pipeline was triggered.'
+ description: 'Reference to the branch from which the pipeline was triggered.'
field :ref_path, GraphQL::Types::String, null: true,
- description: 'Reference path to the branch from which the pipeline was triggered.',
- method: :source_ref_path
+ description: 'Reference path to the branch from which the pipeline was triggered.',
+ method: :source_ref_path
field :warning_messages, [Types::Ci::PipelineMessageType], null: true,
- description: 'Pipeline warning messages.'
+ description: 'Pipeline warning messages.'
field :merge_request_event_type, Types::Ci::PipelineMergeRequestEventTypeEnum, null: true,
- description: "Event type of the pipeline associated with a merge request."
+ description: "Event type of the pipeline associated with a merge request."
def detailed_status
object.detailed_status(current_user)
@@ -200,7 +200,7 @@ module Types
if id
pipeline.statuses.id_in(id.model_id)
else
- pipeline.statuses.by_name(name)
+ pipeline.latest_statuses.by_name(name)
end.take # rubocop: disable CodeReuse/ActiveRecord
end
diff --git a/app/graphql/types/ci/project_variable_type.rb b/app/graphql/types/ci/project_variable_type.rb
new file mode 100644
index 00000000000..625bb7fd4b1
--- /dev/null
+++ b/app/graphql/types/ci/project_variable_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class ProjectVariableType < BaseObject
+ graphql_name 'CiProjectVariable'
+ description 'CI/CD variables for a project.'
+
+ implements(VariableInterface)
+
+ field :environment_scope, GraphQL::Types::String,
+ null: true,
+ description: 'Scope defining the environments that can use the variable.'
+
+ field :protected, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is protected.'
+
+ field :masked, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is masked.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/recent_failures_type.rb b/app/graphql/types/ci/recent_failures_type.rb
index f56b0939086..0892cb2735c 100644
--- a/app/graphql/types/ci/recent_failures_type.rb
+++ b/app/graphql/types/ci/recent_failures_type.rb
@@ -10,10 +10,10 @@ module Types
connection_type_class(Types::CountableConnectionType)
field :count, GraphQL::Types::Int, null: true,
- description: 'Number of times the test case has failed in the past 14 days.'
+ description: 'Number of times the test case has failed in the past 14 days.'
field :base_branch, GraphQL::Types::String, null: true,
- description: 'Name of the base branch of the project.'
+ description: 'Name of the base branch of the project.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/runner_architecture_type.rb b/app/graphql/types/ci/runner_architecture_type.rb
index eb576cf09ce..8b0558fc6a6 100644
--- a/app/graphql/types/ci/runner_architecture_type.rb
+++ b/app/graphql/types/ci/runner_architecture_type.rb
@@ -6,10 +6,12 @@ module Types
class RunnerArchitectureType < BaseObject
graphql_name 'RunnerArchitecture'
- field :download_location, GraphQL::Types::String, null: false,
- description: 'Download location for the runner for the platform architecture.'
+ field :download_location,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Download location for the runner for the platform architecture.'
field :name, GraphQL::Types::String, null: false,
- description: 'Name of the runner platform architecture.'
+ description: 'Name of the runner platform architecture.'
end
end
end
diff --git a/app/graphql/types/ci/runner_platform_type.rb b/app/graphql/types/ci/runner_platform_type.rb
index 3c893615b20..1e481cc08bf 100644
--- a/app/graphql/types/ci/runner_platform_type.rb
+++ b/app/graphql/types/ci/runner_platform_type.rb
@@ -6,12 +6,14 @@ module Types
class RunnerPlatformType < BaseObject
graphql_name 'RunnerPlatform'
- field :architectures, Types::Ci::RunnerArchitectureType.connection_type, null: true,
- description: 'Runner architectures supported for the platform.'
+ field :architectures,
+ Types::Ci::RunnerArchitectureType.connection_type,
+ null: true,
+ description: 'Runner architectures supported for the platform.'
field :human_readable_name, GraphQL::Types::String, null: false,
- description: 'Human readable name of the runner platform.'
+ description: 'Human readable name of the runner platform.'
field :name, GraphQL::Types::String, null: false,
- description: 'Name slug of the runner platform.'
+ description: 'Name slug of the runner platform.'
end
end
end
diff --git a/app/graphql/types/ci/runner_setup_type.rb b/app/graphql/types/ci/runner_setup_type.rb
index b6b020db40e..5328ac8f21f 100644
--- a/app/graphql/types/ci/runner_setup_type.rb
+++ b/app/graphql/types/ci/runner_setup_type.rb
@@ -7,9 +7,9 @@ module Types
graphql_name 'RunnerSetup'
field :install_instructions, GraphQL::Types::String, null: false,
- description: 'Instructions for installing the runner on the specified architecture.'
+ description: 'Instructions for installing the runner on the specified architecture.'
field :register_instructions, GraphQL::Types::String, null: true,
- description: 'Instructions for registering the runner. The actual registration tokens are not included in the commands. Instead, a placeholder `$REGISTRATION_TOKEN` is shown.'
+ description: 'Instructions for registering the runner. The actual registration tokens are not included in the commands. Instead, a placeholder `$REGISTRATION_TOKEN` is shown.'
end
end
end
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index ac5ffd39407..0afb61d2b64 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -17,77 +17,77 @@ module Types
alias_method :runner, :object
field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false,
- description: 'Access level of the runner.'
+ description: 'Access level of the runner.'
field :active, GraphQL::Types::Boolean, null: false,
- description: 'Indicates the runner is allowed to receive jobs.',
- deprecated: { reason: 'Use paused', milestone: '14.8' }
+ description: 'Indicates the runner is allowed to receive jobs.',
+ deprecated: { reason: 'Use paused', milestone: '14.8' }
field :admin_url, GraphQL::Types::String, null: true,
- description: 'Admin URL of the runner. Only available for administrators.'
+ description: 'Admin URL of the runner. Only available for administrators.'
field :contacted_at, Types::TimeType, null: true,
- description: 'Timestamp of last contact from this runner.',
- method: :contacted_at
+ description: 'Timestamp of last contact from this runner.',
+ method: :contacted_at
field :created_at, Types::TimeType, null: true,
- description: 'Timestamp of creation of this runner.'
+ description: 'Timestamp of creation of this runner.'
field :description, GraphQL::Types::String, null: true,
- description: 'Description of the runner.'
+ description: 'Description of the runner.'
field :edit_admin_url, GraphQL::Types::String, null: true,
- description: 'Admin form URL of the runner. Only available for administrators.'
+ description: 'Admin form URL of the runner. Only available for administrators.'
field :executor_name, GraphQL::Types::String, null: true,
- description: 'Executor last advertised by the runner.',
- method: :executor_name
+ description: 'Executor last advertised by the runner.',
+ method: :executor_name
field :platform_name, GraphQL::Types::String, null: true,
- description: 'Platform provided by the runner.',
- method: :platform
+ description: 'Platform provided by the runner.',
+ method: :platform
field :architecture_name, GraphQL::Types::String, null: true,
- description: 'Architecture provided by the the runner.',
- method: :architecture
+ description: 'Architecture provided by the the runner.',
+ method: :architecture
field :maintenance_note, GraphQL::Types::String, null: true,
- description: 'Runner\'s maintenance notes.'
+ description: 'Runner\'s maintenance notes.'
field :groups, ::Types::GroupType.connection_type, null: true,
- description: 'Groups the runner is associated with. For group runners only.'
+ description: 'Groups the runner is associated with. For group runners only.'
field :id, ::Types::GlobalIDType[::Ci::Runner], null: false,
- description: 'ID of the runner.'
+ description: 'ID of the runner.'
field :ip_address, GraphQL::Types::String, null: true,
- description: 'IP address of the runner.'
+ description: 'IP address of the runner.'
field :job_count, GraphQL::Types::Int, null: true,
- description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)."
+ description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)."
field :jobs, ::Types::Ci::JobType.connection_type, null: true,
- description: 'Jobs assigned to the runner.',
- authorize: :read_builds,
- resolver: ::Resolvers::Ci::RunnerJobsResolver
+ description: 'Jobs assigned to the runner.',
+ authorize: :read_builds,
+ resolver: ::Resolvers::Ci::RunnerJobsResolver
field :locked, GraphQL::Types::Boolean, null: true,
- description: 'Indicates the runner is locked.'
+ description: 'Indicates the runner is locked.'
field :maximum_timeout, GraphQL::Types::Int, null: true,
- description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
+ description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
field :paused, GraphQL::Types::Boolean, null: false,
- description: 'Indicates the runner is paused and not available to run jobs.'
+ description: 'Indicates the runner is paused and not available to run jobs.'
field :project_count, GraphQL::Types::Int, null: true,
- description: 'Number of projects that the runner is associated with.'
+ description: 'Number of projects that the runner is associated with.'
field :projects, ::Types::ProjectType.connection_type, null: true,
- description: 'Projects the runner is associated with. For project runners only.'
+ description: 'Projects the runner is associated with. For project runners only.'
field :revision, GraphQL::Types::String, null: true,
- description: 'Revision of the runner.'
+ description: 'Revision of the runner.'
field :run_untagged, GraphQL::Types::Boolean, null: false,
- description: 'Indicates the runner is able to run untagged jobs.'
+ description: 'Indicates the runner is able to run untagged jobs.'
field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false,
- description: 'Type of the runner.'
+ description: 'Type of the runner.'
field :short_sha, GraphQL::Types::String, null: true,
- description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.)
+ description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.)
field :status,
Types::Ci::RunnerStatusEnum,
null: false,
description: 'Status of the runner.',
resolver: ::Resolvers::Ci::RunnerStatusResolver # TODO: Remove :resolver in %17.0
field :tag_list, [GraphQL::Types::String], null: true,
- description: 'Tags associated with the runner.'
+ description: 'Tags associated with the runner.'
field :token_expires_at, Types::TimeType, null: true,
- description: 'Runner token expiration time.',
- method: :token_expires_at
+ description: 'Runner token expiration time.',
+ method: :token_expires_at
field :version, GraphQL::Types::String, null: true,
- description: 'Version of the runner.'
+ description: 'Version of the runner.'
field :owner_project, ::Types::ProjectType, null: true,
- description: 'Project that owns the runner. For project runners only.',
- resolver: ::Resolvers::Ci::RunnerOwnerProjectResolver
+ description: 'Project that owns the runner. For project runners only.',
+ resolver: ::Resolvers::Ci::RunnerOwnerProjectResolver
markdown_field :maintenance_note_html, null: true
diff --git a/app/graphql/types/ci/runner_upgrade_status_type_enum.rb b/app/graphql/types/ci/runner_upgrade_status_enum.rb
index 8e32eee5e6e..34a931c8f79 100644
--- a/app/graphql/types/ci/runner_upgrade_status_type_enum.rb
+++ b/app/graphql/types/ci/runner_upgrade_status_enum.rb
@@ -2,8 +2,8 @@
module Types
module Ci
- class RunnerUpgradeStatusTypeEnum < BaseEnum
- graphql_name 'CiRunnerUpgradeStatusType'
+ class RunnerUpgradeStatusEnum < BaseEnum
+ graphql_name 'CiRunnerUpgradeStatus'
::Ci::RunnerVersion::STATUS_DESCRIPTIONS.each do |status, description|
status_name_src =
diff --git a/app/graphql/types/ci/runner_web_url_edge.rb b/app/graphql/types/ci/runner_web_url_edge.rb
index 7dfcd1f3510..9255e59267c 100644
--- a/app/graphql/types/ci/runner_web_url_edge.rb
+++ b/app/graphql/types/ci/runner_web_url_edge.rb
@@ -5,11 +5,11 @@ module Types
# rubocop: disable Graphql/AuthorizeTypes
class RunnerWebUrlEdge < ::Types::BaseEdge
field :edit_url, GraphQL::Types::String, null: true,
- description: 'Web URL of the runner edit page. The value depends on where you put this field in the query. You can use it for projects or groups.',
- extras: [:parent]
+ description: 'Web URL of the runner edit page. The value depends on where you put this field in the query. You can use it for projects or groups.',
+ extras: [:parent]
field :web_url, GraphQL::Types::String, null: true,
- description: 'Web URL of the runner. The value depends on where you put this field in the query. You can use it for projects or groups.',
- extras: [:parent]
+ description: 'Web URL of the runner. The value depends on where you put this field in the query. You can use it for projects or groups.',
+ extras: [:parent]
def initialize(node, connection)
super
diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb
index dcb3092d15a..c0f3d1db57b 100644
--- a/app/graphql/types/ci/stage_type.rb
+++ b/app/graphql/types/ci/stage_type.rb
@@ -7,16 +7,16 @@ module Types
authorize :read_build
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the stage.'
+ description: 'Detailed status of the stage.'
field :groups, type: Ci::GroupType.connection_type, null: true,
- extras: [:lookahead],
- description: 'Group of jobs for the stage.'
+ extras: [:lookahead],
+ description: 'Group of jobs for the stage.'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the stage.'
+ description: 'ID of the stage.'
field :jobs, Types::Ci::JobType.connection_type, null: true,
- description: 'Jobs for the stage.'
+ description: 'Jobs for the stage.'
field :name, type: GraphQL::Types::String, null: true,
- description: 'Name of the stage.'
+ description: 'Name of the stage.'
field :status, GraphQL::Types::String,
null: true,
description: 'Status of the pipeline stage.'
diff --git a/app/graphql/types/ci/status_action_type.rb b/app/graphql/types/ci/status_action_type.rb
index c0f61cf49f2..45773be49e2 100644
--- a/app/graphql/types/ci/status_action_type.rb
+++ b/app/graphql/types/ci/status_action_type.rb
@@ -6,19 +6,19 @@ module Types
graphql_name 'StatusAction'
field :button_title, GraphQL::Types::String, null: true,
- description: 'Title for the button, for example: Retry this job.'
+ description: 'Title for the button, for example: Retry this job.'
field :icon, GraphQL::Types::String, null: true,
- description: 'Icon used in the action button.'
+ description: 'Icon used in the action button.'
field :id, GraphQL::Types::String, null: false,
- description: 'ID for a status action.',
- extras: [:parent]
+ description: 'ID for a status action.',
+ extras: [:parent]
field :method, GraphQL::Types::String, null: true,
- description: 'Method for the action, for example: :post.',
- resolver_method: :action_method
+ description: 'Method for the action, for example: :post.',
+ resolver_method: :action_method
field :path, GraphQL::Types::String, null: true,
- description: 'Path for the action.'
+ description: 'Path for the action.'
field :title, GraphQL::Types::String, null: true,
- description: 'Title for the action, for example: Retry.'
+ description: 'Title for the action, for example: Retry.'
def id(parent:)
# parent is a SimpleDelegator
diff --git a/app/graphql/types/ci/template_type.rb b/app/graphql/types/ci/template_type.rb
index 4f1ec6436de..91e10c619c8 100644
--- a/app/graphql/types/ci/template_type.rb
+++ b/app/graphql/types/ci/template_type.rb
@@ -8,9 +8,9 @@ module Types
description 'GitLab CI/CD configuration template.'
field :content, GraphQL::Types::String, null: false,
- description: 'Contents of the CI template.'
+ description: 'Contents of the CI template.'
field :name, GraphQL::Types::String, null: false,
- description: 'Name of the CI template.'
+ description: 'Name of the CI template.'
end
end
end
diff --git a/app/graphql/types/ci/test_case_type.rb b/app/graphql/types/ci/test_case_type.rb
index 6e5f55aa3ed..f88923215eb 100644
--- a/app/graphql/types/ci/test_case_type.rb
+++ b/app/graphql/types/ci/test_case_type.rb
@@ -9,32 +9,36 @@ module Types
connection_type_class(Types::CountableConnectionType)
- field :status, Types::Ci::TestCaseStatusEnum, null: true,
- description: "Status of the test case (#{::Gitlab::Ci::Reports::TestCase::STATUS_TYPES.join(', ')})."
+ field :status,
+ Types::Ci::TestCaseStatusEnum,
+ null: true,
+ description: "Status of the test case (#{::Gitlab::Ci::Reports::TestCase::STATUS_TYPES.join(', ')})."
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the test case.'
+ description: 'Name of the test case.'
field :classname, GraphQL::Types::String, null: true,
- description: 'Classname of the test case.'
+ description: 'Classname of the test case.'
field :execution_time, GraphQL::Types::Float, null: true,
- description: 'Test case execution time in seconds.'
+ description: 'Test case execution time in seconds.'
field :file, GraphQL::Types::String, null: true,
- description: 'Path to the file of the test case.'
+ description: 'Path to the file of the test case.'
field :attachment_url, GraphQL::Types::String, null: true,
- description: 'URL of the test case attachment file.'
+ description: 'URL of the test case attachment file.'
field :system_output, GraphQL::Types::String, null: true,
- description: 'System output of the test case.'
+ description: 'System output of the test case.'
field :stack_trace, GraphQL::Types::String, null: true,
- description: 'Stack trace of the test case.'
+ description: 'Stack trace of the test case.'
- field :recent_failures, Types::Ci::RecentFailuresType, null: true,
- description: 'Recent failure history of the test case on the base branch.'
+ field :recent_failures,
+ Types::Ci::RecentFailuresType,
+ null: true,
+ description: 'Recent failure history of the test case on the base branch.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/test_report_summary_type.rb b/app/graphql/types/ci/test_report_summary_type.rb
index 87207c8a765..e3577f4fa8f 100644
--- a/app/graphql/types/ci/test_report_summary_type.rb
+++ b/app/graphql/types/ci/test_report_summary_type.rb
@@ -9,10 +9,12 @@ module Types
description 'Test report for a pipeline'
field :total, Types::Ci::TestReportTotalType, null: false,
- description: 'Total report statistics for a pipeline test report.'
+ description: 'Total report statistics for a pipeline test report.'
- field :test_suites, Types::Ci::TestSuiteSummaryType.connection_type, null: false,
- description: 'Test suites belonging to a pipeline test report.'
+ field :test_suites,
+ Types::Ci::TestSuiteSummaryType.connection_type,
+ null: false,
+ description: 'Test suites belonging to a pipeline test report.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/test_report_total_type.rb b/app/graphql/types/ci/test_report_total_type.rb
index 48aea1257c5..54959d5173f 100644
--- a/app/graphql/types/ci/test_report_total_type.rb
+++ b/app/graphql/types/ci/test_report_total_type.rb
@@ -8,25 +8,25 @@ module Types
description 'Total test report statistics.'
field :time, GraphQL::Types::Float, null: true,
- description: 'Total duration of the tests.'
+ description: 'Total duration of the tests.'
field :count, GraphQL::Types::Int, null: true,
- description: 'Total number of the test cases.'
+ description: 'Total number of the test cases.'
field :success, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that succeeded.'
+ description: 'Total number of test cases that succeeded.'
field :failed, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that failed.'
+ description: 'Total number of test cases that failed.'
field :skipped, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that were skipped.'
+ description: 'Total number of test cases that were skipped.'
field :error, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that had an error.'
+ description: 'Total number of test cases that had an error.'
field :suite_error, GraphQL::Types::String, null: true,
- description: 'Test suite error message.'
+ description: 'Test suite error message.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/test_suite_summary_type.rb b/app/graphql/types/ci/test_suite_summary_type.rb
index ec7b852213b..8801501c8d4 100644
--- a/app/graphql/types/ci/test_suite_summary_type.rb
+++ b/app/graphql/types/ci/test_suite_summary_type.rb
@@ -10,31 +10,37 @@ module Types
connection_type_class(Types::CountableConnectionType)
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the test suite.'
+ description: 'Name of the test suite.'
field :total_time, GraphQL::Types::Float, null: true,
- description: 'Total duration of the tests in the test suite.'
+ description: 'Total duration of the tests in the test suite.'
field :total_count, GraphQL::Types::Int, null: true,
- description: 'Total number of the test cases in the test suite.'
+ description: 'Total number of the test cases in the test suite.'
- field :success_count, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that succeeded in the test suite.'
+ field :success_count,
+ GraphQL::Types::Int,
+ null: true,
+ description: 'Total number of test cases that succeeded in the test suite.'
- field :failed_count, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that failed in the test suite.'
+ field :failed_count,
+ GraphQL::Types::Int,
+ null: true,
+ description: 'Total number of test cases that failed in the test suite.'
- field :skipped_count, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that were skipped in the test suite.'
+ field :skipped_count,
+ GraphQL::Types::Int,
+ null: true,
+ description: 'Total number of test cases that were skipped in the test suite.'
field :error_count, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that had an error.'
+ description: 'Total number of test cases that had an error.'
field :suite_error, GraphQL::Types::String, null: true,
- description: 'Test suite error message.'
+ description: 'Test suite error message.'
field :build_ids, [GraphQL::Types::ID], null: true,
- description: 'IDs of the builds used to run the test suite.'
+ description: 'IDs of the builds used to run the test suite.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/test_suite_type.rb b/app/graphql/types/ci/test_suite_type.rb
index 7ce479632cc..8845338ed6d 100644
--- a/app/graphql/types/ci/test_suite_type.rb
+++ b/app/graphql/types/ci/test_suite_type.rb
@@ -10,31 +10,36 @@ module Types
connection_type_class(Types::CountableConnectionType)
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the test suite.'
+ description: 'Name of the test suite.'
field :total_time, GraphQL::Types::Float, null: true,
- description: 'Total duration of the tests in the test suite.'
+ description: 'Total duration of the tests in the test suite.'
field :total_count, GraphQL::Types::Int, null: true,
- description: 'Total number of the test cases in the test suite.'
+ description: 'Total number of the test cases in the test suite.'
- field :success_count, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that succeeded in the test suite.'
+ field :success_count,
+ GraphQL::Types::Int,
+ null: true,
+ description: 'Total number of test cases that succeeded in the test suite.'
- field :failed_count, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that failed in the test suite.'
+ field :failed_count,
+ GraphQL::Types::Int,
+ null: true,
+ description: 'Total number of test cases that failed in the test suite.'
- field :skipped_count, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that were skipped in the test suite.'
+ field :skipped_count,
+ GraphQL::Types::Int, null: true,
+ description: 'Total number of test cases that were skipped in the test suite.'
field :error_count, GraphQL::Types::Int, null: true,
- description: 'Total number of test cases that had an error.'
+ description: 'Total number of test cases that had an error.'
field :suite_error, GraphQL::Types::String, null: true,
- description: 'Test suite error message.'
+ description: 'Test suite error message.'
field :test_cases, Types::Ci::TestCaseType.connection_type, null: true,
- description: 'Test cases in the test suite.'
+ description: 'Test cases in the test suite.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/variable_input_type.rb b/app/graphql/types/ci/variable_input_type.rb
new file mode 100644
index 00000000000..193ca6ffe4e
--- /dev/null
+++ b/app/graphql/types/ci/variable_input_type.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class VariableInputType < BaseInputObject
+ graphql_name 'CiVariableInput'
+ description 'Attributes for defining a CI/CD variable.'
+
+ argument :key, GraphQL::Types::String, description: 'Name of the variable.'
+ argument :value, GraphQL::Types::String, description: 'Value of the variable.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/variable_interface.rb b/app/graphql/types/ci/variable_interface.rb
new file mode 100644
index 00000000000..82c9ba7121c
--- /dev/null
+++ b/app/graphql/types/ci/variable_interface.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ module VariableInterface
+ include Types::BaseInterface
+
+ graphql_name 'CiVariable'
+
+ field :id, GraphQL::Types::ID,
+ null: false,
+ description: 'ID of the variable.'
+
+ field :key, GraphQL::Types::String,
+ null: true,
+ description: 'Name of the variable.'
+
+ field :value, GraphQL::Types::String,
+ null: true,
+ description: 'Value of the variable.'
+
+ field :variable_type, ::Types::Ci::VariableTypeEnum,
+ null: true,
+ description: 'Type of the variable.'
+
+ field :raw, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is raw.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/variable_type.rb b/app/graphql/types/ci/variable_type.rb
deleted file mode 100644
index 63f89b6d207..00000000000
--- a/app/graphql/types/ci/variable_type.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-module Types
- module Ci
- # rubocop: disable Graphql/AuthorizeTypes
- class VariableType < BaseObject
- graphql_name 'CiVariable'
-
- field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the variable.'
-
- field :key, GraphQL::Types::String, null: true,
- description: 'Name of the variable.'
-
- field :value, GraphQL::Types::String, null: true,
- description: 'Value of the variable.'
-
- field :variable_type, ::Types::Ci::VariableTypeEnum, null: true,
- description: 'Type of the variable.'
-
- field :protected, GraphQL::Types::Boolean, null: true,
- description: 'Indicates whether the variable is protected.'
-
- field :masked, GraphQL::Types::Boolean, null: true,
- description: 'Indicates whether the variable is masked.'
-
- field :raw, GraphQL::Types::Boolean, null: true,
- description: 'Indicates whether the variable is raw.'
-
- field :environment_scope, GraphQL::Types::String, null: true,
- description: 'Scope defining the environments in which the variable can be used.'
-
- def environment_scope
- if object.respond_to?(:environment_scope)
- object.environment_scope
- end
- end
- end
- end
-end
diff --git a/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb b/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb
index 68b9a63d8dc..3344693bf46 100644
--- a/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb
+++ b/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb
@@ -8,10 +8,10 @@ module Types
description 'Represents the analyzers entity in SAST CI configuration'
argument :name, GraphQL::Types::String, required: true,
- description: 'Name of analyzer.'
+ description: 'Name of analyzer.'
argument :enabled, GraphQL::Types::Boolean, required: true,
- description: 'State of the analyzer.'
+ description: 'State of the analyzer.'
argument :variables, [::Types::CiConfiguration::Sast::EntityInputType],
description: 'List of variables for the analyzer.',
diff --git a/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb
index 9fdc7c1b000..de160756c8c 100644
--- a/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb
+++ b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb
@@ -9,19 +9,21 @@ module Types
description 'Represents an analyzer entity in SAST CI configuration'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the analyzer.'
+ description: 'Name of the analyzer.'
field :label, GraphQL::Types::String, null: true,
- description: 'Analyzer label used in the config UI.'
+ description: 'Analyzer label used in the config UI.'
field :enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates whether an analyzer is enabled.'
+ description: 'Indicates whether an analyzer is enabled.'
field :description, GraphQL::Types::String, null: true,
- description: 'Analyzer description that is displayed on the form.'
+ description: 'Analyzer description that is displayed on the form.'
- field :variables, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true,
- description: 'List of supported variables.'
+ field :variables,
+ ::Types::CiConfiguration::Sast::EntityType.connection_type,
+ null: true,
+ description: 'List of supported variables.'
end
end
end
diff --git a/app/graphql/types/ci_configuration/sast/entity_input_type.rb b/app/graphql/types/ci_configuration/sast/entity_input_type.rb
index f0e3c07d71f..095097fe7b5 100644
--- a/app/graphql/types/ci_configuration/sast/entity_input_type.rb
+++ b/app/graphql/types/ci_configuration/sast/entity_input_type.rb
@@ -8,13 +8,13 @@ module Types
description 'Represents an entity in SAST CI configuration'
argument :field, GraphQL::Types::String, required: true,
- description: 'CI keyword of entity.'
+ description: 'CI keyword of entity.'
argument :default_value, GraphQL::Types::String, required: true,
- description: 'Default value that is used if value is empty.'
+ description: 'Default value that is used if value is empty.'
argument :value, GraphQL::Types::String, required: true,
- description: 'Current value of the entity.'
+ description: 'Current value of the entity.'
end
end
end
diff --git a/app/graphql/types/ci_configuration/sast/entity_type.rb b/app/graphql/types/ci_configuration/sast/entity_type.rb
index 41b8575d99a..91e80fdd9f8 100644
--- a/app/graphql/types/ci_configuration/sast/entity_type.rb
+++ b/app/graphql/types/ci_configuration/sast/entity_type.rb
@@ -9,28 +9,30 @@ module Types
description 'Represents an entity in SAST CI configuration'
field :field, GraphQL::Types::String, null: true,
- description: 'CI keyword of entity.'
+ description: 'CI keyword of entity.'
field :label, GraphQL::Types::String, null: true,
- description: 'Label for entity used in the form.'
+ description: 'Label for entity used in the form.'
field :type, GraphQL::Types::String, null: true,
- description: 'Type of the field value.'
+ description: 'Type of the field value.'
- field :options, ::Types::CiConfiguration::Sast::OptionsEntityType.connection_type, null: true,
- description: 'Different possible values of the field.'
+ field :options,
+ ::Types::CiConfiguration::Sast::OptionsEntityType.connection_type,
+ null: true,
+ description: 'Different possible values of the field.'
field :default_value, GraphQL::Types::String, null: true,
- description: 'Default value that is used if value is empty.'
+ description: 'Default value that is used if value is empty.'
field :description, GraphQL::Types::String, null: true,
- description: 'Entity description that is displayed on the form.'
+ description: 'Entity description that is displayed on the form.'
field :value, GraphQL::Types::String, null: true,
- description: 'Current value of the entity.'
+ description: 'Current value of the entity.'
field :size, ::Types::CiConfiguration::Sast::UiComponentSizeEnum, null: true,
- description: 'Size of the UI component.'
+ description: 'Size of the UI component.'
end
end
end
diff --git a/app/graphql/types/ci_configuration/sast/options_entity_type.rb b/app/graphql/types/ci_configuration/sast/options_entity_type.rb
index 5f365807cfe..2de84adf685 100644
--- a/app/graphql/types/ci_configuration/sast/options_entity_type.rb
+++ b/app/graphql/types/ci_configuration/sast/options_entity_type.rb
@@ -9,10 +9,10 @@ module Types
description 'Represents an entity for options in SAST CI configuration'
field :label, GraphQL::Types::String, null: true,
- description: 'Label of option entity.'
+ description: 'Label of option entity.'
field :value, GraphQL::Types::String, null: true,
- description: 'Value of option entity.'
+ description: 'Value of option entity.'
end
end
end
diff --git a/app/graphql/types/ci_configuration/sast/type.rb b/app/graphql/types/ci_configuration/sast/type.rb
index 35d11584ac7..edfdf296929 100644
--- a/app/graphql/types/ci_configuration/sast/type.rb
+++ b/app/graphql/types/ci_configuration/sast/type.rb
@@ -8,14 +8,20 @@ module Types
graphql_name 'SastCiConfiguration'
description 'Represents a CI configuration of SAST'
- field :global, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true,
- description: 'List of global entities related to SAST configuration.'
+ field :global,
+ ::Types::CiConfiguration::Sast::EntityType.connection_type,
+ null: true,
+ description: 'List of global entities related to SAST configuration.'
- field :pipeline, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true,
- description: 'List of pipeline entities related to SAST configuration.'
+ field :pipeline,
+ ::Types::CiConfiguration::Sast::EntityType.connection_type,
+ null: true,
+ description: 'List of pipeline entities related to SAST configuration.'
- field :analyzers, ::Types::CiConfiguration::Sast::AnalyzersEntityType.connection_type, null: true,
- description: 'List of analyzers entities attached to SAST configuration.'
+ field :analyzers,
+ ::Types::CiConfiguration::Sast::AnalyzersEntityType.connection_type,
+ null: true,
+ description: 'List of analyzers entities attached to SAST configuration.'
end
end
end
diff --git a/app/graphql/types/commit_action_type.rb b/app/graphql/types/commit_action_type.rb
index 1aa3a4e7ee1..9e808bd3174 100644
--- a/app/graphql/types/commit_action_type.rb
+++ b/app/graphql/types/commit_action_type.rb
@@ -3,18 +3,18 @@
module Types
class CommitActionType < BaseInputObject
argument :action, type: Types::CommitActionModeEnum, required: true,
- description: 'Action to perform: create, delete, move, update, or chmod.'
+ description: 'Action to perform: create, delete, move, update, or chmod.'
argument :content, type: GraphQL::Types::String, required: false,
- description: 'Content of the file.'
+ description: 'Content of the file.'
argument :encoding, type: Types::CommitEncodingEnum, required: false,
- description: 'Encoding of the file. Default is text.'
+ description: 'Encoding of the file. Default is text.'
argument :execute_filemode, type: GraphQL::Types::Boolean, required: false,
- description: 'Enables/disables the execute flag on the file.'
+ description: 'Enables/disables the execute flag on the file.'
argument :file_path, type: GraphQL::Types::String, required: true,
- description: 'Full path to the file.'
+ description: 'Full path to the file.'
argument :last_commit_id, type: GraphQL::Types::String, required: false,
- description: 'Last known file commit ID.'
+ description: 'Last known file commit ID.'
argument :previous_path, type: GraphQL::Types::String, required: false,
- description: 'Original full path to the file being moved.'
+ description: 'Original full path to the file being moved.'
end
end
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index c3a6d6f7faa..dfb02f29fb7 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -11,48 +11,48 @@ module Types
implements(Types::TodoableInterface)
field :id, type: GraphQL::Types::ID, null: false,
- description: 'ID (global ID) of the commit.'
+ description: 'ID (global ID) of the commit.'
field :sha, type: GraphQL::Types::String, null: false,
- description: 'SHA1 ID of the commit.'
+ description: 'SHA1 ID of the commit.'
field :short_id, type: GraphQL::Types::String, null: false,
- description: 'Short SHA1 ID of the commit.'
+ description: 'Short SHA1 ID of the commit.'
field :title, type: GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Title of the commit message.'
+ description: 'Title of the commit message.'
field :full_title, type: GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Full title of the commit message.'
+ description: 'Full title of the commit message.'
field :description, type: GraphQL::Types::String, null: true,
- description: 'Description of the commit message.'
+ description: 'Description of the commit message.'
field :message, type: GraphQL::Types::String, null: true,
- description: 'Raw commit message.'
+ description: 'Raw commit message.'
field :authored_date, type: Types::TimeType, null: true,
- description: 'Timestamp of when the commit was authored.'
+ description: 'Timestamp of when the commit was authored.'
field :web_url, type: GraphQL::Types::String, null: false,
- description: 'Web URL of the commit.'
+ description: 'Web URL of the commit.'
field :web_path, type: GraphQL::Types::String, null: false,
- description: 'Web path of the commit.'
+ description: 'Web path of the commit.'
field :signature_html, type: GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Rendered HTML of the commit signature.'
+ description: 'Rendered HTML of the commit signature.'
field :author_email, type: GraphQL::Types::String, null: true,
- description: "Commit author's email."
+ description: "Commit author's email."
field :author_gravatar, type: GraphQL::Types::String, null: true,
- description: 'Commit authors gravatar.'
+ description: 'Commit authors gravatar.'
field :author_name, type: GraphQL::Types::String, null: true,
- description: 'Commit authors name.'
+ description: 'Commit authors name.'
# models/commit lazy loads the author by email
field :author, type: Types::UserType, null: true,
- description: 'Author of the commit.'
+ description: 'Author of the commit.'
field :pipelines,
null: true,
diff --git a/app/graphql/types/concerns/gitlab_style_deprecations.rb b/app/graphql/types/concerns/gitlab_style_deprecations.rb
index cd8e393b235..e404f1fcad9 100644
--- a/app/graphql/types/concerns/gitlab_style_deprecations.rb
+++ b/app/graphql/types/concerns/gitlab_style_deprecations.rb
@@ -14,7 +14,10 @@ module GitlabStyleDeprecations
'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-schema-items'
end
- deprecation = ::Gitlab::Graphql::Deprecation.parse(kwargs.delete(:deprecated))
+ # GitLab allows items to be marked as "alpha", which leverages GraphQL deprecations.
+ deprecation_args = kwargs.extract!(:alpha, :deprecated)
+
+ deprecation = ::Gitlab::Graphql::Deprecation.parse(**deprecation_args)
return unless deprecation
raise ArgumentError, "Bad deprecation. #{deprecation.errors.full_messages.to_sentence}" unless deprecation.valid?
diff --git a/app/graphql/types/countable_connection_type.rb b/app/graphql/types/countable_connection_type.rb
index 0f24964daa6..4c216ceceb6 100644
--- a/app/graphql/types/countable_connection_type.rb
+++ b/app/graphql/types/countable_connection_type.rb
@@ -4,7 +4,7 @@ module Types
# rubocop: disable Graphql/AuthorizeTypes
class CountableConnectionType < GraphQL::Types::Relay::BaseConnection
field :count, GraphQL::Types::Int, null: false,
- description: 'Total count of collection.'
+ description: 'Total count of collection.'
def count
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/graphql/types/customer_relations/contact_sort_enum.rb b/app/graphql/types/customer_relations/contact_sort_enum.rb
new file mode 100644
index 00000000000..221dedacb6a
--- /dev/null
+++ b/app/graphql/types/customer_relations/contact_sort_enum.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module CustomerRelations
+ class ContactSortEnum < SortEnum
+ graphql_name 'ContactSort'
+ description 'Values for sorting contacts'
+
+ sortable_fields = ['First name', 'Last name', 'Email', 'Phone', 'Description', 'Organization']
+
+ sortable_fields.each do |field|
+ value "#{field.upcase.tr(' ', '_')}_ASC",
+ value: { field: field.downcase.tr(' ', '_'), direction: :asc },
+ description: "#{field} by ascending order."
+ value "#{field.upcase.tr(' ', '_')}_DESC",
+ value: { field: field.downcase.tr(' ', '_'), direction: :desc },
+ description: "#{field} by descending order."
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/customer_relations/contact_state_counts_type.rb b/app/graphql/types/customer_relations/contact_state_counts_type.rb
new file mode 100644
index 00000000000..96230f8a952
--- /dev/null
+++ b/app/graphql/types/customer_relations/contact_state_counts_type.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Types
+ module CustomerRelations
+ class ContactStateCountsType < Types::BaseObject
+ graphql_name 'ContactStateCounts'
+ description 'Represents the total number of contacts for the represented states.'
+
+ authorize :read_crm_contact
+
+ def self.available_contact_states
+ @available_contact_states ||= ::CustomerRelations::Contact.states.keys.push('all')
+ end
+
+ available_contact_states.each do |state|
+ field state,
+ GraphQL::Types::Int,
+ null: true,
+ description: "Number of contacts with state `#{state.upcase}`"
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/customer_relations/contact_state_enum.rb b/app/graphql/types/customer_relations/contact_state_enum.rb
index 445d2a41401..1e5cae8528f 100644
--- a/app/graphql/types/customer_relations/contact_state_enum.rb
+++ b/app/graphql/types/customer_relations/contact_state_enum.rb
@@ -5,12 +5,16 @@ module Types
class ContactStateEnum < BaseEnum
graphql_name 'CustomerRelationsContactState'
+ value 'all',
+ description: "All available contacts.",
+ value: :all
+
value 'active',
- description: "Active contact.",
+ description: "Active contacts.",
value: :active
value 'inactive',
- description: "Inactive contact.",
+ description: "Inactive contacts.",
value: :inactive
end
end
diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb
index 91978aa37b0..38864075288 100644
--- a/app/graphql/types/design_management/design_collection_type.rb
+++ b/app/graphql/types/design_management/design_collection_type.rb
@@ -9,9 +9,9 @@ module Types
authorize :read_design
field :issue, Types::IssueType, null: false,
- description: 'Issue associated with the design collection.'
+ description: 'Issue associated with the design collection.'
field :project, Types::ProjectType, null: false,
- description: 'Project associated with the design collection.'
+ description: 'Project associated with the design collection.'
field :designs,
Types::DesignManagement::DesignType.connection_type,
diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb
index c3a35cfe1ad..f9e9b270a36 100644
--- a/app/graphql/types/design_management/design_fields.rb
+++ b/app/graphql/types/design_management/design_fields.rb
@@ -13,7 +13,10 @@ module Types
field :filename, GraphQL::Types::String, null: false, description: 'Filename of the design.'
field :full_path, GraphQL::Types::String, null: false, description: 'Full path to the design file.'
field :image, GraphQL::Types::String, null: false, extras: [:parent], description: 'URL of the full-sized image.'
- field :image_v432x230, GraphQL::Types::String, null: true, extras: [:parent],
+ field :image_v432x230,
+ GraphQL::Types::String,
+ null: true,
+ extras: [:parent],
description: 'The URL of the design resized to fit within the bounds of 432x230. ' \
'This will be `null` if the image has not been generated'
field :diff_refs, Types::DiffRefsType,
diff --git a/app/graphql/types/design_management/version_type.rb b/app/graphql/types/design_management/version_type.rb
index cfd2b887dc3..2cbe50afae6 100644
--- a/app/graphql/types/design_management/version_type.rb
+++ b/app/graphql/types/design_management/version_type.rb
@@ -12,9 +12,9 @@ module Types
authorize :read_design
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the design version.'
+ description: 'ID of the design version.'
field :sha, GraphQL::Types::ID, null: false,
- description: 'SHA of the design version.'
+ description: 'SHA of the design version.'
field :designs,
::Types::DesignManagement::DesignType.connection_type,
@@ -35,7 +35,7 @@ module Types
field :author, Types::UserType, null: false, description: 'Author of the version.'
field :created_at, Types::TimeType, null: false,
- description: 'Timestamp of when the version was created.'
+ description: 'Timestamp of when the version was created.'
end
end
end
diff --git a/app/graphql/types/diff_paths_input_type.rb b/app/graphql/types/diff_paths_input_type.rb
index c5c75105fda..94e86285b86 100644
--- a/app/graphql/types/diff_paths_input_type.rb
+++ b/app/graphql/types/diff_paths_input_type.rb
@@ -3,8 +3,8 @@
module Types
class DiffPathsInputType < BaseInputObject
argument :new_path, GraphQL::Types::String, required: false,
- description: 'Path of the file on the HEAD SHA.'
+ description: 'Path of the file on the HEAD SHA.'
argument :old_path, GraphQL::Types::String, required: false,
- description: 'Path of the file on the start SHA.'
+ description: 'Path of the file on the start SHA.'
end
end
diff --git a/app/graphql/types/diff_refs_type.rb b/app/graphql/types/diff_refs_type.rb
index a03d72a4dc2..6caf2eb87e6 100644
--- a/app/graphql/types/diff_refs_type.rb
+++ b/app/graphql/types/diff_refs_type.rb
@@ -7,11 +7,11 @@ module Types
graphql_name 'DiffRefs'
field :base_sha, GraphQL::Types::String, null: true,
- description: 'Merge base of the branch the comment was made on.'
+ description: 'Merge base of the branch the comment was made on.'
field :head_sha, GraphQL::Types::String, null: false,
- description: 'SHA of the HEAD at the time the comment was made.'
+ description: 'SHA of the HEAD at the time the comment was made.'
field :start_sha, GraphQL::Types::String, null: false,
- description: 'SHA of the branch being compared against.'
+ description: 'SHA of the branch being compared against.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/diff_stats_summary_type.rb b/app/graphql/types/diff_stats_summary_type.rb
index 95705ddecf3..44b92789408 100644
--- a/app/graphql/types/diff_stats_summary_type.rb
+++ b/app/graphql/types/diff_stats_summary_type.rb
@@ -9,13 +9,13 @@ module Types
description 'Aggregated summary of changes'
field :additions, GraphQL::Types::Int, null: false,
- description: 'Number of lines added.'
+ description: 'Number of lines added.'
field :changes, GraphQL::Types::Int, null: false,
- description: 'Number of lines changed.'
+ description: 'Number of lines changed.'
field :deletions, GraphQL::Types::Int, null: false,
- description: 'Number of lines deleted.'
+ description: 'Number of lines deleted.'
field :file_count, GraphQL::Types::Int, null: false,
- description: 'Number of files changed.'
+ description: 'Number of files changed.'
def changes
object[:additions] + object[:deletions]
diff --git a/app/graphql/types/diff_stats_type.rb b/app/graphql/types/diff_stats_type.rb
index da366fec8c3..a6b7f8e9084 100644
--- a/app/graphql/types/diff_stats_type.rb
+++ b/app/graphql/types/diff_stats_type.rb
@@ -9,11 +9,11 @@ module Types
description 'Changes to a single file'
field :additions, GraphQL::Types::Int, null: false,
- description: 'Number of lines added to this file.'
+ description: 'Number of lines added to this file.'
field :deletions, GraphQL::Types::Int, null: false,
- description: 'Number of lines deleted from this file.'
+ description: 'Number of lines deleted from this file.'
field :path, GraphQL::Types::String, null: false,
- description: 'File path, relative to repository root.'
+ description: 'File path, relative to repository root.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
index aba83f559fa..2a7076cc3c9 100644
--- a/app/graphql/types/environment_type.rb
+++ b/app/graphql/types/environment_type.rb
@@ -10,20 +10,20 @@ module Types
authorize :read_environment
field :name, GraphQL::Types::String, null: false,
- description: 'Human-readable name of the environment.'
+ description: 'Human-readable name of the environment.'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the environment.'
+ description: 'ID of the environment.'
field :state, GraphQL::Types::String, null: false,
- description: 'State of the environment, for example: available/stopped.'
+ description: 'State of the environment, for example: available/stopped.'
field :path, GraphQL::Types::String, null: false,
- description: 'Path to the environment.'
+ description: 'Path to the environment.'
field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
- description: 'Metrics dashboard schema for the environment.',
- resolver: Resolvers::Metrics::DashboardResolver
+ description: 'Metrics dashboard schema for the environment.',
+ resolver: Resolvers::Metrics::DashboardResolver
field :latest_opened_most_severe_alert,
Types::AlertManagement::AlertType,
diff --git a/app/graphql/types/evidence_type.rb b/app/graphql/types/evidence_type.rb
index ed644a4b2c6..0d73a935e50 100644
--- a/app/graphql/types/evidence_type.rb
+++ b/app/graphql/types/evidence_type.rb
@@ -10,12 +10,12 @@ module Types
present_using Releases::EvidencePresenter
field :collected_at, Types::TimeType, null: true,
- description: 'Timestamp when the evidence was collected.'
+ description: 'Timestamp when the evidence was collected.'
field :filepath, GraphQL::Types::String, null: true,
- description: 'URL from where the evidence can be downloaded.'
+ description: 'URL from where the evidence can be downloaded.'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the evidence.'
+ description: 'ID of the evidence.'
field :sha, GraphQL::Types::String, null: true,
- description: 'SHA1 ID of the evidence hash.'
+ description: 'SHA1 ID of the evidence hash.'
end
end
diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb
index 145a5a22460..a71c2fb0e6c 100644
--- a/app/graphql/types/global_id_type.rb
+++ b/app/graphql/types/global_id_type.rb
@@ -50,7 +50,7 @@ module Types
#{
if deprecation = Gitlab::GlobalId::Deprecations.deprecation_by(model_name)
'The older format `"' +
- ::Gitlab::GlobalId.build(model_name: deprecation.old_model_name, id: 1).to_s +
+ ::Gitlab::GlobalId.build(model_name: deprecation.old_name, id: 1).to_s +
'"` was deprecated in ' + deprecation.milestone + '.'
end}
diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb
index 2bbc0d34db6..982ba803603 100644
--- a/app/graphql/types/grafana_integration_type.rb
+++ b/app/graphql/types/grafana_integration_type.rb
@@ -7,14 +7,14 @@ module Types
authorize :admin_operations
field :created_at, Types::TimeType, null: false,
- description: 'Timestamp of the issue\'s creation.'
+ description: 'Timestamp of the issue\'s creation.'
field :enabled, GraphQL::Types::Boolean, null: false,
- description: 'Indicates whether Grafana integration is enabled.'
+ description: 'Indicates whether Grafana integration is enabled.'
field :grafana_url, GraphQL::Types::String, null: false,
- description: 'URL for the Grafana host for the Grafana integration.'
+ description: 'URL for the Grafana host for the Grafana integration.'
field :id, GraphQL::Types::ID, null: false,
- description: 'Internal ID of the Grafana integration.'
+ description: 'Internal ID of the Grafana integration.'
field :updated_at, Types::TimeType, null: false,
- description: 'Timestamp of the issue\'s last activity.'
+ description: 'Timestamp of the issue\'s last activity.'
end
end
diff --git a/app/graphql/types/group_invitation_type.rb b/app/graphql/types/group_invitation_type.rb
index 48281dcfd9f..2b874e23c64 100644
--- a/app/graphql/types/group_invitation_type.rb
+++ b/app/graphql/types/group_invitation_type.rb
@@ -11,7 +11,7 @@ module Types
implements InvitationInterface
field :group, Types::GroupType, null: true,
- description: 'Group that a User is invited to.'
+ description: 'Group that a User is invited to.'
def group
Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find
diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb
index c4582f31bec..2745853c9bb 100644
--- a/app/graphql/types/group_member_type.rb
+++ b/app/graphql/types/group_member_type.rb
@@ -11,7 +11,7 @@ module Types
implements MemberInterface
field :group, Types::GroupType, null: true,
- description: 'Group that a User is a member of.'
+ description: 'Group that a User is a member of.'
field :notification_email,
resolver: Resolvers::GroupMembers::NotificationEmailResolver,
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 52e9f808066..235a2bc2a34 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -22,7 +22,7 @@ module Types
type: Types::CustomEmojiType.connection_type,
null: true,
description: 'Custom emoji within this namespace.',
- feature_flag: :custom_emoji
+ _deprecated_feature_flag: :custom_emoji
field :share_with_group_lock,
type: GraphQL::Types::Boolean,
@@ -85,6 +85,7 @@ module Types
field :milestones,
description: 'Milestones of the group.',
+ extras: [:lookahead],
resolver: Resolvers::GroupMilestonesResolver
field :boards,
@@ -183,10 +184,10 @@ module Types
resolver: Resolvers::GroupLabelsResolver
field :timelogs, ::Types::TimelogType.connection_type, null: false,
- description: 'Time logged on issues and merge requests in the group and its subgroups.',
- extras: [:lookahead],
- complexity: 5,
- resolver: ::Resolvers::TimelogResolver
+ description: 'Time logged on issues and merge requests in the group and its subgroups.',
+ extras: [:lookahead],
+ complexity: 5,
+ resolver: ::Resolvers::TimelogResolver
field :descendant_groups, Types::GroupType.connection_type,
null: true,
@@ -195,7 +196,7 @@ module Types
resolver: Resolvers::GroupsResolver
field :ci_variables,
- Types::Ci::VariableType.connection_type,
+ Types::Ci::GroupVariableType.connection_type,
null: true,
description: "List of the group's CI/CD variables.",
authorize: :admin_group,
@@ -216,6 +217,12 @@ module Types
description: "Find contacts of this group.",
resolver: Resolvers::Crm::ContactsResolver
+ field :contact_state_counts,
+ Types::CustomerRelations::ContactStateCountsType,
+ null: true,
+ description: 'Counts of contacts by state for the group.',
+ resolver: Resolvers::Crm::ContactStateCountsResolver
+
field :work_item_types, Types::WorkItems::TypeType.connection_type,
resolver: Resolvers::WorkItems::TypesResolver,
description: 'Work item types available to the group.' \
diff --git a/app/graphql/types/invitation_interface.rb b/app/graphql/types/invitation_interface.rb
index 1f0746d7726..bbecf5b5f54 100644
--- a/app/graphql/types/invitation_interface.rb
+++ b/app/graphql/types/invitation_interface.rb
@@ -5,25 +5,25 @@ module Types
include BaseInterface
field :email, GraphQL::Types::String, null: false,
- description: 'Email of the member to invite.'
+ description: 'Email of the member to invite.'
field :access_level, Types::AccessLevelType, null: true,
- description: 'GitLab::Access level.'
+ description: 'GitLab::Access level.'
field :created_by, Types::UserType, null: true,
- description: 'User that authorized membership.'
+ description: 'User that authorized membership.'
field :created_at, Types::TimeType, null: true,
- description: 'Date and time the membership was created.'
+ description: 'Date and time the membership was created.'
field :updated_at, Types::TimeType, null: true,
- description: 'Date and time the membership was last updated.'
+ description: 'Date and time the membership was last updated.'
field :expires_at, Types::TimeType, null: true,
- description: 'Date and time the membership expires.'
+ description: 'Date and time the membership expires.'
field :user, Types::UserType, null: true,
- description: 'User that is associated with the member object.'
+ description: 'User that is associated with the member object.'
definition_methods do
def resolve_type(object, context)
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 58729b34fc7..d897f3cde48 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -17,101 +17,101 @@ module Types
present_using IssuePresenter
field :description, GraphQL::Types::String, null: true,
- description: 'Description of the issue.'
+ description: 'Description of the issue.'
field :id, GraphQL::Types::ID, null: false,
- description: "ID of the issue."
+ description: "ID of the issue."
field :iid, GraphQL::Types::ID, null: false,
- description: "Internal ID of the issue."
+ description: "Internal ID of the issue."
field :state, IssueStateEnum, null: false,
- description: 'State of the issue.'
+ description: 'State of the issue.'
field :title, GraphQL::Types::String, null: false,
- description: 'Title of the issue.'
+ description: 'Title of the issue.'
field :reference, GraphQL::Types::String, null: false,
- description: 'Internal reference of the issue. Returned in shortened format by default.',
- method: :to_reference do
+ description: 'Internal reference of the issue. Returned in shortened format by default.',
+ method: :to_reference do
argument :full, GraphQL::Types::Boolean, required: false, default_value: false,
- description: 'Boolean option specifying whether the reference should be returned in full.'
+ description: 'Boolean option specifying whether the reference should be returned in full.'
end
field :author, Types::UserType, null: false,
- description: 'User that created the issue.'
+ description: 'User that created the issue.'
field :assignees, Types::UserType.connection_type, null: true,
- description: 'Assignees of the issue.'
+ description: 'Assignees of the issue.'
field :updated_by, Types::UserType, null: true,
- description: 'User that last updated the issue.'
+ description: 'User that last updated the issue.'
field :labels, Types::LabelType.connection_type, null: true,
- description: 'Labels of the issue.'
+ description: 'Labels of the issue.'
field :milestone, Types::MilestoneType, null: true,
- description: 'Milestone of the issue.'
+ description: 'Milestone of the issue.'
field :confidential, GraphQL::Types::Boolean, null: false,
- description: 'Indicates the issue is confidential.'
+ description: 'Indicates the issue is confidential.'
field :discussion_locked, GraphQL::Types::Boolean, null: false,
- description: 'Indicates discussion is locked on the issue.'
+ description: 'Indicates discussion is locked on the issue.'
field :due_date, Types::TimeType, null: true,
- description: 'Due date of the issue.'
+ description: 'Due date of the issue.'
field :hidden, GraphQL::Types::Boolean, null: true, resolver_method: :hidden?,
- description: 'Indicates the issue is hidden because the author has been banned. ' \
+ description: 'Indicates the issue is hidden because the author has been banned. ' \
'Will always return `null` if `ban_user_feature_flag` feature flag is disabled.'
field :downvotes, GraphQL::Types::Int, null: false,
- description: 'Number of downvotes the issue has received.'
+ description: 'Number of downvotes the issue has received.'
field :merge_requests_count, GraphQL::Types::Int, null: false,
- description: 'Number of merge requests that close the issue on merge.',
- resolver: Resolvers::MergeRequestsCountResolver
+ description: 'Number of merge requests that close the issue on merge.',
+ resolver: Resolvers::MergeRequestsCountResolver
field :relative_position, GraphQL::Types::Int, null: true,
- description: 'Relative position of the issue (used for positioning in epic tree and issue boards).'
+ description: 'Relative position of the issue (used for positioning in epic tree and issue boards).'
field :upvotes, GraphQL::Types::Int, null: false,
- description: 'Number of upvotes the issue has received.'
+ description: 'Number of upvotes the issue has received.'
field :user_discussions_count, GraphQL::Types::Int, null: false,
- description: 'Number of user discussions in the issue.',
- resolver: Resolvers::UserDiscussionsCountResolver
+ description: 'Number of user discussions in the issue.',
+ resolver: Resolvers::UserDiscussionsCountResolver
field :user_notes_count, GraphQL::Types::Int, null: false,
- description: 'Number of user notes of the issue.',
- resolver: Resolvers::UserNotesCountResolver
+ description: 'Number of user notes of the issue.',
+ resolver: Resolvers::UserNotesCountResolver
field :web_path, GraphQL::Types::String, null: false, method: :issue_path,
- description: 'Web path of the issue.'
+ description: 'Web path of the issue.'
field :web_url, GraphQL::Types::String, null: false,
- description: 'Web URL of the issue.'
+ description: 'Web URL of the issue.'
field :emails_disabled, GraphQL::Types::Boolean, null: false,
- method: :project_emails_disabled?,
- description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.'
+ method: :project_emails_disabled?,
+ description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.'
field :human_time_estimate, GraphQL::Types::String, null: true,
- description: 'Human-readable time estimate of the issue.'
+ description: 'Human-readable time estimate of the issue.'
field :human_total_time_spent, GraphQL::Types::String, null: true,
- description: 'Human-readable total time reported as spent on the issue.'
+ description: 'Human-readable total time reported as spent on the issue.'
field :participants, Types::UserType.connection_type, null: true, complexity: 5,
- description: 'List of participants in the issue.',
- resolver: Resolvers::Users::ParticipantsResolver
+ description: 'List of participants in the issue.',
+ resolver: Resolvers::Users::ParticipantsResolver
field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5,
- description: 'Indicates the currently logged in user is subscribed to the issue.'
+ description: 'Indicates the currently logged in user is subscribed to the issue.'
field :time_estimate, GraphQL::Types::Int, null: false,
- description: 'Time estimate of the issue.'
+ description: 'Time estimate of the issue.'
field :total_time_spent, GraphQL::Types::Int, null: false,
- description: 'Total time reported as spent on the issue.'
+ description: 'Total time reported as spent on the issue.'
field :closed_at, Types::TimeType, null: true,
- description: 'Timestamp of when the issue was closed.'
+ description: 'Timestamp of when the issue was closed.'
field :created_at, Types::TimeType, null: false,
- description: 'Timestamp of when the issue was created.'
+ description: 'Timestamp of when the issue was created.'
field :updated_at, Types::TimeType, null: false,
- description: 'Timestamp of when the issue was last updated.'
+ description: 'Timestamp of when the issue was last updated.'
field :task_completion_status, Types::TaskCompletionStatus, null: false,
- description: 'Task completion status of the issue.'
+ description: 'Task completion status of the issue.'
field :design_collection, Types::DesignManagement::DesignCollectionType, null: true,
- description: 'Collection of design images associated with this issue.'
+ description: 'Collection of design images associated with this issue.'
field :type, Types::IssueTypeEnum, null: true,
- method: :issue_type,
- description: 'Type of the issue.'
+ method: :issue_type,
+ description: 'Type of the issue.'
field :alert_management_alert,
Types::AlertManagement::AlertType,
@@ -119,31 +119,31 @@ module Types
description: 'Alert associated to this issue.'
field :severity, Types::IssuableSeverityEnum, null: true,
- description: 'Severity level of the incident.'
+ description: 'Severity level of the incident.'
field :moved, GraphQL::Types::Boolean, method: :moved?, null: true,
- description: 'Indicates if issue got moved from other project.'
+ description: 'Indicates if issue got moved from other project.'
field :moved_to, Types::IssueType, null: true,
- description: 'Updated Issue after it got moved to another project.'
+ description: 'Updated Issue after it got moved to another project.'
field :closed_as_duplicate_of, Types::IssueType, null: true,
- description: 'Issue this issue was closed as a duplicate of.'
+ description: 'Issue this issue was closed as a duplicate of.'
field :create_note_email, GraphQL::Types::String, null: true,
- description: 'User specific email address for the issue.'
+ description: 'User specific email address for the issue.'
field :timelogs, Types::TimelogType.connection_type, null: false,
- description: 'Timelogs on the issue.'
+ description: 'Timelogs on the issue.'
field :project_id, GraphQL::Types::Int, null: false, method: :project_id,
- description: 'ID of the issue project.'
+ description: 'ID of the issue project.'
field :customer_relations_contacts, Types::CustomerRelations::ContactType.connection_type, null: true,
- description: 'Customer relations contacts of the issue.'
+ description: 'Customer relations contacts of the issue.'
field :escalation_status, Types::IncidentManagement::EscalationStatusEnum, null: true,
- description: 'Escalation status of the issue.'
+ description: 'Escalation status of the issue.'
markdown_field :title_html, null: true
markdown_field :description_html, null: true
diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb
index bc21b802179..1044c2ceea4 100644
--- a/app/graphql/types/issue_type_enum.rb
+++ b/app/graphql/types/issue_type_enum.rb
@@ -11,6 +11,6 @@ module Types
value 'TASK', value: 'task',
description: 'Task issue type. Available only when feature flag `work_items` is enabled.',
- deprecated: { milestone: '15.2', reason: :alpha }
+ alpha: { milestone: '15.2' }
end
end
diff --git a/app/graphql/types/jira_import_type.rb b/app/graphql/types/jira_import_type.rb
index 8477f0b97f0..bcbecff1ad8 100644
--- a/app/graphql/types/jira_import_type.rb
+++ b/app/graphql/types/jira_import_type.rb
@@ -7,19 +7,19 @@ module Types
graphql_name 'JiraImport'
field :created_at, Types::TimeType, null: true,
- description: 'Timestamp of when the Jira import was created.'
+ description: 'Timestamp of when the Jira import was created.'
field :failed_to_import_count, GraphQL::Types::Int, null: false,
- description: 'Count of issues that failed to import.'
+ description: 'Count of issues that failed to import.'
field :imported_issues_count, GraphQL::Types::Int, null: false,
- description: 'Count of issues that were successfully imported.'
+ description: 'Count of issues that were successfully imported.'
field :jira_project_key, GraphQL::Types::String, null: false,
- description: 'Project key for the imported Jira project.'
+ description: 'Project key for the imported Jira project.'
field :scheduled_at, Types::TimeType, null: true,
- description: 'Timestamp of when the Jira import was scheduled.'
+ description: 'Timestamp of when the Jira import was scheduled.'
field :scheduled_by, Types::UserType, null: true,
- description: 'User that started the Jira import.'
+ description: 'User that started the Jira import.'
field :total_issue_count, GraphQL::Types::Int, null: false,
- description: 'Total count of issues that were attempted to import.'
+ description: 'Total count of issues that were attempted to import.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/jira_user_type.rb b/app/graphql/types/jira_user_type.rb
index aba05385ece..aa070d2c4c9 100644
--- a/app/graphql/types/jira_user_type.rb
+++ b/app/graphql/types/jira_user_type.rb
@@ -7,16 +7,18 @@ module Types
graphql_name 'JiraUser'
field :gitlab_id, GraphQL::Types::Int, null: true,
- description: 'ID of the matched GitLab user.'
+ description: 'ID of the matched GitLab user.'
field :gitlab_name, GraphQL::Types::String, null: true,
- description: 'Name of the matched GitLab user.'
+ description: 'Name of the matched GitLab user.'
field :gitlab_username, GraphQL::Types::String, null: true,
- description: 'Username of the matched GitLab user.'
+ description: 'Username of the matched GitLab user.'
field :jira_account_id, GraphQL::Types::String, null: false,
- description: 'Account ID of the Jira user.'
+ description: 'Account ID of the Jira user.'
field :jira_display_name, GraphQL::Types::String, null: false,
- description: 'Display name of the Jira user.'
- field :jira_email, GraphQL::Types::String, null: true,
+ description: 'Display name of the Jira user.'
+ field :jira_email,
+ GraphQL::Types::String,
+ null: true,
description: 'Email of the Jira user, returned only for users with public emails.'
end
# rubocop: enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb
index b5b3e20bcbc..05b703e60af 100644
--- a/app/graphql/types/label_type.rb
+++ b/app/graphql/types/label_type.rb
@@ -9,19 +9,21 @@ module Types
authorize :read_label
field :color, GraphQL::Types::String, null: false,
- description: 'Background color of the label.'
+ description: 'Background color of the label.'
field :created_at, Types::TimeType, null: false,
- description: 'When this label was created.'
- field :description, GraphQL::Types::String, null: true,
+ description: 'When this label was created.'
+ field :description,
+ GraphQL::Types::String,
+ null: true,
description: 'Description of the label (Markdown rendered as HTML for caching).'
field :id, GraphQL::Types::ID, null: false,
- description: 'Label ID.'
+ description: 'Label ID.'
field :text_color, GraphQL::Types::String, null: false,
- description: 'Text color of the label.'
+ description: 'Text color of the label.'
field :title, GraphQL::Types::String, null: false,
- description: 'Content of the label.'
+ description: 'Content of the label.'
field :updated_at, Types::TimeType, null: false,
- description: 'When this label was last updated.'
+ description: 'When this label was last updated.'
markdown_field :description_html, null: true
end
diff --git a/app/graphql/types/member_interface.rb b/app/graphql/types/member_interface.rb
index 67d0e18b522..edadbcddfb3 100644
--- a/app/graphql/types/member_interface.rb
+++ b/app/graphql/types/member_interface.rb
@@ -5,25 +5,25 @@ module Types
include BaseInterface
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the member.'
+ description: 'ID of the member.'
field :access_level, Types::AccessLevelType, null: true,
- description: 'GitLab::Access level.'
+ description: 'GitLab::Access level.'
field :created_by, Types::UserType, null: true,
- description: 'User that authorized membership.'
+ description: 'User that authorized membership.'
field :created_at, Types::TimeType, null: true,
- description: 'Date and time the membership was created.'
+ description: 'Date and time the membership was created.'
field :updated_at, Types::TimeType, null: true,
- description: 'Date and time the membership was last updated.'
+ description: 'Date and time the membership was last updated.'
field :expires_at, Types::TimeType, null: true,
- description: 'Date and time the membership expires.'
+ description: 'Date and time the membership expires.'
field :user, Types::UserType, null: true,
- description: 'User that is associated with the member object.'
+ description: 'User that is associated with the member object.'
field :merge_request_interaction, Types::UserMergeRequestInteractionType,
null: true,
diff --git a/app/graphql/types/merge_request_connection_type.rb b/app/graphql/types/merge_request_connection_type.rb
index 9596c812c69..f77b66954c1 100644
--- a/app/graphql/types/merge_request_connection_type.rb
+++ b/app/graphql/types/merge_request_connection_type.rb
@@ -3,7 +3,9 @@
module Types
# rubocop: disable Graphql/AuthorizeTypes
class MergeRequestConnectionType < Types::CountableConnectionType
- field :total_time_to_merge, GraphQL::Types::Float, null: true,
+ field :total_time_to_merge,
+ GraphQL::Types::Float,
+ null: true,
description: 'Total sum of time to merge, in seconds, for the collection of merge requests.'
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index cc3df474bef..d88653f2f8c 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -17,93 +17,98 @@ module Types
present_using MergeRequestPresenter
field :created_at, Types::TimeType, null: false,
- description: 'Timestamp of when the merge request was created.'
+ description: 'Timestamp of when the merge request was created.'
field :description, GraphQL::Types::String, null: true,
- description: 'Description of the merge request (Markdown rendered as HTML for caching).'
+ description: 'Description of the merge request (Markdown rendered as HTML for caching).'
field :diff_head_sha, GraphQL::Types::String, null: true,
- description: 'Diff head SHA of the merge request.'
+ description: 'Diff head SHA of the merge request.'
field :diff_refs, Types::DiffRefsType, null: true,
- description: 'References of the base SHA, the head SHA, and the start SHA for this merge request.'
+ description: 'References of the base SHA, the head SHA, and the start SHA for this merge request.'
field :diff_stats, [Types::DiffStatsType], null: true, calls_gitaly: true,
- description: 'Details about which files were changed in this merge request.' do
+ description: 'Details about which files were changed in this merge request.' do
argument :path, GraphQL::Types::String, required: false, description: 'Specific file path.'
end
field :draft, GraphQL::Types::Boolean, method: :draft?, null: false,
- description: 'Indicates if the merge request is a draft.'
+ description: 'Indicates if the merge request is a draft.'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the merge request.'
+ description: 'ID of the merge request.'
field :iid, GraphQL::Types::String, null: false,
- description: 'Internal ID of the merge request.'
+ description: 'Internal ID of the merge request.'
field :merge_when_pipeline_succeeds, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).'
+ description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).'
field :merged_at, Types::TimeType, null: true, complexity: 5,
- description: 'Timestamp of when the merge request was merged, null if not merged.'
+ description: 'Timestamp of when the merge request was merged, null if not merged.'
field :project, Types::ProjectType, null: false,
- description: 'Alias for target_project.'
+ description: 'Alias for target_project.'
field :project_id, GraphQL::Types::Int, null: false, method: :target_project_id,
- description: 'ID of the merge request project.'
+ description: 'ID of the merge request project.'
field :source_branch, GraphQL::Types::String, null: false,
- description: 'Source branch of the merge request.'
+ description: 'Source branch of the merge request.'
field :source_branch_protected, GraphQL::Types::Boolean, null: false, calls_gitaly: true,
- description: 'Indicates if the source branch is protected.'
+ description: 'Indicates if the source branch is protected.'
field :source_project, Types::ProjectType, null: true,
- description: 'Source project of the merge request.'
+ description: 'Source project of the merge request.'
field :source_project_id, GraphQL::Types::Int, null: true,
- description: 'ID of the merge request source project.'
+ description: 'ID of the merge request source project.'
field :state, MergeRequestStateEnum, null: false,
- description: 'State of the merge request.'
+ description: 'State of the merge request.'
field :target_branch, GraphQL::Types::String, null: false,
- description: 'Target branch of the merge request.'
+ description: 'Target branch of the merge request.'
field :target_project, Types::ProjectType, null: false,
- description: 'Target project of the merge request.'
+ description: 'Target project of the merge request.'
field :target_project_id, GraphQL::Types::Int, null: false,
- description: 'ID of the merge request target project.'
+ description: 'ID of the merge request target project.'
field :title, GraphQL::Types::String, null: false,
- description: 'Title of the merge request.'
+ description: 'Title of the merge request.'
field :updated_at, Types::TimeType, null: false,
- description: 'Timestamp of when the merge request was last updated.'
+ description: 'Timestamp of when the merge request was last updated.'
field :allow_collaboration, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if members of the target project can push to the fork.'
+ description: 'Indicates if members of the target project can push to the fork.'
field :default_merge_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Default merge commit message of the merge request.'
+ description: 'Default merge commit message of the merge request.'
field :default_squash_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Default squash commit message of the merge request.'
+ description: 'Default squash commit message of the merge request.'
field :diff_stats_summary, Types::DiffStatsSummaryType, null: true, calls_gitaly: true,
- description: 'Summary of which files were changed in this merge request.'
+ description: 'Summary of which files were changed in this merge request.'
field :diverged_from_target_branch, GraphQL::Types::Boolean,
null: false, calls_gitaly: true,
method: :diverged_from_target_branch?,
description: 'Indicates if the source branch is behind the target branch.'
field :downvotes, GraphQL::Types::Int, null: false,
- description: 'Number of downvotes for the merge request.'
+ description: 'Number of downvotes for the merge request.'
field :force_remove_source_branch, GraphQL::Types::Boolean, method: :force_remove_source_branch?, null: true,
- description: 'Indicates if the project settings will lead to source branch deletion after merge.'
+ description: 'Indicates if the project settings will lead to source branch deletion after merge.'
field :in_progress_merge_commit_sha, GraphQL::Types::String, null: true,
- description: 'Commit SHA of the merge request if merge is in progress.'
+ description: 'Commit SHA of the merge request if merge is in progress.'
field :merge_commit_sha, GraphQL::Types::String, null: true,
- description: 'SHA of the merge request commit (set once merged).'
+ description: 'SHA of the merge request commit (set once merged).'
field :merge_error, GraphQL::Types::String, null: true,
- description: 'Error message due to a merge error.'
+ description: 'Error message due to a merge error.'
field :merge_ongoing, GraphQL::Types::Boolean, method: :merge_ongoing?, null: false,
- description: 'Indicates if a merge is currently occurring.'
+ description: 'Indicates if a merge is currently occurring.'
field :merge_status, GraphQL::Types::String, method: :public_merge_status, null: true,
- description: 'Status of the merge request.',
- deprecated: { reason: :renamed, replacement: 'MergeRequest.mergeStatusEnum', milestone: '14.0' }
+ description: 'Status of the merge request.',
+ deprecated: { reason: :renamed, replacement: 'MergeRequest.mergeStatusEnum', milestone: '14.0' }
field :merge_status_enum, ::Types::MergeRequests::MergeStatusEnum,
method: :public_merge_status, null: true,
description: 'Merge status of the merge request.'
+
+ field :detailed_merge_status, ::Types::MergeRequests::DetailedMergeStatusEnum, method: :detailed_merge_status, null: true,
+ calls_gitaly: true,
+ description: 'Detailed merge status of the merge request.', alpha: { milestone: '15.3' }
+
field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true,
- calls_gitaly: true,
- description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.'
+ calls_gitaly: true,
+ description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.'
field :rebase_commit_sha, GraphQL::Types::String, null: true,
- description: 'Rebase commit SHA of the merge request.'
+ description: 'Rebase commit SHA of the merge request.'
field :rebase_in_progress, GraphQL::Types::Boolean, method: :rebase_in_progress?, null: false, calls_gitaly: true,
- description: 'Indicates if there is a rebase currently in progress for the merge request.'
+ description: 'Indicates if there is a rebase currently in progress for the merge request.'
field :should_be_rebased, GraphQL::Types::Boolean, method: :should_be_rebased?, null: false, calls_gitaly: true,
- description: 'Indicates if the merge request will be rebased.'
+ description: 'Indicates if the merge request will be rebased.'
field :should_remove_source_branch, GraphQL::Types::Boolean, method: :should_remove_source_branch?, null: true,
- description: 'Indicates if the source branch of the merge request will be deleted after merge.'
+ description: 'Indicates if the source branch of the merge request will be deleted after merge.'
field :source_branch_exists, GraphQL::Types::Boolean,
null: false, calls_gitaly: true,
method: :source_branch_exists?,
@@ -113,18 +118,18 @@ module Types
method: :target_branch_exists?,
description: 'Indicates if the target branch of the merge request exists.'
field :upvotes, GraphQL::Types::Int, null: false,
- description: 'Number of upvotes for the merge request.'
+ description: 'Number of upvotes for the merge request.'
field :user_discussions_count, GraphQL::Types::Int, null: true,
- description: 'Number of user discussions in the merge request.',
- resolver: Resolvers::UserDiscussionsCountResolver
+ description: 'Number of user discussions in the merge request.',
+ resolver: Resolvers::UserDiscussionsCountResolver
field :user_notes_count, GraphQL::Types::Int, null: true,
- description: 'User notes count of the merge request.',
- resolver: Resolvers::UserNotesCountResolver
+ description: 'User notes count of the merge request.',
+ resolver: Resolvers::UserNotesCountResolver
field :web_url, GraphQL::Types::String, null: true,
- description: 'Web URL of the merge request.'
+ description: 'Web URL of the merge request.'
field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline,
- description: 'Pipeline running on the branch HEAD of the merge request.'
+ description: 'Pipeline running on the branch HEAD of the merge request.'
field :pipelines,
null: true,
description: 'Pipelines for the merge request. Note: for performance reasons, no more than the most recent 500 pipelines will be returned.',
@@ -136,72 +141,72 @@ module Types
complexity: 5,
description: 'Assignees of the merge request.'
field :author, Types::MergeRequests::AuthorType, null: true,
- description: 'User who created this merge request.'
+ description: 'User who created this merge request.'
field :discussion_locked, GraphQL::Types::Boolean,
description: 'Indicates if comments on the merge request are locked to members only.',
null: false
field :human_time_estimate, GraphQL::Types::String, null: true,
- description: 'Human-readable time estimate of the merge request.'
+ description: 'Human-readable time estimate of the merge request.'
field :human_total_time_spent, GraphQL::Types::String, null: true,
- description: 'Human-readable total time reported as spent on the merge request.'
+ description: 'Human-readable total time reported as spent on the merge request.'
field :labels, Types::LabelType.connection_type, null: true, complexity: 5,
- description: 'Labels of the merge request.'
+ description: 'Labels of the merge request.'
field :milestone, Types::MilestoneType, null: true,
- description: 'Milestone of the merge request.'
+ description: 'Milestone of the merge request.'
field :participants, Types::MergeRequests::ParticipantType.connection_type, null: true, complexity: 15,
- description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.',
- resolver: Resolvers::Users::ParticipantsResolver
+ description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.',
+ resolver: Resolvers::Users::ParticipantsResolver
field :reference, GraphQL::Types::String, null: false, method: :to_reference,
- description: 'Internal reference of the merge request. Returned in shortened format by default.' do
+ description: 'Internal reference of the merge request. Returned in shortened format by default.' do
argument :full, GraphQL::Types::Boolean, required: false, default_value: false,
- description: 'Boolean option specifying whether the reference should be returned in full.'
+ description: 'Boolean option specifying whether the reference should be returned in full.'
end
field :auto_merge_enabled, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if auto merge is enabled for the merge request.'
+ description: 'Indicates if auto merge is enabled for the merge request.'
field :commit_count, GraphQL::Types::Int, null: true, method: :commits_count,
- description: 'Number of commits in the merge request.'
+ description: 'Number of commits in the merge request.'
field :conflicts, GraphQL::Types::Boolean, null: false, method: :cannot_be_merged?,
- description: 'Indicates if the merge request has conflicts.'
+ description: 'Indicates if the merge request has conflicts.'
field :reviewers,
type: Types::MergeRequests::ReviewerType.connection_type,
null: true,
complexity: 5,
description: 'Users from whom a review has been requested.'
field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5,
- description: 'Indicates if the currently logged in user is subscribed to this merge request.'
+ description: 'Indicates if the currently logged in user is subscribed to this merge request.'
field :task_completion_status, Types::TaskCompletionStatus, null: false,
- description: Types::TaskCompletionStatus.description
+ description: Types::TaskCompletionStatus.description
field :time_estimate, GraphQL::Types::Int, null: false,
- description: 'Time estimate of the merge request.'
+ description: 'Time estimate of the merge request.'
field :total_time_spent, GraphQL::Types::Int, null: false,
- description: 'Total time reported as spent on the merge request.'
+ description: 'Total time reported as spent on the merge request.'
field :approved_by, Types::UserType.connection_type, null: true,
- description: 'Users who approved the merge request.', method: :approved_by_users
+ description: 'Users who approved the merge request.', method: :approved_by_users
field :auto_merge_strategy, GraphQL::Types::String, null: true,
- description: 'Selected auto merge strategy.'
+ description: 'Selected auto merge strategy.'
field :available_auto_merge_strategies, [GraphQL::Types::String], null: true, calls_gitaly: true,
- description: 'Array of available auto merge strategies.'
+ description: 'Array of available auto merge strategies.'
field :commits, Types::CommitType.connection_type, null: true,
- calls_gitaly: true, description: 'Merge request commits.'
+ calls_gitaly: true, description: 'Merge request commits.'
field :committers, Types::UserType.connection_type, null: true, complexity: 5,
- calls_gitaly: true, description: 'Users who have added commits to the merge request.'
+ calls_gitaly: true, description: 'Users who have added commits to the merge request.'
field :commits_without_merge_commits, Types::CommitType.connection_type, null: true,
- calls_gitaly: true, description: 'Merge request commits excluding merge commits.'
+ calls_gitaly: true, description: 'Merge request commits excluding merge commits.'
field :has_ci, GraphQL::Types::Boolean, null: false, method: :has_ci?,
- description: 'Indicates if the merge request has CI.'
+ description: 'Indicates if the merge request has CI.'
field :merge_user, Types::UserType, null: true,
- description: 'User who merged this merge request or set it to merge when pipeline succeeds.'
+ description: 'User who merged this merge request or set it to merge when pipeline succeeds.'
field :mergeable, GraphQL::Types::Boolean, null: false, method: :mergeable?, calls_gitaly: true,
- description: 'Indicates if the merge request is mergeable.'
+ description: 'Indicates if the merge request is mergeable.'
field :security_auto_fix, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the merge request is created by @GitLab-Security-Bot.'
+ description: 'Indicates if the merge request is created by @GitLab-Security-Bot.'
field :squash, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if squash on merge is enabled.'
+ description: 'Indicates if squash on merge is enabled.'
field :squash_on_merge, GraphQL::Types::Boolean, null: false, method: :squash_on_merge?,
- description: 'Indicates if squash on merge is enabled.'
+ description: 'Indicates if squash on merge is enabled.'
field :timelogs, Types::TimelogType.connection_type, null: false,
- description: 'Timelogs on the merge request.'
+ description: 'Timelogs on the merge request.'
markdown_field :title_html, null: true
markdown_field :description_html, null: true
diff --git a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb
new file mode 100644
index 00000000000..58104159303
--- /dev/null
+++ b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Types
+ module MergeRequests
+ class DetailedMergeStatusEnum < BaseEnum
+ graphql_name 'DetailedMergeStatus'
+ description 'Detailed representation of whether a GitLab merge request can be merged.'
+
+ value 'UNCHECKED',
+ value: :unchecked,
+ description: 'Merge status has not been checked.'
+ value 'CHECKING',
+ value: :checking,
+ description: 'Currently checking for mergeability.'
+ value 'MERGEABLE',
+ value: :mergeable,
+ description: 'Branch can be merged.'
+ value 'BROKEN_STATUS',
+ value: :broken_status,
+ description: 'Can not merge the source into the target branch, potential conflict.'
+ value 'CI_MUST_PASS',
+ value: :ci_must_pass,
+ description: 'Pipeline must succeed before merging.'
+ value 'DISCUSSIONS_NOT_RESOLVED',
+ value: :discussions_not_resolved,
+ description: 'Discussions must be resolved before merging.'
+ value 'DRAFT_STATUS',
+ value: :draft_status,
+ description: 'Merge request must not be draft before merging.'
+ value 'NOT_OPEN',
+ value: :not_open,
+ description: 'Merge request must be open before merging.'
+ value 'NOT_APPROVED',
+ value: :not_approved,
+ description: 'Merge request must be approved before merging.'
+ value 'BLOCKED_STATUS',
+ value: :merge_request_blocked,
+ description: 'Merge request is blocked by another merge request.'
+ value 'POLICIES_DENIED',
+ value: :policies_denied,
+ description: 'There are denied policies for the merge request.'
+ end
+ end
+end
diff --git a/app/graphql/types/metadata/kas_type.rb b/app/graphql/types/metadata/kas_type.rb
index 6a8d54b6c7d..e29635aedcf 100644
--- a/app/graphql/types/metadata/kas_type.rb
+++ b/app/graphql/types/metadata/kas_type.rb
@@ -8,11 +8,11 @@ module Types
authorize :read_instance_metadata
field :enabled, GraphQL::Types::Boolean, null: false,
- description: 'Indicates whether the Kubernetes Agent Server is enabled.'
+ description: 'Indicates whether the Kubernetes Agent Server is enabled.'
field :external_url, GraphQL::Types::String, null: true,
- description: 'URL used by the Agents to communicate with KAS.'
+ description: 'URL used by the Agents to communicate with KAS.'
field :version, GraphQL::Types::String, null: true,
- description: 'KAS version.'
+ description: 'KAS version.'
end
end
end
diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb
index 6fb141a50c9..b00fcfd38ad 100644
--- a/app/graphql/types/metadata_type.rb
+++ b/app/graphql/types/metadata_type.rb
@@ -7,10 +7,10 @@ module Types
authorize :read_instance_metadata
field :kas, ::Types::Metadata::KasType, null: false,
- description: 'Metadata about KAS.'
+ description: 'Metadata about KAS.'
field :revision, GraphQL::Types::String, null: false,
- description: 'Revision.'
+ description: 'Revision.'
field :version, GraphQL::Types::String, null: false,
- description: 'Version.'
+ description: 'Version.'
end
end
diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb
index 04cac55894e..5570b904d79 100644
--- a/app/graphql/types/metrics/dashboard_type.rb
+++ b/app/graphql/types/metrics/dashboard_type.rb
@@ -8,12 +8,16 @@ module Types
graphql_name 'MetricsDashboard'
field :path, GraphQL::Types::String, null: true,
- description: 'Path to a file with the dashboard definition.'
+ description: 'Path to a file with the dashboard definition.'
- field :schema_validation_warnings, [GraphQL::Types::String], null: true,
+ field :schema_validation_warnings,
+ [GraphQL::Types::String],
+ null: true,
description: 'Dashboard schema validation warnings.'
- field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true,
+ field :annotations,
+ Types::Metrics::Dashboards::AnnotationType.connection_type,
+ null: true,
description: 'Annotations added to the dashboard.',
resolver: Resolvers::Metrics::Dashboards::AnnotationResolver
diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb
index 0621cf4d674..ec479078272 100644
--- a/app/graphql/types/metrics/dashboards/annotation_type.rb
+++ b/app/graphql/types/metrics/dashboards/annotation_type.rb
@@ -8,20 +8,22 @@ module Types
authorize :read_metrics_dashboard_annotation
field :description, GraphQL::Types::String, null: true,
- description: 'Description of the annotation.'
+ description: 'Description of the annotation.'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the annotation.'
+ description: 'ID of the annotation.'
- field :panel_id, GraphQL::Types::String, null: true,
+ field :panel_id,
+ GraphQL::Types::String,
+ null: true,
description: 'ID of a dashboard panel to which the annotation should be scoped.',
method: :panel_xid
field :starting_at, Types::TimeType, null: true,
- description: 'Timestamp marking start of annotated time span.'
+ description: 'Timestamp marking start of annotated time span.'
field :ending_at, Types::TimeType, null: true,
- description: 'Timestamp marking end of annotated time span.'
+ description: 'Timestamp marking end of annotated time span.'
end
end
end
diff --git a/app/graphql/types/milestone_stats_type.rb b/app/graphql/types/milestone_stats_type.rb
index 6d8b7deb8e7..36448c4987b 100644
--- a/app/graphql/types/milestone_stats_type.rb
+++ b/app/graphql/types/milestone_stats_type.rb
@@ -7,10 +7,14 @@ module Types
authorize :read_milestone
- field :total_issues_count, GraphQL::Types::Int, null: true,
+ field :total_issues_count,
+ GraphQL::Types::Int,
+ null: true,
description: 'Total number of issues associated with the milestone.'
- field :closed_issues_count, GraphQL::Types::Int, null: true,
+ field :closed_issues_count,
+ GraphQL::Types::Int,
+ null: true,
description: 'Number of closed issues associated with the milestone.'
end
end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index 7741fd723f0..447528917f0 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -12,52 +12,52 @@ module Types
alias_method :milestone, :object
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the milestone.'
+ description: 'ID of the milestone.'
field :iid, GraphQL::Types::ID, null: false,
- description: "Internal ID of the milestone."
+ description: "Internal ID of the milestone."
field :title, GraphQL::Types::String, null: false,
- description: 'Title of the milestone.'
+ description: 'Title of the milestone.'
field :description, GraphQL::Types::String, null: true,
- description: 'Description of the milestone.'
+ description: 'Description of the milestone.'
field :state, Types::MilestoneStateEnum, null: false,
- description: 'State of the milestone.'
+ description: 'State of the milestone.'
field :expired, GraphQL::Types::Boolean, null: false,
- description: 'Expired state of the milestone (a milestone is expired when the due date is past the current date). Defaults to `false` when due date has not been set.'
+ description: 'Expired state of the milestone (a milestone is expired when the due date is past the current date). Defaults to `false` when due date has not been set.'
field :web_path, GraphQL::Types::String, null: false, method: :milestone_path,
- description: 'Web path of the milestone.'
+ description: 'Web path of the milestone.'
field :due_date, Types::TimeType, null: true,
- description: 'Timestamp of the milestone due date.'
+ description: 'Timestamp of the milestone due date.'
field :start_date, Types::TimeType, null: true,
- description: 'Timestamp of the milestone start date.'
+ description: 'Timestamp of the milestone start date.'
field :created_at, Types::TimeType, null: false,
- description: 'Timestamp of milestone creation.'
+ description: 'Timestamp of milestone creation.'
field :updated_at, Types::TimeType, null: false,
- description: 'Timestamp of last milestone update.'
+ description: 'Timestamp of last milestone update.'
field :project_milestone, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if milestone is at project level.',
- method: :project_milestone?
+ description: 'Indicates if milestone is at project level.',
+ method: :project_milestone?
field :group_milestone, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if milestone is at group level.',
- method: :group_milestone?
+ description: 'Indicates if milestone is at group level.',
+ method: :group_milestone?
field :subgroup_milestone, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if milestone is at subgroup level.',
- method: :subgroup_milestone?
+ description: 'Indicates if milestone is at subgroup level.',
+ method: :subgroup_milestone?
field :stats, Types::MilestoneStatsType, null: true,
- description: 'Milestone statistics.'
+ description: 'Milestone statistics.'
field :releases, ::Types::ReleaseType.connection_type,
null: true,
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 46ab3f3f432..499c2e786bf 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -37,8 +37,8 @@ module Types
mount_mutation Mutations::Clusters::AgentTokens::Create
mount_mutation Mutations::Clusters::AgentTokens::Revoke
mount_mutation Mutations::Commits::Create, calls_gitaly: true
- mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji
- mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji
+ mount_mutation Mutations::CustomEmoji::Create, _deprecated_feature_flag: :custom_emoji
+ mount_mutation Mutations::CustomEmoji::Destroy, _deprecated_feature_flag: :custom_emoji
mount_mutation Mutations::CustomerRelations::Contacts::Create
mount_mutation Mutations::CustomerRelations::Contacts::Update
mount_mutation Mutations::CustomerRelations::Organizations::Create
@@ -72,10 +72,8 @@ module Types
mount_mutation Mutations::MergeRequests::SetSubscription
mount_mutation Mutations::MergeRequests::SetDraft, calls_gitaly: true
mount_mutation Mutations::MergeRequests::SetAssignees
+ mount_mutation Mutations::MergeRequests::SetReviewers
mount_mutation Mutations::MergeRequests::ReviewerRereview
- mount_mutation Mutations::MergeRequests::RequestAttention
- mount_mutation Mutations::MergeRequests::RemoveAttentionRequest
- mount_mutation Mutations::MergeRequests::ToggleAttentionRequested
mount_mutation Mutations::Metrics::Dashboard::Annotations::Create
mount_mutation Mutations::Metrics::Dashboard::Annotations::Delete
mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true
@@ -94,6 +92,7 @@ module Types
mount_mutation Mutations::Terraform::State::Delete
mount_mutation Mutations::Terraform::State::Lock
mount_mutation Mutations::Terraform::State::Unlock
+ mount_mutation Mutations::Timelogs::Create
mount_mutation Mutations::Timelogs::Delete
mount_mutation Mutations::Todos::Create
mount_mutation Mutations::Todos::MarkDone
@@ -129,6 +128,7 @@ module Types
mount_mutation Mutations::Ci::JobTokenScope::RemoveProject
mount_mutation Mutations::Ci::Runner::Update
mount_mutation Mutations::Ci::Runner::Delete
+ mount_mutation Mutations::Ci::Runner::BulkDelete, alpha: { milestone: '15.3' }
mount_mutation Mutations::Ci::RunnersRegistrationToken::Reset
mount_mutation Mutations::Namespace::PackageSettings::Update
mount_mutation Mutations::Groups::Update
@@ -139,17 +139,18 @@ module Types
mount_mutation Mutations::Packages::DestroyFiles
mount_mutation Mutations::Packages::Cleanup::Policy::Update
mount_mutation Mutations::Echo
- mount_mutation Mutations::WorkItems::Create, deprecated: { milestone: '15.1', reason: :alpha }
- mount_mutation Mutations::WorkItems::CreateFromTask, deprecated: { milestone: '15.1', reason: :alpha }
- mount_mutation Mutations::WorkItems::Delete, deprecated: { milestone: '15.1', reason: :alpha }
- mount_mutation Mutations::WorkItems::DeleteTask, deprecated: { milestone: '15.1', reason: :alpha }
- mount_mutation Mutations::WorkItems::Update, deprecated: { milestone: '15.1', reason: :alpha }
- mount_mutation Mutations::WorkItems::UpdateWidgets, deprecated: { milestone: '15.1', reason: :alpha }
- mount_mutation Mutations::WorkItems::UpdateTask, deprecated: { milestone: '15.1', reason: :alpha }
+ mount_mutation Mutations::WorkItems::Create, alpha: { milestone: '15.1' }
+ mount_mutation Mutations::WorkItems::CreateFromTask, alpha: { milestone: '15.1' }
+ mount_mutation Mutations::WorkItems::Delete, alpha: { milestone: '15.1' }
+ mount_mutation Mutations::WorkItems::DeleteTask, alpha: { milestone: '15.1' }
+ mount_mutation Mutations::WorkItems::Update, alpha: { milestone: '15.1' }
+ mount_mutation Mutations::WorkItems::UpdateWidgets, alpha: { milestone: '15.1' }
+ mount_mutation Mutations::WorkItems::UpdateTask, alpha: { milestone: '15.1' }
mount_mutation Mutations::SavedReplies::Create
mount_mutation Mutations::SavedReplies::Update
mount_mutation Mutations::Pages::MarkOnboardingComplete
mount_mutation Mutations::SavedReplies::Destroy
+ mount_mutation Mutations::Uploads::Delete
end
end
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
index de6a078c6ef..0f634e7c2d3 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -7,38 +7,45 @@ module Types
authorize :read_namespace
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the namespace.'
+ description: 'ID of the namespace.'
field :full_name, GraphQL::Types::String, null: false,
- description: 'Full name of the namespace.'
+ description: 'Full name of the namespace.'
field :full_path, GraphQL::Types::ID, null: false,
- description: 'Full path of the namespace.'
+ description: 'Full path of the namespace.'
field :name, GraphQL::Types::String, null: false,
- description: 'Name of the namespace.'
+ description: 'Name of the namespace.'
field :path, GraphQL::Types::String, null: false,
- description: 'Path of the namespace.'
+ description: 'Path of the namespace.'
- field :cross_project_pipeline_available, GraphQL::Types::Boolean, null: false,
+ field :cross_project_pipeline_available,
+ GraphQL::Types::Boolean,
+ null: false,
resolver_method: :cross_project_pipeline_available?,
description: 'Indicates if the cross_project_pipeline feature is available for the namespace.'
field :description, GraphQL::Types::String, null: true,
- description: 'Description of the namespace.'
+ description: 'Description of the namespace.'
- field :lfs_enabled, GraphQL::Types::Boolean, null: true, method: :lfs_enabled?,
+ field :lfs_enabled,
+ GraphQL::Types::Boolean,
+ null: true,
+ method: :lfs_enabled?,
description: 'Indicates if Large File Storage (LFS) is enabled for namespace.'
- field :request_access_enabled, GraphQL::Types::Boolean, null: true,
+ field :request_access_enabled,
+ GraphQL::Types::Boolean,
+ null: true,
description: 'Indicates if users can request access to namespace.'
field :visibility, GraphQL::Types::String, null: true,
- description: 'Visibility of the namespace.'
+ description: 'Visibility of the namespace.'
field :root_storage_statistics, Types::RootStorageStatisticsType,
null: true,
description: 'Aggregated storage statistics of the namespace. Only available for root namespaces.'
field :projects, Types::ProjectType.connection_type, null: false,
- description: 'Projects within this namespace.',
- resolver: ::Resolvers::NamespaceProjectsResolver
+ description: 'Projects within this namespace.',
+ resolver: ::Resolvers::NamespaceProjectsResolver
field :package_settings,
Types::Namespace::PackageSettingsType,
@@ -50,8 +57,18 @@ module Types
null: true,
description: "Shared runners availability for the namespace and its descendants."
+ field :timelog_categories,
+ Types::TimeTracking::TimelogCategoryType.connection_type,
+ null: true,
+ description: "Timelog categories for the namespace.",
+ alpha: { milestone: '15.3' }
+
markdown_field :description_html, null: true
+ def timelog_categories
+ object.timelog_categories if Feature.enabled?(:timelog_categories)
+ end
+
def cross_project_pipeline_available?
object.licensed_feature_available?(:cross_project_pipelines)
end
diff --git a/app/graphql/types/notes/diff_image_position_input_type.rb b/app/graphql/types/notes/diff_image_position_input_type.rb
index d535dea2e07..8c82f4fec2e 100644
--- a/app/graphql/types/notes/diff_image_position_input_type.rb
+++ b/app/graphql/types/notes/diff_image_position_input_type.rb
@@ -5,13 +5,21 @@ module Types
class DiffImagePositionInputType < DiffPositionBaseInputType
graphql_name 'DiffImagePositionInput'
- argument :height, GraphQL::Types::Int, required: true,
+ argument :height,
+ GraphQL::Types::Int,
+ required: true,
description: copy_field_description(Types::Notes::DiffPositionType, :height)
- argument :width, GraphQL::Types::Int, required: true,
+ argument :width,
+ GraphQL::Types::Int,
+ required: true,
description: copy_field_description(Types::Notes::DiffPositionType, :width)
- argument :x, GraphQL::Types::Int, required: true,
+ argument :x,
+ GraphQL::Types::Int,
+ required: true,
description: copy_field_description(Types::Notes::DiffPositionType, :x)
- argument :y, GraphQL::Types::Int, required: true,
+ argument :y,
+ GraphQL::Types::Int,
+ required: true,
description: copy_field_description(Types::Notes::DiffPositionType, :y)
end
end
diff --git a/app/graphql/types/notes/diff_position_base_input_type.rb b/app/graphql/types/notes/diff_position_base_input_type.rb
index 2780dbab573..433cd442235 100644
--- a/app/graphql/types/notes/diff_position_base_input_type.rb
+++ b/app/graphql/types/notes/diff_position_base_input_type.rb
@@ -4,11 +4,11 @@ module Types
module Notes
class DiffPositionBaseInputType < BaseInputObject
argument :base_sha, GraphQL::Types::String, required: false,
- description: copy_field_description(Types::DiffRefsType, :base_sha)
+ description: copy_field_description(Types::DiffRefsType, :base_sha)
argument :head_sha, GraphQL::Types::String, required: true,
- description: copy_field_description(Types::DiffRefsType, :head_sha)
+ description: copy_field_description(Types::DiffRefsType, :head_sha)
argument :start_sha, GraphQL::Types::String, required: true,
- description: copy_field_description(Types::DiffRefsType, :start_sha)
+ description: copy_field_description(Types::DiffRefsType, :start_sha)
argument :paths,
Types::DiffPathsInputType,
diff --git a/app/graphql/types/notes/diff_position_input_type.rb b/app/graphql/types/notes/diff_position_input_type.rb
index ccde4188f29..5823a4f19cc 100644
--- a/app/graphql/types/notes/diff_position_input_type.rb
+++ b/app/graphql/types/notes/diff_position_input_type.rb
@@ -6,9 +6,9 @@ module Types
graphql_name 'DiffPositionInput'
argument :new_line, GraphQL::Types::Int, required: false,
- description: "#{copy_field_description(Types::Notes::DiffPositionType, :new_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field."
+ description: "#{copy_field_description(Types::Notes::DiffPositionType, :new_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field."
argument :old_line, GraphQL::Types::Int, required: false,
- description: "#{copy_field_description(Types::Notes::DiffPositionType, :old_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field."
+ description: "#{copy_field_description(Types::Notes::DiffPositionType, :old_line)} Please see the [REST API Documentation](https://docs.gitlab.com/ee/api/discussions.html#create-a-new-thread-in-the-merge-request-diff) for more information on how to use this field."
end
end
end
diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb
index 531bd0edac0..dd343cf45e4 100644
--- a/app/graphql/types/notes/diff_position_type.rb
+++ b/app/graphql/types/notes/diff_position_type.rb
@@ -7,33 +7,35 @@ module Types
class DiffPositionType < BaseObject
graphql_name 'DiffPosition'
- field :diff_refs, Types::DiffRefsType, null: false,
+ field :diff_refs,
+ Types::DiffRefsType,
+ null: false,
description: 'Information about the branch, HEAD, and base at the time of commenting.'
field :file_path, GraphQL::Types::String, null: false,
- description: 'Path of the file that was changed.'
+ description: 'Path of the file that was changed.'
field :new_path, GraphQL::Types::String, null: true,
- description: 'Path of the file on the HEAD SHA.'
+ description: 'Path of the file on the HEAD SHA.'
field :old_path, GraphQL::Types::String, null: true,
- description: 'Path of the file on the start SHA.'
+ description: 'Path of the file on the start SHA.'
field :position_type, Types::Notes::PositionTypeEnum, null: false,
- description: 'Type of file the position refers to.'
+ description: 'Type of file the position refers to.'
# Fields for text positions
field :new_line, GraphQL::Types::Int, null: true,
- description: 'Line on HEAD SHA that was changed.'
+ description: 'Line on HEAD SHA that was changed.'
field :old_line, GraphQL::Types::Int, null: true,
- description: 'Line on start SHA that was changed.'
+ description: 'Line on start SHA that was changed.'
# Fields for image positions
field :height, GraphQL::Types::Int, null: true,
- description: 'Total height of the image.'
+ description: 'Total height of the image.'
field :width, GraphQL::Types::Int, null: true,
- description: 'Total width of the image.'
+ description: 'Total width of the image.'
field :x, GraphQL::Types::Int, null: true,
- description: 'X position of the note.'
+ description: 'X position of the note.'
field :y, GraphQL::Types::Int, null: true,
- description: 'Y position of the note.'
+ description: 'Y position of the note.'
def old_line
object.old_line if object.on_text?
diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb
index 89778b2a99a..5e40c8008a9 100644
--- a/app/graphql/types/notes/discussion_type.rb
+++ b/app/graphql/types/notes/discussion_type.rb
@@ -12,15 +12,15 @@ module Types
implements(Types::ResolvableInterface)
field :created_at, Types::TimeType, null: false,
- description: "Timestamp of the discussion's creation."
+ description: "Timestamp of the discussion's creation."
field :id, DiscussionID, null: false,
- description: "ID of this discussion."
+ description: "ID of this discussion."
field :noteable, Types::NoteableType, null: true,
- description: 'Object which the discussion belongs to.'
+ description: 'Object which the discussion belongs to.'
field :notes, Types::Notes::NoteType.connection_type, null: false,
- description: 'All notes in the discussion.'
+ description: 'All notes in the discussion.'
field :reply_id, DiscussionID, null: false,
- description: 'ID used to reply to this discussion.'
+ description: 'ID used to reply to this discussion.'
# DiscussionID.coerce_result is suitable here, but will always mark this
# as being a 'Discussion'. Using `GlobalId.build` guarantees that we get
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index 32f3ff7f556..c254460a51f 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -12,7 +12,7 @@ module Types
implements(Types::ResolvableInterface)
field :id, ::Types::GlobalIDType[::Note], null: false,
- description: 'ID of the note.'
+ description: 'ID of the note.'
field :project, Types::ProjectType,
null: true,
@@ -25,7 +25,9 @@ module Types
field :system, GraphQL::Types::Boolean,
null: false,
description: 'Indicates whether this note was created by the system or by a user.'
- field :system_note_icon_name, GraphQL::Types::String, null: true,
+ field :system_note_icon_name,
+ GraphQL::Types::String,
+ null: true,
description: 'Name of the icon corresponding to a system note.'
field :body, GraphQL::Types::String,
@@ -34,16 +36,26 @@ module Types
description: 'Content of the note.'
field :confidential, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if this note is confidential.',
- method: :confidential?
+ description: 'Indicates if this note is confidential.',
+ method: :confidential?,
+ deprecated: {
+ reason: :renamed,
+ replacement: 'internal',
+ milestone: '15.3'
+ }
+
+ field :internal, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if this note is internal.',
+ method: :confidential?
+
field :created_at, Types::TimeType, null: false,
- description: 'Timestamp of the note creation.'
+ description: 'Timestamp of the note creation.'
field :discussion, Types::Notes::DiscussionType, null: true,
- description: 'Discussion this note is a part of.'
+ description: 'Discussion this note is a part of.'
field :position, Types::Notes::DiffPositionType, null: true,
- description: 'Position of this note on a diff.'
+ description: 'Position of this note on a diff.'
field :updated_at, Types::TimeType, null: false,
- description: "Timestamp of the note's last activity."
+ description: "Timestamp of the note's last activity."
field :url, GraphQL::Types::String,
null: true,
description: 'URL to view this Note in the Web UI.'
diff --git a/app/graphql/types/packages/package_base_type.rb b/app/graphql/types/packages/package_base_type.rb
index 06ccde94cd4..2dc4a2a2bb6 100644
--- a/app/graphql/types/packages/package_base_type.rb
+++ b/app/graphql/types/packages/package_base_type.rb
@@ -10,12 +10,12 @@ module Types
authorize :read_package
- field :id, ::Types::GlobalIDType[::Packages::Package], null: false,
- description: 'ID of the package.'
+ field :id, ::Types::GlobalIDType[::Packages::Package], null: false, description: 'ID of the package.'
field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.'
field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
- field :metadata, Types::Packages::MetadataType, null: true,
+ field :metadata, Types::Packages::MetadataType,
+ null: true,
description: 'Package metadata.'
field :name, GraphQL::Types::String, null: false, description: 'Name of the package.'
field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'Package type.'
diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb
index ae57e103f40..0413177ef14 100644
--- a/app/graphql/types/packages/package_details_type.rb
+++ b/app/graphql/types/packages/package_details_type.rb
@@ -11,7 +11,7 @@ module Types
authorize :read_package
field :versions, ::Types::Packages::PackageBaseType.connection_type, null: true,
- description: 'Other versions of the package.'
+ description: 'Other versions of the package.'
field :package_files, Types::Packages::PackageFileType.connection_type, null: true, method: :installable_package_files, description: 'Package files.'
diff --git a/app/graphql/types/packages/package_file_type.rb b/app/graphql/types/packages/package_file_type.rb
index b058dc0ab0d..3ee0d983745 100644
--- a/app/graphql/types/packages/package_file_type.rb
+++ b/app/graphql/types/packages/package_file_type.rb
@@ -11,7 +11,7 @@ module Types
field :download_path, GraphQL::Types::String, null: false, description: 'Download path of the package file.'
field :file_md5, GraphQL::Types::String, null: true, description: 'Md5 of the package file.'
field :file_metadata, Types::Packages::FileMetadataType, null: true,
- description: 'File metadata.'
+ description: 'File metadata.'
field :file_name, GraphQL::Types::String, null: false, description: 'Name of the package file.'
field :file_sha1, GraphQL::Types::String, null: true, description: 'Sha1 of the package file.'
field :file_sha256, GraphQL::Types::String, null: true, description: 'Sha256 of the package file.'
diff --git a/app/graphql/types/permission_types/group_enum.rb b/app/graphql/types/permission_types/group_enum.rb
index 8b0fee8898c..f636d43790f 100644
--- a/app/graphql/types/permission_types/group_enum.rb
+++ b/app/graphql/types/permission_types/group_enum.rb
@@ -7,7 +7,8 @@ module Types
description 'User permission on groups'
value 'CREATE_PROJECTS', value: :create_projects, description: 'Groups where the user can create projects.'
- value 'TRANSFER_PROJECTS', value: :transfer_projects,
+ value 'TRANSFER_PROJECTS',
+ value: :transfer_projects,
description: 'Groups where the user can transfer projects to.'
end
end
diff --git a/app/graphql/types/project_invitation_type.rb b/app/graphql/types/project_invitation_type.rb
index b76f05e289f..b5760a911be 100644
--- a/app/graphql/types/project_invitation_type.rb
+++ b/app/graphql/types/project_invitation_type.rb
@@ -12,7 +12,7 @@ module Types
authorize :admin_project
field :project, Types::ProjectType, null: true,
- description: 'Project ID for the project of the invitation.'
+ description: 'Project ID for the project of the invitation.'
def project
Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.source_id).find
diff --git a/app/graphql/types/project_member_type.rb b/app/graphql/types/project_member_type.rb
index 1f00df84641..2eba0d2dea2 100644
--- a/app/graphql/types/project_member_type.rb
+++ b/app/graphql/types/project_member_type.rb
@@ -12,7 +12,7 @@ module Types
authorize :read_project
field :project, Types::ProjectType, null: true,
- description: 'Project that User is a member of.'
+ description: 'Project that User is a member of.'
def project
Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.source_id).find
diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb
index 5ab3cc33e85..c43baf1280b 100644
--- a/app/graphql/types/project_statistics_type.rb
+++ b/app/graphql/types/project_statistics_type.rb
@@ -7,27 +7,31 @@ module Types
authorize :read_statistics
field :commit_count, GraphQL::Types::Float, null: false,
- description: 'Commit count of the project.'
+ description: 'Commit count of the project.'
field :build_artifacts_size, GraphQL::Types::Float, null: false,
- description: 'Build artifacts size of the project in bytes.'
- field :lfs_objects_size, GraphQL::Types::Float, null: false,
+ description: 'Build artifacts size of the project in bytes.'
+ field :lfs_objects_size,
+ GraphQL::Types::Float,
+ null: false,
description: 'Large File Storage (LFS) object size of the project in bytes.'
field :packages_size, GraphQL::Types::Float, null: false,
- description: 'Packages size of the project in bytes.'
+ description: 'Packages size of the project in bytes.'
field :pipeline_artifacts_size, GraphQL::Types::Float, null: true,
- description: 'CI Pipeline artifacts size in bytes.'
+ description: 'CI Pipeline artifacts size in bytes.'
field :repository_size, GraphQL::Types::Float, null: false,
- description: 'Repository size of the project in bytes.'
+ description: 'Repository size of the project in bytes.'
field :snippets_size, GraphQL::Types::Float, null: true,
- description: 'Snippets size of the project in bytes.'
+ description: 'Snippets size of the project in bytes.'
field :storage_size, GraphQL::Types::Float, null: false,
- description: 'Storage size of the project in bytes.'
+ description: 'Storage size of the project in bytes.'
field :uploads_size, GraphQL::Types::Float, null: true,
- description: 'Uploads size of the project in bytes.'
+ description: 'Uploads size of the project in bytes.'
field :wiki_size, GraphQL::Types::Float, null: true,
- description: 'Wiki size of the project in bytes.'
- field :container_registry_size, GraphQL::Types::Float, null: true,
+ description: 'Wiki size of the project in bytes.'
+ field :container_registry_size,
+ GraphQL::Types::Float,
+ null: true,
description: 'Container Registry size of the project in bytes.'
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 7e3800c6a13..ecc6c9d7811 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -11,118 +11,118 @@ module Types
expose_permissions Types::PermissionTypes::Project
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the project.'
+ description: 'ID of the project.'
field :ci_config_path_or_default, GraphQL::Types::String, null: false,
- description: 'Path of the CI configuration file.'
+ description: 'Path of the CI configuration file.'
field :full_path, GraphQL::Types::ID, null: false,
- description: 'Full path of the project.'
+ description: 'Full path of the project.'
field :path, GraphQL::Types::String, null: false,
- description: 'Path of the project.'
+ description: 'Path of the project.'
field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, null: true,
- calls_gitaly: true,
- description: 'SAST CI configuration for the project.'
+ calls_gitaly: true,
+ description: 'SAST CI configuration for the project.'
field :name, GraphQL::Types::String, null: false,
- description: 'Name of the project (without namespace).'
+ description: 'Name of the project (without namespace).'
field :name_with_namespace, GraphQL::Types::String, null: false,
- description: 'Full name of the project with its namespace.'
+ description: 'Full name of the project with its namespace.'
field :description, GraphQL::Types::String, null: true,
- description: 'Short description of the project.'
+ description: 'Short description of the project.'
field :tag_list, GraphQL::Types::String, null: true,
- deprecated: { reason: 'Use `topics`', milestone: '13.12' },
- description: 'List of project topics (not Git tags).', method: :topic_list
+ deprecated: { reason: 'Use `topics`', milestone: '13.12' },
+ description: 'List of project topics (not Git tags).', method: :topic_list
field :topics, [GraphQL::Types::String], null: true,
- description: 'List of project topics.', method: :topic_list
+ description: 'List of project topics.', method: :topic_list
field :http_url_to_repo, GraphQL::Types::String, null: true,
- description: 'URL to connect to the project via HTTPS.'
+ description: 'URL to connect to the project via HTTPS.'
field :ssh_url_to_repo, GraphQL::Types::String, null: true,
- description: 'URL to connect to the project via SSH.'
+ description: 'URL to connect to the project via SSH.'
field :web_url, GraphQL::Types::String, null: true,
- description: 'Web URL of the project.'
+ description: 'Web URL of the project.'
field :forks_count, GraphQL::Types::Int, null: false, calls_gitaly: true, # 4 times
- description: 'Number of times the project has been forked.'
+ description: 'Number of times the project has been forked.'
field :star_count, GraphQL::Types::Int, null: false,
- description: 'Number of times the project has been starred.'
+ description: 'Number of times the project has been starred.'
field :created_at, Types::TimeType, null: true,
- description: 'Timestamp of the project creation.'
+ description: 'Timestamp of the project creation.'
field :last_activity_at, Types::TimeType, null: true,
- description: 'Timestamp of the project last activity.'
+ description: 'Timestamp of the project last activity.'
field :archived, GraphQL::Types::Boolean, null: true,
- description: 'Indicates the archived status of the project.'
+ description: 'Indicates the archived status of the project.'
field :visibility, GraphQL::Types::String, null: true,
- description: 'Visibility of the project.'
+ description: 'Visibility of the project.'
field :lfs_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the project has Large File Storage (LFS) enabled.'
+ description: 'Indicates if the project has Large File Storage (LFS) enabled.'
field :merge_requests_ff_only_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.'
+ description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.'
field :shared_runners_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if shared runners are enabled for the project.'
+ description: 'Indicates if shared runners are enabled for the project.'
field :service_desk_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the project has Service Desk enabled.'
+ description: 'Indicates if the project has Service Desk enabled.'
field :service_desk_address, GraphQL::Types::String, null: true,
- description: 'E-mail address of the Service Desk.'
+ description: 'E-mail address of the Service Desk.'
field :avatar_url, GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'URL to avatar image file of the project.'
+ description: 'URL to avatar image file of the project.'
field :jobs_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.'
+ description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.'
field :public_jobs, GraphQL::Types::Boolean, method: :public_builds, null: true,
- description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts.'
+ description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts.'
field :open_issues_count, GraphQL::Types::Int, null: true,
- description: 'Number of open issues for the project.'
+ description: 'Number of open issues for the project.'
field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, null: true,
- description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs.'
+ description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs.'
field :autoclose_referenced_issues, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically.'
+ description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically.'
field :import_status, GraphQL::Types::String, null: true,
- description: 'Status of import background job of the project.'
+ description: 'Status of import background job of the project.'
field :jira_import_status, GraphQL::Types::String, null: true,
- description: 'Status of Jira import background job of the project.'
+ description: 'Status of Jira import background job of the project.'
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.'
+ description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.'
field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if merge requests of the project can only be merged with successful jobs.'
+ description: 'Indicates if merge requests of the project can only be merged with successful jobs.'
field :printing_merge_request_link_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line.'
+ description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line.'
field :remove_source_branch_after_merge, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project.'
+ description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project.'
field :request_access_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if users can request member access to the project.'
+ description: 'Indicates if users can request member access to the project.'
field :squash_read_only, GraphQL::Types::Boolean, null: false, method: :squash_readonly?,
- description: 'Indicates if `squashReadOnly` is enabled.'
+ description: 'Indicates if `squashReadOnly` is enabled.'
field :suggestion_commit_message, GraphQL::Types::String, null: true,
- description: 'Commit message used to apply merge request suggestions.'
+ description: 'Commit message used to apply merge request suggestions.'
# No, the quotes are not a typo. Used to get around circular dependencies.
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536#note_871009675
field :group, 'Types::GroupType', null: true,
- description: 'Group of the project.'
+ description: 'Group of the project.'
field :namespace, Types::NamespaceType, null: true,
- description: 'Namespace of the project.'
+ description: 'Namespace of the project.'
field :statistics, Types::ProjectStatisticsType,
null: true,
description: 'Statistics of the project.'
field :repository, Types::RepositoryType, null: true,
- description: 'Git repository of the project.'
+ description: 'Git repository of the project.'
field :merge_requests,
Types::MergeRequestType.connection_type,
@@ -147,7 +147,7 @@ module Types
field :work_items,
Types::WorkItemType.connection_type,
null: true,
- deprecated: { milestone: '15.1', reason: :alpha },
+ alpha: { milestone: '15.1' },
description: 'Work items of the project.',
extras: [:lookahead],
resolver: Resolvers::WorkItemsResolver
@@ -160,8 +160,8 @@ module Types
resolver: Resolvers::IssueStatusCountsResolver
field :milestones, Types::MilestoneType.connection_type, null: true,
- description: 'Milestones of the project.',
- resolver: Resolvers::ProjectMilestonesResolver
+ description: 'Milestones of the project.',
+ resolver: Resolvers::ProjectMilestonesResolver
field :project_members,
description: 'Members of the project.',
@@ -221,7 +221,7 @@ module Types
resolver: Resolvers::Ci::ProjectPipelineCountsResolver
field :ci_variables,
- Types::Ci::VariableType.connection_type,
+ Types::Ci::ProjectVariableType.connection_type,
null: true,
description: "List of the project's CI/CD variables.",
authorize: :admin_build,
@@ -355,7 +355,7 @@ module Types
resolver: Resolvers::ContainerRepositoriesResolver
field :container_repositories_count, GraphQL::Types::Int, null: false,
- description: 'Number of container repositories in the project.'
+ description: 'Number of container repositories in the project.'
field :label,
Types::LabelType,
@@ -379,23 +379,23 @@ module Types
resolver: Resolvers::Terraform::StatesResolver
field :pipeline_analytics, Types::Ci::AnalyticsType, null: true,
- description: 'Pipeline analytics.',
- resolver: Resolvers::ProjectPipelineStatisticsResolver
+ description: 'Pipeline analytics.',
+ resolver: Resolvers::ProjectPipelineStatisticsResolver
field :ci_template, Types::Ci::TemplateType, null: true,
- description: 'Find a single CI/CD template by name.',
- resolver: Resolvers::Ci::TemplateResolver
+ description: 'Find a single CI/CD template by name.',
+ resolver: Resolvers::Ci::TemplateResolver
field :ci_job_token_scope, Types::Ci::JobTokenScopeType, null: true,
- description: 'The CI Job Tokens scope of access.',
- resolver: Resolvers::Ci::JobTokenScopeResolver
+ description: 'The CI Job Tokens scope of access.',
+ resolver: Resolvers::Ci::JobTokenScopeResolver
field :timelogs,
Types::TimelogType.connection_type, null: true,
- description: 'Time logged on issues and merge requests in the project.',
- extras: [:lookahead],
- complexity: 5,
- resolver: ::Resolvers::TimelogResolver
+ description: 'Time logged on issues and merge requests in the project.',
+ extras: [:lookahead],
+ complexity: 5,
+ resolver: ::Resolvers::TimelogResolver
field :agent_configurations,
::Types::Kas::AgentConfigurationType.connection_type,
@@ -438,6 +438,20 @@ module Types
' Returns `null` if `work_items` feature flag is disabled.' \
' This flag is disabled by default, because the feature is experimental and is subject to change without notice.'
+ field :timelog_categories,
+ Types::TimeTracking::TimelogCategoryType.connection_type,
+ null: true,
+ description: "Timelog categories for the project.",
+ alpha: { milestone: '15.3' }
+
+ field :fork_targets, Types::NamespaceType.connection_type,
+ resolver: Resolvers::Projects::ForkTargetsResolver,
+ description: 'Namespaces in which the current user can fork the project into.'
+
+ def timelog_categories
+ object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories)
+ end
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
@@ -455,7 +469,7 @@ module Types
container_registry: 'Container Registry is'
}.each do |feature, name_string|
field "#{feature}_enabled", GraphQL::Types::Boolean, null: true,
- description: "Indicates if #{name_string} enabled for the current user"
+ description: "Indicates if #{name_string} enabled for the current user"
define_method "#{feature}_enabled" do
object.feature_available?(feature, context[:current_user])
diff --git a/app/graphql/types/projects/service_type.rb b/app/graphql/types/projects/service_type.rb
index 88b7b95aa57..1416d93d3b4 100644
--- a/app/graphql/types/projects/service_type.rb
+++ b/app/graphql/types/projects/service_type.rb
@@ -9,11 +9,11 @@ module Types
# TODO: Add all the fields that we want to expose for the project services integrations
# https://gitlab.com/gitlab-org/gitlab/-/issues/213088
field :type, GraphQL::Types::String, null: true,
- description: 'Class name of the service.'
+ description: 'Class name of the service.'
field :service_type, ::Types::Projects::ServiceTypeEnum, null: true,
- description: 'Type of the service.'
+ description: 'Type of the service.'
field :active, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the service is active.'
+ description: 'Indicates if the service is active.'
def type
enum = ::Types::Projects::ServiceTypeEnum.coerce_result(service_type, context)
diff --git a/app/graphql/types/projects/services/jira_project_type.rb b/app/graphql/types/projects/services/jira_project_type.rb
index 0ff1b9d8903..1c5b97802e3 100644
--- a/app/graphql/types/projects/services/jira_project_type.rb
+++ b/app/graphql/types/projects/services/jira_project_type.rb
@@ -8,12 +8,12 @@ module Types
graphql_name 'JiraProject'
field :key, GraphQL::Types::String, null: false,
- description: 'Key of the Jira project.'
+ description: 'Key of the Jira project.'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the Jira project.'
+ description: 'Name of the Jira project.'
field :project_id, GraphQL::Types::Int, null: false,
- description: 'ID of the Jira project.',
- method: :id
+ description: 'ID of the Jira project.',
+ method: :id
end
# rubocop:enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/projects/topic_type.rb b/app/graphql/types/projects/topic_type.rb
index bde6d79ddbf..da7df6df4a2 100644
--- a/app/graphql/types/projects/topic_type.rb
+++ b/app/graphql/types/projects/topic_type.rb
@@ -7,20 +7,20 @@ module Types
graphql_name 'Topic'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the topic.'
+ description: 'ID of the topic.'
field :name, GraphQL::Types::String, null: false,
- description: 'Name of the topic.'
+ description: 'Name of the topic.'
field :title, GraphQL::Types::String, null: false,
- method: :title_or_name,
- description: 'Title of the topic.'
+ method: :title_or_name,
+ description: 'Title of the topic.'
field :description, GraphQL::Types::String, null: true,
- description: 'Description of the topic.'
+ description: 'Description of the topic.'
field :avatar_url, GraphQL::Types::String, null: true,
- description: 'URL to avatar image file of the topic.'
+ description: 'URL to avatar image file of the topic.'
markdown_field :description_html, null: true
diff --git a/app/graphql/types/prometheus_alert_type.rb b/app/graphql/types/prometheus_alert_type.rb
index 789f1d6eb5f..fddb8b73768 100644
--- a/app/graphql/types/prometheus_alert_type.rb
+++ b/app/graphql/types/prometheus_alert_type.rb
@@ -10,7 +10,7 @@ module Types
present_using PrometheusAlertPresenter
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the alert condition.'
+ description: 'ID of the alert condition.'
field :humanized_text,
GraphQL::Types::String,
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 9207a867639..84355390ea0 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -91,7 +91,7 @@ module Types
field :work_item, Types::WorkItemType,
null: true,
resolver: Resolvers::WorkItemResolver,
- deprecated: { milestone: '15.1', reason: :alpha },
+ alpha: { milestone: '15.1' },
description: 'Find a work item. Returns `null` if `work_items` feature flag is disabled.'
field :merge_request, Types::MergeRequestType,
@@ -124,7 +124,7 @@ module Types
description: "Find runners visible to the current user."
field :ci_variables,
- Types::Ci::VariableType.connection_type,
+ Types::Ci::InstanceVariableType.connection_type,
null: true,
description: "List of the instance's CI/CD variables."
@@ -184,7 +184,7 @@ module Types
end
def ci_variables
- return unless current_user.can_admin_all_resources?
+ return unless current_user&.can_admin_all_resources?
::Ci::InstanceVariable.all
end
diff --git a/app/graphql/types/release_asset_link_type.rb b/app/graphql/types/release_asset_link_type.rb
index 29738de27e5..e171c683e7d 100644
--- a/app/graphql/types/release_asset_link_type.rb
+++ b/app/graphql/types/release_asset_link_type.rb
@@ -10,19 +10,21 @@ module Types
present_using Releases::LinkPresenter
field :external, GraphQL::Types::Boolean, null: true, method: :external?,
- description: 'Indicates the link points to an external resource.'
+ description: 'Indicates the link points to an external resource.'
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the link.'
- field :link_type, Types::ReleaseAssetLinkTypeEnum, null: true,
+ description: 'ID of the link.'
+ field :link_type,
+ Types::ReleaseAssetLinkTypeEnum,
+ null: true,
description: 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`.'
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the link.'
+ description: 'Name of the link.'
field :url, GraphQL::Types::String, null: true,
- description: 'URL of the link.'
+ description: 'URL of the link.'
field :direct_asset_path, GraphQL::Types::String, null: true, method: :filepath,
- description: 'Relative path for the direct asset link.'
+ description: 'Relative path for the direct asset link.'
field :direct_asset_url, GraphQL::Types::String, null: true,
- description: 'Direct asset URL of the link.'
+ description: 'Direct asset URL of the link.'
end
end
diff --git a/app/graphql/types/release_assets_type.rb b/app/graphql/types/release_assets_type.rb
index ea6ee0b5fd9..396ba112130 100644
--- a/app/graphql/types/release_assets_type.rb
+++ b/app/graphql/types/release_assets_type.rb
@@ -12,10 +12,10 @@ module Types
present_using ReleasePresenter
field :count, GraphQL::Types::Int, null: true, method: :assets_count,
- description: 'Number of assets of the release.'
+ description: 'Number of assets of the release.'
field :links, Types::ReleaseAssetLinkType.connection_type, null: true, method: :sorted_links,
- description: 'Asset links of the release.'
+ description: 'Asset links of the release.'
field :sources, Types::ReleaseSourceType.connection_type, null: true,
- description: 'Sources of the release.'
+ description: 'Sources of the release.'
end
end
diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb
index b7a1a5a9dbe..6bc767152e8 100644
--- a/app/graphql/types/release_links_type.rb
+++ b/app/graphql/types/release_links_type.rb
@@ -10,25 +10,35 @@ module Types
present_using ReleasePresenter
- field :closed_issues_url, GraphQL::Types::String, null: true,
+ field :closed_issues_url,
+ GraphQL::Types::String,
+ null: true,
description: 'HTTP URL of the issues page, filtered by this release and `state=closed`.',
authorize: :download_code
- field :closed_merge_requests_url, GraphQL::Types::String, null: true,
+ field :closed_merge_requests_url,
+ GraphQL::Types::String,
+ null: true,
description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`.',
authorize: :download_code
field :edit_url, GraphQL::Types::String, null: true,
- description: "HTTP URL of the release's edit page.",
- authorize: :update_release
- field :merged_merge_requests_url, GraphQL::Types::String, null: true,
+ description: "HTTP URL of the release's edit page.",
+ authorize: :update_release
+ field :merged_merge_requests_url,
+ GraphQL::Types::String,
+ null: true,
description: 'HTTP URL of the merge request page , filtered by this release and `state=merged`.',
authorize: :download_code
- field :opened_issues_url, GraphQL::Types::String, null: true,
+ field :opened_issues_url,
+ GraphQL::Types::String,
+ null: true,
description: 'HTTP URL of the issues page, filtered by this release and `state=open`.',
authorize: :download_code
- field :opened_merge_requests_url, GraphQL::Types::String, null: true,
+ field :opened_merge_requests_url,
+ GraphQL::Types::String,
+ null: true,
description: 'HTTP URL of the merge request page, filtered by this release and `state=open`.',
authorize: :download_code
field :self_url, GraphQL::Types::String, null: true,
- description: 'HTTP URL of the release.'
+ description: 'HTTP URL of the release.'
end
end
diff --git a/app/graphql/types/release_source_type.rb b/app/graphql/types/release_source_type.rb
index fd29a69d72a..e05a2926ac1 100644
--- a/app/graphql/types/release_source_type.rb
+++ b/app/graphql/types/release_source_type.rb
@@ -8,8 +8,8 @@ module Types
authorize :download_code
field :format, GraphQL::Types::String, null: true,
- description: 'Format of the source.'
+ description: 'Format of the source.'
field :url, GraphQL::Types::String, null: true,
- description: 'Download URL of the source.'
+ description: 'Download URL of the source.'
end
end
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index d906c577aa5..d70fe05c906 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -17,38 +17,40 @@ module Types
null: false,
description: 'Global ID of the release.'
field :assets, Types::ReleaseAssetsType, null: true, method: :itself,
- description: 'Assets of the release.'
+ description: 'Assets of the release.'
field :created_at, Types::TimeType, null: true,
- description: 'Timestamp of when the release was created.'
- field :description, GraphQL::Types::String, null: true,
+ description: 'Timestamp of when the release was created.'
+ field :description,
+ GraphQL::Types::String,
+ null: true,
description: 'Description (also known as "release notes") of the release.'
field :evidences, Types::EvidenceType.connection_type, null: true,
- description: 'Evidence for the release.'
+ description: 'Evidence for the release.'
field :links, Types::ReleaseLinksType, null: true, method: :itself,
- description: 'Links of the release.'
+ description: 'Links of the release.'
field :milestones, Types::MilestoneType.connection_type, null: true,
- description: 'Milestones associated to the release.',
- resolver: ::Resolvers::ReleaseMilestonesResolver
+ description: 'Milestones associated to the release.',
+ resolver: ::Resolvers::ReleaseMilestonesResolver
field :name, GraphQL::Types::String, null: true,
- description: 'Name of the release.'
+ description: 'Name of the release.'
field :released_at, Types::TimeType, null: true,
- description: 'Timestamp of when the release was released.'
+ description: 'Timestamp of when the release was released.'
field :tag_name, GraphQL::Types::String, null: true, method: :tag,
- description: 'Name of the tag associated with the release.'
+ description: 'Name of the tag associated with the release.'
field :tag_path, GraphQL::Types::String, null: true,
- description: 'Relative web path to the tag associated with the release.',
- authorize: :download_code
+ description: 'Relative web path to the tag associated with the release.',
+ authorize: :download_code
field :upcoming_release, GraphQL::Types::Boolean, null: true, method: :upcoming_release?,
- description: 'Indicates the release is an upcoming release.'
+ description: 'Indicates the release is an upcoming release.'
field :historical_release, GraphQL::Types::Boolean, null: true, method: :historical_release?,
- description: 'Indicates the release is an historical release.'
+ description: 'Indicates the release is an historical release.'
field :author, Types::UserType, null: true,
- description: 'User that created the release.'
+ description: 'User that created the release.'
field :commit, Types::CommitType, null: true,
- complexity: 10, calls_gitaly: true,
- description: 'Commit associated with the release.'
+ complexity: 10, calls_gitaly: true,
+ description: 'Commit associated with the release.'
markdown_field :description_html, null: true
diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb
index dd5c70887de..8c90a8df611 100644
--- a/app/graphql/types/repository/blob_type.rb
+++ b/app/graphql/types/repository/blob_type.rb
@@ -9,108 +9,108 @@ module Types
present_using BlobPresenter
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the blob.'
+ description: 'ID of the blob.'
field :oid, GraphQL::Types::String, null: false, method: :id,
- description: 'OID of the blob.'
+ description: 'OID of the blob.'
field :path, GraphQL::Types::String, null: false,
- description: 'Path of the blob.'
+ description: 'Path of the blob.'
field :name, GraphQL::Types::String,
description: 'Blob name.',
null: true
field :mode, type: GraphQL::Types::String,
- description: 'Blob mode.',
- null: true
+ description: 'Blob mode.',
+ null: true
field :lfs_oid, GraphQL::Types::String, null: true,
- calls_gitaly: true,
- description: 'LFS OID of the blob.'
+ calls_gitaly: true,
+ description: 'LFS OID of the blob.'
field :web_path, GraphQL::Types::String, null: true,
- description: 'Web path of the blob.'
+ description: 'Web path of the blob.'
field :ide_edit_path, GraphQL::Types::String, null: true,
- description: 'Web path to edit this blob in the Web IDE.'
+ description: 'Web path to edit this blob in the Web IDE.'
field :fork_and_edit_path, GraphQL::Types::String, null: true,
- description: 'Web path to edit this blob using a forked project.'
+ description: 'Web path to edit this blob using a forked project.'
field :ide_fork_and_edit_path, GraphQL::Types::String, null: true,
- description: 'Web path to edit this blob in the Web IDE using a forked project.'
+ description: 'Web path to edit this blob in the Web IDE using a forked project.'
field :fork_and_view_path, GraphQL::Types::String, null: true,
- description: 'Web path to view this blob using a forked project.'
+ description: 'Web path to view this blob using a forked project.'
field :size, GraphQL::Types::Int, null: true,
- description: 'Size (in bytes) of the blob.'
+ description: 'Size (in bytes) of the blob.'
field :raw_size, GraphQL::Types::Int, null: true,
- description: 'Size (in bytes) of the blob, or the blob target if stored externally.'
+ description: 'Size (in bytes) of the blob, or the blob target if stored externally.'
field :raw_blob, GraphQL::Types::String, null: true, method: :data,
- description: 'Raw content of the blob.'
+ description: 'Raw content of the blob.'
field :raw_text_blob, GraphQL::Types::String, null: true, method: :text_only_data,
- description: 'Raw content of the blob, if the blob is text data.'
+ description: 'Raw content of the blob, if the blob is text data.'
field :stored_externally, GraphQL::Types::Boolean, null: true, method: :stored_externally?,
- description: "Whether the blob's content is stored externally (for instance, in LFS)."
+ description: "Whether the blob's content is stored externally (for instance, in LFS)."
field :external_storage, GraphQL::Types::String, null: true, method: :external_storage,
- description: "External storage being used, if enabled (for instance, 'LFS')."
+ description: "External storage being used, if enabled (for instance, 'LFS')."
field :edit_blob_path, GraphQL::Types::String, null: true,
- description: 'Web path to edit the blob in the old-style editor.'
+ description: 'Web path to edit the blob in the old-style editor.'
field :raw_path, GraphQL::Types::String, null: true,
- description: 'Web path to download the raw blob.'
+ description: 'Web path to download the raw blob.'
field :external_storage_url, GraphQL::Types::String, null: true,
- description: 'Web path to download the raw blob via external storage, if enabled.'
+ description: 'Web path to download the raw blob via external storage, if enabled.'
field :replace_path, GraphQL::Types::String, null: true,
- description: 'Web path to replace the blob content.'
+ description: 'Web path to replace the blob content.'
field :pipeline_editor_path, GraphQL::Types::String, null: true,
- description: 'Web path to edit .gitlab-ci.yml file.'
+ description: 'Web path to edit .gitlab-ci.yml file.'
field :gitpod_blob_url, GraphQL::Types::String, null: true,
- description: 'URL to the blob within Gitpod.'
+ description: 'URL to the blob within Gitpod.'
field :find_file_path, GraphQL::Types::String, null: true,
- description: 'Web path to find file.'
+ description: 'Web path to find file.'
field :blame_path, GraphQL::Types::String, null: true,
- description: 'Web path to blob blame page.'
+ description: 'Web path to blob blame page.'
field :history_path, GraphQL::Types::String, null: true,
- description: 'Web path to blob history page.'
+ description: 'Web path to blob history page.'
field :permalink_path, GraphQL::Types::String, null: true,
- description: 'Web path to blob permalink.',
- calls_gitaly: true
+ description: 'Web path to blob permalink.',
+ calls_gitaly: true
field :environment_formatted_external_url, GraphQL::Types::String, null: true,
- description: 'Environment on which the blob is available.',
- calls_gitaly: true
+ description: 'Environment on which the blob is available.',
+ calls_gitaly: true
field :environment_external_url_for_route_map, GraphQL::Types::String, null: true,
- description: 'Web path to blob on an environment.',
- calls_gitaly: true
+ description: 'Web path to blob on an environment.',
+ calls_gitaly: true
field :file_type, GraphQL::Types::String, null: true,
- description: 'Expected format of the blob based on the extension.'
+ description: 'Expected format of the blob based on the extension.'
field :simple_viewer, type: Types::BlobViewerType,
- description: 'Blob content simple viewer.',
- null: false
+ description: 'Blob content simple viewer.',
+ null: false
field :rich_viewer, type: Types::BlobViewerType,
- description: 'Blob content rich viewer.',
- null: true
+ description: 'Blob content rich viewer.',
+ null: true
field :plain_data, GraphQL::Types::String,
description: 'Blob plain highlighted data.',
@@ -118,14 +118,14 @@ module Types
calls_gitaly: true
field :can_modify_blob, GraphQL::Types::Boolean, null: true, method: :can_modify_blob?,
- calls_gitaly: true,
- description: 'Whether the current user can modify the blob.'
+ calls_gitaly: true,
+ description: 'Whether the current user can modify the blob.'
field :can_current_user_push_to_branch, GraphQL::Types::Boolean, null: true, method: :can_current_user_push_to_branch?,
- description: 'Whether the current user can push to the branch.'
+ description: 'Whether the current user can push to the branch.'
field :archived, GraphQL::Types::Boolean, null: true, method: :archived?,
- description: 'Whether the current project is archived.'
+ description: 'Whether the current project is archived.'
field :language, GraphQL::Types::String,
description: 'Blob language.',
@@ -134,10 +134,10 @@ module Types
calls_gitaly: true
field :code_navigation_path, GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Web path for code navigation.'
+ description: 'Web path for code navigation.'
field :project_blob_path_root, GraphQL::Types::String, null: true,
- description: 'Web path for the root of the blob.'
+ description: 'Web path for the root of the blob.'
def raw_text_blob
object.data unless object.binary?
diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb
index aa02f0058da..ba94f59ab6c 100644
--- a/app/graphql/types/repository_type.rb
+++ b/app/graphql/types/repository_type.rb
@@ -7,24 +7,24 @@ module Types
authorize :download_code
field :blobs, Types::Repository::BlobType.connection_type, null: true, resolver: Resolvers::BlobsResolver, calls_gitaly: true,
- description: 'Blobs contained within the repository'
+ description: 'Blobs contained within the repository'
field :branch_names, [GraphQL::Types::String], null: true, calls_gitaly: true,
- complexity: 170, description: 'Names of branches available in this repository that match the search pattern.',
- resolver: Resolvers::RepositoryBranchNamesResolver
+ complexity: 170, description: 'Names of branches available in this repository that match the search pattern.',
+ resolver: Resolvers::RepositoryBranchNamesResolver
field :disk_path, GraphQL::Types::String,
description: 'Shows a disk path of the repository.',
null: true,
authorize: :read_storage_disk_path
field :empty, GraphQL::Types::Boolean, null: false, method: :empty?, calls_gitaly: true,
- description: 'Indicates repository has no visible content.'
+ description: 'Indicates repository has no visible content.'
field :exists, GraphQL::Types::Boolean, null: false, method: :exists?, calls_gitaly: true,
- description: 'Indicates a corresponding Git repository exists on disk.'
+ description: 'Indicates a corresponding Git repository exists on disk.'
field :paginated_tree, Types::Tree::TreeType.connection_type, null: true, resolver: Resolvers::PaginatedTreeResolver, calls_gitaly: true,
- max_page_size: 100,
- description: 'Paginated tree of the repository.'
+ max_page_size: 100,
+ description: 'Paginated tree of the repository.'
field :root_ref, GraphQL::Types::String, null: true, calls_gitaly: true,
- description: 'Default branch of the repository.'
+ description: 'Default branch of the repository.'
field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true,
- description: 'Tree of the repository.'
+ description: 'Tree of the repository.'
end
end
diff --git a/app/graphql/types/resolvable_interface.rb b/app/graphql/types/resolvable_interface.rb
index 42784aa5e00..2869d2cfd0f 100644
--- a/app/graphql/types/resolvable_interface.rb
+++ b/app/graphql/types/resolvable_interface.rb
@@ -17,12 +17,12 @@ module Types
end
field :resolved, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if the object is resolved.',
- method: :resolved?
+ description: 'Indicates if the object is resolved.',
+ method: :resolved?
field :resolvable, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if the object can be resolved.',
- method: :resolvable?
+ description: 'Indicates if the object can be resolved.',
+ method: :resolvable?
field :resolved_at, Types::TimeType, null: true,
- description: 'Timestamp of when the object was resolved.'
+ description: 'Timestamp of when the object was resolved.'
end
end
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index 7b96cc34941..5ee0500b1e0 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -54,28 +54,28 @@ module Types
null: false
field :web_url, type: GraphQL::Types::String,
- description: 'Web URL of the snippet.',
- null: false
+ description: 'Web URL of the snippet.',
+ null: false
field :raw_url, type: GraphQL::Types::String,
- description: 'Raw URL of the snippet.',
- null: false
+ description: 'Raw URL of the snippet.',
+ null: false
field :blobs, type: Types::Snippets::BlobType.connection_type,
- description: 'Snippet blobs.',
- calls_gitaly: true,
- null: true,
- resolver: Resolvers::Snippets::BlobsResolver
+ description: 'Snippet blobs.',
+ calls_gitaly: true,
+ null: true,
+ resolver: Resolvers::Snippets::BlobsResolver
field :ssh_url_to_repo, type: GraphQL::Types::String,
- description: 'SSH URL to the snippet repository.',
- calls_gitaly: true,
- null: true
+ description: 'SSH URL to the snippet repository.',
+ calls_gitaly: true,
+ null: true
field :http_url_to_repo, type: GraphQL::Types::String,
- description: 'HTTP URL to the snippet repository.',
- calls_gitaly: true,
- null: true
+ description: 'HTTP URL to the snippet repository.',
+ calls_gitaly: true,
+ null: true
markdown_field :description_html, null: true, method: :description
diff --git a/app/graphql/types/snippets/blob_connection_type.rb b/app/graphql/types/snippets/blob_connection_type.rb
index 15d26af7374..476a6f04b4a 100644
--- a/app/graphql/types/snippets/blob_connection_type.rb
+++ b/app/graphql/types/snippets/blob_connection_type.rb
@@ -4,7 +4,9 @@ module Types
module Snippets
# rubocop: disable Graphql/AuthorizeTypes
class BlobConnectionType < GraphQL::Types::Relay::BaseConnection
- field :has_unretrievable_blobs, GraphQL::Types::Boolean, null: false,
+ field :has_unretrievable_blobs,
+ GraphQL::Types::Boolean,
+ null: false,
description: 'Indicates if the snippet has unretrievable blobs.',
resolver_method: :unretrievable_blobs?
diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb
index 80702c71f63..bb4a0a64de8 100644
--- a/app/graphql/types/snippets/blob_type.rb
+++ b/app/graphql/types/snippets/blob_type.rb
@@ -44,25 +44,25 @@ module Types
null: true
field :simple_viewer, type: Types::Snippets::BlobViewerType,
- description: 'Blob content simple viewer.',
- null: false
+ description: 'Blob content simple viewer.',
+ null: false
field :rich_viewer, type: Types::Snippets::BlobViewerType,
- description: 'Blob content rich viewer.',
- null: true
+ description: 'Blob content rich viewer.',
+ null: true
field :mode, type: GraphQL::Types::String,
- description: 'Blob mode.',
- null: true
+ description: 'Blob mode.',
+ null: true
field :external_storage, type: GraphQL::Types::String,
- description: 'Blob external storage.',
- null: true
+ description: 'Blob external storage.',
+ null: true
field :rendered_as_text, type: GraphQL::Types::Boolean,
- description: 'Shows whether the blob is rendered as text.',
- method: :rendered_as_text?,
- null: false
+ description: 'Shows whether the blob is rendered as text.',
+ method: :rendered_as_text?,
+ null: false
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb
index de3f71090f6..9b5f028a857 100644
--- a/app/graphql/types/subscription_type.rb
+++ b/app/graphql/types/subscription_type.rb
@@ -5,15 +5,18 @@ module Types
graphql_name 'Subscription'
field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true,
- description: 'Triggered when the assignees of an issuable are updated.'
+ description: 'Triggered when the assignees of an issuable are updated.'
field :issue_crm_contacts_updated, subscription: Subscriptions::IssuableUpdated, null: true,
- description: 'Triggered when the crm contacts of an issuable are updated.'
+ description: 'Triggered when the crm contacts of an issuable are updated.'
field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true,
- description: 'Triggered when the title of an issuable is updated.'
+ description: 'Triggered when the title of an issuable is updated.'
field :issuable_labels_updated, subscription: Subscriptions::IssuableUpdated, null: true,
- description: 'Triggered when the labels of an issuable are updated.'
+ description: 'Triggered when the labels of an issuable are updated.'
+
+ field :issuable_dates_updated, subscription: Subscriptions::IssuableUpdated, null: true,
+ description: 'Triggered when the due date or start date of an issuable is updated.'
end
end
diff --git a/app/graphql/types/task_completion_status.rb b/app/graphql/types/task_completion_status.rb
index 9a979b04d37..c7da2f2cf01 100644
--- a/app/graphql/types/task_completion_status.rb
+++ b/app/graphql/types/task_completion_status.rb
@@ -9,9 +9,9 @@ module Types
description 'Completion status of tasks'
field :completed_count, GraphQL::Types::Int, null: false,
- description: 'Number of completed tasks.'
+ description: 'Number of completed tasks.'
field :count, GraphQL::Types::Int, null: false,
- description: 'Number of total tasks.'
+ description: 'Number of total tasks.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/time_tracking/timelog_category_type.rb b/app/graphql/types/time_tracking/timelog_category_type.rb
new file mode 100644
index 00000000000..c73a6fbd43b
--- /dev/null
+++ b/app/graphql/types/time_tracking/timelog_category_type.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Types
+ module TimeTracking
+ class TimelogCategoryType < BaseObject
+ graphql_name 'TimeTrackingTimelogCategory'
+
+ authorize :read_timelog_category
+
+ field :id,
+ GraphQL::Types::ID,
+ null: false,
+ description: 'Internal ID of the timelog category.'
+
+ field :name,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Name of the category.'
+
+ field :description,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Description of the category.'
+
+ field :color,
+ Types::ColorType,
+ null: true,
+ description: 'Color assigned to the category.'
+
+ field :billable,
+ GraphQL::Types::Boolean,
+ null: true,
+ description: 'Whether the category is billable or not.'
+
+ field :billing_rate,
+ GraphQL::Types::Float,
+ null: true,
+ description: 'Billing rate for the category.'
+
+ field :created_at,
+ Types::TimeType,
+ null: false,
+ description: 'When the category was created.'
+
+ field :updated_at,
+ Types::TimeType,
+ null: false,
+ description: 'When the category was last updated.'
+ end
+ end
+end
diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb
index 284542e1d2a..3db64d812c5 100644
--- a/app/graphql/types/tree/blob_type.rb
+++ b/app/graphql/types/tree/blob_type.rb
@@ -10,14 +10,14 @@ module Types
present_using BlobPresenter
field :lfs_oid, GraphQL::Types::String, null: true,
- calls_gitaly: true,
- description: 'LFS ID of the blob.'
+ calls_gitaly: true,
+ description: 'LFS ID of the blob.'
field :mode, GraphQL::Types::String, null: true,
- description: 'Blob mode in numeric format.'
+ description: 'Blob mode in numeric format.'
field :web_path, GraphQL::Types::String, null: true,
- description: 'Web path of the blob.'
+ description: 'Web path of the blob.'
field :web_url, GraphQL::Types::String, null: true,
- description: 'Web URL of the blob.'
+ description: 'Web URL of the blob.'
def lfs_oid
Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(object.repository, object.id).find
diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb
index 1c612f91a5b..4b4119dcab9 100644
--- a/app/graphql/types/tree/entry_type.rb
+++ b/app/graphql/types/tree/entry_type.rb
@@ -5,17 +5,17 @@ module Types
include Types::BaseInterface
field :id, GraphQL::Types::ID, null: false,
- description: 'ID of the entry.'
+ description: 'ID of the entry.'
field :sha, GraphQL::Types::String, null: false,
- description: 'Last commit SHA for the entry.', method: :id
+ description: 'Last commit SHA for the entry.', method: :id
field :name, GraphQL::Types::String, null: false,
- description: 'Name of the entry.'
+ description: 'Name of the entry.'
field :type, Tree::TypeEnum, null: false,
- description: 'Type of tree entry.'
+ description: 'Type of tree entry.'
field :path, GraphQL::Types::String, null: false,
- description: 'Path of the entry.'
+ description: 'Path of the entry.'
field :flat_path, GraphQL::Types::String, null: false,
- description: 'Flat path of the entry.'
+ description: 'Flat path of the entry.'
end
end
end
diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb
index 8f462011f0f..57597d9884c 100644
--- a/app/graphql/types/tree/submodule_type.rb
+++ b/app/graphql/types/tree/submodule_type.rb
@@ -9,9 +9,9 @@ module Types
implements Types::Tree::EntryType
field :tree_url, type: GraphQL::Types::String, null: true,
- description: 'Tree URL for the sub-module.'
+ description: 'Tree URL for the sub-module.'
field :web_url, type: GraphQL::Types::String, null: true,
- description: 'Web URL for the sub-module.'
+ description: 'Web URL for the sub-module.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb
index 28024fd010b..1de78250812 100644
--- a/app/graphql/types/tree/tree_entry_type.rb
+++ b/app/graphql/types/tree/tree_entry_type.rb
@@ -11,9 +11,9 @@ module Types
present_using TreeEntryPresenter
field :web_path, GraphQL::Types::String, null: true,
- description: 'Web path for the tree entry (directory).'
+ description: 'Web path for the tree entry (directory).'
field :web_url, GraphQL::Types::String, null: true,
- description: 'Web URL for the tree entry (directory).'
+ description: 'Web URL for the tree entry (directory).'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb
index 011cff0c89c..51dc8cdb7bb 100644
--- a/app/graphql/types/tree/tree_type.rb
+++ b/app/graphql/types/tree/tree_type.rb
@@ -12,15 +12,15 @@ module Types
description: 'Last commit for the tree.'
field :trees, Types::Tree::TreeEntryType.connection_type, null: false,
- description: 'Trees of the tree.'
+ description: 'Trees of the tree.'
field :submodules, Types::Tree::SubmoduleType.connection_type, null: false,
- description: 'Sub-modules of the tree.',
- calls_gitaly: true
+ description: 'Sub-modules of the tree.',
+ calls_gitaly: true
field :blobs, Types::Tree::BlobType.connection_type, null: false,
- description: 'Blobs of the tree.',
- calls_gitaly: true
+ description: 'Blobs of the tree.',
+ calls_gitaly: true
def trees
Gitlab::Graphql::Representation::TreeEntry.decorate(object.trees, object.repository)
diff --git a/app/graphql/types/upload_type.rb b/app/graphql/types/upload_type.rb
new file mode 100644
index 00000000000..0bb7f68cdba
--- /dev/null
+++ b/app/graphql/types/upload_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ class UploadType < BaseObject
+ graphql_name 'FileUpload'
+
+ authorize :read_upload
+
+ field :id, Types::GlobalIDType[::Upload],
+ null: false,
+ description: 'Global ID of the upload.'
+ field :path, GraphQL::Types::String,
+ null: false,
+ description: 'Path of the upload.'
+ field :size, GraphQL::Types::Int,
+ null: false,
+ description: 'Size of the upload in bytes.'
+ end
+end
diff --git a/app/graphql/types/user_callout_type.rb b/app/graphql/types/user_callout_type.rb
index 526027322ef..f509900e91d 100644
--- a/app/graphql/types/user_callout_type.rb
+++ b/app/graphql/types/user_callout_type.rb
@@ -5,8 +5,8 @@ module Types
graphql_name 'UserCallout'
field :dismissed_at, Types::TimeType, null: true,
- description: 'Date when the callout was dismissed.'
+ description: 'Date when the callout was dismissed.'
field :feature_name, UserCalloutFeatureNameEnum, null: true,
- description: 'Name of the feature that the callout is for.'
+ description: 'Name of the feature that the callout is for.'
end
end
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index edbc8aee9c5..f49b3eee4f5 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -122,13 +122,15 @@ module Types
'Will not return saved replies if `saved_replies` feature flag is disabled.'
field :gitpod_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Whether Gitpod is enabled at the user level.'
+ description: 'Whether Gitpod is enabled at the user level.'
- field :preferences_gitpod_path, GraphQL::Types::String, null: true,
+ field :preferences_gitpod_path,
+ GraphQL::Types::String,
+ null: true,
description: 'Web path to the Gitpod section within user preferences.'
field :profile_enable_gitpod_path, GraphQL::Types::String, null: true,
- description: 'Web path to enable Gitpod for the user.'
+ description: 'Web path to enable Gitpod for the user.'
definition_methods do
def resolve_type(object, context)
diff --git a/app/graphql/types/user_status_type.rb b/app/graphql/types/user_status_type.rb
index 68c00bffe48..199c7d31083 100644
--- a/app/graphql/types/user_status_type.rb
+++ b/app/graphql/types/user_status_type.rb
@@ -6,12 +6,12 @@ module Types
graphql_name 'UserStatus'
markdown_field :message_html, null: true,
- description: 'HTML of the user status message'
+ description: 'HTML of the user status message'
field :availability, Types::AvailabilityEnum, null: false,
- description: 'User availability status.'
+ description: 'User availability status.'
field :emoji, GraphQL::Types::String, null: true,
- description: 'String representation of emoji.'
+ description: 'String representation of emoji.'
field :message, GraphQL::Types::String, null: true,
- description: 'User status message.'
+ description: 'User status message.'
end
end
diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb
index 18b9bfd1c9a..7904841863b 100644
--- a/app/graphql/types/work_item_type.rb
+++ b/app/graphql/types/work_item_type.rb
@@ -6,22 +6,37 @@ module Types
authorize :read_work_item
+ field :closed_at, Types::TimeType, null: true,
+ description: 'Timestamp of when the work item was closed.'
+ field :confidential, GraphQL::Types::Boolean, null: false,
+ description: 'Indicates the work item is confidential.'
+ field :created_at, Types::TimeType, null: false,
+ description: 'Timestamp of when the work item was created.'
field :description, GraphQL::Types::String, null: true,
- description: 'Description of the work item.'
+ description: 'Description of the work item.'
field :id, Types::GlobalIDType[::WorkItem], null: false,
- description: 'Global ID of the work item.'
+ description: 'Global ID of the work item.'
field :iid, GraphQL::Types::ID, null: false,
- description: 'Internal ID of the work item.'
- field :lock_version, GraphQL::Types::Int, null: false,
+ description: 'Internal ID of the work item.'
+ field :lock_version,
+ GraphQL::Types::Int,
+ null: false,
description: 'Lock version of the work item. Incremented each time the work item is updated.'
+ field :project, Types::ProjectType, null: false,
+ description: 'Project the work item belongs to.',
+ alpha: { milestone: '15.3' }
field :state, WorkItemStateEnum, null: false,
- description: 'State of the work item.'
+ description: 'State of the work item.'
field :title, GraphQL::Types::String, null: false,
- description: 'Title of the work item.'
- field :widgets, [Types::WorkItems::WidgetInterface], null: true,
+ description: 'Title of the work item.'
+ field :updated_at, Types::TimeType, null: false,
+ description: 'Timestamp of when the work item was last updated.'
+ field :widgets,
+ [Types::WorkItems::WidgetInterface],
+ null: true,
description: 'Collection of widgets that belong to the work item.'
field :work_item_type, Types::WorkItems::TypeType, null: false,
- description: 'Type assigned to the work item.'
+ description: 'Type assigned to the work item.'
markdown_field :title_html, null: true
markdown_field :description_html, null: true
diff --git a/app/graphql/types/work_items/type_type.rb b/app/graphql/types/work_items/type_type.rb
index f31bd7ee9ba..4d008a21b9c 100644
--- a/app/graphql/types/work_items/type_type.rb
+++ b/app/graphql/types/work_items/type_type.rb
@@ -8,11 +8,11 @@ module Types
authorize :read_work_item_type
field :icon_name, GraphQL::Types::String, null: true,
- description: 'Icon name of the work item type.'
+ description: 'Icon name of the work item type.'
field :id, Types::GlobalIDType[::WorkItems::Type], null: false,
- description: 'Global ID of the work item type.'
+ description: 'Global ID of the work item type.'
field :name, GraphQL::Types::String, null: false,
- description: 'Name of the work item type.'
+ description: 'Name of the work item type.'
end
end
end
diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb
index 1b752393296..eca8c8d845a 100644
--- a/app/graphql/types/work_items/widget_interface.rb
+++ b/app/graphql/types/work_items/widget_interface.rb
@@ -7,8 +7,21 @@ module Types
graphql_name 'WorkItemWidget'
- field :type, ::Types::WorkItems::WidgetTypeEnum, null: true,
- description: 'Widget type.'
+ field :type, ::Types::WorkItems::WidgetTypeEnum,
+ null: true,
+ description: 'Widget type.'
+
+ ORPHAN_TYPES = [
+ ::Types::WorkItems::Widgets::DescriptionType,
+ ::Types::WorkItems::Widgets::HierarchyType,
+ ::Types::WorkItems::Widgets::LabelsType,
+ ::Types::WorkItems::Widgets::AssigneesType,
+ ::Types::WorkItems::Widgets::StartAndDueDateType
+ ].freeze
+
+ def self.ce_orphan_types
+ ORPHAN_TYPES
+ end
def self.resolve_type(object, context)
case object
@@ -18,17 +31,18 @@ module Types
::Types::WorkItems::Widgets::HierarchyType
when ::WorkItems::Widgets::Assignees
::Types::WorkItems::Widgets::AssigneesType
- when ::WorkItems::Widgets::Weight
- ::Types::WorkItems::Widgets::WeightType
+ when ::WorkItems::Widgets::Labels
+ ::Types::WorkItems::Widgets::LabelsType
+ when ::WorkItems::Widgets::StartAndDueDate
+ ::Types::WorkItems::Widgets::StartAndDueDateType
else
raise "Unknown GraphQL type for widget #{object}"
end
end
- orphan_types ::Types::WorkItems::Widgets::DescriptionType,
- ::Types::WorkItems::Widgets::HierarchyType,
- ::Types::WorkItems::Widgets::AssigneesType,
- ::Types::WorkItems::Widgets::WeightType
+ orphan_types(*ORPHAN_TYPES)
end
end
end
+
+Types::WorkItems::WidgetInterface.prepend_mod
diff --git a/app/graphql/types/work_items/widgets/assignees_input_type.rb b/app/graphql/types/work_items/widgets/assignees_input_type.rb
new file mode 100644
index 00000000000..ee61bc73054
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/assignees_input_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class AssigneesInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetAssigneesInput'
+
+ argument :assignee_ids, [::Types::GlobalIDType[::User]],
+ required: true,
+ description: 'Global IDs of assignees.',
+ prepare: ->(ids, _) { ids.map(&:model_id) }
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/assignees_type.rb b/app/graphql/types/work_items/widgets/assignees_type.rb
index 08ee06fdfa0..74da3264567 100644
--- a/app/graphql/types/work_items/widgets/assignees_type.rb
+++ b/app/graphql/types/work_items/widgets/assignees_type.rb
@@ -12,14 +12,17 @@ module Types
implements Types::WorkItems::WidgetInterface
- field :assignees, Types::UserType.connection_type, null: true,
- description: 'Assignees of the work item.'
+ field :assignees, Types::UserType.connection_type,
+ null: true,
+ description: 'Assignees of the work item.'
- field :allows_multiple_assignees, GraphQL::Types::Boolean, null: true, method: :allows_multiple_assignees?,
- description: 'Indicates whether multiple assignees are allowed.'
+ field :allows_multiple_assignees, GraphQL::Types::Boolean,
+ null: true, method: :allows_multiple_assignees?,
+ description: 'Indicates whether multiple assignees are allowed.'
- field :can_invite_members, GraphQL::Types::Boolean, null: false, resolver_method: :can_invite_members?,
- description: 'Indicates whether the current user can invite members to the work item\'s project.'
+ field :can_invite_members, GraphQL::Types::Boolean,
+ null: false, resolver_method: :can_invite_members?,
+ description: 'Indicates whether the current user can invite members to the work item\'s project.'
def can_invite_members?
Ability.allowed?(current_user, :admin_project_member, object.work_item.project)
diff --git a/app/graphql/types/work_items/widgets/description_type.rb b/app/graphql/types/work_items/widgets/description_type.rb
index 79192d7c3d4..4c365a67bfd 100644
--- a/app/graphql/types/work_items/widgets/description_type.rb
+++ b/app/graphql/types/work_items/widgets/description_type.rb
@@ -12,8 +12,9 @@ module Types
implements Types::WorkItems::WidgetInterface
- field :description, GraphQL::Types::String, null: true,
- description: 'Description of the work item.'
+ field :description, GraphQL::Types::String,
+ null: true,
+ description: 'Description of the work item.'
markdown_field :description_html, null: true do |resolved_object|
resolved_object.work_item
diff --git a/app/graphql/types/work_items/widgets/hierarchy_type.rb b/app/graphql/types/work_items/widgets/hierarchy_type.rb
index 057d5fbf056..0ccd8af7dc8 100644
--- a/app/graphql/types/work_items/widgets/hierarchy_type.rb
+++ b/app/graphql/types/work_items/widgets/hierarchy_type.rb
@@ -12,13 +12,13 @@ module Types
implements Types::WorkItems::WidgetInterface
- field :parent, ::Types::WorkItemType, null: true,
- description: 'Parent work item.',
- complexity: 5
+ field :parent, ::Types::WorkItemType,
+ null: true, complexity: 5,
+ description: 'Parent work item.'
- field :children, ::Types::WorkItemType.connection_type, null: true,
- description: 'Child work items.',
- complexity: 5
+ field :children, ::Types::WorkItemType.connection_type,
+ null: true, complexity: 5,
+ description: 'Child work items.'
def children
object.children.inc_relations_for_permission_check
diff --git a/app/graphql/types/work_items/widgets/labels_type.rb b/app/graphql/types/work_items/widgets/labels_type.rb
new file mode 100644
index 00000000000..20574b3e3bc
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/labels_type.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ # Disabling widget level authorization as it might be too granular
+ # and we already authorize the parent work item
+ # rubocop:disable Graphql/AuthorizeTypes
+ class LabelsType < BaseObject
+ graphql_name 'WorkItemWidgetLabels'
+ description 'Represents the labels widget'
+
+ implements Types::WorkItems::WidgetInterface
+
+ field :labels, Types::LabelType.connection_type,
+ null: true,
+ description: 'Labels assigned to the work item.'
+
+ field :allows_scoped_labels, GraphQL::Types::Boolean,
+ null: true,
+ method: :allows_scoped_labels?,
+ description: 'Indicates whether a scoped label is allowed.'
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/start_and_due_date_type.rb b/app/graphql/types/work_items/widgets/start_and_due_date_type.rb
new file mode 100644
index 00000000000..d4dbc969937
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/start_and_due_date_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ # Disabling widget level authorization as it might be too granular
+ # and we already authorize the parent work item
+ # rubocop:disable Graphql/AuthorizeTypes
+ class StartAndDueDateType < BaseObject
+ graphql_name 'WorkItemWidgetStartAndDueDate'
+ description 'Represents a start and due date widget'
+
+ implements Types::WorkItems::WidgetInterface
+
+ field :due_date, Types::DateType,
+ null: true,
+ description: 'Due date of the work item.'
+ field :start_date, Types::DateType,
+ null: true,
+ description: 'Start date of the work item.'
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/start_and_due_date_update_input_type.rb b/app/graphql/types/work_items/widgets/start_and_due_date_update_input_type.rb
new file mode 100644
index 00000000000..bccd4afe8f3
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/start_and_due_date_update_input_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class StartAndDueDateUpdateInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetStartAndDueDateUpdateInput'
+
+ argument :due_date, Types::DateType,
+ required: false,
+ description: 'Due date for the work item.'
+ argument :start_date, Types::DateType,
+ required: false,
+ description: 'Start date for the work item.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/weight_input_type.rb b/app/graphql/types/work_items/widgets/weight_input_type.rb
deleted file mode 100644
index a01c63222a5..00000000000
--- a/app/graphql/types/work_items/widgets/weight_input_type.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module Types
- module WorkItems
- module Widgets
- class WeightInputType < BaseInputObject
- graphql_name 'WorkItemWidgetWeightInput'
-
- argument :weight, GraphQL::Types::Int,
- required: true,
- description: 'Weight of the work item.'
- end
- end
- end
-end
diff --git a/app/graphql/types/work_items/widgets/weight_type.rb b/app/graphql/types/work_items/widgets/weight_type.rb
deleted file mode 100644
index c8eaf560268..00000000000
--- a/app/graphql/types/work_items/widgets/weight_type.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-module Types
- module WorkItems
- module Widgets
- # Disabling widget level authorization as it might be too granular
- # and we already authorize the parent work item
- # rubocop:disable Graphql/AuthorizeTypes
- class WeightType < BaseObject
- graphql_name 'WorkItemWidgetWeight'
- description 'Represents a weight widget'
-
- implements Types::WorkItems::WidgetInterface
-
- field :weight, GraphQL::Types::Int, null: true,
- description: 'Weight of the work item.'
- end
- # rubocop:enable Graphql/AuthorizeTypes
- end
- end
-end
diff --git a/app/helpers/admin/identities_helper.rb b/app/helpers/admin/identities_helper.rb
new file mode 100644
index 00000000000..48e01840394
--- /dev/null
+++ b/app/helpers/admin/identities_helper.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Admin
+ module IdentitiesHelper
+ def label_for_identity_provider(identity)
+ provider = identity.provider
+ "#{Gitlab::Auth::OAuth::Provider.label_for(provider)} (#{provider})"
+ end
+
+ def provider_id_cell_testid(identity)
+ 'provider_id_blank'
+ end
+
+ def provider_id(identity)
+ '-'
+ end
+
+ def saml_group_cell_testid(identity)
+ 'saml_group_blank'
+ end
+
+ def saml_group_link(identity)
+ '-'
+ end
+
+ def identity_cells_to_render?(identities, _user)
+ identities.present?
+ end
+
+ def scim_identities_collection(_user)
+ []
+ end
+ end
+end
+
+Admin::IdentitiesHelper.prepend_mod
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index d2cc50be509..a75c1b16145 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -19,23 +19,23 @@ module ApplicationHelper
def dispensable_render(...)
render(...)
- rescue StandardError => error
+ rescue StandardError => e
if Feature.enabled?(:dispensable_render)
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
nil
else
- raise error
+ raise e
end
end
def dispensable_render_if_exists(...)
render_if_exists(...)
- rescue StandardError => error
+ rescue StandardError => e
if Feature.enabled?(:dispensable_render)
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
nil
else
- raise error
+ raise e
end
end
@@ -223,6 +223,16 @@ module ApplicationHelper
ApplicationHelper.promo_host
end
+ # This needs to be used outside of Rails
+ def self.community_forum
+ 'https://forum.gitlab.com'
+ end
+
+ # Convenient method for Rails helper
+ def community_forum
+ ApplicationHelper.community_forum
+ end
+
def promo_url
'https://' + promo_host
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 9dc93779b12..617bc0e9bee 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -84,9 +84,9 @@ module AvatarsHelper
end
image_options = {
- alt: alt_text,
- src: avatar_url,
- data: data_attributes,
+ alt: alt_text,
+ src: avatar_url,
+ data: data_attributes,
class: css_class,
title: user_name
}
diff --git a/app/helpers/badges_helper.rb b/app/helpers/badges_helper.rb
index 26ebe8a6470..d48eae26a90 100644
--- a/app/helpers/badges_helper.rb
+++ b/app/helpers/badges_helper.rb
@@ -8,13 +8,13 @@ module BadgesHelper
success: "badge-success",
warning: "badge-warning",
danger: "badge-danger"
- }.tap { |hash| hash.default = hash.fetch(:muted) } .freeze
+ }.tap { |hash| hash.default = hash.fetch(:muted) }.freeze
SIZE_CLASSES = {
sm: "sm",
md: "md",
lg: "lg"
- }.tap { |hash| hash.default = hash.fetch(:md) } .freeze
+ }.tap { |hash| hash.default = hash.fetch(:md) }.freeze
GL_BADGE_CLASSES = %w[gl-badge badge badge-pill].freeze
@@ -53,7 +53,7 @@ module BadgesHelper
#
# See also https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-badge--default.
def gl_badge_tag(*args, &block)
- if block_given?
+ if block
build_gl_badge_tag(capture(&block), *args)
else
build_gl_badge_tag(*args)
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index fcf6a177984..2c84da4862a 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -98,9 +98,9 @@ module BlobHelper
ref,
path,
blob: blob,
- label: _("Replace"),
- action: "replace",
- btn_class: "default",
+ label: _("Replace"),
+ action: "replace",
+ btn_class: "default",
modal_type: "upload"
)
end
@@ -111,9 +111,9 @@ module BlobHelper
ref,
path,
blob: blob,
- label: _("Delete"),
- action: "delete",
- btn_class: "default",
+ label: _("Delete"),
+ action: "delete",
+ btn_class: "default",
modal_type: "remove"
)
end
@@ -298,7 +298,9 @@ module BlobHelper
def readable_blob(options, path, project, ref)
blob = options.fetch(:blob) do
- project.repository.blob_at(ref, path) rescue nil
+ project.repository.blob_at(ref, path)
+ rescue StandardError
+ nil
end
blob if blob&.readable_text?
diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
index d044a93213a..d00301678dd 100644
--- a/app/helpers/ci/pipeline_editor_helper.rb
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -32,8 +32,7 @@ module Ci
"project-path" => project.path,
"project-full-path" => project.full_path,
"project-namespace" => project.namespace.full_path,
- "runner-help-page-path" => help_page_path('ci/runners/index'),
- "simulate-pipeline-help-page-path" => help_page_path('ci/lint', anchor: 'simulate-a-pipeline'),
+ "simulate-pipeline-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'simulate-a-cicd-pipeline'),
"total-branches" => total_branches,
"validate-tab-illustration-path" => image_path('illustrations/project-run-CICD-pipelines-sm.svg'),
"yml-help-page-path" => help_page_path('ci/yaml/index')
diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb
index 7722677e503..a67771116b9 100644
--- a/app/helpers/ci/pipelines_helper.rb
+++ b/app/helpers/ci/pipelines_helper.rb
@@ -40,14 +40,14 @@ module Ci
{ name: 'Crystal', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/crystal.svg') },
{ name: 'Dart', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/dart.svg') },
{ name: 'Django', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/django.svg') },
- { name: 'Docker', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/docker.svg') },
+ { name: 'Docker', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/docker.png') },
{ name: 'Elixir', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/elixir.svg') },
{ name: 'iOS-Fastlane', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/fastlane.svg'), title: 'iOS with Fastlane' },
{ name: 'Flutter', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/flutter.svg') },
{ name: 'Go', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/go_logo.svg') },
{ name: 'Gradle', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/gradle.svg') },
{ name: 'Grails', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/grails.svg') },
- { name: 'dotNET', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/dotnet.svg') },
+ { name: 'dotNET', logo: image_path('illustrations/third-party-logos/dotnet.svg') },
{ name: 'Julia', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/julia.svg') },
{ name: 'Laravel', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/laravel.svg') },
{ name: 'LaTeX', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/latex.svg') },
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 74318797069..852eaeca5e3 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -73,7 +73,7 @@ module Ci
def group_shared_runners_settings_data(group)
{
- update_path: api_v4_groups_path(id: group.id),
+ group_id: group.id,
shared_runners_setting: group.shared_runners_setting,
parent_shared_runners_setting: group.parent&.shared_runners_setting,
runner_enabled_value: Namespace::SR_ENABLED,
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 33b771eef69..1920650bc93 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -28,7 +28,7 @@ module CommitsHelper
def commit_to_html(commit, ref, project)
render partial: 'projects/commits/commit', formats: :html,
- locals: {
+ locals: {
commit: commit,
ref: ref,
project: project
@@ -137,7 +137,12 @@ module CommitsHelper
def conditionally_paginate_diff_files(diffs, paginate:, page:, per:)
if paginate
- Kaminari.paginate_array(diffs.diff_files.to_a).page(page).per(per)
+ diff_files = diffs.diff_files.to_a
+ Gitlab::Utils::BatchLoader.clear_key([:repository_blobs, diffs.project.repository])
+
+ Kaminari.paginate_array(diff_files).page(page).per(per).tap do |diff_files|
+ diff_files.each(&:add_blobs_to_batch_loader)
+ end
else
diffs.diff_files
end
diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb
index f9d62747308..e955ad4cfda 100644
--- a/app/helpers/compare_helper.rb
+++ b/app/helpers/compare_helper.rb
@@ -1,25 +1,30 @@
# frozen_string_literal: true
module CompareHelper
- def create_mr_button?(from: params[:from], to: params[:to], source_project: @project, target_project: @target_project)
+ def create_mr_button?(source_project:, from:, to: nil, target_project: nil)
+ target_project ||= source_project.default_merge_request_target
+ to ||= target_project.default_branch
+
from.present? &&
to.present? &&
from != to &&
can?(current_user, :create_merge_request_from, source_project) &&
can?(current_user, :create_merge_request_in, target_project) &&
- target_project.repository.branch_exists?(from) &&
- source_project.repository.branch_exists?(to)
+ target_project.repository.branch_exists?(to) &&
+ source_project.repository.branch_exists?(from)
end
- def create_mr_path(from: params[:from], to: params[:to], source_project: @project, target_project: @target_project)
+ def create_mr_path(from:, source_project:, to: nil, target_project: nil, mr_params: {})
+ merge_request_params = {
+ source_branch: from
+ }
+
+ merge_request_params[:target_project_id] = target_project.id if target_project
+ merge_request_params[:target_branch] = to if to
+
project_new_merge_request_path(
- target_project,
- merge_request: {
- source_project_id: source_project.id,
- source_branch: to,
- target_project_id: target_project.id,
- target_branch: from
- }
+ source_project,
+ merge_request: merge_request_params.merge(mr_params)
)
end
@@ -32,14 +37,32 @@ module CompareHelper
def project_compare_selector_data(project, merge_request, params)
{
project_compare_index_path: project_compare_index_path(project),
- refs_project_path: refs_project_path(project),
+ source_project: { id: project.id, name: project.full_path }.to_json,
+ target_project: { id: @target_project.id, name: @target_project.full_path }.to_json,
+ source_project_refs_path: refs_project_path(project),
+ target_project_refs_path: refs_project_path(@target_project),
params_from: params[:from],
- params_to: params[:to],
- project_merge_request_path: merge_request.present? ? project_merge_request_path(project, merge_request) : '',
- create_mr_path: create_mr_button? ? create_mr_path : ''
+ params_to: params[:to]
}.tap do |data|
- data[:project_to] = { id: project.id, name: project.full_path }.to_json
- data[:projects_from] = target_projects(project).map { |project| { id: project.id, name: project.full_path } }.to_json
+ data[:projects_from] = target_projects(project).map do |target_project|
+ { id: target_project.id, name: target_project.full_path }
+ end.to_json
+
+ data[:project_merge_request_path] =
+ if merge_request.present?
+ project_merge_request_path(project, merge_request)
+ else
+ ''
+ end
+
+ # The `from` and `to` params are inverted in the compare page. The route is `/compare/:from...:to`, but the UI
+ # correctly shows `:to` as the "Source" (i.e. the `from` for MR), and `:from` as "Target" (i.e. the `to` for MR).
+ data[:create_mr_path] =
+ if create_mr_button?(from: params[:to], to: params[:from], source_project: project, target_project: @target_project)
+ create_mr_path(from: params[:to], to: params[:from], source_project: project, target_project: @target_project)
+ else
+ ''
+ end
end
end
end
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index bcb1f63840d..f0e1f252917 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -15,10 +15,6 @@ module DashboardHelper
merge_requests_dashboard_path(reviewer_username: current_user.username)
end
- def attention_requested_mrs_dashboard_path
- merge_requests_dashboard_path(attention: current_user.username)
- end
-
def dashboard_nav_links
@dashboard_nav_links ||= get_dashboard_nav_links
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 2623e32dbc8..333237db6a4 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -58,17 +58,17 @@ module EnvironmentsHelper
return {} unless project
{
- 'settings_path' => edit_project_settings_integration_path(project, 'prometheus'),
- 'clusters_path' => project_clusters_path(project),
- 'dashboards_endpoint' => project_performance_monitoring_dashboards_path(project, format: :json),
- 'default_branch' => project.default_branch,
- 'project_path' => project_path(project),
- 'tags_path' => project_tags_path(project),
- 'external_dashboard_url' => project.metrics_setting_external_dashboard_url,
- 'custom_metrics_path' => project_prometheus_metrics_path(project),
- 'validate_query_path' => validate_query_project_prometheus_metrics_path(project),
- 'custom_metrics_available' => "#{custom_metrics_available?(project)}",
- 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase
+ 'settings_path' => edit_project_settings_integration_path(project, 'prometheus'),
+ 'clusters_path' => project_clusters_path(project),
+ 'dashboards_endpoint' => project_performance_monitoring_dashboards_path(project, format: :json),
+ 'default_branch' => project.default_branch,
+ 'project_path' => project_path(project),
+ 'tags_path' => project_tags_path(project),
+ 'external_dashboard_url' => project.metrics_setting_external_dashboard_url,
+ 'custom_metrics_path' => project_prometheus_metrics_path(project),
+ 'validate_query_path' => validate_query_project_prometheus_metrics_path(project),
+ 'custom_metrics_available' => "#{custom_metrics_available?(project)}",
+ 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase
}
end
@@ -77,9 +77,9 @@ module EnvironmentsHelper
{
'metrics_dashboard_base_path' => metrics_dashboard_base_path(environment, project),
- 'current_environment_name' => environment.name,
- 'has_metrics' => "#{environment.has_metrics?}",
- 'environment_state' => "#{environment.state}"
+ 'current_environment_name' => environment.name,
+ 'has_metrics' => "#{environment.has_metrics?}",
+ 'environment_state' => "#{environment.state}"
}
end
@@ -98,8 +98,8 @@ module EnvironmentsHelper
return {} unless project && environment
{
- 'metrics_endpoint' => additional_metrics_project_environment_path(project, environment, format: :json),
- 'dashboard_endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json),
+ 'metrics_endpoint' => additional_metrics_project_environment_path(project, environment, format: :json),
+ 'dashboard_endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json),
'deployments_endpoint' => project_environment_deployments_path(project, environment, format: :json),
'operations_settings_path' => project_settings_operations_path(project),
'can_access_operations_settings' => can?(current_user, :admin_operations, project).to_s,
@@ -109,14 +109,14 @@ module EnvironmentsHelper
def static_metrics_data
{
- 'documentation_path' => help_page_path('administration/monitoring/prometheus/index.md'),
+ 'documentation_path' => help_page_path('administration/monitoring/prometheus/index.md'),
'add_dashboard_documentation_path' => help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'),
- 'empty_getting_started_svg_path' => image_path('illustrations/monitoring/getting_started.svg'),
- 'empty_loading_svg_path' => image_path('illustrations/monitoring/loading.svg'),
- 'empty_no_data_svg_path' => image_path('illustrations/monitoring/no_data.svg'),
- 'empty_no_data_small_svg_path' => image_path('illustrations/chart-empty-state-small.svg'),
+ 'empty_getting_started_svg_path' => image_path('illustrations/monitoring/getting_started.svg'),
+ 'empty_loading_svg_path' => image_path('illustrations/monitoring/loading.svg'),
+ 'empty_no_data_svg_path' => image_path('illustrations/monitoring/no_data.svg'),
+ 'empty_no_data_small_svg_path' => image_path('illustrations/chart-empty-state-small.svg'),
'empty_unable_to_connect_svg_path' => image_path('illustrations/monitoring/unable_to_connect.svg'),
- 'custom_dashboard_base_path' => Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT
+ 'custom_dashboard_base_path' => Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT
}
end
end
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 4ee3acd32d2..b35dc3b00cb 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -38,7 +38,7 @@ module EventsHelper
active = 'active' if @event_filter.active?(key)
link_opts = {
class: "event-filter-link",
- id: "#{key}_event_filter",
+ id: "#{key}_event_filter",
title: tooltip
}
diff --git a/app/helpers/favicon_helper.rb b/app/helpers/favicon_helper.rb
index 4a809731d97..c98c7c4909a 100644
--- a/app/helpers/favicon_helper.rb
+++ b/app/helpers/favicon_helper.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
module FaviconHelper
- def favicon_extension_whitelist
- FaviconUploader::EXTENSION_WHITELIST
- .map { |extension| "'.#{extension}'"}
+ def favicon_extension_allowlist
+ FaviconUploader::EXTENSION_ALLOWLIST
+ .map { |extension| "'.#{extension}'" }
.to_sentence
end
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 17812aed3ff..f74eeeb8c6a 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module FormHelper
- def form_errors(model, type: 'form', truncate: [], pajamas_alert: false)
+ def form_errors(model, type: 'form', truncate: [], pajamas_alert: true)
errors = model.errors
return unless errors.any?
@@ -25,26 +25,27 @@ module FormHelper
tag.li(message)
end.join.html_safe
- if pajamas_alert
- render Pajamas::AlertComponent.new(
- variant: :danger,
- title: headline,
- dismissible: false,
- alert_options: { id: 'error_explanation', class: 'gl-mb-5' }
- ) do |c|
- c.body do
- tag.ul(class: 'gl-pl-5 gl-mb-0') do
- messages
- end
+ render Pajamas::AlertComponent.new(
+ variant: :danger,
+ title: headline,
+ dismissible: false,
+ alert_options: { id: 'error_explanation', class: 'gl-mb-5' }
+ ) do |c|
+ c.body do
+ tag.ul(class: 'gl-pl-5 gl-mb-0') do
+ messages
end
end
+ end
+ end
+
+ def dropdown_max_select(data)
+ return data[:'max-select'] unless Feature.enabled?(:limit_reviewer_and_assignee_size)
+
+ if data[:'max-select'] && data[:'max-select'] < MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
+ data[:'max-select']
else
- tag.div(class: 'alert alert-danger', id: 'error_explanation') do
- tag.h4(headline) <<
- tag.ul do
- messages
- end
- end
+ MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
end
end
@@ -165,7 +166,12 @@ module FormHelper
new_options[:title] = _('Select reviewer(s)')
new_options[:data][:'dropdown-header'] = _('Reviewer(s)')
- new_options[:data].delete(:'max-select')
+
+ if Feature.enabled?(:limit_reviewer_and_assignee_size)
+ new_options[:data][:'max-select'] = MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
+ else
+ new_options[:data].delete(:'max-select')
+ end
new_options
end
diff --git a/app/helpers/gitlab_script_tag_helper.rb b/app/helpers/gitlab_script_tag_helper.rb
index f784bb69dd8..55653c592e5 100644
--- a/app/helpers/gitlab_script_tag_helper.rb
+++ b/app/helpers/gitlab_script_tag_helper.rb
@@ -7,7 +7,9 @@ module GitlabScriptTagHelper
# The helper also makes sure the `nonce` attribute is included in every script when the content security
# policy is enabled.
def javascript_include_tag(*sources)
- super(*sources, defer: true, nonce: true)
+ options = { defer: true }.merge(sources.extract_options!)
+ options[:nonce] = true
+ super(*sources, **options)
end
# The helper makes sure the `nonce` attribute is included in every script when the content security
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index 2021961772a..6a013a6c864 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -21,10 +21,11 @@ module Groups::GroupMembersHelper
end
def group_member_header_subtext(group)
- html_escape(_('You can invite a new member to ' \
- '%{strong_start}%{group_name}%{strong_end}.')) % { group_name: group.name,
- strong_start: '<strong>'.html_safe,
- strong_end: '</strong>'.html_safe }
+ 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
+ }
end
private
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 9d152416b2e..bb92792de2d 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -134,6 +134,13 @@ module GroupsHelper
@group_projects_sort || @sort || params[:sort] || sort_value_recently_created
end
+ def subgroup_creation_data(group)
+ {
+ parent_group_name: group.parent&.name,
+ import_existing_group_path: new_group_path(parent_id: group.parent_id, anchor: 'import-group-pane')
+ }
+ end
+
def verification_for_group_creation_data
# overridden in EE
{}
@@ -144,11 +151,9 @@ module GroupsHelper
false
end
- def group_name_and_path_app_data(group)
- parent = group.parent
-
+ def group_name_and_path_app_data
{
- base_path: URI.join(root_url, parent&.full_path || "").to_s,
+ base_path: root_url,
mattermost_enabled: Gitlab.config.mattermost.enabled.to_s
}
end
@@ -156,7 +161,7 @@ module GroupsHelper
def subgroups_and_projects_list_app_data(group)
{
show_schema_markup: 'true',
- new_subgroup_path: new_group_path(parent_id: group.id),
+ new_subgroup_path: new_group_path(parent_id: group.id, anchor: 'create-group-pane'),
new_project_path: new_project_path(namespace_id: group.id),
new_subgroup_illustration: image_path('illustrations/subgroup-create-new-sm.svg'),
new_project_illustration: image_path('illustrations/project-create-new-sm.svg'),
@@ -182,7 +187,7 @@ module GroupsHelper
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
- icon = group_icon(group, class: "avatar-tile", width: 15, height: 15) if (group.try(:avatar_url) || show_avatar) && !Rails.env.test?
+ icon = group_icon(group, alt: group.name, class: "avatar-tile", width: 15, height: 15) if group.try(:avatar_url) || show_avatar
[icon, simple_sanitize(group.name)].join.html_safe
end
end
diff --git a/app/helpers/instance_configuration_helper.rb b/app/helpers/instance_configuration_helper.rb
index b06e3ff2904..7eb14d31dc7 100644
--- a/app/helpers/instance_configuration_helper.rb
+++ b/app/helpers/instance_configuration_helper.rb
@@ -4,7 +4,7 @@ module InstanceConfigurationHelper
def instance_configuration_cell_html(value, &block)
return '-' unless value.to_s.presence
- block_given? ? yield(value) : value
+ block ? yield(value) : value
end
def instance_configuration_host(host)
diff --git a/app/helpers/issuables_description_templates_helper.rb b/app/helpers/issuables_description_templates_helper.rb
index a82a5ac0fb0..58b86dca1e0 100644
--- a/app/helpers/issuables_description_templates_helper.rb
+++ b/app/helpers/issuables_description_templates_helper.rb
@@ -5,8 +5,12 @@ module IssuablesDescriptionTemplatesHelper
include GitlabRoutingHelper
def template_dropdown_tag(issuable, &block)
- selected_template = selected_template(issuable)
- title = selected_template || _('Choose a template')
+ template_names = template_names(issuable)
+
+ selected_template = selected_template_name(template_names)
+ default_template = default_template_name(template_names, issuable)
+ title = _('Choose a template')
+
options = {
toggle_class: 'js-issuable-selector',
title: title,
@@ -17,6 +21,7 @@ module IssuablesDescriptionTemplatesHelper
data: issuable_templates(ref_project, issuable.to_ability_name),
field_name: 'issuable_template',
selected: selected_template,
+ default: default_template,
project_id: ref_project.id
}
}
@@ -32,19 +37,19 @@ module IssuablesDescriptionTemplatesHelper
@template_types[project.id][issuable_type] ||= TemplateFinder.all_template_names(project, issuable_type.pluralize)
end
- def selected_template(issuable)
- all_templates = issuable_templates(ref_project, issuable.to_ability_name)
-
- # Only local templates will be listed if licenses for inherited templates are not present
- all_templates = all_templates.values.flatten.map { |tpl| tpl[:name] }.compact.uniq
+ def selected_template_name(template_names)
+ template_names.find { |tmpl_name| tmpl_name == params[:issuable_template] }
+ end
- template = all_templates.find { |tmpl_name| tmpl_name == params[:issuable_template] }
+ def default_template_name(template_names, issuable)
+ return if issuable.description.present? || issuable.persisted?
- unless issuable.description.present?
- template ||= all_templates.find { |tmpl_name| tmpl_name.casecmp?('default') }
- end
+ template_names.find { |tmpl_name| tmpl_name.casecmp?('default') }
+ end
- template
+ def template_names(issuable)
+ # Only local templates will be listed if licenses for inherited templates are not present
+ issuable_templates(ref_project, issuable.to_ability_name).values.flatten.map { |tpl| tpl[:name] }.compact.uniq
end
def available_service_desk_templates_for(project)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 486d5bb3866..8fd004233e2 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -147,14 +147,20 @@ module IssuablesHelper
end
def issuable_meta_author_status(author)
- return "" unless show_status_emoji?(author&.status) && status = user_status(author)
+ return "" unless author&.status&.customized? && status = user_status(author)
"#{status}".html_safe
end
def issuable_meta(issuable, project)
output = []
- output << "Created #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
+
+ if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.work_item_type.base_type)
+ output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", css_class: 'gl-icon gl-vertical-align-middle'), class: 'gl-mr-2', aria: { hidden: 'true' })
+ output << s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) }
+ else
+ output << s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) }
+ end
if issuable.is_a?(Issue) && issuable.service_desk_reply_to
output << "#{html_escape(issuable.service_desk_reply_to)} via "
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 877785c9eaf..2d0bc1bc63f 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -39,7 +39,7 @@ module LabelsHelper
def link_to_label(label, type: :issue, tooltip: true, small: false, css_class: nil, &block)
link = label.filter_path(type: type)
- if block_given?
+ if block
link_to link, class: css_class, &block
else
render_label(label, link: link, tooltip: tooltip, small: small)
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 6077a059f6f..fc558958ca3 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -266,9 +266,10 @@ module MarkupHelper
def markdown_toolbar_button(options = {})
data = options[:data].merge({ container: 'body' })
+ css_classes = %w[gl-button btn btn-default-tertiary btn-icon js-md has-tooltip] << options[:css_class].to_s
content_tag :button,
type: 'button',
- class: 'gl-button btn btn-default-tertiary btn-icon js-md has-tooltip',
+ class: css_classes.join(' '),
data: data,
title: options[:title],
aria: { label: options[:title] } do
@@ -282,8 +283,8 @@ module MarkupHelper
def asciidoc_unsafe(text, context = {})
context.reverse_merge!(
- commit: @commit,
- ref: @ref,
+ commit: @commit,
+ ref: @ref,
requested_path: @path
)
Gitlab::Asciidoc.render(text, context)
@@ -323,9 +324,9 @@ module MarkupHelper
current_user: (current_user if defined?(current_user)),
# RepositoryLinkFilter and UploadLinkFilter
- commit: @commit,
- wiki: @wiki,
- ref: @ref,
+ commit: @commit,
+ wiki: @wiki,
+ ref: @ref,
requested_path: @path
)
diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb
index 4b1cbd3f1ae..f1f5f941edd 100644
--- a/app/helpers/members_helper.rb
+++ b/app/helpers/members_helper.rb
@@ -83,6 +83,24 @@ module MembersHelper
params: pagination[:params] || {}
}
end
+
+ def member_request_access_link(member)
+ user = member.user
+ member_source = member.source
+
+ member_link = link_to user.name, user, class: :highlight
+ member_role = content_tag :span, member.human_access, class: :highlight
+ target_source_link = link_to member_source.human_name, polymorphic_url([member_source, :members]), class: :highlight
+ target_type = member_source.model_name.singular
+
+ s_('Notify|%{member_link} requested %{member_role} access to the %{target_source_link} %{target_type}.')
+ .html_safe % {
+ member_link: member_link,
+ member_role: member_role,
+ target_source_link: target_source_link,
+ target_type: target_type
+ }
+ end
end
MembersHelper.prepend_mod_with('MembersHelper')
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index d840223a066..4581da4a063 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -3,23 +3,12 @@
module MergeRequestsHelper
include Gitlab::Utils::StrongMemoize
- def new_mr_path_from_push_event(event)
- target_project = event.project.default_merge_request_target
- project_new_merge_request_path(
- event.project,
- new_mr_from_push_event(event, target_project)
- )
+ def create_mr_button_from_event?(event)
+ create_mr_button?(from: event.branch_name, source_project: event.project)
end
- def new_mr_from_push_event(event, target_project)
- {
- merge_request: {
- source_project_id: event.project.id,
- target_project_id: target_project.id,
- source_branch: event.branch_name,
- target_branch: target_project.repository.root_ref
- }
- }
+ def create_mr_path_from_push_event(event)
+ create_mr_path(from: event.branch_name, source_project: event.project)
end
def mr_css_classes(mr)
@@ -29,11 +18,31 @@ module MergeRequestsHelper
classes.join(' ')
end
- def merge_path_description(merge_request, separator)
+ def merge_path_description(merge_request, with_arrow: false)
if merge_request.for_fork?
- "Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.full_path}:#{@merge_request.target_branch}"
+ msg = if with_arrow
+ _("Project:Branches: %{source_project_path}:%{source_branch} → %{target_project_path}:%{target_branch}")
+ else
+ _("Project:Branches: %{source_project_path}:%{source_branch} to %{target_project_path}:%{target_branch}")
+ end
+
+ msg % {
+ source_project_path: merge_request.source_project_path,
+ source_branch: merge_request.source_branch,
+ target_project_path: merge_request.target_project.full_path,
+ target_branch: merge_request.target_branch
+ }
else
- "Branches: #{@merge_request.source_branch} #{separator} #{@merge_request.target_branch}"
+ msg = if with_arrow
+ _("Branches: %{source_branch} → %{target_branch}")
+ else
+ _("Branches: %{source_branch} to %{target_branch}")
+ end
+
+ msg % {
+ source_branch: merge_request.source_branch,
+ target_branch: merge_request.target_branch
+ }
end
end
@@ -150,20 +159,11 @@ module MergeRequestsHelper
review_requested_count = review_requested_merge_requests_count
total_count = assigned_count + review_requested_count
- counts = {
+ {
assigned: assigned_count,
review_requested: review_requested_count,
total: total_count
}
-
- if current_user&.mr_attention_requests_enabled?
- attention_requested_count = attention_requested_merge_requests_count
-
- counts[:attention_requested_count] = attention_requested_count
- counts[:total] = attention_requested_count
- end
-
- counts
end
end
@@ -225,10 +225,6 @@ module MergeRequestsHelper
current_user.review_requested_open_merge_requests_count
end
- def attention_requested_merge_requests_count
- current_user.attention_requested_open_merge_requests_count
- end
-
def default_suggestion_commit_message
@project.suggestion_commit_message.presence || Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index a50629b7996..60796e628a3 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -81,13 +81,6 @@ module NamespacesHelper
group.namespace_settings.public_send(method_name, **args) # rubocop:disable GitlabSecurity/PublicSend
end
- def namespaces_as_json(selected = :current_user)
- {
- group: formatted_namespaces(current_user.manageable_groups_with_routes),
- user: formatted_namespaces([current_user.namespace])
- }.to_json
- end
-
def pipeline_usage_app_data(namespace)
{
namespace_actual_plan_name: namespace.actual_plan_name,
@@ -129,17 +122,6 @@ module NamespacesHelper
[group_label.camelize, elements]
end
-
- def formatted_namespaces(namespaces)
- namespaces.sort_by(&:human_name).map! do |n|
- {
- id: n.id,
- display_path: n.full_path,
- human_name: n.human_name,
- name: n.name
- }
- end
- end
end
NamespacesHelper.prepend_mod_with('NamespacesHelper')
diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb
index fb8fafe59f3..dc7d8049556 100644
--- a/app/helpers/nav/new_dropdown_helper.rb
+++ b/app/helpers/nav/new_dropdown_helper.rb
@@ -42,7 +42,7 @@ module Nav
::Gitlab::Nav::TopNavMenuItem.build(
id: 'new_subgroup',
title: _('New subgroup'),
- href: new_group_path(parent_id: group.id),
+ href: new_group_path(parent_id: group.id, anchor: 'create-group-pane'),
data: { track_action: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' }
)
)
diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb
index 3ceb60251c2..efec6f2d0d8 100644
--- a/app/helpers/nav/top_nav_helper.rb
+++ b/app/helpers/nav/top_nav_helper.rb
@@ -81,13 +81,6 @@ module Nav
**snippets_menu_item_attrs
)
end
-
- builder.add_secondary_menu_item(
- id: 'help',
- title: _('Help'),
- icon: 'question-o',
- href: help_path
- )
end
def build_view_model(builder:, project:, group:)
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index ec64746d6b6..b52357bc891 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -63,4 +63,27 @@ module PackagesHelper
Gitlab.config.packages.enabled &&
Ability.allowed?(current_user, :admin_package, project)
end
+
+ def cleanup_settings_data
+ {
+ project_id: @project.id,
+ project_path: @project.full_path,
+ cadence_options: cadence_options.to_json,
+ keep_n_options: keep_n_options.to_json,
+ older_than_options: older_than_options.to_json,
+ is_admin: current_user&.admin.to_s,
+ admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
+ enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s,
+ help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'),
+ show_cleanup_policy_link: show_cleanup_policy_link(@project).to_s,
+ tags_regex_help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'regex-pattern-examples')
+ }
+ end
+
+ def settings_data
+ cleanup_settings_data.merge(
+ show_container_registry_settings: show_container_registry_settings(@project).to_s,
+ show_package_registry_settings: show_package_registry_settings(@project).to_s
+ )
+ end
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 39a57e786ed..57afe0ed0be 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -26,7 +26,7 @@ module PreferencesHelper
def localized_dashboard_choices
{
projects: _("Your Projects (default)"),
- stars: _("Starred Projects"),
+ stars: _("Starred Projects"),
project_activity: _("Your Projects' Activity"),
starred_project_activity: _("Starred Projects' Activity"),
followed_user_activity: _("Followed Users' Activity"),
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 20d0dd9b30c..104026ff21e 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -31,10 +31,6 @@ module ProfilesHelper
Types::AvailabilityEnum.enum
end
- def user_status_set_to_busy?(status)
- status&.availability == availability_values[:busy]
- end
-
def middle_dot_divider_classes(stacking, breakpoint)
['gl-mb-3'].tap do |classes|
if stacking
diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb
index 3b3fe13e58a..5f2a9f7bf21 100644
--- a/app/helpers/projects/pipeline_helper.rb
+++ b/app/helpers/projects/pipeline_helper.rb
@@ -14,7 +14,14 @@ module Projects
metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json),
pipeline_iid: pipeline.iid,
pipeline_project_path: project.full_path,
- total_job_count: pipeline.total_size
+ total_job_count: pipeline.total_size,
+ summary_endpoint: summary_project_pipeline_tests_path(project, pipeline, format: :json),
+ suite_endpoint: project_pipeline_test_path(project, pipeline, suite_name: 'suite', format: :json),
+ blob_path: project_blob_path(project, pipeline.sha),
+ has_test_report: pipeline.has_test_reports?,
+ empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg'),
+ artifacts_expired_image_path: image_path('illustrations/pipeline.svg'),
+ tests_count: pipeline.test_report_summary.total[:count]
}
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 2ece3e87500..dfc270adf8b 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -458,6 +458,16 @@ module ProjectsHelper
end
end
+ def project_coverage_chart_data_attributes(daily_coverage_options, ref)
+ {
+ graph_endpoint: "#{daily_coverage_options[:graph_api_path]}?#{daily_coverage_options[:base_params].to_query}",
+ graph_start_date: "#{daily_coverage_options[:base_params][:start_date].strftime('%b %d')}",
+ graph_end_date: "#{daily_coverage_options[:base_params][:end_date].strftime('%b %d')}",
+ graph_ref: "#{ref}",
+ graph_csv_path: "#{daily_coverage_options[:download_path]}?#{daily_coverage_options[:base_params].to_query}"
+ }
+ end
+
private
def configure_oauth_import_message(provider, help_url)
@@ -473,35 +483,35 @@ module ProjectsHelper
def tab_ability_map
{
- cycle_analytics: :read_cycle_analytics,
- environments: :read_environment,
+ cycle_analytics: :read_cycle_analytics,
+ environments: :read_environment,
metrics_dashboards: :metrics_dashboard,
- milestones: :read_milestone,
- snippets: :read_snippet,
- settings: :admin_project,
- builds: :read_build,
- clusters: :read_cluster,
- serverless: :read_cluster,
- terraform: :read_terraform_state,
- error_tracking: :read_sentry_issue,
- alert_management: :read_alert_management_alert,
- incidents: :read_issue,
- labels: :read_label,
- issues: :read_issue,
- project_members: :read_project_member,
- wiki: :read_wiki,
- feature_flags: :read_feature_flag,
- analytics: :read_analytics
+ milestones: :read_milestone,
+ snippets: :read_snippet,
+ settings: :admin_project,
+ builds: :read_build,
+ clusters: :read_cluster,
+ serverless: :read_cluster,
+ terraform: :read_terraform_state,
+ error_tracking: :read_sentry_issue,
+ alert_management: :read_alert_management_alert,
+ incidents: :read_issue,
+ labels: :read_label,
+ issues: :read_issue,
+ project_members: :read_project_member,
+ wiki: :read_wiki,
+ feature_flags: :read_feature_flag,
+ analytics: :read_analytics
}
end
def search_tab_ability_map
@search_tab_ability_map ||= tab_ability_map.merge(
- blobs: :download_code,
- commits: :download_code,
+ blobs: :download_code,
+ commits: :download_code,
merge_requests: :read_merge_request,
- notes: [:read_merge_request, :download_code, :read_issue, :read_snippet],
- members: :read_project_member
+ notes: [:read_merge_request, :download_code, :read_issue, :read_snippet],
+ members: :read_project_member
)
end
@@ -629,7 +639,10 @@ module ProjectsHelper
warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?,
enforceAuthChecksOnUploads: project.enforce_auth_checks_on_uploads?,
securityAndComplianceAccessLevel: project.security_and_compliance_access_level,
- containerRegistryAccessLevel: feature.container_registry_access_level
+ containerRegistryAccessLevel: feature.container_registry_access_level,
+ environmentsAccessLevel: feature.environments_access_level,
+ featureFlagsAccessLevel: feature.feature_flags_access_level,
+ releasesAccessLevel: feature.releases_access_level
}
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index ecbcaec27bc..dc53be330fe 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -14,28 +14,42 @@ module SearchHelper
:project_ids
].freeze
- def search_autocomplete_opts(term)
+ def search_autocomplete_opts(term, filter: nil)
return unless current_user
- resources_results = [
- recent_items_autocomplete(term),
+ results = case filter&.to_sym
+ when :search
+ resource_results(term)
+ when :generic
+ [
+ recent_items_autocomplete(term),
+ generic_results(term)
+ ]
+ else
+ [
+ recent_items_autocomplete(term),
+ resource_results(term),
+ generic_results(term)
+ ]
+ end
+
+ results.flatten { |item| item[:label] }
+ end
+
+ def resource_results(term)
+ [
groups_autocomplete(term),
projects_autocomplete(term),
issue_autocomplete(term)
].flatten
+ end
+ def generic_results(term)
search_pattern = Regexp.new(Regexp.escape(term), "i")
generic_results = project_autocomplete + default_autocomplete + help_autocomplete
generic_results.concat(default_autocomplete_admin) if current_user.admin?
- generic_results.select! { |result| result[:label] =~ search_pattern }
-
- [
- resources_results,
- generic_results
- ].flatten do |item|
- item[:label]
- end
+ generic_results.select { |result| result[:label] =~ search_pattern }
end
def recent_items_autocomplete(term)
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index ef79e2bc86f..58f0af883f5 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -6,39 +6,39 @@ module SortingHelper
# rubocop: disable Metrics/AbcSize
def sort_options_hash
{
- sort_value_created_date => sort_title_created_date,
- sort_value_downvotes => sort_title_downvotes,
- sort_value_due_date => sort_title_due_date,
- sort_value_due_date_later => sort_title_due_date_later,
- sort_value_due_date_soon => sort_title_due_date_soon,
- sort_value_label_priority => sort_title_label_priority,
- sort_value_largest_group => sort_title_largest_group,
- sort_value_largest_repo => sort_title_largest_repo,
- sort_value_milestone => sort_title_milestone,
- sort_value_milestone_later => sort_title_milestone_later,
- sort_value_milestone_soon => sort_title_milestone_soon,
- sort_value_name => sort_title_name,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_oldest_created => sort_title_oldest_created,
- sort_value_oldest_signin => sort_title_oldest_signin,
- sort_value_oldest_updated => sort_title_oldest_updated,
- sort_value_recently_created => sort_title_recently_created,
- sort_value_recently_signin => sort_title_recently_signin,
- sort_value_recently_updated => sort_title_recently_updated,
- sort_value_popularity => sort_title_popularity,
- sort_value_priority => sort_title_priority,
- sort_value_merged_date => sort_title_merged_date,
- sort_value_merged_recently => sort_title_merged_recently,
- sort_value_merged_earlier => sort_title_merged_earlier,
- sort_value_closed_date => sort_title_closed_date,
- sort_value_closed_recently => sort_title_closed_recently,
- sort_value_closed_earlier => sort_title_closed_earlier,
- sort_value_upvotes => sort_title_upvotes,
- sort_value_contacted_date => sort_title_contacted_date,
+ sort_value_created_date => sort_title_created_date,
+ sort_value_downvotes => sort_title_downvotes,
+ sort_value_due_date => sort_title_due_date,
+ sort_value_due_date_later => sort_title_due_date_later,
+ sort_value_due_date_soon => sort_title_due_date_soon,
+ sort_value_label_priority => sort_title_label_priority,
+ sort_value_largest_group => sort_title_largest_group,
+ sort_value_largest_repo => sort_title_largest_repo,
+ sort_value_milestone => sort_title_milestone,
+ sort_value_milestone_later => sort_title_milestone_later,
+ sort_value_milestone_soon => sort_title_milestone_soon,
+ sort_value_name => sort_title_name,
+ sort_value_name_desc => sort_title_name_desc,
+ sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_oldest_signin => sort_title_oldest_signin,
+ sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_recently_created => sort_title_recently_created,
+ sort_value_recently_signin => sort_title_recently_signin,
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_popularity => sort_title_popularity,
+ sort_value_priority => sort_title_priority,
+ sort_value_merged_date => sort_title_merged_date,
+ sort_value_merged_recently => sort_title_merged_recently,
+ sort_value_merged_earlier => sort_title_merged_earlier,
+ sort_value_closed_date => sort_title_closed_date,
+ sort_value_closed_recently => sort_title_closed_recently,
+ sort_value_closed_earlier => sort_title_closed_earlier,
+ sort_value_upvotes => sort_title_upvotes,
+ sort_value_contacted_date => sort_title_contacted_date,
sort_value_relative_position => sort_title_relative_position,
- sort_value_size => sort_title_size,
- sort_value_expire_date => sort_title_expire_date,
- sort_value_title => sort_title_title
+ sort_value_size => sort_title_size,
+ sort_value_expire_date => sort_title_expire_date,
+ sort_value_title => sort_title_title
}
end
# rubocop: enable Metrics/AbcSize
@@ -47,19 +47,19 @@ module SortingHelper
use_old_sorting = Feature.disabled?(:project_list_filter_bar) || current_controller?('admin/projects')
options = {
- sort_value_latest_activity => sort_title_latest_activity,
+ sort_value_latest_activity => sort_title_latest_activity,
sort_value_recently_created => sort_title_created_date,
- sort_value_name => sort_title_name,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_stars_desc => sort_title_stars
+ sort_value_name => sort_title_name,
+ sort_value_name_desc => sort_title_name_desc,
+ sort_value_stars_desc => sort_title_stars
}
if use_old_sorting
options = options.merge({
- sort_value_oldest_activity => sort_title_oldest_activity,
- sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_oldest_activity => sort_title_oldest_activity,
+ sort_value_oldest_created => sort_title_oldest_created,
sort_value_recently_created => sort_title_recently_created,
- sort_value_stars_desc => sort_title_most_stars
+ sort_value_stars_desc => sort_title_most_stars
})
end
@@ -73,52 +73,52 @@ module SortingHelper
def forks_sort_options_hash
{
sort_value_recently_created => sort_title_created_date,
- sort_value_oldest_created => sort_title_created_date,
- sort_value_latest_activity => sort_title_latest_activity,
- sort_value_oldest_activity => sort_title_latest_activity
+ sort_value_oldest_created => sort_title_created_date,
+ sort_value_latest_activity => sort_title_latest_activity,
+ sort_value_oldest_activity => sort_title_latest_activity
}
end
def projects_sort_option_titles
# Only used for the project filter search bar
projects_sort_options_hash.merge({
- sort_value_oldest_activity => sort_title_latest_activity,
- sort_value_oldest_created => sort_title_created_date,
- sort_value_name_desc => sort_title_name,
- sort_value_stars_asc => sort_title_stars
+ sort_value_oldest_activity => sort_title_latest_activity,
+ sort_value_oldest_created => sort_title_created_date,
+ sort_value_name_desc => sort_title_name,
+ sort_value_stars_asc => sort_title_stars
})
end
def projects_reverse_sort_options_hash
{
- sort_value_latest_activity => sort_value_oldest_activity,
+ sort_value_latest_activity => sort_value_oldest_activity,
sort_value_recently_created => sort_value_oldest_created,
- sort_value_name => sort_value_name_desc,
- sort_value_stars_desc => sort_value_stars_asc,
- sort_value_oldest_activity => sort_value_latest_activity,
- sort_value_oldest_created => sort_value_recently_created,
- sort_value_name_desc => sort_value_name,
- sort_value_stars_asc => sort_value_stars_desc
+ sort_value_name => sort_value_name_desc,
+ sort_value_stars_desc => sort_value_stars_asc,
+ sort_value_oldest_activity => sort_value_latest_activity,
+ sort_value_oldest_created => sort_value_recently_created,
+ sort_value_name_desc => sort_value_name,
+ sort_value_stars_asc => sort_value_stars_desc
}
end
def forks_reverse_sort_options_hash
{
sort_value_recently_created => sort_value_oldest_created,
- sort_value_oldest_created => sort_value_recently_created,
- sort_value_latest_activity => sort_value_oldest_activity,
- sort_value_oldest_activity => sort_value_latest_activity
+ sort_value_oldest_created => sort_value_recently_created,
+ sort_value_latest_activity => sort_value_oldest_activity,
+ sort_value_oldest_activity => sort_value_latest_activity
}
end
def groups_sort_options_hash
{
- sort_value_name => sort_title_name,
- sort_value_name_desc => sort_title_name_desc,
+ sort_value_name => sort_title_name,
+ sort_value_name_desc => sort_title_name_desc,
sort_value_recently_created => sort_title_recently_created,
- sort_value_oldest_created => sort_title_oldest_created,
- sort_value_latest_activity => sort_title_recently_updated,
- sort_value_oldest_activity => sort_title_oldest_updated
+ sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_latest_activity => sort_title_recently_updated,
+ sort_value_oldest_activity => sort_title_oldest_updated
}
end
@@ -136,27 +136,27 @@ module SortingHelper
def milestones_sort_options_hash
{
- sort_value_due_date_soon => sort_title_due_date_soon,
- sort_value_due_date_later => sort_title_due_date_later,
- sort_value_start_date_soon => sort_title_start_date_soon,
+ sort_value_due_date_soon => sort_title_due_date_soon,
+ sort_value_due_date_later => sort_title_due_date_later,
+ sort_value_start_date_soon => sort_title_start_date_soon,
sort_value_start_date_later => sort_title_start_date_later,
- sort_value_name => sort_title_name_asc,
- sort_value_name_desc => sort_title_name_desc
+ sort_value_name => sort_title_name_asc,
+ sort_value_name_desc => sort_title_name_desc
}
end
def branches_sort_options_hash
{
- sort_value_name => sort_title_name,
- sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_name => sort_title_name,
+ sort_value_oldest_updated => sort_title_oldest_updated,
sort_value_recently_updated => sort_title_recently_updated
}
end
def tags_sort_options_hash
{
- sort_value_name => sort_title_name,
- sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_name => sort_title_name,
+ sort_value_oldest_updated => sort_title_oldest_updated,
sort_value_recently_updated => sort_title_recently_updated
}
end
@@ -240,7 +240,7 @@ module SortingHelper
def audit_logs_sort_order_hash
{
sort_value_recently_created => sort_title_recently_created,
- sort_value_oldest_created => sort_title_oldest_created
+ sort_value_oldest_created => sort_title_oldest_created
}
end
@@ -336,31 +336,31 @@ module SortingHelper
def packages_sort_options_hash
{
- sort_value_recently_created => sort_title_created_date,
- sort_value_oldest_created => sort_title_created_date,
- sort_value_name => sort_title_name,
- sort_value_name_desc => sort_title_name,
- sort_value_version_desc => sort_title_version,
- sort_value_version_asc => sort_title_version,
- sort_value_type_desc => sort_title_type,
- sort_value_type_asc => sort_title_type,
+ sort_value_recently_created => sort_title_created_date,
+ sort_value_oldest_created => sort_title_created_date,
+ sort_value_name => sort_title_name,
+ sort_value_name_desc => sort_title_name,
+ sort_value_version_desc => sort_title_version,
+ sort_value_version_asc => sort_title_version,
+ sort_value_type_desc => sort_title_type,
+ sort_value_type_asc => sort_title_type,
sort_value_project_name_desc => sort_title_project_name,
- sort_value_project_name_asc => sort_title_project_name
+ sort_value_project_name_asc => sort_title_project_name
}
end
def packages_reverse_sort_order_hash
{
- sort_value_recently_created => sort_value_oldest_created,
- sort_value_oldest_created => sort_value_recently_created,
- sort_value_name => sort_value_name_desc,
- sort_value_name_desc => sort_value_name,
- sort_value_version_desc => sort_value_version_asc,
- sort_value_version_asc => sort_value_version_desc,
- sort_value_type_desc => sort_value_type_asc,
- sort_value_type_asc => sort_value_type_desc,
+ sort_value_recently_created => sort_value_oldest_created,
+ sort_value_oldest_created => sort_value_recently_created,
+ sort_value_name => sort_value_name_desc,
+ sort_value_name_desc => sort_value_name,
+ sort_value_version_desc => sort_value_version_asc,
+ sort_value_version_asc => sort_value_version_desc,
+ sort_value_type_desc => sort_value_type_asc,
+ sort_value_type_asc => sort_value_type_desc,
sort_value_project_name_desc => sort_value_project_name_asc,
- sort_value_project_name_asc => sort_value_project_name_desc
+ sort_value_project_name_asc => sort_value_project_name_desc
}
end
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index ca81d5af4af..9e516d726c1 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -24,29 +24,89 @@ module StorageHelper
_("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters
end
- def storage_enforcement_banner_info(namespace)
- root_ancestor = namespace.root_ancestor
+ def storage_enforcement_banner_info(context)
+ root_ancestor = context.root_ancestor
- return unless can?(current_user, :maintain_namespace, root_ancestor)
- return if root_ancestor.paid?
- return unless future_enforcement_date?(root_ancestor)
- return if user_dismissed_storage_enforcement_banner?(root_ancestor)
- return unless ::Feature.enabled?(:namespace_storage_limit_show_preenforcement_banner, root_ancestor)
+ return unless should_show_storage_enforcement_banner?(context, current_user, root_ancestor)
+
+ text_args = storage_enforcement_banner_text_args(root_ancestor, context)
+
+ text_paragraph_2 = if root_ancestor.user_namespace?
+ html_escape_once(s_("UsageQuota|The namespace is currently using %{strong_start}%{used_storage}%{strong_end} of namespace storage. " \
+ "View and manage your usage from %{strong_start}User settings &gt; Usage quotas%{strong_end}. %{docs_link_start}Learn more%{link_end} " \
+ "about how to reduce your storage.")).html_safe % text_args[:p2]
+ else
+ html_escape_once(s_("UsageQuota|The namespace is currently using %{strong_start}%{used_storage}%{strong_end} of namespace storage. " \
+ "Group owners can view namespace storage usage and purchase more from %{strong_start}Group settings &gt; Usage quotas%{strong_end}. %{docs_link_start}Learn more.%{link_end}" \
+ )).html_safe % text_args[:p2]
+ end
{
- text: html_escape_once(s_("UsageQuota|From %{storage_enforcement_date} storage limits will apply to this namespace. " \
- "You are currently using %{used_storage} of namespace storage. " \
- "View and manage your usage from %{strong_start}%{namespace_type} settings &gt; Usage quotas%{strong_end}.")).html_safe %
- { storage_enforcement_date: root_ancestor.storage_enforcement_date, used_storage: storage_counter(root_ancestor.root_storage_statistics&.storage_size || 0), strong_start: "<strong>".html_safe, strong_end: "</strong>".html_safe, namespace_type: root_ancestor.type },
+ text_paragraph_1: html_escape_once(s_("UsageQuota|Effective %{storage_enforcement_date}, namespace storage limits will apply " \
+ "to the %{strong_start}%{namespace_name}%{strong_end} namespace. %{extra_message}" \
+ "View the %{rollout_link_start}rollout schedule for this change%{link_end}.")).html_safe % text_args[:p1],
+ text_paragraph_2: text_paragraph_2,
+ text_paragraph_3: html_escape_once(s_("UsageQuota|See our %{faq_link_start}FAQ%{link_end} for more information.")).html_safe % text_args[:p3],
variant: 'warning',
+ namespace_id: root_ancestor.id,
callouts_path: root_ancestor.user_namespace? ? callouts_path : group_callouts_path,
- callouts_feature_name: storage_enforcement_banner_user_callouts_feature_name(root_ancestor),
- learn_more_link: link_to(_('Learn more.'), help_page_path('/'), rel: 'noopener noreferrer', target: '_blank')
+ callouts_feature_name: storage_enforcement_banner_user_callouts_feature_name(root_ancestor)
}
end
private
+ def should_show_storage_enforcement_banner?(context, current_user, root_ancestor)
+ return false unless user_allowed_storage_enforcement_banner?(context, current_user, root_ancestor)
+ return false if root_ancestor.paid?
+ return false unless future_enforcement_date?(root_ancestor)
+ return false if user_dismissed_storage_enforcement_banner?(root_ancestor)
+
+ ::Feature.enabled?(:namespace_storage_limit_show_preenforcement_banner, root_ancestor)
+ end
+
+ def user_allowed_storage_enforcement_banner?(context, current_user, root_ancestor)
+ return can?(current_user, :maintainer_access, context) unless context.respond_to?(:user_namespace?) && context.user_namespace?
+
+ can?(current_user, :owner_access, context)
+ end
+
+ def storage_enforcement_banner_text_args(root_ancestor, context)
+ strong_tags = {
+ strong_start: "<strong>".html_safe,
+ strong_end: "</strong>".html_safe
+ }
+
+ extra_message = if context.is_a?(Project)
+ html_escape_once(s_("UsageQuota|The %{strong_start}%{context_name}%{strong_end} project will be affected by this. "))
+ .html_safe % strong_tags.merge(context_name: context.name)
+ elsif !context.root?
+ html_escape_once(s_("UsageQuota|The %{strong_start}%{context_name}%{strong_end} group will be affected by this. "))
+ .html_safe % strong_tags.merge(context_name: context.name)
+ else
+ ''
+ end
+
+ {
+ p1: {
+ storage_enforcement_date: root_ancestor.storage_enforcement_date,
+ namespace_name: root_ancestor.name,
+ extra_message: extra_message,
+ rollout_link_start: '<a href="%{url}" >'.html_safe % { url: help_page_path('user/usage_quotas', anchor: 'namespace-storage-limit-enforcement-schedule') },
+ link_end: "</a>".html_safe
+ }.merge(strong_tags),
+ p2: {
+ used_storage: storage_counter(root_ancestor.root_storage_statistics&.storage_size || 0),
+ docs_link_start: '<a href="%{url}" >'.html_safe % { url: help_page_path('user/usage_quotas', anchor: 'manage-your-storage-usage') },
+ link_end: "</a>".html_safe
+ }.merge(strong_tags),
+ p3: {
+ faq_link_start: '<a href="%{url}" >'.html_safe % { url: "#{Gitlab::Saas.about_pricing_url}faq-efficient-free-tier/#storage-limits-on-gitlab-saas-free-tier" },
+ link_end: "</a>".html_safe
+ }
+ }
+ end
+
def storage_enforcement_banner_user_callouts_feature_name(namespace)
"storage_enforcement_banner_#{storage_enforcement_banner_threshold(namespace)}_enforcement_threshold"
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 5ab70115f34..a957c9ce9e0 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -30,6 +30,7 @@ module SystemNoteHelper
'locked' => 'lock',
'unlocked' => 'lock-open',
'due_date' => 'calendar',
+ 'start_date_or_due_date' => 'calendar',
'health_status' => 'status-health',
'designs_added' => 'doc-image',
'designs_modified' => 'doc-image',
diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb
index dbbe7069ca4..04619ad3bda 100644
--- a/app/helpers/tab_helper.rb
+++ b/app/helpers/tab_helper.rb
@@ -17,7 +17,7 @@ module TabHelper
class: [*html_options[:class], gl_tabs_classes].join(' ')
)
- content = capture(&block) if block_given?
+ content = capture(&block) if block
content_tag(:ul, content, html_options)
end
@@ -35,7 +35,7 @@ module TabHelper
link_classes = %w[nav-link gl-tab-nav-item]
active_link_classes = %w[active gl-tab-nav-item-active]
- if block_given?
+ if block
# Shift params to skip the omitted "name" param
html_options = options
options = name
@@ -54,7 +54,7 @@ module TabHelper
tab_class = %w[nav-item].push(*extra_tab_classes)
content_tag(:li, class: tab_class) do
- if block_given?
+ if block
link_to(options, html_options, &block)
else
link_to(name, options, html_options)
@@ -150,7 +150,7 @@ module TabHelper
o[:class] = [*o[:class], klass].join(' ')
o[:class].strip!
- if block_given?
+ if block
content_tag(:li, capture(&block), o)
else
content_tag(:li, nil, o)
diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb
index d16f13304e5..29bd5a84651 100644
--- a/app/helpers/time_zone_helper.rb
+++ b/app/helpers/time_zone_helper.rb
@@ -18,7 +18,7 @@ module TimeZoneHelper
#
def timezone_data(format: :short)
attrs = TIME_ZONE_FORMAT_ATTRS.fetch(format) do
- valid_formats = TIME_ZONE_FORMAT_ATTRS.keys.map { |k| ":#{k}"}.join(", ")
+ valid_formats = TIME_ZONE_FORMAT_ATTRS.keys.map { |k| ":#{k}" }.join(", ")
raise ArgumentError, "Invalid format :#{format}. Valid formats are #{valid_formats}."
end
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index f87125af07d..5977f51cab1 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -23,7 +23,6 @@ module TodosHelper
when Todo::APPROVAL_REQUIRED then "set #{todo_action_subject(todo)} as an approver for"
when Todo::UNMERGEABLE then 'Could not merge'
when Todo::MERGE_TRAIN_REMOVED then "Removed from Merge Train:"
- when Todo::ATTENTION_REQUESTED then 'requested your attention on'
end
end
@@ -131,11 +130,11 @@ module TodosHelper
def todos_filter_params
{
- state: params[:state],
+ state: params[:state],
project_id: params[:project_id],
- author_id: params[:author_id],
- type: params[:type],
- action_id: params[:action_id]
+ author_id: params[:author_id],
+ type: params[:type],
+ action_id: params[:action_id]
}
end
@@ -179,7 +178,7 @@ module TodosHelper
end
def todo_actions_dropdown_label(selected_action_id, default_action)
- selected_action = todo_actions_options.find { |action| action[:id] == selected_action_id.to_i}
+ selected_action = todo_actions_options.find { |action| action[:id] == selected_action_id.to_i }
selected_action ? selected_action[:text] : default_action
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 4ea2512bc67..cae2addea9c 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -15,11 +15,11 @@ module UsersHelper
end
def user_email_help_text(user)
- return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present?
+ return _('We also use email for avatar detection if no avatar is uploaded.') unless user.unconfirmed_email.present?
- confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post
+ confirmation_link = link_to _('Resend confirmation e-mail'), user_confirmation_path(user: { email: user.unconfirmed_email }), method: :post
- h('Please click the link in the confirmation email before continuing. It was sent to ') +
+ h(_('Please click the link in the confirmation email before continuing. It was sent to ')) +
content_tag(:strong) { user.unconfirmed_email } + h('.') +
content_tag(:p) { confirmation_link }
end
@@ -67,12 +67,6 @@ module UsersHelper
"access:#{max_project_member_access(project)}"
end
- def show_status_emoji?(status)
- return false unless status
-
- status.message.present? || status.emoji != UserStatus::DEFAULT_EMOJI
- end
-
def user_status(user)
return unless user
diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb
index 64900714327..ba3c232bec4 100644
--- a/app/helpers/webpack_helper.rb
+++ b/app/helpers/webpack_helper.rb
@@ -83,16 +83,8 @@ module WebpackHelper
end
def webpack_public_host
- # We do not proxy the webpack output in the 'test' environment,
- # so we must reference the webpack dev server directly.
- if Rails.env.test? && Gitlab.config.webpack.dev_server.enabled
- host = Gitlab.config.webpack.dev_server.host
- port = Gitlab.config.webpack.dev_server.port
- protocol = Gitlab.config.webpack.dev_server.https ? 'https' : 'http'
- "#{protocol}://#{host}:#{port}"
- else
- ActionController::Base.asset_host.try(:chomp, '/')
- end
+ # We proxy webpack output in 'test' and 'dev' environment, so we can just use asset_host
+ ActionController::Base.asset_host.try(:chomp, '/')
end
def webpack_public_path
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 02ea3c1b010..d6ffd3deafe 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -121,11 +121,11 @@ module WikiHelper
def wiki_page_tracking_context(page)
{
- 'wiki-format' => page.format,
- 'wiki-title-size' => page.title.bytesize,
- 'wiki-content-size' => page.raw_content.bytesize,
+ 'wiki-format' => page.format,
+ 'wiki-title-size' => page.title.bytesize,
+ 'wiki-content-size' => page.raw_content.bytesize,
'wiki-directory-nest-level' => page.path.scan('/').count,
- 'wiki-container-type' => page.wiki.container.class.name
+ 'wiki-container-type' => page.wiki.container.class.name
}
end
diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb
index 20aabb6fe58..1fa85064c57 100644
--- a/app/mailers/abuse_report_mailer.rb
+++ b/app/mailers/abuse_report_mailer.rb
@@ -11,8 +11,8 @@ class AbuseReportMailer < ApplicationMailer
@abuse_report = AbuseReport.find(abuse_report_id)
mail(
- to: Gitlab::CurrentSettings.abuse_notification_email,
- subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse"
+ to: Gitlab::CurrentSettings.abuse_notification_email,
+ subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse"
)
end
diff --git a/app/mailers/emails/admin_notification.rb b/app/mailers/emails/admin_notification.rb
index 9d02d4132a1..3766b4447d1 100644
--- a/app/mailers/emails/admin_notification.rb
+++ b/app/mailers/emails/admin_notification.rb
@@ -15,23 +15,7 @@ module Emails
email = user.notification_email_or_default
mail to: email, subject: "Unsubscribed from GitLab administrator notifications"
end
-
- def user_auto_banned_email(admin_id, user_id, max_project_downloads:, within_seconds:, group: nil)
- admin = User.find(admin_id)
- @user = User.find(user_id)
- @max_project_downloads = max_project_downloads
- @within_minutes = within_seconds / 60
- @ban_scope = if group.present?
- _('your group (%{group_name})' % { group_name: group.name })
- else
- _('your GitLab instance')
- end
-
- Gitlab::I18n.with_locale(admin.preferred_language) do
- email_with_layout(
- to: admin.notification_email_or_default,
- subject: subject(_("We've detected unusual activity")))
- end
- end
end
end
+
+Emails::AdminNotification.prepend_mod
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 6a2b447f4a0..fc944c34166 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -104,13 +104,6 @@ module Emails
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
end
- def attention_requested_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
- setup_merge_request_mail(merge_request_id, recipient_id)
-
- @updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason))
- end
-
def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index ed3fa28b15f..5b8471abb0f 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -51,9 +51,9 @@ module Emails
add_project_headers
headers['X-GitLab-Author'] = @message.author_username
- mail(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?),
- reply_to: @message.reply_to,
- subject: @message.subject)
+ mail(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?),
+ reply_to: @message.reply_to,
+ subject: @message.subject)
end
def prometheus_alert_fired_email(project, user, alert)
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 17b46f929c3..579f2c38ae6 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -790,10 +790,10 @@ class ApplicationSetting < ApplicationRecord
def parsed_kroki_url
@parsed_kroki_url ||= Gitlab::UrlBlocker.validate!(kroki_url, schemes: %w(http https), enforce_sanitization: true)[0]
- rescue Gitlab::UrlBlocker::BlockedUrlError => error
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
self.errors.add(
:kroki_url,
- "is not valid. #{error}"
+ "is not valid. #{e}"
)
end
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index e9a0a156121..4d377855dea 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -122,7 +122,7 @@ module ApplicationSettingImplementation
password_authentication_enabled_for_git: true,
password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'],
performance_bar_allowed_group_id: nil,
- personal_access_token_prefix: nil,
+ personal_access_token_prefix: 'glpat-',
plantuml_enabled: false,
plantuml_url: nil,
polling_interval_multiplier: 1,
diff --git a/app/models/approval.rb b/app/models/approval.rb
index 899ea466315..9ded44fe425 100644
--- a/app/models/approval.rb
+++ b/app/models/approval.rb
@@ -2,11 +2,12 @@
class Approval < ApplicationRecord
include CreatedAtFilterable
+ include Importable
belongs_to :user
belongs_to :merge_request
- validates :merge_request_id, presence: true
+ validates :merge_request_id, presence: true, unless: :importing?
validates :user_id, presence: true, uniqueness: { scope: [:merge_request_id] }
scope :with_user, -> { joins(:user) }
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 8e8e9389e2d..0ad17cd8869 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -86,6 +86,18 @@ class AuditEvent < ApplicationRecord
end
end
+ def target_type
+ super || details[:target_type]
+ end
+
+ def target_id
+ details[:target_id]
+ end
+
+ def target_details
+ super || details[:target_details]
+ end
+
private
def sanitize_message
diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb
index 0ed197f32df..d5a5079acd6 100644
--- a/app/models/authentication_event.rb
+++ b/app/models/authentication_event.rb
@@ -20,7 +20,7 @@ class AuthenticationEvent < ApplicationRecord
}
scope :for_provider, ->(provider) { where(provider: provider) }
- scope :ldap, -> { where('provider LIKE ?', 'ldap%')}
+ scope :ldap, -> { where('provider LIKE ?', 'ldap%') }
def self.providers
STATIC_PROVIDERS | Devise.omniauth_providers.map(&:to_s)
diff --git a/app/models/blob.rb b/app/models/blob.rb
index a12d856dc36..20d7c230aa2 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -93,8 +93,8 @@ class Blob < SimpleDelegator
end
def self.lazy(repository, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE)
- BatchLoader.for([commit_id, path]).batch(key: repository) do |items, loader, args|
- args[:key].blobs_at(items, blob_size_limit: blob_size_limit).each do |blob|
+ BatchLoader.for([commit_id, path]).batch(key: [:repository_blobs, repository]) do |items, loader, args|
+ args[:key].last.blobs_at(items, blob_size_limit: blob_size_limit).each do |blob|
loader.call([blob.commit_id, blob.path], blob) if blob
end
end
diff --git a/app/models/blob_viewer/metrics_dashboard_yml.rb b/app/models/blob_viewer/metrics_dashboard_yml.rb
index 88643253d3d..cac6b2192d0 100644
--- a/app/models/blob_viewer/metrics_dashboard_yml.rb
+++ b/app/models/blob_viewer/metrics_dashboard_yml.rb
@@ -36,10 +36,10 @@ module BlobViewer
yaml = ::Gitlab::Config::Loader::Yaml.new(blob.data).load_raw!
::PerformanceMonitoring::PrometheusDashboard.from_json(yaml)
[]
- rescue Gitlab::Config::Loader::FormatError => error
- ["YAML syntax: #{error.message}"]
- rescue ActiveModel::ValidationError => invalid
- invalid.model.errors.messages.map { |messages| messages.join(': ') }
+ rescue Gitlab::Config::Loader::FormatError => e
+ ["YAML syntax: #{e.message}"]
+ rescue ActiveModel::ValidationError => e
+ e.model.errors.messages.map { |messages| messages.join(': ') }
end
def exhaustive_metrics_dashboard_validation
@@ -47,8 +47,8 @@ module BlobViewer
Gitlab::Metrics::Dashboard::Validator
.errors(yaml, dashboard_path: blob.path, project: project)
.map(&:message)
- rescue Gitlab::Config::Loader::FormatError => error
- [error.message]
+ rescue Gitlab::Config::Loader::FormatError => e
+ [e.message]
end
end
end
diff --git a/app/models/bulk_imports/configuration.rb b/app/models/bulk_imports/configuration.rb
index 6d9f598583e..3b263ed0340 100644
--- a/app/models/bulk_imports/configuration.rb
+++ b/app/models/bulk_imports/configuration.rb
@@ -9,7 +9,7 @@ class BulkImports::Configuration < ApplicationRecord
validates :url, :access_token, length: { maximum: 255 }, presence: true
validates :url, public_url: { schemes: %w[http https], enforce_sanitization: true, ascii_only: true },
- allow_nil: true
+ allow_nil: true
attr_encrypted :url,
key: Settings.attr_encrypted_db_key_base_32,
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index cad2fafe640..e0a616b5fb4 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -52,9 +52,11 @@ class BulkImports::Entity < ApplicationRecord
scope :by_user_id, ->(user_id) { joins(:bulk_import).where(bulk_imports: { user_id: user_id }) }
scope :stale, -> { where('created_at < ?', 8.hours.ago).where(status: [0, 1]) }
- scope :by_bulk_import_id, ->(bulk_import_id) { where(bulk_import_id: bulk_import_id)}
+ scope :by_bulk_import_id, ->(bulk_import_id) { where(bulk_import_id: bulk_import_id) }
scope :order_by_created_at, -> (direction) { order(created_at: direction) }
+ alias_attribute :destination_slug, :destination_name
+
state_machine :status, initial: :created do
state :created, value: 0
state :started, value: 1
diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb
index ff3f2663b73..60370c525d5 100644
--- a/app/models/chat_name.rb
+++ b/app/models/chat_name.rb
@@ -3,7 +3,7 @@
class ChatName < ApplicationRecord
LAST_USED_AT_INTERVAL = 1.hour
- belongs_to :integration, foreign_key: :service_id
+ belongs_to :integration
belongs_to :user
validates :user, presence: true
@@ -11,8 +11,8 @@ class ChatName < ApplicationRecord
validates :team_id, presence: true
validates :chat_id, presence: true
- validates :user_id, uniqueness: { scope: [:service_id] }
- validates :chat_id, uniqueness: { scope: [:service_id, :team_id] }
+ validates :user_id, uniqueness: { scope: [:integration_id] }
+ validates :chat_id, uniqueness: { scope: [:integration_id, :team_id] }
# Updates the "last_used_timestamp" but only if it wasn't already updated
# recently.
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 13af5b1f8d1..3fda8693a58 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -19,7 +19,7 @@ module Ci
belongs_to :project
belongs_to :trigger_request
has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline",
- foreign_key: :source_job_id
+ foreign_key: :source_job_id
has_one :sourced_pipeline, class_name: "::Ci::Sources::Pipeline", foreign_key: :source_job_id
has_one :downstream_pipeline, through: :sourced_pipeline, source: :pipeline
@@ -114,7 +114,12 @@ module Ci
def downstream_project_path
strong_memoize(:downstream_project_path) do
- options&.dig(:trigger, :project)
+ project = options&.dig(:trigger, :project)
+ next unless project
+
+ scoped_variables.to_runner_variables.yield_self do |all_variables|
+ ::ExpandVariables.expand(project, all_variables)
+ end
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 7f9697d0424..bf8817e6e78 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -194,7 +194,7 @@ module Ci
after_save :stick_build_if_status_changed
after_create unless: :importing? do |build|
- run_after_commit { BuildHooksWorker.perform_async(build) }
+ run_after_commit { build.feature_flagged_execute_hooks }
end
class << self
@@ -285,7 +285,7 @@ module Ci
build.run_after_commit do
BuildQueueWorker.perform_async(id)
- BuildHooksWorker.perform_async(build)
+ build.feature_flagged_execute_hooks
end
end
@@ -313,7 +313,7 @@ module Ci
build.run_after_commit do
build.ensure_persistent_ref
- BuildHooksWorker.perform_async(build)
+ build.feature_flagged_execute_hooks
end
end
@@ -322,6 +322,8 @@ module Ci
build.run_status_commit_hooks!
Ci::BuildFinishedWorker.perform_async(id)
+
+ observe_report_types
end
end
@@ -340,8 +342,8 @@ module Ci
# rubocop: disable CodeReuse/ServiceClass
Ci::RetryJobService.new(build.project, build.user).execute(build)
# rubocop: enable CodeReuse/ServiceClass
- rescue Gitlab::Access::AccessDeniedError => ex
- Gitlab::AppLogger.error "Unable to auto-retry job #{build.id}: #{ex}"
+ rescue Gitlab::Access::AccessDeniedError => e
+ Gitlab::AppLogger.error "Unable to auto-retry job #{build.id}: #{e}"
end
end
end
@@ -490,11 +492,7 @@ module Ci
if metadata&.expanded_environment_name.present?
metadata.expanded_environment_name
else
- if ::Feature.enabled?(:ci_expand_environment_name_and_url, project)
- ExpandVariables.expand(environment, -> { simple_variables.sort_and_expand_all })
- else
- ExpandVariables.expand(environment, -> { simple_variables })
- end
+ ExpandVariables.expand(environment, -> { simple_variables.sort_and_expand_all })
end
end
end
@@ -527,10 +525,14 @@ module Ci
self.options.fetch(:environment, {}).fetch(:action, 'start') if self.options
end
- def environment_deployment_tier
+ def environment_tier_from_options
self.options.dig(:environment, :deployment_tier) if self.options
end
+ def environment_tier
+ environment_tier_from_options || persisted_environment.try(:tier)
+ end
+
def triggered_by?(current_user)
user == current_user
end
@@ -585,6 +587,7 @@ module Ci
variables.concat(persisted_environment.predefined_variables)
variables.append(key: 'CI_ENVIRONMENT_ACTION', value: environment_action)
+ variables.append(key: 'CI_ENVIRONMENT_TIER', value: environment_tier)
# Here we're passing unexpanded environment_url for runner to expand,
# and we need to make sure that CI_ENVIRONMENT_NAME and
@@ -777,10 +780,20 @@ module Ci
pending? && !any_runners_online?
end
+ def feature_flagged_execute_hooks
+ if Feature.enabled?(:execute_build_hooks_inline, project)
+ execute_hooks
+ else
+ BuildHooksWorker.perform_async(self)
+ end
+ end
+
def execute_hooks
return unless project
return if user&.blocked?
+ ActiveRecord::Associations::Preloader.new.preload([self], { runner: :tags })
+
project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks)
project.execute_integrations(build_data.dup, :job_hooks) if project.has_active_integrations?(:job_hooks)
end
@@ -818,7 +831,11 @@ module Ci
)
end
- job_artifacts.erasable.destroy_all # rubocop: disable Cop/DestroyAll
+ destroyed_artifacts = job_artifacts.erasable.destroy_all # rubocop: disable Cop/DestroyAll
+
+ Gitlab::Ci::Artifacts::Logger.log_deleted(destroyed_artifacts, 'Ci::Build#erase_erasable_artifacts!')
+
+ destroyed_artifacts
end
def erase(opts = {})
@@ -831,7 +848,12 @@ module Ci
)
end
- job_artifacts.destroy_all # rubocop: disable Cop/DestroyAll
+ # TODO: We should use DestroyBatchService here
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/369132
+ destroyed_artifacts = job_artifacts.destroy_all # rubocop: disable Cop/DestroyAll
+
+ Gitlab::Ci::Artifacts::Logger.log_deleted(destroyed_artifacts, 'Ci::Build#erase')
+
erase_trace!
update_erased!(opts[:erased_by])
end
@@ -983,7 +1005,7 @@ module Ci
def collect_test_reports!(test_reports)
test_reports.get_suite(test_suite_name).tap do |test_suite|
- each_report(Ci::JobArtifact::TEST_REPORT_FILE_TYPES) do |file_type, blob|
+ each_report(Ci::JobArtifact.file_types_for_report(:test)) do |file_type, blob|
Gitlab::Ci::Parsers.fabricate!(file_type).parse!(
blob,
test_suite,
@@ -994,7 +1016,7 @@ module Ci
end
def collect_accessibility_reports!(accessibility_report)
- each_report(Ci::JobArtifact::ACCESSIBILITY_REPORT_FILE_TYPES) do |file_type, blob|
+ each_report(Ci::JobArtifact.file_types_for_report(:accessibility)) do |file_type, blob|
Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, accessibility_report)
end
@@ -1002,7 +1024,7 @@ module Ci
end
def collect_codequality_reports!(codequality_report)
- each_report(Ci::JobArtifact::CODEQUALITY_REPORT_FILE_TYPES) do |file_type, blob|
+ each_report(Ci::JobArtifact.file_types_for_report(:codequality)) do |file_type, blob|
Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, codequality_report)
end
@@ -1010,7 +1032,7 @@ module Ci
end
def collect_terraform_reports!(terraform_reports)
- each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact|
+ each_report(::Ci::JobArtifact.file_types_for_report(:terraform)) do |file_type, blob, report_artifact|
::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact)
end
@@ -1079,7 +1101,10 @@ module Ci
end
def drop_with_exit_code!(failure_reason, exit_code)
- drop!(::Gitlab::Ci::Build::Status::Reason.new(self, failure_reason, exit_code))
+ failure_reason ||= :unknown_failure
+ result = drop!(::Gitlab::Ci::Build::Status::Reason.new(self, failure_reason, exit_code))
+ ::Ci::TrackFailedBuildWorker.perform_async(id, exit_code, failure_reason)
+ result
end
def exit_codes_defined?
@@ -1149,6 +1174,21 @@ module Ci
end
end
+ def clone(current_user:, new_job_variables_attributes: [])
+ new_build = super
+
+ if action? && new_job_variables_attributes.any?
+ new_build.job_variables = []
+ new_build.job_variables_attributes = new_job_variables_attributes
+ end
+
+ new_build
+ end
+
+ def job_artifact_types
+ job_artifacts.map(&:file_type)
+ end
+
protected
def run_status_commit_hooks!
@@ -1256,6 +1296,20 @@ module Ci
expires_in: RUNNERS_STATUS_CACHE_EXPIRATION
) { yield }
end
+
+ def observe_report_types
+ return unless ::Gitlab.com? && Feature.enabled?(:report_artifact_build_completed_metrics_on_build_completion)
+
+ report_types = options&.dig(:artifacts, :reports)&.keys || []
+
+ report_types.each do |report_type|
+ next unless Ci::JobArtifact::REPORT_TYPES.include?(report_type)
+
+ ::Gitlab::Ci::Artifacts::Metrics
+ .build_completed_report_type_counter(report_type)
+ .increment(status: status)
+ end
+ end
end
end
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index 4ee661d89f4..5fc21ba3f28 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -19,6 +19,7 @@ module Ci
before_create :set_build_project
validates :build, presence: true
+ validates :id_tokens, json_schema: { filename: 'build_metadata_id_tokens' }
validates :secrets, json_schema: { filename: 'build_metadata_secrets' }
serialize :config_options, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize
diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb
index 1ffa0e31f99..86de90983ff 100644
--- a/app/models/ci/build_trace_metadata.rb
+++ b/app/models/ci/build_trace_metadata.rb
@@ -39,8 +39,8 @@ module Ci
def track_archival!(trace_artifact_id, checksum)
update!(trace_artifact_id: trace_artifact_id,
- checksum: checksum,
- archived_at: Time.current)
+ checksum: checksum,
+ archived_at: Time.current)
end
def archival_attempts_message
diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb
index aba7b73aba9..d36646aba66 100644
--- a/app/models/ci/deleted_object.rb
+++ b/app/models/ci/deleted_object.rb
@@ -27,8 +27,8 @@ module Ci
def delete_file_from_storage
file.remove!
true
- rescue StandardError => exception
- Gitlab::ErrorTracking.track_exception(exception)
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e)
false
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index ee7175a4f69..71d33f0bb63 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -13,14 +13,19 @@ module Ci
include EachBatch
include Gitlab::Utils::StrongMemoize
- TEST_REPORT_FILE_TYPES = %w[junit].freeze
- COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
- CODEQUALITY_REPORT_FILE_TYPES = %w[codequality].freeze
- ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze
NON_ERASABLE_FILE_TYPES = %w[trace].freeze
- TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze
- SAST_REPORT_TYPES = %w[sast].freeze
- SECRET_DETECTION_REPORT_TYPES = %w[secret_detection].freeze
+
+ REPORT_FILE_TYPES = {
+ sast: %w[sast],
+ secret_detection: %w[secret_detection],
+ test: %w[junit],
+ accessibility: %w[accessibility],
+ coverage: %w[cobertura],
+ codequality: %w[codequality],
+ terraform: %w[terraform],
+ sbom: %w[cyclonedx]
+ }.freeze
+
DEFAULT_FILE_NAMES = {
archive: nil,
metadata: nil,
@@ -48,7 +53,8 @@ module Ci
cluster_applications: 'gl-cluster-applications.json', # DEPRECATED: https://gitlab.com/gitlab-org/gitlab/-/issues/361094
requirements: 'requirements.json',
coverage_fuzzing: 'gl-coverage-fuzzing.json',
- api_fuzzing: 'gl-api-fuzzing-report.json'
+ api_fuzzing: 'gl-api-fuzzing-report.json',
+ cyclonedx: 'gl-sbom.cdx.zip'
}.freeze
INTERNAL_TYPES = {
@@ -88,7 +94,8 @@ module Ci
terraform: :raw,
requirements: :raw,
coverage_fuzzing: :raw,
- api_fuzzing: :raw
+ api_fuzzing: :raw,
+ cyclonedx: :zip
}.freeze
DOWNLOADABLE_TYPES = %w[
@@ -112,6 +119,7 @@ module Ci
secret_detection
requirements
cluster_image_scanning
+ cyclonedx
].freeze
TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
@@ -152,36 +160,14 @@ module Ci
where(file_type: types)
end
- scope :all_reports, -> do
- with_file_types(REPORT_TYPES.keys.map(&:to_s))
- end
-
- scope :sast_reports, -> do
- with_file_types(SAST_REPORT_TYPES)
- end
-
- scope :secret_detection_reports, -> do
- with_file_types(SECRET_DETECTION_REPORT_TYPES)
- end
-
- scope :test_reports, -> do
- with_file_types(TEST_REPORT_FILE_TYPES)
- end
-
- scope :accessibility_reports, -> do
- with_file_types(ACCESSIBILITY_REPORT_FILE_TYPES)
- end
-
- scope :coverage_reports, -> do
- with_file_types(COVERAGE_REPORT_FILE_TYPES)
- end
-
- scope :codequality_reports, -> do
- with_file_types(CODEQUALITY_REPORT_FILE_TYPES)
+ REPORT_FILE_TYPES.each do |report_type, file_types|
+ scope "#{report_type}_reports", -> do
+ with_file_types(file_types)
+ end
end
- scope :terraform_reports, -> do
- with_file_types(TERRAFORM_REPORT_FILE_TYPES)
+ scope :all_reports, -> do
+ with_file_types(REPORT_TYPES.keys.map(&:to_s))
end
scope :erasable, -> do
@@ -225,7 +211,8 @@ module Ci
browser_performance: 24, ## EE-specific
load_performance: 25, ## EE-specific
api_fuzzing: 26, ## EE-specific
- cluster_image_scanning: 27 ## EE-specific
+ cluster_image_scanning: 27, ## EE-specific
+ cyclonedx: 28 ## EE-specific
}
# `file_location` indicates where actual files are stored.
@@ -259,6 +246,10 @@ module Ci
end
end
+ def self.file_types_for_report(report_type)
+ REPORT_FILE_TYPES.fetch(report_type)
+ end
+
def self.associated_file_types_for(file_type)
return unless file_types.include?(file_type)
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 95c6da4a7af..a94330270e2 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -52,15 +52,15 @@ module Ci
belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines
has_internal_id :iid, scope: :project, presence: false,
- track_if: -> { !importing? },
- ensure_if: -> { !importing? },
- init: ->(pipeline, scope) do
- if pipeline
- pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count
- elsif scope
- ::Ci::Pipeline.where(**scope).maximum(:iid)
- end
- end
+ track_if: -> { !importing? },
+ ensure_if: -> { !importing? },
+ init: ->(pipeline, scope) do
+ if pipeline
+ pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count
+ elsif scope
+ ::Ci::Pipeline.where(**scope).maximum(:iid)
+ end
+ end
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
@@ -102,6 +102,7 @@ module Ci
has_one :chat_data, class_name: 'Ci::PipelineChatData'
has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline
+ # Only includes direct and not nested children
has_many :child_pipelines, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :sourced_pipelines, source: :pipeline
has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline
has_one :parent_pipeline, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :source_pipeline, source: :source_pipeline
@@ -389,7 +390,7 @@ module Ci
end
def self.latest_status(ref = nil)
- newest_first(ref: ref).pluck(:status).first
+ newest_first(ref: ref).pick(:status)
end
def self.latest_successful_for_ref(ref)
@@ -592,26 +593,20 @@ module Ci
canceled? && auto_canceled_by_id?
end
- def cancel_running(retries: 1)
- preloaded_relations = [:project, :pipeline, :deployment, :taggings]
-
- retry_lock(cancelable_statuses, retries, name: 'ci_pipeline_cancel_running') do |cancelables|
- cancelables.find_in_batches do |batch|
- Preloaders::CommitStatusPreloader.new(batch).execute(preloaded_relations)
-
- batch.each do |job|
- yield(job) if block_given?
- job.cancel
- end
- end
- end
- end
+ # Cancel a pipelines cancelable jobs and optionally it's child pipelines cancelable jobs
+ # retries - # of times to retry if errors
+ # cascade_to_children - if true cancels all related child pipelines for parent child pipelines
+ # auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation
+ # execute_async - if true cancel the children asyncronously
+ def cancel_running(retries: 1, cascade_to_children: true, auto_canceled_by_pipeline_id: nil, execute_async: true)
+ update(auto_canceled_by_id: auto_canceled_by_pipeline_id) if auto_canceled_by_pipeline_id
- def auto_cancel_running(pipeline, retries: 1)
- update(auto_canceled_by: pipeline)
+ cancel_jobs(cancelable_statuses, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id)
- cancel_running(retries: retries) do |job|
- job.auto_canceled_by = pipeline
+ if cascade_to_children
+ # cancel any bridges that could spin up new child pipelines
+ cancel_jobs(bridges_in_self_and_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id)
+ cancel_children(auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, execute_async: execute_async)
end
end
@@ -953,6 +948,10 @@ module Ci
Ci::Build.latest.where(pipeline: self_and_descendants)
end
+ def bridges_in_self_and_descendants
+ Ci::Bridge.latest.where(pipeline: self_and_descendants)
+ end
+
def environments_in_self_and_descendants(deployment_status: nil)
# We limit to 100 unique environments for application safety.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700
@@ -986,6 +985,11 @@ module Ci
object_hierarchy(project_condition: :same).base_and_descendants
end
+ # With only parent-child pipelines
+ def all_child_pipelines
+ object_hierarchy(project_condition: :same).descendants
+ end
+
def self_and_descendants_complete?
self_and_descendants.all?(&:complete?)
end
@@ -1152,6 +1156,10 @@ module Ci
end
end
+ def modified_paths_since(compare_to_sha)
+ project.repository.diff_stats(project.repository.merge_base(compare_to_sha, sha), sha).paths
+ end
+
def all_worktree_paths
strong_memoize(:all_worktree_paths) do
project.repository.ls_files(sha)
@@ -1216,10 +1224,6 @@ module Ci
stages.find_by(name: name)
end
- def find_stage_by_name!(name)
- stages.find_by!(name: name)
- end
-
def full_error_messages
errors ? errors.full_messages.to_sentence : ""
end
@@ -1321,6 +1325,42 @@ module Ci
private
+ def cancel_jobs(jobs, retries: 1, auto_canceled_by_pipeline_id: nil)
+ retry_lock(jobs, retries, name: 'ci_pipeline_cancel_running') do |statuses|
+ preloaded_relations = [:project, :pipeline, :deployment, :taggings]
+
+ statuses.find_in_batches do |status_batch|
+ relation = CommitStatus.where(id: status_batch)
+ Preloaders::CommitStatusPreloader.new(relation).execute(preloaded_relations)
+
+ relation.each do |job|
+ job.auto_canceled_by_id = auto_canceled_by_pipeline_id if auto_canceled_by_pipeline_id
+ job.cancel
+ end
+ end
+ end
+ end
+
+ # For parent child-pipelines only (not multi-project)
+ def cancel_children(auto_canceled_by_pipeline_id: nil, execute_async: true)
+ all_child_pipelines.each do |child_pipeline|
+ if execute_async
+ ::Ci::CancelPipelineWorker.perform_async(
+ child_pipeline.id,
+ auto_canceled_by_pipeline_id
+ )
+ else
+ child_pipeline.cancel_running(
+ # cascade_to_children is false because we iterate through children
+ # we also cancel bridges prior to prevent more children
+ cascade_to_children: false,
+ execute_async: execute_async,
+ auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id
+ )
+ end
+ end
+ end
+
def add_message(severity, content)
messages.build(severity: severity, content: content)
end
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index f666629c8fd..a2ff49077be 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -101,7 +101,7 @@ module Ci
:merge_train_pipeline?,
to: :pipeline
- def clone(current_user:)
+ def clone(current_user:, new_job_variables_attributes: [])
new_attributes = self.class.clone_accessors.to_h do |attribute|
[attribute, public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index f41ad890184..6c3754d84d0 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -15,7 +15,7 @@ module Ci
include Presentable
include EachBatch
- ignore_column :semver, remove_with: '15.3', remove_after: '2022-07-22'
+ ignore_column :semver, remove_with: '15.4', remove_after: '2022-08-22'
add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced?
@@ -437,7 +437,12 @@ module Ci
cache_attributes(values)
# We save data without validation, it will always change due to `contacted_at`
- self.update_columns(values) if persist_cached_data?
+ if persist_cached_data?
+ version_updated = values.include?(:version) && values[:version] != version
+
+ update_columns(values)
+ schedule_runner_version_update if version_updated
+ end
end
end
@@ -477,7 +482,7 @@ module Ci
private
scope :with_upgrade_status, ->(upgrade_status) do
- Ci::Runner.joins(:runner_version).where(runner_version: { status: upgrade_status })
+ joins(:runner_version).where(runner_version: { status: upgrade_status })
end
EXECUTOR_NAME_TO_TYPES = {
@@ -565,6 +570,12 @@ module Ci
errors.add(:runner, 'needs to be assigned to exactly one group')
end
end
+
+ def schedule_runner_version_update
+ return unless version
+
+ Ci::Runners::ProcessRunnerVersionUpdateWorker.perform_async(version)
+ end
end
end
diff --git a/app/models/ci/runner_version.rb b/app/models/ci/runner_version.rb
index 6b2d0060c9b..bbde98ee591 100644
--- a/app/models/ci/runner_version.rb
+++ b/app/models/ci/runner_version.rb
@@ -8,7 +8,6 @@ module Ci
enum_with_nil status: {
not_processed: nil,
invalid_version: -1,
- unknown: 0,
not_available: 1,
available: 2,
recommended: 3
@@ -16,7 +15,6 @@ module Ci
STATUS_DESCRIPTIONS = {
invalid_version: 'Runner version is not valid.',
- unknown: 'Upgrade status is unknown.',
not_available: 'Upgrade is not available for the runner.',
available: 'Upgrade is available for the runner.',
recommended: 'Upgrade is available and recommended for the runner.'
@@ -27,7 +25,7 @@ module Ci
# This scope returns all versions that might need recalculating. For instance, once a version is considered
# :recommended, it normally doesn't change status even if the instance is upgraded
- scope :potentially_outdated, -> { where(status: [nil, :not_available, :available, :unknown]) }
+ scope :potentially_outdated, -> { where(status: [nil, :not_available, :available]) }
validates :version, length: { maximum: 2048 }
end
diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb
index 078b05ff779..9a35f1876c9 100644
--- a/app/models/ci/secure_file.rb
+++ b/app/models/ci/secure_file.rb
@@ -3,11 +3,8 @@
module Ci
class SecureFile < Ci::ApplicationRecord
include FileStoreMounter
- include IgnorableColumns
include Limitable
- ignore_column :permissions, remove_with: '15.2', remove_after: '2022-06-22'
-
FILE_SIZE_LIMIT = 5.megabytes.freeze
CHECKSUM_ALGORITHM = 'sha256'
@@ -24,6 +21,7 @@ module Ci
before_validation :assign_checksum
scope :order_by_created_at, -> { order(created_at: :desc) }
+ scope :project_id_in, ->(ids) { where(project_id: ids) }
default_value_for(:file_store) { Ci::SecureFileUploader.default_store }
@@ -46,3 +44,5 @@ module Ci
end
end
end
+
+Ci::SecureFile.prepend_mod
diff --git a/app/models/commit.rb b/app/models/commit.rb
index ca18cb50e02..bd60f02b532 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -190,7 +190,7 @@ class Commit
def self.link_reference_pattern
@link_reference_pattern ||=
- super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/)
+ super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/o)
end
def to_reference(from = nil, full: false)
diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb
index 08f1eb3731e..e2f0de52bc9 100644
--- a/app/models/commit_range.rb
+++ b/app/models/commit_range.rb
@@ -50,7 +50,7 @@ class CommitRange
end
def self.link_reference_pattern
- @link_reference_pattern ||= super("compare", /(?<commit_range>#{PATTERN})/)
+ @link_reference_pattern ||= super("compare", /(?<commit_range>#{PATTERN})/o)
end
# Initialize a CommitRange
@@ -64,7 +64,7 @@ class CommitRange
range_string = range_string.strip
- unless range_string =~ /\A#{PATTERN}\z/
+ unless range_string =~ /\A#{PATTERN}\z/o
raise ArgumentError, "invalid CommitRange string format: #{range_string}"
end
diff --git a/app/models/commit_signatures/ssh_signature.rb b/app/models/commit_signatures/ssh_signature.rb
index dbfbe0c3889..7a8d0653fcd 100644
--- a/app/models/commit_signatures/ssh_signature.rb
+++ b/app/models/commit_signatures/ssh_signature.rb
@@ -4,6 +4,6 @@ module CommitSignatures
class SshSignature < ApplicationRecord
include CommitSignature
- belongs_to :key, optional: false
+ belongs_to :key, optional: true
end
end
diff --git a/app/models/compare.rb b/app/models/compare.rb
index 7f42e1ee491..f594a796987 100644
--- a/app/models/compare.rb
+++ b/app/models/compare.rb
@@ -92,7 +92,7 @@ class Compare
def diff_refs
Gitlab::Diff::DiffRefs.new(
- base_sha: @straight ? start_commit_sha : base_commit_sha,
+ base_sha: @straight ? start_commit_sha : base_commit_sha,
start_sha: start_commit_sha,
head_sha: head_commit_sha
)
diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb
index fb4ea4206f4..ee8e98ec1bf 100644
--- a/app/models/concerns/ci/artifactable.rb
+++ b/app/models/concerns/ci/artifactable.rb
@@ -5,11 +5,13 @@ module Ci
extend ActiveSupport::Concern
include ObjectStorable
+ include Gitlab::Ci::Artifacts::Logger
STORE_COLUMN = :file_store
NotSupportedAdapterError = Class.new(StandardError)
FILE_FORMAT_ADAPTERS = {
gzip: Gitlab::Ci::Build::Artifacts::Adapters::GzipStream,
+ zip: Gitlab::Ci::Build::Artifacts::Adapters::ZipStream,
raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream
}.freeze
@@ -30,7 +32,7 @@ module Ci
raise NotSupportedAdapterError, 'This file format requires a dedicated adapter'
end
- ::Gitlab::ApplicationContext.push(artifact: file.model)
+ log_artifacts_filesize(file.model)
file.open do |stream|
file_format_adapter_class.new(stream).each_blob(&blk)
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
index 721cb14201f..910885c833f 100644
--- a/app/models/concerns/ci/has_status.rb
+++ b/app/models/concerns/ci/has_status.rb
@@ -17,8 +17,8 @@ module Ci
ALIVE_STATUSES = (ACTIVE_STATUSES + ['created']).freeze
CANCELABLE_STATUSES = (ALIVE_STATUSES + ['scheduled']).freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
- failed: 4, canceled: 5, skipped: 6, manual: 7,
- scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze
+ failed: 4, canceled: 5, skipped: 6, manual: 7,
+ scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze
UnknownStatusError = Class.new(StandardError)
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
index aa9669ee208..8c3a05c23f0 100644
--- a/app/models/concerns/ci/metadatable.rb
+++ b/app/models/concerns/ci/metadatable.rb
@@ -20,6 +20,8 @@ module Ci
delegate :interruptible, to: :metadata, prefix: false, allow_nil: true
delegate :environment_auto_stop_in, to: :metadata, prefix: false, allow_nil: true
delegate :set_cancel_gracefully, to: :metadata, prefix: false, allow_nil: false
+ delegate :id_tokens, to: :metadata, allow_nil: true
+
before_create :ensure_metadata
end
@@ -77,6 +79,14 @@ module Ci
ensure_metadata.interruptible = value
end
+ def id_tokens?
+ !!metadata&.id_tokens?
+ end
+
+ def id_tokens=(value)
+ ensure_metadata.id_tokens = value
+ end
+
private
def read_metadata_attribute(legacy_key, metadata_key, default_value = nil)
diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb
index b41b1ba6008..65cf3246d11 100644
--- a/app/models/concerns/counter_attribute.rb
+++ b/app/models/concerns/counter_attribute.rb
@@ -82,18 +82,23 @@ module CounterAttribute
lock_key = counter_lock_key(attribute)
with_exclusive_lease(lock_key) do
+ previous_db_value = read_attribute(attribute)
increment_key = counter_key(attribute)
flushed_key = counter_flushed_key(attribute)
increment_value = steal_increments(increment_key, flushed_key)
+ new_db_value = nil
next if increment_value == 0
transaction do
unsafe_update_counters(id, attribute => increment_value)
redis_state { |redis| redis.del(flushed_key) }
+ new_db_value = reset.read_attribute(attribute)
end
execute_after_flush_callbacks
+
+ log_flush_counter(attribute, increment_value, previous_db_value, new_db_value)
end
end
@@ -115,15 +120,19 @@ module CounterAttribute
def increment_counter(attribute, increment)
if counter_attribute_enabled?(attribute)
- redis_state do |redis|
+ new_value = redis_state do |redis|
redis.incrby(counter_key(attribute), increment)
end
+
+ log_increment_counter(attribute, increment, new_value)
end
end
def clear_counter!(attribute)
if counter_attribute_enabled?(attribute)
redis_state { |redis| redis.del(counter_key(attribute)) }
+
+ log_clear_counter(attribute)
end
end
@@ -184,4 +193,40 @@ module CounterAttribute
rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
# a worker is already updating the counters
end
+
+ def log_increment_counter(attribute, increment, new_value)
+ payload = Gitlab::ApplicationContext.current.merge(
+ message: 'Increment counter attribute',
+ attribute: attribute,
+ project_id: project_id,
+ increment: increment,
+ new_counter_value: new_value,
+ current_db_value: read_attribute(attribute)
+ )
+
+ Gitlab::AppLogger.info(payload)
+ end
+
+ def log_flush_counter(attribute, increment, previous_db_value, new_db_value)
+ payload = Gitlab::ApplicationContext.current.merge(
+ message: 'Flush counter attribute to database',
+ attribute: attribute,
+ project_id: project_id,
+ increment: increment,
+ previous_db_value: previous_db_value,
+ new_db_value: new_db_value
+ )
+
+ Gitlab::AppLogger.info(payload)
+ end
+
+ def log_clear_counter(attribute)
+ payload = Gitlab::ApplicationContext.current.merge(
+ message: 'Clear counter attribute',
+ attribute: attribute,
+ project_id: project_id
+ )
+
+ Gitlab::AppLogger.info(payload)
+ end
end
diff --git a/app/models/concerns/cross_database_modification.rb b/app/models/concerns/cross_database_modification.rb
index dea62f03f91..273d5f35e76 100644
--- a/app/models/concerns/cross_database_modification.rb
+++ b/app/models/concerns/cross_database_modification.rb
@@ -80,34 +80,22 @@ module CrossDatabaseModification
end
def transaction(**options, &block)
- if track_gitlab_schema_in_current_transaction?
- super(**options) do
- # Hook into current transaction to ensure that once
- # the `COMMIT` is executed the `gitlab_transactions_stack`
- # will be allowing to execute `after_commit_queue`
- record = TransactionStackTrackRecord.new(self, gitlab_schema)
-
- begin
- connection.current_transaction.add_record(record)
-
- yield
- ensure
- record.done!
- end
+ super(**options) do
+ # Hook into current transaction to ensure that once
+ # the `COMMIT` is executed the `gitlab_transactions_stack`
+ # will be allowing to execute `after_commit_queue`
+ record = TransactionStackTrackRecord.new(self, gitlab_schema)
+
+ begin
+ connection.current_transaction.add_record(record)
+
+ yield
+ ensure
+ record.done!
end
- else
- super(**options, &block)
end
end
- def track_gitlab_schema_in_current_transaction?
- return false unless Feature::FlipperFeature.table_exists?
-
- Feature.enabled?(:track_gitlab_schema_in_current_transaction)
- rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad
- false
- end
-
def gitlab_schema
case self.name
when 'ActiveRecord::Base', 'ApplicationRecord'
diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb
new file mode 100644
index 00000000000..9f75b3ed4d8
--- /dev/null
+++ b/app/models/concerns/database_event_tracking.rb
@@ -0,0 +1,53 @@
+# 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)
+ return unless Feature.enabled?(:product_intelligence_database_event_tracking)
+
+ # 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.event(
+ self.class.to_s,
+ "database_event_#{name}",
+ label: self.class.table_name,
+ namespace: try(:group) || try(: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/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb
index 051158e5de5..7a6076c7d2e 100644
--- a/app/models/concerns/diff_positionable_note.rb
+++ b/app/models/concerns/diff_positionable_note.rb
@@ -17,7 +17,11 @@ module DiffPositionableNote
%i(original_position position change_position).each do |meth|
define_method "#{meth}=" do |new_position|
if new_position.is_a?(String)
- new_position = Gitlab::Json.parse(new_position) rescue nil
+ new_position = begin
+ Gitlab::Json.parse(new_position)
+ rescue StandardError
+ nil
+ end
end
if new_position.is_a?(Hash)
diff --git a/app/models/concerns/enums/data_visualization_palette.rb b/app/models/concerns/enums/data_visualization_palette.rb
index 25002e64ba6..6e712e79915 100644
--- a/app/models/concerns/enums/data_visualization_palette.rb
+++ b/app/models/concerns/enums/data_visualization_palette.rb
@@ -16,17 +16,17 @@ module Enums
def self.weights
{
- '50' => 0,
- '100' => 1,
- '200' => 2,
- '300' => 3,
- '400' => 4,
- '500' => 5,
- '600' => 6,
- '700' => 7,
- '800' => 8,
- '900' => 9,
- '950' => 10
+ '50' => 0,
+ '100' => 1,
+ '200' => 2,
+ '300' => 3,
+ '400' => 4,
+ '500' => 5,
+ '600' => 6,
+ '700' => 7,
+ '800' => 8,
+ '900' => 9,
+ '950' => 10
}
end
end
diff --git a/app/models/concerns/enums/sbom.rb b/app/models/concerns/enums/sbom.rb
new file mode 100644
index 00000000000..518efa669ad
--- /dev/null
+++ b/app/models/concerns/enums/sbom.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Enums
+ class Sbom
+ COMPONENT_TYPES = {
+ library: 0
+ }.with_indifferent_access.freeze
+
+ def self.component_types
+ COMPONENT_TYPES
+ end
+ end
+end
diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb
index e029ada84f0..5975ea23723 100644
--- a/app/models/concerns/expirable.rb
+++ b/app/models/concerns/expirable.rb
@@ -6,7 +6,10 @@ module Expirable
DAYS_TO_EXPIRE = 7
included do
- scope :expired, -> { where('expires_at <= ?', Time.current) }
+ scope :not, ->(scope) { where(scope.arel.constraints.reduce(:and).not) }
+
+ scope :expired, -> { where('expires_at IS NOT NULL AND expires_at <= ?', Time.current) }
+ scope :not_expired, -> { self.not(expired) }
end
def expired?
diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb
index 08189d83534..3b741208221 100644
--- a/app/models/concerns/featurable.rb
+++ b/app/models/concerns/featurable.rb
@@ -30,9 +30,9 @@ module Featurable
STRING_OPTIONS = HashWithIndifferentAccess.new({
'disabled' => DISABLED,
- 'private' => PRIVATE,
- 'enabled' => ENABLED,
- 'public' => PUBLIC
+ 'private' => PRIVATE,
+ 'enabled' => ENABLED,
+ 'public' => PUBLIC
}).freeze
class_methods do
@@ -114,7 +114,7 @@ module Featurable
self.errors.add(field, "cannot have public visibility level") if not_allowed
end
- (self.class.available_features - feature_validation_exclusion).each {|f| validator.call("#{f}_access_level")}
+ (self.class.available_features - feature_validation_exclusion).each { |f| validator.call("#{f}_access_level") }
end
# Features that we should exclude from the validation
diff --git a/app/models/concerns/integrations/base_data_fields.rb b/app/models/concerns/integrations/base_data_fields.rb
index 11bdd3aae7b..2870922d90d 100644
--- a/app/models/concerns/integrations/base_data_fields.rb
+++ b/app/models/concerns/integrations/base_data_fields.rb
@@ -4,15 +4,10 @@ module Integrations
module BaseDataFields
extend ActiveSupport::Concern
- LEGACY_FOREIGN_KEY_NAME = %w(
- Integrations::IssueTrackerData
- Integrations::JiraTrackerData
- ).freeze
-
included do
# TODO: Once we rename the tables we can't rely on `table_name` anymore.
# https://gitlab.com/gitlab-org/gitlab/-/issues/331953
- belongs_to :integration, inverse_of: self.table_name.to_sym, foreign_key: foreign_key_name
+ belongs_to :integration, inverse_of: self.table_name.to_sym, foreign_key: :integration_id
validates :integration, presence: true
end
@@ -26,16 +21,6 @@ module Integrations
algorithm: 'aes-256-gcm'
}
end
-
- private
-
- # Older data field models use the `service_id` foreign key for the
- # integration association.
- def foreign_key_name
- return :service_id if self.name.in?(LEGACY_FOREIGN_KEY_NAME)
-
- :integration_id
- end
end
def activated?
diff --git a/app/models/concerns/integrations/has_data_fields.rb b/app/models/concerns/integrations/has_data_fields.rb
index 635147a2f3c..2671df873aa 100644
--- a/app/models/concerns/integrations/has_data_fields.rb
+++ b/app/models/concerns/integrations/has_data_fields.rb
@@ -44,8 +44,8 @@ module Integrations
end
included do
- has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::IssueTrackerData'
- has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::JiraTrackerData'
+ has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::IssueTrackerData'
+ has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::JiraTrackerData'
has_one :zentao_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::ZentaoTrackerData'
def data_fields
diff --git a/app/models/concerns/integrations/has_web_hook.rb b/app/models/concerns/integrations/has_web_hook.rb
index bc28c32695c..e6ca6cc7938 100644
--- a/app/models/concerns/integrations/has_web_hook.rb
+++ b/app/models/concerns/integrations/has_web_hook.rb
@@ -6,6 +6,7 @@ module Integrations
included do
after_save :update_web_hook!, if: :activated?
+ has_one :service_hook, inverse_of: :integration, foreign_key: :service_id
end
# Return the URL to be used for the webhook.
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 4dca07132ef..b81a9b51e1c 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -515,11 +515,23 @@ module Issuable
changes
end
+ def hook_reviewer_changes(old_associations)
+ changes = {}
+ old_reviewers = old_associations.fetch(:reviewers, reviewers)
+
+ if old_reviewers != reviewers
+ changes[:reviewers] = [old_reviewers.map(&:hook_attrs), reviewers.map(&:hook_attrs)]
+ end
+
+ changes
+ end
+
def to_hook_data(user, old_associations: {})
changes = previous_changes
if old_associations.present?
changes.merge!(hook_association_changes(old_associations))
+ changes.merge!(hook_reviewer_changes(old_associations)) if allows_reviewers?
end
Gitlab::DataBuilder::Issuable.new(self).build(user: user, changes: changes)
@@ -537,6 +549,10 @@ module Issuable
labels.map(&:hook_attrs)
end
+ def allows_scoped_labels?
+ false
+ end
+
# Convert this Issuable class name to a format usable by Ability definitions
#
# Examples:
@@ -550,7 +566,7 @@ module Issuable
# Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes
{
- 'Author' => author.try(:name),
+ 'Author' => author.try(:name),
'Assignee' => assignee_list
}
end
diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb
index f59b5d1ecc8..8130adf05f1 100644
--- a/app/models/concerns/participable.rb
+++ b/app/models/concerns/participable.rb
@@ -109,6 +109,7 @@ module Participable
when User
participants << source
when Participable
+ next if skippable_system_notes?(source, participants)
next unless !verify_access || source_visible_to_user?(source, current_user)
source.class.participant_attrs.each do |attr|
@@ -133,6 +134,13 @@ module Participable
participants.merge(extractor.users)
end
+ def skippable_system_notes?(source, participants)
+ source.is_a?(Note) &&
+ source.system? &&
+ source.author.in?(participants) &&
+ !source.note.match?(User.reference_pattern)
+ end
+
def use_internal_notes_extractor_for?(source)
source.is_a?(Note) && source.confidential?
end
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 900e8f7d39b..7613691bc2e 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -94,6 +94,18 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:container_registry_access_level, value)
end
+ def environments_access_level=(value)
+ write_feature_attribute_string(:environments_access_level, value)
+ end
+
+ def feature_flags_access_level=(value)
+ write_feature_attribute_string(:feature_flags_access_level, value)
+ end
+
+ def releases_access_level=(value)
+ write_feature_attribute_string(:releases_access_level, value)
+ end
+
# TODO: Remove this method after we drop support for project create/edit APIs to set the
# container_registry_enabled attribute. They can instead set the container_registry_access_level
# attribute.
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index 86280097d19..df297017119 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -62,8 +62,8 @@ module PrometheusAdapter
data: data,
last_update: Time.current.utc
}
- rescue Gitlab::PrometheusClient::Error => err
- { success: false, result: err.message }
+ rescue Gitlab::PrometheusClient::Error => e
+ { success: false, result: e.message }
end
def query_klass_for(query_name)
diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb
index 1dd8eebeff3..b7fd52ab305 100644
--- a/app/models/concerns/repository_storage_movable.rb
+++ b/app/models/concerns/repository_storage_movable.rb
@@ -50,8 +50,8 @@ module RepositoryStorageMovable
begin
storage_move.container.set_repository_read_only!(skip_git_transfer_check: true)
- rescue StandardError => err
- storage_move.add_error(err.message)
+ rescue StandardError => e
+ storage_move.add_error(e.message)
next false
end
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index 904c96b11b3..ee5774d4868 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -59,7 +59,7 @@ module Taskable
end
# Return a string that describes the current state of this Taskable's task
- # list items, e.g. "12 of 20 tasks completed"
+ # list items, e.g. "12 of 20 checklist items completed"
def task_status(short: false)
return '' if description.blank?
@@ -70,7 +70,7 @@ module Taskable
end
sum = tasks.summary
- "#{sum.complete_count}#{prep}#{sum.item_count} #{'task'.pluralize(sum.item_count)}#{completed}"
+ "#{sum.complete_count}#{prep}#{sum.item_count} #{'checklist item'.pluralize(sum.item_count)}#{completed}"
end
# Return a short string that describes the current state of this Taskable's
diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb
index 8fe34632430..e3800caa43f 100644
--- a/app/models/concerns/triggerable_hooks.rb
+++ b/app/models/concerns/triggerable_hooks.rb
@@ -2,22 +2,22 @@
module TriggerableHooks
AVAILABLE_TRIGGERS = {
- repository_update_hooks: :repository_update_events,
- push_hooks: :push_events,
- tag_push_hooks: :tag_push_events,
- issue_hooks: :issues_events,
- confidential_note_hooks: :confidential_note_events,
+ repository_update_hooks: :repository_update_events,
+ push_hooks: :push_events,
+ tag_push_hooks: :tag_push_events,
+ issue_hooks: :issues_events,
+ confidential_note_hooks: :confidential_note_events,
confidential_issue_hooks: :confidential_issues_events,
- note_hooks: :note_events,
- merge_request_hooks: :merge_requests_events,
- job_hooks: :job_events,
- pipeline_hooks: :pipeline_events,
- wiki_page_hooks: :wiki_page_events,
- deployment_hooks: :deployment_events,
- feature_flag_hooks: :feature_flag_events,
- release_hooks: :releases_events,
- member_hooks: :member_events,
- subgroup_hooks: :subgroup_events
+ note_hooks: :note_events,
+ merge_request_hooks: :merge_requests_events,
+ job_hooks: :job_events,
+ pipeline_hooks: :pipeline_events,
+ wiki_page_hooks: :wiki_page_events,
+ deployment_hooks: :deployment_events,
+ feature_flag_hooks: :feature_flag_events,
+ release_hooks: :releases_events,
+ member_hooks: :member_events,
+ subgroup_hooks: :subgroup_events
}.freeze
extend ActiveSupport::Concern
diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb
index 4cf36f83857..b5d48260072 100644
--- a/app/models/concerns/vulnerability_finding_helpers.rb
+++ b/app/models/concerns/vulnerability_finding_helpers.rb
@@ -50,7 +50,7 @@ module VulnerabilityFindingHelpers
finding_data = report_finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :links, :signatures,
:flags, :evidence)
identifiers = report_finding.identifiers.map do |identifier|
- Vulnerabilities::Identifier.new(identifier.to_hash)
+ Vulnerabilities::Identifier.new(identifier.to_hash.merge({ project: project }))
end
signatures = report_finding.signatures.map do |signature|
Vulnerabilities::FindingSignature.new(signature.to_hash)
@@ -72,6 +72,7 @@ module VulnerabilityFindingHelpers
end
finding.identifiers = identifiers
+ finding.primary_identifier = identifiers.first
finding.signatures = signatures
end
end
diff --git a/app/models/concerns/x509_serial_number_attribute.rb b/app/models/concerns/x509_serial_number_attribute.rb
index e51ed95bf70..9dc53859ac0 100644
--- a/app/models/concerns/x509_serial_number_attribute.rb
+++ b/app/models/concerns/x509_serial_number_attribute.rb
@@ -33,8 +33,8 @@ module X509SerialNumberAttribute
unless column.type == :binary
raise ArgumentError, "x509_serial_number_attribute #{name.inspect} is invalid since the column type is not :binary"
end
- rescue StandardError => error
- Gitlab::AppLogger.error "X509SerialNumberAttribute initialization: #{error.message}"
+ rescue StandardError => e
+ Gitlab::AppLogger.error "X509SerialNumberAttribute initialization: #{e.message}"
raise
end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index cdfd24e00aa..e10452c1081 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -19,6 +19,8 @@ class ContainerRepository < ApplicationRecord
MIGRATION_PHASE_1_STARTED_AT = Date.new(2021, 11, 4).freeze
MIGRATION_PHASE_1_ENDED_AT = Date.new(2022, 01, 23).freeze
+ MAX_TAGS_PAGES = 2000
+
TooManyImportsError = Class.new(StandardError)
belongs_to :project
@@ -377,6 +379,10 @@ class ContainerRepository < ApplicationRecord
migration_retries_count >= ContainerRegistry::Migration.max_retries - 1
end
+ def migrated?
+ MIGRATION_PHASE_1_ENDED_AT < self.created_at || import_done?
+ end
+
def last_import_step_done_at
[migration_pre_import_done_at, migration_import_done_at, migration_aborted_at, migration_skipped_at].compact.max
end
@@ -427,6 +433,32 @@ class ContainerRepository < ApplicationRecord
end
end
+ def each_tags_page(page_size: 100, &block)
+ raise ArgumentError, 'not a migrated repository' unless migrated?
+ raise ArgumentError, 'block not given' unless block
+
+ # dummy uri to initialize the loop
+ next_page_uri = URI('')
+ page_count = 0
+
+ while next_page_uri && page_count < MAX_TAGS_PAGES
+ last = Rack::Utils.parse_nested_query(next_page_uri.query)['last']
+ current_page = gitlab_api_client.tags(self.path, page_size: page_size, last: last)
+
+ if current_page&.key?(:response_body)
+ yield transform_tags_page(current_page[:response_body])
+ next_page_uri = current_page.dig(:pagination, :next, :uri)
+ else
+ # no current page. Break the loop
+ next_page_uri = nil
+ end
+
+ page_count += 1
+ end
+
+ raise 'too many pages requested' if page_count >= MAX_TAGS_PAGES
+ end
+
def tags_count
return 0 unless manifest && manifest['tags']
@@ -550,7 +582,7 @@ class ContainerRepository < ApplicationRecord
def self.find_by_path(path)
self.find_by(project: path.repository_project,
- name: path.repository_name)
+ name: path.repository_name)
end
private
@@ -559,6 +591,16 @@ class ContainerRepository < ApplicationRecord
self.migration_skipped_reason = reason
finish_import
end
+
+ def transform_tags_page(tags_response_body)
+ return [] unless tags_response_body
+
+ tags_response_body.map do |raw_tag|
+ tag = ContainerRegistry::Tag.new(self, raw_tag['name'])
+ tag.force_created_at_from_iso8601(raw_tag['created_at'])
+ tag
+ end
+ end
end
ContainerRepository.prepend_mod_with('ContainerRepository')
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 09fbb93525b..625d68925c6 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -22,7 +22,7 @@ class CustomEmoji < ApplicationRecord
presence: true,
length: { maximum: 36 },
- format: { with: /\A#{NAME_REGEXP}\z/ }
+ format: { with: /\A#{NAME_REGEXP}\z/o }
scope :by_name, -> (names) { where(name: names) }
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index 0f13c45b84d..f6455da890b 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -29,6 +29,12 @@ class CustomerRelations::Contact < ApplicationRecord
validate :validate_email_format
validate :validate_root_group
+ scope :order_scope_asc, ->(field) { order(arel_table[field].asc.nulls_last) }
+ scope :order_scope_desc, ->(field) { order(arel_table[field].desc.nulls_last) }
+
+ scope :order_by_organization_asc, -> { includes(:organization).order("customer_relations_organizations.name ASC NULLS LAST") }
+ scope :order_by_organization_desc, -> { includes(:organization).order("customer_relations_organizations.name DESC NULLS LAST") }
+
def self.reference_prefix
'[contact:'
end
@@ -56,6 +62,22 @@ class CustomerRelations::Contact < ApplicationRecord
where(state: state)
end
+ def self.sort_by_field(field, direction)
+ if direction == :asc
+ order_scope_asc(field)
+ else
+ order_scope_desc(field)
+ end
+ end
+
+ def self.sort_by_organization(direction)
+ if direction == :asc
+ order_by_organization_asc
+ else
+ order_by_organization_desc
+ end
+ end
+
def self.sort_by_name
order(Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
@@ -115,6 +137,10 @@ class CustomerRelations::Contact < ApplicationRecord
where(group: group).update_all(group_id: group.root_ancestor.id)
end
+ def self.counts_by_state
+ group(:state).count
+ end
+
private
def validate_email_format
diff --git a/app/models/customer_relations/contact_state_counts.rb b/app/models/customer_relations/contact_state_counts.rb
new file mode 100644
index 00000000000..31c95e166bb
--- /dev/null
+++ b/app/models/customer_relations/contact_state_counts.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module CustomerRelations
+ class ContactStateCounts
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :group
+
+ def self.declarative_policy_class
+ 'CustomerRelations::ContactPolicy'
+ end
+
+ def initialize(current_user, group, params)
+ @current_user = current_user
+ @group = group
+ @params = params
+ end
+
+ # Define method for each state
+ ::CustomerRelations::Contact.states.each_key do |state|
+ define_method(state) { counts[state] }
+ end
+
+ def all
+ counts.values.sum
+ end
+
+ private
+
+ attr_reader :current_user, :params
+
+ def counts
+ strong_memoize(:counts) do
+ Hash.new(0).merge(counts_by_state)
+ end
+ end
+
+ def counts_by_state
+ ::Crm::ContactsFinder.counts_by_state(current_user, params.merge({ group: group }))
+ end
+ end
+end
diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb
index 4ed38f578ee..94ac2405f61 100644
--- a/app/models/deploy_key.rb
+++ b/app/models/deploy_key.rb
@@ -40,6 +40,10 @@ class DeployKey < Key
super || User.ghost
end
+ def audit_details
+ title
+ end
+
def has_access_to?(project)
deploy_keys_project_for(project).present?
end
@@ -62,4 +66,9 @@ class DeployKey < Key
query
end
+
+ # This is used for the internal logic of AuditEvents::BuildService.
+ def impersonated?
+ false
+ end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index c25ba6f9268..a3213a59bed 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -206,11 +206,6 @@ class Deployment < ApplicationRecord
end
end
- def self.distinct_on_environment
- order('environment_id, deployments.id DESC')
- .select('DISTINCT ON (environment_id) deployments.*')
- end
-
def self.find_successful_deployment!(iid)
success.find_by!(iid: iid)
end
@@ -438,7 +433,7 @@ class Deployment < ApplicationRecord
def tier_in_yaml
return unless deployable
- deployable.environment_deployment_tier
+ deployable.environment_tier_from_options
end
private
diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb
index feb1bf5438c..317399e780a 100644
--- a/app/models/design_management/design.rb
+++ b/app/models/design_management/design.rb
@@ -28,8 +28,8 @@ module DesignManagement
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_internal_id :iid, scope: :project, presence: true,
- hook_names: %i[create update], # Deal with old records
- track_if: -> { !importing? }
+ hook_names: %i[create update], # Deal with old records
+ track_if: -> { !importing? }
validates :project, :filename, presence: true
validates :issue, presence: true, unless: :importing?
diff --git a/app/models/design_management/design_action.rb b/app/models/design_management/design_action.rb
index 43dcce545d2..eae470a1ae2 100644
--- a/app/models/design_management/design_action.rb
+++ b/app/models/design_management/design_action.rb
@@ -21,7 +21,7 @@ module DesignManagement
validates :action, presence: true, inclusion: { in: EVENT_FOR_GITALY_ACTION.keys }
validates :content,
absence: { if: :forbids_content?,
- message: 'this action forbids content' },
+ message: 'this action forbids content' },
presence: { if: :needs_content?,
message: 'this action needs content' }
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 68540ce0f5c..1950431446b 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -26,12 +26,11 @@ class Environment < ApplicationRecord
has_many :self_managed_prometheus_alert_events, inverse_of: :environment
has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment
+ # NOTE: If you preload multiple last deployments of environments, use Preloaders::Environments::DeploymentPreloader.
has_one :last_deployment, -> { success.ordered }, class_name: 'Deployment', inverse_of: :environment
- has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment'
- has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus', disable_joins: true
- has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: true
+ has_one :last_visible_deployment, -> { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment'
- has_one :upcoming_deployment, -> { upcoming.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
+ has_one :upcoming_deployment, -> { upcoming.order(id: :desc) }, class_name: 'Deployment', inverse_of: :environment
has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
before_validation :generate_slug, if: ->(env) { env.slug.blank? }
@@ -56,8 +55,9 @@ class Environment < ApplicationRecord
validates :external_url,
length: { maximum: 255 },
- allow_nil: true,
- addressable_url: true
+ allow_nil: true
+
+ validate :safe_external_url
delegate :manual_actions, :other_manual_actions, to: :last_deployment, allow_nil: true
delegate :auto_rollback_enabled?, to: :project
@@ -215,28 +215,11 @@ class Environment < ApplicationRecord
deployable_id: last_deployment_pipeline.latest_builds.pluck(:id))
end
- # NOTE: Below assocation overrides is a workaround for issue https://gitlab.com/gitlab-org/gitlab/-/issues/339908
- # It helps to avoid cross joins with the CI database.
- # Caveat: It also overrides and losses the default AR caching mechanism.
- # Read - https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68870#note_677227727
-
- # NOTE: Association Preloads does not use the overriden definitions below.
- # Association Preloads when preloading uses the original definitions from the relationships above.
- # https://github.com/rails/rails/blob/75ac626c4e21129d8296d4206a1960563cc3d4aa/activerecord/lib/active_record/associations/preloader.rb#L158
- # But after preloading, when they are called it is using the overriden methods below.
- # So we are checking for `association_cached?(:association_name)` in the overridden methods and calling `super` which inturn fetches the preloaded values.
-
- # Overriding association
def last_visible_deployable
- return super if association_cached?(:last_visible_deployable)
-
last_visible_deployment&.deployable
end
- # Overriding association
def last_visible_pipeline
- return super if association_cached?(:last_visible_pipeline)
-
last_visible_deployable&.pipeline
end
@@ -252,7 +235,6 @@ class Environment < ApplicationRecord
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI_ENVIRONMENT_NAME', value: name)
.append(key: 'CI_ENVIRONMENT_SLUG', value: slug)
- .append(key: 'CI_ENVIRONMENT_TIER', value: tier)
end
def recently_updated_on_branch?(ref)
@@ -329,11 +311,7 @@ class Environment < ApplicationRecord
end
def last_deployment_group
- if ::Feature.enabled?(:batch_load_environment_last_deployment_group, project)
- Deployment.last_deployment_group_for_environment(self)
- else
- legacy_last_deployment_group
- end
+ Deployment.last_deployment_group_for_environment(self)
end
def reset_auto_stop
@@ -493,6 +471,22 @@ class Environment < ApplicationRecord
private
+ # We deliberately avoid using AddressableUrlValidator to allow users to update their environments even if they have
+ # misconfigured `environment:url` keyword. The external URL is presented as a clickable link on UI and not consumed
+ # in GitLab internally, thus we sanitize the URL before the persistence to make sure the rendered link is XSS safe.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/337417
+ def safe_external_url
+ return unless self.external_url.present?
+
+ new_external_url = Addressable::URI.parse(self.external_url)
+
+ if Gitlab::Utils::SanitizeNodeLink::UNSAFE_PROTOCOLS.include?(new_external_url.normalized_scheme)
+ errors.add(:external_url, "#{new_external_url.normalized_scheme} scheme is not allowed")
+ end
+ rescue Addressable::URI::InvalidURIError
+ errors.add(:external_url, 'URI is invalid')
+ end
+
def rollout_status_available?
has_terminals?
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 7760be3e817..a20ca0dc423 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -14,18 +14,18 @@ class Event < ApplicationRecord
default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope
ACTIONS = HashWithIndifferentAccess.new(
- created: 1,
- updated: 2,
- closed: 3,
- reopened: 4,
- pushed: 5,
- commented: 6,
- merged: 7,
- joined: 8, # User joined project
- left: 9, # User left project
- destroyed: 10,
- expired: 11, # User left project due to expiry
- approved: 12
+ created: 1,
+ updated: 2,
+ closed: 3,
+ reopened: 4,
+ pushed: 5,
+ commented: 6,
+ merged: 7,
+ joined: 8, # User joined project
+ left: 9, # User left project
+ destroyed: 10,
+ expired: 11, # User left project due to expiry
+ approved: 12
).freeze
private_constant :ACTIONS
@@ -36,15 +36,15 @@ class Event < ApplicationRecord
ISSUE_ACTIONS = [:created, :updated, :closed, :reopened].freeze
TARGET_TYPES = HashWithIndifferentAccess.new(
- issue: Issue,
- milestone: Milestone,
- merge_request: MergeRequest,
- note: Note,
- project: Project,
- snippet: Snippet,
- user: User,
- wiki: WikiPage::Meta,
- design: DesignManagement::Design
+ issue: Issue,
+ milestone: Milestone,
+ merge_request: MergeRequest,
+ note: Note,
+ project: Project,
+ snippet: Snippet,
+ user: User,
+ wiki: WikiPage::Meta,
+ design: DesignManagement::Design
).freeze
RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour
@@ -216,6 +216,10 @@ class Event < ApplicationRecord
target_type == 'DesignManagement::Design'
end
+ def work_item?
+ target_type == 'WorkItem'
+ end
+
def milestone
target if milestone?
end
@@ -399,7 +403,8 @@ class Event < ApplicationRecord
read_milestone: %i[milestone?],
read_wiki: %i[wiki_page?],
read_design: %i[design_note? design?],
- read_note: %i[note?]
+ read_note: %i[note?],
+ read_work_item: %i[work_item?]
}
end
diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb
index a56e28859c9..2db074e733e 100644
--- a/app/models/gpg_key.rb
+++ b/app/models/gpg_key.rb
@@ -21,7 +21,7 @@ class GpgKey < ApplicationRecord
presence: true,
uniqueness: true,
format: {
- with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX})(?!#{KEY_SUFFIX}).)+#{KEY_SUFFIX}\Z/m,
+ with: /\A#{KEY_PREFIX}((?!#{KEY_PREFIX})(?!#{KEY_SUFFIX}).)+#{KEY_SUFFIX}\Z/mo,
message: "is invalid. A valid public GPG key begins with '#{KEY_PREFIX}' and ends with '#{KEY_SUFFIX}'"
}
diff --git a/app/models/grafana_integration.rb b/app/models/grafana_integration.rb
index 0358e37c58b..5cd5aa1b085 100644
--- a/app/models/grafana_integration.rb
+++ b/app/models/grafana_integration.rb
@@ -4,9 +4,9 @@ class GrafanaIntegration < ApplicationRecord
belongs_to :project
attr_encrypted :token,
- mode: :per_attribute_iv,
+ mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_32
+ key: Settings.attr_encrypted_db_key_base_32
before_validation :check_token_changes
diff --git a/app/models/group.rb b/app/models/group.rb
index 6d8f8bd7613..55455d85531 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -149,7 +149,7 @@ class Group < Namespace
add_authentication_token_field :runners_token,
encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption) ? :optional : :required },
- prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
+ prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
after_create :post_create_hook
after_destroy :post_destroy_hook
@@ -176,6 +176,16 @@ class Group < Namespace
.where(project_authorizations: { user_id: user_ids })
end
+ scope :project_creation_allowed, -> do
+ permitted_levels = [
+ ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS,
+ ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS,
+ nil
+ ]
+
+ where(project_creation_level: permitted_levels)
+ end
+
class << self
def sort_by_attribute(method)
if method == 'storage_size_desc'
@@ -855,6 +865,14 @@ class Group < Namespace
feature_flag_enabled_for_self_or_ancestor?(:work_items)
end
+ def work_items_mvc_2_feature_flag_enabled?
+ feature_flag_enabled_for_self_or_ancestor?(:work_items_mvc_2)
+ end
+
+ def work_items_create_from_markdown_feature_flag_enabled?
+ feature_flag_enabled_for_self_or_ancestor?(:work_items_create_from_markdown)
+ end
+
# Check for enabled features, similar to `Project#feature_available?`
# NOTE: We still want to keep this after removing `Namespace#feature_available?`.
override :feature_available?
diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index a70110c4076..8dd245a6ab5 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -14,6 +14,23 @@ class GroupGroupLink < ApplicationRecord
presence: true
scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) }
+
+ scope :with_owner_or_maintainer_access, -> do
+ where(group_access: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER])
+ end
+
+ scope :groups_accessible_via, -> (shared_with_group_ids) do
+ links = where(shared_with_group_id: shared_with_group_ids)
+ # a group share also gives you access to the descendants of the group being shared,
+ # so we must include the descendants as well in the result.
+ Group.id_in(links.select(:shared_group_id)).self_and_descendants
+ end
+
+ scope :groups_having_access_to, -> (shared_group_ids) do
+ links = where(shared_group_id: shared_group_ids)
+ Group.id_in(links.select(:shared_with_group_id))
+ end
+
scope :preload_shared_with_groups, -> { preload(:shared_with_group) }
scope :distinct_on_shared_with_group_id_with_group_access, -> do
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index f428d07cd7f..84ee23d77ce 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -12,14 +12,14 @@ class WebHook < ApplicationRecord
BACKOFF_GROWTH_FACTOR = 2.0
attr_encrypted :token,
- mode: :per_attribute_iv,
+ mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_32
+ key: Settings.attr_encrypted_db_key_base_32
attr_encrypted :url,
- mode: :per_attribute_iv,
+ mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_32
+ key: Settings.attr_encrypted_db_key_base_32
attr_encrypted :url_variables,
mode: :per_attribute_iv,
@@ -57,14 +57,14 @@ class WebHook < ApplicationRecord
!temporarily_disabled? && !permanently_disabled?
end
- def temporarily_disabled?(ignore_flag: false)
- return false unless ignore_flag || web_hooks_disable_failed?
+ def temporarily_disabled?
+ return false unless web_hooks_disable_failed?
disabled_until.present? && disabled_until >= Time.current
end
- def permanently_disabled?(ignore_flag: false)
- return false unless ignore_flag || web_hooks_disable_failed?
+ def permanently_disabled?
+ return false unless web_hooks_disable_failed?
recent_failures > FAILURE_THRESHOLD
end
@@ -126,13 +126,6 @@ class WebHook < ApplicationRecord
save(validate: false)
end
- def active_state(ignore_flag: false)
- return :permanently_disabled if permanently_disabled?(ignore_flag: ignore_flag)
- return :temporarily_disabled if temporarily_disabled?(ignore_flag: ignore_flag)
-
- :enabled
- end
-
# @return [Boolean] Whether or not the WebHook is currently throttled.
def rate_limited?
rate_limiter.rate_limited?
diff --git a/app/models/integration.rb b/app/models/integration.rb
index f5f701662e7..6d755016380 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -21,7 +21,7 @@ class Integration < ApplicationRecord
asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat harbor irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
- pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao
+ pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao
].freeze
# TODO Shimo is temporary disabled on group and instance-levels.
@@ -48,6 +48,9 @@ class Integration < ApplicationRecord
SECTION_TYPE_CONNECTION = 'connection'
SECTION_TYPE_TRIGGER = 'trigger'
+ SNOWPLOW_EVENT_ACTION = 'perform_integrations_action'
+ SNOWPLOW_EVENT_LABEL = 'redis_hll_counters.ecosystem.ecosystem_total_unique_counts_monthly'
+
attr_encrypted :properties,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_32,
@@ -89,7 +92,6 @@ class Integration < ApplicationRecord
belongs_to :project, inverse_of: :integrations
belongs_to :group, inverse_of: :integrations
- has_one :service_hook, inverse_of: :integration, foreign_key: :service_id
validates :project_id, presence: true, unless: -> { instance_level? || group_level? }
validates :group_id, presence: true, unless: -> { instance_level? || project_level? }
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index 230dc6bb336..c3a4b84bb2d 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -63,11 +63,11 @@ module Integrations
end
def build_page(sha, ref)
- with_reactive_cache(sha, ref) {|cached| cached[:build_page] }
+ with_reactive_cache(sha, ref) { |cached| cached[:build_page] }
end
def commit_status(sha, ref)
- with_reactive_cache(sha, ref) {|cached| cached[:commit_status] }
+ with_reactive_cache(sha, ref) { |cached| cached[:commit_status] }
end
def execute(data)
diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb
index fe4a2f43b13..a4cec5f927b 100644
--- a/app/models/integrations/base_issue_tracker.rb
+++ b/app/models/integrations/base_issue_tracker.rb
@@ -100,8 +100,8 @@ module Integrations
message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}"
result = true
end
- rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error
- message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}"
+ rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => e
+ message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{e.message}"
end
log_info(message)
result
diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb
index a0ac5474893..e51d748b562 100644
--- a/app/models/integrations/base_slash_commands.rb
+++ b/app/models/integrations/base_slash_commands.rb
@@ -8,7 +8,7 @@ module Integrations
prop_accessor :token
- has_many :chat_names, foreign_key: :service_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :chat_names, foreign_key: :integration_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
def valid_token?(token)
self.respond_to?(:token) &&
diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb
index def646c6d49..7a48e71b934 100644
--- a/app/models/integrations/buildkite.rb
+++ b/app/models/integrations/buildkite.rb
@@ -60,7 +60,7 @@ module Integrations
end
def commit_status(sha, ref)
- with_reactive_cache(sha, ref) {|cached| cached[:commit_status] }
+ with_reactive_cache(sha, ref) { |cached| cached[:commit_status] }
end
def commit_status_path(sha)
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index 97e586c0662..bb0fb6b9079 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -15,75 +15,7 @@ module Integrations
TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze
- field :datadog_site,
- placeholder: DEFAULT_DOMAIN,
- help: -> do
- ERB::Util.html_escape(
- s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.')
- ) % {
- codeOpen: '<code>'.html_safe,
- codeClose: '</code>'.html_safe
- }
- end
-
- field :api_url,
- title: -> { s_('DatadogIntegration|API URL') },
- help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') }
-
- field :api_key,
- type: 'password',
- title: -> { _('API key') },
- non_empty_password_title: -> { s_('ProjectService|Enter new API key') },
- non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') },
- help: -> do
- ERB::Util.html_escape(
- s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.')
- ) % {
- linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe,
- linkClose: '</a>'.html_safe
- }
- end,
- required: true
-
- field :archive_trace_events,
- type: 'checkbox',
- title: -> { s_('Logs') },
- checkbox_label: -> { s_('Enable logs collection') },
- help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') }
-
- field :datadog_service,
- title: -> { s_('DatadogIntegration|Service') },
- placeholder: 'gitlab-ci',
- help: -> { s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') }
-
- field :datadog_env,
- title: -> { s_('DatadogIntegration|Environment') },
- placeholder: 'ci',
- help: -> do
- ERB::Util.html_escape(
- s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}')
- ) % {
- codeOpen: '<code>'.html_safe,
- codeClose: '</code>'.html_safe,
- linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe,
- linkClose: '</a>'.html_safe
- }
- end
-
- field :datadog_tags,
- type: 'textarea',
- title: -> { s_('DatadogIntegration|Tags') },
- placeholder: "tag:value\nanother_tag:value",
- help: -> do
- ERB::Util.html_escape(
- s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}')
- ) % {
- codeOpen: '<code>'.html_safe,
- codeClose: '</code>'.html_safe,
- linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe,
- linkClose: '</a>'.html_safe
- }
- end
+ prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env, :datadog_tags
before_validation :strip_properties
@@ -145,11 +77,92 @@ module Integrations
end
def fields
+ f = [
+ {
+ type: 'text',
+ name: 'datadog_site',
+ placeholder: DEFAULT_DOMAIN,
+ help: ERB::Util.html_escape(
+ s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.')
+ ) % {
+ codeOpen: '<code>'.html_safe,
+ codeClose: '</code>'.html_safe
+ },
+ required: false
+ },
+ {
+ type: 'text',
+ name: 'api_url',
+ title: s_('DatadogIntegration|API URL'),
+ help: s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.'),
+ required: false
+ },
+ {
+ type: 'password',
+ name: 'api_key',
+ title: _('API key'),
+ non_empty_password_title: s_('ProjectService|Enter new API key'),
+ non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'),
+ help: ERB::Util.html_escape(
+ s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.')
+ ) % {
+ linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe,
+ linkClose: '</a>'.html_safe
+ },
+ required: true
+ }
+ ]
+
if Feature.enabled?(:datadog_integration_logs_collection, parent)
- super
- else
- super.reject { _1.name == 'archive_trace_events' }
+ f.append({
+ type: 'checkbox',
+ name: 'archive_trace_events',
+ title: s_('Logs'),
+ checkbox_label: s_('Enable logs collection'),
+ help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'),
+ required: false
+ })
end
+
+ f += [
+ {
+ type: 'text',
+ name: 'datadog_service',
+ title: s_('DatadogIntegration|Service'),
+ placeholder: 'gitlab-ci',
+ help: s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.')
+ },
+ {
+ type: 'text',
+ name: 'datadog_env',
+ title: s_('DatadogIntegration|Environment'),
+ placeholder: 'ci',
+ help: ERB::Util.html_escape(
+ s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}')
+ ) % {
+ codeOpen: '<code>'.html_safe,
+ codeClose: '</code>'.html_safe,
+ linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe,
+ linkClose: '</a>'.html_safe
+ }
+ },
+ {
+ type: 'textarea',
+ name: 'datadog_tags',
+ title: s_('DatadogIntegration|Tags'),
+ placeholder: "tag:value\nanother_tag:value",
+ help: ERB::Util.html_escape(
+ s_('DatadogIntegration|Custom tags in Datadog. Enter one tag per line in the %{codeOpen}key:value%{codeClose} format. %{linkOpen}How do I use tags?%{linkClose}')
+ ) % {
+ codeOpen: '<code>'.html_safe,
+ codeClose: '</code>'.html_safe,
+ linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe,
+ linkClose: '</a>'.html_safe
+ }
+ }
+ ]
+
+ f
end
override :hook_url
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index ecabf23c90b..ec8a12e4760 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -33,10 +33,21 @@ module Integrations
def default_fields
[
- { type: "text", name: "webhook", placeholder: "https://discordapp.com/api/webhooks/…", help: "URL to the webhook for the Discord channel." },
- { type: "checkbox", name: "notify_only_broken_pipelines" },
+ {
+ type: 'text',
+ section: SECTION_TYPE_CONNECTION,
+ name: 'webhook',
+ placeholder: 'https://discordapp.com/api/webhooks/…',
+ help: 'URL to the webhook for the Discord channel.'
+ },
+ {
+ type: 'checkbox',
+ section: SECTION_TYPE_CONFIGURATION,
+ name: 'notify_only_broken_pipelines'
+ },
{
type: 'select',
+ section: SECTION_TYPE_CONFIGURATION,
name: 'branches_to_be_notified',
title: s_('Integrations|Branches for which notifications are to be sent'),
choices: self.class.branch_choices
@@ -44,6 +55,26 @@ module Integrations
]
end
+ def sections
+ [
+ {
+ type: SECTION_TYPE_CONNECTION,
+ title: s_('Integrations|Connection details'),
+ description: help
+ },
+ {
+ type: SECTION_TYPE_TRIGGER,
+ title: s_('Integrations|Trigger'),
+ description: s_('Integrations|An event will be triggered when one of the following items happen.')
+ },
+ {
+ type: SECTION_TYPE_CONFIGURATION,
+ title: s_('Integrations|Notification settings'),
+ description: s_('Integrations|Configure the scope of notifications.')
+ }
+ ]
+ end
+
private
def notify(message, opts)
@@ -57,8 +88,8 @@ module Integrations
embed.timestamp = Time.now.utc
end
end
- rescue RestClient::Exception => error
- log_error(error.message)
+ rescue RestClient::Exception => e
+ log_error(e.message)
false
end
diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb
index ed12a3a8d63..25bda8c2bf0 100644
--- a/app/models/integrations/emails_on_push.rb
+++ b/app/models/integrations/emails_on_push.rb
@@ -71,7 +71,7 @@ module Integrations
recipients,
push_data,
send_from_committer_email: send_from_committer_email?,
- disable_diffs: disable_diffs?
+ disable_diffs: disable_diffs?
)
end
diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb
index bc2ea193a84..75fe6b6f164 100644
--- a/app/models/integrations/external_wiki.rb
+++ b/app/models/integrations/external_wiki.rb
@@ -5,6 +5,7 @@ module Integrations
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
field :external_wiki_url,
+ section: SECTION_TYPE_CONNECTION,
title: -> { s_('ExternalWikiService|External wiki URL') },
placeholder: -> { s_('ExternalWikiService|https://example.com/xxx/wiki/...') },
help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') },
@@ -28,6 +29,16 @@ module Integrations
s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
+ def sections
+ [
+ {
+ type: SECTION_TYPE_CONNECTION,
+ title: s_('Integrations|Connection details'),
+ description: help
+ }
+ ]
+ end
+
def execute(_data)
response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true)
response.body if response.code == 200
diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb
index 82981493822..03913a71d47 100644
--- a/app/models/integrations/harbor.rb
+++ b/app/models/integrations/harbor.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+require 'uri'
module Integrations
class Harbor < Integration
@@ -20,7 +21,7 @@ module Integrations
end
def help
- s_("HarborIntegration|After the Harbor integration is activated, global variables ‘$HARBOR_USERNAME’, ‘$HARBOR_PASSWORD’, ‘$HARBOR_URL’ and ‘$HARBOR_PROJECT’ will be created for CI/CD use.")
+ s_("HarborIntegration|After the Harbor integration is activated, global variables '$HARBOR_USERNAME', '$HARBOR_HOST', '$HARBOR_OCI', '$HARBOR_PASSWORD', '$HARBOR_URL' and '$HARBOR_PROJECT' will be created for CI/CD use.")
end
class << self
@@ -78,8 +79,12 @@ module Integrations
def ci_variables
return [] unless activated?
+ oci_uri = URI.parse(url)
+ oci_uri.scheme = 'oci'
[
{ key: 'HARBOR_URL', value: url },
+ { key: 'HARBOR_HOST', value: oci_uri.host },
+ { key: 'HARBOR_OCI', value: oci_uri.to_s },
{ key: 'HARBOR_PROJECT', value: project_name },
{ key: 'HARBOR_USERNAME', value: username.gsub(/^robot\$/, 'robot$$') },
{ key: 'HARBOR_PASSWORD', value: password, public: false, masked: true }
diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb
index ab39d1f7b77..c68b5fd2a96 100644
--- a/app/models/integrations/jenkins.rb
+++ b/app/models/integrations/jenkins.rb
@@ -53,8 +53,8 @@ module Integrations
begin
result = execute(data)
return { success: false, result: result[:message] } if result[:http_status] != 200
- rescue StandardError => error
- return { success: false, result: error }
+ rescue StandardError => e
+ return { success: false, result: e }
end
{ success: true, result: result[:message] }
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index 566bbc456f8..3ca514ab1fd 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -18,6 +18,8 @@ module Integrations
SECTION_TYPE_JIRA_TRIGGER = 'jira_trigger'
SECTION_TYPE_JIRA_ISSUES = 'jira_issues'
+ SNOWPLOW_EVENT_CATEGORY = self.name
+
validates :url, public_url: true, presence: true, if: :activated?
validates :api_url, public_url: true, allow_blank: true
validates :username, presence: true, if: :activated?
@@ -362,13 +364,17 @@ module Integrations
)
true
- rescue StandardError => error
- log_exception(error, message: 'Issue transition failed', client_url: client_url)
+ rescue StandardError => e
+ log_exception(e, message: 'Issue transition failed', client_url: client_url)
false
end
def transition_issue_to_done(issue)
- transitions = issue.transitions rescue []
+ transitions = begin
+ issue.transitions
+ rescue StandardError
+ []
+ end
transition = transitions.find do |transition|
status = transition&.to&.statusCategory
@@ -384,6 +390,22 @@ module Integrations
key = "i_ecosystem_jira_service_#{action}"
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user.id)
+
+ return unless Feature.enabled?(:route_hll_to_snowplow_phase2)
+
+ optional_arguments = {
+ project: project,
+ namespace: group || project&.namespace
+ }.compact
+
+ Gitlab::Tracking.event(
+ SNOWPLOW_EVENT_CATEGORY,
+ Integration::SNOWPLOW_EVENT_ACTION,
+ label: Integration::SNOWPLOW_EVENT_LABEL,
+ property: key,
+ user: user,
+ **optional_arguments
+ )
end
def add_issue_solved_comment(issue, commit_id, commit_url)
@@ -505,7 +527,7 @@ module Integrations
self.project,
entity_type.to_sym
],
- id: entity_id,
+ id: entity_id,
host: Settings.gitlab.base_url
)
end
@@ -538,9 +560,9 @@ module Integrations
# Handle errors when doing Jira API calls
def jira_request
yield
- rescue StandardError => error
- @error = error
- log_exception(error, message: 'Error sending message', client_url: client_url)
+ rescue StandardError => e
+ @error = e
+ log_exception(e, message: 'Error sending message', client_url: client_url)
nil
end
diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb
index fda4822c19f..f91404dab23 100644
--- a/app/models/integrations/packagist.rb
+++ b/app/models/integrations/packagist.rb
@@ -6,14 +6,14 @@ module Integrations
extend Gitlab::Utils::Override
field :username,
- title: -> { _('Username') },
+ title: -> { s_('Username') },
help: -> { s_('Enter your Packagist username.') },
placeholder: '',
required: true
field :token,
type: 'password',
- title: -> { _('Token') },
+ title: -> { s_('Token') },
help: -> { s_('Enter your Packagist token.') },
non_empty_password_title: -> { s_('ProjectService|Enter new token') },
non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
@@ -21,10 +21,11 @@ module Integrations
required: true
field :server,
- title: -> { _('Server (optional)') },
+ title: -> { s_('Server (optional)') },
help: -> { s_('Enter your Packagist server. Defaults to https://packagist.org.') },
placeholder: 'https://packagist.org',
- exposes_secrets: true
+ exposes_secrets: true,
+ required: false
validates :username, presence: true, if: :activated?
validates :token, presence: true, if: :activated?
@@ -55,8 +56,8 @@ module Integrations
begin
result = execute(data)
return { success: false, result: result[:message] } if result[:http_status] != 202
- rescue StandardError => error
- return { success: false, result: error }
+ rescue StandardError => e
+ return { success: false, result: e }
end
{ success: true, result: result[:message] }
diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb
index 77cbba25f2c..55a8ce0be11 100644
--- a/app/models/integrations/pipelines_email.rb
+++ b/app/models/integrations/pipelines_email.rb
@@ -84,8 +84,8 @@ module Integrations
result = execute(data, force: true)
{ success: true, result: result }
- rescue StandardError => error
- { success: false, result: error }
+ rescue StandardError => e
+ { success: false, result: e }
end
def should_pipeline_be_notified?(data)
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index e672a985810..142f466018b 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -70,8 +70,8 @@ module Integrations
prometheus_client.ping
{ success: true, result: 'Checked API endpoint' }
- rescue Gitlab::PrometheusClient::Error => err
- { success: false, result: err }
+ rescue Gitlab::PrometheusClient::Error => e
+ { success: false, result: e }
end
def prometheus_client
diff --git a/app/models/integrations/pumble.rb b/app/models/integrations/pumble.rb
new file mode 100644
index 00000000000..17026410eb1
--- /dev/null
+++ b/app/models/integrations/pumble.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Pumble < BaseChatNotification
+ def title
+ 'Pumble'
+ end
+
+ def description
+ s_("PumbleIntegration|Send notifications about project events to Pumble.")
+ end
+
+ def self.to_param
+ 'pumble'
+ end
+
+ def help
+ docs_link = ActionController::Base.helpers.link_to(
+ _('Learn more.'),
+ Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pumble'),
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ )
+ # rubocop:disable Layout/LineLength
+ s_("PumbleIntegration|Send notifications about project events to Pumble. %{docs_link}") % { docs_link: docs_link.html_safe }
+ # rubocop:enable Layout/LineLength
+ end
+
+ def default_channel_placeholder
+ end
+
+ def self.supported_events
+ %w[push issue confidential_issue merge_request note confidential_note tag_push
+ pipeline wiki_page]
+ end
+
+ def default_fields
+ [
+ { type: 'text', name: 'webhook', placeholder: "https://api.pumble.com/workspaces/x/...", required: true },
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' },
+ {
+ type: 'select',
+ name: 'branches_to_be_notified',
+ title: s_('Integrations|Branches for which notifications are to be sent'),
+ choices: self.class.branch_choices
+ }
+ ]
+ end
+
+ private
+
+ def notify(message, opts)
+ header = { 'Content-Type' => 'application/json' }
+ response = Gitlab::HTTP.post(webhook, headers: header, body: { text: message.summary }.to_json)
+
+ response if response.success?
+ end
+ end
+end
diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb
index 93263229109..c254ea379bb 100644
--- a/app/models/integrations/slack.rb
+++ b/app/models/integrations/slack.rb
@@ -9,6 +9,7 @@ module Integrations
push issue confidential_issue merge_request note confidential_note
tag_push wiki_page deployment
].freeze
+ SNOWPLOW_EVENT_CATEGORY = self.name
prop_accessor EVENT_CHANNEL['alert']
@@ -54,6 +55,22 @@ module Integrations
key = "i_ecosystem_slack_service_#{event}_notification"
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id)
+
+ return unless Feature.enabled?(:route_hll_to_snowplow_phase2)
+
+ optional_arguments = {
+ project: project,
+ namespace: group || project&.namespace
+ }.compact
+
+ Gitlab::Tracking.event(
+ SNOWPLOW_EVENT_CATEGORY,
+ Integration::SNOWPLOW_EVENT_ACTION,
+ label: Integration::SNOWPLOW_EVENT_LABEL,
+ property: key,
+ user: User.find(user_id),
+ **optional_arguments
+ )
end
override :configurable_channels?
diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb
index e0299c9ac5f..ca7a715f4b3 100644
--- a/app/models/integrations/teamcity.rb
+++ b/app/models/integrations/teamcity.rb
@@ -67,11 +67,11 @@ module Integrations
end
def build_page(sha, ref)
- with_reactive_cache(sha, ref) {|cached| cached[:build_page] }
+ with_reactive_cache(sha, ref) { |cached| cached[:build_page] }
end
def commit_status(sha, ref)
- with_reactive_cache(sha, ref) {|cached| cached[:commit_status] }
+ with_reactive_cache(sha, ref) { |cached| cached[:commit_status] }
end
def calculate_reactive_cache(sha, ref)
diff --git a/app/models/issuable_severity.rb b/app/models/issuable_severity.rb
index 928301e1da6..cd7e5fafb60 100644
--- a/app/models/issuable_severity.rb
+++ b/app/models/issuable_severity.rb
@@ -3,18 +3,18 @@
class IssuableSeverity < ApplicationRecord
DEFAULT = 'unknown'
SEVERITY_LABELS = {
- unknown: 'Unknown',
- low: 'Low - S4',
- medium: 'Medium - S3',
- high: 'High - S2',
+ unknown: 'Unknown',
+ low: 'Low - S4',
+ medium: 'Medium - S3',
+ high: 'High - S2',
critical: 'Critical - S1'
}.freeze
SEVERITY_QUICK_ACTION_PARAMS = {
- unknown: %w(Unknown 0),
- low: %w(Low S4 4),
- medium: %w(Medium S3 3),
- high: %w(High S2 2),
+ unknown: %w(Unknown 0),
+ low: %w(Low S4 4),
+ medium: %w(Medium S3 3),
+ high: %w(High S2 2),
critical: %w(Critical S1 1)
}.freeze
diff --git a/app/models/issue.rb b/app/models/issue.rb
index cae42115bef..4114467eb25 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -99,6 +99,10 @@ class Issue < ApplicationRecord
validates :project, presence: true
validates :issue_type, presence: true
validates :namespace, presence: true, if: -> { project.present? }
+ validates :work_item_type, presence: true
+
+ validate :due_date_after_start_date
+ validate :parent_link_confidentiality
enum issue_type: WorkItems::Type.base_types
@@ -201,7 +205,7 @@ class Issue < ApplicationRecord
scope :with_null_relative_position, -> { where(relative_position: nil) }
scope :with_non_null_relative_position, -> { where.not(relative_position: nil) }
- before_validation :ensure_namespace_id
+ before_validation :ensure_namespace_id, :ensure_work_item_type
after_commit :expire_etag_cache, unless: :importing?
after_save :ensure_metrics, unless: :importing?
@@ -257,17 +261,17 @@ class Issue < ApplicationRecord
order = ::Gitlab::Pagination::Keyset::Order.build([
::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: attribute_name,
- column_expression: column,
- order_expression: column.send(direction).send(nullable),
- reversed_order_expression: column.send(reversed_direction).send(nullable),
- order_direction: direction,
- distinct: false,
- add_to_projections: true,
- nullable: nullable
+ column_expression: column,
+ order_expression: column.send(direction).send(nullable),
+ reversed_order_expression: column.send(reversed_direction).send(nullable),
+ order_direction: direction,
+ distinct: false,
+ add_to_projections: true,
+ nullable: nullable
),
::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id',
- order_expression: arel_table['id'].desc
+ order_expression: arel_table['id'].desc
)
])
# rubocop: enable GitlabSecurity/PublicSend
@@ -289,6 +293,16 @@ class Issue < ApplicationRecord
def pg_full_text_search(search_term)
super.where('issue_search_data.project_id = issues.project_id')
end
+
+ override :full_search
+ def full_search(query, matched_columns: nil, use_minimum_char_limit: true)
+ return super if query.match?(IssuableFinder::FULL_TEXT_SEARCH_TERM_REGEX)
+
+ super.where(
+ 'issues.title NOT SIMILAR TO :pattern OR issues.description NOT SIMILAR TO :pattern',
+ pattern: IssuableFinder::FULL_TEXT_SEARCH_TERM_PATTERN
+ )
+ end
end
def next_object_by_relative_position(ignoring: nil, order: :asc)
@@ -660,6 +674,29 @@ class Issue < ApplicationRecord
private
+ def due_date_after_start_date
+ return unless start_date.present? && due_date.present?
+
+ if due_date < start_date
+ errors.add(:due_date, 'must be greater than or equal to start date')
+ end
+ end
+
+ # Although parent/child relationship can be set only for WorkItems, we
+ # still need to validate it for Issue model too, because both models use
+ # same table.
+ def parent_link_confidentiality
+ return unless persisted?
+
+ if confidential? && WorkItems::ParentLink.has_public_children?(id)
+ errors.add(:confidential, _('confidential parent can not be used if there are non-confidential children.'))
+ end
+
+ if !confidential? && WorkItems::ParentLink.has_confidential_parent?(id)
+ errors.add(:confidential, _('associated parent is confidential and can not have non-confidential children.'))
+ end
+ end
+
override :persist_pg_full_text_search_vector
def persist_pg_full_text_search_vector(search_vector)
Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id))
@@ -696,6 +733,12 @@ class Issue < ApplicationRecord
def ensure_namespace_id
self.namespace = project.project_namespace if project
end
+
+ def ensure_work_item_type
+ return if work_item_type_id.present? || work_item_type_id_change&.last.present?
+
+ self.work_item_type = WorkItems::Type.default_by_type(issue_type)
+ end
end
Issue.prepend_mod_with('Issue')
diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb
index e34543534f3..8befe9a9230 100644
--- a/app/models/jira_connect_installation.rb
+++ b/app/models/jira_connect_installation.rb
@@ -2,9 +2,9 @@
class JiraConnectInstallation < ApplicationRecord
attr_encrypted :shared_secret,
- mode: :per_attribute_iv,
+ mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_32
+ key: Settings.attr_encrypted_db_key_base_32
has_many :subscriptions, class_name: 'JiraConnectSubscription'
diff --git a/app/models/key.rb b/app/models/key.rb
index 9f6029cc5d4..78b0a38bcaa 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -40,6 +40,7 @@ class Key < ApplicationRecord
after_destroy :refresh_user_cache
alias_attribute :fingerprint_md5, :fingerprint
+ alias_attribute :name, :title
scope :preload_users, -> { preload(:user) }
scope :for_user, -> (user) { where(user: user) }
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index caeffae7bda..8aa48561e60 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -15,7 +15,7 @@ class LfsObject < ApplicationRecord
scope :for_oids, -> (oids) { where(oid: oids) }
scope :for_oid_and_size, -> (oid, size) { find_by(oid: oid, size: size) }
- validates :oid, presence: true, uniqueness: true
+ validates :oid, presence: true, uniqueness: true, format: { with: /\A\h{64}\z/ }
mount_file_store_uploader LfsObjectUploader
diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb
index 6dfd6ea2aae..94444f4b6d3 100644
--- a/app/models/loose_foreign_keys/deleted_record.rb
+++ b/app/models/loose_foreign_keys/deleted_record.rb
@@ -9,26 +9,23 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel
self.ignored_columns = %i[partition]
partitioned_by :partition, strategy: :sliding_list,
- next_partition_if: -> (active_partition) do
- return false if Feature.disabled?(:lfk_automatic_partition_creation)
-
- oldest_record_in_partition = LooseForeignKeys::DeletedRecord
- .select(:id, :created_at)
- .for_partition(active_partition)
- .order(:id)
- .limit(1)
- .take
-
- oldest_record_in_partition.present? && oldest_record_in_partition.created_at < PARTITION_DURATION.ago
- end,
- detach_partition_if: -> (partition) do
- return false if Feature.disabled?(:lfk_automatic_partition_dropping)
-
- !LooseForeignKeys::DeletedRecord
- .for_partition(partition)
- .status_pending
- .exists?
- end
+ next_partition_if: -> (active_partition) do
+ oldest_record_in_partition = LooseForeignKeys::DeletedRecord
+ .select(:id, :created_at)
+ .for_partition(active_partition)
+ .order(:id)
+ .limit(1)
+ .take
+
+ oldest_record_in_partition.present? &&
+ oldest_record_in_partition.created_at < PARTITION_DURATION.ago
+ end,
+ detach_partition_if: -> (partition) do
+ !LooseForeignKeys::DeletedRecord
+ .for_partition(partition)
+ .status_pending
+ .exists?
+ end
scope :for_table, -> (table) { where(fully_qualified_table_name: table) }
scope :for_partition, -> (partition) { where(partition: partition) }
diff --git a/app/models/member.rb b/app/models/member.rb
index dcca63b5691..0cd1e022617 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -28,6 +28,7 @@ class Member < ApplicationRecord
belongs_to :user
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :member_namespace, inverse_of: :namespace_members, foreign_key: 'member_namespace_id', class_name: 'Namespace'
+ belongs_to :member_role
has_one :member_task
delegate :name, :username, :email, :last_activity_on, to: :user, prefix: true
@@ -58,6 +59,7 @@ class Member < ApplicationRecord
},
if: :project_bot?
validate :access_level_inclusion
+ validate :validate_member_role_access_level
scope :with_invited_user_state, -> do
joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email')
@@ -428,6 +430,14 @@ class Member < ApplicationRecord
errors.add(:access_level, "is not included in the list")
end
+ def validate_member_role_access_level
+ return unless member_role_id
+
+ if access_level != member_role.base_access_level
+ errors.add(:member_role_id, _("role's base access level does not match the access level of the membership"))
+ end
+ end
+
def send_invite
# override in subclass
end
@@ -455,6 +465,8 @@ class Member < ApplicationRecord
# transaction has been committed, resulting in the job either throwing an
# error or not doing any meaningful work.
# rubocop: disable CodeReuse/ServiceClass
+
+ # This method is overridden in the test environment, see stubbed_member.rb
def refresh_member_authorized_projects(blocking:)
UserProjectAccessChangedService.new(user_id).execute(blocking: blocking)
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 87af6a9a7f7..2b35f7da7b4 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -7,7 +7,6 @@ class GroupMember < Member
SOURCE_TYPE = 'Namespace'
SOURCE_TYPE_FORMAT = /\ANamespace\z/.freeze
- THRESHOLD_FOR_REFRESHING_AUTHORIZATIONS_VIA_PROJECTS = 1000
belongs_to :group, foreign_key: 'source_id'
alias_attribute :namespace_id, :source_id
@@ -67,28 +66,8 @@ class GroupMember < Member
# its projects are also destroyed, so the removal of project_authorizations
# will happen behind the scenes via DB foreign keys anyway.
return if destroyed_by_association.present?
- return unless user_id
- return super if Feature.disabled?(:refresh_authorizations_via_affected_projects_on_group_membership, group)
- # rubocop:disable CodeReuse/ServiceClass
- projects_to_refresh = Groups::ProjectsRequiringAuthorizationsRefresh::OnDirectMembershipFinder.new(group).execute
- threshold_exceeded = (projects_to_refresh.size > THRESHOLD_FOR_REFRESHING_AUTHORIZATIONS_VIA_PROJECTS)
-
- # We want to try the new approach only if the number of affected projects are greater than the set threshold.
- return super unless threshold_exceeded
-
- AuthorizedProjectUpdate::ProjectAccessChangedService
- .new(projects_to_refresh)
- .execute(blocking: false)
-
- # Until we compare the inconsistency rates of the new approach
- # the old approach, we still run AuthorizedProjectsWorker
- # but with some delay and lower urgency as a safety net.
- UserProjectAccessChangedService
- .new(user_id)
- .execute(blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY)
-
- # rubocop:enable CodeReuse/ServiceClass
+ super
end
def send_invite
diff --git a/app/models/members/last_group_owner_assigner.rb b/app/models/members/last_group_owner_assigner.rb
index c85116858c7..e411a0ef5eb 100644
--- a/app/models/members/last_group_owner_assigner.rb
+++ b/app/models/members/last_group_owner_assigner.rb
@@ -8,7 +8,7 @@ class LastGroupOwnerAssigner
end
def execute
- @last_blocked_owner = no_owners_in_heirarchy? && group.single_blocked_owner?
+ @last_blocked_owner = no_owners_in_hierarchy? && group.single_blocked_owner?
@group_single_owner = owners.size == 1
members.each { |member| set_last_owner(member) }
@@ -18,7 +18,7 @@ class LastGroupOwnerAssigner
attr_reader :group, :members, :last_blocked_owner, :group_single_owner
- def no_owners_in_heirarchy?
+ def no_owners_in_hierarchy?
owners.empty?
end
diff --git a/app/models/members/member_role.rb b/app/models/members/member_role.rb
new file mode 100644
index 00000000000..2e8532fa739
--- /dev/null
+++ b/app/models/members/member_role.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass
+ has_many :members
+ belongs_to :namespace
+
+ validates :namespace_id, presence: true
+ validates :base_access_level, presence: true
+end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index c97f00364fd..8fd82fcb34a 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -111,7 +111,7 @@ class ProjectMember < Member
# rubocop:disable CodeReuse/ServiceClass
if blocking
- AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.bulk_perform_and_wait([[project.id, user.id]])
+ blocking_project_authorizations_refresh
else
AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.perform_async(project.id, user.id)
end
@@ -124,6 +124,11 @@ class ProjectMember < Member
# rubocop:enable CodeReuse/ServiceClass
end
+ # This method is overridden in the test environment, see stubbed_member.rb
+ def blocking_project_authorizations_refresh
+ AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.bulk_perform_and_wait([[project.id, user.id]])
+ end
+
# TODO: https://gitlab.com/groups/gitlab-org/-/epics/7054
# temporary until we can we properly remove the source columns
override :set_member_namespace_id
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index ec97ab0ea42..3c06e1aa983 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -37,23 +37,25 @@ class MergeRequest < ApplicationRecord
SORTING_PREFERENCE_FIELD = :merge_requests_sort
ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = {
- 'Ci::CompareMetricsReportsService' => ->(project) { true },
+ 'Ci::CompareMetricsReportsService' => ->(project) { true },
'Ci::CompareCodequalityReportsService' => ->(project) { true }
}.freeze
+ MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS = 100
+
belongs_to :target_project, class_name: "Project"
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
belongs_to :iteration, foreign_key: 'sprint_id'
has_internal_id :iid, scope: :target_project, track_if: -> { !importing? },
- init: ->(mr, scope) do
- if mr
- mr.target_project&.merge_requests&.maximum(:iid)
- elsif scope[:project]
- where(target_project: scope[:project]).maximum(:iid)
- end
- end
+ init: ->(mr, scope) do
+ if mr
+ mr.target_project&.merge_requests&.maximum(:iid)
+ elsif scope[:project]
+ where(target_project: scope[:project]).maximum(:iid)
+ end
+ end
has_many :merge_request_diffs,
-> { regular }, inverse_of: :merge_request
@@ -121,7 +123,8 @@ class MergeRequest < ApplicationRecord
:force_remove_source_branch,
:commit_message,
:squash_commit_message,
- :sha
+ :sha,
+ :skip_ci
].freeze
serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize
@@ -263,6 +266,7 @@ class MergeRequest < ApplicationRecord
validate :validate_branches, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?]
validate :validate_fork, unless: :closed_or_merged_without_fork?
validate :validate_target_project, on: :create
+ validate :validate_reviewer_and_assignee_size_length, unless: :importing?
scope :by_source_or_target_branch, ->(branch_name) do
where("source_branch = :branch OR target_branch = :branch", branch: branch_name)
@@ -427,8 +431,7 @@ class MergeRequest < ApplicationRecord
def self.total_time_to_merge
join_metrics
.merge(MergeRequest::Metrics.with_valid_time_to_merge)
- .pluck(MergeRequest::Metrics.time_to_merge_expression)
- .first
+ .pick(MergeRequest::Metrics.time_to_merge_expression)
end
after_save :keep_around_commit, unless: :importing?
@@ -927,9 +930,9 @@ class MergeRequest < ApplicationRecord
# most recent data possible.
def repository_diff_refs
Gitlab::Diff::DiffRefs.new(
- base_sha: branch_merge_base_sha,
+ base_sha: branch_merge_base_sha,
start_sha: target_branch_sha,
- head_sha: source_branch_sha
+ head_sha: source_branch_sha
)
end
@@ -992,6 +995,20 @@ class MergeRequest < ApplicationRecord
'Source project is not a fork of the target project'
end
+ def self.max_number_of_assignees_or_reviewers_message
+ # Assignees will be included in https://gitlab.com/gitlab-org/gitlab/-/issues/368936
+ _("total must be less than or equal to %{size}") % { size: MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS }
+ end
+
+ def validate_reviewer_and_assignee_size_length
+ # Assigness will be added in a subsequent MR https://gitlab.com/gitlab-org/gitlab/-/issues/368936
+ return true unless Feature.enabled?(:limit_reviewer_and_assignee_size)
+ return true unless reviewers.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
+
+ errors.add :reviewers,
+ -> (_object, _data) { MergeRequest.max_number_of_assignees_or_reviewers_message }
+ end
+
def merge_ongoing?
# While the MergeRequest is locked, it should present itself as 'merge ongoing'.
# The unlocking process is handled by StuckMergeJobsWorker scheduled in Cron.
@@ -1170,17 +1187,30 @@ class MergeRequest < ApplicationRecord
]
end
+ def detailed_merge_status
+ if cannot_be_merged_rechecking? || preparing? || checking?
+ return :checking
+ elsif unchecked?
+ return :unchecked
+ end
+
+ checks = execute_merge_checks
+
+ if checks.success?
+ :mergeable
+ else
+ checks.failure_reason
+ end
+ end
+
# rubocop: disable CodeReuse/ServiceClass
def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
if Feature.enabled?(:improved_mergeability_checks, self.project)
- additional_checks = MergeRequests::Mergeability::RunChecksService.new(
- merge_request: self,
- params: {
- skip_ci_check: skip_ci_check,
- skip_discussions_check: skip_discussions_check
- }
- )
- additional_checks.execute.all?(&:success?)
+ additional_checks = execute_merge_checks(params: {
+ skip_ci_check: skip_ci_check,
+ skip_discussions_check: skip_discussions_check
+ })
+ additional_checks.execute.success?
else
return false unless open?
return false if draft?
@@ -1500,14 +1530,14 @@ class MergeRequest < ApplicationRecord
end
def self.merge_train_ref?(ref)
- %r{\Arefs/#{Repository::REF_MERGE_REQUEST}/\d+/train\z}.match?(ref)
+ %r{\Arefs/#{Repository::REF_MERGE_REQUEST}/\d+/train\z}o.match?(ref)
end
def in_locked_state
lock_mr
yield
ensure
- unlock_mr
+ unlock_mr if locked?
end
def update_and_mark_in_progress_merge_commit_sha(commit_id)
@@ -1985,6 +2015,10 @@ class MergeRequest < ApplicationRecord
target_branch == project.default_branch
end
+ def merge_blocked_by_other_mrs?
+ false # Overridden in EE
+ end
+
private
attr_accessor :skip_fetch_ref
@@ -2038,6 +2072,12 @@ class MergeRequest < ApplicationRecord
def report_type_enabled?(report_type)
!!actual_head_pipeline&.batch_lookup_report_artifact_for_file_type(report_type)
end
+
+ def execute_merge_checks(params: {})
+ # rubocop: disable CodeReuse/ServiceClass
+ MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: params).execute
+ # rubocop: enable CodeReuse/ServiceClass
+ end
end
MergeRequest.prepend_mod_with('MergeRequest')
diff --git a/app/models/merge_request/approval_removal_settings.rb b/app/models/merge_request/approval_removal_settings.rb
new file mode 100644
index 00000000000..b07242e2578
--- /dev/null
+++ b/app/models/merge_request/approval_removal_settings.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class MergeRequest::ApprovalRemovalSettings # rubocop:disable Style/ClassAndModuleChildren
+ include ActiveModel::Validations
+
+ attr_accessor :project
+
+ validate :mutually_exclusive_settings
+
+ def initialize(project, reset_approvals_on_push, selective_code_owner_removals)
+ @project = project
+ @reset_approvals_on_push = reset_approvals_on_push
+ @selective_code_owner_removals = selective_code_owner_removals
+ end
+
+ private
+
+ def selective_code_owner_removals
+ if @selective_code_owner_removals.nil?
+ project.project_setting.selective_code_owner_removals
+ else
+ @selective_code_owner_removals
+ end
+ end
+
+ def reset_approvals_on_push
+ if @reset_approvals_on_push.nil?
+ project.reset_approvals_on_push
+ else
+ @reset_approvals_on_push
+ end
+ end
+
+ def mutually_exclusive_settings
+ return unless selective_code_owner_removals && reset_approvals_on_push
+
+ errors.add(:base, 'selective_code_owner_removals can only be enabled when reset_approvals_on_push is disabled')
+ end
+end
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index b984228eb13..c546a5a0025 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -41,8 +41,7 @@ class MergeRequest::Metrics < ApplicationRecord
def self.total_time_to_merge
with_valid_time_to_merge
- .pluck(time_to_merge_expression)
- .first
+ .pick(time_to_merge_expression)
end
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index e08b2cc2a7d..9f7e98dc04b 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -358,9 +358,9 @@ class MergeRequestDiff < ApplicationRecord
return unless start_commit_sha || base_commit_sha
Gitlab::Diff::DiffRefs.new(
- base_sha: base_commit_sha,
+ base_sha: base_commit_sha,
start_sha: start_commit_sha,
- head_sha: head_commit_sha
+ head_sha: head_commit_sha
)
end
@@ -381,9 +381,9 @@ class MergeRequestDiff < ApplicationRecord
likely_base_commit_sha = (first_commit&.parent || first_commit)&.sha
Gitlab::Diff::DiffRefs.new(
- base_sha: likely_base_commit_sha,
+ base_sha: likely_base_commit_sha,
start_sha: safe_start_commit_sha,
- head_sha: head_commit_sha
+ head_sha: head_commit_sha
)
end
@@ -706,8 +706,7 @@ class MergeRequestDiff < ApplicationRecord
latest_id = MergeRequest
.where(id: merge_request_id)
.limit(1)
- .pluck(:latest_merge_request_diff_id)
- .first
+ .pick(:latest_merge_request_diff_id)
latest_id && self.id < latest_id
end
diff --git a/app/models/ml.rb b/app/models/ml.rb
new file mode 100644
index 00000000000..e426ce851eb
--- /dev/null
+++ b/app/models/ml.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module Ml
+ def self.table_name_prefix
+ 'ml_'
+ end
+end
diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb
new file mode 100644
index 00000000000..e181217f01c
--- /dev/null
+++ b/app/models/ml/candidate.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Ml
+ class Candidate < ApplicationRecord
+ validates :iid, :experiment, presence: true
+
+ belongs_to :experiment, class_name: 'Ml::Experiment'
+ belongs_to :user
+ has_many :metrics, class_name: 'Ml::CandidateMetric'
+ has_many :params, class_name: 'Ml::CandidateParam'
+ end
+end
diff --git a/app/models/ml/candidate_metric.rb b/app/models/ml/candidate_metric.rb
new file mode 100644
index 00000000000..e03a8b83ee6
--- /dev/null
+++ b/app/models/ml/candidate_metric.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Ml
+ class CandidateMetric < ApplicationRecord
+ validates :candidate, presence: true
+ validates :name, length: { maximum: 250 }, presence: true
+
+ belongs_to :candidate, class_name: 'Ml::Candidate'
+ end
+end
diff --git a/app/models/ml/candidate_param.rb b/app/models/ml/candidate_param.rb
new file mode 100644
index 00000000000..cbdddcc8a1a
--- /dev/null
+++ b/app/models/ml/candidate_param.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Ml
+ class CandidateParam < ApplicationRecord
+ validates :candidate, presence: true
+ validates :name, :value, length: { maximum: 250 }, presence: true
+
+ belongs_to :candidate, class_name: 'Ml::Candidate'
+ end
+end
diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb
new file mode 100644
index 00000000000..7ef9c70ba7e
--- /dev/null
+++ b/app/models/ml/experiment.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Ml
+ class Experiment < ApplicationRecord
+ validates :name, :iid, :project, presence: true
+ validates :iid, :name, uniqueness: { scope: :project, message: "should be unique in the project" }
+
+ belongs_to :project
+ belongs_to :user
+ has_many :candidates, class_name: 'Ml::Candidate'
+ end
+end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index f23a859b119..06f49f16d66 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -40,15 +40,21 @@ class Namespace < ApplicationRecord
PATH_TRAILING_VIOLATIONS = %w[.git .atom .].freeze
+ # The first date in https://docs.gitlab.com/ee/user/usage_quotas.html#namespace-storage-limit-enforcement-schedule
+ # Determines when we start enforcing namespace storage
+ MIN_STORAGE_ENFORCEMENT_DATE = Date.new(2022, 10, 19)
+
cache_markdown_field :description, pipeline: :description
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
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
+ has_one :namespace_details, inverse_of: :namespace, class_name: 'Namespace::Detail', autosave: true
has_one :namespace_statistics
has_one :namespace_route, foreign_key: :namespace_id, autosave: false, inverse_of: :namespace, class_name: 'Route'
has_many :namespace_members, foreign_key: :member_namespace_id, inverse_of: :member_namespace, class_name: 'Member'
+ has_many :member_roles
has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace'
has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
@@ -77,6 +83,8 @@ class Namespace < ApplicationRecord
has_many :work_items, inverse_of: :namespace
has_many :issues, inverse_of: :namespace
+ has_many :timelog_categories, class_name: 'TimeTracking::TimelogCategory'
+
validates :owner, presence: true, if: ->(n) { n.owner_required? }
validates :name,
presence: true,
@@ -120,6 +128,7 @@ class Namespace < ApplicationRecord
to: :namespace_settings, allow_nil: true
after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_parent_id? }
+ after_save :reload_namespace_details
after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') }
@@ -559,9 +568,7 @@ class Namespace < ApplicationRecord
def storage_enforcement_date
return Date.current if Feature.enabled?(:namespace_storage_limit_bypass_date_check, self)
- # should return something like Date.new(2022, 02, 03)
- # TBD: https://gitlab.com/gitlab-org/gitlab/-/issues/350632
- nil
+ MIN_STORAGE_ENFORCEMENT_DATE
end
def certificate_based_clusters_enabled?
@@ -671,6 +678,12 @@ class Namespace < ApplicationRecord
end
end
+ def reload_namespace_details
+ return unless !project_namespace? && (previous_changes.keys & %w(description description_html cached_markdown_version)).any? && namespace_details.present?
+
+ namespace_details.reset
+ end
+
def sync_share_with_group_lock_with_parent
if parent&.share_with_group_lock?
self.share_with_group_lock = true
diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb
new file mode 100644
index 00000000000..dbbf9f4944a
--- /dev/null
+++ b/app/models/namespace/detail.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Namespace::Detail < ApplicationRecord
+ belongs_to :namespace, inverse_of: :namespace_details
+ validates :namespace, presence: true
+ validates :description, length: { maximum: 255 }
+
+ self.primary_key = :namespace_id
+end
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index 6f404ec12d0..81ac026d7ff 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -27,15 +27,9 @@ module Namespaces
def self_and_ancestors(include_self: true, upto: nil, hierarchy_order: nil)
return super unless use_traversal_ids_for_ancestor_scopes?
- if Feature.enabled?(:use_traversal_ids_for_ancestor_scopes_with_inner_join)
- self_and_ancestors_from_inner_join(include_self: include_self,
- upto: upto, hierarchy_order:
- hierarchy_order)
- else
- self_and_ancestors_from_ancestors_cte(include_self: include_self,
- upto: upto,
- hierarchy_order: hierarchy_order)
- end
+ self_and_ancestors_from_inner_join(include_self: include_self,
+ upto: upto, hierarchy_order:
+ hierarchy_order)
end
def self_and_ancestor_ids(include_self: true)
@@ -117,37 +111,6 @@ module Namespaces
use_traversal_ids?
end
- def self_and_ancestors_from_ancestors_cte(include_self: true, upto: nil, hierarchy_order: nil)
- base_cte = all.select('namespaces.id', 'namespaces.traversal_ids').as_cte(:base_ancestors_cte)
-
- # We have to alias id with 'AS' to avoid ambiguous column references by calling methods.
- ancestors_cte = unscoped
- .unscope(where: [:type])
- .select('id as base_id',
- "#{unnest_func(base_cte.table['traversal_ids']).to_sql} as ancestor_id")
- .from(base_cte.table)
- .as_cte(:ancestors_cte)
-
- namespaces = Arel::Table.new(:namespaces)
-
- records = unscoped
- .with(base_cte.to_arel, ancestors_cte.to_arel)
- .distinct
- .from([ancestors_cte.table, namespaces])
- .where(namespaces[:id].eq(ancestors_cte.table[:ancestor_id]))
- .order_by_depth(hierarchy_order)
-
- unless include_self
- records = records.where(ancestors_cte.table[:base_id].not_eq(ancestors_cte.table[:ancestor_id]))
- end
-
- if upto
- records = records.where.not(id: unscoped.where(id: upto).select('unnest(traversal_ids)'))
- end
-
- records
- end
-
def self_and_ancestors_from_inner_join(include_self: true, upto: nil, hierarchy_order: nil)
base_cte = all.reselect('namespaces.traversal_ids').as_cte(:base_ancestors_cte)
@@ -181,25 +144,15 @@ module Namespaces
end
def self_and_descendants_with_comparison_operators(include_self: true)
- base = all.select(:traversal_ids)
- base = base.select(:id) if Feature.enabled?(:linear_scopes_superset)
+ base = all.select(:id, :traversal_ids)
base_cte = base.as_cte(:descendants_base_cte)
namespaces = Arel::Table.new(:namespaces)
- withs = [base_cte.to_arel]
- froms = []
-
- if Feature.enabled?(:linear_scopes_superset)
- superset_cte = self.superset_cte(base_cte.table.name)
- withs += [superset_cte.to_arel]
- froms = [superset_cte.table]
- else
- froms = [base_cte.table]
- end
-
+ superset_cte = self.superset_cte(base_cte.table.name)
+ withs = [base_cte.to_arel, superset_cte.to_arel]
# Order is important. namespace should be last to handle future joins.
- froms += [namespaces]
+ froms = [superset_cte.table, namespaces]
base_ref = froms.first
diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb
index 560ff861105..a034d97a6bb 100644
--- a/app/models/network/graph.rb
+++ b/app/models/network/graph.rb
@@ -211,7 +211,7 @@ module Network
# Visit branching chains
leaves.each do |l|
- parents = l.parents(@map).select {|p| p.space == 0}
+ parents = l.parents(@map).select { |p| p.space == 0 }
parents.each do |p|
place_chain(p, l.time)
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 986a85acac6..1715f7cdc3b 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -23,6 +23,8 @@ class Note < ApplicationRecord
include FromUnion
include Sortable
+ ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/.freeze
+
cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true
redact_field :note
@@ -685,6 +687,22 @@ class Note < ApplicationRecord
Ability.users_that_can_read_internal_notes(users, resource_parent).pluck(:id)
end
+ # Method necesary while we transition into the new format for task system notes
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/369923
+ def note
+ return super unless system? && for_issue? && super.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN)
+
+ super.sub!('task', 'checklist item')
+ end
+
+ # Method necesary while we transition into the new format for task system notes
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/369923
+ def note_html
+ return super unless system? && for_issue? && super.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN)
+
+ super.sub!('task', 'checklist item')
+ end
+
private
def system_note_viewable_by?(user)
diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb
index 3713be6cb91..c227626af9e 100644
--- a/app/models/notification_reason.rb
+++ b/app/models/notification_reason.rb
@@ -6,7 +6,6 @@ class NotificationReason
OWN_ACTIVITY = 'own_activity'
ASSIGNED = 'assigned'
REVIEW_REQUESTED = 'review_requested'
- ATTENTION_REQUESTED = 'attention_requested'
MENTIONED = 'mentioned'
SUBSCRIBED = 'subscribed'
@@ -15,7 +14,6 @@ class NotificationReason
OWN_ACTIVITY,
ASSIGNED,
REVIEW_REQUESTED,
- ATTENTION_REQUESTED,
MENTIONED,
SUBSCRIBED
].freeze
diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb
index 20130f01d44..7d71e15d3c5 100644
--- a/app/models/oauth_access_token.rb
+++ b/app/models/oauth_access_token.rb
@@ -6,7 +6,6 @@ class OauthAccessToken < Doorkeeper::AccessToken
alias_attribute :user, :resource_owner
- scope :distinct_resource_owner_counts, ->(applications) { where(application: applications).distinct.group(:application_id).count(:resource_owner_id) }
scope :latest_per_application, -> { select('distinct on(application_id) *').order(application_id: :desc, created_at: :desc) }
scope :preload_application, -> { preload(:application) }
@@ -17,4 +16,14 @@ class OauthAccessToken < Doorkeeper::AccessToken
super
end
end
+
+ # this method overrides a shortcoming upstream, more context:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/367888
+ def self.find_by_fallback_token(attr, plain_secret)
+ return unless fallback_secret_strategy && fallback_secret_strategy == Doorkeeper::SecretStoring::Plain
+ # token is hashed, don't allow plaintext comparison
+ return if plain_secret.starts_with?("$")
+
+ super
+ end
end
diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb
index 7db396bcad5..e36c59366fe 100644
--- a/app/models/operations/feature_flag.rb
+++ b/app/models/operations/feature_flag.rb
@@ -42,7 +42,7 @@ module Operations
scope :enabled, -> { where(active: true) }
scope :disabled, -> { where(active: false) }
- scope :new_version_only, -> { where(version: :new_version_flag)}
+ scope :new_version_only, -> { where(version: :new_version_flag) }
enum version: {
new_version_flag: 2
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 90a1bb4bc69..afd55b4f143 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -65,7 +65,7 @@ class Packages::Package < ApplicationRecord
validates :name,
uniqueness: {
scope: %i[project_id version package_type],
- conditions: -> { not_pending_destruction}
+ conditions: -> { not_pending_destruction }
},
unless: -> { pending_destruction? || conan? || debian_package? }
@@ -327,7 +327,7 @@ class Packages::Package < ApplicationRecord
def normalized_pypi_name
return name unless pypi?
- name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/, '-').downcase
+ name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/o, '-').downcase
end
private
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 9e93bff4acf..2e25839c47f 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -23,10 +23,10 @@ class PagesDomain < ApplicationRecord
validates :domain, uniqueness: { case_sensitive: false }
validates :certificate, :key, presence: true, if: :usage_serverless?
validates :certificate, presence: { message: 'must be present if HTTPS-only is enabled' },
- if: :certificate_should_be_present?
+ if: :certificate_should_be_present?
validates :certificate, certificate: true, if: ->(domain) { domain.certificate.present? }
validates :key, presence: { message: 'must be present if HTTPS-only is enabled' },
- if: :certificate_should_be_present?
+ if: :certificate_should_be_present?
validates :key, certificate_key: true, named_ecdsa_key: true, if: ->(domain) { domain.key.present? }
validates :verification_code, presence: true, allow_blank: false
diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb
index 40d14aaa1de..4804f620a99 100644
--- a/app/models/performance_monitoring/prometheus_dashboard.rb
+++ b/app/models/performance_monitoring/prometheus_dashboard.rb
@@ -57,10 +57,10 @@ module PerformanceMonitoring
self.class.from_json(reload_schema)
[]
- rescue Gitlab::Metrics::Dashboard::Errors::LayoutError => error
- [error.message]
- rescue ActiveModel::ValidationError => exception
- exception.model.errors.map { |attr, error| "#{attr}: #{error}" }
+ rescue Gitlab::Metrics::Dashboard::Errors::LayoutError => e
+ [e.message]
+ rescue ActiveModel::ValidationError => e
+ e.model.errors.map { |attr, error| "#{attr}: #{error}" }
end
private
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 68ba3d6eab4..7e6e366f8da 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -20,7 +20,7 @@ class PersonalAccessToken < ApplicationRecord
before_save :ensure_token
- scope :active, -> { where("revoked = false AND (expires_at >= CURRENT_DATE OR expires_at IS NULL)") }
+ scope :active, -> { not_revoked.not_expired }
scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) }
scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) }
scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") }
@@ -33,6 +33,7 @@ class PersonalAccessToken < ApplicationRecord
scope :preload_users, -> { preload(:user) }
scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
scope :order_expires_at_desc, -> { reorder(expires_at: :desc) }
+ scope :order_expires_at_asc_id_desc, -> { reorder(expires_at: :asc, id: :desc) }
scope :project_access_token, -> { includes(:user).where(user: { user_type: :project_bot }) }
scope :owner_is_human, -> { includes(:user).where(user: { user_type: :human }) }
@@ -57,8 +58,8 @@ class PersonalAccessToken < ApplicationRecord
begin
Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
- rescue StandardError => ex
- logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{ex.class}"
+ rescue StandardError => e
+ logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{e.class}"
encrypted_token
end
end
@@ -77,7 +78,8 @@ class PersonalAccessToken < ApplicationRecord
super.merge(
{
'expires_at_asc' => -> { order_expires_at_asc },
- 'expires_at_desc' => -> { order_expires_at_desc }
+ 'expires_at_desc' => -> { order_expires_at_desc },
+ 'expires_at_asc_id_desc' => -> { order_expires_at_asc_id_desc }
}
)
end
diff --git a/app/models/preloaders/labels_preloader.rb b/app/models/preloaders/labels_preloader.rb
index bb3206f5399..722d588d8bc 100644
--- a/app/models/preloaders/labels_preloader.rb
+++ b/app/models/preloaders/labels_preloader.rb
@@ -21,8 +21,8 @@ module Preloaders
def preload_all
preloader = ActiveRecord::Associations::Preloader.new
- preloader.preload(labels.select {|l| l.is_a? ProjectLabel }, { project: [:project_feature, namespace: :route] })
- preloader.preload(labels.select {|l| l.is_a? GroupLabel }, { group: :route })
+ preloader.preload(labels.select { |l| l.is_a? ProjectLabel }, { project: [:project_feature, namespace: :route] })
+ preloader.preload(labels.select { |l| l.is_a? GroupLabel }, { group: :route })
labels.each do |label|
label.lazy_subscription(user)
label.lazy_subscription(user, project) if project.present?
diff --git a/app/models/project.rb b/app/models/project.rb
index ebfec34c3e1..0c49cc24a8d 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -53,6 +53,7 @@ class Project < ApplicationRecord
ignore_columns :mirror_last_update_at, :mirror_last_successful_update_at, remove_after: '2021-09-22', remove_with: '14.4'
ignore_columns :pull_mirror_branch_prefix, remove_after: '2021-09-22', remove_with: '14.4'
+ ignore_columns :build_coverage_regex, remove_after: '2022-10-22', remove_with: '15.5'
STATISTICS_ATTRIBUTE = 'repositories_count'
UNKNOWN_IMPORT_URL = 'http://unknown.git'
@@ -131,6 +132,8 @@ class Project < ApplicationRecord
after_save :save_topics
+ after_save :reload_project_namespace_details
+
after_create -> { create_or_load_association(:project_feature) }
after_create -> { create_or_load_association(:ci_cd_settings) }
@@ -176,7 +179,7 @@ class Project < ApplicationRecord
alias_method :parent, :namespace
alias_attribute :parent_id, :namespace_id
- has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
+ has_one :last_event, -> { order 'events.created_at DESC' }, class_name: 'Event'
has_many :boards
def self.integration_association_name(name)
@@ -213,6 +216,7 @@ class Project < ApplicationRecord
has_one :pipelines_email_integration, class_name: 'Integrations::PipelinesEmail'
has_one :pivotaltracker_integration, class_name: 'Integrations::Pivotaltracker'
has_one :prometheus_integration, class_name: 'Integrations::Prometheus', inverse_of: :project
+ has_one :pumble_integration, class_name: 'Integrations::Pumble'
has_one :pushover_integration, class_name: 'Integrations::Pushover'
has_one :redmine_integration, class_name: 'Integrations::Redmine'
has_one :shimo_integration, class_name: 'Integrations::Shimo'
@@ -288,6 +292,8 @@ class Project < ApplicationRecord
has_many :project_members, -> { where(requested_at: nil) },
as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :project_callouts, class_name: 'Users::ProjectCallout', foreign_key: :project_id
+
alias_method :members, :project_members
has_many :users, through: :project_members
@@ -446,7 +452,8 @@ class Project < ApplicationRecord
:repository_access_level, :package_registry_access_level, :pages_access_level,
:metrics_dashboard_access_level, :analytics_access_level,
:operations_access_level, :security_and_compliance_access_level,
- :container_registry_access_level,
+ :container_registry_access_level, :environments_access_level, :feature_flags_access_level,
+ :releases_access_level,
to: :project_feature, allow_nil: true
delegate :show_default_award_emojis, :show_default_award_emojis=,
@@ -472,6 +479,7 @@ class Project < ApplicationRecord
delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true
delegate :opt_in_jwt, :opt_in_jwt=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
+ delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true
delegate :separated_caches, :separated_caches=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :ci_cd_settings, allow_nil: true
@@ -663,6 +671,7 @@ class Project < ApplicationRecord
scope :imported_from, -> (type) { where(import_type: type) }
scope :imported, -> { where.not(import_type: nil) }
scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) }
+ scope :last_activity_before, -> (time) { where('projects.last_activity_at < ?', time) }
scope :with_service_desk_key, -> (key) do
# project_key is not indexed for now
@@ -814,7 +823,7 @@ class Project < ApplicationRecord
(?<!#{Gitlab::PathRegex::PATH_START_CHAR})
((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)?
(?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX})
- }x
+ }xo
end
def reference_postfix
@@ -1041,6 +1050,7 @@ class Project < ApplicationRecord
def emails_enabled?
!emails_disabled?
end
+
override :lfs_enabled?
def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
@@ -1675,7 +1685,13 @@ class Project < ApplicationRecord
end
def has_active_hooks?(hooks_scope = :push_hooks)
- hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? || Gitlab::FileHook.any?
+ @has_active_hooks ||= {} # rubocop: disable Gitlab/PredicateMemoization
+
+ return @has_active_hooks[hooks_scope] if @has_active_hooks.key?(hooks_scope)
+
+ @has_active_hooks[hooks_scope] = hooks.hooks_for(hooks_scope).any? ||
+ SystemHook.hooks_for(hooks_scope).any? ||
+ Gitlab::FileHook.any?
end
def has_active_integrations?(hooks_scope = :push_hooks)
@@ -1757,8 +1773,8 @@ class Project < ApplicationRecord
repository.after_create
true
- rescue StandardError => err
- Gitlab::ErrorTracking.track_exception(err, project: { id: id, full_path: full_path, disk_path: disk_path })
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, project: { id: id, full_path: full_path, disk_path: disk_path })
errors.add(:base, _('Failed to create repository'))
false
end
@@ -2254,6 +2270,7 @@ class Project < ApplicationRecord
.concat(dependency_proxy_variables)
.concat(auto_devops_variables)
.concat(api_variables)
+ .concat(ci_template_variables)
end
end
@@ -2307,6 +2324,12 @@ class Project < ApplicationRecord
end
end
+ def ci_template_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI_TEMPLATE_REGISTRY_HOST', value: 'registry.gitlab.com')
+ end
+ end
+
def dependency_proxy_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
break variables unless Gitlab.config.dependency_proxy.enabled
@@ -2651,7 +2674,7 @@ class Project < ApplicationRecord
{
repository_storage: repository_storage,
- pool_repository: pool_repository || create_new_pool_repository
+ pool_repository: pool_repository || create_new_pool_repository
}
end
@@ -2880,6 +2903,12 @@ class Project < ApplicationRecord
ci_cd_settings.forward_deployment_enabled?
end
+ def ci_allow_fork_pipelines_to_run_in_parent_project?
+ return false unless ci_cd_settings
+
+ ci_cd_settings.allow_fork_pipelines_to_run_in_parent_project?
+ end
+
def ci_job_token_scope_enabled?
return false unless ci_cd_settings
@@ -2984,6 +3013,14 @@ class Project < ApplicationRecord
group&.work_items_feature_flag_enabled? || Feature.enabled?(:work_items, self)
end
+ def work_items_mvc_2_feature_flag_enabled?
+ group&.work_items_mvc_2_feature_flag_enabled? || Feature.enabled?(:work_items_mvc_2)
+ end
+
+ def work_items_create_from_markdown_feature_flag_enabled?
+ work_items_feature_flag_enabled? && (group&.work_items_create_from_markdown_feature_flag_enabled? || Feature.enabled?(:work_items_create_from_markdown))
+ end
+
def enqueue_record_project_target_platforms
return unless Gitlab.com?
return unless Feature.enabled?(:record_projects_target_platforms, self)
@@ -3008,6 +3045,10 @@ class Project < ApplicationRecord
licensed_feature_available?(:security_training)
end
+ def destroy_deployment_by_id(deployment_id)
+ deployments.where(id: deployment_id).fast_destroy_all
+ end
+
private
# overridden in EE
@@ -3238,6 +3279,12 @@ class Project < ApplicationRecord
project_namespace.assign_attributes(attributes_to_sync)
end
+ def reload_project_namespace_details
+ return unless (previous_changes.keys & %w(description description_html cached_markdown_version)).any? && project_namespace.namespace_details.present?
+
+ project_namespace.namespace_details.reset
+ end
+
# SyncEvents are created by PG triggers (with the function `insert_projects_sync_event`)
def schedule_sync_event_worker
run_after_commit do
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 0a30e125c83..8623e477c06 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -21,6 +21,9 @@ class ProjectFeature < ApplicationRecord
security_and_compliance
container_registry
package_registry
+ environments
+ feature_flags
+ releases
].freeze
EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze
diff --git a/app/models/projects/import_export/relation_export.rb b/app/models/projects/import_export/relation_export.rb
index 0a31e525ac2..15198049f87 100644
--- a/app/models/projects/import_export/relation_export.rb
+++ b/app/models/projects/import_export/relation_export.rb
@@ -3,6 +3,20 @@
module Projects
module ImportExport
class RelationExport < ApplicationRecord
+ DESIGN_REPOSITORY_RELATION = 'design_repository'
+ LFS_OBJECTS_RELATION = 'lfs_objects'
+ REPOSITORY_RELATION = 'repository'
+ ROOT_RELATION = 'project'
+ SNIPPETS_REPOSITORY_RELATION = 'snippets_repository'
+ UPLOADS_RELATION = 'uploads'
+ WIKI_REPOSITORY_RELATION = 'wiki_repository'
+
+ EXTRA_RELATION_LIST = [
+ DESIGN_REPOSITORY_RELATION, LFS_OBJECTS_RELATION, REPOSITORY_RELATION, ROOT_RELATION,
+ SNIPPETS_REPOSITORY_RELATION, UPLOADS_RELATION, WIKI_REPOSITORY_RELATION
+ ].freeze
+ private_constant :EXTRA_RELATION_LIST
+
self.table_name = 'project_relation_exports'
belongs_to :project_export_job
@@ -17,6 +31,33 @@ module Projects
validates :project_export_job, presence: true
validates :relation, presence: true, length: { maximum: 255 }, uniqueness: { scope: :project_export_job_id }
validates :status, numericality: { only_integer: true }, presence: true
+
+ scope :by_relation, -> (relation) { where(relation: relation) }
+
+ state_machine :status, initial: :queued do
+ state :queued, value: 0
+ state :started, value: 1
+ state :finished, value: 2
+ state :failed, value: 3
+
+ event :start do
+ transition queued: :started
+ end
+
+ event :finish do
+ transition started: :finished
+ end
+
+ event :fail_op do
+ transition [:queued, :started] => :failed
+ end
+ end
+
+ def self.relation_names_list
+ project_tree_relation_names = ::Gitlab::ImportExport::Reader.new(shared: nil).project_relation_names.map(&:to_s)
+
+ project_tree_relation_names + EXTRA_RELATION_LIST
+ end
end
end
end
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index bc7f94e4374..b0f138714a0 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -15,6 +15,7 @@ module Projects
has_many :project_topics, class_name: 'Projects::ProjectTopic'
has_many :projects, through: :project_topics
+ scope :without_assigned_projects, -> { where(total_projects_count: 0) }
scope :order_by_non_private_projects_count, -> { order(non_private_projects_count: :desc).order(id: :asc) }
scope :reorder_by_similarity, -> (search) do
order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [
diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb
index 684f50d5f58..9080f3d9de1 100644
--- a/app/models/prometheus_alert.rb
+++ b/app/models/prometheus_alert.rb
@@ -25,7 +25,7 @@ class PrometheusAlert < ApplicationRecord
validates :environment, :project, :prometheus_metric, :threshold, :operator, presence: true
validates :runbook_url, length: { maximum: 255 }, allow_blank: true,
- addressable_url: { enforce_sanitization: true, ascii_only: true }
+ addressable_url: { enforce_sanitization: true, ascii_only: true }
validate :require_valid_environment_project!
validate :require_valid_metric_project!
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 7cf15439b47..76c277e4b86 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -4,8 +4,6 @@ class ProtectedBranch < ApplicationRecord
include ProtectedRef
include Gitlab::SQL::Pattern
- CACHE_EXPIRE_IN = 1.hour
-
scope :requiring_code_owner_approval,
-> { where(code_owner_approval_required: true) }
@@ -27,10 +25,30 @@ class ProtectedBranch < ApplicationRecord
end
# Check if branch name is marked as protected in the system
- def self.protected?(project, ref_name)
+ def self.protected?(project, ref_name, dry_run: true)
return true if project.empty_repo? && project.default_branch_protected?
return false if ref_name.blank?
+ new_cache_result = new_cache(project, ref_name, dry_run: dry_run)
+
+ return new_cache_result unless new_cache_result.nil?
+
+ deprecated_cache(project, ref_name)
+ end
+
+ def self.new_cache(project, ref_name, dry_run: true)
+ if Feature.enabled?(:hash_based_cache_for_protected_branches, project)
+ ProtectedBranches::CacheService.new(project).fetch(ref_name, dry_run: dry_run) do # rubocop: disable CodeReuse/ServiceClass
+ self.matching(ref_name, protected_refs: protected_refs(project)).present?
+ end
+ end
+ end
+
+ # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/368279
+ # ----------------------------------------------------------------
+ CACHE_EXPIRE_IN = 1.hour
+
+ def self.deprecated_cache(project, ref_name)
Rails.cache.fetch(protected_ref_cache_key(project, ref_name), expires_in: CACHE_EXPIRE_IN) do
self.matching(ref_name, protected_refs: protected_refs(project)).present?
end
@@ -39,6 +57,7 @@ class ProtectedBranch < ApplicationRecord
def self.protected_ref_cache_key(project, ref_name)
"protected_ref-#{project.cache_key}-#{Digest::SHA1.hexdigest(ref_name)}"
end
+ # End of deprecation --------------------------------------------
def self.allow_force_push?(project, ref_name)
project.protected_branches.allowing_force_push.matching(ref_name).any?
diff --git a/app/models/release.rb b/app/models/release.rb
index ee5d7bab190..5ef3ff1bc6c 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -94,7 +94,7 @@ class Release < ApplicationRecord
end
def milestone_titles
- self.milestones.order_by_dates_and_title.map {|m| m.title }.join(', ')
+ self.milestones.order_by_dates_and_title.map { |m| m.title }.join(', ')
end
def to_hook_data(action)
diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb
index 17a9ad7db66..c2d498ecb13 100644
--- a/app/models/release_highlight.rb
+++ b/app/models/release_highlight.rb
@@ -33,7 +33,7 @@ class ReleaseHighlight
next unless include_item?(item)
begin
- item.tap {|i| i['body'] = Banzai.render(i['body'], { project: nil }) }
+ item.tap { |i| i['description'] = Banzai.render(i['description'], { project: nil }) }
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(e, file_path: file_path)
@@ -116,6 +116,6 @@ class ReleaseHighlight
return true unless Gitlab::CurrentSettings.current_application_settings.whats_new_variant_current_tier?
- item['packages']&.include?(current_package)
+ item['available_in']&.include?(current_package)
end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 9039bdf1a20..eb8e45877f3 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -244,10 +244,10 @@ class Repository
end
end
- def add_branch(user, branch_name, ref)
+ def add_branch(user, branch_name, ref, expire_cache: true)
branch = raw_repository.add_branch(branch_name, user: user, target: ref)
- after_create_branch
+ after_create_branch(expire_cache: expire_cache)
branch
rescue Gitlab::Git::Repository::InvalidRef
@@ -337,11 +337,17 @@ class Repository
def expire_branches_cache
expire_method_caches(%i(branch_names merged_branch_names branch_count has_visible_content? has_ambiguous_refs?))
+ expire_protected_branches_cache
+
@local_branches = nil
@branch_exists_memo = nil
@branch_names_include = nil
end
+ def expire_protected_branches_cache
+ ProtectedBranches::CacheService.new(project).refresh if project # rubocop:disable CodeReuse/ServiceClass
+ end
+
def expire_statistics_caches
expire_method_caches(%i(size commit_count))
end
@@ -646,8 +652,8 @@ class Repository
return if licensee_object.name.blank?
licensee_object
- rescue Licensee::InvalidLicense => ex
- Gitlab::ErrorTracking.track_exception(ex)
+ rescue Licensee::InvalidLicense => e
+ Gitlab::ErrorTracking.track_exception(e)
nil
end
memoize_method :license
@@ -1072,9 +1078,9 @@ class Repository
) do |commit_id|
merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil)
end
- rescue StandardError => error
+ rescue StandardError => e
merge_request.update!(rebase_commit_sha: nil)
- raise error
+ raise e
end
def squash(user, merge_request, message)
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index 5d7b3879d75..8fea0d6d993 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -68,7 +68,11 @@ class SentNotification < ApplicationRecord
def noteable
if for_commit?
- project.commit(commit_id) rescue nil
+ begin
+ project.commit(commit_id)
+ rescue StandardError
+ nil
+ end
else
super
end
@@ -76,7 +80,11 @@ class SentNotification < ApplicationRecord
def position=(new_position)
if new_position.is_a?(String)
- new_position = Gitlab::Json.parse(new_position) rescue nil
+ new_position = begin
+ Gitlab::Json.parse(new_position)
+ rescue StandardError
+ nil
+ end
end
if new_position.is_a?(Hash)
diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb
index 0d54a97370e..1effabf1c22 100644
--- a/app/models/serverless/domain_cluster.rb
+++ b/app/models/serverless/domain_cluster.rb
@@ -17,7 +17,7 @@ module Serverless
validates :pages_domain, :knative, presence: true
validates :uuid, presence: true, uniqueness: true, length: { is: ::Serverless::Domain::UUID_LENGTH },
- format: { with: HEX_REGEXP, message: 'only allows hex characters' }
+ format: { with: HEX_REGEXP, message: 'only allows hex characters' }
default_value_for(:uuid, allows_nil: false) { ::Serverless::Domain.generate_uuid }
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 47b23bbd28a..fd882633a44 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -94,8 +94,8 @@ class Snippet < ApplicationRecord
attr_spammable :content, spam_description: true
attr_encrypted :secret_token,
- key: Settings.attr_encrypted_db_key_base_truncated,
- mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ mode: :per_attribute_iv,
algorithm: 'aes-256-cbc'
class << self
diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb
index 92405a0d943..5ac159d9615 100644
--- a/app/models/snippet_repository.rb
+++ b/app/models/snippet_repository.rb
@@ -44,11 +44,11 @@ class SnippetRepository < ApplicationRecord
Gitlab::Git::CommitError,
Gitlab::Git::PreReceiveError,
Gitlab::Git::CommandError,
- ArgumentError => error
+ ArgumentError => e
- logger.error(message: "Snippet git error. Reason: #{error.message}", snippet: snippet.id)
+ logger.error(message: "Snippet git error. Reason: #{e.message}", snippet: snippet.id)
- raise commit_error_exception(error)
+ raise commit_error_exception(e)
end
def transform_file_entries(files)
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 2643ef272d8..cc389dbe3f4 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -22,7 +22,7 @@ class SystemNoteMetadata < ApplicationRecord
designs_added designs_modified designs_removed designs_discussion_added
title time_tracking branch milestone discussion task moved cloned
opened closed merged duplicate locked unlocked outdated reviewer
- tag due_date pinned_embed cherry_pick health_status approved unapproved
+ tag due_date start_date_or_due_date pinned_embed cherry_pick health_status approved unapproved
status alert_issue_added relate unrelate new_alert_added severity
attention_requested attention_request_removed contact timeline_event
].freeze
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index 59f7d852ce6..e5c8f4ab32a 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -26,7 +26,7 @@ module Terraform
validates :project_id, :name, presence: true
validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
- format: { with: HEX_REGEXP, message: 'only allows hex characters' }
+ format: { with: HEX_REGEXP, message: 'only allows hex characters' }
default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) }
diff --git a/app/models/todo.rb b/app/models/todo.rb
index c698783d750..d165e60e4c3 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -19,7 +19,6 @@ class Todo < ApplicationRecord
DIRECTLY_ADDRESSED = 7
MERGE_TRAIN_REMOVED = 8 # This is an EE-only feature
REVIEW_REQUESTED = 9
- ATTENTION_REQUESTED = 10
ACTION_NAMES = {
ASSIGNED => :assigned,
@@ -30,8 +29,7 @@ class Todo < ApplicationRecord
APPROVAL_REQUIRED => :approval_required,
UNMERGEABLE => :unmergeable,
DIRECTLY_ADDRESSED => :directly_addressed,
- MERGE_TRAIN_REMOVED => :merge_train_removed,
- ATTENTION_REQUESTED => :attention_requested
+ MERGE_TRAIN_REMOVED => :merge_train_removed
}.freeze
ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED].freeze
@@ -195,10 +193,6 @@ class Todo < ApplicationRecord
action == REVIEW_REQUESTED
end
- def attention_requested?
- action == ATTENTION_REQUESTED
- end
-
def merge_train_removed?
action == MERGE_TRAIN_REMOVED
end
@@ -238,7 +232,11 @@ class Todo < ApplicationRecord
# override to return commits, which are not active record
def target
if for_commit?
- project.commit(commit_id) rescue nil
+ begin
+ project.commit(commit_id)
+ rescue StandardError
+ nil
+ end
else
super
end
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
index 7c01aa7a420..ba6c1ee6af1 100644
--- a/app/models/u2f_registration.rb
+++ b/app/models/u2f_registration.rb
@@ -6,21 +6,7 @@ class U2fRegistration < ApplicationRecord
belongs_to :user
after_create :create_webauthn_registration
- after_update :update_webauthn_registration, if: :counter_changed?
-
- def create_webauthn_registration
- converter = Gitlab::Auth::U2fWebauthnConverter.new(self)
- WebauthnRegistration.create!(converter.convert)
- rescue StandardError => ex
- Gitlab::ErrorTracking.track_exception(ex, u2f_registration_id: self.id)
- end
-
- def update_webauthn_registration
- # When we update the sign count of this registration
- # we need to update the sign count of the corresponding webauthn registration
- # as well if it exists already
- WebauthnRegistration.find_by_credential_xid(webauthn_credential_xid)&.update_attribute(:counter, counter)
- end
+ after_update :update_webauthn_registration, if: :saved_change_to_counter?
def self.register(user, app_id, params, challenges)
u2f = U2F::U2F.new(app_id)
@@ -60,10 +46,22 @@ class U2fRegistration < ApplicationRecord
private
+ def create_webauthn_registration
+ converter = Gitlab::Auth::U2fWebauthnConverter.new(self)
+ WebauthnRegistration.create!(converter.convert)
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, u2f_registration_id: self.id)
+ end
+
+ def update_webauthn_registration
+ # When we update the sign count of this registration
+ # we need to update the sign count of the corresponding webauthn registration
+ # as well if it exists already
+ WebauthnRegistration.find_by_credential_xid(webauthn_credential_xid)
+ &.update_attribute(:counter, counter)
+ end
+
def webauthn_credential_xid
- # To find the corresponding webauthn registration, we use that
- # the key handle of the u2f reg corresponds to the credential xid of the webauthn reg
- # (with some base64 back and forth)
Base64.strict_encode64(Base64.urlsafe_decode64(key_handle))
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 188b27383f9..afee2d70844 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -30,6 +30,7 @@ class User < ApplicationRecord
include Gitlab::Auth::Otp::Fortinet
include RestrictedSignup
include StripAttribute
+ include EachBatch
DEFAULT_NOTIFICATION_LEVEL = :participating
@@ -69,8 +70,8 @@ class User < ApplicationRecord
default_value_for :theme_id, gitlab_config.default_theme
attr_encrypted :otp_secret,
- key: Gitlab::Application.secrets.otp_key_base,
- mode: :per_attribute_iv_and_salt,
+ key: Gitlab::Application.secrets.otp_key_base,
+ mode: :per_attribute_iv_and_salt,
insecure_mode: true,
algorithm: 'aes-256-cbc'
@@ -222,6 +223,7 @@ class User < ApplicationRecord
has_many :custom_attributes, class_name: 'UserCustomAttribute'
has_many :callouts, class_name: 'Users::Callout'
has_many :group_callouts, class_name: 'Users::GroupCallout'
+ has_many :project_callouts, class_name: 'Users::ProjectCallout'
has_many :namespace_callouts, class_name: 'Users::NamespaceCallout'
has_many :term_agreements
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
@@ -272,10 +274,10 @@ class User < ApplicationRecord
validate :check_username_format, if: :username_changed?
validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids,
- message: _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } }
+ message: _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } }
validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids,
- message: _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } }
+ message: _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } }
validates :website_url, allow_blank: true, url: true, if: :website_url_changed?
@@ -447,6 +449,11 @@ class User < ApplicationRecord
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
scope :by_name, -> (names) { iwhere(name: Array(names)) }
+ scope :by_login, -> (login) do
+ return none if login.blank?
+
+ login.include?('@') ? iwhere(email: login) : iwhere(username: login)
+ end
scope :by_user_email, -> (emails) { iwhere(email: Array(emails)) }
scope :by_emails, -> (emails) { joins(:emails).where(emails: { email: Array(emails).map(&:downcase) }) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id).distinct) }
@@ -481,7 +488,6 @@ class User < 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 :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) }
scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.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)) }
@@ -691,33 +697,29 @@ class User < ApplicationRecord
scope = options[:with_private_emails] ? with_primary_or_secondary_email(query) : with_public_email(query)
scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: options[:use_minimum_char_limit]))
- if Feature.enabled?(:use_keyset_aware_user_search_query)
- order = Gitlab::Pagination::Keyset::Order.build([
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'users_match_priority',
- order_expression: sanitized_order_sql.asc,
- add_to_projections: true,
- distinct: false
- ),
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'users_name',
- order_expression: arel_table[:name].asc,
- add_to_projections: true,
- nullable: :not_nullable,
- distinct: false
- ),
- Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: 'users_id',
- order_expression: arel_table[:id].asc,
- add_to_projections: true,
- nullable: :not_nullable,
- distinct: true
- )
- ])
- scope.reorder(order)
- else
- scope.reorder(sanitized_order_sql, :name)
- end
+ order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'users_match_priority',
+ order_expression: sanitized_order_sql.asc,
+ add_to_projections: true,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'users_name',
+ order_expression: arel_table[:name].asc,
+ add_to_projections: true,
+ nullable: :not_nullable,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'users_id',
+ order_expression: arel_table[:id].asc,
+ add_to_projections: true,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
+ scope.reorder(order)
end
# Limits the result set to users _not_ in the given query/list of IDs.
@@ -768,14 +770,8 @@ class User < ApplicationRecord
true
end
- def by_login(login)
- return unless login
-
- if login.include?('@')
- unscoped.iwhere(email: login).take
- else
- unscoped.iwhere(username: login).take
- end
+ def find_by_login(login)
+ by_login(login).take
end
def find_by_username(username)
@@ -991,12 +987,12 @@ class User < ApplicationRecord
def disable_two_factor!
transaction do
update(
- otp_required_for_login: false,
- encrypted_otp_secret: nil,
- encrypted_otp_secret_iv: nil,
- encrypted_otp_secret_salt: nil,
+ otp_required_for_login: false,
+ encrypted_otp_secret: nil,
+ encrypted_otp_secret_iv: nil,
+ encrypted_otp_secret_salt: nil,
otp_grace_period_started_at: nil,
- otp_backup_codes: nil
+ otp_backup_codes: nil
)
self.u2f_registrations.destroy_all # rubocop: disable Cop/DestroyAll
self.webauthn_registrations.destroy_all # rubocop: disable Cop/DestroyAll
@@ -1663,7 +1659,14 @@ class User < ApplicationRecord
end
def forkable_namespaces
- @forkable_namespaces ||= [namespace] + manageable_groups(include_groups_with_developer_maintainer_access: true)
+ strong_memoize(:forkable_namespaces) do
+ personal_namespace = Namespace.where(id: namespace_id)
+
+ Namespace.from_union([
+ manageable_groups(include_groups_with_developer_maintainer_access: true),
+ personal_namespace
+ ])
+ end
end
def manageable_groups(include_groups_with_developer_maintainer_access: false)
@@ -1808,16 +1811,6 @@ class User < ApplicationRecord
end
end
- def attention_requested_open_merge_requests_count(force: false)
- if Feature.enabled?(:uncached_mr_attention_requests_count, self)
- MergeRequestsFinder.new(self, attention: self.username, state: 'opened', non_archived: true).execute.count
- else
- Rails.cache.fetch(attention_request_cache_key, force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
- MergeRequestsFinder.new(self, attention: self.username, state: 'opened', non_archived: true).execute.count
- end
- end
- end
-
def assigned_open_issues_count(force: false)
Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count
@@ -1861,11 +1854,6 @@ class User < ApplicationRecord
def invalidate_merge_request_cache_counts
Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count'])
Rails.cache.delete(['users', id, 'review_requested_open_merge_requests_count'])
- invalidate_attention_requested_count
- end
-
- def invalidate_attention_requested_count
- Rails.cache.delete(attention_request_cache_key)
end
def invalidate_todos_cache_counts
@@ -1877,10 +1865,6 @@ class User < ApplicationRecord
Rails.cache.delete(['users', id, 'personal_projects_count'])
end
- def attention_request_cache_key
- ['users', id, 'attention_requested_open_merge_requests_count']
- end
-
# This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth
# flow means we don't call that automatically (and can't conveniently do so).
#
@@ -2095,6 +2079,12 @@ class User < ApplicationRecord
callout_dismissed?(callout, ignore_dismissal_earlier_than)
end
+ def dismissed_callout_for_project?(feature_name:, project:, ignore_dismissal_earlier_than: nil)
+ callout = project_callouts.find_by(feature_name: feature_name, project: project)
+
+ callout_dismissed?(callout, ignore_dismissal_earlier_than)
+ end
+
# Load the current highest access by looking directly at the user's memberships
def current_highest_access_level
members.non_request.maximum(:access_level)
@@ -2126,6 +2116,11 @@ class User < ApplicationRecord
.find_or_initialize_by(feature_name: ::Users::NamespaceCallout.feature_names[feature_name], namespace_id: namespace_id)
end
+ def find_or_initialize_project_callout(feature_name, project_id)
+ project_callouts
+ .find_or_initialize_by(feature_name: ::Users::ProjectCallout.feature_names[feature_name], project_id: project_id)
+ end
+
def can_trigger_notifications?
confirmed? && !blocked? && !ghost?
end
@@ -2160,6 +2155,10 @@ class User < ApplicationRecord
Feature.enabled?(:mr_attention_requests, self)
end
+ def account_age_in_days
+ (Date.current - created_at.to_date).to_i
+ end
+
protected
# override, from Devise::Validatable
diff --git a/app/models/user_status.rb b/app/models/user_status.rb
index 7a803e8f1f6..dee976a4497 100644
--- a/app/models/user_status.rb
+++ b/app/models/user_status.rb
@@ -9,12 +9,12 @@ class UserStatus < ApplicationRecord
CLEAR_STATUS_QUICK_OPTIONS = {
'30_minutes' => 30.minutes,
- '3_hours' => 3.hours,
- '8_hours' => 8.hours,
- '1_day' => 1.day,
- '3_days' => 3.days,
- '7_days' => 7.days,
- '30_days' => 30.days
+ '3_hours' => 3.hours,
+ '8_hours' => 8.hours,
+ '1_day' => 1.day,
+ '3_days' => 3.days,
+ '7_days' => 7.days,
+ '30_days' => 30.days
}.freeze
belongs_to :user
@@ -32,6 +32,10 @@ class UserStatus < ApplicationRecord
def clear_status_after=(value)
self.clear_status_at = CLEAR_STATUS_QUICK_OPTIONS[value]&.from_now
end
+
+ def customized?
+ message.present? || emoji != UserStatus::DEFAULT_EMOJI
+ end
end
UserStatus.prepend_mod_with('UserStatus')
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 570e3ae9b3c..7b5c7fef7ba 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -55,8 +55,13 @@ module Users
preview_user_over_limit_free_plan_alert: 50, # EE-only
user_reached_limit_free_plan_alert: 51, # EE-only
submit_license_usage_data_banner: 52, # EE-only
- personal_project_limitations_banner: 53, # EE-only
- mr_experience_survey: 54
+ personal_project_limitations_banner: 53, # EE-only
+ mr_experience_survey: 54,
+ namespace_storage_limit_banner_info_threshold: 55, # EE-only
+ namespace_storage_limit_banner_warning_threshold: 56, # EE-only
+ namespace_storage_limit_banner_alert_threshold: 57, # EE-only
+ namespace_storage_limit_banner_error_threshold: 58, # EE-only
+ project_quality_summary_feedback: 59 # EE-only
}
validates :feature_name,
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index 0ea7b8199aa..70498ae83e0 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -17,7 +17,13 @@ module Users
storage_enforcement_banner_fourth_enforcement_threshold: 6,
preview_user_over_limit_free_plan_alert: 7, # EE-only
user_reached_limit_free_plan_alert: 8, # EE-only
- free_group_limited_alert: 9 # EE-only
+ free_group_limited_alert: 9, # EE-only
+ namespace_storage_limit_banner_info_threshold: 10, # EE-only
+ namespace_storage_limit_banner_warning_threshold: 11, # EE-only
+ namespace_storage_limit_banner_alert_threshold: 12, # EE-only
+ namespace_storage_limit_banner_error_threshold: 13, # EE-only
+ usage_quota_trial_alert: 14, # EE-only
+ preview_usage_quota_free_plan_alert: 15 # EE-only
}
validates :group, presence: true
diff --git a/app/models/users/project_callout.rb b/app/models/users/project_callout.rb
new file mode 100644
index 00000000000..ddc5f8fb4de
--- /dev/null
+++ b/app/models/users/project_callout.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Users
+ class ProjectCallout < ApplicationRecord
+ include Users::Calloutable
+
+ self.table_name = 'user_project_callouts'
+
+ belongs_to :project
+
+ enum feature_name: {
+ awaiting_members_banner: 1 # EE-only
+ }
+
+ validates :project, presence: true
+ validates :feature_name,
+ presence: true,
+ uniqueness: { scope: [:user_id, :project_id] },
+ inclusion: { in: ProjectCallout.feature_names.keys }
+ end
+end
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index c9cb3b0b796..d28a73b644f 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -146,8 +146,8 @@ class Wiki
repository.create_if_not_exists(default_branch)
raise CouldNotCreateWikiError unless repository_exists?
- rescue StandardError => err
- Gitlab::ErrorTracking.track_exception(err, wiki: {
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, wiki: {
container_type: container.class.name,
container_id: container.id,
full_path: full_path,
@@ -335,7 +335,7 @@ class Wiki
end
def wiki_base_path
- web_url(only_path: true).sub(%r{/#{Wiki::HOMEPAGE}\z}, '')
+ web_url(only_path: true).sub(%r{/#{Wiki::HOMEPAGE}\z}o, '')
end
# Callbacks for synchronous processing after wiki changes.
@@ -364,9 +364,9 @@ class Wiki
Gitlab::Git::CommitError,
Gitlab::Git::PreReceiveError,
Gitlab::Git::CommandError,
- ArgumentError => error
+ ArgumentError => e
- Gitlab::ErrorTracking.log_exception(error, action: action, wiki_id: id)
+ Gitlab::ErrorTracking.log_exception(e, action: action, wiki_id: id)
false
end
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index d29df0c31fc..451359c1f85 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -12,7 +12,7 @@ class WorkItem < Issue
has_many :child_links, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_parent_id
has_many :work_item_children, through: :child_links, class_name: 'WorkItem',
- foreign_key: :work_item_id, source: :work_item
+ foreign_key: :work_item_id, source: :work_item
scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) }
@@ -34,9 +34,22 @@ class WorkItem < Issue
private
+ override :parent_link_confidentiality
+ def parent_link_confidentiality
+ if confidential? && work_item_children.public_only.exists?
+ errors.add(:confidential, _('confidential parent can not be used if there are non-confidential children.'))
+ end
+
+ if !confidential? && work_item_parent&.confidential?
+ errors.add(:confidential, _('associated parent is confidential and can not have non-confidential children.'))
+ end
+ end
+
def record_create_action
super
Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter.track_work_item_created_action(author: author)
end
end
+
+WorkItem.prepend_mod
diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb
index f5ebbfa59b8..13d6db3e08e 100644
--- a/app/models/work_items/parent_link.rb
+++ b/app/models/work_items/parent_link.rb
@@ -16,6 +16,20 @@ module WorkItems
validate :validate_parent_type
validate :validate_same_project
validate :validate_max_children
+ validate :validate_confidentiality
+
+ class << self
+ def has_public_children?(parent_id)
+ joins(:work_item).where(work_item_parent_id: parent_id, 'issues.confidential': false).exists?
+ end
+
+ def has_confidential_parent?(id)
+ link = find_by_work_item_id(id)
+ return false unless link
+
+ link.work_item_parent.confidential?
+ end
+ end
private
@@ -56,5 +70,14 @@ module WorkItems
errors.add :work_item_parent, _('parent already has maximum number of children.')
end
end
+
+ def validate_confidentiality
+ return unless work_item_parent && work_item
+
+ if work_item_parent.confidential? && !work_item.confidential?
+ errors.add :work_item, _("cannot assign a non-confidential work item to a confidential "\
+ "parent. Make the work item confidential and try again.")
+ end
+ end
end
end
diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb
index e38d0ae153a..753fcbcb8f9 100644
--- a/app/models/work_items/type.rb
+++ b/app/models/work_items/type.rb
@@ -13,21 +13,23 @@ module WorkItems
# Base types need to exist on the DB on app startup
# This constant is used by the DB seeder
BASE_TYPES = {
- issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 },
- incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 },
- test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only
+ issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 },
+ incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 },
+ test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only
requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only
- task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 }
+ task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 }
}.freeze
WIDGETS_FOR_TYPE = {
- issue: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight],
+ issue: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate],
incident: [Widgets::Description, Widgets::Hierarchy],
test_case: [Widgets::Description],
requirement: [Widgets::Description],
- task: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight]
+ task: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate]
}.freeze
+ WI_TYPES_WITH_CREATED_HEADER = %w[issue incident].freeze
+
cache_markdown_field :description, pipeline: :single_line
enum base_type: BASE_TYPES.transform_values { |value| value[:enum_value] }
@@ -83,3 +85,5 @@ module WorkItems
end
end
end
+
+WorkItems::Type.prepend_mod
diff --git a/app/models/work_items/widgets/labels.rb b/app/models/work_items/widgets/labels.rb
new file mode 100644
index 00000000000..4ad8319ffac
--- /dev/null
+++ b/app/models/work_items/widgets/labels.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class Labels < Base
+ delegate :labels, to: :work_item
+ delegate :allows_scoped_labels?, to: :work_item
+ end
+ end
+end
diff --git a/app/models/work_items/widgets/start_and_due_date.rb b/app/models/work_items/widgets/start_and_due_date.rb
new file mode 100644
index 00000000000..0b828c5b5a9
--- /dev/null
+++ b/app/models/work_items/widgets/start_and_due_date.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class StartAndDueDate < Base
+ delegate :start_date, :due_date, to: :work_item
+ end
+ end
+end
diff --git a/app/models/work_items/widgets/weight.rb b/app/models/work_items/widgets/weight.rb
deleted file mode 100644
index f589378f307..00000000000
--- a/app/models/work_items/widgets/weight.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- class Weight < Base
- delegate :weight, to: :work_item
- end
- end
-end
diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb
index 6dfe9cc496b..8a99f4d1a3e 100644
--- a/app/policies/ci/runner_policy.rb
+++ b/app/policies/ci/runner_policy.rb
@@ -31,3 +31,5 @@ module Ci
rule { ~admin & locked }.prevent :assign_runner
end
end
+
+Ci::RunnerPolicy.prepend_mod_with('Ci::RunnerPolicy')
diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb
index 1a92b735e36..70b2e864094 100644
--- a/app/policies/deployment_policy.rb
+++ b/app/policies/deployment_policy.rb
@@ -24,3 +24,5 @@ class DeploymentPolicy < BasePolicy
prevent :update_deployment
end
end
+
+DeploymentPolicy.prepend_mod_with('DeploymentPolicy')
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 50b6f4bbe15..44393539327 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -180,7 +180,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_deploy_token
enable :create_jira_connect_subscription
enable :maintainer_access
- enable :maintain_namespace
+ enable :read_upload
+ enable :destroy_upload
end
rule { owner }.policy do
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index f1efcb25331..3c5e1020c8a 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -44,6 +44,10 @@ class IssuablePolicy < BasePolicy
rule { can?(:read_issue) & can?(:developer_access) }.policy do
enable :admin_incident_management_timeline_event
end
+
+ rule { can?(:reporter_access) }.policy do
+ enable :create_timelog
+ end
end
IssuablePolicy.prepend_mod_with('IssuablePolicy')
diff --git a/app/policies/namespaces/group_project_namespace_shared_policy.rb b/app/policies/namespaces/group_project_namespace_shared_policy.rb
index 1ed9f05306f..bfb1706bc5a 100644
--- a/app/policies/namespaces/group_project_namespace_shared_policy.rb
+++ b/app/policies/namespaces/group_project_namespace_shared_policy.rb
@@ -2,8 +2,20 @@
module Namespaces
class GroupProjectNamespaceSharedPolicy < ::NamespacePolicy
- # Nothing here at the moment, but as we move policies from ProjectPolicy to ProjectNamespacePolicy,
+ # As we move policies from ProjectPolicy to ProjectNamespacePolicy,
# anything common with GroupPolicy but not with UserNamespacePolicy can go in here.
# See https://gitlab.com/groups/gitlab-org/-/epics/6689
+
+ condition(:timelog_categories_enabled, score: 0, scope: :subject) do
+ Feature.enabled?(:timelog_categories, @subject)
+ end
+
+ rule { ~timelog_categories_enabled }.policy do
+ prevent :read_timelog_category
+ end
+
+ rule { can?(:reporter_access) }.policy do
+ enable :read_timelog_category
+ end
end
end
diff --git a/app/policies/namespaces/project_namespace_policy.rb b/app/policies/namespaces/project_namespace_policy.rb
index 33aadc7c411..500c325138e 100644
--- a/app/policies/namespaces/project_namespace_policy.rb
+++ b/app/policies/namespaces/project_namespace_policy.rb
@@ -2,8 +2,8 @@
module Namespaces
class ProjectNamespacePolicy < Namespaces::GroupProjectNamespaceSharedPolicy
- # For now users are not granted any permissions on project namespace
- # as it's completely hidden to them. When we start using project
- # namespaces in queries, we will have to extend this policy.
+ # TODO: once https://gitlab.com/gitlab-org/gitlab/-/issues/364277 is solved, this
+ # should not be necessary anymore, and should be replaced with `delegate(:project)`.
+ delegate(:reload_project)
end
end
diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb
index 26112332003..028247497e5 100644
--- a/app/policies/namespaces/user_namespace_policy.rb
+++ b/app/policies/namespaces/user_namespace_policy.rb
@@ -11,7 +11,6 @@ module Namespaces
enable :owner_access
enable :create_projects
enable :admin_namespace
- enable :maintain_namespace
enable :read_namespace
enable :read_statistics
enable :create_jira_connect_subscription
diff --git a/app/policies/project_hook_policy.rb b/app/policies/project_hook_policy.rb
new file mode 100644
index 00000000000..c177fabb1ba
--- /dev/null
+++ b/app/policies/project_hook_policy.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class ProjectHookPolicy < ::BasePolicy
+ delegate(:project)
+
+ rule { can?(:admin_project) }.policy do
+ enable :read_web_hook
+ enable :destroy_web_hook
+ end
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 54270dc186e..f4f7275a78a 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -209,6 +209,9 @@ class ProjectPolicy < BasePolicy
analytics
operations
security_and_compliance
+ environments
+ feature_flags
+ releases
]
features.each do |f|
@@ -366,7 +369,11 @@ class ProjectPolicy < BasePolicy
prevent(:metrics_dashboard)
end
- rule { operations_disabled }.policy do
+ condition(:split_operations_visibility_permissions) do
+ ::Feature.enabled?(:split_operations_visibility_permissions, @subject)
+ end
+
+ rule { ~split_operations_visibility_permissions & operations_disabled }.policy do
prevent(*create_read_update_admin_destroy(:feature_flag))
prevent(*create_read_update_admin_destroy(:environment))
prevent(*create_read_update_admin_destroy(:sentry_issue))
@@ -379,6 +386,21 @@ class ProjectPolicy < BasePolicy
prevent(:read_prometheus)
end
+ rule { split_operations_visibility_permissions & environments_disabled }.policy do
+ prevent(*create_read_update_admin_destroy(:environment))
+ prevent(*create_read_update_admin_destroy(:deployment))
+ end
+
+ rule { split_operations_visibility_permissions & feature_flags_disabled }.policy do
+ prevent(*create_read_update_admin_destroy(:feature_flag))
+ prevent(:admin_feature_flags_user_lists)
+ prevent(:admin_feature_flags_client)
+ end
+
+ rule { split_operations_visibility_permissions & releases_disabled }.policy do
+ prevent(*create_read_update_admin_destroy(:release))
+ end
+
rule { can?(:metrics_dashboard) }.policy do
enable :read_prometheus
enable :read_deployment
@@ -470,6 +492,7 @@ class ProjectPolicy < BasePolicy
enable :admin_pipeline
enable :admin_environment
enable :admin_deployment
+ enable :destroy_deployment
enable :admin_pages
enable :read_pages
enable :update_pages
@@ -497,6 +520,8 @@ class ProjectPolicy < BasePolicy
enable :admin_project_google_cloud
enable :admin_secure_files
enable :read_web_hooks
+ enable :read_upload
+ enable :destroy_upload
end
rule { public_project & metrics_dashboard_allowed }.policy do
diff --git a/app/policies/system_hook_policy.rb b/app/policies/system_hook_policy.rb
new file mode 100644
index 00000000000..ec28d39a5fa
--- /dev/null
+++ b/app/policies/system_hook_policy.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class SystemHookPolicy < ::BasePolicy
+ rule { admin }.policy do
+ enable :read_web_hook
+ enable :destroy_web_hook
+ end
+end
diff --git a/app/policies/time_tracking/timelog_category_policy.rb b/app/policies/time_tracking/timelog_category_policy.rb
new file mode 100644
index 00000000000..89161cdacfb
--- /dev/null
+++ b/app/policies/time_tracking/timelog_category_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module TimeTracking
+ class TimelogCategoryPolicy < BasePolicy
+ delegate { @subject.namespace }
+ end
+end
diff --git a/app/policies/upload_policy.rb b/app/policies/upload_policy.rb
new file mode 100644
index 00000000000..c7fde5d9df4
--- /dev/null
+++ b/app/policies/upload_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class UploadPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass
+ delegate { @subject.model }
+end
diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb
index 2f3561f1135..1ccc152bc6b 100644
--- a/app/policies/work_item_policy.rb
+++ b/app/policies/work_item_policy.rb
@@ -3,9 +3,12 @@
class WorkItemPolicy < IssuePolicy
condition(:is_member_and_author) { is_project_member? & is_author? }
+ rule { can?(:admin_issue) }.enable :admin_work_item
+
rule { can?(:destroy_issue) | is_member_and_author }.enable :delete_work_item
rule { can?(:update_issue) }.enable :update_work_item
+ rule { can?(:set_issue_metadata) }.enable :set_work_item_metadata
rule { can?(:read_issue) }.enable :read_work_item
# because IssuePolicy delegates to ProjectPolicy and
diff --git a/app/presenters/analytics/cycle_analytics/stage_presenter.rb b/app/presenters/analytics/cycle_analytics/stage_presenter.rb
index 7b295b814bc..d023b0c5d55 100644
--- a/app/presenters/analytics/cycle_analytics/stage_presenter.rb
+++ b/app/presenters/analytics/cycle_analytics/stage_presenter.rb
@@ -28,7 +28,7 @@ module Analytics
description: _('Time before an issue gets scheduled')
},
plan: {
- title: s_('CycleAnalyticsStage|Plan'),
+ title: s_('CycleAnalyticsStage|Plan'),
description: _('Time before an issue starts implementation')
},
code: {
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 015dfc16df0..71a05ef2c72 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -33,7 +33,8 @@ module Ci
end
def runner_variables
- variables.sort_and_expand_all(keep_undefined: true).to_runner_variables
+ stop_expanding_file_vars = ::Feature.enabled?(:ci_stop_expanding_file_vars_for_runners, project)
+ variables.sort_and_expand_all(keep_undefined: true, expand_file_vars: !stop_expanding_file_vars).to_runner_variables
end
def refspecs
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index efab1e84923..417a2f9c51f 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -191,18 +191,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def mergeable_discussions_state
- if Feature.enabled?(:change_response_code_merge_status, project)
- merge_request.mergeable_discussions_state?
- else
- # This avoids calling MergeRequest#mergeable_discussions_state without
- # considering the state of the MR first. If a MR isn't mergeable, we can
- # safely short-circuit it.
- if merge_request.mergeable_state?(skip_ci_check: true, skip_discussions_check: true)
- merge_request.mergeable_discussions_state?
- else
- false
- end
- end
+ merge_request.mergeable_discussions_state?
end
delegator_override :subscribed?
diff --git a/app/presenters/project_hook_presenter.rb b/app/presenters/project_hook_presenter.rb
index a696e9fd0ec..76a3a187924 100644
--- a/app/presenters/project_hook_presenter.rb
+++ b/app/presenters/project_hook_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class ProjectHookPresenter < Gitlab::View::Presenter::Delegated
- presents ::ProjectHook, as: :project_hook
+ presents ::ProjectHook
def logs_details_path(log)
project_hook_hook_log_path(project, self, log)
diff --git a/app/presenters/project_member_presenter.rb b/app/presenters/project_member_presenter.rb
index 91d3ae96877..da24972775a 100644
--- a/app/presenters/project_member_presenter.rb
+++ b/app/presenters/project_member_presenter.rb
@@ -3,6 +3,24 @@
class ProjectMemberPresenter < MemberPresenter
presents ::ProjectMember
+ def access_level_roles
+ ProjectMember.permissible_access_level_roles(current_user, source)
+ end
+
+ def can_remove?
+ # If this user is attempting to manage an Owner member and doesn't have permission, do not allow
+ return can_manage_owners? if member.owner?
+
+ super
+ end
+
+ def can_update?
+ # If this user is attempting to manage an Owner member and doesn't have permission, do not allow
+ return can_manage_owners? if member.owner?
+
+ super
+ end
+
private
def admin_member_permission
@@ -16,6 +34,10 @@ class ProjectMemberPresenter < MemberPresenter
def destroy_member_permission
:destroy_project_member
end
+
+ def can_manage_owners?
+ can?(current_user, :manage_owners, source)
+ end
end
ProjectMemberPresenter.prepend_mod_with('ProjectMemberPresenter')
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 84aec19cba0..209f016dc6b 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -437,9 +437,9 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
project_new_blob_path(
project,
default_branch_or_main,
- file_name: file_name,
+ file_name: file_name,
commit_message: commit_message,
- branch_name: branch_name,
+ branch_name: branch_name,
**additional_params
)
end
diff --git a/app/presenters/service_hook_presenter.rb b/app/presenters/service_hook_presenter.rb
index b34679c85cf..7ec3d7c5b5c 100644
--- a/app/presenters/service_hook_presenter.rb
+++ b/app/presenters/service_hook_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class ServiceHookPresenter < Gitlab::View::Presenter::Delegated
- presents ::ServiceHook, as: :service_hook
+ presents ::ServiceHook
def logs_details_path(log)
project_settings_integration_hook_log_path(integration.project, integration, log)
diff --git a/app/presenters/web_hook_log_presenter.rb b/app/presenters/web_hook_log_presenter.rb
index a5166589073..30941076913 100644
--- a/app/presenters/web_hook_log_presenter.rb
+++ b/app/presenters/web_hook_log_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class WebHookLogPresenter < Gitlab::View::Presenter::Delegated
- presents ::WebHookLog, as: :web_hook_log
+ presents ::WebHookLog
def details_path
web_hook.present.logs_details_path(self)
diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb
index ca2854224a7..38b3c16dd2a 100644
--- a/app/serializers/concerns/user_status_tooltip.rb
+++ b/app/serializers/concerns/user_status_tooltip.rb
@@ -13,7 +13,7 @@ module UserStatusTooltip
end
expose :show_status do |user|
- status_loaded? && show_status_emoji?(user.status)
+ status_loaded? && !!user.status&.customized?
end
expose :availability, if: -> (*) { status_loaded? } do |user|
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 3f236fa55df..6363d6276a7 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -34,8 +34,8 @@ class EnvironmentSerializer < BaseSerializer
# rubocop: disable CodeReuse/ActiveRecord
def itemize(resource)
items = resource.order('folder ASC')
- .group('COALESCE(environment_type, name)')
- .select('COALESCE(environment_type, name) AS folder',
+ .group('COALESCE(environment_type, id::text)', 'COALESCE(environment_type, name)')
+ .select('COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS folder',
'COUNT(*) AS size', 'MAX(id) AS last_id')
# It makes a difference when you call `paginate` method, because
@@ -54,11 +54,7 @@ class EnvironmentSerializer < BaseSerializer
def batch_load(resource)
temp_deployment_associations = deployment_associations
- resource = resource.preload(environment_associations.except(:last_deployment, :upcoming_deployment))
-
- if ::Feature.enabled?(:batch_load_environment_last_deployment_group, resource.first&.project)
- temp_deployment_associations[:deployable][:pipeline][:latest_successful_builds] = []
- end
+ resource = resource.preload(environment_associations)
Preloaders::Environments::DeploymentPreloader.new(resource)
.execute_with_union(:last_deployment, temp_deployment_associations)
@@ -72,18 +68,14 @@ class EnvironmentSerializer < BaseSerializer
environment.last_deployment&.commit&.try(:lazy_author)
environment.upcoming_deployment&.commit&.try(:lazy_author)
- if ::Feature.enabled?(:batch_load_environment_last_deployment_group, environment.project)
- # Batch loading last_deployment_group which is called later by environment.stop_actions
- environment.last_deployment_group
- end
+ # Batch loading last_deployment_group which is called later by environment.stop_actions
+ environment.last_deployment_group
end
end
end
def environment_associations
{
- last_deployment: deployment_associations,
- upcoming_deployment: deployment_associations,
project: project_associations
}
end
@@ -101,7 +93,8 @@ class EnvironmentSerializer < BaseSerializer
metadata: [],
pipeline: {
manual_actions: [:metadata, :deployment],
- scheduled_actions: [:metadata]
+ scheduled_actions: [:metadata],
+ latest_successful_builds: []
},
project: project_associations,
deployment: []
diff --git a/app/serializers/group_access_token_entity.rb b/app/serializers/group_access_token_entity.rb
new file mode 100644
index 00000000000..e832eef1188
--- /dev/null
+++ b/app/serializers/group_access_token_entity.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+# rubocop: disable Gitlab/NamespacedClass
+class GroupAccessTokenEntity < API::Entities::PersonalAccessToken
+ include Gitlab::Routing
+
+ expose :revoke_path do |token, options|
+ group = options.fetch(:group)
+
+ next unless group
+
+ revoke_group_settings_access_token_path(
+ id: token,
+ group_id: group.path)
+ end
+
+ expose :access_level do |token, options|
+ group = options.fetch(:group)
+
+ next unless group
+ next unless token.user
+
+ group.member(token.user)&.access_level
+ end
+end
+# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/group_access_token_serializer.rb b/app/serializers/group_access_token_serializer.rb
new file mode 100644
index 00000000000..55f6de77844
--- /dev/null
+++ b/app/serializers/group_access_token_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# rubocop: disable Gitlab/NamespacedClass
+class GroupAccessTokenSerializer < BaseSerializer
+ entity GroupAccessTokenEntity
+end
+# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/integrations/project_entity.rb b/app/serializers/integrations/project_entity.rb
index ee28c7c19c1..c091133eb39 100644
--- a/app/serializers/integrations/project_entity.rb
+++ b/app/serializers/integrations/project_entity.rb
@@ -4,6 +4,7 @@ module Integrations
class ProjectEntity < Grape::Entity
include RequestAwareEntity
+ expose :id
expose :avatar_url
expose :full_name
expose :name
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index ea43ed87d22..7ff75927fcd 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -47,6 +47,10 @@ class IssueEntity < IssuableEntity
can?(request.current_user, :update_issue, issue)
end
+ expose :can_set_issue_metadata do |issue|
+ can?(request.current_user, :set_issue_metadata, issue)
+ end
+
expose :can_award_emoji do |issue|
can?(request.current_user, :award_emoji, issue)
end
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index fc1534a88aa..40bb905c5c9 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -33,18 +33,7 @@ class MergeRequestPollWidgetEntity < Grape::Entity
# Booleans
expose :mergeable_discussions_state?, as: :mergeable_discussions_state do |merge_request|
- if Feature.enabled?(:change_response_code_merge_status, merge_request.project)
- merge_request.mergeable_discussions_state?
- else
- # This avoids calling MergeRequest#mergeable_discussions_state without
- # considering the state of the MR first. If a MR isn't mergeable, we can
- # safely short-circuit it.
- if merge_request.mergeable_state?(skip_ci_check: true, skip_discussions_check: true)
- merge_request.mergeable_discussions_state?
- else
- false
- end
- end
+ merge_request.mergeable_discussions_state?
end
expose :project_archived do |merge_request|
diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb
index 12c573d1a13..2e875af6531 100644
--- a/app/serializers/merge_request_user_entity.rb
+++ b/app/serializers/merge_request_user_entity.rb
@@ -20,10 +20,6 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic
find_reviewer_or_assignee(user, options)&.reviewed?
end
- expose :attention_requested, if: ->(_, options) { options[:merge_request].present? && options[:merge_request].allows_reviewers? && request.current_user&.mr_attention_requests_enabled? } do |user, options|
- find_reviewer_or_assignee(user, options)&.attention_requested?
- end
-
expose :approved, if: satisfies(:present?) do |user, options|
# This approach is preferred over MergeRequest#approved_by? since this
# makes one query per merge request, whereas #approved_by? makes one per user
diff --git a/app/serializers/personal_access_token_entity.rb b/app/serializers/personal_access_token_entity.rb
new file mode 100644
index 00000000000..acd06fecd12
--- /dev/null
+++ b/app/serializers/personal_access_token_entity.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# rubocop: disable Gitlab/NamespacedClass
+class PersonalAccessTokenEntity < API::Entities::PersonalAccessToken
+ include Gitlab::Routing
+
+ expose :revoke_path do |token, options|
+ revoke_profile_personal_access_token_path(token)
+ end
+end
+# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/personal_access_token_serializer.rb b/app/serializers/personal_access_token_serializer.rb
new file mode 100644
index 00000000000..0a59fa117f9
--- /dev/null
+++ b/app/serializers/personal_access_token_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# rubocop: disable Gitlab/NamespacedClass
+class PersonalAccessTokenSerializer < BaseSerializer
+ entity PersonalAccessTokenEntity
+end
+# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/project_access_token_entity.rb b/app/serializers/project_access_token_entity.rb
new file mode 100644
index 00000000000..b317057c952
--- /dev/null
+++ b/app/serializers/project_access_token_entity.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# rubocop: disable Gitlab/NamespacedClass
+class ProjectAccessTokenEntity < API::Entities::PersonalAccessToken
+ include Gitlab::Routing
+
+ expose :revoke_path do |token, options|
+ project = options.fetch(:project)
+
+ next unless project
+
+ revoke_namespace_project_settings_access_token_path(
+ id: token,
+ namespace_id: project.namespace.path,
+ project_id: project.path)
+ end
+
+ expose :access_level do |token, options|
+ project = options.fetch(:project)
+
+ next unless project
+ next unless token.user
+
+ project.member(token.user)&.access_level
+ end
+end
+# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/project_access_token_serializer.rb b/app/serializers/project_access_token_serializer.rb
new file mode 100644
index 00000000000..97db088cf64
--- /dev/null
+++ b/app/serializers/project_access_token_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# rubocop: disable Gitlab/NamespacedClass
+class ProjectAccessTokenSerializer < BaseSerializer
+ entity ProjectAccessTokenEntity
+end
+# rubocop: enable Gitlab/NamespacedClass
diff --git a/app/serializers/rollout_status_entity.rb b/app/serializers/rollout_status_entity.rb
index 9f4c844859b..f432fe98289 100644
--- a/app/serializers/rollout_status_entity.rb
+++ b/app/serializers/rollout_status_entity.rb
@@ -14,5 +14,5 @@ class RolloutStatusEntity < Grape::Entity
expose :completion, if: -> (rollout_status, _) { rollout_status.found? }
expose :complete?, as: :is_completed, if: -> (rollout_status, _) { rollout_status.found? }
expose :canary_ingress, using: RolloutStatuses::IngressEntity, expose_nil: false,
- if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? }
+ if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? }
end
diff --git a/app/services/audit_events/build_service.rb b/app/services/audit_events/build_service.rb
new file mode 100644
index 00000000000..f5322fa5ff4
--- /dev/null
+++ b/app/services/audit_events/build_service.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module AuditEvents
+ class BuildService
+ # Handle missing attributes
+ MissingAttributeError = Class.new(StandardError)
+
+ # @raise [MissingAttributeError] when required attributes are blank
+ #
+ # @return [BuildService]
+ def initialize(
+ author:, scope:, target:, message:,
+ created_at: DateTime.current, additional_details: {}, ip_address: nil, target_details: nil)
+ raise MissingAttributeError if missing_attribute?(author, scope, target, message)
+
+ @author = build_author(author)
+ @scope = scope
+ @target = build_target(target)
+ @ip_address = ip_address || build_ip_address
+ @message = build_message(message)
+ @created_at = created_at
+ @additional_details = additional_details
+ @target_details = target_details
+ end
+
+ # Create an instance of AuditEvent
+ #
+ # @return [AuditEvent]
+ def execute
+ AuditEvent.new(payload)
+ end
+
+ private
+
+ def missing_attribute?(author, scope, target, message)
+ author.blank? || scope.blank? || target.blank? || message.blank?
+ end
+
+ def payload
+ base_payload.merge(details: base_details_payload)
+ end
+
+ def base_payload
+ {
+ author_id: @author.id,
+ author_name: @author.name,
+ entity_id: @scope.id,
+ entity_type: @scope.class.name,
+ created_at: @created_at
+ }
+ end
+
+ def base_details_payload
+ @additional_details.merge({
+ author_name: @author.name,
+ author_class: @author.class.name,
+ target_id: @target.id,
+ target_type: @target.type,
+ target_details: @target_details || @target.details,
+ custom_message: @message
+ })
+ end
+
+ def build_author(author)
+ author.id = -2 if author.instance_of? DeployToken
+ author.id = -3 if author.instance_of? DeployKey
+
+ author
+ end
+
+ def build_target(target)
+ return target if target.is_a? ::Gitlab::Audit::NullTarget
+
+ ::Gitlab::Audit::Target.new(target)
+ end
+
+ def build_message(message)
+ message
+ end
+
+ def build_ip_address
+ Gitlab::RequestContext.instance.client_ip || @author.current_sign_in_ip
+ end
+ end
+end
+
+AuditEvents::BuildService.prepend_mod_with('AuditEvents::BuildService')
diff --git a/app/services/authorized_project_update/project_recalculate_service.rb b/app/services/authorized_project_update/project_recalculate_service.rb
index 17ba48cffcd..e0b8158417c 100644
--- a/app/services/authorized_project_update/project_recalculate_service.rb
+++ b/app/services/authorized_project_update/project_recalculate_service.rb
@@ -47,7 +47,7 @@ module AuthorizedProjectUpdate
def user_ids_to_remove
strong_memoize(:user_ids_to_remove) do
(current_authorizations - fresh_authorizations)
- .map {|user_id, _| user_id }
+ .map { |user_id, _| user_id }
end
end
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
index 9e49bd86ec0..1660ddb934f 100644
--- a/app/services/auto_merge/base_service.rb
+++ b/app/services/auto_merge/base_service.rb
@@ -59,6 +59,7 @@ module AutoMerge
!merge_request.broken? &&
!merge_request.draft? &&
merge_request.mergeable_discussions_state? &&
+ !merge_request.merge_blocked_by_other_mrs? &&
yield
end
end
diff --git a/app/services/base_count_service.rb b/app/services/base_count_service.rb
index ff1949ce4dd..eff3eb33c71 100644
--- a/app/services/base_count_service.rb
+++ b/app/services/base_count_service.rb
@@ -45,7 +45,7 @@ class BaseCountService
end
def update_cache_for_key(key, &block)
- Rails.cache.write(key, block_given? ? yield : uncached_count, raw: raw?)
+ Rails.cache.write(key, block ? yield : uncached_count, raw: raw?)
end
end
diff --git a/app/services/boards/destroy_service.rb b/app/services/boards/destroy_service.rb
index 0b1cd61b119..ceda005044e 100644
--- a/app/services/boards/destroy_service.rb
+++ b/app/services/boards/destroy_service.rb
@@ -3,10 +3,6 @@
module Boards
class DestroyService < Boards::BaseService
def execute(board)
- if boards.size == 1
- return ServiceResponse.error(message: "The board could not be deleted, because the parent doesn't have any other boards.")
- end
-
board.destroy!
ServiceResponse.success
diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb
index 93f81837d1a..4bb7b4dbc6d 100644
--- a/app/services/boards/lists/move_service.rb
+++ b/app/services/boards/lists/move_service.rb
@@ -23,7 +23,7 @@ module Boards
def valid_move?
new_position.present? && new_position != old_position &&
- new_position >= 0 && new_position < board.lists.movable.size
+ new_position >= 0 && new_position <= board.lists.movable.last.position
end
def reorder_intermediate_lists
diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb
index 7300b31e3b3..5cbd587e546 100644
--- a/app/services/branches/create_service.rb
+++ b/app/services/branches/create_service.rb
@@ -2,35 +2,91 @@
module Branches
class CreateService < BaseService
+ def initialize(project, user = nil, params = {})
+ super(project, user, params)
+
+ @errors = []
+ end
+
def execute(branch_name, ref, create_default_branch_if_empty: true)
create_default_branch if create_default_branch_if_empty && project.empty_repo?
- result = ::Branches::ValidateNewService.new(project).execute(branch_name)
+ result = branch_validation_service.execute(branch_name)
return result if result[:status] == :error
- begin
- new_branch = repository.add_branch(current_user, branch_name, ref)
- rescue Gitlab::Git::CommandError => e
- return error("Failed to create branch '#{branch_name}': #{e}")
+ create_branch(branch_name, ref)
+ end
+
+ def bulk_create(branches)
+ reset_errors
+
+ created_branches =
+ branches
+ .then { |branches| only_valid_branches(branches) }
+ .then { |branches| create_branches(branches) }
+ .then { |branches| expire_branches_cache(branches) }
+
+ return error(errors) if errors.present?
+
+ success(branches: created_branches)
+ end
+
+ private
+
+ attr_reader :errors
+
+ def reset_errors
+ @errors = []
+ end
+
+ def only_valid_branches(branches)
+ branches.select do |branch_name, _ref|
+ result = branch_validation_service.execute(branch_name)
+
+ if result[:status] == :error
+ errors << result[:message]
+ next
+ end
+
+ true
end
+ end
+
+ def create_branches(branches)
+ branches.filter_map do |branch_name, ref|
+ result = create_branch(branch_name, ref, expire_cache: false)
+
+ if result[:status] == :error
+ errors << result[:message]
+ next
+ end
+
+ result[:branch]
+ end
+ end
+
+ def expire_branches_cache(branches)
+ repository.expire_branches_cache if branches.present?
+
+ branches
+ end
+
+ def create_branch(branch_name, ref, expire_cache: true)
+ new_branch = repository.add_branch(current_user, branch_name, ref, expire_cache: expire_cache)
if new_branch
- success(new_branch)
+ success(branch: new_branch)
else
error("Failed to create branch '#{branch_name}': invalid reference name '#{ref}'")
end
+ rescue Gitlab::Git::CommandError => e
+ error("Failed to create branch '#{branch_name}': #{e}")
rescue Gitlab::Git::PreReceiveError => e
Gitlab::ErrorTracking.log_exception(e, pre_receive_message: e.raw_message, branch_name: branch_name, ref: ref)
error(e.message)
end
- def success(branch)
- super().merge(branch: branch)
- end
-
- private
-
def create_default_branch
project.repository.create_file(
current_user,
@@ -40,5 +96,9 @@ module Branches
branch_name: project.default_branch_or_main
)
end
+
+ def branch_validation_service
+ @branch_validation_service ||= ::Branches::ValidateNewService.new(project)
+ end
end
end
diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb
index cbf2b34b33c..31e1a822e78 100644
--- a/app/services/bulk_imports/create_service.rb
+++ b/app/services/bulk_imports/create_service.rb
@@ -64,7 +64,7 @@ module BulkImports
bulk_import: bulk_import,
source_type: entity[:source_type],
source_full_path: entity[:source_full_path],
- destination_name: entity[:destination_name],
+ destination_slug: entity[:destination_slug],
destination_namespace: entity[:destination_namespace]
)
end
diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb
index 8d6ba54cd50..a2c8ba5b1cd 100644
--- a/app/services/bulk_imports/file_download_service.rb
+++ b/app/services/bulk_imports/file_download_service.rb
@@ -55,12 +55,17 @@ module BulkImports
bytes_downloaded = 0
http_client.stream(relative_url) do |chunk|
+ next if bytes_downloaded == 0 && [301, 302, 303, 307, 308].include?(chunk.code)
+
bytes_downloaded += chunk.size
validate_size!(bytes_downloaded)
- raise(ServiceError, "File download error #{chunk.code}") unless chunk.code == 200
- file.write(chunk)
+ if chunk.code == 200
+ file.write(chunk)
+ else
+ raise(ServiceError, "File download error #{chunk.code}")
+ end
end
end
rescue StandardError => e
diff --git a/app/services/chat_names/authorize_user_service.rb b/app/services/chat_names/authorize_user_service.rb
index f7780488923..6c28a1cea7e 100644
--- a/app/services/chat_names/authorize_user_service.rb
+++ b/app/services/chat_names/authorize_user_service.rb
@@ -4,8 +4,8 @@ module ChatNames
class AuthorizeUserService
include Gitlab::Routing
- def initialize(service, params)
- @service = service
+ def initialize(integration, params)
+ @integration = integration
@params = params
end
@@ -29,11 +29,11 @@ module ChatNames
def chat_name_params
{
- service_id: @service.id,
- team_id: @params[:team_id],
+ integration_id: @integration.id,
+ team_id: @params[:team_id],
team_domain: @params[:team_domain],
- chat_id: @params[:user_id],
- chat_name: @params[:user_name]
+ chat_id: @params[:user_id],
+ chat_name: @params[:user_name]
}
end
end
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
index 7b1d2207460..9705a236d98 100644
--- a/app/services/ci/archive_trace_service.rb
+++ b/app/services/ci/archive_trace_service.rb
@@ -62,8 +62,8 @@ module Ci
failed_archive_counter.increment
Sidekiq.logger.warn(class: worker_name,
- message: "Failed to archive trace. message: #{error.message}.",
- job_id: job.id)
+ message: "Failed to archive trace. message: #{error.message}.",
+ job_id: job.id)
Gitlab::ErrorTracking
.track_and_raise_for_dev_exception(error,
diff --git a/app/services/ci/deployments/destroy_service.rb b/app/services/ci/deployments/destroy_service.rb
new file mode 100644
index 00000000000..ac51fa55537
--- /dev/null
+++ b/app/services/ci/deployments/destroy_service.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Ci
+ module Deployments
+ class DestroyService < BaseService
+ def execute(deployment)
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_deployment, deployment)
+
+ return ServiceResponse.error(message: 'Cannot destroy running deployment') if deployment&.running?
+ return ServiceResponse.error(message: 'Deployment currently deployed to environment') if deployment&.last?
+
+ project.destroy_deployment_by_id(deployment)
+
+ ServiceResponse.success(message: 'Deployment destroyed')
+ end
+ end
+ end
+end
diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb
index d85e52e1312..1c563396162 100644
--- a/app/services/ci/destroy_pipeline_service.rb
+++ b/app/services/ci/destroy_pipeline_service.rb
@@ -7,7 +7,7 @@ module Ci
Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true)
- pipeline.cancel_running if pipeline.cancelable?
+ pipeline.cancel_running(cascade_to_children: true, execute_async: false) if pipeline.cancelable?
# The pipeline, the builds, job and pipeline artifacts all get destroyed here.
# Ci::Pipeline#destroy triggers fast destroy on job_artifacts and
diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb
index 05f8e804c67..af56eb221d5 100644
--- a/app/services/ci/job_artifacts/create_service.rb
+++ b/app/services/ci/job_artifacts/create_service.rb
@@ -126,6 +126,8 @@ module Ci
job.update_column(:artifacts_expire_at, artifact.expire_at)
end
+ Gitlab::Ci::Artifacts::Logger.log_created(artifact)
+
success(artifact: artifact)
rescue ActiveRecord::RecordNotUnique => error
track_exception(error, params)
diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb
index 9d6b413ce59..54ec2c671c6 100644
--- a/app/services/ci/job_artifacts/destroy_batch_service.rb
+++ b/app/services/ci/job_artifacts/destroy_batch_service.rb
@@ -53,8 +53,10 @@ module Ci
update_project_statistics! if update_stats
increment_monitoring_statistics(artifacts_count, artifacts_bytes)
+ Gitlab::Ci::Artifacts::Logger.log_deleted(@job_artifacts, 'Ci::JobArtifacts::DestroyBatchService#execute')
+
success(destroyed_artifacts_count: artifacts_count,
- statistics_updates: affected_project_statistics)
+ statistics_updates: affected_project_statistics)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb
index 88dac514bb9..c791a89b804 100644
--- a/app/services/ci/list_config_variables_service.rb
+++ b/app/services/ci/list_config_variables_service.rb
@@ -26,8 +26,8 @@ module Ci
return {} unless config
result = Gitlab::Ci::YamlProcessor.new(config, project: project,
- user: current_user,
- sha: sha).execute
+ user: current_user,
+ sha: sha).execute
result.valid? ? result.variables_with_data : {}
end
diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb
index 40e2cd82b4f..fd13ed245cf 100644
--- a/app/services/ci/parse_dotenv_artifact_service.rb
+++ b/app/services/ci/parse_dotenv_artifact_service.rb
@@ -40,7 +40,7 @@ module Ci
key, value = scan_line!(line)
variables[key] = Ci::JobVariable.new(job_id: artifact.job_id,
- source: :dotenv, key: key, value: value)
+ source: :dotenv, key: key, value: value)
end
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 8969b95b81f..b357855db12 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -4,6 +4,8 @@ module Ci
# This class responsible for assigning
# proper pending build to runner on runner API request
class RegisterJobService
+ include ::Gitlab::Ci::Artifacts::Logger
+
attr_reader :runner, :metrics
TEMPORARY_LOCK_TIMEOUT = 3.seconds
@@ -220,10 +222,26 @@ module Ci
# We need to use the presenter here because Gitaly calls in the presenter
# may fail, and we need to ensure the response has been generated.
presented_build = ::Ci::BuildRunnerPresenter.new(build) # rubocop:disable CodeReuse/Presenter
+
+ log_artifacts_context(build)
+ log_build_dependencies_size(presented_build)
+
build_json = ::API::Entities::Ci::JobRequest::Response.new(presented_build).to_json
Result.new(build, build_json, true)
end
+ def log_build_dependencies_size(presented_build)
+ return unless ::Feature.enabled?(:ci_build_dependencies_artifacts_logger, type: :ops)
+
+ presented_build.all_dependencies.then do |dependencies|
+ size = dependencies.sum do |build|
+ build.available_artifacts? ? build.artifacts_file.size : 0
+ end
+
+ log_build_dependencies(size: size, count: dependencies.size) if size > 0
+ end
+ end
+
def assign_runner!(build, params)
build.runner_id = runner.id
build.runner_session_attributes = params[:session] if params[:session].present?
diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb
index e0ced3d0197..25bda8a6380 100644
--- a/app/services/ci/retry_job_service.rb
+++ b/app/services/ci/retry_job_service.rb
@@ -4,10 +4,10 @@ module Ci
class RetryJobService < ::BaseService
include Gitlab::Utils::StrongMemoize
- def execute(job)
+ def execute(job, variables: [])
if job.retryable?
job.ensure_scheduling_type!
- new_job = retry_job(job)
+ new_job = retry_job(job, variables: variables)
ServiceResponse.success(payload: { job: new_job })
else
@@ -19,7 +19,7 @@ module Ci
end
# rubocop: disable CodeReuse/ActiveRecord
- def clone!(job)
+ def clone!(job, variables: [])
# Cloning a job requires a strict type check to ensure
# the attributes being used for the clone are taken straight
# from the model and not overridden by other abstractions.
@@ -27,7 +27,7 @@ module Ci
check_access!(job)
- new_job = job.clone(current_user: current_user)
+ new_job = job.clone(current_user: current_user, new_job_variables_attributes: variables)
new_job.run_after_commit do
::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job)
@@ -55,8 +55,8 @@ module Ci
def check_assignable_runners!(job); end
- def retry_job(job)
- clone!(job).tap do |new_job|
+ def retry_job(job, variables: [])
+ clone!(job, variables: variables).tap do |new_job|
check_assignable_runners!(new_job) if new_job.is_a?(Ci::Build)
next if new_job.failed?
diff --git a/app/services/ci/runners/assign_runner_service.rb b/app/services/ci/runners/assign_runner_service.rb
index 886cd3a4e44..290f945cc72 100644
--- a/app/services/ci/runners/assign_runner_service.rb
+++ b/app/services/ci/runners/assign_runner_service.rb
@@ -13,9 +13,15 @@ module Ci
end
def execute
- return false unless @user.present? && @user.can?(:assign_runner, @runner)
+ unless @user.present? && @user.can?(:assign_runner, @runner)
+ return ServiceResponse.error(message: 'user not allowed to assign runner', http_status: :forbidden)
+ end
- @runner.assign_to(@project, @user)
+ if @runner.assign_to(@project, @user)
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: 'failed to assign runner')
+ end
end
private
diff --git a/app/services/ci/runners/bulk_delete_runners_service.rb b/app/services/ci/runners/bulk_delete_runners_service.rb
new file mode 100644
index 00000000000..ce07aa541c2
--- /dev/null
+++ b/app/services/ci/runners/bulk_delete_runners_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class BulkDeleteRunnersService
+ attr_reader :runners
+
+ RUNNER_LIMIT = 50
+
+ # @param runners [Array<Ci::Runner, Integer>] the runners to unregister/destroy
+ def initialize(runners:)
+ @runners = runners
+ end
+
+ def execute
+ if @runners
+ # Delete a few runners immediately
+ return ServiceResponse.success(payload: delete_runners)
+ end
+
+ ServiceResponse.success(payload: { deleted_count: 0, deleted_ids: [] })
+ end
+
+ private
+
+ def delete_runners
+ # rubocop:disable CodeReuse/ActiveRecord
+ runners_to_be_deleted = Ci::Runner.where(id: @runners).limit(RUNNER_LIMIT)
+ # rubocop:enable CodeReuse/ActiveRecord
+ deleted_ids = runners_to_be_deleted.destroy_all.map(&:id) # rubocop: disable Cop/DestroyAll
+
+ { deleted_count: deleted_ids.count, deleted_ids: deleted_ids }
+ end
+ end
+ end
+end
diff --git a/app/services/ci/runners/process_runner_version_update_service.rb b/app/services/ci/runners/process_runner_version_update_service.rb
new file mode 100644
index 00000000000..c8a5e42ccab
--- /dev/null
+++ b/app/services/ci/runners/process_runner_version_update_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class ProcessRunnerVersionUpdateService
+ def initialize(version)
+ @version = version
+ end
+
+ def execute
+ return ServiceResponse.error(message: 'version not present') unless @version
+
+ _, status = upgrade_check_service.check_runner_upgrade_suggestion(@version)
+ return ServiceResponse.error(message: 'upgrade version check failed') if status == :error
+
+ Ci::RunnerVersion.upsert({ version: @version, status: status })
+ ServiceResponse.success(payload: { upgrade_status: status.to_s })
+ end
+
+ private
+
+ def upgrade_check_service
+ @runner_upgrade_check ||= Gitlab::Ci::RunnerUpgradeCheck.new(::Gitlab::VERSION)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/runners/reconcile_existing_runner_versions_service.rb b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb
index e04079bfe27..1950d82845b 100644
--- a/app/services/ci/runners/reconcile_existing_runner_versions_service.rb
+++ b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb
@@ -3,8 +3,6 @@
module Ci
module Runners
class ReconcileExistingRunnerVersionsService
- include BaseServiceUtility
-
VERSION_BATCH_SIZE = 100
def execute
@@ -12,7 +10,7 @@ module Ci
total_deleted = cleanup_runner_versions(insert_result[:versions_from_runners])
total_updated = update_status_on_outdated_runner_versions(insert_result[:versions_from_runners])
- success({
+ ServiceResponse.success(payload: {
total_inserted: insert_result[:new_record_count],
total_updated: total_updated,
total_deleted: total_deleted
@@ -22,7 +20,7 @@ module Ci
private
def upgrade_check
- Gitlab::Ci::RunnerUpgradeCheck.instance
+ @runner_upgrade_check ||= Gitlab::Ci::RunnerUpgradeCheck.new(::Gitlab::VERSION)
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -74,13 +72,11 @@ module Ci
end
def runner_version_with_updated_status(runner_version)
- version = runner_version['version']
- suggestion = upgrade_check.check_runner_upgrade_status(version)
- new_status = suggestion.each_key.first
+ _, new_status = upgrade_check.check_runner_upgrade_suggestion(runner_version.version)
- if new_status != :error && new_status != runner_version['status'].to_sym
+ if new_status != :error && new_status != runner_version.status.to_sym
{
- version: version,
+ version: runner_version.version,
status: Ci::RunnerVersion.statuses[new_status]
}
end
diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb
index 6588cd7e248..ae9b8bc8a16 100644
--- a/app/services/ci/runners/register_runner_service.rb
+++ b/app/services/ci/runners/register_runner_service.rb
@@ -6,7 +6,7 @@ module Ci
def execute(registration_token, attributes)
runner_type_attrs = extract_runner_type_attrs(registration_token)
- return unless runner_type_attrs
+ return ServiceResponse.error(message: 'invalid token supplied', http_status: :forbidden) unless runner_type_attrs
runner = ::Ci::Runner.new(attributes.merge(runner_type_attrs))
@@ -20,7 +20,7 @@ module Ci
end
end
- runner
+ ServiceResponse.success(payload: { runner: runner })
end
private
diff --git a/app/services/ci/runners/reset_registration_token_service.rb b/app/services/ci/runners/reset_registration_token_service.rb
index 81a70a771cf..dddbfb78d44 100644
--- a/app/services/ci/runners/reset_registration_token_service.rb
+++ b/app/services/ci/runners/reset_registration_token_service.rb
@@ -11,15 +11,19 @@ module Ci
end
def execute
- return unless @user.present? && @user.can?(:update_runners_registration_token, scope)
+ unless @user.present? && @user.can?(:update_runners_registration_token, scope)
+ return ServiceResponse.error(message: 'user not allowed to update runners registration token')
+ end
if scope.respond_to?(:runners_registration_token)
scope.reset_runners_registration_token!
- scope.runners_registration_token
+ runners_token = scope.runners_registration_token
else
scope.reset_runners_token!
- scope.runners_token
+ runners_token = scope.runners_token
end
+
+ ServiceResponse.success(payload: { new_registration_token: runners_token })
end
private
diff --git a/app/services/ci/runners/unassign_runner_service.rb b/app/services/ci/runners/unassign_runner_service.rb
index 1e46cf6add8..c40e5e0d44e 100644
--- a/app/services/ci/runners/unassign_runner_service.rb
+++ b/app/services/ci/runners/unassign_runner_service.rb
@@ -13,9 +13,15 @@ module Ci
end
def execute
- return false unless @user.present? && @user.can?(:assign_runner, @runner)
+ unless @user.present? && @user.can?(:assign_runner, @runner)
+ return ServiceResponse.error(message: 'user not allowed to assign runner')
+ end
- @runner_project.destroy
+ if @runner_project.destroy
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: 'failed to destroy runner project')
+ end
end
private
diff --git a/app/services/ci/runners/unregister_runner_service.rb b/app/services/ci/runners/unregister_runner_service.rb
index 4ee1e73c458..742b21f77df 100644
--- a/app/services/ci/runners/unregister_runner_service.rb
+++ b/app/services/ci/runners/unregister_runner_service.rb
@@ -14,6 +14,7 @@ module Ci
def execute
@runner&.destroy
+ ServiceResponse.success
end
end
end
diff --git a/app/services/ci/stuck_builds/drop_helpers.rb b/app/services/ci/stuck_builds/drop_helpers.rb
index 048b52c6e13..dca50963883 100644
--- a/app/services/ci/stuck_builds/drop_helpers.rb
+++ b/app/services/ci/stuck_builds/drop_helpers.rb
@@ -56,12 +56,12 @@ module Ci
def log_dropping_message(type, build, reason)
Gitlab::AppLogger.info(class: self.class.name,
- message: "Dropping #{type} build",
- build_stuck_type: type,
- build_id: build.id,
- runner_id: build.runner_id,
- build_status: build.status,
- build_failure_reason: reason)
+ message: "Dropping #{type} build",
+ build_stuck_type: type,
+ build_id: build.id,
+ runner_id: build.runner_id,
+ build_status: build.status,
+ build_failure_reason: reason)
end
end
end
diff --git a/app/services/ci/track_failed_build_service.rb b/app/services/ci/track_failed_build_service.rb
new file mode 100644
index 00000000000..caf7034234c
--- /dev/null
+++ b/app/services/ci/track_failed_build_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+# This service tracks failed CI builds using Snowplow.
+#
+# @param build [Ci::Build] the build that failed.
+# @param exit_code [Int] the resulting exit code.
+module Ci
+ class TrackFailedBuildService
+ SCHEMA_URL = 'iglu:com.gitlab/ci_build_failed/jsonschema/1-0-0'
+
+ def initialize(build:, exit_code:, failure_reason:)
+ @build = build
+ @exit_code = exit_code
+ @failure_reason = failure_reason
+ end
+
+ def execute
+ # rubocop:disable Style/IfUnlessModifier
+ unless @build.failed?
+ return ServiceResponse.error(message: 'Attempted to track a non-failed CI build')
+ end
+
+ # rubocop:enable Style/IfUnlessModifier
+
+ context = SnowplowTracker::SelfDescribingJson.new(SCHEMA_URL, payload)
+
+ ::Gitlab::Tracking.event(
+ 'ci::build',
+ 'failed',
+ context: [context],
+ user: @build.user,
+ project: @build.project_id)
+
+ ServiceResponse.success
+ end
+
+ private
+
+ def payload
+ {
+ build_id: @build.id,
+ build_name: @build.name,
+ build_artifact_types: @build.job_artifact_types,
+ exit_code: @exit_code,
+ failure_reason: @failure_reason
+ }
+ end
+ end
+end
diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb
index a74ddcfaf06..835d5f9a16c 100644
--- a/app/services/ci/update_build_state_service.rb
+++ b/app/services/ci/update_build_state_service.rb
@@ -105,7 +105,7 @@ module Ci
Result.new(status: 200)
when 'failed'
- build.drop_with_exit_code!(params[:failure_reason] || :unknown_failure, params[:exit_code])
+ build.drop_with_exit_code!(params[:failure_reason], params[:exit_code])
Result.new(status: 200)
else
diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb
index f10ff4e6f19..8c6c7b15d28 100644
--- a/app/services/concerns/alert_management/alert_processing.rb
+++ b/app/services/concerns/alert_management/alert_processing.rb
@@ -39,12 +39,6 @@ module AlertManagement
SystemNoteService.change_alert_status(alert, User.alert_bot)
close_issue(alert.issue_id) if auto_close_incident?
- else
- logger.warn(
- message: 'Unable to update AlertManagement::Alert status to resolved',
- project_id: project.id,
- alert_id: alert.id
- )
end
end
@@ -64,13 +58,18 @@ module AlertManagement
if alert.save
alert.execute_integrations
SystemNoteService.create_new_alert(alert, alert_source)
+ elsif alert.errors[:fingerprint].any?
+ refind_and_increment_alert
else
logger.warn(
- message: "Unable to create AlertManagement::Alert from #{alert_source}",
+ message: "Unable to create AlertManagement::Alert",
project_id: project.id,
- alert_errors: alert.errors.messages
+ alert_errors: alert.errors.messages,
+ alert_source: alert_source
)
end
+ rescue ActiveRecord::RecordNotUnique
+ refind_and_increment_alert
end
def process_incident_issues
@@ -107,6 +106,12 @@ module AlertManagement
AlertManagement::Alert.new(**incoming_payload.alert_params, ended_at: nil)
end
+ def refind_and_increment_alert
+ clear_memoization(:alert)
+
+ process_firing_alert
+ end
+
def resolving_alert?
incoming_payload.ends_at.present?
end
diff --git a/app/services/concerns/work_items/widgetable_service.rb b/app/services/concerns/work_items/widgetable_service.rb
index 5665b07dce1..beb614c7b76 100644
--- a/app/services/concerns/work_items/widgetable_service.rb
+++ b/app/services/concerns/work_items/widgetable_service.rb
@@ -18,7 +18,7 @@ module WorkItems
# rubocop:enable Gitlab/ModuleWithInstanceVariables
def widget_service_class(widget)
- "WorkItems::Widgets::#{widget.type.capitalize}Service::#{self.class.name.demodulize}".constantize
+ "WorkItems::Widgets::#{widget.type.to_s.camelize}Service::#{self.class.name.demodulize}".constantize
rescue NameError
nil
end
diff --git a/app/services/database/consistency_check_service.rb b/app/services/database/consistency_check_service.rb
index e39bc8f25b8..fee2e79a6cb 100644
--- a/app/services/database/consistency_check_service.rb
+++ b/app/services/database/consistency_check_service.rb
@@ -80,7 +80,7 @@ module Database
end
def max_id
- @max_id ||= source_model.minimum(source_sort_column)
+ @max_id ||= source_model.maximum(source_sort_column)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb
index b0eb153a7af..3cacedc7d6e 100644
--- a/app/services/deployments/update_environment_service.rb
+++ b/app/services/deployments/update_environment_service.rb
@@ -58,11 +58,7 @@ module Deployments
def expanded_environment_url
return unless environment_url
- if ::Feature.enabled?(:ci_expand_environment_name_and_url, deployment.project)
- ExpandVariables.expand(environment_url, -> { variables.sort_and_expand_all })
- else
- ExpandVariables.expand(environment_url, -> { variables })
- end
+ ExpandVariables.expand(environment_url, -> { variables.sort_and_expand_all })
end
def environment_url
@@ -88,7 +84,7 @@ module Deployments
def renew_deployment_tier
return unless deployable
- if (tier = deployable.environment_deployment_tier)
+ if (tier = deployable.environment_tier_from_options)
environment.tier = tier
end
end
diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb
index e56d163c461..3ff239b59cc 100644
--- a/app/services/design_management/generate_image_versions_service.rb
+++ b/app/services/design_management/generate_image_versions_service.rb
@@ -43,7 +43,7 @@ module DesignManagement
end
# Skip attempting to process images that would be rejected by CarrierWave.
- return unless DesignManagement::DesignV432x230Uploader::MIME_TYPE_WHITELIST.include?(raw_file.content_type)
+ return unless DesignManagement::DesignV432x230Uploader::MIME_TYPE_ALLOWLIST.include?(raw_file.content_type)
# Store and process the file
action.image_v432x230.store!(raw_file)
diff --git a/app/services/error_tracking/base_service.rb b/app/services/error_tracking/base_service.rb
index d2ecd0a6d5a..8458eb1f3b8 100644
--- a/app/services/error_tracking/base_service.rb
+++ b/app/services/error_tracking/base_service.rb
@@ -25,7 +25,7 @@ module ErrorTracking
errors = parse_errors(response)
return errors if errors
- yield if block_given?
+ yield if block
track_usage_event(params[:tracking_event], current_user.id) if params[:tracking_event]
diff --git a/app/services/google_cloud/base_service.rb b/app/services/google_cloud/base_service.rb
index 016ab15408f..01aee2231c9 100644
--- a/app/services/google_cloud/base_service.rb
+++ b/app/services/google_cloud/base_service.rb
@@ -22,7 +22,7 @@ module GoogleCloud
def unique_gcp_project_ids
filter_params = { key: 'GCP_PROJECT_ID' }
- ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq
+ @unique_gcp_project_ids ||= ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq
end
def group_vars_by_environment(keys)
diff --git a/app/services/google_cloud/create_cloudsql_instance_service.rb b/app/services/google_cloud/create_cloudsql_instance_service.rb
new file mode 100644
index 00000000000..f7fca277c52
--- /dev/null
+++ b/app/services/google_cloud/create_cloudsql_instance_service.rb
@@ -0,0 +1,74 @@
+# 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.to_json)
+ 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/enable_cloudsql_service.rb b/app/services/google_cloud/enable_cloudsql_service.rb
new file mode 100644
index 00000000000..a466b2f3696
--- /dev/null
+++ b/app/services/google_cloud/enable_cloudsql_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class EnableCloudsqlService < ::GoogleCloud::BaseService
+ def execute
+ return no_projects_error if unique_gcp_project_ids.empty?
+
+ 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 })
+ end
+
+ private
+
+ def no_projects_error
+ error("No GCP projects found. Configure a service account or GCP_PROJECT_ID CI variable.")
+ 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
new file mode 100644
index 00000000000..701e83d556d
--- /dev/null
+++ b/app/services/google_cloud/get_cloudsql_instances_service.rb
@@ -0,0 +1,18 @@
+# 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/setup_cloudsql_instance_service.rb b/app/services/google_cloud/setup_cloudsql_instance_service.rb
index 73650ee752f..10237f83b37 100644
--- a/app/services/google_cloud/setup_cloudsql_instance_service.rb
+++ b/app/services/google_cloud/setup_cloudsql_instance_service.rb
@@ -16,29 +16,29 @@ module GoogleCloud
return error("CloudSQL instance not RUNNABLE: #{get_instance_response.to_json}")
end
- database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name)
+ save_instance_ci_vars(get_instance_response)
- if database_response.status != OPERATION_STATE_DONE
- return error("Database creation failed: #{database_response.to_json}")
- end
+ 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)
- user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password)
+ existing_database = list_database_response.items.find { |database| database.name == database_name }
+ existing_user = list_user_response.items.find { |user| user.name == username }
- if user_response.status != OPERATION_STATE_DONE
- return error("User creation failed: #{user_response.to_json}")
+ if existing_database && existing_user
+ save_database_ci_vars
+ save_user_ci_vars(existing_user)
+ return success
end
- primary_ip_address = get_instance_response.ip_addresses.first.ip_address
- connection_name = get_instance_response.connection_name
+ database_response = execute_database_setup(existing_database)
+ return database_response if database_response[:status] == :error
- 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)
- save_ci_var('GCP_CLOUDSQL_DATABASE_NAME', database_name)
- save_ci_var('GCP_CLOUDSQL_DATABASE_USER', username)
- save_ci_var('GCP_CLOUDSQL_DATABASE_PASS', password, true)
+ 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
@@ -64,11 +64,55 @@ module GoogleCloud
end
def password
- SecureRandom.hex(16)
+ @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: #{database_response.to_json}")
+ 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: #{user_response.to_json}")
+ end
+
+ success
+ end
end
end
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index bcf3110ca21..02a760ccf29 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -45,6 +45,8 @@ module Groups
.execute(blocking: true)
end
+ publish_event
+
group
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -91,6 +93,17 @@ module Groups
end
end
# rubocop:enable CodeReuse/ActiveRecord
+
+ def publish_event
+ event = Groups::GroupDeletedEvent.new(
+ data: {
+ group_id: group.id,
+ root_namespace_id: group.root_ancestor.id
+ }
+ )
+
+ Gitlab::EventStore.publish(event)
+ end
end
end
diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb
index 2bfd5a5ebab..bd54b48c5f4 100644
--- a/app/services/groups/import_export/export_service.rb
+++ b/app/services/groups/import_export/export_service.rb
@@ -49,13 +49,23 @@ module Groups
# We cannot include the file_saver with the other savers because
# it removes the tmp dir. This means that if we want to add new savers
# in EE the data won't be available.
- if savers.all?(&:save) && file_saver.save
+ if save_exporters && file_saver.save
notify_success
else
notify_error!
end
end
+ def save_exporters
+ log_info('Group export started')
+
+ savers.all? do |exporter|
+ log_info("#{exporter.class.name} saver started")
+
+ exporter.save
+ end
+ end
+
def savers
[version_saver, tree_exporter]
end
@@ -99,12 +109,16 @@ module Groups
raise Gitlab::ImportExport::Error, shared.errors.to_sentence
end
- def notify_success
+ def log_info(message)
@logger.info(
- message: 'Group Export succeeded',
+ message: message,
group_id: group.id,
group_name: group.name
)
+ end
+
+ def notify_success
+ log_info('Group Export succeeded')
notification_service.group_was_exported(group, current_user)
end
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
index f026f1698a9..db52a272bf2 100644
--- a/app/services/groups/import_export/import_service.rb
+++ b/app/services/groups/import_export/import_service.rb
@@ -97,17 +97,17 @@ module Groups
def notify_success
@logger.info(
- group_id: group.id,
+ group_id: group.id,
group_name: group.name,
- message: 'Group Import/Export: Import succeeded'
+ message: 'Group Import/Export: Import succeeded'
)
end
def notify_error
@logger.error(
- group_id: group.id,
+ group_id: group.id,
group_name: group.name,
- message: "Group Import/Export: Errors occurred, see '#{Gitlab::ErrorTracking::Logger.file_name}' for details"
+ message: "Group Import/Export: Errors occurred, see '#{Gitlab::ErrorTracking::Logger.file_name}' for details"
)
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 29e3a9473ab..6fbf7daeb81 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -36,7 +36,7 @@ module Groups
update_crm_objects(was_root_group)
end
- post_update_hooks(@updated_project_ids)
+ post_update_hooks(@updated_project_ids, old_root_ancestor_id)
propagate_integrations
update_pending_builds
@@ -44,9 +44,10 @@ module Groups
end
# Overridden in EE
- def post_update_hooks(updated_project_ids)
+ def post_update_hooks(updated_project_ids, old_root_ancestor_id)
refresh_project_authorizations
refresh_descendant_groups if @new_parent_group
+ publish_event(old_root_ancestor_id)
end
def ensure_allowed_transfer
@@ -266,6 +267,18 @@ module Groups
CustomerRelations::IssueContact.delete_for_group(@group)
end
+
+ def publish_event(old_root_ancestor_id)
+ event = ::Groups::GroupTransferedEvent.new(
+ data: {
+ group_id: group.id,
+ old_root_namespace_id: old_root_ancestor_id,
+ new_root_namespace_id: group.root_ancestor.id
+ }
+ )
+
+ Gitlab::EventStore.publish(event)
+ end
end
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index b3b0397eac3..2135892a95a 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -61,15 +61,18 @@ module Groups
end
def before_assignment_hook(group, params)
- # overridden in EE
+ @full_path_before = group.full_path
+ @path_before = group.path
end
def renaming_group_with_container_registry_images?
+ renaming? && group.has_container_repository_including_subgroups?
+ end
+
+ def renaming?
new_path = params[:path]
- new_path &&
- new_path != group.path &&
- group.has_container_repository_including_subgroups?
+ new_path && new_path != @path_before
end
def container_images_error
@@ -83,6 +86,8 @@ module Groups
end
update_two_factor_requirement_for_subgroups
+
+ publish_event
end
def update_two_factor_requirement_for_subgroups
@@ -154,6 +159,21 @@ module Groups
group.errors.add(:update_shared_runners, result[:message])
false
end
+
+ def publish_event
+ return unless renaming?
+
+ event = Groups::GroupPathChangedEvent.new(
+ data: {
+ group_id: group.id,
+ root_namespace_id: group.root_ancestor.id,
+ old_path: @full_path_before,
+ new_path: group.full_path
+ }
+ )
+
+ Gitlab::EventStore.publish(event)
+ end
end
end
diff --git a/app/services/import/prepare_service.rb b/app/services/import/prepare_service.rb
new file mode 100644
index 00000000000..278bd463dcd
--- /dev/null
+++ b/app/services/import/prepare_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Import
+ class PrepareService < ::BaseService
+ def execute
+ uploader = UploadService.new(project, params[:file]).execute
+
+ if uploader
+ enqueue_import(uploader.upload)
+
+ ServiceResponse.success(message: success_message)
+ else
+ ServiceResponse.error(message: _('File upload error.'))
+ end
+ end
+
+ private
+
+ def enqueue_import(upload)
+ worker.perform_async(current_user.id, project.id, upload.id)
+ end
+
+ def worker
+ raise NotImplementedError
+ end
+
+ def success_message
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/services/incident_management/timeline_events/create_service.rb b/app/services/incident_management/timeline_events/create_service.rb
index 3cb67ccf2b1..40ce9097c88 100644
--- a/app/services/incident_management/timeline_events/create_service.rb
+++ b/app/services/incident_management/timeline_events/create_service.rb
@@ -48,6 +48,26 @@ module IncidentManagement
new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute
end
+
+ def change_labels(incident, user, added_labels: [], removed_labels: [])
+ return if Feature.disabled?(:incident_timeline_events_from_labels, incident.project)
+
+ if added_labels.blank? && removed_labels.blank?
+ return ServiceResponse.error(message: _('There are no changed labels'))
+ end
+
+ labels_note = -> (verb, labels) {
+ "#{verb} #{labels.map(&:to_reference).join(' ')} #{'label'.pluralize(labels.count)}" if labels.present?
+ }
+
+ added_note = labels_note.call('added', added_labels)
+ removed_note = labels_note.call('removed', removed_labels)
+ note = "@#{user.username} #{[added_note, removed_note].compact.join(' and ')}"
+ occurred_at = incident.updated_at
+ action = 'label'
+
+ new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute
+ end
end
def execute
diff --git a/app/services/incident_management/timeline_events/update_service.rb b/app/services/incident_management/timeline_events/update_service.rb
index 8217c8125c2..5c5de4717bc 100644
--- a/app/services/incident_management/timeline_events/update_service.rb
+++ b/app/services/incident_management/timeline_events/update_service.rb
@@ -34,7 +34,7 @@ module IncidentManagement
attr_reader :timeline_event, :incident, :user, :note, :occurred_at
def update_params
- { updated_by_user: user, note: note.presence, occurred_at: occurred_at.presence }.compact
+ { updated_by_user: user, note: note, occurred_at: occurred_at }.compact
end
def add_system_note(timeline_event)
diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb
index 98c50347719..3c13944cfbc 100644
--- a/app/services/issuable/clone/base_service.rb
+++ b/app/services/issuable/clone/base_service.rb
@@ -16,6 +16,7 @@ module Issuable
#
ApplicationRecord.transaction do
@new_entity = create_new_entity
+ @new_entity.system_note_timestamp = nil
update_new_entity
update_old_entity
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index 5cf32ee3e40..db28be864a7 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -21,7 +21,7 @@ module Issuable
create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
end
- create_due_date_note if issuable.previous_changes.include?('due_date')
+ 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
end
@@ -29,6 +29,13 @@ module Issuable
private
+ def handle_start_date_or_due_date_change_note
+ # Type check needed as some issuables do their own date change handling for date fields other than due_date
+ change_date_fields = issuable.is_a?(Issue) ? %w[due_date start_date] : %w[due_date]
+ changed_dates = issuable.previous_changes.slice(*change_date_fields)
+ create_start_date_or_due_date_note(changed_dates)
+ end
+
def handle_time_tracking_note
if issuable.previous_changes.include?('time_estimate')
create_time_estimate_note
@@ -99,8 +106,10 @@ module Issuable
.execute
end
- def create_due_date_note
- SystemNoteService.change_due_date(issuable, issuable.project, current_user, issuable.due_date)
+ def create_start_date_or_due_date_note(changed_dates)
+ return if changed_dates.blank?
+
+ SystemNoteService.change_start_date_or_due_date(issuable, issuable.project, current_user, changed_dates)
end
def create_discussion_lock_note
diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb
index 9b41c88159f..822e3cd787c 100644
--- a/app/services/issuable/import_csv/base_service.rb
+++ b/app/services/issuable/import_csv/base_service.rb
@@ -21,13 +21,9 @@ module Issuable
def process_csv
with_csv_lines.each do |row, line_no|
- issuable_attributes = {
- title: row[:title],
- description: row[:description],
- due_date: row[:due_date]
- }
+ attributes = issuable_attributes_for(row)
- if create_issuable(issuable_attributes).persisted?
+ if create_issuable(attributes).persisted?
@results[:success] += 1
else
@results[:error_lines].push(line_no)
@@ -37,6 +33,14 @@ module Issuable
@results[:parse_error] = true
end
+ def issuable_attributes_for(row)
+ {
+ title: row[:title],
+ description: row[:description],
+ due_date: row[:due_date]
+ }
+ end
+
def with_csv_lines
csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8)
validate_headers_presence!(csv_data.lines.first)
diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb
index 896b15a14b8..07dd9a98f89 100644
--- a/app/services/issues/clone_service.rb
+++ b/app/services/issues/clone_service.rb
@@ -41,7 +41,6 @@ module Issues
def update_new_entity
# we don't call `super` because we want to be able to decide whether or not to copy all comments over.
update_new_entity_description
- copy_award_emoji
if with_notes
copy_notes
@@ -96,9 +95,14 @@ module Issues
end
def add_note_from
- SystemNoteService.noteable_cloned(new_entity, target_project,
- original_entity, current_user,
- direction: :from)
+ SystemNoteService.noteable_cloned(
+ new_entity,
+ target_project,
+ original_entity,
+ current_user,
+ direction: :from,
+ created_at: new_entity.created_at
+ )
end
def add_note_to
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 30d4cb68840..92cf4811439 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -45,7 +45,7 @@ module Issues
# current_user (defined in BaseService) is not available within run_after_commit block
user = current_user
issue.run_after_commit do
- NewIssueWorker.perform_async(issue.id, user.id)
+ NewIssueWorker.perform_async(issue.id, user.id, issue.class.to_s)
Issues::PlacementWorker.perform_async(nil, issue.project_id)
Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.project.namespace_id)
end
diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb
index 7076e858155..6209127bd86 100644
--- a/app/services/issues/export_csv_service.rb
+++ b/app/services/issues/export_csv_service.rb
@@ -25,24 +25,24 @@ module Issues
{
'Title' => 'title',
'Description' => 'description',
- 'Issue ID' => 'iid',
- 'URL' => -> (issue) { issue_url(issue) },
- 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' },
- 'Author' => 'author_name',
- 'Author Username' => -> (issue) { issue.author&.username },
- 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') },
- 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') },
- 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' },
- 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' },
- 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
- 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
- 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) },
- 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) },
- 'Milestone' => -> (issue) { issue.milestone&.title },
- 'Weight' => -> (issue) { issue.weight },
- 'Labels' => -> (issue) { issue_labels(issue) },
- 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) },
- 'Time Spent' => -> (issue) { issue_time_spent(issue) }
+ 'Issue ID' => 'iid',
+ 'URL' => -> (issue) { issue_url(issue) },
+ 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' },
+ 'Author' => 'author_name',
+ 'Author Username' => -> (issue) { issue.author&.username },
+ 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') },
+ 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') },
+ 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' },
+ 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' },
+ 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
+ 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
+ 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) },
+ 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) },
+ 'Milestone' => -> (issue) { issue.milestone&.title },
+ 'Weight' => -> (issue) { issue.weight },
+ 'Labels' => -> (issue) { issue_labels(issue) },
+ 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) },
+ 'Time Spent' => -> (issue) { issue_time_spent(issue) }
}
end
diff --git a/app/services/issues/prepare_import_csv_service.rb b/app/services/issues/prepare_import_csv_service.rb
new file mode 100644
index 00000000000..7afe363117e
--- /dev/null
+++ b/app/services/issues/prepare_import_csv_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Issues
+ class PrepareImportCsvService < Import::PrepareService
+ extend ::Gitlab::Utils::Override
+
+ private
+
+ override :worker
+ def worker
+ ImportIssuesCsvWorker
+ end
+
+ override :success_message
+ def success_message
+ _("Your issues are being imported. Once finished, you'll get a confirmation email.")
+ end
+ end
+end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index afc61eed287..46c28d82ddc 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -70,6 +70,7 @@ module Issues
handle_severity_change(issue, old_severity)
handle_escalation_status_change(issue)
handle_issue_type_change(issue)
+ handle_date_changes(issue)
end
def handle_assignee_changes(issue, old_assignees)
@@ -116,6 +117,12 @@ module Issues
attr_reader :spam_params
+ def handle_date_changes(issue)
+ return unless issue.previous_changes.slice('due_date', 'start_date').any?
+
+ GraphqlTriggers.issuable_dates_updated(issue)
+ end
+
def clone_issue(issue)
target_project = params.delete(:target_clone_project)
with_notes = params.delete(:clone_with_notes)
diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb
index 8e8511e5180..d0ca8863c29 100644
--- a/app/services/jira/requests/base.rb
+++ b/app/services/jira/requests/base.rb
@@ -9,10 +9,10 @@ module Jira
ERRORS = {
connection: [Errno::ECONNRESET, Errno::ECONNREFUSED],
- jira_ruby: JIRA::HTTPError,
- ssl: OpenSSL::SSL::SSLError,
- timeout: [Timeout::Error, Errno::ETIMEDOUT],
- uri: [URI::InvalidURIError, SocketError]
+ jira_ruby: JIRA::HTTPError,
+ ssl: OpenSSL::SSL::SSLError,
+ timeout: [Timeout::Error, Errno::ETIMEDOUT],
+ uri: [URI::InvalidURIError, SocketError]
}.freeze
ALL_ERRORS = ERRORS.values.flatten.freeze
diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb
index b8d817a15f3..dcc4cf4bb1e 100644
--- a/app/services/merge_requests/approval_service.rb
+++ b/app/services/merge_requests/approval_service.rb
@@ -10,14 +10,27 @@ module MergeRequests
return success unless save_approval(approval)
reset_approvals_cache(merge_request)
- create_event(merge_request)
- stream_audit_event(merge_request)
- create_approval_note(merge_request)
- mark_pending_todos_as_done(merge_request)
- execute_approval_hooks(merge_request, current_user)
- remove_attention_requested(merge_request)
merge_request_activity_counter.track_approve_mr_action(user: current_user, merge_request: merge_request)
+ # Approval side effects (things not required to be done immediately but
+ # should happen after a successful approval) should be done asynchronously
+ # utilizing the `Gitlab::EventStore`.
+ #
+ # Workers can subscribe to the `MergeRequests::ApprovedEvent`.
+ if Feature.enabled?(:async_after_approval, project)
+ Gitlab::EventStore.publish(
+ MergeRequests::ApprovedEvent.new(
+ data: { current_user_id: current_user.id, merge_request_id: merge_request.id }
+ )
+ )
+ else
+ create_event(merge_request)
+ stream_audit_event(merge_request)
+ create_approval_note(merge_request)
+ mark_pending_todos_as_done(merge_request)
+ execute_approval_hooks(merge_request, current_user)
+ end
+
success
end
@@ -27,21 +40,22 @@ module MergeRequests
current_user.can?(:approve_merge_request, merge_request)
end
+ def save_approval(approval)
+ Approval.safe_ensure_unique do
+ approval.save
+ end
+ end
+
def reset_approvals_cache(merge_request)
merge_request.approvals.reset
end
- def execute_approval_hooks(merge_request, current_user)
- # Only one approval is required for a merge request to be approved
- notification_service.async.approve_mr(merge_request, current_user)
-
- execute_hooks(merge_request, 'approved')
+ def create_event(merge_request)
+ event_service.approve_mr(merge_request, current_user)
end
- def save_approval(approval)
- Approval.safe_ensure_unique do
- approval.save
- end
+ def stream_audit_event(merge_request)
+ # Defined in EE
end
def create_approval_note(merge_request)
@@ -52,12 +66,11 @@ module MergeRequests
todo_service.resolve_todos_for_target(merge_request, current_user)
end
- def create_event(merge_request)
- event_service.approve_mr(merge_request, current_user)
- end
+ def execute_approval_hooks(merge_request, current_user)
+ # Only one approval is required for a merge request to be approved
+ notification_service.async.approve_mr(merge_request, current_user)
- def stream_audit_event(merge_request)
- # Defined in EE
+ execute_hooks(merge_request, 'approved')
end
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 9bd38478796..bda8dc64ac0 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -61,10 +61,6 @@ module MergeRequests
merge_request_activity_counter.track_users_review_requested(users: new_reviewers)
merge_request_activity_counter.track_reviewers_changed_action(user: current_user)
bulk_update_reviewers_state(merge_request, new_reviewers)
-
- unless new_reviewers.include?(current_user)
- remove_attention_requested(merge_request)
- end
end
def cleanup_environments(merge_request)
@@ -252,20 +248,6 @@ module MergeRequests
Milestones::MergeRequestsCountService.new(milestone).delete_cache
end
- def remove_all_attention_requests(merge_request)
- return unless current_user.mr_attention_requests_enabled?
-
- users = merge_request.reviewers + merge_request.assignees
-
- ::MergeRequests::BulkRemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, users: users.uniq).execute
- end
-
- def remove_attention_requested(merge_request)
- return unless current_user.mr_attention_requests_enabled?
-
- ::MergeRequests::RemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: current_user).execute
- end
-
def bulk_update_assignees_state(merge_request, new_assignees)
return unless current_user.mr_attention_requests_enabled?
return if new_assignees.empty?
diff --git a/app/services/merge_requests/bulk_remove_attention_requested_service.rb b/app/services/merge_requests/bulk_remove_attention_requested_service.rb
deleted file mode 100644
index 774f2c2ee35..00000000000
--- a/app/services/merge_requests/bulk_remove_attention_requested_service.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-module MergeRequests
- class BulkRemoveAttentionRequestedService < MergeRequests::BaseService
- attr_accessor :merge_request
- attr_accessor :users
-
- def initialize(project:, current_user:, merge_request:, users:)
- super(project: project, current_user: current_user)
-
- @merge_request = merge_request
- @users = users
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def execute
- return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request)
-
- merge_request.merge_request_assignees.where(user_id: users).update_all(state: :reviewed)
- merge_request.merge_request_reviewers.where(user_id: users).update_all(state: :reviewed)
-
- users.each { |user| user.invalidate_attention_requested_count }
-
- success
- end
- # rubocop: enable CodeReuse/ActiveRecord
- end
-end
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index e9b253129b4..f83b14c7269 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -17,7 +17,6 @@ module MergeRequests
create_note(merge_request)
notification_service.async.close_mr(merge_request, current_user)
todo_service.close_merge_request(merge_request, current_user)
- remove_all_attention_requests(merge_request)
execute_hooks(merge_request, 'close')
invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers)
merge_request.update_project_counter_caches
diff --git a/app/services/merge_requests/create_approval_event_service.rb b/app/services/merge_requests/create_approval_event_service.rb
new file mode 100644
index 00000000000..1678bf31139
--- /dev/null
+++ b/app/services/merge_requests/create_approval_event_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class CreateApprovalEventService < MergeRequests::BaseService
+ def execute(merge_request)
+ event_service.approve_mr(merge_request, current_user)
+ end
+ end
+end
+
+MergeRequests::CreateApprovalEventService.prepend_mod
diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb
index c6a91a3b61e..4f20ade2a42 100644
--- a/app/services/merge_requests/create_pipeline_service.rb
+++ b/app/services/merge_requests/create_pipeline_service.rb
@@ -50,7 +50,8 @@ module MergeRequests
end
def can_create_pipeline_in_target_project?(merge_request)
- can?(current_user, :create_pipeline, merge_request.target_project) &&
+ merge_request.target_project.ci_allow_fork_pipelines_to_run_in_parent_project? &&
+ can?(current_user, :create_pipeline, merge_request.target_project) &&
can_update_source_branch_in_target_project?(merge_request)
end
diff --git a/app/services/merge_requests/execute_approval_hooks_service.rb b/app/services/merge_requests/execute_approval_hooks_service.rb
new file mode 100644
index 00000000000..7beeb9ea3f9
--- /dev/null
+++ b/app/services/merge_requests/execute_approval_hooks_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class ExecuteApprovalHooksService < MergeRequests::BaseService
+ def execute(merge_request)
+ # Only one approval is required for a merge request to be approved
+ notification_service.async.approve_mr(merge_request, current_user)
+ execute_hooks(merge_request, 'approved')
+ end
+ end
+end
+
+MergeRequests::ExecuteApprovalHooksService.prepend_mod
diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb
index 78c93d10f2a..87cd6544406 100644
--- a/app/services/merge_requests/handle_assignees_change_service.rb
+++ b/app/services/merge_requests/handle_assignees_change_service.rb
@@ -22,10 +22,6 @@ module MergeRequests
merge_request_activity_counter.track_assignees_changed_action(user: current_user)
execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks]
-
- unless new_assignees.include?(current_user)
- remove_attention_requested(merge_request)
- end
end
private
diff --git a/app/services/merge_requests/mergeability/check_base_service.rb b/app/services/merge_requests/mergeability/check_base_service.rb
index d5ddcb4b828..e614a7c27fe 100644
--- a/app/services/merge_requests/mergeability/check_base_service.rb
+++ b/app/services/merge_requests/mergeability/check_base_service.rb
@@ -24,12 +24,12 @@ module MergeRequests
private
- def success(*args)
- Gitlab::MergeRequests::Mergeability::CheckResult.success(*args)
+ def success(**args)
+ Gitlab::MergeRequests::Mergeability::CheckResult.success(payload: args)
end
- def failure(*args)
- Gitlab::MergeRequests::Mergeability::CheckResult.failed(*args)
+ def failure(**args)
+ Gitlab::MergeRequests::Mergeability::CheckResult.failed(payload: args)
end
end
end
diff --git a/app/services/merge_requests/mergeability/check_broken_status_service.rb b/app/services/merge_requests/mergeability/check_broken_status_service.rb
index 9a54a4292c8..6fe4eb4a57f 100644
--- a/app/services/merge_requests/mergeability/check_broken_status_service.rb
+++ b/app/services/merge_requests/mergeability/check_broken_status_service.rb
@@ -4,7 +4,7 @@ module MergeRequests
class CheckBrokenStatusService < CheckBaseService
def execute
if merge_request.broken?
- failure
+ failure(reason: failure_reason)
else
success
end
@@ -17,6 +17,12 @@ module MergeRequests
def cacheable?
false
end
+
+ private
+
+ def failure_reason
+ :broken_status
+ end
end
end
end
diff --git a/app/services/merge_requests/mergeability/check_ci_status_service.rb b/app/services/merge_requests/mergeability/check_ci_status_service.rb
index c0ef5ba1c30..9e09b513c57 100644
--- a/app/services/merge_requests/mergeability/check_ci_status_service.rb
+++ b/app/services/merge_requests/mergeability/check_ci_status_service.rb
@@ -6,7 +6,7 @@ module MergeRequests
if merge_request.mergeable_ci_state?
success
else
- failure
+ failure(reason: failure_reason)
end
end
@@ -17,6 +17,12 @@ module MergeRequests
def cacheable?
false
end
+
+ private
+
+ def failure_reason
+ :ci_must_pass
+ end
end
end
end
diff --git a/app/services/merge_requests/mergeability/check_discussions_status_service.rb b/app/services/merge_requests/mergeability/check_discussions_status_service.rb
index 9b4eab9d399..3421d96e8ae 100644
--- a/app/services/merge_requests/mergeability/check_discussions_status_service.rb
+++ b/app/services/merge_requests/mergeability/check_discussions_status_service.rb
@@ -6,7 +6,7 @@ module MergeRequests
if merge_request.mergeable_discussions_state?
success
else
- failure
+ failure(reason: failure_reason)
end
end
@@ -17,6 +17,12 @@ module MergeRequests
def cacheable?
false
end
+
+ private
+
+ def failure_reason
+ :discussions_not_resolved
+ end
end
end
end
diff --git a/app/services/merge_requests/mergeability/check_draft_status_service.rb b/app/services/merge_requests/mergeability/check_draft_status_service.rb
index bc940e2116d..a1524317155 100644
--- a/app/services/merge_requests/mergeability/check_draft_status_service.rb
+++ b/app/services/merge_requests/mergeability/check_draft_status_service.rb
@@ -5,7 +5,7 @@ module MergeRequests
class CheckDraftStatusService < CheckBaseService
def execute
if merge_request.draft?
- failure
+ failure(reason: failure_reason)
else
success
end
@@ -18,6 +18,12 @@ module MergeRequests
def cacheable?
false
end
+
+ private
+
+ def failure_reason
+ :draft_status
+ end
end
end
end
diff --git a/app/services/merge_requests/mergeability/check_open_status_service.rb b/app/services/merge_requests/mergeability/check_open_status_service.rb
index 361af946e3f..29f3d0d3ccb 100644
--- a/app/services/merge_requests/mergeability/check_open_status_service.rb
+++ b/app/services/merge_requests/mergeability/check_open_status_service.rb
@@ -7,7 +7,7 @@ module MergeRequests
if merge_request.open?
success
else
- failure
+ failure(reason: failure_reason)
end
end
@@ -18,6 +18,12 @@ module MergeRequests
def cacheable?
false
end
+
+ private
+
+ def failure_reason
+ :not_open
+ end
end
end
end
diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb
index 1d4b96b3090..68f842b3322 100644
--- a/app/services/merge_requests/mergeability/run_checks_service.rb
+++ b/app/services/merge_requests/mergeability/run_checks_service.rb
@@ -10,36 +10,50 @@ module MergeRequests
end
def execute
- merge_request.mergeability_checks.each_with_object([]) do |check_class, results|
+ @results = merge_request.mergeability_checks.each_with_object([]) do |check_class, result_hash|
check = check_class.new(merge_request: merge_request, params: params)
next if check.skip?
check_result = run_check(check)
- results << check_result
+ result_hash << check_result
- break results if check_result.failed?
+ break result_hash if check_result.failed?
end
+
+ self
+ end
+
+ def success?
+ raise 'Execute needs to be called before' if results.nil?
+
+ results.all?(&:success?)
+ end
+
+ def failure_reason
+ raise 'Execute needs to be called before' if results.nil?
+
+ results.find(&:failed?)&.payload&.fetch(:reason)
end
private
- attr_reader :merge_request, :params
+ attr_reader :merge_request, :params, :results
def run_check(check)
return check.execute unless Feature.enabled?(:mergeability_caching, merge_request.project)
return check.execute unless check.cacheable?
- cached_result = results.read(merge_check: check)
+ cached_result = cached_results.read(merge_check: check)
return cached_result if cached_result.respond_to?(:status)
check.execute.tap do |result|
- results.write(merge_check: check, result_hash: result.to_hash)
+ cached_results.write(merge_check: check, result_hash: result.to_hash)
end
end
- def results
- strong_memoize(:results) do
+ def cached_results
+ strong_memoize(:cached_results) do
Gitlab::MergeRequests::Mergeability::ResultsStore.new(merge_request: merge_request)
end
end
diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb
index 30531fcc17b..1ce44f465cd 100644
--- a/app/services/merge_requests/mergeability_check_service.rb
+++ b/app/services/merge_requests/mergeability_check_service.rb
@@ -78,8 +78,8 @@ module MergeRequests
lease_key = "mergeability_check:#{merge_request.id}"
lease_opts = {
- ttl: 1.minute,
- retries: retry_lease ? 10 : 0,
+ ttl: 1.minute,
+ retries: retry_lease ? 10 : 0,
sleep_sec: retry_lease ? 1.second : 0
}
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index 286c082ac8a..9fca2b0d19e 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -28,7 +28,6 @@ module MergeRequests
notification_service.merge_mr(merge_request, current_user)
invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers)
merge_request.update_project_counter_caches
- remove_all_attention_requests(merge_request)
delete_non_latest_diffs(merge_request)
cancel_review_app_jobs!(merge_request)
cleanup_environments(merge_request)
diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb
index 076fe8c3b21..ef251f121ae 100644
--- a/app/services/merge_requests/push_options_handler_service.rb
+++ b/app/services/merge_requests/push_options_handler_service.rb
@@ -105,7 +105,7 @@ module MergeRequests
project: project,
current_user: current_user,
params: merge_request.attributes.merge(assignees: merge_request.assignees,
- label_ids: merge_request.label_ids)
+ label_ids: merge_request.label_ids)
).execute
end
diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb
index d9bb17a7b1b..52628729519 100644
--- a/app/services/merge_requests/remove_approval_service.rb
+++ b/app/services/merge_requests/remove_approval_service.rb
@@ -17,7 +17,6 @@ module MergeRequests
reset_approvals_cache(merge_request)
create_note(merge_request)
merge_request_activity_counter.track_unapprove_mr_action(user: current_user)
- remove_attention_requested(merge_request)
end
success
diff --git a/app/services/merge_requests/remove_attention_requested_service.rb b/app/services/merge_requests/remove_attention_requested_service.rb
deleted file mode 100644
index 8a410fda691..00000000000
--- a/app/services/merge_requests/remove_attention_requested_service.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-module MergeRequests
- class RemoveAttentionRequestedService < MergeRequests::BaseService
- attr_accessor :merge_request, :user
-
- def initialize(project:, current_user:, merge_request:, user:)
- super(project: project, current_user: current_user)
-
- @merge_request = merge_request
- @user = user
- end
-
- def execute
- return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request)
-
- if reviewer || assignee
- return success if reviewer&.reviewed? || assignee&.reviewed?
-
- update_state(reviewer)
- update_state(assignee)
-
- user.invalidate_attention_requested_count
- create_remove_attention_request_note
-
- success
- else
- error("User is not a reviewer or assignee of the merge request")
- end
- end
-
- private
-
- def assignee
- @assignee ||= merge_request.find_assignee(user)
- end
-
- def reviewer
- @reviewer ||= merge_request.find_reviewer(user)
- end
-
- def update_state(reviewer_or_assignee)
- reviewer_or_assignee&.update(state: :reviewed)
- end
-
- def create_remove_attention_request_note
- SystemNoteService.remove_attention_request(merge_request, merge_request.project, current_user, user)
- end
- end
-end
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index 4612688f78b..d2247a6d4c1 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -20,8 +20,6 @@ module MergeRequests
merge_request.cache_merge_request_closes_issues!(current_user)
merge_request.cleanup_schedule&.destroy
merge_request.update_column(:merge_ref_sha, nil)
-
- users.each { |user| user.invalidate_attention_requested_count }
end
merge_request
diff --git a/app/services/merge_requests/request_attention_service.rb b/app/services/merge_requests/request_attention_service.rb
deleted file mode 100644
index 07e9996f87b..00000000000
--- a/app/services/merge_requests/request_attention_service.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-# frozen_string_literal: true
-
-module MergeRequests
- class RequestAttentionService < MergeRequests::BaseService
- attr_accessor :merge_request, :user
-
- def initialize(project:, current_user:, merge_request:, user:)
- super(project: project, current_user: current_user)
-
- @merge_request = merge_request
- @user = user
- end
-
- def execute
- return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request)
-
- if reviewer || assignee
- return success if reviewer&.attention_requested? || assignee&.attention_requested?
-
- update_state(reviewer)
- update_state(assignee)
-
- user.invalidate_attention_requested_count
- create_attention_request_note
- notity_user
-
- if current_user.id != user.id
- remove_attention_requested(merge_request)
- end
-
- success
- else
- error("User is not a reviewer or assignee of the merge request")
- end
- end
-
- private
-
- def notity_user
- notification_service.async.attention_requested_of_merge_request(merge_request, current_user, user)
- todo_service.create_attention_requested_todo(merge_request, current_user, user)
- end
-
- def create_attention_request_note
- SystemNoteService.request_attention(merge_request, merge_request.project, current_user, user)
- end
-
- def assignee
- @assignee ||= merge_request.find_assignee(user)
- end
-
- def reviewer
- @reviewer ||= merge_request.find_reviewer(user)
- end
-
- def update_state(reviewer_or_assignee)
- reviewer_or_assignee&.update(state: :attention_requested, updated_state_by: current_user)
- end
- end
-end
diff --git a/app/services/merge_requests/toggle_attention_requested_service.rb b/app/services/merge_requests/toggle_attention_requested_service.rb
deleted file mode 100644
index 64cdcd725a2..00000000000
--- a/app/services/merge_requests/toggle_attention_requested_service.rb
+++ /dev/null
@@ -1,68 +0,0 @@
-# frozen_string_literal: true
-
-module MergeRequests
- class ToggleAttentionRequestedService < MergeRequests::BaseService
- attr_accessor :merge_request, :user
-
- def initialize(project:, current_user:, merge_request:, user:)
- super(project: project, current_user: current_user)
-
- @merge_request = merge_request
- @user = user
- end
-
- def execute
- return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request)
-
- if reviewer || assignee
- update_state(reviewer)
- update_state(assignee)
-
- user.invalidate_attention_requested_count
-
- if reviewer&.attention_requested? || assignee&.attention_requested?
- create_attention_request_note
- notity_user
-
- if current_user.id != user.id
- remove_attention_requested(merge_request)
- end
- else
- create_remove_attention_request_note
- end
-
- success
- else
- error("User is not a reviewer or assignee of the merge request")
- end
- end
-
- private
-
- def notity_user
- notification_service.async.attention_requested_of_merge_request(merge_request, current_user, user)
- todo_service.create_attention_requested_todo(merge_request, current_user, user)
- end
-
- def create_attention_request_note
- SystemNoteService.request_attention(merge_request, merge_request.project, current_user, user)
- end
-
- def create_remove_attention_request_note
- SystemNoteService.remove_attention_request(merge_request, merge_request.project, current_user, user)
- end
-
- def assignee
- merge_request.find_assignee(user)
- end
-
- def reviewer
- merge_request.find_reviewer(user)
- end
-
- def update_state(reviewer_or_assignee)
- reviewer_or_assignee&.update(state: reviewer_or_assignee&.attention_requested? ? :reviewed : :attention_requested,
- updated_state_by: current_user)
- end
- end
-end
diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb
index 5b23f69ac4a..a6b0235c525 100644
--- a/app/services/merge_requests/update_assignees_service.rb
+++ b/app/services/merge_requests/update_assignees_service.rb
@@ -11,7 +11,7 @@ module MergeRequests
old_assignees = merge_request.assignees.to_a
old_ids = old_assignees.map(&:id)
- new_ids = new_assignee_ids(merge_request)
+ new_ids = new_user_ids(merge_request, update_attrs[:assignee_ids], :assignees)
return merge_request if merge_request.errors.any?
return merge_request if new_ids.size != update_attrs[:assignee_ids].size
@@ -32,27 +32,8 @@ module MergeRequests
private
- def new_assignee_ids(merge_request)
- # prime the cache - prevent N+1 lookup during authorization loop.
- user_ids = update_attrs[:assignee_ids]
- return [] if user_ids.empty?
-
- merge_request.project.team.max_member_access_for_user_ids(user_ids)
- User.id_in(user_ids).map do |user|
- if user.can?(:read_merge_request, merge_request)
- user.id
- else
- merge_request.errors.add(
- :assignees,
- "Cannot assign #{user.to_reference} to #{merge_request.to_reference}"
- )
- nil
- end
- end.compact
- end
-
def assignee_ids
- params.fetch(:assignee_ids).reject { _1 == 0 }.first(1)
+ filter_sentinel_values(params.fetch(:assignee_ids)).first(1)
end
def params
diff --git a/app/services/merge_requests/update_reviewers_service.rb b/app/services/merge_requests/update_reviewers_service.rb
new file mode 100644
index 00000000000..8e974d75676
--- /dev/null
+++ b/app/services/merge_requests/update_reviewers_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class UpdateReviewersService < UpdateService
+ def execute(merge_request)
+ return merge_request unless current_user&.can?(:update_merge_request, merge_request)
+
+ old_reviewers = merge_request.reviewers.to_a
+ old_ids = old_reviewers.map(&:id)
+ new_ids = new_user_ids(merge_request, update_attrs[:reviewer_ids], :reviewers)
+
+ return merge_request if merge_request.errors.any?
+ return merge_request if new_ids.size != update_attrs[:reviewer_ids].size
+ return merge_request if old_ids.to_set == new_ids.to_set # no-change
+
+ merge_request.update!(update_attrs.merge(reviewer_ids: new_ids))
+ handle_reviewers_change(merge_request, old_reviewers)
+ resolve_todos_for(merge_request)
+ execute_reviewers_hooks(merge_request, old_reviewers)
+
+ merge_request
+ end
+
+ private
+
+ def reviewer_ids
+ filter_sentinel_values(params.fetch(:reviewer_ids)).first(1)
+ end
+
+ def update_attrs
+ @attrs ||= { updated_by: current_user, reviewer_ids: reviewer_ids }
+ end
+
+ def execute_reviewers_hooks(merge_request, old_reviewers)
+ execute_hooks(
+ merge_request,
+ 'update',
+ old_associations: { reviewers: old_reviewers }
+ )
+ end
+ end
+end
+
+MergeRequests::UpdateReviewersService.prepend_mod
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 603da4ef535..0902b5195a1 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -155,11 +155,7 @@ module MergeRequests
def resolve_todos(merge_request, old_labels, old_assignees, old_reviewers)
return unless has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees, old_reviewers: old_reviewers)
- service_user = current_user
-
- merge_request.run_after_commit_or_now do
- ::MergeRequests::ResolveTodosService.new(merge_request, service_user).async_execute
- end
+ resolve_todos_for(merge_request)
end
def handle_target_branch_change(merge_request)
@@ -296,6 +292,36 @@ module MergeRequests
def add_time_spent_service
@add_time_spent_service ||= ::MergeRequests::AddSpentTimeService.new(project: project, current_user: current_user, params: params)
end
+
+ def new_user_ids(merge_request, user_ids, attribute)
+ # prime the cache - prevent N+1 lookup during authorization loop.
+ return [] if user_ids.empty?
+
+ merge_request.project.team.max_member_access_for_user_ids(user_ids)
+ User.id_in(user_ids).map do |user|
+ if user.can?(:read_merge_request, merge_request)
+ user.id
+ else
+ merge_request.errors.add(
+ attribute,
+ "Cannot assign #{user.to_reference} to #{merge_request.to_reference}"
+ )
+ nil
+ end
+ end.compact
+ end
+
+ def resolve_todos_for(merge_request)
+ service_user = current_user
+
+ merge_request.run_after_commit_or_now do
+ ::MergeRequests::ResolveTodosService.new(merge_request, service_user).async_execute
+ end
+ end
+
+ def filter_sentinel_values(param)
+ param.reject { _1 == 0 }
+ end
end
end
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index 8c250526efc..cc5c81cf280 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -16,6 +16,14 @@ module Notes
params.merge!(discussion.reply_attributes)
end
+ # The `confidential` param for notes is deprecated with 15.3
+ # and renamed to `internal`.
+ # We still accept `confidential` until the param gets removed from the API.
+ # Until we have not migrated the database column to `internal` we need to rename
+ # the parameter. Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/367923.
+ params[:confidential] = params[:internal] || params[:confidential]
+ params.delete(:internal)
+
new_note(params, discussion)
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 4074b1d1182..b7e6a50fa5c 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -88,8 +88,13 @@ module Notes
return if quick_actions_service.commands_executed_count.to_i == 0
if update_params.present?
- quick_actions_service.apply_updates(update_params, note)
- note.commands_changes = update_params
+ if check_for_reviewer_validity(message, update_params)
+ quick_actions_service.apply_updates(update_params, note)
+ note.commands_changes = update_params
+ else
+ message = "Reviewers #{MergeRequest.max_number_of_assignees_or_reviewers_message}"
+ note.errors.add(:validation, message)
+ end
end
# We must add the error after we call #save because errors are reset
@@ -109,6 +114,18 @@ module Notes
}
end
+ def check_for_reviewer_validity(message, update_params)
+ return true unless Feature.enabled?(:limit_reviewer_and_assignee_size)
+
+ if update_params.key?(:reviewer_ids)
+ possible_reviewers = update_params[:reviewer_ids]&.uniq&.size
+
+ return false if possible_reviewers > MergeRequest::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS
+ end
+
+ true
+ end
+
def track_event(note, user)
track_note_creation_usage_for_issues(note) if note.for_issue?
track_note_creation_usage_for_merge_requests(note) if note.for_merge_request?
@@ -130,7 +147,8 @@ module Notes
end
def track_note_creation_usage_for_issues(note)
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_added_action(author: note.author)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_added_action(author: note.author,
+ project: project)
end
def track_note_creation_usage_for_merge_requests(note)
diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb
index c25b1ab0379..eda8bbcbc2e 100644
--- a/app/services/notes/destroy_service.rb
+++ b/app/services/notes/destroy_service.rb
@@ -15,7 +15,8 @@ module Notes
private
def track_note_removal_usage_for_issues(note)
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_removed_action(author: note.author)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_removed_action(author: note.author,
+ project: project)
end
def track_note_removal_usage_for_merge_requests(note)
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 04fc4c7c944..2dae76feb0b 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -86,7 +86,8 @@ module Notes
end
def track_note_edit_usage_for_issues(note)
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_edited_action(author: note.author)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_edited_action(author: note.author,
+ project: project)
end
def track_note_edit_usage_for_merge_requests(note)
diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb
index e63e19e365c..bdeebc641b8 100644
--- a/app/services/notification_recipients/build_service.rb
+++ b/app/services/notification_recipients/build_service.rb
@@ -36,9 +36,5 @@ module NotificationRecipients
def self.build_requested_review_recipients(*args)
::NotificationRecipients::Builder::RequestReview.new(*args).notification_recipients
end
-
- def self.build_attention_requested_recipients(*args)
- ::NotificationRecipients::Builder::AttentionRequested.new(*args).notification_recipients
- end
end
end
diff --git a/app/services/notification_recipients/builder/attention_requested.rb b/app/services/notification_recipients/builder/attention_requested.rb
deleted file mode 100644
index cdc371fcece..00000000000
--- a/app/services/notification_recipients/builder/attention_requested.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module NotificationRecipients
- module Builder
- class AttentionRequested < Base
- attr_reader :merge_request, :current_user, :user
-
- def initialize(merge_request, current_user, user)
- @merge_request = merge_request
- @current_user = current_user
- @user = user
- end
-
- def target
- merge_request
- end
-
- def build!
- add_recipients(user, :mention, NotificationReason::ATTENTION_REQUESTED)
- end
- end
- end
-end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 2477fcd02e5..5a92adfd25a 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -333,14 +333,6 @@ class NotificationService
end
end
- def attention_requested_of_merge_request(merge_request, current_user, user)
- recipients = NotificationRecipients::BuildService.build_attention_requested_recipients(merge_request, current_user, user)
-
- recipients.each do |recipient|
- mailer.attention_requested_merge_request_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason).deliver_later
- end
- end
-
# When we add labels to a merge request we should send an email to:
#
# * watchers of the mr's labels
@@ -799,7 +791,7 @@ class NotificationService
end
recipients = NotificationRecipients::BuildService.build_recipients(target, current_user, action: "new")
- recipients = recipients.select {|r| new_mentioned_users.include?(r.user) }
+ recipients = recipients.select { |r| new_mentioned_users.include?(r.user) }
recipients.each do |recipient|
mailer.send(method, recipient.user.id, target.id, current_user.id, recipient.reason).deliver_later
diff --git a/app/services/packages/conan/create_package_file_service.rb b/app/services/packages/conan/create_package_file_service.rb
index 1bde9606492..904a1d10bcb 100644
--- a/app/services/packages/conan/create_package_file_service.rb
+++ b/app/services/packages/conan/create_package_file_service.rb
@@ -13,11 +13,11 @@ module Packages
def execute
package_file = package.package_files.build(
- file: file,
- size: params['file.size'],
+ file: file,
+ size: params['file.size'],
file_name: params[:file_name],
file_sha1: params['file.sha1'],
- file_md5: params['file.md5'],
+ file_md5: params['file.md5'],
conan_file_metadatum_attributes: {
recipe_revision: params[:recipe_revision],
package_revision: params[:package_revision],
diff --git a/app/services/packages/create_package_file_service.rb b/app/services/packages/create_package_file_service.rb
index 5723b0b4717..6e1a5672a52 100644
--- a/app/services/packages/create_package_file_service.rb
+++ b/app/services/packages/create_package_file_service.rb
@@ -10,12 +10,12 @@ module Packages
def execute
package_file = package.package_files.build(
- file: params[:file],
- size: params[:size],
- file_name: params[:file_name],
- file_sha1: params[:file_sha1],
+ file: params[:file],
+ size: params[:size],
+ file_name: params[:file_name],
+ file_sha1: params[:file_sha1],
file_sha256: params[:file_sha256],
- file_md5: params[:file_md5]
+ file_md5: params[:file_md5]
)
if params[:build].present?
diff --git a/app/services/packages/debian/create_package_file_service.rb b/app/services/packages/debian/create_package_file_service.rb
index fbbc8159ca0..53275fdc9bb 100644
--- a/app/services/packages/debian/create_package_file_service.rb
+++ b/app/services/packages/debian/create_package_file_service.rb
@@ -17,12 +17,12 @@ module Packages
# Debian package file are first uploaded to incoming with empty metadata,
# and are moved later by Packages::Debian::ProcessChangesService
package.package_files.create!(
- file: params[:file],
- size: params[:file]&.size,
- file_name: params[:file_name],
- file_sha1: params[:file_sha1],
+ file: params[:file],
+ size: params[:file]&.size,
+ file_name: params[:file_name],
+ file_sha1: params[:file_sha1],
file_sha256: params[:file]&.sha256,
- file_md5: params[:file_md5],
+ file_md5: params[:file_md5],
debian_file_metadatum_attributes: {
file_type: 'unknown',
architecture: nil,
diff --git a/app/services/packages/debian/extract_metadata_service.rb b/app/services/packages/debian/extract_metadata_service.rb
index f94587919b9..eb8227d1296 100644
--- a/app/services/packages/debian/extract_metadata_service.rb
+++ b/app/services/packages/debian/extract_metadata_service.rb
@@ -61,12 +61,12 @@ module Packages
def fields
strong_memoize(:fields) do
if file_type_debian?
- package_file.file.use_file do |file_path|
- ::Packages::Debian::ExtractDebMetadataService.new(file_path).execute
+ package_file.file.use_open_file(unlink_early: false) do |file|
+ ::Packages::Debian::ExtractDebMetadataService.new(file.file_path).execute
end
elsif file_type_meta?
- package_file.file.use_file do |file_path|
- ::Packages::Debian::ParseDebian822Service.new(File.read(file_path)).execute.each_value.first
+ package_file.file.use_open_file do |file|
+ ::Packages::Debian::ParseDebian822Service.new(file.read).execute.each_value.first
end
end
end
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
index b0a5f37cfa3..a3596314199 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -87,11 +87,11 @@ module Packages
def file_params
{
- file: CarrierWaveStringFile.new(Base64.decode64(attachment['data'])),
- size: calculated_package_file_size,
+ file: CarrierWaveStringFile.new(Base64.decode64(attachment['data'])),
+ size: calculated_package_file_size,
file_sha1: version_data[:dist][:shasum],
file_name: package_file_name,
- build: params[:build]
+ build: params[:build]
}
end
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index e5d40b60747..c21a61bcb52 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -32,7 +32,7 @@ module Projects
attr_reader :project, :payload, :integration
def valid_payload_size?
- Gitlab::Utils::DeepSize.new(payload).valid?
+ Gitlab::Utils::DeepSize.new(payload.to_h).valid?
end
override :alert_source
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 9bc8bb428fb..6381ee67ce7 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -26,7 +26,7 @@ module Projects
return ::Projects::CreateFromTemplateService.new(current_user, params).execute
end
- @project = Project.new(params)
+ @project = Project.new(params.merge(creator: current_user))
validate_import_source_enabled!
@@ -45,20 +45,14 @@ module Projects
set_project_name_from_path
# get namespace id
- namespace_id = params[:namespace_id]
-
- if namespace_id
- # Find matching namespace and check if it allowed
- # for current user if namespace_id passed.
- unless current_user.can?(:create_projects, parent_namespace)
- @project.namespace_id = nil
- deny_namespace
- return @project
- end
- else
- # Set current user namespace if namespace_id is nil
- @project.namespace_id = current_user.namespace_id
- end
+ namespace_id = params[:namespace_id] || current_user.namespace_id
+ @project.namespace_id = namespace_id.to_i
+
+ @project.check_personal_projects_limit
+ return @project if @project.errors.any?
+
+ validate_create_permissions
+ return @project if @project.errors.any?
@relations_block&.call(@project)
yield(@project) if block_given?
@@ -92,7 +86,9 @@ module Projects
protected
- def deny_namespace
+ def validate_create_permissions
+ return if current_user.can?(:create_projects, parent_namespace)
+
@project.errors.add(:namespace, "is not valid")
end
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index 70a04cd556a..5fce816064b 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -46,15 +46,15 @@ module Projects
def new_fork_params
new_params = {
- forked_from_project: @project,
- visibility_level: target_visibility_level,
- description: target_description,
- name: target_name,
- path: target_path,
- shared_runners_enabled: @project.shared_runners_enabled,
- namespace_id: target_namespace.id,
- fork_network: fork_network,
- ci_config_path: @project.ci_config_path,
+ forked_from_project: @project,
+ visibility_level: target_visibility_level,
+ description: target_description,
+ name: target_name,
+ path: target_path,
+ shared_runners_enabled: @project.shared_runners_enabled,
+ namespace_id: target_namespace.id,
+ fork_network: fork_network,
+ ci_config_path: @project.ci_config_path,
# We need to set ci_default_git_depth to 0 for the forked project when
# @project.ci_default_git_depth is nil in order to keep the same behaviour
# and not get ProjectCiCdSetting::DEFAULT_GIT_DEPTH set on create
@@ -63,8 +63,8 @@ module Projects
# been instantiated to avoid ActiveRecord trying to create it when
# initializing the project, as that would cause a foreign key constraint
# exception.
- relations_block: -> (project) { build_fork_network_member(project) },
- skip_disk_validation: skip_disk_validation,
+ relations_block: -> (project) { build_fork_network_member(project) },
+ skip_disk_validation: skip_disk_validation,
external_authorization_classification_label: @project.external_authorization_classification_label,
suggestion_commit_message: @project.suggestion_commit_message,
merge_commit_template: @project.merge_commit_template,
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index d8d35422590..ddbcfbb675c 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -54,15 +54,21 @@ module Projects
end
def save_all!
+ log_info('Project export started')
+
if save_exporters && save_export_archive
- notify_success
+ log_info('Project successfully exported')
else
notify_error!
end
end
def save_exporters
- exporters.all?(&:save)
+ exporters.all? do |exporter|
+ log_info("#{exporter.class.name} saver started")
+
+ exporter.save
+ end
end
def save_export_archive
@@ -78,11 +84,12 @@ module Projects
end
def project_tree_saver
- @project_tree_saver ||= tree_saver_class.new(project: project,
- current_user: current_user,
- shared: shared,
- params: params,
- logger: logger)
+ @project_tree_saver ||= tree_saver_class.new(
+ project: project,
+ current_user: current_user,
+ shared: shared,
+ params: params,
+ logger: logger)
end
def tree_saver_class
@@ -127,11 +134,10 @@ module Projects
raise Gitlab::ImportExport::Error, shared.errors.to_sentence
end
- def notify_success
+ def log_info(message)
logger.info(
- message: 'Project successfully exported',
- project_name: project.name,
- project_id: project.id
+ message: message,
+ **log_base_data
)
end
@@ -139,8 +145,7 @@ module Projects
logger.error(
message: 'Project export error',
export_errors: shared.errors.join(', '),
- project_name: project.name,
- project_id: project.id
+ **log_base_data
)
user = current_user
@@ -150,6 +155,10 @@ module Projects
NotificationService.new.project_not_exported(project, user, errors)
end
end
+
+ def log_base_data
+ @log_base_data ||= Gitlab::ImportExport::LogUtil.exportable_to_log_payload(project)
+ end
end
end
end
diff --git a/app/services/projects/import_export/relation_export_service.rb b/app/services/projects/import_export/relation_export_service.rb
new file mode 100644
index 00000000000..dce40cf18ba
--- /dev/null
+++ b/app/services/projects/import_export/relation_export_service.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module Projects
+ module ImportExport
+ class RelationExportService
+ include Gitlab::ImportExport::CommandLineUtil
+
+ def initialize(relation_export, jid)
+ @relation_export = relation_export
+ @jid = jid
+ @logger = Gitlab::Export::Logger.build
+ end
+
+ def execute
+ relation_export.update!(status_event: :start, jid: jid)
+
+ mkdir_p(shared.export_path)
+ mkdir_p(shared.archive_path)
+
+ if relation_saver.save
+ compress_export_path
+ upload_compressed_file
+ relation_export.finish!
+ else
+ fail_export(shared.errors.join(', '))
+ end
+ rescue StandardError => e
+ fail_export(e.message)
+ ensure
+ FileUtils.remove_entry(shared.export_path) if File.exist?(shared.export_path)
+ FileUtils.remove_entry(shared.archive_path) if File.exist?(shared.archive_path)
+ end
+
+ private
+
+ attr_reader :relation_export, :jid, :logger
+
+ delegate :relation, :project_export_job, to: :relation_export
+ delegate :project, to: :project_export_job
+
+ def shared
+ project.import_export_shared
+ end
+
+ def relation_saver
+ case relation
+ when Projects::ImportExport::RelationExport::UPLOADS_RELATION
+ Gitlab::ImportExport::UploadsSaver.new(project: project, shared: shared)
+ when Projects::ImportExport::RelationExport::REPOSITORY_RELATION
+ Gitlab::ImportExport::RepoSaver.new(exportable: project, shared: shared)
+ when Projects::ImportExport::RelationExport::WIKI_REPOSITORY_RELATION
+ Gitlab::ImportExport::WikiRepoSaver.new(exportable: project, shared: shared)
+ when Projects::ImportExport::RelationExport::LFS_OBJECTS_RELATION
+ Gitlab::ImportExport::LfsSaver.new(project: project, shared: shared)
+ when Projects::ImportExport::RelationExport::SNIPPETS_REPOSITORY_RELATION
+ Gitlab::ImportExport::SnippetsRepoSaver.new(project: project, shared: shared, current_user: nil)
+ when Projects::ImportExport::RelationExport::DESIGN_REPOSITORY_RELATION
+ Gitlab::ImportExport::DesignRepoSaver.new(exportable: project, shared: shared)
+ else
+ Gitlab::ImportExport::Project::RelationSaver.new(
+ project: project,
+ shared: shared,
+ relation: relation
+ )
+ end
+ end
+
+ def upload_compressed_file
+ upload = relation_export.build_upload
+ File.open(archive_file_full_path) { |file| upload.export_file = file }
+ upload.save!
+ end
+
+ def compress_export_path
+ tar_czf(archive: archive_file_full_path, dir: shared.export_path)
+ end
+
+ def archive_file_full_path
+ @archive_file ||= File.join(shared.archive_path, "#{relation}.tar.gz")
+ end
+
+ def fail_export(error_message)
+ relation_export.update!(status_event: :fail_op, export_error: error_message.truncate(300))
+
+ logger.error(
+ message: 'Project relation export failed',
+ export_error: error_message,
+ project_export_job_id: project_export_job.id,
+ project_name: project.name,
+ project_id: project.id
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index c032fbf1508..eaf73b78c1c 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -50,7 +50,7 @@ module Projects
def find_or_create_lfs_object(tmp_file)
lfs_obj = LfsObject.safe_find_or_create_by!(
- oid: lfs_oid,
+ oid: lfs_oid,
size: lfs_size
)
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index bc517ee3d6f..6265a74fad2 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -56,7 +56,7 @@ module Projects
attr_reader :project, :payload
def valid_payload_size?
- Gitlab::Utils::DeepSize.new(payload).valid?
+ Gitlab::Utils::DeepSize.new(payload.to_h).valid?
end
def max_alerts_exceeded?
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 666227951c6..3cb5a564ba5 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -121,6 +121,8 @@ module Projects
# Overridden in EE
def post_update_hooks(project)
ensure_personal_project_owner_membership(project)
+
+ publish_event
end
# Overridden in EE
@@ -268,6 +270,18 @@ module Projects
CustomerRelations::IssueContact.delete_for_project(project.id)
end
+
+ def publish_event
+ event = ::Projects::ProjectTransferedEvent.new(data: {
+ project_id: project.id,
+ old_namespace_id: old_namespace.id,
+ old_root_namespace_id: old_namespace.root_ancestor.id,
+ new_namespace_id: new_namespace.id,
+ new_root_namespace_id: new_namespace.root_ancestor.id
+ })
+
+ Gitlab::EventStore.publish(event)
+ end
end
end
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index 705d23ec704..f686f14b5b3 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -76,11 +76,11 @@ module Projects
if message.present?
Gitlab::AppJsonLogger.info(message: "Error synching remote mirror",
- project_id: project.id,
- project_path: project.full_path,
- remote_mirror_id: remote_mirror.id,
- lfs_sync_failed: lfs_sync_failed,
- divergent_ref_list: response.divergent_refs)
+ project_id: project.id,
+ project_path: project.full_path,
+ remote_mirror_id: remote_mirror.id,
+ lfs_sync_failed: lfs_sync_failed,
+ divergent_ref_list: response.divergent_refs)
end
[failed, message]
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 5708421014a..d757b0700b9 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -121,6 +121,8 @@ module Projects
end
update_pending_builds if runners_settings_toggled?
+
+ publish_event
end
def after_rename_service(project)
@@ -209,6 +211,18 @@ module Projects
[]
end
end
+
+ def publish_event
+ return unless project.archived_previously_changed?
+
+ event = Projects::ProjectArchivedEvent.new(data: {
+ project_id: @project.id,
+ namespace_id: @project.namespace_id,
+ root_namespace_id: @project.root_namespace.id
+ })
+
+ Gitlab::EventStore.publish(event)
+ end
end
end
diff --git a/app/services/protected_branches/base_service.rb b/app/services/protected_branches/base_service.rb
index f48e02ab4b5..d26c1b148bf 100644
--- a/app/services/protected_branches/base_service.rb
+++ b/app/services/protected_branches/base_service.rb
@@ -13,5 +13,9 @@ module ProtectedBranches
def after_execute(*)
# overridden in EE::ProtectedBranches module
end
+
+ def refresh_cache
+ CacheService.new(@project, @current_user, @params).refresh
+ end
end
end
diff --git a/app/services/protected_branches/cache_service.rb b/app/services/protected_branches/cache_service.rb
new file mode 100644
index 00000000000..8c521f4ebcb
--- /dev/null
+++ b/app/services/protected_branches/cache_service.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module ProtectedBranches
+ class CacheService < ProtectedBranches::BaseService
+ CACHE_ROOT_KEY = 'cache:gitlab:protected_branch'
+ TTL_UNSET = -1
+ CACHE_EXPIRE_IN = 1.day
+ CACHE_LIMIT = 1000
+
+ def fetch(ref_name, dry_run: false)
+ record = OpenSSL::Digest::SHA256.hexdigest(ref_name)
+
+ Gitlab::Redis::Cache.with do |redis|
+ cached_result = redis.hget(redis_key, record)
+
+ decoded_result = Gitlab::Redis::Boolean.decode(cached_result) unless cached_result.nil?
+
+ # If we're dry-running, don't break because we need to check against
+ # the real value to ensure the cache is working properly.
+ # If the result is nil we'll need to run the block, so don't break yet.
+ break decoded_result unless dry_run || decoded_result.nil?
+
+ calculated_value = yield
+
+ check_and_log_discrepancy(decoded_result, calculated_value, ref_name) if dry_run
+
+ redis.hset(redis_key, record, Gitlab::Redis::Boolean.encode(calculated_value))
+
+ # We don't want to extend cache expiration time
+ if redis.ttl(redis_key) == TTL_UNSET
+ redis.expire(redis_key, CACHE_EXPIRE_IN)
+ end
+
+ # If the cache record has too many elements, then something went wrong and
+ # it's better to drop the cache key.
+ if redis.hlen(redis_key) > CACHE_LIMIT
+ redis.unlink(redis_key)
+ end
+
+ calculated_value
+ end
+ end
+
+ def refresh
+ Gitlab::Redis::Cache.with { |redis| redis.unlink(redis_key) }
+ end
+
+ private
+
+ def check_and_log_discrepancy(cached_value, real_value, ref_name)
+ return if cached_value.nil?
+ return if cached_value == real_value
+
+ encoded_ref_name = Gitlab::EncodingHelper.encode_utf8_with_replacement_character(ref_name)
+
+ log_error(
+ 'class' => self.class.name,
+ 'message' => "Cache mismatch '#{encoded_ref_name}': cached value: #{cached_value}, real value: #{real_value}",
+ 'project_id' => @project.id,
+ 'project_path' => @project.full_path
+ )
+ end
+
+ def redis_key
+ @redis_key ||= [CACHE_ROOT_KEY, @project.id].join(':')
+ end
+ end
+end
diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb
index dada449989a..903addf7afc 100644
--- a/app/services/protected_branches/create_service.rb
+++ b/app/services/protected_branches/create_service.rb
@@ -7,6 +7,8 @@ module ProtectedBranches
save_protected_branch
+ refresh_cache
+
protected_branch
end
diff --git a/app/services/protected_branches/destroy_service.rb b/app/services/protected_branches/destroy_service.rb
index 47332ace417..01d3b68314f 100644
--- a/app/services/protected_branches/destroy_service.rb
+++ b/app/services/protected_branches/destroy_service.rb
@@ -5,7 +5,7 @@ module ProtectedBranches
def execute(protected_branch)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_protected_branch, protected_branch)
- protected_branch.destroy
+ protected_branch.destroy.tap { refresh_cache }
end
end
end
diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb
index 1e70f2d9793..c155e0022f5 100644
--- a/app/services/protected_branches/update_service.rb
+++ b/app/services/protected_branches/update_service.rb
@@ -10,6 +10,8 @@ module ProtectedBranches
if protected_branch.update(params)
after_execute(protected_branch: protected_branch, old_merge_access_levels: old_merge_access_levels, old_push_access_levels: old_push_access_levels)
+
+ refresh_cache
end
protected_branch
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index e3134070231..2588d2187a5 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -19,10 +19,6 @@ module Releases
create_release(tag, evidence_pipeline)
end
- def find_or_build_release
- release || build_release(existing_tag)
- end
-
private
def ensure_tag
diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb
index 03ac839c509..04f917ec8ef 100644
--- a/app/services/resource_events/change_labels_service.rb
+++ b/app/services/resource_events/change_labels_service.rb
@@ -24,6 +24,9 @@ module ResourceEvents
end
ApplicationRecord.legacy_bulk_insert(ResourceLabelEvent.table_name, labels) # rubocop:disable Gitlab/BulkInsert
+
+ create_timeline_events_from(added_labels: added_labels, removed_labels: removed_labels)
+
resource.expire_note_etag_cache
Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) if resource.is_a?(Issue)
@@ -41,6 +44,17 @@ module ResourceEvents
raise ArgumentError, "Unknown resource type #{resource.class.name}"
end
end
+
+ def create_timeline_events_from(added_labels: [], removed_labels: [])
+ return unless resource.incident?
+
+ IncidentManagement::TimelineEvents::CreateService.change_labels(
+ resource,
+ user,
+ added_labels: added_labels,
+ removed_labels: removed_labels
+ )
+ end
end
end
diff --git a/app/services/security/ci_configuration/sast_parser_service.rb b/app/services/security/ci_configuration/sast_parser_service.rb
index cae9a90f0a0..16a9efcefdf 100644
--- a/app/services/security/ci_configuration/sast_parser_service.rb
+++ b/app/services/security/ci_configuration/sast_parser_service.rb
@@ -75,7 +75,11 @@ module Security
def sast_excluded_analyzers
strong_memoize(:sast_excluded_analyzers) do
excluded_analyzers = gitlab_ci_yml_attributes["SAST_EXCLUDED_ANALYZERS"] || sast_template_attributes["SAST_EXCLUDED_ANALYZERS"]
- excluded_analyzers.split(',').map(&:strip) rescue []
+ begin
+ excluded_analyzers.split(',').map(&:strip)
+ rescue StandardError
+ []
+ end
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index d7e4b53b5de..9de73a00eac 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -57,7 +57,7 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).unrelate_issuable(noteable_ref)
end
- # Called when the due_date of a Noteable is changed
+ # Called when the due_date or start_date of a Noteable is changed
#
# noteable - Noteable object
# project - Project owning noteable
@@ -68,11 +68,15 @@ module SystemNoteService
#
# "removed due date"
#
- # "changed due date to September 20, 2018"
+ # "changed due date to September 20, 2018 and changed start date to September 25, 2018"
#
# Returns the created Note object
- def change_due_date(noteable, project, author, due_date)
- ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_due_date(due_date)
+ def change_start_date_or_due_date(noteable, project, author, changed_dates)
+ ::SystemNotes::TimeTrackingService.new(
+ noteable: noteable,
+ project: project,
+ author: author
+ ).change_start_date_or_due_date(changed_dates)
end
# Called when the estimated time of a Noteable is changed
@@ -111,6 +115,24 @@ module SystemNoteService
::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_spent
end
+ # Called when a timelog is added to an issuable
+ #
+ # issuable - Issuable object (Issue, WorkItem or MergeRequest)
+ # project - Project owning the issuable
+ # author - User performing the change
+ # timelog - Created timelog
+ #
+ # Example Note text:
+ #
+ # "subtracted 1h 15m of time spent"
+ #
+ # "added 2h 30m of time spent"
+ #
+ # Returns the created Note object
+ def created_timelog(issuable, project, author, timelog)
+ ::SystemNotes::TimeTrackingService.new(noteable: issuable, project: project, author: author).created_timelog(timelog)
+ end
+
# Called when a timelog is removed from a Noteable
#
# noteable - Noteable object
@@ -134,14 +156,6 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_status(status, source)
end
- def request_attention(noteable, project, author, user)
- ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).request_attention(user)
- end
-
- def remove_attention_request(noteable, project, author, user)
- ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).remove_attention_request(user)
- end
-
# Called when 'merge when pipeline succeeds' is executed
def merge_when_pipeline_succeeds(noteable, project, author, sha)
::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).merge_when_pipeline_succeeds(sha)
@@ -256,8 +270,8 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_moved(noteable_ref, direction)
end
- def noteable_cloned(noteable, project, noteable_ref, author, direction:)
- ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_cloned(noteable_ref, direction)
+ def noteable_cloned(noteable, project, noteable_ref, author, direction:, created_at: nil)
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_cloned(noteable_ref, direction, created_at: created_at)
end
def mark_duplicate_issue(noteable, project, author, canonical_issue)
@@ -280,6 +294,18 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: mentioned).cross_reference_disallowed?(mentioned_in)
end
+ def relate_work_item(noteable, work_item, user)
+ ::SystemNotes::IssuablesService
+ .new(noteable: noteable, project: noteable.project, author: user)
+ .hierarchy_changed(work_item, 'relate')
+ end
+
+ def unrelate_work_item(noteable, work_item, user)
+ ::SystemNotes::IssuablesService
+ .new(noteable: noteable, project: noteable.project, author: user)
+ .hierarchy_changed(work_item, 'unrelate')
+ end
+
def zoom_link_added(issue, project, author)
::SystemNotes::ZoomService.new(noteable: issue, project: project, author: author).zoom_link_added
end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index f9e5c3725d8..75903fde39e 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -178,6 +178,24 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
+ # Called when the hierarchy of a work item is changed
+ #
+ # noteable - Noteable object that responds to `work_item_parent` and `work_item_children`
+ # project - Project owning noteable
+ # author - User performing the change
+ #
+ # Example Note text:
+ #
+ # "added #1 as child Task"
+ #
+ # Returns the created Note object
+ def hierarchy_changed(work_item, action)
+ params = hierarchy_note_params(action, noteable, work_item)
+
+ create_note(NoteSummary.new(noteable, project, author, params[:parent_note_body], action: params[:parent_action]))
+ create_note(NoteSummary.new(work_item, project, author, params[:child_note_body], action: params[:child_action]))
+ end
+
# Called when the description of a Noteable is changed
#
# noteable - Noteable object that responds to `description`
@@ -255,12 +273,12 @@ module SystemNotes
#
# Example Note text:
#
- # "marked the task Whatever as completed."
+ # "marked the checklist item Whatever as completed."
#
# Returns the created Note object
def change_task_status(new_task)
status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE
- body = "marked the task **#{new_task.source}** as #{status_label}"
+ body = "marked the checklist item **#{new_task.source}** as #{status_label}"
issue_activity_counter.track_issue_description_changed_action(author: author) if noteable.is_a?(Issue)
@@ -294,13 +312,14 @@ module SystemNotes
#
# noteable_ref - Referenced noteable
# direction - symbol, :to or :from
+ # created_at - timestamp for the system note, defaults to current time
#
# Example Note text:
#
# "cloned to some_namespace/project_new#11"
#
# Returns the created Note object
- def noteable_cloned(noteable_ref, direction)
+ def noteable_cloned(noteable_ref, direction, created_at: nil)
unless [:to, :from].include?(direction)
raise ArgumentError, "Invalid direction `#{direction}`"
end
@@ -308,9 +327,11 @@ module SystemNotes
cross_reference = noteable_ref.to_reference(project)
body = "cloned #{direction} #{cross_reference}"
- issue_activity_counter.track_issue_cloned_action(author: author) if noteable.is_a?(Issue) && direction == :to
+ if noteable.is_a?(Issue) && direction == :to
+ issue_activity_counter.track_issue_cloned_action(author: author, project: project)
+ end
- create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned'))
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned', created_at: created_at))
end
# Called when the confidentiality changes
@@ -367,36 +388,6 @@ module SystemNotes
existing_mentions_for(mentioned_in, noteable, notes).exists?
end
- # Called when a user's attention has been requested for a Notable
- #
- # user - User's whos attention has been requested
- #
- # Example Note text:
- #
- # "requested attention from @eli.wisoky"
- #
- # Returns the created Note object
- def request_attention(user)
- body = "requested attention from #{user.to_reference}"
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'attention_requested'))
- end
-
- # Called when a user's attention request has been removed for a Notable
- #
- # user - User's whos attention request has been removed
- #
- # Example Note text:
- #
- # "removed attention request from @eli.wisoky"
- #
- # Returns the created Note object
- def remove_attention_request(user)
- body = "removed attention request from #{user.to_reference}"
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'attention_request_removed'))
- end
-
# Called when a Noteable has been marked as the canonical Issue of a duplicate
#
# duplicate_issue - Issue that was a duplicate of this
@@ -506,6 +497,29 @@ module SystemNotes
def track_cross_reference_action
issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue)
end
+
+ def hierarchy_note_params(action, parent, child)
+ return {} unless child && parent
+
+ child_type = child.issue_type.humanize(capitalize: false)
+ parent_type = parent.issue_type.humanize(capitalize: false)
+
+ if action == 'relate'
+ {
+ parent_note_body: "added #{child.to_reference} as child #{child_type}",
+ child_note_body: "added #{parent.to_reference} as parent #{parent_type}",
+ parent_action: 'relate_to_child',
+ child_action: 'relate_to_parent'
+ }
+ else
+ {
+ parent_note_body: "removed child #{child_type} #{child.to_reference}",
+ child_note_body: "removed parent #{parent_type} #{parent.to_reference}",
+ parent_action: 'unrelate_from_child',
+ child_action: 'unrelate_from_parent'
+ }
+ end
+ end
end
end
diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb
index a9b1f6d3d37..68df52a03c7 100644
--- a/app/services/system_notes/time_tracking_service.rb
+++ b/app/services/system_notes/time_tracking_service.rb
@@ -2,8 +2,9 @@
module SystemNotes
class TimeTrackingService < ::SystemNotes::BaseService
- # Called when the due_date of a Noteable is changed
+ # Called when the start_date or due_date of an Issue/WorkItem is changed
#
+ # start_date - Start date being assigned, or nil
# due_date - Due date being assigned, or nil
#
# Example Note text:
@@ -11,14 +12,23 @@ module SystemNotes
# "removed due date"
#
# "changed due date to September 20, 2018"
+
+ # "changed start date to September 20, 2018 and changed due date to September 25, 2018"
#
# Returns the created Note object
- def change_due_date(due_date)
- body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date'
+ def change_start_date_or_due_date(changed_dates = {})
+ return if changed_dates.empty?
+
+ # Using instance_of because WorkItem < Issue. We don't want to track work item updates as issue updates
+ if noteable.instance_of?(Issue) && changed_dates.key?('due_date')
+ issue_activity_counter.track_issue_due_date_changed_action(author: author)
+ end
- issue_activity_counter.track_issue_due_date_changed_action(author: author) if noteable.is_a?(Issue)
+ work_item_activity_counter.track_work_item_date_changed_action(author: author) if noteable.is_a?(WorkItem)
- create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date'))
+ create_note(
+ NoteSummary.new(noteable, project, author, changed_date_body(changed_dates), action: 'start_date_or_due_date')
+ )
end
# Called when the estimated time of a Noteable is changed
@@ -76,6 +86,32 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
end
+ # Called when a timelog is added to an issuable
+ #
+ # timelog - Added timelog
+ #
+ # Example Note text:
+ #
+ # "subtracted 1h 15m of time spent"
+ #
+ # "added 2h 30m of time spent"
+ #
+ # Returns the created Note object
+ def created_timelog(timelog)
+ time_spent = timelog.time_spent
+ spent_at = timelog.spent_at&.to_date
+ parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
+ action = time_spent > 0 ? 'added' : 'subtracted'
+
+ text_parts = ["#{action} #{parsed_time} of time spent"]
+ text_parts << "at #{spent_at}" if spent_at && spent_at != DateTime.current.to_date
+ body = text_parts.join(' ')
+
+ issue_activity_counter.track_issue_time_spent_changed_action(author: author) if noteable.is_a?(Issue)
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
+ end
+
def remove_timelog(timelog)
time_spent = timelog.time_spent
spent_at = timelog.spent_at&.to_date
@@ -90,8 +126,33 @@ module SystemNotes
private
+ def changed_date_body(changed_dates)
+ %w[start_date due_date].each_with_object([]) do |date_field, word_array|
+ next unless changed_dates.key?(date_field)
+
+ word_array << 'and' if word_array.any?
+
+ word_array << message_for_changed_date(changed_dates, date_field)
+ end.join(' ')
+ end
+
+ def message_for_changed_date(changed_dates, date_key)
+ changed_date = changed_dates[date_key].last
+ readable_date = date_key.humanize.downcase
+
+ if changed_date.nil?
+ "removed #{readable_date}"
+ else
+ "changed #{readable_date} to #{changed_date.to_s(:long)}"
+ end
+ end
+
def issue_activity_counter
Gitlab::UsageDataCounters::IssueActivityUniqueCounter
end
+
+ def work_item_activity_counter
+ Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter
+ end
end
end
diff --git a/app/services/timelogs/base_service.rb b/app/services/timelogs/base_service.rb
index be46c26e047..e09264864fd 100644
--- a/app/services/timelogs/base_service.rb
+++ b/app/services/timelogs/base_service.rb
@@ -5,11 +5,26 @@ module Timelogs
include BaseServiceUtility
include Gitlab::Utils::StrongMemoize
- attr_accessor :timelog, :current_user
+ attr_accessor :current_user
- def initialize(timelog, user)
- @timelog = timelog
+ def initialize(user)
@current_user = user
end
+
+ def success(timelog)
+ ServiceResponse.success(payload: {
+ timelog: timelog
+ })
+ end
+
+ def error(message, http_status = nil)
+ ServiceResponse.error(message: message, http_status: http_status)
+ end
+
+ def error_in_save(timelog)
+ return error(_("Failed to save timelog")) if timelog.errors.empty?
+
+ error(timelog.errors.full_messages.to_sentence)
+ end
end
end
diff --git a/app/services/timelogs/create_service.rb b/app/services/timelogs/create_service.rb
new file mode 100644
index 00000000000..12181cec20a
--- /dev/null
+++ b/app/services/timelogs/create_service.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Timelogs
+ class CreateService < Timelogs::BaseService
+ attr_accessor :issuable, :time_spent, :spent_at, :summary
+
+ def initialize(issuable, time_spent, spent_at, summary, user)
+ super(user)
+
+ @issuable = issuable
+ @time_spent = time_spent
+ @spent_at = spent_at
+ @summary = summary
+ end
+
+ def execute
+ unless can?(current_user, :create_timelog, issuable)
+ return error(
+ _("%{issuable_class_name} doesn't exist or you don't have permission to add timelog to it.") % {
+ issuable_class_name: issuable.nil? ? 'Issuable' : issuable.base_class_name
+ }, 404)
+ end
+
+ issue = issuable if issuable.is_a?(Issue)
+ merge_request = issuable if issuable.is_a?(MergeRequest)
+
+ timelog = Timelog.new(
+ time_spent: time_spent,
+ spent_at: spent_at,
+ summary: summary,
+ user: current_user,
+ issue: issue,
+ merge_request: merge_request,
+ note: nil
+ )
+
+ if !timelog.save
+ error_in_save(timelog)
+ else
+ SystemNoteService.created_timelog(issuable, issuable.project, current_user, timelog)
+ success(timelog)
+ end
+ end
+ end
+end
diff --git a/app/services/timelogs/delete_service.rb b/app/services/timelogs/delete_service.rb
index 0df888a3706..e72dfd98494 100644
--- a/app/services/timelogs/delete_service.rb
+++ b/app/services/timelogs/delete_service.rb
@@ -2,11 +2,17 @@
module Timelogs
class DeleteService < Timelogs::BaseService
+ attr_accessor :timelog
+
+ def initialize(timelog, user)
+ super(user)
+
+ @timelog = timelog
+ end
+
def execute
unless can?(current_user, :admin_timelog, timelog)
- return ServiceResponse.error(
- message: "Timelog doesn't exist or you don't have permission to delete it",
- http_status: 404)
+ return error(_("Timelog doesn't exist or you don't have permission to delete it"), 404)
end
if timelog.destroy
@@ -17,9 +23,9 @@ module Timelogs
SystemNoteService.remove_timelog(issuable, issuable.project, current_user, timelog)
end
- ServiceResponse.success(payload: timelog)
+ success(timelog)
else
- ServiceResponse.error(message: 'Failed to remove timelog', http_status: 400)
+ error(_('Failed to remove timelog'), 400)
end
end
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 14cf264cc51..6ae394072c6 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -218,11 +218,6 @@ class TodoService
create_todos(reviewers, attributes)
end
- def create_attention_requested_todo(target, author, users)
- attributes = attributes_for_todo(target.project, target, author, Todo::ATTENTION_REQUESTED)
- create_todos(users, attributes)
- end
-
private
def create_todos(users, attributes)
diff --git a/app/services/todos/destroy/destroyed_issuable_service.rb b/app/services/todos/destroy/destroyed_issuable_service.rb
index 7a85b59eeea..759c430ec7a 100644
--- a/app/services/todos/destroy/destroyed_issuable_service.rb
+++ b/app/services/todos/destroy/destroyed_issuable_service.rb
@@ -5,9 +5,14 @@ module Todos
class DestroyedIssuableService
BATCH_SIZE = 100
+ # Since we are moving towards work items, in some instances we create todos with
+ # `target_type: WorkItem` in other instances we still create todos with `target_type: Issue`
+ # So when an issue/work item is deleted, we just make sure to delete todos for both target types
+ BOUND_TARGET_TYPES = %w(Issue WorkItem).freeze
+
def initialize(target_id, target_type)
@target_id = target_id
- @target_type = target_type
+ @target_type = BOUND_TARGET_TYPES.include?(target_type) ? BOUND_TARGET_TYPES : target_type
end
def execute
diff --git a/app/services/topics/merge_service.rb b/app/services/topics/merge_service.rb
new file mode 100644
index 00000000000..0d256579fe0
--- /dev/null
+++ b/app/services/topics/merge_service.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module Topics
+ class MergeService
+ attr_accessor :source_topic, :target_topic
+
+ def initialize(source_topic, target_topic)
+ @source_topic = source_topic
+ @target_topic = target_topic
+ end
+
+ def execute
+ validate_parameters!
+
+ ::Projects::ProjectTopic.transaction do
+ move_project_topics
+ refresh_target_topic_counters
+ delete_source_topic
+ end
+ end
+
+ private
+
+ def validate_parameters!
+ raise ArgumentError, 'The source topic is not a topic.' unless source_topic.is_a?(Projects::Topic)
+ raise ArgumentError, 'The target topic is not a topic.' unless target_topic.is_a?(Projects::Topic)
+ raise ArgumentError, 'The source topic and the target topic are identical.' if source_topic == target_topic
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def move_project_topics
+ project_ids_for_projects_currently_using_source_and_target = ::Projects::ProjectTopic
+ .where(topic_id: target_topic).select(:project_id)
+ # Only update for projects that exclusively use the source topic
+ ::Projects::ProjectTopic.where(topic_id: source_topic.id)
+ .where.not(project_id: project_ids_for_projects_currently_using_source_and_target)
+ .update_all(topic_id: target_topic.id)
+
+ # Delete source topic for projects that were using source and target
+ ::Projects::ProjectTopic.where(topic_id: source_topic.id).delete_all
+ end
+
+ def refresh_target_topic_counters
+ target_topic.update!(
+ total_projects_count: total_projects_count(target_topic.id),
+ non_private_projects_count: non_private_projects_count(target_topic.id)
+ )
+ end
+
+ def delete_source_topic
+ source_topic.destroy!
+ end
+
+ def total_projects_count(topic_id)
+ ::Projects::ProjectTopic.where(topic_id: topic_id).count
+ end
+
+ def non_private_projects_count(topic_id)
+ ::Projects::ProjectTopic.joins(:project).where(topic_id: topic_id).where('projects.visibility_level in (10, 20)')
+ .count
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/services/uploads/destroy_service.rb b/app/services/uploads/destroy_service.rb
new file mode 100644
index 00000000000..1f0d99ff7bb
--- /dev/null
+++ b/app/services/uploads/destroy_service.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Uploads
+ class DestroyService < BaseService
+ attr_accessor :model, :current_user
+
+ def initialize(model, user = nil)
+ @model = model
+ @current_user = user
+ end
+
+ def execute(secret, filename)
+ upload = find_upload(secret, filename)
+
+ unless current_user && upload && current_user.can?(:destroy_upload, upload)
+ return error(_("The resource that you are attempting to access does not "\
+ "exist or you don't have permission to perform this action."))
+ end
+
+ if upload.destroy
+ success(upload: upload)
+ else
+ error(_('Upload could not be deleted.'))
+ end
+ end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find_upload(secret, filename)
+ uploader = uploader_class.new(model, secret: secret)
+ upload_paths = uploader.upload_paths(filename)
+
+ Upload.find_by(model: model, uploader: uploader_class.to_s, path: upload_paths)
+ rescue FileUploader::InvalidSecret
+ nil
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def uploader_class
+ case model
+ when Group
+ NamespaceFileUploader
+ when Project
+ FileUploader
+ else
+ raise ArgumentError, "unknown uploader for #{model.class.name}"
+ end
+ end
+ end
+end
diff --git a/app/services/users/dismiss_namespace_callout_service.rb b/app/services/users/dismiss_namespace_callout_service.rb
new file mode 100644
index 00000000000..51261a93e20
--- /dev/null
+++ b/app/services/users/dismiss_namespace_callout_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Users
+ class DismissNamespaceCalloutService < DismissCalloutService
+ private
+
+ def callout
+ current_user.find_or_initialize_namespace_callout(params[:feature_name], params[:namespace_id])
+ end
+ end
+end
diff --git a/app/services/users/dismiss_project_callout_service.rb b/app/services/users/dismiss_project_callout_service.rb
new file mode 100644
index 00000000000..23549b3b194
--- /dev/null
+++ b/app/services/users/dismiss_project_callout_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Users
+ class DismissProjectCalloutService < DismissCalloutService
+ private
+
+ def callout
+ current_user.find_or_initialize_project_callout(params[:feature_name], params[:project_id])
+ end
+ end
+end
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index c3df9b153a1..cb2711b6fee 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -17,7 +17,7 @@ module Users
end
def execute(validate: true, check_password: false, &block)
- yield(@user) if block_given?
+ yield(@user) if block
user_exists = @user.persisted?
@user.user_detail # prevent assignment
diff --git a/app/services/web_hooks/admin_destroy_service.rb b/app/services/web_hooks/admin_destroy_service.rb
new file mode 100644
index 00000000000..e9835801a39
--- /dev/null
+++ b/app/services/web_hooks/admin_destroy_service.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module WebHooks
+ # A variant of the destroy service that can only be used by an administrator
+ # from a rake task.
+ class AdminDestroyService < WebHooks::DestroyService
+ def initialize(rake_task:)
+ super(nil)
+ @rake_task = rake_task
+ end
+
+ def authorized?(web_hook)
+ @rake_task.present? # Not impossible to circumvent, but you need to provide something
+ end
+
+ def log_message(hook)
+ "An administrator scheduled a deletion of logs for hook ID #{hook.id} from #{@rake_task.name}"
+ end
+ end
+end
diff --git a/app/services/web_hooks/destroy_service.rb b/app/services/web_hooks/destroy_service.rb
index 54c6c7ea71b..dbd164ab20e 100644
--- a/app/services/web_hooks/destroy_service.rb
+++ b/app/services/web_hooks/destroy_service.rb
@@ -1,25 +1,41 @@
# frozen_string_literal: true
module WebHooks
+ # Destroy a hook, and schedule the logs for deletion.
class DestroyService
+ include Services::ReturnServiceResponses
+
attr_accessor :current_user
+ DENIED = 'Insufficient permissions'
+
def initialize(current_user)
@current_user = current_user
end
- # Destroy the hook immediately, schedule the logs for deletion
def execute(web_hook)
+ return error(DENIED, 401) unless authorized?(web_hook)
+
hook_id = web_hook.id
if web_hook.destroy
WebHooks::LogDestroyWorker.perform_async({ 'hook_id' => hook_id })
- Gitlab::AppLogger.info("User #{current_user&.id} scheduled a deletion of logs for hook ID #{hook_id}")
+ Gitlab::AppLogger.info(log_message(web_hook))
- ServiceResponse.success(payload: { async: false })
+ success({ async: false })
else
- ServiceResponse.error(message: "Unable to destroy #{web_hook.model_name.human}")
+ error("Unable to destroy #{web_hook.model_name.human}", 500)
end
end
+
+ private
+
+ def log_message(hook)
+ "User #{current_user&.id} scheduled a deletion of logs for hook ID #{hook.id}"
+ end
+
+ def authorized?(web_hook)
+ Ability.allowed?(current_user, :destroy_web_hook, web_hook)
+ end
end
end
diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb
index 17dcf615830..5be8aee3ae8 100644
--- a/app/services/web_hooks/log_execution_service.rb
+++ b/app/services/web_hooks/log_execution_service.rb
@@ -14,7 +14,6 @@ module WebHooks
@hook = hook
@log_data = log_data.transform_keys(&:to_sym)
@response_category = response_category
- @prev_state = hook.active_state(ignore_flag: true)
end
def execute
@@ -43,36 +42,12 @@ module WebHooks
hook.failed!
end
- log_state_change
hook.update_last_failure
end
rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
raise if raise_lock_error?
end
- def log_state_change
- new_state = hook.active_state(ignore_flag: true)
-
- return if @prev_state == new_state
-
- Gitlab::AuthLogger.info(
- message: 'WebHook change active_state',
- # identification
- hook_id: hook.id,
- hook_type: hook.type,
- project_id: hook.project_id,
- group_id: hook.group_id,
- # relevant data
- prev_state: @prev_state,
- new_state: new_state,
- duration: log_data[:execution_duration],
- response_status: log_data[:response_status],
- recent_hook_failures: hook.recent_failures,
- # context
- **Gitlab::ApplicationContext.current
- )
- end
-
def lock_name
"web_hooks:update_hook_failure_state:#{hook.id}"
end
diff --git a/app/services/webauthn/authenticate_service.rb b/app/services/webauthn/authenticate_service.rb
index a575a853995..52437a77df8 100644
--- a/app/services/webauthn/authenticate_service.rb
+++ b/app/services/webauthn/authenticate_service.rb
@@ -30,6 +30,8 @@ module Webauthn
false
end
+ private
+
##
# Validates that webauthn_credential is syntactically valid
#
diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb
index 6a773a84225..5cc358c4b4f 100644
--- a/app/services/work_items/create_and_link_service.rb
+++ b/app/services/work_items/create_and_link_service.rb
@@ -7,19 +7,20 @@ module WorkItems
# new work items that were never associated with other work items as expected.
class CreateAndLinkService
def initialize(project:, current_user: nil, params: {}, spam_params:, link_params: {})
- @create_service = CreateService.new(
- project: project,
- current_user: current_user,
- params: params,
- spam_params: spam_params
- )
@project = project
@current_user = current_user
+ @params = params
@link_params = link_params
+ @spam_params = spam_params
end
def execute
- create_result = @create_service.execute
+ create_result = CreateService.new(
+ project: @project,
+ current_user: @current_user,
+ params: @params.merge(title: @params[:title].strip).reverse_merge(confidential: confidential_parent),
+ spam_params: @spam_params
+ ).execute
return create_result if create_result.error?
work_item = create_result[:work_item]
@@ -40,6 +41,10 @@ module WorkItems
private
+ def confidential_parent
+ !!@link_params[:parent_work_item]&.confidential
+ end
+
def payload(work_item)
{ work_item: work_item }
end
diff --git a/app/services/work_items/parent_links/create_service.rb b/app/services/work_items/parent_links/create_service.rb
index 9940776e367..e7906f1fcdd 100644
--- a/app/services/work_items/parent_links/create_service.rb
+++ b/app/services/work_items/parent_links/create_service.rb
@@ -41,10 +41,8 @@ module WorkItems
params[:issuable_references]
end
- # TODO: Create system notes when work item's parent or children are updated
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/362213
def create_notes(work_item)
- # no-op
+ SystemNoteService.relate_work_item(issuable, work_item, current_user)
end
def target_issuable_type
diff --git a/app/services/work_items/parent_links/destroy_service.rb b/app/services/work_items/parent_links/destroy_service.rb
index 55870d44db9..19770b3e4b5 100644
--- a/app/services/work_items/parent_links/destroy_service.rb
+++ b/app/services/work_items/parent_links/destroy_service.rb
@@ -14,10 +14,8 @@ module WorkItems
private
- # TODO: Create system notes when work item's parent or children are removed
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/362213
def create_notes
- # no-op
+ SystemNoteService.unrelate_work_item(parent, child, current_user)
end
def not_found_message
diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb
index 98818fda263..2deb8c82741 100644
--- a/app/services/work_items/update_service.rb
+++ b/app/services/work_items/update_service.rb
@@ -26,8 +26,8 @@ module WorkItems
private
- def update(work_item)
- execute_widgets(work_item: work_item, callback: :update, widget_params: @widget_params)
+ def before_update(work_item, skip_spam_check: false)
+ execute_widgets(work_item: work_item, callback: :before_update_callback, widget_params: @widget_params)
super
end
diff --git a/app/services/work_items/widgets/assignees_service/update_service.rb b/app/services/work_items/widgets/assignees_service/update_service.rb
new file mode 100644
index 00000000000..9176b71c85e
--- /dev/null
+++ b/app/services/work_items/widgets/assignees_service/update_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module AssigneesService
+ class UpdateService < WorkItems::Widgets::BaseService
+ def before_update_in_transaction(params:)
+ 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/base_service.rb b/app/services/work_items/widgets/base_service.rb
index 037733bbed5..37ed2bf4b05 100644
--- a/app/services/work_items/widgets/base_service.rb
+++ b/app/services/work_items/widgets/base_service.rb
@@ -5,12 +5,19 @@ module WorkItems
class BaseService < ::BaseService
WidgetError = Class.new(StandardError)
- attr_reader :widget, :current_user
+ attr_reader :widget, :work_item, :current_user
def initialize(widget:, current_user:)
@widget = widget
+ @work_item = widget.work_item
@current_user = current_user
end
+
+ private
+
+ def has_permission?(permission)
+ can?(current_user, permission, widget.work_item)
+ 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
index e63b6b2ee6c..fe591ba605e 100644
--- a/app/services/work_items/widgets/description_service/update_service.rb
+++ b/app/services/work_items/widgets/description_service/update_service.rb
@@ -4,10 +4,12 @@ module WorkItems
module Widgets
module DescriptionService
class UpdateService < WorkItems::Widgets::BaseService
- def update(params: {})
- return unless params.present? && params[:description]
+ def before_update_callback(params: {})
+ return unless params.present? && params.key?(:description)
+ return unless has_permission?(:update_work_item)
- widget.work_item.description = params[:description]
+ work_item.description = params[:description]
+ work_item.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user)
end
end
end
diff --git a/app/services/work_items/widgets/hierarchy_service/base_service.rb b/app/services/work_items/widgets/hierarchy_service/base_service.rb
index 085d6c6b0e7..bb681ef0083 100644
--- a/app/services/work_items/widgets/hierarchy_service/base_service.rb
+++ b/app/services/work_items/widgets/hierarchy_service/base_service.rb
@@ -15,7 +15,7 @@ module WorkItems
elsif params.key?(:children)
update_work_item_children(params.delete(:children))
else
- invalid_args_error
+ invalid_args_error(params)
end
end
@@ -29,13 +29,13 @@ module WorkItems
def set_parent(parent)
::WorkItems::ParentLinks::CreateService
- .new(parent, current_user, { target_issuable: widget.work_item })
+ .new(parent, current_user, { target_issuable: work_item })
.execute
end
# rubocop: disable CodeReuse/ActiveRecord
def remove_parent
- link = ::WorkItems::ParentLink.find_by(work_item: widget.work_item)
+ link = ::WorkItems::ParentLink.find_by(work_item: work_item)
return success unless link.present?
::WorkItems::ParentLinks::DestroyService.new(link, current_user).execute
@@ -44,12 +44,12 @@ module WorkItems
def update_work_item_children(children)
::WorkItems::ParentLinks::CreateService
- .new(widget.work_item, current_user, { issuable_references: children })
+ .new(work_item, current_user, { issuable_references: children })
.execute
end
def feature_flag_enabled?
- Feature.enabled?(:work_items_hierarchy, widget.work_item&.project)
+ Feature.enabled?(:work_items_hierarchy, work_item&.project)
end
def incompatible_args?(params)
@@ -64,11 +64,14 @@ module WorkItems
error(_('A Work Item can be a parent or a child, but not both.'))
end
- def invalid_args_error
+ def invalid_args_error(params)
error(_("One or more arguments are invalid: %{args}." % { args: params.keys.to_sentence } ))
end
def service_response!(result)
+ work_item.reload_work_item_parent
+ work_item.work_item_children.reset
+
return result unless result[:status] == :error
raise WidgetError, result[:message]
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
new file mode 100644
index 00000000000..6a5dc0d5ef3
--- /dev/null
+++ b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module StartAndDueDateService
+ class UpdateService < WorkItems::Widgets::BaseService
+ def before_update_callback(params: {})
+ return if params.blank?
+
+ widget.work_item.assign_attributes(params.slice(:start_date, :due_date))
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/widgets/weight_service/update_service.rb b/app/services/work_items/widgets/weight_service/update_service.rb
deleted file mode 100644
index cd62a25358f..00000000000
--- a/app/services/work_items/widgets/weight_service/update_service.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module WeightService
- class UpdateService < WorkItems::Widgets::BaseService
- def update(params: {})
- return unless params.present? && params[:weight]
-
- widget.work_item.weight = params[:weight]
- end
- end
- end
- end
-end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index 73dafaefb41..ac7b05bc7ea 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -6,7 +6,7 @@ class AvatarUploader < GitlabUploader
include ObjectStorage::Concern
prepend ObjectStorage::Extension::RecordsUploads
- MIME_WHITELIST = %w[image/png image/jpeg image/gif image/bmp image/tiff image/vnd.microsoft.icon].freeze
+ MIME_ALLOWLIST = %w[image/png image/jpeg image/gif image/bmp image/tiff image/vnd.microsoft.icon].freeze
def exists?
model.avatar.file && model.avatar.file.present?
@@ -29,7 +29,7 @@ class AvatarUploader < GitlabUploader
end
def content_type_whitelist
- MIME_WHITELIST
+ MIME_ALLOWLIST
end
private
diff --git a/app/uploaders/design_management/design_v432x230_uploader.rb b/app/uploaders/design_management/design_v432x230_uploader.rb
index ba48f381bbd..975050c26e4 100644
--- a/app/uploaders/design_management/design_v432x230_uploader.rb
+++ b/app/uploaders/design_management/design_v432x230_uploader.rb
@@ -20,13 +20,13 @@ module DesignManagement
#
# We currently choose not to resize `image/svg+xml` for security reasons.
# See https://gitlab.com/gitlab-org/gitlab/issues/207740#note_302766171
- MIME_TYPE_WHITELIST = %w(image/png image/jpeg image/bmp image/gif).freeze
+ MIME_TYPE_ALLOWLIST = %w(image/png image/jpeg image/bmp image/gif).freeze
process resize_to_fit: [432, 230]
# Allow CarrierWave to reject files without correct mimetypes.
def content_type_whitelist
- MIME_TYPE_WHITELIST
+ MIME_TYPE_ALLOWLIST
end
# Override `GitlabUploader` and always return false, otherwise local
diff --git a/app/uploaders/favicon_uploader.rb b/app/uploaders/favicon_uploader.rb
index c9be55e001c..a21b21de101 100644
--- a/app/uploaders/favicon_uploader.rb
+++ b/app/uploaders/favicon_uploader.rb
@@ -1,15 +1,15 @@
# frozen_string_literal: true
class FaviconUploader < AttachmentUploader
- EXTENSION_WHITELIST = %w[png ico].freeze
- MIME_WHITELIST = %w[image/png image/vnd.microsoft.icon].freeze
+ EXTENSION_ALLOWLIST = %w[png ico].freeze
+ MIME_ALLOWLIST = %w[image/png image/vnd.microsoft.icon].freeze
def extension_whitelist
- EXTENSION_WHITELIST
+ EXTENSION_ALLOWLIST
end
def content_type_whitelist
- MIME_WHITELIST
+ MIME_ALLOWLIST
end
private
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index bd959b14648..bf5be708060 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -142,8 +142,8 @@ class FileUploader < GitlabUploader
def to_h
{
- alt: markdown_name,
- url: secure_url,
+ alt: markdown_name,
+ url: secure_url,
markdown: markdown_link
}
end
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index 891df5180d8..063aca7937c 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -224,6 +224,10 @@ module ObjectStorage
def initialize(file)
@file = file
end
+
+ def file_path
+ @file.path
+ end
end
# allow to configure and overwrite the filename
@@ -275,9 +279,9 @@ module ObjectStorage
end
end
- def use_open_file(&blk)
+ def use_open_file(unlink_early: true)
Tempfile.open(path) do |file|
- file.unlink
+ file.unlink if unlink_early
file.binmode
if file_storage?
@@ -291,6 +295,8 @@ module ObjectStorage
file.seek(0, IO::SEEK_SET)
yield OpenFile.new(file)
+ ensure
+ file.unlink unless unlink_early
end
end
diff --git a/app/validators/json_schemas/build_metadata_id_tokens.json b/app/validators/json_schemas/build_metadata_id_tokens.json
new file mode 100644
index 00000000000..7f39c7274f3
--- /dev/null
+++ b/app/validators/json_schemas/build_metadata_id_tokens.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "CI builds metadata ID tokens",
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": "object",
+ "patternProperties": {
+ "^id_token$": {
+ "type": "object",
+ "required": ["aud"],
+ "properties": {
+ "aud": { "type": "string" },
+ "field": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/app/validators/json_schemas/cyclonedx_report.json b/app/validators/json_schemas/cyclonedx_report.json
new file mode 100644
index 00000000000..65c3c3c0cb9
--- /dev/null
+++ b/app/validators/json_schemas/cyclonedx_report.json
@@ -0,0 +1,1697 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "http://cyclonedx.org/schema/bom-1.4.schema.json",
+ "type": "object",
+ "title": "CycloneDX Software Bill of Materials Standard",
+ "$comment" : "CycloneDX JSON schema is published under the terms of the Apache License 2.0.",
+ "required": [
+ "bomFormat",
+ "specVersion",
+ "version"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "$schema": {
+ "type": "string",
+ "enum": [
+ "http://cyclonedx.org/schema/bom-1.4.schema.json"
+ ]
+ },
+ "bomFormat": {
+ "type": "string",
+ "title": "BOM Format",
+ "description": "Specifies the format of the BOM. This helps to identify the file as CycloneDX since BOMs do not have a filename convention nor does JSON schema support namespaces. This value MUST be \"CycloneDX\".",
+ "enum": [
+ "CycloneDX"
+ ]
+ },
+ "specVersion": {
+ "type": "string",
+ "title": "CycloneDX Specification Version",
+ "description": "The version of the CycloneDX specification a BOM conforms to (starting at version 1.2).",
+ "examples": ["1.4"]
+ },
+ "serialNumber": {
+ "type": "string",
+ "title": "BOM Serial Number",
+ "description": "Every BOM generated SHOULD have a unique serial number, even if the contents of the BOM have not changed over time. If specified, the serial number MUST conform to RFC-4122. Use of serial numbers are RECOMMENDED.",
+ "examples": ["urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79"],
+ "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
+ },
+ "version": {
+ "type": "integer",
+ "title": "BOM Version",
+ "description": "Whenever an existing BOM is modified, either manually or through automated processes, the version of the BOM SHOULD be incremented by 1. When a system is presented with multiple BOMs with identical serial numbers, the system SHOULD use the most recent version of the BOM. The default version is '1'.",
+ "default": 1,
+ "examples": [1]
+ },
+ "metadata": {
+ "$ref": "#/definitions/metadata",
+ "title": "BOM Metadata",
+ "description": "Provides additional information about a BOM."
+ },
+ "components": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/component"},
+ "uniqueItems": true,
+ "title": "Components",
+ "description": "A list of software and hardware components."
+ },
+ "services": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/service"},
+ "uniqueItems": true,
+ "title": "Services",
+ "description": "A list of services. This may include microservices, function-as-a-service, and other types of network or intra-process services."
+ },
+ "externalReferences": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/externalReference"},
+ "title": "External References",
+ "description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM."
+ },
+ "dependencies": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/dependency"},
+ "uniqueItems": true,
+ "title": "Dependencies",
+ "description": "Provides the ability to document dependency relationships."
+ },
+ "compositions": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/compositions"},
+ "uniqueItems": true,
+ "title": "Compositions",
+ "description": "Compositions describe constituent parts (including components, services, and dependency relationships) and their completeness."
+ },
+ "vulnerabilities": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/vulnerability"},
+ "uniqueItems": true,
+ "title": "Vulnerabilities",
+ "description": "Vulnerabilities identified in components or services."
+ },
+ "signature": {
+ "$ref": "#/definitions/signature",
+ "title": "Signature",
+ "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)."
+ }
+ },
+ "definitions": {
+ "refType": {
+ "$comment": "Identifier-DataType for interlinked elements.",
+ "type": "string"
+ },
+ "metadata": {
+ "type": "object",
+ "title": "BOM Metadata Object",
+ "additionalProperties": false,
+ "properties": {
+ "timestamp": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Timestamp",
+ "description": "The date and time (timestamp) when the BOM was created."
+ },
+ "tools": {
+ "type": "array",
+ "title": "Creation Tools",
+ "description": "The tool(s) used in the creation of the BOM.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/tool"}
+ },
+ "authors" :{
+ "type": "array",
+ "title": "Authors",
+ "description": "The person(s) who created the BOM. Authors are common in BOMs created through manual processes. BOMs created through automated means may not have authors.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/organizationalContact"}
+ },
+ "component": {
+ "title": "Component",
+ "description": "The component that the BOM describes.",
+ "$ref": "#/definitions/component"
+ },
+ "manufacture": {
+ "title": "Manufacture",
+ "description": "The organization that manufactured the component that the BOM describes.",
+ "$ref": "#/definitions/organizationalEntity"
+ },
+ "supplier": {
+ "title": "Supplier",
+ "description": " The organization that supplied the component that the BOM describes. The supplier may often be the manufacturer, but may also be a distributor or repackager.",
+ "$ref": "#/definitions/organizationalEntity"
+ },
+ "licenses": {
+ "type": "array",
+ "title": "BOM License(s)",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/licenseChoice"}
+ },
+ "properties": {
+ "type": "array",
+ "title": "Properties",
+ "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/property"}
+ }
+ }
+ },
+ "tool": {
+ "type": "object",
+ "title": "Tool",
+ "description": "Information about the automated or manual tool used",
+ "additionalProperties": false,
+ "properties": {
+ "vendor": {
+ "type": "string",
+ "title": "Tool Vendor",
+ "description": "The name of the vendor who created the tool"
+ },
+ "name": {
+ "type": "string",
+ "title": "Tool Name",
+ "description": "The name of the tool"
+ },
+ "version": {
+ "type": "string",
+ "title": "Tool Version",
+ "description": "The version of the tool"
+ },
+ "hashes": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/hash"},
+ "title": "Hashes",
+ "description": "The hashes of the tool (if applicable)."
+ },
+ "externalReferences": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/externalReference"},
+ "title": "External References",
+ "description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM."
+ }
+ }
+ },
+ "organizationalEntity": {
+ "type": "object",
+ "title": "Organizational Entity Object",
+ "description": "",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "The name of the organization",
+ "examples": [
+ "Example Inc."
+ ]
+ },
+ "url": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "iri-reference"
+ },
+ "title": "URL",
+ "description": "The URL of the organization. Multiple URLs are allowed.",
+ "examples": ["https://example.com"]
+ },
+ "contact": {
+ "type": "array",
+ "title": "Contact",
+ "description": "A contact at the organization. Multiple contacts are allowed.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/organizationalContact"}
+ }
+ }
+ },
+ "organizationalContact": {
+ "type": "object",
+ "title": "Organizational Contact Object",
+ "description": "",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "The name of a contact",
+ "examples": ["Contact name"]
+ },
+ "email": {
+ "type": "string",
+ "format": "idn-email",
+ "title": "Email Address",
+ "description": "The email address of the contact.",
+ "examples": ["firstname.lastname@example.com"]
+ },
+ "phone": {
+ "type": "string",
+ "title": "Phone",
+ "description": "The phone number of the contact.",
+ "examples": ["800-555-1212"]
+ }
+ }
+ },
+ "component": {
+ "type": "object",
+ "title": "Component Object",
+ "required": [
+ "type",
+ "name"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "application",
+ "framework",
+ "library",
+ "container",
+ "operating-system",
+ "device",
+ "firmware",
+ "file"
+ ],
+ "title": "Component Type",
+ "description": "Specifies the type of component. For software components, classify as application if no more specific appropriate classification is available or cannot be determined for the component. Types include:\n\n* __application__ = A software application. Refer to [https://en.wikipedia.org/wiki/Application_software](https://en.wikipedia.org/wiki/Application_software) for information about applications.\n* __framework__ = A software framework. Refer to [https://en.wikipedia.org/wiki/Software_framework](https://en.wikipedia.org/wiki/Software_framework) for information on how frameworks vary slightly from libraries.\n* __library__ = A software library. Refer to [https://en.wikipedia.org/wiki/Library_(computing)](https://en.wikipedia.org/wiki/Library_(computing))\n for information about libraries. All third-party and open source reusable components will likely be a library. If the library also has key features of a framework, then it should be classified as a framework. If not, or is unknown, then specifying library is RECOMMENDED.\n* __container__ = A packaging and/or runtime format, not specific to any particular technology, which isolates software inside the container from software outside of a container through virtualization technology. Refer to [https://en.wikipedia.org/wiki/OS-level_virtualization](https://en.wikipedia.org/wiki/OS-level_virtualization)\n* __operating-system__ = A software operating system without regard to deployment model (i.e. installed on physical hardware, virtual machine, image, etc) Refer to [https://en.wikipedia.org/wiki/Operating_system](https://en.wikipedia.org/wiki/Operating_system)\n* __device__ = A hardware device such as a processor, or chip-set. A hardware device containing firmware SHOULD include a component for the physical hardware itself, and another component of type 'firmware' or 'operating-system' (whichever is relevant), describing information about the software running on the device.\n* __firmware__ = A special type of software that provides low-level control over a devices hardware. Refer to [https://en.wikipedia.org/wiki/Firmware](https://en.wikipedia.org/wiki/Firmware)\n* __file__ = A computer file. Refer to [https://en.wikipedia.org/wiki/Computer_file](https://en.wikipedia.org/wiki/Computer_file) for information about files.",
+ "examples": ["library"]
+ },
+ "mime-type": {
+ "type": "string",
+ "title": "Mime-Type",
+ "description": "The optional mime-type of the component. When used on file components, the mime-type can provide additional context about the kind of file being represented such as an image, font, or executable. Some library or framework components may also have an associated mime-type.",
+ "examples": ["image/jpeg"],
+ "pattern": "^[-+a-z0-9.]+/[-+a-z0-9.]+$"
+ },
+ "bom-ref": {
+ "$ref": "#/definitions/refType",
+ "title": "BOM Reference",
+ "description": "An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be unique within the BOM."
+ },
+ "supplier": {
+ "title": "Component Supplier",
+ "description": " The organization that supplied the component. The supplier may often be the manufacturer, but may also be a distributor or repackager.",
+ "$ref": "#/definitions/organizationalEntity"
+ },
+ "author": {
+ "type": "string",
+ "title": "Component Author",
+ "description": "The person(s) or organization(s) that authored the component",
+ "examples": ["Acme Inc"]
+ },
+ "publisher": {
+ "type": "string",
+ "title": "Component Publisher",
+ "description": "The person(s) or organization(s) that published the component",
+ "examples": ["Acme Inc"]
+ },
+ "group": {
+ "type": "string",
+ "title": "Component Group",
+ "description": "The grouping name or identifier. This will often be a shortened, single name of the company or project that produced the component, or the source package or domain name. Whitespace and special characters should be avoided. Examples include: apache, org.apache.commons, and apache.org.",
+ "examples": ["com.acme"]
+ },
+ "name": {
+ "type": "string",
+ "title": "Component Name",
+ "description": "The name of the component. This will often be a shortened, single name of the component. Examples: commons-lang3 and jquery",
+ "examples": ["tomcat-catalina"]
+ },
+ "version": {
+ "type": "string",
+ "title": "Component Version",
+ "description": "The component version. The version should ideally comply with semantic versioning but is not enforced.",
+ "examples": ["9.0.14"]
+ },
+ "description": {
+ "type": "string",
+ "title": "Component Description",
+ "description": "Specifies a description for the component"
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "required",
+ "optional",
+ "excluded"
+ ],
+ "title": "Component Scope",
+ "description": "Specifies the scope of the component. If scope is not specified, 'required' scope SHOULD be assumed by the consumer of the BOM.",
+ "default": "required"
+ },
+ "hashes": {
+ "type": "array",
+ "title": "Component Hashes",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/hash"}
+ },
+ "licenses": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/licenseChoice"},
+ "title": "Component License(s)"
+ },
+ "copyright": {
+ "type": "string",
+ "title": "Component Copyright",
+ "description": "A copyright notice informing users of the underlying claims to copyright ownership in a published work.",
+ "examples": ["Acme Inc"]
+ },
+ "cpe": {
+ "type": "string",
+ "title": "Component Common Platform Enumeration (CPE)",
+ "description": "Specifies a well-formed CPE name that conforms to the CPE 2.2 or 2.3 specification. See [https://nvd.nist.gov/products/cpe](https://nvd.nist.gov/products/cpe)",
+ "examples": ["cpe:2.3:a:acme:component_framework:-:*:*:*:*:*:*:*"]
+ },
+ "purl": {
+ "type": "string",
+ "title": "Component Package URL (purl)",
+ "description": "Specifies the package-url (purl). The purl, if specified, MUST be valid and conform to the specification defined at: [https://github.com/package-url/purl-spec](https://github.com/package-url/purl-spec)",
+ "examples": ["pkg:maven/com.acme/tomcat-catalina@9.0.14?packaging=jar"]
+ },
+ "swid": {
+ "$ref": "#/definitions/swid",
+ "title": "SWID Tag",
+ "description": "Specifies metadata and content for [ISO-IEC 19770-2 Software Identification (SWID) Tags](https://www.iso.org/standard/65666.html)."
+ },
+ "modified": {
+ "type": "boolean",
+ "title": "Component Modified From Original",
+ "description": "[Deprecated] - DO NOT USE. This will be removed in a future version. Use the pedigree element instead to supply information on exactly how the component was modified. A boolean value indicating if the component has been modified from the original. A value of true indicates the component is a derivative of the original. A value of false indicates the component has not been modified from the original."
+ },
+ "pedigree": {
+ "type": "object",
+ "title": "Component Pedigree",
+ "description": "Component pedigree is a way to document complex supply chain scenarios where components are created, distributed, modified, redistributed, combined with other components, etc. Pedigree supports viewing this complex chain from the beginning, the end, or anywhere in the middle. It also provides a way to document variants where the exact relation may not be known.",
+ "additionalProperties": false,
+ "properties": {
+ "ancestors": {
+ "type": "array",
+ "title": "Ancestors",
+ "description": "Describes zero or more components in which a component is derived from. This is commonly used to describe forks from existing projects where the forked version contains a ancestor node containing the original component it was forked from. For example, Component A is the original component. Component B is the component being used and documented in the BOM. However, Component B contains a pedigree node with a single ancestor documenting Component A - the original component from which Component B is derived from.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/component"}
+ },
+ "descendants": {
+ "type": "array",
+ "title": "Descendants",
+ "description": "Descendants are the exact opposite of ancestors. This provides a way to document all forks (and their forks) of an original or root component.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/component"}
+ },
+ "variants": {
+ "type": "array",
+ "title": "Variants",
+ "description": "Variants describe relations where the relationship between the components are not known. For example, if Component A contains nearly identical code to Component B. They are both related, but it is unclear if one is derived from the other, or if they share a common ancestor.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/component"}
+ },
+ "commits": {
+ "type": "array",
+ "title": "Commits",
+ "description": "A list of zero or more commits which provide a trail describing how the component deviates from an ancestor, descendant, or variant.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/commit"}
+ },
+ "patches": {
+ "type": "array",
+ "title": "Patches",
+ "description": ">A list of zero or more patches describing how the component deviates from an ancestor, descendant, or variant. Patches may be complimentary to commits or may be used in place of commits.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/patch"}
+ },
+ "notes": {
+ "type": "string",
+ "title": "Notes",
+ "description": "Notes, observations, and other non-structured commentary describing the components pedigree."
+ }
+ }
+ },
+ "externalReferences": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/externalReference"},
+ "title": "External References",
+ "description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM."
+ },
+ "components": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/component"},
+ "uniqueItems": true,
+ "title": "Components",
+ "description": "A list of software and hardware components included in the parent component. This is not a dependency tree. It provides a way to specify a hierarchical representation of component assemblies, similar to system &#8594; subsystem &#8594; parts assembly in physical supply chains."
+ },
+ "evidence": {
+ "$ref": "#/definitions/componentEvidence",
+ "title": "Evidence",
+ "description": "Provides the ability to document evidence collected through various forms of extraction or analysis."
+ },
+ "releaseNotes": {
+ "$ref": "#/definitions/releaseNotes",
+ "title": "Release notes",
+ "description": "Specifies optional release notes."
+ },
+ "properties": {
+ "type": "array",
+ "title": "Properties",
+ "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/property"}
+ },
+ "signature": {
+ "$ref": "#/definitions/signature",
+ "title": "Signature",
+ "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)."
+ }
+ }
+ },
+ "swid": {
+ "type": "object",
+ "title": "SWID Tag",
+ "description": "Specifies metadata and content for ISO-IEC 19770-2 Software Identification (SWID) Tags.",
+ "required": [
+ "tagId",
+ "name"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "tagId": {
+ "type": "string",
+ "title": "Tag ID",
+ "description": "Maps to the tagId of a SoftwareIdentity."
+ },
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "Maps to the name of a SoftwareIdentity."
+ },
+ "version": {
+ "type": "string",
+ "title": "Version",
+ "default": "0.0",
+ "description": "Maps to the version of a SoftwareIdentity."
+ },
+ "tagVersion": {
+ "type": "integer",
+ "title": "Tag Version",
+ "default": 0,
+ "description": "Maps to the tagVersion of a SoftwareIdentity."
+ },
+ "patch": {
+ "type": "boolean",
+ "title": "Patch",
+ "default": false,
+ "description": "Maps to the patch of a SoftwareIdentity."
+ },
+ "text": {
+ "title": "Attachment text",
+ "description": "Specifies the metadata and content of the SWID tag.",
+ "$ref": "#/definitions/attachment"
+ },
+ "url": {
+ "type": "string",
+ "title": "URL",
+ "description": "The URL to the SWID file.",
+ "format": "iri-reference"
+ }
+ }
+ },
+ "attachment": {
+ "type": "object",
+ "title": "Attachment",
+ "description": "Specifies the metadata and content for an attachment.",
+ "required": [
+ "content"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "contentType": {
+ "type": "string",
+ "title": "Content-Type",
+ "description": "Specifies the content type of the text. Defaults to text/plain if not specified.",
+ "default": "text/plain"
+ },
+ "encoding": {
+ "type": "string",
+ "title": "Encoding",
+ "description": "Specifies the optional encoding the text is represented in.",
+ "enum": [
+ "base64"
+ ]
+ },
+ "content": {
+ "type": "string",
+ "title": "Attachment Text",
+ "description": "The attachment data. Proactive controls such as input validation and sanitization should be employed to prevent misuse of attachment text."
+ }
+ }
+ },
+ "hash": {
+ "type": "object",
+ "title": "Hash Objects",
+ "required": [
+ "alg",
+ "content"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "alg": {
+ "$ref": "#/definitions/hash-alg"
+ },
+ "content": {
+ "$ref": "#/definitions/hash-content"
+ }
+ }
+ },
+ "hash-alg": {
+ "type": "string",
+ "enum": [
+ "MD5",
+ "SHA-1",
+ "SHA-256",
+ "SHA-384",
+ "SHA-512",
+ "SHA3-256",
+ "SHA3-384",
+ "SHA3-512",
+ "BLAKE2b-256",
+ "BLAKE2b-384",
+ "BLAKE2b-512",
+ "BLAKE3"
+ ],
+ "title": "Hash Algorithm"
+ },
+ "hash-content": {
+ "type": "string",
+ "title": "Hash Content (value)",
+ "examples": ["3942447fac867ae5cdb3229b658f4d48"],
+ "pattern": "^([a-fA-F0-9]{32}|[a-fA-F0-9]{40}|[a-fA-F0-9]{64}|[a-fA-F0-9]{96}|[a-fA-F0-9]{128})$"
+ },
+ "license": {
+ "type": "object",
+ "title": "License Object",
+ "oneOf": [
+ {
+ "required": ["id"]
+ },
+ {
+ "required": ["name"]
+ }
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "spdx.schema.json",
+ "title": "License ID (SPDX)",
+ "description": "A valid SPDX license ID",
+ "examples": ["Apache-2.0"]
+ },
+ "name": {
+ "type": "string",
+ "title": "License Name",
+ "description": "If SPDX does not define the license used, this field may be used to provide the license name",
+ "examples": ["Acme Software License"]
+ },
+ "text": {
+ "title": "License text",
+ "description": "An optional way to include the textual content of a license.",
+ "$ref": "#/definitions/attachment"
+ },
+ "url": {
+ "type": "string",
+ "title": "License URL",
+ "description": "The URL to the license file. If specified, a 'license' externalReference should also be specified for completeness",
+ "examples": ["https://www.apache.org/licenses/LICENSE-2.0.txt"],
+ "format": "iri-reference"
+ }
+ }
+ },
+ "licenseChoice": {
+ "type": "object",
+ "title": "License(s)",
+ "additionalProperties": false,
+ "properties": {
+ "license": {
+ "$ref": "#/definitions/license"
+ },
+ "expression": {
+ "type": "string",
+ "title": "SPDX License Expression",
+ "examples": [
+ "Apache-2.0 AND (MIT OR GPL-2.0-only)",
+ "GPL-3.0-only WITH Classpath-exception-2.0"
+ ]
+ }
+ },
+ "oneOf":[
+ {
+ "required": ["license"]
+ },
+ {
+ "required": ["expression"]
+ }
+ ]
+ },
+ "commit": {
+ "type": "object",
+ "title": "Commit",
+ "description": "Specifies an individual commit",
+ "additionalProperties": false,
+ "properties": {
+ "uid": {
+ "type": "string",
+ "title": "UID",
+ "description": "A unique identifier of the commit. This may be version control specific. For example, Subversion uses revision numbers whereas git uses commit hashes."
+ },
+ "url": {
+ "type": "string",
+ "title": "URL",
+ "description": "The URL to the commit. This URL will typically point to a commit in a version control system.",
+ "format": "iri-reference"
+ },
+ "author": {
+ "title": "Author",
+ "description": "The author who created the changes in the commit",
+ "$ref": "#/definitions/identifiableAction"
+ },
+ "committer": {
+ "title": "Committer",
+ "description": "The person who committed or pushed the commit",
+ "$ref": "#/definitions/identifiableAction"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "The text description of the contents of the commit"
+ }
+ }
+ },
+ "patch": {
+ "type": "object",
+ "title": "Patch",
+ "description": "Specifies an individual patch",
+ "required": [
+ "type"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "unofficial",
+ "monkey",
+ "backport",
+ "cherry-pick"
+ ],
+ "title": "Type",
+ "description": "Specifies the purpose for the patch including the resolution of defects, security issues, or new behavior or functionality.\n\n* __unofficial__ = A patch which is not developed by the creators or maintainers of the software being patched. Refer to [https://en.wikipedia.org/wiki/Unofficial_patch](https://en.wikipedia.org/wiki/Unofficial_patch)\n* __monkey__ = A patch which dynamically modifies runtime behavior. Refer to [https://en.wikipedia.org/wiki/Monkey_patch](https://en.wikipedia.org/wiki/Monkey_patch)\n* __backport__ = A patch which takes code from a newer version of software and applies it to older versions of the same software. Refer to [https://en.wikipedia.org/wiki/Backporting](https://en.wikipedia.org/wiki/Backporting)\n* __cherry-pick__ = A patch created by selectively applying commits from other versions or branches of the same software."
+ },
+ "diff": {
+ "title": "Diff",
+ "description": "The patch file (or diff) that show changes. Refer to [https://en.wikipedia.org/wiki/Diff](https://en.wikipedia.org/wiki/Diff)",
+ "$ref": "#/definitions/diff"
+ },
+ "resolves": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/issue"},
+ "title": "Resolves",
+ "description": "A collection of issues the patch resolves"
+ }
+ }
+ },
+ "diff": {
+ "type": "object",
+ "title": "Diff",
+ "description": "The patch file (or diff) that show changes. Refer to https://en.wikipedia.org/wiki/Diff",
+ "additionalProperties": false,
+ "properties": {
+ "text": {
+ "title": "Diff text",
+ "description": "Specifies the optional text of the diff",
+ "$ref": "#/definitions/attachment"
+ },
+ "url": {
+ "type": "string",
+ "title": "URL",
+ "description": "Specifies the URL to the diff",
+ "format": "iri-reference"
+ }
+ }
+ },
+ "issue": {
+ "type": "object",
+ "title": "Diff",
+ "description": "An individual issue that has been resolved.",
+ "required": [
+ "type"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "defect",
+ "enhancement",
+ "security"
+ ],
+ "title": "Type",
+ "description": "Specifies the type of issue"
+ },
+ "id": {
+ "type": "string",
+ "title": "ID",
+ "description": "The identifier of the issue assigned by the source of the issue"
+ },
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "The name of the issue"
+ },
+ "description": {
+ "type": "string",
+ "title": "Description",
+ "description": "A description of the issue"
+ },
+ "source": {
+ "type": "object",
+ "title": "Source",
+ "description": "The source of the issue where it is documented",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "The name of the source. For example 'National Vulnerability Database', 'NVD', and 'Apache'"
+ },
+ "url": {
+ "type": "string",
+ "title": "URL",
+ "description": "The url of the issue documentation as provided by the source",
+ "format": "iri-reference"
+ }
+ }
+ },
+ "references": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "iri-reference"
+ },
+ "title": "References",
+ "description": "A collection of URL's for reference. Multiple URLs are allowed.",
+ "examples": ["https://example.com"]
+ }
+ }
+ },
+ "identifiableAction": {
+ "type": "object",
+ "title": "Identifiable Action",
+ "description": "Specifies an individual commit",
+ "additionalProperties": false,
+ "properties": {
+ "timestamp": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Timestamp",
+ "description": "The timestamp in which the action occurred"
+ },
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "The name of the individual who performed the action"
+ },
+ "email": {
+ "type": "string",
+ "format": "idn-email",
+ "title": "E-mail",
+ "description": "The email address of the individual who performed the action"
+ }
+ }
+ },
+ "externalReference": {
+ "type": "object",
+ "title": "External Reference",
+ "description": "Specifies an individual external reference",
+ "required": [
+ "url",
+ "type"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "url": {
+ "type": "string",
+ "title": "URL",
+ "description": "The URL to the external reference",
+ "format": "iri-reference"
+ },
+ "comment": {
+ "type": "string",
+ "title": "Comment",
+ "description": "An optional comment describing the external reference"
+ },
+ "type": {
+ "type": "string",
+ "title": "Type",
+ "description": "Specifies the type of external reference. There are built-in types to describe common references. If a type does not exist for the reference being referred to, use the \"other\" type.",
+ "enum": [
+ "vcs",
+ "issue-tracker",
+ "website",
+ "advisories",
+ "bom",
+ "mailing-list",
+ "social",
+ "chat",
+ "documentation",
+ "support",
+ "distribution",
+ "license",
+ "build-meta",
+ "build-system",
+ "release-notes",
+ "other"
+ ]
+ },
+ "hashes": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/hash"},
+ "title": "Hashes",
+ "description": "The hashes of the external reference (if applicable)."
+ }
+ }
+ },
+ "dependency": {
+ "type": "object",
+ "title": "Dependency",
+ "description": "Defines the direct dependencies of a component. Components that do not have their own dependencies MUST be declared as empty elements within the graph. Components that are not represented in the dependency graph MAY have unknown dependencies. It is RECOMMENDED that implementations assume this to be opaque and not an indicator of a component being dependency-free.",
+ "required": [
+ "ref"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "ref": {
+ "$ref": "#/definitions/refType",
+ "title": "Reference",
+ "description": "References a component by the components bom-ref attribute"
+ },
+ "dependsOn": {
+ "type": "array",
+ "uniqueItems": true,
+ "additionalItems": false,
+ "items": {
+ "$ref": "#/definitions/refType"
+ },
+ "title": "Depends On",
+ "description": "The bom-ref identifiers of the components that are dependencies of this dependency object."
+ }
+ }
+ },
+ "service": {
+ "type": "object",
+ "title": "Service Object",
+ "required": [
+ "name"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "bom-ref": {
+ "$ref": "#/definitions/refType",
+ "title": "BOM Reference",
+ "description": "An optional identifier which can be used to reference the service elsewhere in the BOM. Every bom-ref MUST be unique within the BOM."
+ },
+ "provider": {
+ "title": "Provider",
+ "description": "The organization that provides the service.",
+ "$ref": "#/definitions/organizationalEntity"
+ },
+ "group": {
+ "type": "string",
+ "title": "Service Group",
+ "description": "The grouping name, namespace, or identifier. This will often be a shortened, single name of the company or project that produced the service or domain name. Whitespace and special characters should be avoided.",
+ "examples": ["com.acme"]
+ },
+ "name": {
+ "type": "string",
+ "title": "Service Name",
+ "description": "The name of the service. This will often be a shortened, single name of the service.",
+ "examples": ["ticker-service"]
+ },
+ "version": {
+ "type": "string",
+ "title": "Service Version",
+ "description": "The service version.",
+ "examples": ["1.0.0"]
+ },
+ "description": {
+ "type": "string",
+ "title": "Service Description",
+ "description": "Specifies a description for the service"
+ },
+ "endpoints": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "iri-reference"
+ },
+ "title": "Endpoints",
+ "description": "The endpoint URIs of the service. Multiple endpoints are allowed.",
+ "examples": ["https://example.com/api/v1/ticker"]
+ },
+ "authenticated": {
+ "type": "boolean",
+ "title": "Authentication Required",
+ "description": "A boolean value indicating if the service requires authentication. A value of true indicates the service requires authentication prior to use. A value of false indicates the service does not require authentication."
+ },
+ "x-trust-boundary": {
+ "type": "boolean",
+ "title": "Crosses Trust Boundary",
+ "description": "A boolean value indicating if use of the service crosses a trust zone or boundary. A value of true indicates that by using the service, a trust boundary is crossed. A value of false indicates that by using the service, a trust boundary is not crossed."
+ },
+ "data": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/dataClassification"},
+ "title": "Data Classification",
+ "description": "Specifies the data classification."
+ },
+ "licenses": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/licenseChoice"},
+ "title": "Component License(s)"
+ },
+ "externalReferences": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/externalReference"},
+ "title": "External References",
+ "description": "External references provide a way to document systems, sites, and information that may be relevant but which are not included with the BOM."
+ },
+ "services": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/service"},
+ "uniqueItems": true,
+ "title": "Services",
+ "description": "A list of services included or deployed behind the parent service. This is not a dependency tree. It provides a way to specify a hierarchical representation of service assemblies."
+ },
+ "releaseNotes": {
+ "$ref": "#/definitions/releaseNotes",
+ "title": "Release notes",
+ "description": "Specifies optional release notes."
+ },
+ "properties": {
+ "type": "array",
+ "title": "Properties",
+ "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/property"}
+ },
+ "signature": {
+ "$ref": "#/definitions/signature",
+ "title": "Signature",
+ "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)."
+ }
+ }
+ },
+ "dataClassification": {
+ "type": "object",
+ "title": "Hash Objects",
+ "required": [
+ "flow",
+ "classification"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "flow": {
+ "$ref": "#/definitions/dataFlow",
+ "title": "Directional Flow",
+ "description": "Specifies the flow direction of the data. Direction is relative to the service. Inbound flow states that data enters the service. Outbound flow states that data leaves the service. Bi-directional states that data flows both ways, and unknown states that the direction is not known."
+ },
+ "classification": {
+ "type": "string",
+ "title": "Classification",
+ "description": "Data classification tags data according to its type, sensitivity, and value if altered, stolen, or destroyed."
+ }
+ }
+ },
+ "dataFlow": {
+ "type": "string",
+ "enum": [
+ "inbound",
+ "outbound",
+ "bi-directional",
+ "unknown"
+ ],
+ "title": "Data flow direction",
+ "description": "Specifies the flow direction of the data. Direction is relative to the service. Inbound flow states that data enters the service. Outbound flow states that data leaves the service. Bi-directional states that data flows both ways, and unknown states that the direction is not known."
+ },
+
+ "copyright": {
+ "type": "object",
+ "title": "Copyright",
+ "required": [
+ "text"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "text": {
+ "type": "string",
+ "title": "Copyright Text"
+ }
+ }
+ },
+
+ "componentEvidence": {
+ "type": "object",
+ "title": "Evidence",
+ "description": "Provides the ability to document evidence collected through various forms of extraction or analysis.",
+ "additionalProperties": false,
+ "properties": {
+ "licenses": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/licenseChoice"},
+ "title": "Component License(s)"
+ },
+ "copyright": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/copyright"},
+ "title": "Copyright"
+ }
+ }
+ },
+ "compositions": {
+ "type": "object",
+ "title": "Compositions",
+ "required": [
+ "aggregate"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "aggregate": {
+ "$ref": "#/definitions/aggregateType",
+ "title": "Aggregate",
+ "description": "Specifies an aggregate type that describe how complete a relationship is."
+ },
+ "assemblies": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string"
+ },
+ "title": "BOM references",
+ "description": "The bom-ref identifiers of the components or services being described. Assemblies refer to nested relationships whereby a constituent part may include other constituent parts. References do not cascade to child parts. References are explicit for the specified constituent part only."
+ },
+ "dependencies": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string"
+ },
+ "title": "BOM references",
+ "description": "The bom-ref identifiers of the components or services being described. Dependencies refer to a relationship whereby an independent constituent part requires another independent constituent part. References do not cascade to transitive dependencies. References are explicit for the specified dependency only."
+ },
+ "signature": {
+ "$ref": "#/definitions/signature",
+ "title": "Signature",
+ "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)."
+ }
+ }
+ },
+ "aggregateType": {
+ "type": "string",
+ "default": "not_specified",
+ "enum": [
+ "complete",
+ "incomplete",
+ "incomplete_first_party_only",
+ "incomplete_third_party_only",
+ "unknown",
+ "not_specified"
+ ]
+ },
+ "property": {
+ "type": "object",
+ "title": "Lightweight name-value pair",
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "The name of the property. Duplicate names are allowed, each potentially having a different value."
+ },
+ "value": {
+ "type": "string",
+ "title": "Value",
+ "description": "The value of the property."
+ }
+ }
+ },
+ "localeType": {
+ "type": "string",
+ "pattern": "^([a-z]{2})(-[A-Z]{2})?$",
+ "title": "Locale",
+ "description": "Defines a syntax for representing two character language code (ISO-639) followed by an optional two character country code. The language code MUST be lower case. If the country code is specified, the country code MUST be upper case. The language code and country code MUST be separated by a minus sign. Examples: en, en-US, fr, fr-CA"
+ },
+ "releaseType": {
+ "type": "string",
+ "examples": [
+ "major",
+ "minor",
+ "patch",
+ "pre-release",
+ "internal"
+ ],
+ "description": "The software versioning type. It is RECOMMENDED that the release type use one of 'major', 'minor', 'patch', 'pre-release', or 'internal'. Representing all possible software release types is not practical, so standardizing on the recommended values, whenever possible, is strongly encouraged.\n\n* __major__ = A major release may contain significant changes or may introduce breaking changes.\n* __minor__ = A minor release, also known as an update, may contain a smaller number of changes than major releases.\n* __patch__ = Patch releases are typically unplanned and may resolve defects or important security issues.\n* __pre-release__ = A pre-release may include alpha, beta, or release candidates and typically have limited support. They provide the ability to preview a release prior to its general availability.\n* __internal__ = Internal releases are not for public consumption and are intended to be used exclusively by the project or manufacturer that produced it."
+ },
+ "note": {
+ "type": "object",
+ "title": "Note",
+ "description": "A note containing the locale and content.",
+ "required": [
+ "text"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "locale": {
+ "$ref": "#/definitions/localeType",
+ "title": "Locale",
+ "description": "The ISO-639 (or higher) language code and optional ISO-3166 (or higher) country code. Examples include: \"en\", \"en-US\", \"fr\" and \"fr-CA\""
+ },
+ "text": {
+ "title": "Release note content",
+ "description": "Specifies the full content of the release note.",
+ "$ref": "#/definitions/attachment"
+ }
+ }
+ },
+ "releaseNotes": {
+ "type": "object",
+ "title": "Release notes",
+ "required": [
+ "type"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "$ref": "#/definitions/releaseType",
+ "title": "Type",
+ "description": "The software versioning type the release note describes."
+ },
+ "title": {
+ "type": "string",
+ "title": "Title",
+ "description": "The title of the release."
+ },
+ "featuredImage": {
+ "type": "string",
+ "format": "iri-reference",
+ "title": "Featured image",
+ "description": "The URL to an image that may be prominently displayed with the release note."
+ },
+ "socialImage": {
+ "type": "string",
+ "format": "iri-reference",
+ "title": "Social image",
+ "description": "The URL to an image that may be used in messaging on social media platforms."
+ },
+ "description": {
+ "type": "string",
+ "title": "Description",
+ "description": "A short description of the release."
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Timestamp",
+ "description": "The date and time (timestamp) when the release note was created."
+ },
+ "aliases": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "title": "Aliases",
+ "description": "One or more alternate names the release may be referred to. This may include unofficial terms used by development and marketing teams (e.g. code names)."
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "title": "Tags",
+ "description": "One or more tags that may aid in search or retrieval of the release note."
+ },
+ "resolves": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/issue"},
+ "title": "Resolves",
+ "description": "A collection of issues that have been resolved."
+ },
+ "notes": {
+ "type": "array",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/note"},
+ "title": "Notes",
+ "description": "Zero or more release notes containing the locale and content. Multiple note objects may be specified to support release notes in a wide variety of languages."
+ },
+ "properties": {
+ "type": "array",
+ "title": "Properties",
+ "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/property"}
+ }
+ }
+ },
+ "advisory": {
+ "type": "object",
+ "title": "Advisory",
+ "description": "Title and location where advisory information can be obtained. An advisory is a notification of a threat to a component, service, or system.",
+ "required": ["url"],
+ "additionalProperties": false,
+ "properties": {
+ "title": {
+ "type": "string",
+ "title": "Title",
+ "description": "An optional name of the advisory."
+ },
+ "url": {
+ "type": "string",
+ "title": "URL",
+ "format": "iri-reference",
+ "description": "Location where the advisory can be obtained."
+ }
+ }
+ },
+ "cwe": {
+ "type": "integer",
+ "minimum": 1,
+ "title": "CWE",
+ "description": "Integer representation of a Common Weaknesses Enumerations (CWE). For example 399 (of https://cwe.mitre.org/data/definitions/399.html)"
+ },
+ "severity": {
+ "type": "string",
+ "title": "Severity",
+ "description": "Textual representation of the severity of the vulnerability adopted by the analysis method. If the analysis method uses values other than what is provided, the user is expected to translate appropriately.",
+ "enum": [
+ "critical",
+ "high",
+ "medium",
+ "low",
+ "info",
+ "none",
+ "unknown"
+ ]
+ },
+ "scoreMethod": {
+ "type": "string",
+ "title": "Method",
+ "description": "Specifies the severity or risk scoring methodology or standard used.\n\n* CVSSv2 - [Common Vulnerability Scoring System v2](https://www.first.org/cvss/v2/)\n* CVSSv3 - [Common Vulnerability Scoring System v3](https://www.first.org/cvss/v3-0/)\n* CVSSv31 - [Common Vulnerability Scoring System v3.1](https://www.first.org/cvss/v3-1/)\n* OWASP - [OWASP Risk Rating Methodology](https://owasp.org/www-community/OWASP_Risk_Rating_Methodology)",
+ "enum": [
+ "CVSSv2",
+ "CVSSv3",
+ "CVSSv31",
+ "OWASP",
+ "other"
+ ]
+ },
+ "impactAnalysisState": {
+ "type": "string",
+ "title": "Impact Analysis State",
+ "description": "Declares the current state of an occurrence of a vulnerability, after automated or manual analysis. \n\n* __resolved__ = the vulnerability has been remediated. \n* __resolved\\_with\\_pedigree__ = the vulnerability has been remediated and evidence of the changes are provided in the affected components pedigree containing verifiable commit history and/or diff(s). \n* __exploitable__ = the vulnerability may be directly or indirectly exploitable. \n* __in\\_triage__ = the vulnerability is being investigated. \n* __false\\_positive__ = the vulnerability is not specific to the component or service and was falsely identified or associated. \n* __not\\_affected__ = the component or service is not affected by the vulnerability. Justification should be specified for all not_affected cases.",
+ "enum": [
+ "resolved",
+ "resolved_with_pedigree",
+ "exploitable",
+ "in_triage",
+ "false_positive",
+ "not_affected"
+ ]
+ },
+ "impactAnalysisJustification": {
+ "type": "string",
+ "title": "Impact Analysis Justification",
+ "description": "The rationale of why the impact analysis state was asserted. \n\n* __code\\_not\\_present__ = the code has been removed or tree-shaked. \n* __code\\_not\\_reachable__ = the vulnerable code is not invoked at runtime. \n* __requires\\_configuration__ = exploitability requires a configurable option to be set/unset. \n* __requires\\_dependency__ = exploitability requires a dependency that is not present. \n* __requires\\_environment__ = exploitability requires a certain environment which is not present. \n* __protected\\_by\\_compiler__ = exploitability requires a compiler flag to be set/unset. \n* __protected\\_at\\_runtime__ = exploits are prevented at runtime. \n* __protected\\_at\\_perimeter__ = attacks are blocked at physical, logical, or network perimeter. \n* __protected\\_by\\_mitigating\\_control__ = preventative measures have been implemented that reduce the likelihood and/or impact of the vulnerability.",
+ "enum": [
+ "code_not_present",
+ "code_not_reachable",
+ "requires_configuration",
+ "requires_dependency",
+ "requires_environment",
+ "protected_by_compiler",
+ "protected_at_runtime",
+ "protected_at_perimeter",
+ "protected_by_mitigating_control"
+ ]
+ },
+ "rating": {
+ "type": "object",
+ "title": "Rating",
+ "description": "Defines the severity or risk ratings of a vulnerability.",
+ "additionalProperties": false,
+ "properties": {
+ "source": {
+ "$ref": "#/definitions/vulnerabilitySource",
+ "description": "The source that calculated the severity or risk rating of the vulnerability."
+ },
+ "score": {
+ "type": "number",
+ "title": "Score",
+ "description": "The numerical score of the rating."
+ },
+ "severity": {
+ "$ref": "#/definitions/severity",
+ "description": "Textual representation of the severity that corresponds to the numerical score of the rating."
+ },
+ "method": {
+ "$ref": "#/definitions/scoreMethod"
+ },
+ "vector": {
+ "type": "string",
+ "title": "Vector",
+ "description": "Textual representation of the metric values used to score the vulnerability"
+ },
+ "justification": {
+ "type": "string",
+ "title": "Justification",
+ "description": "An optional reason for rating the vulnerability as it was"
+ }
+ }
+ },
+ "vulnerabilitySource": {
+ "type": "object",
+ "title": "Source",
+ "description": "The source of vulnerability information. This is often the organization that published the vulnerability.",
+ "additionalProperties": false,
+ "properties": {
+ "url": {
+ "type": "string",
+ "title": "URL",
+ "description": "The url of the vulnerability documentation as provided by the source.",
+ "examples": [
+ "https://nvd.nist.gov/vuln/detail/CVE-2021-39182"
+ ]
+ },
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "The name of the source.",
+ "examples": [
+ "NVD",
+ "National Vulnerability Database",
+ "OSS Index",
+ "VulnDB",
+ "GitHub Advisories"
+ ]
+ }
+ }
+ },
+ "vulnerability": {
+ "type": "object",
+ "title": "Vulnerability",
+ "description": "Defines a weakness in an component or service that could be exploited or triggered by a threat source.",
+ "additionalProperties": false,
+ "properties": {
+ "bom-ref": {
+ "$ref": "#/definitions/refType",
+ "title": "BOM Reference",
+ "description": "An optional identifier which can be used to reference the vulnerability elsewhere in the BOM. Every bom-ref MUST be unique within the BOM."
+ },
+ "id": {
+ "type": "string",
+ "title": "ID",
+ "description": "The identifier that uniquely identifies the vulnerability.",
+ "examples": [
+ "CVE-2021-39182",
+ "GHSA-35m5-8cvj-8783",
+ "SNYK-PYTHON-ENROCRYPT-1912876"
+ ]
+ },
+ "source": {
+ "$ref": "#/definitions/vulnerabilitySource",
+ "description": "The source that published the vulnerability."
+ },
+ "references": {
+ "type": "array",
+ "title": "References",
+ "description": "Zero or more pointers to vulnerabilities that are the equivalent of the vulnerability specified. Often times, the same vulnerability may exist in multiple sources of vulnerability intelligence, but have different identifiers. References provide a way to correlate vulnerabilities across multiple sources of vulnerability intelligence.",
+ "additionalItems": false,
+ "items": {
+ "required": [
+ "id",
+ "source"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "ID",
+ "description": "An identifier that uniquely identifies the vulnerability.",
+ "examples": [
+ "CVE-2021-39182",
+ "GHSA-35m5-8cvj-8783",
+ "SNYK-PYTHON-ENROCRYPT-1912876"
+ ]
+ },
+ "source": {
+ "$ref": "#/definitions/vulnerabilitySource",
+ "description": "The source that published the vulnerability."
+ }
+ }
+ }
+ },
+ "ratings": {
+ "type": "array",
+ "title": "Ratings",
+ "description": "List of vulnerability ratings",
+ "additionalItems": false,
+ "items": {
+ "$ref": "#/definitions/rating"
+ }
+ },
+ "cwes": {
+ "type": "array",
+ "title": "CWEs",
+ "description": "List of Common Weaknesses Enumerations (CWEs) codes that describes this vulnerability. For example 399 (of https://cwe.mitre.org/data/definitions/399.html)",
+ "examples": [399],
+ "additionalItems": false,
+ "items": {
+ "$ref": "#/definitions/cwe"
+ }
+ },
+ "description": {
+ "type": "string",
+ "title": "Description",
+ "description": "A description of the vulnerability as provided by the source."
+ },
+ "detail": {
+ "type": "string",
+ "title": "Details",
+ "description": "If available, an in-depth description of the vulnerability as provided by the source organization. Details often include examples, proof-of-concepts, and other information useful in understanding root cause."
+ },
+ "recommendation": {
+ "type": "string",
+ "title": "Details",
+ "description": "Recommendations of how the vulnerability can be remediated or mitigated."
+ },
+ "advisories": {
+ "type": "array",
+ "title": "Advisories",
+ "description": "Published advisories of the vulnerability if provided.",
+ "additionalItems": false,
+ "items": {
+ "$ref": "#/definitions/advisory"
+ }
+ },
+ "created": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Created",
+ "description": "The date and time (timestamp) when the vulnerability record was created in the vulnerability database."
+ },
+ "published": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Published",
+ "description": "The date and time (timestamp) when the vulnerability record was first published."
+ },
+ "updated": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Updated",
+ "description": "The date and time (timestamp) when the vulnerability record was last updated."
+ },
+ "credits": {
+ "type": "object",
+ "title": "Credits",
+ "description": "Individuals or organizations credited with the discovery of the vulnerability.",
+ "additionalProperties": false,
+ "properties": {
+ "organizations": {
+ "type": "array",
+ "title": "Organizations",
+ "description": "The organizations credited with vulnerability discovery.",
+ "additionalItems": false,
+ "items": {
+ "$ref": "#/definitions/organizationalEntity"
+ }
+ },
+ "individuals": {
+ "type": "array",
+ "title": "Individuals",
+ "description": "The individuals, not associated with organizations, that are credited with vulnerability discovery.",
+ "additionalItems": false,
+ "items": {
+ "$ref": "#/definitions/organizationalContact"
+ }
+ }
+ }
+ },
+ "tools": {
+ "type": "array",
+ "title": "Creation Tools",
+ "description": "The tool(s) used to identify, confirm, or score the vulnerability.",
+ "additionalItems": false,
+ "items": {"$ref": "#/definitions/tool"}
+ },
+ "analysis": {
+ "type": "object",
+ "title": "Impact Analysis",
+ "description": "An assessment of the impact and exploitability of the vulnerability.",
+ "additionalProperties": false,
+ "properties": {
+ "state": {
+ "$ref": "#/definitions/impactAnalysisState"
+ },
+ "justification": {
+ "$ref": "#/definitions/impactAnalysisJustification"
+ },
+ "response": {
+ "type": "array",
+ "title": "Response",
+ "description": "A response to the vulnerability by the manufacturer, supplier, or project responsible for the affected component or service. More than one response is allowed. Responses are strongly encouraged for vulnerabilities where the analysis state is exploitable.",
+ "additionalItems": false,
+ "items": {
+ "type": "string",
+ "enum": [
+ "can_not_fix",
+ "will_not_fix",
+ "update",
+ "rollback",
+ "workaround_available"
+ ]
+ }
+ },
+ "detail": {
+ "type": "string",
+ "title": "Detail",
+ "description": "Detailed description of the impact including methods used during assessment. If a vulnerability is not exploitable, this field should include specific details on why the component or service is not impacted by this vulnerability."
+ }
+ }
+ },
+ "affects": {
+ "type": "array",
+ "uniqueItems": true,
+ "additionalItems": false,
+ "items": {
+ "required": [
+ "ref"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "ref": {
+ "$ref": "#/definitions/refType",
+ "title": "Reference",
+ "description": "References a component or service by the objects bom-ref"
+ },
+ "versions": {
+ "type": "array",
+ "title": "Versions",
+ "description": "Zero or more individual versions or range of versions.",
+ "additionalItems": false,
+ "items": {
+ "oneOf": [
+ {
+ "required": ["version"]
+ },
+ {
+ "required": ["range"]
+ }
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "description": "A single version of a component or service.",
+ "$ref": "#/definitions/version"
+ },
+ "range": {
+ "description": "A version range specified in Package URL Version Range syntax (vers) which is defined at https://github.com/package-url/purl-spec/VERSION-RANGE-SPEC.rst",
+ "$ref": "#/definitions/version"
+ },
+ "status": {
+ "description": "The vulnerability status for the version or range of versions.",
+ "$ref": "#/definitions/affectedStatus",
+ "default": "affected"
+ }
+ }
+ }
+ }
+ }
+ },
+ "title": "Affects",
+ "description": "The components or services that are affected by the vulnerability."
+ },
+ "properties": {
+ "type": "array",
+ "title": "Properties",
+ "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.",
+ "additionalItems": false,
+ "items": {
+ "$ref": "#/definitions/property"
+ }
+ }
+ }
+ },
+ "affectedStatus": {
+ "description": "The vulnerability status of a given version or range of versions of a product. The statuses 'affected' and 'unaffected' indicate that the version is affected or unaffected by the vulnerability. The status 'unknown' indicates that it is unknown or unspecified whether the given version is affected. There can be many reasons for an 'unknown' status, including that an investigation has not been undertaken or that a vendor has not disclosed the status.",
+ "type": "string",
+ "enum": [
+ "affected",
+ "unaffected",
+ "unknown"
+ ]
+ },
+ "version": {
+ "description": "A single version of a component or service.",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "range": {
+ "description": "A version range specified in Package URL Version Range syntax (vers) which is defined at https://github.com/package-url/purl-spec/VERSION-RANGE-SPEC.rst",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "signature": {
+ "$ref": "jsf-0.82.schema.json#/definitions/signature",
+ "title": "Signature",
+ "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)."
+ }
+ }
+}
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index ba2a2f34d63..77170761448 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -22,7 +22,7 @@
.form-group
= f.label :shared_runners_text, _('Shared runners details'), class: 'label-bold'
= f.text_area :shared_runners_text, class: 'form-control gl-form-input', rows: 4
- .form-text.text-muted= _("Add a custom message with details about the instance's shared runners. The message is visible in group and project CI/CD settings, in the Runners section. Markdown is supported.")
+ .form-text.text-muted= _("Add a custom message with details about the instance's shared runners. The message is visible when you view runners for projects and groups. Markdown is supported.")
.form-group
= f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold'
= f.number_field :max_artifacts_size, class: 'form-control gl-form-input'
diff --git a/app/views/admin/application_settings/_default_branch.html.haml b/app/views/admin/application_settings/_default_branch.html.haml
index 4a06dcbc031..f9b1aa22b7a 100644
--- a/app/views/admin/application_settings/_default_branch.html.haml
+++ b/app/views/admin/application_settings/_default_branch.html.haml
@@ -14,4 +14,4 @@
= render_if_exists 'admin/application_settings/group_owners_can_manage_default_branch_protection_setting', form: f
- = f.submit _('Save changes'), class: 'gl-button btn-confirm'
+ = f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml
index 1af4d294c1b..30165139711 100644
--- a/app/views/admin/application_settings/_diff_limits.html.haml
+++ b/app/views/admin/application_settings/_diff_limits.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form', id: 'merge-request-settings' } do |f|
+= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form', id: 'merge-request-settings' } do |f|
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
@@ -29,4 +29,4 @@
= link_to sprite_icon('question-o'),
help_page_path('user/admin_area/diff_limits',
anchor: 'diff-limits-administration')
- = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
+ = 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 5dc2d322bb3..ff10e4a8f77 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -23,7 +23,7 @@
= link_to s_('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer'
.form-check
= f.fields_for :repository_storages_weighted, storage_weights do |storage_form|
- - Gitlab.config.repositories.storages.keys.each do |storage|
+ - Gitlab.config.repositories.storages.each_key do |storage|
= storage_form.text_field storage, class: 'form-text-input'
= storage_form.label storage, storage, class: 'label-bold form-check-label'
%br
diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml
index 1d6051a06ea..7781db29bab 100644
--- a/app/views/admin/application_settings/_runner_registrars_form.html.haml
+++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-runner-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.gl-form-group
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index 8684b909853..d500194b742 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -10,7 +10,7 @@
= html_escape(_('Configure %{link} to track events. %{link_start}Learn more.%{link_end}')) % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank', rel: 'noopener noreferrer').html_safe, link_start: link_start, link_end: '</a>'.html_safe }
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form', id: 'snowplow-settings' } do |f|
- = form_errors(@application_setting) if expanded
+ = form_errors(@application_setting, pajamas_alert: true) if expanded
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index c9ed2309cec..7326a63f8c2 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -3,7 +3,7 @@
- link_end = '</a>'.html_safe
= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
@@ -38,7 +38,7 @@
%p.gl-mb-3= s_('AdminSettings|Registration Features include:')
- email_from_gitlab_path = help_page_path('user/admin_area/email_from_gitlab')
- repo_size_limit_path = help_page_path('user/admin_area/settings/account_and_limit_settings', anchor: 'repository-size-limit')
- - restrict_ip_path = help_page_path('user/group/index', anchor: 'restrict-group-access-by-ip-address')
+ - restrict_ip_path = help_page_path('user/group/access_and_permissions', anchor: 'restrict-group-access-by-ip-address')
- email_from_gitlab_link = link_start % { url: email_from_gitlab_path }
- repo_size_limit_link = link_start % { url: repo_size_limit_path }
- restrict_ip_link = link_start % { url: restrict_ip_path }
diff --git a/app/views/admin/application_settings/_whats_new.html.haml b/app/views/admin/application_settings/_whats_new.html.haml
index 8ae912d24b7..d82bb1c94e4 100644
--- a/app/views/admin/application_settings/_whats_new.html.haml
+++ b/app/views/admin/application_settings/_whats_new.html.haml
@@ -1,7 +1,7 @@
= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-whats-new-settings'), html: { class: 'fieldset-form whats-new-settings' } do |f|
= form_errors(@application_setting, pajamas_alert: true)
- - whats_new_variants.keys.each do |variant|
+ - whats_new_variants.each_key do |variant|
.gl-mb-4
= f.gitlab_ui_radio_component :whats_new_variant, variant, whats_new_variants_label(variant), help_text: whats_new_variants_description(variant)
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index 224d9fbe953..349e1dfde5d 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -40,7 +40,7 @@
= 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_whitelist}.") % { favicon_extension_whitelist: favicon_extension_whitelist }
+ = _("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.")
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index 180871e48dd..b603c7e5f49 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -29,8 +29,6 @@
%th
= _('Callback URL')
%th
- = _('Clients')
- %th
= _('Trusted')
%th
= _('Confidential')
@@ -41,7 +39,6 @@
%tr{ id: "application_#{application.id}" }
%td= link_to application.name, admin_application_path(application)
%td= application.redirect_uri
- %td= @application_counts[application.id].to_i
%td= application.trusted? ? _('Yes'): _('No')
%td= application.confidential? ? _('Yes'): _('No')
%td= link_to 'Edit', edit_admin_application_path(application), class: 'gl-button btn btn-link'
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 88fbbb28201..271f89a6b08 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -58,7 +58,7 @@
= link_to(s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-default")
= c.footer do
.d-flex.align-items-center
- = link_to(s_('AdminArea|View latest users'), admin_users_path)
+ = link_to(s_('AdminArea|View latest users'), admin_users_path({ sort: 'created_desc' }))
= sprite_icon('chevron-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2')
.col-md-4.gl-mb-6
= render Pajamas::CardComponent.new(**component_params) do |c|
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 7bcc97914e5..a254690de72 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -9,7 +9,7 @@
= _('Update your group name, description, avatar, and visibility.')
= link_to _('Learn more about groups.'), help_page_path('user/group/index')
.col-lg-8
- = render 'shared/group_form', f: f
+ = render 'shared/groups/group_name_and_path_fields', f: f
= render 'shared/group_form_description', f: f
.form-group.gl-form-group{ role: 'group' }
= f.label :avatar, _("Group avatar"), class: 'gl-display-block col-form-label'
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index e8176e9f8bb..224afbff39a 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -15,6 +15,6 @@
= render 'shared/web_hooks/test_button', hook: @hook
= link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this webhook?'), confirm_btn_variant: 'danger' }
- %hr
+%hr
- = render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs }
+= render partial: 'shared/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs }
diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml
index d852e4a2463..3121cd2ae59 100644
--- a/app/views/admin/identities/_identity.html.haml
+++ b/app/views/admin/identities/_identity.html.haml
@@ -1,12 +1,24 @@
%tr
%td
- #{Gitlab::Auth::OAuth::Provider.label_for(identity.provider)} (#{identity.provider}) #{identity.saml_provider_id.present? ? "for #{link_to identity.saml_provider.group.path, identity.saml_provider.group} ID: #{identity.saml_provider_id}".html_safe : ""}
+ = label_for_identity_provider(identity)
+ %td{ data: { testid: provider_id_cell_testid(identity) } }
+ = provider_id(identity)
+ %td{ data: { testid: saml_group_cell_testid(identity) } }
+ = saml_group_link(identity)
%td
= identity.extern_uid
- %td
- = link_to edit_admin_user_identity_path(@user, identity), class: 'gl-button btn btn-sm btn-grouped' do
- = _("Edit")
- = link_to [:admin, @user, identity], method: :delete,
- class: 'gl-button btn btn-sm btn-danger',
- data: { confirm: _("Are you sure you want to remove this identity?") } do
- = _('Delete')
+ %td{ class: 'gl-py-0!' }
+ - button_classes = 'has-tooltip gl-my-3'
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ href: edit_admin_user_identity_path(@user, identity),
+ icon: 'pencil',
+ button_options: { title: _('Edit'),
+ 'aria-label' => _('Edit'),
+ class: button_classes } )
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ href: url_for([:admin, @user, identity]),
+ icon: 'remove',
+ button_options: { title: _('Delete'),
+ 'aria-label' => _('Delete identity'),
+ class: button_classes,
+ data: { method: :delete, confirm: _("Are you sure you want to remove this identity?") } } )
diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml
index 2bab802b2c1..1bb14969939 100644
--- a/app/views/admin/identities/index.html.haml
+++ b/app/views/admin/identities/index.html.haml
@@ -3,14 +3,20 @@
- page_title _("Identities"), @user.name, _("Users")
= render 'admin/users/head'
-- if @identities.present?
- .table-holder
- %table.table
- %thead
- %tr
- %th= _('Provider')
- %th= _('Identifier')
- %th
- = render @identities
-- else
- %h4= _('This user has no identities')
+%table.table.gl-table
+ %thead
+ %tr
+ %th{ class: 'gl-border-t-0!' }= _('Provider')
+ %th{ class: 'gl-border-t-0!' }= s_('Identity|Provider ID')
+ %th{ class: 'gl-border-t-0!' }= _('Group')
+ %th{ class: 'gl-border-t-0!' }= _('Identifier')
+ %th{ class: 'gl-border-t-0!' }= _('Actions')
+ - if identity_cells_to_render?(@identities, @user)
+ = render_if_exists partial: 'admin/identities/scim_identity', collection: scim_identities_collection(@user)
+ = render @identities
+ - else
+ %tbody
+ %tr
+ %td{ colspan: '5' }
+ .text-center.my-2
+ = _('This user has no identities')
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index ae8fed8964f..333c865629f 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -1,7 +1,7 @@
%li.label-list-item{ id: dom_id(label) }
= render "shared/label_row", label: label.present(issuable_subject: nil)
.label-actions-list
- = link_to edit_admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
+ = link_to edit_admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria: { label: _('Edit') } do
= sprite_icon('pencil')
= link_to admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary hover-red js-remove-label label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: _('Are you sure you want to delete this label?'), confirm_btn_variant: 'danger' }, aria: { label: _('Delete label') }, method: :delete, remote: true do
= sprite_icon('remove')
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 22351397b9a..a9dbcf4a6a5 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -1,4 +1,5 @@
- add_page_specific_style 'page_bundles/ci_status'
+- add_page_specific_style 'page_bundles/runner_details'
- title = "##{@runner.id} (#{@runner.short_sha})"
- breadcrumb_title title
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index ed453b42725..0ceff211806 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -45,5 +45,5 @@
= gl_tab_link_to _("SSH keys"), keys_admin_user_path(@user)
= gl_tab_link_to _("Identities"), admin_user_identities_path(@user)
- if impersonation_enabled?
- = gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user)
+ = gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user), data: { qa_selector: 'impersonation_tokens_tab' }
.gl-mb-3
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 9ef2599a2a6..02c468cebd7 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -6,10 +6,15 @@
= s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- is_group = !@group.nil?
+- is_project = !@project.nil?
#js-ci-project-variables{ data: { endpoint: save_endpoint,
+ is_project: is_project.to_s,
project_id: @project&.id || '',
- group: is_group.to_s,
+ project_full_path: @project&.full_path || '',
+ is_group: is_group.to_s,
+ group_id: @group&.id || '',
+ group_path: @group&.full_path,
maskable_regex: ci_variable_maskable_regex,
protected_by_default: ci_variable_protected_by_default?.to_s,
aws_logo_svg_path: image_path('aws_logo.svg'),
diff --git a/app/views/clusters/clusters/_gitlab_integration_form.html.haml b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
index b6d6dcdd7a9..e0f5a984529 100644
--- a/app/views/clusters/clusters/_gitlab_integration_form.html.haml
+++ b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
@@ -1,3 +1,3 @@
= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'js-cluster-details-form' } do |field|
- = form_errors(@cluster)
+ = form_errors(@cluster, pajamas_alert: true)
#js-cluster-details-form{ data: js_cluster_form_data(@cluster, can?(current_user, :update_cluster, @cluster)) }
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
index 3e0a8a4f88b..88da252f2bb 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
@@ -2,7 +2,7 @@
- help_path = local_assigns.fetch(:help_path)
- label = local_assigns.fetch(:label)
- last = local_assigns.fetch(:last, false)
-- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-flex-basis-0 gl-flex-grow-1 gl-min-w-0"]
+- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-flex-basis-third "]
- conditional_classes = [("gl-mr-5" unless last)]
= link_to help_path, class: classes + conditional_classes do
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index f0f1413831a..813c1cdbfe4 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -3,7 +3,7 @@
- if current_user.can_create_group?
.page-title-controls
- = link_to _("New group"), new_group_path, class: "gl-button btn btn-confirm", data: { testid: "new-group-button" }
+ = link_to _("New group"), new_group_path, class: "gl-button btn btn-confirm", data: { qa_selector: "new_group_button", testid: "new-group-button" }
.top-area
= gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do
diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml
index c90a9e7c672..1400ac9ca72 100644
--- a/app/views/devise/passwords/new.html.haml
+++ b/app/views/devise/passwords/new.html.haml
@@ -4,7 +4,7 @@
.devise-errors
= render "devise/shared/error_messages", resource: resource
.form-group.gl-px-5.gl-pt-5
- = f.label :email
+ = f.label :email, class: ("gl-mb-1" if Feature.enabled?(:restyle_login_page))
= f.email_field :email, class: "form-control gl-form-input", required: true, autocomplete: 'off', value: params[:user_email], autofocus: true, title: _('Please provide a valid email address.')
.form-text.text-muted
= _('Requires your primary GitLab email address.')
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index b6acb244384..b6719834358 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -11,5 +11,6 @@
= render 'devise/shared/signup_box',
url: registration_path(resource_name),
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_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 48b38861e6e..5a322a8f89b 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -3,7 +3,7 @@
= render_if_exists 'devise/sessions/new_base_user_login_label', form: f
= f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' }
.form-group.gl-px-5
- = f.label :password, class: 'label-bold'
+ = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
= f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
- if devise_mapping.rememberable?
.gl-px-5
@@ -23,3 +23,6 @@
.submit-container.move-submit-down.gl-px-5
= f.button _('Sign in'), type: :submit, class: "gl-button btn btn-block btn-confirm js-sign-in-button#{' js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' }
+ - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project)
+ .gl-px-5
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
diff --git a/app/views/devise/sessions/_new_base_user_login_label.html.haml b/app/views/devise/sessions/_new_base_user_login_label.html.haml
index 2aa66684cad..8a8b9f7a361 100644
--- a/app/views/devise/sessions/_new_base_user_login_label.html.haml
+++ b/app/views/devise/sessions/_new_base_user_login_label.html.haml
@@ -1 +1 @@
-= local_assigns[:form].label _('Username or email'), for: 'user_login', class: 'label-bold'
+= local_assigns[:form].label _('Username or email'), for: 'user_login', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index 9a09f6bee38..f4db9ea5637 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -18,10 +18,9 @@
= _('No authentication methods configured.')
- if allow_signup?
- %p.gl-mt-3
+ %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: { qa_selector: 'register_link' }
-
+ = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' }, class: "#{'gl-font-weight-bold' if Feature.enabled?(:restyle_login_page, @project)} "
- if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled?
.clearfix
= 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 77a2fda021f..fd20ff9a418 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -1,5 +1,5 @@
%div
- = render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication')
+ = 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_otp_enabled?
@@ -7,7 +7,7 @@
- resource_params = params[resource_name].presence || params
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
%div
- = f.label _('Two-Factor Authentication code'), name: :otp_attempt
+ = f.label _('Two-Factor Authentication code'), name: :otp_attempt, class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-mb-1' : ''
= f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' }
%p.form-text.text-muted.hint= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
.prepend-top-20
diff --git a/app/views/devise/shared/_footer.html.haml b/app/views/devise/shared/_footer.html.haml
index 5803107a8f7..10cfc07a719 100644
--- a/app/views/devise/shared/_footer.html.haml
+++ b/app/views/devise/shared/_footer.html.haml
@@ -5,4 +5,5 @@
= link_to _("Explore"), explore_root_path
= 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'
= footer_message
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 32b4a15517e..d67669352a6 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -1,20 +1,19 @@
- hide_remember_me = local_assigns.fetch(:hide_remember_me, false)
-
-.omniauth-container.gl-mt-5.gl-p-5
- %label.gl-font-weight-bold
+%div{ class: Feature.enabled?(:restyle_login_page, @project) ? 'omniauth-container gl-mt-5 gl-p-5 gl-text-center gl-w-90p gl-ml-auto gl-mr-auto' : 'omniauth-container gl-mt-5 gl-p-5' }
+ %label{ class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-font-weight-normal' : 'gl-font-weight-bold' }
= _('Sign in with')
- providers = enabled_button_based_providers
.gl-display-flex.gl-justify-content-between.gl-flex-wrap
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-w-full js-oauth-login #{qa_class_for_provider(provider)}", form: { class: 'gl-w-full gl-mb-3' } do
+ = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)} #{'gl-w-full' if Feature.disabled?(:restyle_login_page, @project)}", form: { class: 'gl-w-full gl-mb-3' } do
- if has_icon
= provider_image_tag(provider)
%span.gl-button-text
= label_for_provider(provider)
- unless hide_remember_me
%fieldset
- %label
+ %label{ class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-font-weight-normal' : '' }
= check_box_tag :remember_me, nil, false
%span
= _('Remember me')
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 1868cfa06e9..991af1eea0c 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -13,7 +13,7 @@
= invisible_captcha nonce: true, autocomplete: SecureRandom.alphanumeric(12)
.name.form-row
.col.form-group
- = f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold'
+ = f.label :first_name, _('First name'), for: 'new_user_first_name', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
= f.text_field :first_name,
class: 'form-control gl-form-input top js-block-emoji js-validate-length',
data: { max_length: max_first_name_length,
@@ -22,7 +22,7 @@
required: true,
title: _('This field is required.')
.col.form-group
- = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold'
+ = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
= f.text_field :last_name,
class: 'form-control gl-form-input top js-block-emoji js-validate-length',
data: { max_length: max_last_name_length,
@@ -31,7 +31,7 @@
required: true,
title: _('This field is required.')
.username.form-group
- = f.label :username, class: 'label-bold'
+ = f.label :username, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
= f.text_field :username,
class: 'form-control gl-form-input middle js-block-emoji js-validate-length js-validate-username',
data: signup_username_data_attributes,
@@ -42,18 +42,19 @@
%p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.')
%p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...')
.form-group
- = f.label :email, class: 'label-bold'
+ = f.label :email, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
= f.email_field :email,
value: @invite_email,
class: 'form-control gl-form-input middle js-validate-email',
data: { qa_selector: 'new_user_email_field' },
required: true,
title: _('Please provide a valid email address.')
- %p.gl-field-hint.text-secondary= _('We recommend a work email address.')
+ %p.validation-hint.gl-field-hint.text-secondary= _('We recommend a work email address.')
+ %p.validation-warning.gl-field-error-ignore.text-secondary.hide= _('This email address does not look right, are you sure you typed it correctly?')
-# This is used for providing entry to Jihu on email verification
= render_if_exists 'devise/shared/signup_email_additional_info'
.form-group.gl-mb-5#password-strength
- = f.label :password, class: 'label-bold'
+ = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
= f.password_field :password,
class: 'form-control gl-form-input bottom js-password-complexity-validation',
data: { qa_selector: 'new_user_password_field' },
@@ -69,6 +70,9 @@
= recaptcha_tags nonce: content_security_policy_nonce
.submit-container.gl-mt-5
= f.submit button_text, class: 'btn gl-button btn-confirm gl-display-block gl-w-full', data: { qa_selector: 'new_user_register_button' }
+ - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project)
+ .gl-pt-5
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
= render 'devise/shared/terms_of_service_notice', button_text: button_text
- if show_omniauth_providers && omniauth_providers_placement == :bottom
= render 'devise/shared/signup_omniauth_providers'
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index 84aabbe0efd..8dc22674243 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -1,11 +1,22 @@
- register_omniauth_params = Feature.enabled?(:update_oauth_registration_flow) ? { intent: :register } : {}
-
-%label.gl-font-weight-bold
- = _("Create an account using:")
-.gl-display-flex.gl-justify-content-between.gl-flex-wrap
- - providers.each do |provider|
- = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do
- - if provider_has_icon?(provider)
- = provider_image_tag(provider)
- %span.gl-button-text
- = label_for_provider(provider)
+- if Feature.enabled?(:restyle_login_page, @project)
+ .gl-text-center.gl-pt-5
+ %label.gl-font-weight-normal
+ = _("Create an account using:")
+ .gl-text-center.gl-w-90p.gl-ml-auto.gl-mr-auto
+ - providers.each do |provider|
+ = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do
+ - if provider_has_icon?(provider)
+ = provider_image_tag(provider)
+ %span.gl-button-text
+ = label_for_provider(provider)
+- else
+ %label.gl-font-weight-bold
+ = _("Create an account using:")
+ .gl-display-flex.gl-justify-content-between.gl-flex-wrap
+ - providers.each do |provider|
+ = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" 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_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml
index 30a54ab86a6..d2a47974e01 100644
--- a/app/views/devise/shared/_signup_omniauth_providers.haml
+++ b/app/views/devise/shared/_signup_omniauth_providers.haml
@@ -1,3 +1,4 @@
-.omniauth-divider.gl-display-flex.gl-align-items-center
- = _("or")
+- 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
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index 62d6ab36578..4a6b7fcfa84 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -21,8 +21,7 @@
%ul.content-list.event-commits
= render "events/commit", project: project, event: event
- - create_mr = event.new_ref? && create_mr_button?(from: project.default_branch, to: event.ref_name, source_project: project, target_project: project) && event.authored_by?(current_user)
- - create_mr_path = create_mr_path(from: project.default_branch, to: event.ref_name, source_project: project, target_project: project) if create_mr
+ - create_mr = event.new_ref? && create_mr_button_from_event?(event) && event.authored_by?(current_user)
- if event.commits_count > 1
%li.commits-stat
%span ... and #{pluralize(event.commits_count - 1, 'more commit')}.
@@ -41,9 +40,9 @@
- if create_mr
%span
or
- = link_to create_mr_path do
+ = link_to create_mr_path_from_push_event(event) do
create a merge request
- elsif create_mr
%li.commits-stat
- = link_to create_mr_path do
+ = link_to create_mr_path_from_push_event(event) do
Create merge request
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index bd893ca3162..2911e9991f2 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -33,7 +33,10 @@
.js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-mx-2 gl-mt-3 gl-vertical-align-top', no_flip: 'true' } }
- if can_create_subgroups
.gl-px-2.gl-sm-w-auto.gl-w-full
- = link_to _("New subgroup"), new_group_path(parent_id: @group.id), class: "btn btn-default gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_subgroup_button' }
+ = link_to _("New subgroup"),
+ new_group_path(parent_id: @group.id, anchor: 'create-group-pane'),
+ class: "btn btn-default gl-button gl-mt-3 gl-sm-w-auto gl-w-full",
+ data: { qa_selector: 'new_subgroup_button' }
- if can_create_projects
.gl-px-2.gl-sm-w-auto.gl-w-full
= link_to _("New project"), new_project_path(namespace_id: @group.id), class: "btn btn-confirm gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_project_button' }
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
index 0527d38159b..632884051f0 100644
--- a/app/views/groups/_new_group_fields.html.haml
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -1,31 +1,34 @@
+- parent = @group.parent
+- submit_label = parent ? s_('GroupsNew|Create subgroup') : s_('GroupsNew|Create group')
= form_errors(@group, pajamas_alert: true)
-= render 'shared/group_form', f: f, autofocus: true
+= render 'shared/groups/group_name_and_path_fields', f: f, autofocus: true, new_subgroup: !!parent
-.row
- .form-group.gl-form-group.col-sm-12
- %label.label-bold
- = _('Visibility level')
- %p
- = _('Who will be able to see this group?')
- = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer'
- = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false
-
-- if Gitlab.config.mattermost.enabled
+- unless parent
.row
- = render 'create_chat_team', f: f
+ .form-group.gl-form-group.col-sm-12
+ %label.label-bold
+ = _('Visibility level')
+ %p
+ = _('Who will be able to see this group?')
+ = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer'
+ = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false
-- unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers?
- = render 'personalize', f: f
+ - if Gitlab.config.mattermost.enabled
+ .row
+ = render 'create_chat_team', f: f
-.row.js-invite-members-section
- .col-sm-4
- = render_if_exists 'shared/groups/invite_members'
+ - unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers?
+ = render 'personalize', f: f
-- if captcha_required?
- .row.recaptcha
+ .row.js-invite-members-section
.col-sm-4
- = recaptcha_tags nonce: content_security_policy_nonce
+ = render_if_exists 'shared/groups/invite_members'
+
+ - if captcha_required?
+ .row.recaptcha
+ .col-sm-4
+ = recaptcha_tags nonce: content_security_policy_nonce
.row
.col-sm-12
- = f.submit _('Create group'), class: "btn gl-button btn-confirm"
+ = f.submit submit_label, class: "btn gl-button btn-confirm", data: { qa_selector: 'create_group_button' }
= link_to _('Cancel'), dashboard_groups_path, class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/groups/crm/contacts/index.html.haml b/app/views/groups/crm/contacts/index.html.haml
index 8a971e451a4..27f18ac1c57 100644
--- a/app/views/groups/crm/contacts/index.html.haml
+++ b/app/views/groups/crm/contacts/index.html.haml
@@ -5,4 +5,4 @@
= content_for :after_content do
#js-crm-form-portal
-#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group), group_id: @group.id, can_admin_crm_contact: can?(current_user, :admin_crm_contact, @group).to_s, base_path: group_crm_contacts_path(@group) } }
+#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group), group_id: @group.id, can_admin_crm_contact: can?(current_user, :admin_crm_contact, @group).to_s, base_path: group_crm_contacts_path(@group), text_query: params[:search] } }
diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml
index 082f637e854..178d8980ab8 100644
--- a/app/views/groups/dependency_proxies/show.html.haml
+++ b/app/views/groups/dependency_proxies/show.html.haml
@@ -2,4 +2,6 @@
- @content_class = "limit-container-width" unless fluid_layout
#js-dependency-proxy{ data: { group_path: @group.full_path,
- no_manifests_illustration: image_path('illustrations/docker-empty-state.svg'), group_id: @group.id } }
+ no_manifests_illustration: image_path('illustrations/docker-empty-state.svg'),
+ group_id: @group.id,
+ can_clear_cache: can?(current_user, :admin_group, @group).to_s } }
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index 7da5a9e9664..9f13ad301bb 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
+= gitlab_ui_form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
= form_errors(@milestone, pajamas_alert: true)
.form-group.row
.col-form-label.col-sm-2
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 3fb2b88dadd..8384c906eeb 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -6,7 +6,8 @@
.group-edit-container.gl-mt-5
- .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s }.merge(verification_for_group_creation_data) }
+ .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s }.merge(subgroup_creation_data(@group),
+ verification_for_group_creation_data) }
.row{ 'v-cloak': true }
#create-group-pane.tab-pane
diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml
index 65e797a2e82..2fc314cc37f 100644
--- a/app/views/groups/runners/show.html.haml
+++ b/app/views/groups/runners/show.html.haml
@@ -1,10 +1,8 @@
-- add_to_breadcrumbs _('Runners'), group_runners_path(@group)
+- add_page_specific_style 'page_bundles/runner_details'
-- if Feature.enabled?(:group_runner_view_ui, @group)
- - title = "##{@runner.id} (#{@runner.short_sha})"
- - breadcrumb_title title
- - page_title title
+- add_to_breadcrumbs _('Runners'), group_runners_path(@group)
+- title = "##{@runner.id} (#{@runner.short_sha})"
+- breadcrumb_title title
+- page_title title
- #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} }
-- else
- = render 'shared/runners/runner_details', runner: @runner
+#js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} }
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index 3624ff2bcb3..8fa8eeea3cd 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -8,7 +8,7 @@
.form-group
%p
= s_("GroupSettings|Changing a group's URL can have unintended side effects.")
- = link_to _('Learn more.'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/group/manage', anchor: 'change-a-groups-path'), target: '_blank', rel: 'noopener noreferrer'
.input-group.gl-field-error-anchor
.group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' }
diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml
index 16ea96f0b08..ac6c5d1842c 100644
--- a/app/views/groups/settings/access_tokens/index.html.haml
+++ b/app/views/groups/settings/access_tokens/index.html.haml
@@ -37,7 +37,7 @@
token: @resource_access_token,
scopes: @scopes,
access_levels: GroupMember.access_level_roles,
- default_access_level: Gitlab::Access::MAINTAINER,
+ default_access_level: Gitlab::Access::GUEST,
prefix: :resource_access_token,
help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token')
diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
index c294df5ac62..3691c470ea7 100644
--- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
@@ -2,7 +2,7 @@
= form_errors(group, pajamas_alert: true)
%fieldset
.form-group
- .card.auto-devops-card
+ .card.gl-mb-3
.card-body
- learn_more_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
- help_text = s_('GroupSettings|The Auto DevOps pipeline runs if no alternative CI configuration file is found.')
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 3b117022d1e..88352ea351c 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -32,7 +32,7 @@
= expanded ? _('Collapse') : _('Expand')
%p
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
- = link_to s_('How do I configure runners?'), help_page_path('ci/runners/index'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('What is GitLab Runner?'), 'https://docs.gitlab.com/runner/', target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'groups/runners/settings'
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index 08f7cd57732..35fd5d6eda6 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -13,9 +13,9 @@
can_select_namespace: current_user.can_select_namespace?.to_s,
ci_cd_only: has_ci_cd_only_params?.to_s,
namespaces_path: import_available_namespaces_path,
- repos_path: url_for([:status, :import, provider, format: :json]),
- jobs_path: url_for([:realtime_changes, :import, provider, format: :json]),
+ repos_path: url_for([:status, :import, provider, { format: :json }]),
+ jobs_path: url_for([:realtime_changes, :import, provider, { format: :json }]),
default_target_namespace: default_namespace_path,
- import_path: url_for([:import, provider, format: :json]),
+ import_path: url_for([:import, provider, { format: :json }]),
filterable: filterable.to_s,
paginatable: paginatable.to_s }.merge(extra_data) }
diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml
index 25af51ca9cb..f5c823465be 100644
--- a/app/views/layouts/_google_tag_manager_head.html.haml
+++ b/app/views/layouts/_google_tag_manager_head.html.haml
@@ -1,4 +1,23 @@
- return unless google_tag_manager_enabled?
+- if Feature.enabled?(:gitlab_gtm_datalayer, type: :ops)
+ = javascript_tag do
+ :plain
+ window.dataLayer = window.dataLayer || [];
+ function gtag(){dataLayer.push(arguments);}
+
+ gtag('consent', 'default', {
+ 'analytics_storage': 'denied',
+ 'ad_storage': 'denied',
+ 'functionality_storage': 'denied',
+ 'region': ['EU', 'UK', 'PE', 'RU'],
+ 'wait_for_update': 500
+ });
+ gtag('consent', 'default', {
+ 'analytics_storage': 'granted',
+ 'ad_storage': 'granted',
+ 'functionality_storage': 'granted',
+ 'wait_for_update': 500
+ });
- if Feature.enabled?(:gtm_nonce, type: :ops)
= javascript_tag nonce: content_security_policy_nonce do
diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index 0dad6d367c3..22cc8027202 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -12,6 +12,9 @@
window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json}
gl = window.gl || {};
- gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new(namespace: namespace,
- project: @project, user: current_user).to_context.to_json.to_json}
+ gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new(
+ namespace: namespace,
+ project: @project,
+ user: current_user
+ ).to_context.to_json.to_json}
gl.snowplowPseudonymizedPageUrl = #{masked_page_url(group: namespace, project: @project).to_json};
diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml
new file mode 100644
index 00000000000..ec12395a5d4
--- /dev/null
+++ b/app/views/layouts/component_preview.html.haml
@@ -0,0 +1,5 @@
+%head
+ = stylesheet_link_tag "application"
+ = stylesheet_link_tag "application_utilities"
+%body{ style: "background-color: #{params.dig(:lookbook, :display, :bg_color) || 'white'}" }
+ .container.gl-mt-6= yield
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index cb1a2a8c690..87a8b6dd870 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -4,39 +4,61 @@
%body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } }
= header_message
= render "layouts/init_client_detection_flags"
- .page-wrap
- = render "layouts/header/empty"
- .login-page-broadcast
- = render "layouts/broadcast"
- .container.navless-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 Feature.enabled?(:restyle_login_page, @project)
+ .page-wrap.borderless
+ .login-page-broadcast
+ = render "layouts/broadcast"
+ .container.navless-container
+ .content
+ = render "layouts/flash"
+ .mt-3
+ .col-sm-12.gl-text-center
+ %img.gl-w-10{ :alt => _("GitLab Logo"), :src => image_path('logo.svg') }
+ %h1.mb-3.gl-font-size-h2
+ = current_appearance&.title.presence || _('GitLab')
- if current_appearance&.description?
= brand_text
- - else
- %h3.gl-sm-mt-0
- = _('A complete DevOps platform')
+ .mb-3
+ .gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar
+ = yield
+ = render_if_exists 'layouts/devise_help_text'
- %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.')
+ = render 'devise/shared/footer', footer_message: footer_message
+ - else
+ .page-wrap
+ = render "layouts/header/empty"
+ .login-page-broadcast
+ = render "layouts/broadcast"
+ .container.navless-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 current_appearance&.description?
+ = brand_text
+ - else
+ %h3.gl-sm-mt-0
+ = _('A complete DevOps platform')
- - if Gitlab::CurrentSettings.sign_in_text.present?
- = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
+ %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.')
- = render_if_exists 'layouts/devise_help_text'
+ %p
+ = _('This is a self-managed instance of GitLab.')
- .col-md-6.order-1.new-session-forms-container{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' }
- = yield
+ - if Gitlab::CurrentSettings.sign_in_text.present?
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
- = render 'devise/shared/footer', footer_message: footer_message
+ = render_if_exists 'layouts/devise_help_text'
+
+ .col-md-6.order-1.new-session-forms-container{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' }
+ = yield
+
+ = render 'devise/shared/footer', footer_message: footer_message
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 1c2ab8cf008..67809cbc608 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -6,8 +6,8 @@
- @left_sidebar = true
- content_for :flash_message do
- = render "layouts/header/storage_enforcement_banner", namespace: @group
- = dispensable_render_if_exists "shared/namespace_storage_limit_alert"
+ = render "layouts/header/storage_enforcement_banner", context: @group
+ = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @group
- content_for :page_specific_javascripts do
- if current_user
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 11dd8ba6c08..353f07c07c5 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -12,7 +12,7 @@
- if can?(current_user, :update_user_status, current_user)
%li
%button.gl-button.btn.btn-link.menu-item.js-set-status-modal-trigger{ type: 'button' }
- - if show_status_emoji?(current_user.status) || user_status_set_to_busy?(current_user.status)
+ - if current_user.status&.busy? || current_user.status&.customized?
= s_('SetStatusModal|Edit status')
- else
= s_('SetStatusModal|Set status')
diff --git a/app/views/layouts/header/_current_user_dropdown_item.html.haml b/app/views/layouts/header/_current_user_dropdown_item.html.haml
index 06c597b4932..3fded43ee4f 100644
--- a/app/views/layouts/header/_current_user_dropdown_item.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown_item.html.haml
@@ -1,11 +1,11 @@
.gl-font-weight-bold
= current_user.name
- - if current_user&.status && user_status_set_to_busy?(current_user.status)
+ - if current_user.status&.busy?
%span.gl-font-weight-normal.gl-text-gray-500= s_("UserProfile|(Busy)")
= current_user.to_reference
- if current_user.status
.user-status.d-flex.align-items-center.gl-mt-2.gl-mr-0.gl-font-sm.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } }
- - if show_status_emoji?(current_user.status)
+ - if current_user.status.customized?
.user-status-emoji.d-flex.align-items-center
= emoji_icon current_user.status.emoji
%span.user-status-message.str-truncated
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 911cb85de53..783733bb313 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -8,7 +8,7 @@
.title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0
.title
%span.gl-sr-only GitLab
- = link_to root_path, title: _('Dashboard'), id: 'logo', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do
+ = link_to root_path, title: _('Dashboard'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do
= brand_header_logo
- if Gitlab.com_and_canary?
= link_to Gitlab::Saas.canary_toggle_com_url, class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer' do
@@ -57,8 +57,8 @@
= number_with_delimiter(issues_count)
- if header_link?(:merge_requests)
= nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do
- - top_level_link = current_user.mr_attention_requests_enabled? ? attention_requested_mrs_dashboard_path : assigned_mrs_dashboard_path
- = link_to top_level_link, class: 'dashboard-shortcuts-merge_requests', title: _('Merge requests'), aria: { label: _('Merge requests') },
+ - top_level_link = assigned_mrs_dashboard_path
+ = link_to top_level_link, class: 'dashboard-shortcuts-merge_requests has-tooltip', title: _('Merge requests'), aria: { label: _('Merge requests') },
data: { qa_selector: 'merge_requests_shortcut_button',
toggle: "dropdown",
placement: 'bottom',
@@ -74,27 +74,14 @@
%ul
%li.dropdown-header
= _('Merge requests')
- - if current_user.mr_attention_requests_enabled?
- %li#js-need-attention-nav
- #js-need-attention-nav-onboarding
- = link_to attention_requested_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
- = _('Need your attention')
- = gl_badge_tag user_merge_requests_counts[:attention_requested_count], { size: :sm, variant: user_merge_requests_counts[:attention_requested_count] == 0 ? :neutral : :warning }, { class: 'merge-request-badge gl-ml-auto js-attention-count' }
- %li.divider
%li
= link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
- - if current_user.mr_attention_requests_enabled?
- = _('Assignee')
- - else
- = _('Assigned to you')
+ = _('Assigned to you')
= gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-assigned-mr-count gl-ml-auto" }) do
= user_merge_requests_counts[:assigned]
%li
= link_to reviewer_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do
- - if current_user.mr_attention_requests_enabled?
- = _('Reviewer')
- - else
- = _('Review requests for you')
+ = _('Review requests for you')
= gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-reviewer-mr-count gl-ml-auto" }) do
= user_merge_requests_counts[:review_requested]
- if header_link?(:todos)
diff --git a/app/views/layouts/header/_storage_enforcement_banner.html.haml b/app/views/layouts/header/_storage_enforcement_banner.html.haml
index c117f22a402..1f7060f8235 100644
--- a/app/views/layouts/header/_storage_enforcement_banner.html.haml
+++ b/app/views/layouts/header/_storage_enforcement_banner.html.haml
@@ -1,14 +1,15 @@
- return unless current_user
-- namespace = local_assigns.fetch(:namespace)
-- banner_info = storage_enforcement_banner_info(namespace)
+- context = local_assigns.fetch(:context)
+- banner_info = storage_enforcement_banner_info(context)
- return unless banner_info.present?
= render Pajamas::AlertComponent.new(variant: :warning,
alert_options: { class: 'js-storage-enforcement-banner',
data: { feature_id: banner_info[:callouts_feature_name],
dismiss_endpoint: banner_info[:callouts_path],
- group_id: namespace.id,
+ group_id: banner_info[:namespace_id],
defer_links: "true" }}) do |c|
= c.body do
- = banner_info[:text]
- = banner_info[:learn_more_link]
+ %p= banner_info[:text_paragraph_1]
+ %p= banner_info[:text_paragraph_2]
+ %p= banner_info[:text_paragraph_3]
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 02565a8f573..f3f79750643 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -7,14 +7,14 @@
%span.sidebar-context-title
= _('Admin Area')
%ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } }
- = nav_link(controller: %w(dashboard admin admin/projects users groups admin/topics jobs runners gitaly_servers cohorts), html_options: {class: 'home'}) do
+ = nav_link(controller: %w[dashboard admin admin/projects users groups admin/topics jobs runners gitaly_servers cohorts], html_options: {class: 'home'}) do
= link_to admin_root_path, class: 'has-sub-items' do
.nav-icon-container
= sprite_icon('overview')
%span.nav-item-name
= _('Overview')
%ul.sidebar-sub-level-items
- = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts), html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(controller: %w[dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts], html_options: { class: "fly-out-top-item" } ) do
= link_to admin_root_path do
%strong.fly-out-top-item-name
= _('Overview')
@@ -27,7 +27,7 @@
= link_to admin_projects_path, title: _('Projects') do
%span
= _('Projects')
- = nav_link(controller: %w(users cohorts)) do
+ = nav_link(controller: %w[users cohorts]) do
= link_to admin_users_path, title: _('Users'), data: { qa_selector: 'users_overview_link' } do
%span
= _('Users')
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index 16c0c00ad3f..cf1f84790a2 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -3,7 +3,7 @@
.context-header
= link_to profile_path, title: _('Profile Settings'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
%span{ class: ['avatar-container', 'settings-avatar', 's32'] }
- = image_tag avatar_icon_for_user(current_user, 32), class: ['avatar', 'avatar-tile', 'js-sidebar-user-avatar', 's32'], alt: current_user.name, data: { testid: 'sidebar-user-avatar' }
+ = image_tag avatar_icon_for_user(current_user, 32), class: ['avatar', 'avatar-tile', 'js-sidebar-user-avatar', 's32', 'gl-rounded-full!'], alt: current_user.name, data: { testid: 'sidebar-user-avatar' }
%span.sidebar-context-title= _('User Settings')
%ul.sidebar-top-level-items
= nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml
index 322a77116c8..1ec839ef642 100644
--- a/app/views/layouts/profile.html.haml
+++ b/app/views/layouts/profile.html.haml
@@ -7,6 +7,7 @@
- enable_search_settings locals: { container_class: 'gl-my-5' }
- content_for :flash_message do
- = render "layouts/header/storage_enforcement_banner", namespace: current_user.namespace
+ = render "layouts/header/storage_enforcement_banner", context: current_user.namespace
+ = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: current_user.namespace
= render template: "layouts/application"
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 86b4c4eabe3..9503e874fd0 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -8,8 +8,8 @@
- @content_class = [@content_class, project_classes(@project)].compact.join(" ")
- content_for :flash_message do
- = render "layouts/header/storage_enforcement_banner", namespace: @project.namespace
- = dispensable_render_if_exists "shared/namespace_storage_limit_alert"
+ = render "layouts/header/storage_enforcement_banner", context: @project
+ = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @project
- content_for :project_javascripts do
- project = @target_project || @project
diff --git a/app/views/notify/approved_merge_request_email.text.haml b/app/views/notify/approved_merge_request_email.text.haml
index 476da7f9af7..ab79a96c4ed 100644
--- a/app/views/notify/approved_merge_request_email.text.haml
+++ b/app/views/notify/approved_merge_request_email.text.haml
@@ -2,7 +2,7 @@ Merge request #{@merge_request.to_reference} was approved by #{sanitize_name(@ap
Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
-= merge_path_description(@merge_request, 'to')
+= merge_path_description(@merge_request)
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
diff --git a/app/views/notify/attention_requested_merge_request_email.html.haml b/app/views/notify/attention_requested_merge_request_email.html.haml
deleted file mode 100644
index af42f180ae7..00000000000
--- a/app/views/notify/attention_requested_merge_request_email.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-%p
- #{sanitize_name(@updated_by.name)} requested your attention on #{merge_request_reference_link(@merge_request)}.
diff --git a/app/views/notify/attention_requested_merge_request_email.text.erb b/app/views/notify/attention_requested_merge_request_email.text.erb
deleted file mode 100644
index 97b1d4a824b..00000000000
--- a/app/views/notify/attention_requested_merge_request_email.text.erb
+++ /dev/null
@@ -1 +0,0 @@
-<%= sanitize_name(@updated_by.name) %> requested your attention on <%= merge_request_reference_link(@merge_request) %>.
diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml
index 942e771261a..c6e38f5fc3d 100644
--- a/app/views/notify/closed_merge_request_email.text.haml
+++ b/app/views/notify/closed_merge_request_email.text.haml
@@ -2,7 +2,7 @@ Merge request #{@merge_request.to_reference} was closed by #{sanitize_name(@upda
Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
-= merge_path_description(@merge_request, 'to')
+= merge_path_description(@merge_request)
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
diff --git a/app/views/notify/member_access_requested_email.html.haml b/app/views/notify/member_access_requested_email.html.haml
index 43f25af3dba..c3b8d586425 100644
--- a/app/views/notify/member_access_requested_email.html.haml
+++ b/app/views/notify/member_access_requested_email.html.haml
@@ -1,6 +1,5 @@
%tr
%td.text-content
%p
- #{link_to member.user.name, member.user, class: :highlight} requested #{content_tag :span, member.human_access, class: :highlight}
- access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members]), class: :highlight} #{member_source.model_name.singular}.
+ = member_request_access_link member
diff --git a/app/views/notify/member_invite_accepted_email.html.haml b/app/views/notify/member_invite_accepted_email.html.haml
index 0abb79000e0..6a2fda22c36 100644
--- a/app/views/notify/member_invite_accepted_email.html.haml
+++ b/app/views/notify/member_invite_accepted_email.html.haml
@@ -1,8 +1,7 @@
%tr
%td.text-content
%p
- #{content_tag :span, member.invite_email, class: :highlight}, now known as
- #{link_to member.user.name, user_url(member.user)},
- has accepted your invitation to join the
- #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular}.
-
+ = s_('Notify|%{invite_email}, now known as %{user_name}, has accepted your invitation to join the %{target_name} %{target_model_name}.').html_safe % { invite_email: content_tag(:span, member.invite_email, class: :highlight),
+ user_name: link_to(member.user.name, user_url(member.user)),
+ target_name: link_to(member_source.human_name, member_source.web_url, class: :highlight),
+ target_model_name: member_source.model_name.singular }
diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb
index c824533eac2..c694bb96f3c 100644
--- a/app/views/notify/member_invite_accepted_email.text.erb
+++ b/app/views/notify/member_invite_accepted_email.text.erb
@@ -1,3 +1,8 @@
-<%= member.invite_email %>, now known as <%= sanitize_name(member.user.name) %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
+<%= s_('Notify|%{invite_email}, now known as %{user_name}, has accepted your invitation to join the %{target_name} %{target_model_name}.') % {
+ invite_email: member.invite_email,
+ user_name: member.user.name,
+ target_name: member_source.human_name,
+ target_model_name: member_source.model_name.singular }
+%>
<%= member_source.web_url %>
diff --git a/app/views/notify/member_invite_declined_email.html.haml b/app/views/notify/member_invite_declined_email.html.haml
index 5e626767235..df9f388d0b9 100644
--- a/app/views/notify/member_invite_declined_email.html.haml
+++ b/app/views/notify/member_invite_declined_email.html.haml
@@ -1,7 +1,11 @@
%tr
%td.text-content
%p
- #{content_tag :span, @invite_email, class: :highlight}
- has #{content_tag :span, 'declined', class: :highlight} your invitation to join the
- #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular}.
-
+ - invited_user = content_tag :span, @invite_email, class: :highlight
+ - target_link = link_to member_source.human_name, strip_tags(member_source.web_url), class: :highlight
+ - target_name = sanitize_name(member_source.model_name.singular)
+ = sanitize(html_escape(s_('Notify|%{invited_user} has %{highlight_start}declined%{highlight_end} your invitation to join the %{target_link} %{target_name}.')) % { invited_user: invited_user,
+ highlight_start: '<span class="highlight">'.html_safe,
+ highlight_end: '</span>'.html_safe,
+ target_link: target_link,
+ target_name: target_name })
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index f3845b2b910..61c9b130da8 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -2,7 +2,7 @@
= sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) })
-= merge_path_description(@merge_request, 'to')
+= merge_path_description(@merge_request)
= sprintf(s_('Notify|Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) })
= assignees_label(@merge_request)
diff --git a/app/views/notify/merge_request_unmergeable_email.html.haml b/app/views/notify/merge_request_unmergeable_email.html.haml
index f0a5e5d4367..38c9710f385 100644
--- a/app/views/notify/merge_request_unmergeable_email.html.haml
+++ b/app/views/notify/merge_request_unmergeable_email.html.haml
@@ -1,7 +1,7 @@
%p
= sprintf(s_('Notify|Merge request %{merge_request} can no longer be merged due to conflict.'), { merge_request: merge_request_reference_link(@merge_request) }).html_safe
%p
- = merge_path_description(@merge_request, 'to')
+ = merge_path_description(@merge_request)
%p
= sprintf(s_('Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) })
%p
diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml
index 22d56e73ca8..211e4e379df 100644
--- a/app/views/notify/merge_request_unmergeable_email.text.haml
+++ b/app/views/notify/merge_request_unmergeable_email.text.haml
@@ -2,7 +2,7 @@
= sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) })
-= merge_path_description(@merge_request, 'to')
+= merge_path_description(@merge_request)
= sprintf(s_('Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) })
= assignees_label(@merge_request)
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
index 568ca995e04..dbf742a5cbc 100644
--- a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
@@ -2,7 +2,7 @@ Merge request #{@merge_request.to_reference} was scheduled to merge after pipeli
Merge request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
-= merge_path_description(@merge_request, 'to')
+= merge_path_description(@merge_request)
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
diff --git a/app/views/notify/merged_merge_request_email.html.haml b/app/views/notify/merged_merge_request_email.html.haml
index e2e4d6d937f..bf50ad0a9ad 100644
--- a/app/views/notify/merged_merge_request_email.html.haml
+++ b/app/views/notify/merged_merge_request_email.html.haml
@@ -2,7 +2,7 @@
= sprintf(s_('Notify|Merge request %{merge_request} was merged'), { merge_request: merge_request_reference_link(@merge_request) }).html_safe
%p
- = merge_path_description(@merge_request, 'to')
+ = merge_path_description(@merge_request)
%div
= sprintf(s_('Notify|Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) })
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index 9b9eb566903..b80e4606f35 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -2,7 +2,7 @@
= sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) })
-= merge_path_description(@merge_request, 'to')
+= merge_path_description(@merge_request)
= sprintf(s_('Notify|Author: %{author_name}'), { author_name: sanitize_name(@merge_request.author_name) })
diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb
index 9ba86f17ef6..9a45aaf1148 100644
--- a/app/views/notify/new_mention_in_merge_request_email.text.erb
+++ b/app/views/notify/new_mention_in_merge_request_email.text.erb
@@ -2,7 +2,7 @@ You have been mentioned in merge request <%= @merge_request.to_reference %>
<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
-<%= merge_path_description(@merge_request, 'to') %>
+<%= merge_path_description(@merge_request) %>
Author: <%= sanitize_name(@merge_request.author_name) %>
<%= assignees_label(@merge_request) %>
<%= reviewers_label(@merge_request) %>
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index 1542d5bba85..3c60235e6d2 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -3,7 +3,7 @@
mr_link: merge_request_reference_link(@merge_request) }
.branch
- = merge_path_description(@merge_request, 'to')
+ = merge_path_description(@merge_request)
.author
Author: #{@merge_request.author_name}
.assignee
diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb
index 09e8ca36225..f2be0b71592 100644
--- a/app/views/notify/new_merge_request_email.text.erb
+++ b/app/views/notify/new_merge_request_email.text.erb
@@ -2,7 +2,7 @@
mr_link: url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) }
%>
-<%= merge_path_description(@merge_request, 'to') %>
+<%= merge_path_description(@merge_request) %>
<%= "#{_('Author')}: #{sanitize_name(@merge_request.author_name)}" %>
<%= assignees_label(@merge_request) if @merge_request.assignees.any? %>
<%= reviewers_label(@merge_request) if @merge_request.reviewers.any? %>
diff --git a/app/views/notify/unapproved_merge_request_email.text.haml b/app/views/notify/unapproved_merge_request_email.text.haml
index 4e34b883906..52c65e6f5c6 100644
--- a/app/views/notify/unapproved_merge_request_email.text.haml
+++ b/app/views/notify/unapproved_merge_request_email.text.haml
@@ -2,7 +2,7 @@ Merge request #{@merge_request.to_reference} was unapproved by #{@unapproved_by.
Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
-= merge_path_description(@merge_request, 'to')
+= merge_path_description(@merge_request)
Author: #{sanitize_name(@merge_request.author_name)}
= assignees_label(@merge_request)
diff --git a/app/views/notify/user_auto_banned_email.html.haml b/app/views/notify/user_auto_banned_email.html.haml
deleted file mode 100644
index 8c33cd7299d..00000000000
--- a/app/views/notify/user_auto_banned_email.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
-- link_end = '</a>'.html_safe
-= email_default_heading(_("We've detected some unusual activity"))
-%p
- = _('We want to let you know %{username} has been banned from %{scope} due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes, scope: @ban_scope }
-%p
- = _('If this is a mistake, you can %{link_start}unban them%{link_end}.').html_safe % { link_start: link_start % { url: admin_users_url(filter: 'banned') }, link_end: link_end }
-%p
- = _('You can adjust rules on auto-banning %{link_start}here%{link_end}.').html_safe % { link_start: link_start % { url: network_admin_application_settings_url(anchor: 'js-ip-limits-settings') }, link_end: link_end }
diff --git a/app/views/notify/user_auto_banned_email.text.erb b/app/views/notify/user_auto_banned_email.text.erb
deleted file mode 100644
index 336973c2e42..00000000000
--- a/app/views/notify/user_auto_banned_email.text.erb
+++ /dev/null
@@ -1,7 +0,0 @@
-<%= _("We've detected some unusual activity") %>
-
-<%= _('We want to let you know %{username} has been banned from %{scope} due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes, scope: @ban_scope } %>
-
-<%= _('If this is a mistake, you can unban them: %{url}.') % { url: admin_users_url(filter: 'banned') } %>
-
-<%= _('You can adjust rules on auto-banning here: %{url}.') % { url: network_admin_application_settings_url(anchor: 'js-ip-limits-settings') } %>
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index 69f765ee163..ef9e7512b57 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -18,7 +18,7 @@
= f.submit _('Add email address'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_email_address_button' }
%hr
%h4.gl-mt-0
- = _('Linked emails (%{email_count})') % { email_count: @emails.load.size + 1 }
+ = _('Linked emails (%{email_count})') % { email_count: @emails.load.size }
.account-well.gl-mb-3
%ul
%li
@@ -36,28 +36,31 @@
%ul.content-list
%li
= render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? }
- %span.float-right
- = gl_badge_tag s_('Profiles|Primary email'), variant: :success
+ %ul
+ %li= s_('Profiles|Primary email')
- if @primary_email === current_user.commit_email_or_default
- = gl_badge_tag s_('Profiles|Commit email'), variant: :info
+ %li= s_('Profiles|Commit email')
- if @primary_email === current_user.public_email
- = gl_badge_tag s_('Profiles|Public email'), variant: :info
+ %li= s_('Profiles|Public email')
- if @primary_email === current_user.notification_email_or_default
- = gl_badge_tag s_('Profiles|Default notification email'), variant: :info
+ %li= s_('Profiles|Default notification email')
- @emails.reject(&:user_primary_email?).each do |email|
%li{ data: { qa_selector: 'email_row_content' } }
- = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
- %span.float-right
+ .gl-display-flex.gl-justify-content-space-between{ style: 'flex-flow: wrap-reverse; row-gap: 0.5rem' }
+ %div
+ = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
+ .gl-ml-n3
+ - unless email.confirmed?
+ - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
+ = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default gl-ml-3'
+
+ = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger gl-ml-3' do
+ %span.sr-only= _('Remove')
+ = sprite_icon('remove')
+ %ul
- if email.email === current_user.commit_email_or_default
- = gl_badge_tag s_('Profiles|Commit email'), variant: :info
+ %li= s_('Profiles|Commit email')
- if email.email === current_user.public_email
- = gl_badge_tag s_('Profiles|Public email'), variant: :info
+ %li= s_('Profiles|Public email')
- if email.email === current_user.notification_email_or_default
- = gl_badge_tag s_('Profiles|Notification email'), variant: :info
- - unless email.confirmed?
- - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
- = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default gl-ml-3'
-
- = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger gl-ml-3' do
- %span.sr-only= _('Remove')
- = sprite_icon('remove')
+ %li= s_('Profiles|Notification email')
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 5c8acc053f4..35bf7d81502 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -7,6 +7,12 @@
= page_title
%p
= _('SSH keys allow you to establish a secure connection between your computer and GitLab.')
+ %br
+ %h4.gl-mt-0
+ = _('SSH Fingerprints')
+ %p
+ - config_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_instance_configuration_url }
+ = html_escape(s_('SSH fingerprints verify that the client is connecting to the correct host. Check the %{config_link_start}current instance configuration%{config_link_end}.')) % { config_link_start: config_link_start, config_link_end: '</a>'.html_safe }
.col-lg-8
%h5.gl-mt-0
= _('Add an SSH key')
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index a63e02fca1d..f8737a4e54a 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -99,7 +99,7 @@
s_("Preferences|Show one file at a time on merge request's Changes tab"),
help_text: s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
.form-group
- - supported_characters = %w(" ' ` &#40; [ { < * _).map {|char| "<code>#{char}</code>" }.join(', ')
+ - supported_characters = %w(" ' ` &#40; [ { < * _).map { |char| "<code>#{char}</code>" }.join(', ')
= f.gitlab_ui_checkbox_component :markdown_surround_selection,
s_('Preferences|Surround text selection when typing quotes or brackets'),
help_text: sprintf(s_( "Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index dda1640968e..a64968cdcbb 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -3,7 +3,7 @@
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
- availability = availability_values
-- custom_emoji = show_status_emoji?(@user.status)
+- custom_emoji = @user.status&.customized?
= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
.row.js-search-settings-section
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 6304d42896d..c1eaa84e99d 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -21,24 +21,27 @@
- else
%p
- - register_2fa_token = _('We recommend cloud-based mobile authenticator apps such as Authy, Duo Mobile, and LastPass. They can restore access if you lose your hardware device.')
+ - register_2fa_token = _('We recommend using cloud-based authenticator applications that can restore access if you lose your hardware device.')
= register_2fa_token.html_safe
+ = link_to _('What are some examples?'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'enable-one-time-password'), target: '_blank', rel: 'noopener noreferrer'
.row.gl-mb-3
.col-md-4.gl-min-w-fit-content
.gl-p-2.gl-mb-3{ style: 'background: #fff' }
= raw @qr_code
.col-md-8
- .account-well
- %p.gl-mt-0.gl-mb-0
- = _("Can't scan the code?")
- %p.gl-mt-0.gl-mb-0
- = _('To add the entry manually, provide the following details to the application on your phone.')
- %p.gl-mt-0.gl-mb-0
- = _('Account: %{account}') % { account: @account_string }
- %p.gl-mt-0.gl-mb-0.two-factor-secret{ data: { qa_selector: 'otp_secret_content' } }
- = _('Key: %{key}') %{ key: current_user.otp_secret.scan(/.{4}/).join(' ') }
- %p.two-factor-new-manual-content
- = _('Time based: Yes')
+ .gl-card
+ .gl-card-body
+ %p.gl-mt-0.gl-mb-3.gl-font-weight-bold
+ = _("Can't scan the code?")
+ %p.gl-mt-0.gl-mb-3
+ = _('To add the entry manually, provide the following details to the application on your phone.')
+ %p.gl-mt-0.gl-mb-0
+ = _('Account: %{account}') % { account: @account_string }
+ %p.gl-mt-0.gl-mb-0{ data: { qa_selector: 'otp_secret_content' } }
+ = _('Key:')
+ %code.two-factor-secret= current_user.otp_secret.scan(/.{4}/).join(' ')
+ %p.gl-mb-0.two-factor-new-manual-content
+ = _('Time based: Yes')
= form_tag profile_two_factor_auth_path, method: :post do |f|
- if @error
= render Pajamas::AlertComponent.new(title: @error[:message],
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index 659bca25533..952c6daf415 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -1,5 +1,5 @@
.form-actions.gl-display-flex
- = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button qa-commit-button' }) do
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button', data: { qa_selector: 'commit_button' } }) do
= _('Commit changes')
= render Pajamas::ButtonComponent.new(href: cancel_path, button_options: { class: 'gl-ml-3', id: 'cancel-changes', aria: { label: _('Discard changes') }, data: { confirm: leave_edit_message, confirm_btn_variant: "danger" } }) do
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index eee9cfe0618..c220aa66c81 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -24,15 +24,12 @@
%span.gl-ml-3.gl-mb-3
= render 'shared/members/access_request_links', source: @project
- .gl-mt-3.gl-pl-3.gl-w-full
- = render "shared/projects/topics", project: @project, cache_enabled: cache_enabled
-
= cache_if(cache_enabled, [@project, :buttons, current_user, @notification_setting], expires_in: 1.day) do
.project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-start.gl-flex-wrap.gl-mt-5
- if current_user
- if current_user.admin?
= link_to [:admin, @project], class: 'btn gl-button btn-icon gl-align-self-start gl-py-2! gl-mr-3', title: _('View project in admin area'),
- data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ data: {toggle: 'tooltip', placement: 'top', container: 'body'} do
= sprite_icon('admin')
.gl-display-flex.gl-align-items-start.gl-mr-3
- if @notification_setting
@@ -49,7 +46,8 @@
= render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
- else
= render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
-
+ .gl-my-3
+ = render "shared/projects/topics", project: @project, cache_enabled: cache_enabled
.home-panel-home-desc.mt-1
- if @project.description.present?
.home-panel-description.text-break
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index 9845de17a11..859f065377d 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -14,7 +14,7 @@
#{time_ago_with_tooltip(event.created_at)}
- - if can?(current_user, :create_merge_request_in, event.project.default_merge_request_target)
+ - if create_mr_button_from_event?(event)
= c.actions do
- = link_to new_mr_path_from_push_event(event), title: _("New merge request"), class: "btn gl-button btn-confirm qa-create-merge-request" do
- #{ _('Create merge request') }
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path_from_push_event(event), button_options: { class: 'qa-create-merge-request' }) do
+ = _('Create merge request')
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 992b46c1f7b..98cd831d6f1 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -1,6 +1,7 @@
- visibility_level = selected_visibility_level(@project, params.dig(:project, :visibility_level))
- ci_cd_only = local_assigns.fetch(:ci_cd_only, false)
- hide_init_with_readme = local_assigns.fetch(:hide_init_with_readme, false)
+- include_description = local_assigns.fetch(:include_description, true)
- track_label = local_assigns.fetch(:track_label, 'blank_project')
.row{ id: project_name_id }
@@ -44,10 +45,19 @@
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'add-details-to-your-profile-with-a-readme') }
= html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
-.form-group
- = f.label :description, class: 'label-bold' do
- = s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe }
- = f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { qa_selector: 'project_description', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_description", track_value: "" }
+- if include_description
+ .form-group
+ = f.label :description, class: 'label-bold' do
+ = s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe }
+ = f.text_area :description,
+ placeholder: s_('ProjectsNew|Description format'),
+ class: "form-control gl-form-input",
+ rows: 3,
+ maxlength: 250,
+ data: { qa_selector: 'project_description',
+ track_label: track_label,
+ track_action: "activate_form_input",
+ track_property: "project_description" }
- unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? || !Gitlab.com?
.js-deployment-target-select
@@ -63,18 +73,20 @@
= s_('ProjectsNew|Project Configuration')
.form-group
- .form-check.gl-mb-3
- = check_box_tag 'project[initialize_with_readme]', '1', true, class: 'form-check-input', data: { qa_selector: 'initialize_with_readme_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' }
- = label_tag 'project[initialize_with_readme]', s_('ProjectsNew|Initialize repository with a README'), class: 'form-check-label'
- .form-text.text-muted
+ = render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_readme]',
+ checked: true,
+ checkbox_options: { data: { qa_selector: 'initialize_with_readme_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' } }) do |c|
+ = c.label do
+ = s_('ProjectsNew|Initialize repository with a README')
+ = c.help_text do
= s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.')
.form-group
- .form-check.gl-mb-3
- = check_box_tag 'project[initialize_with_sast]', '1', false, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' }
- = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do
+ = render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_sast]',
+ checkbox_options: { data: { qa_selector: 'initialize_with_sast_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } }) do |c|
+ = c.label do
= s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
- .form-text.text-muted
+ = c.help_text do
= s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
= link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed' }
diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml
index 393b199fb05..02aa1f7e93b 100644
--- a/app/views/projects/_transfer.html.haml
+++ b/app/views/projects/_transfer.html.haml
@@ -1,7 +1,7 @@
- return unless can?(current_user, :change_namespace, @project)
- form_id = "transfer-project-form"
- hidden_input_id = "new_namespace_id"
-- initial_data = { namespaces: namespaces_as_json, button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id }
+- initial_data = { button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id }
.sub-section
%h4.danger-title= _('Transfer project')
diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml
index 66066ceb5b2..e8a4e091dcf 100644
--- a/app/views/projects/_visibility_modal.html.haml
+++ b/app/views/projects/_visibility_modal.html.haml
@@ -22,8 +22,8 @@
%label{ for: "confirm_path_input" }
= _("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-legacy-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } }
.form-group
- = text_field_tag 'confirm_path_input', '', class: 'form-control js-legacy-confirm-danger-input qa-confirm-input'
+ = text_field_tag 'confirm_path_input', '', class: 'form-control js-legacy-confirm-danger-input'
.form-actions
%button.btn.gl-button.btn-default.gl-mr-4{ type: "button", "data-dismiss": "modal" }
= _('Cancel')
- = submit_tag _('Reduce project visibility'), class: "btn gl-button btn-danger js-legacy-confirm-danger-submit qa-confirm-button", disabled: true
+ = submit_tag _('Reduce project visibility'), class: "btn gl-button btn-danger js-legacy-confirm-danger-submit", disabled: true
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 09a275c24a1..398ca3dd27c 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -42,7 +42,7 @@
.file-editor.code
- if Feature.enabled?(:source_editor_toolbar, current_user)
#editor-toolbar
- .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }<
+ .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true, qa_selector: 'source_editor_preview_container' } }<
%pre.editor-loading-content= params[:content] || local_assigns[:blob_data]
- if local_assigns[:path]
.js-edit-mode-pane#preview.hide
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 1477ae66d80..52b8d6bc66f 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -33,8 +33,8 @@
.gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
%svg.s24
- - if merge_project && create_mr_button?(from: @repository.root_ref, to: branch.name, source_project: @project, target_project: @project)
- = link_to create_mr_path(from: @repository.root_ref, to: branch.name, source_project: @project, target_project: @project), class: 'gl-button btn btn-default' do
+ - if merge_project && create_mr_button?(from: branch.name, source_project: @project)
+ = render Pajamas::ButtonComponent.new(href: create_mr_path(from: branch.name, source_project: @project)) do
= _('Merge request')
- if branch.name != @repository.root_ref
diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
index bd6831ff3b2..6ca5aaf061e 100644
--- a/app/views/projects/branches/_panel.html.haml
+++ b/app/views/projects/branches/_panel.html.haml
@@ -7,12 +7,13 @@
- return unless branches.any?
-.card
- .card-header
+= render Pajamas::CardComponent.new(card_options: {class: 'gl-mb-5'}, body_options: {class: 'gl-py-0'}, footer_options: {class: 'gl-text-center'}) do |c|
+ - c.header do
= panel_title
- %ul.content-list.all-branches.qa-all-branches
- - branches.first(overview_max_branches).each do |branch|
- = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any?
+ - c.body do
+ %ul.content-list.all-branches.qa-all-branches
+ - branches.first(overview_max_branches).each do |branch|
+ = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any?
- if branches.size > overview_max_branches
- .card-footer.text-center
+ - c.footer do
= link_to show_more_text, project_branches_filtered_path(project, state: state), id: "state-#{state}", data: { state: state }
diff --git a/app/views/projects/buttons/_remove_tag.html.haml b/app/views/projects/buttons/_remove_tag.html.haml
index 060a854d4e4..dfa643a87bb 100644
--- a/app/views/projects/buttons/_remove_tag.html.haml
+++ b/app/views/projects/buttons/_remove_tag.html.haml
@@ -8,4 +8,4 @@
- title = s_('TagsPage|Only a project maintainer or owner can delete a protected tag')
- disabled = true
-= render Pajamas::ButtonComponent.new(variant: :default, icon: 'remove', button_options: { class: "js-delete-tag-button gl-ml-3\!", 'aria-label': s_('TagsPage|Delete tag'), title: title, disabled: disabled, data: { toggle: 'tooltip', container: 'body', path: project_tag_path(@project, tag.name), tag_name: tag.name, is_protected: protected_tag?(project, tag).to_s } })
+= render Pajamas::ButtonComponent.new(variant: :default, icon: 'remove', button_options: { class: "js-delete-tag-button", 'aria-label': s_('TagsPage|Delete tag'), title: title, disabled: disabled, data: { toggle: 'tooltip', container: 'body', path: project_tag_path(@project, tag.name), tag_name: tag.name, is_protected: protected_tag?(project, tag).to_s } })
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 00d518450e9..f607a21ad21 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -7,7 +7,7 @@
- else
= sprite_icon('star-o', css_class: 'icon')
%span= s_('ProjectOverview|Star')
- = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm star-count count' do
+ = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm has-tooltip star-count count' do
= @project.star_count
- else
@@ -15,5 +15,5 @@
= link_to new_user_session_path, class: 'gl-button btn btn-default btn-sm has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
= sprite_icon('star-o', css_class: 'icon')
%span= s_('ProjectOverview|Star')
- = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm star-count count' do
+ = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm has-tooltip star-count count' do
= @project.star_count
diff --git a/app/views/projects/ci/secure_files/show.html.haml b/app/views/projects/ci/secure_files/show.html.haml
deleted file mode 100644
index 1a87ccd753c..00000000000
--- a/app/views/projects/ci/secure_files/show.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- page_title s_('Secure Files')
-
-#js-ci-secure-files{ data: { project_id: @project.id, admin: can?(current_user, :admin_secure_files, @project).to_s, file_size_limit: Ci::SecureFile::FILE_SIZE_LIMIT.to_mb } }
diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
index e56579b162f..629d3cfaf74 100644
--- a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
+++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
@@ -1,5 +1,5 @@
- title = capture do
- = html_escape(_('This commit was signed with a verified signature, but the committer email is %{strong_open}not verified%{strong_close} to belong to the same user.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
+ = html_escape(_('This commit was signed with a verified signature, but the committer email is not associated with the GPG Key.'))
- locals = { signature: signature, title: title, label: _('Unverified'), css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true }
diff --git a/app/views/projects/commits/_commit_list.html.haml b/app/views/projects/commits/_commit_list.html.haml
index 6ed65d07202..23b25b5dcbd 100644
--- a/app/views/projects/commits/_commit_list.html.haml
+++ b/app/views/projects/commits/_commit_list.html.haml
@@ -2,14 +2,15 @@
- hidden = @hidden_commit_count
- commits = Commit.decorate(commits, @project)
-.card
- .card-header
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}, body_options: { class: 'gl-py-0'}) do |c|
+ - c.header do
Commits (#{@total_commit_count})
- - if hidden > 0
- %ul.content-list
- - commits.each do |commit|
- = render "projects/commits/inline_commit", commit: commit, project: @project
- %li.warning-row.unstyled
- #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues.
- - else
- %ul.content-list= render commits, project: @project, ref: @ref
+ - c.body do
+ - if hidden > 0
+ %ul.content-list
+ - commits.each do |commit|
+ = render "projects/commits/inline_commit", commit: commit, project: @project
+ %li.warning-row.unstyled
+ #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues.
+ - else
+ %ul.content-list= render commits, project: @project, ref: @ref
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 764ddace0ad..bb3a38d6ac8 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -41,7 +41,7 @@
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
- if can_update_merge_request && context_commits&.empty?
- = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mt-5', data: { context_commits_empty: 'true' } }) do
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mt-5 add-review-item-modal-trigger', data: { context_commits_empty: 'true' } }) do
= _('Add previously merged commits')
- if commits.size == 0 && context_commits.nil?
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index e5be3a897a5..4007b657403 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -18,9 +18,10 @@
- if @merge_request.present?
.control.d-none.d-md-block
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn gl-button'
- - elsif create_mr_button?(from: @repository.root_ref, to: @ref, source_project: @project, target_project: @project)
+ - elsif create_mr_button?(from: @ref, source_project: @project)
.control.d-none.d-md-block
- = link_to _("Create merge request"), create_mr_path(from: @repository.root_ref, to: @ref, source_project: @project, target_project: @project), class: 'btn gl-button btn-confirm'
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path(from: @ref, source_project: @project)) do
+ = _("Create merge request")
.control
= form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index a6be6695b75..95186b85838 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -1,7 +1,7 @@
- add_to_breadcrumbs _("Compare Revisions"), project_compare_index_path(@project)
- page_title "#{params[:from]}...#{params[:to]}"
-.sub-header-block.no-bottom-space
+.sub-header-block.gl-border-b-0.gl-mb-0
.js-signature-container{ data: { 'signatures-path' => signatures_namespace_project_compare_index_path } }
#js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) }
@@ -17,11 +17,11 @@
paginate_diffs: true,
paginate_diffs_per_page: Projects::CompareController::COMMIT_DIFFS_PER_PAGE
- else
- .card.gl-bg-gray-50.gl-border-none.gl-p-2
- .center
+ = render Pajamas::CardComponent.new(card_options: { class: "gl-bg-gray-50 gl-mb-5 gl-border-none gl-text-center" }) do |c|
+ - c.body do
%h4
= s_("CompareBranches|There isn't anything to compare.")
- %p.slead
+ %p.gl-mb-4.gl-line-height-24
- if params[:to] == params[:from]
- source_branch = capture do
%span.ref-name= params[:from]
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index d596199f816..11984a9d6f6 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -11,7 +11,7 @@
.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed
.files-changed-inner
.inline-parallel-buttons.gl-display-none.gl-md-display-flex
- - if !diffs_expanded? && diff_files.any? { |diff_file| diff_file.collapsed? }
+ - if !diffs_expanded? && diff_files.any?(&:collapsed?)
= link_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil)), class: 'gl-button btn btn-default'
- if show_whitespace_toggle
- if current_controller?(:commit)
diff --git a/app/views/projects/google_cloud/configuration/index.html.haml b/app/views/projects/google_cloud/configuration/index.html.haml
index ec977898f47..dab49d5032a 100644
--- a/app/views/projects/google_cloud/configuration/index.html.haml
+++ b/app/views/projects/google_cloud/configuration/index.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project)
- breadcrumb_title s_('CloudSeed|Configuration')
- page_title s_('CloudSeed|Configuration')
diff --git a/app/views/projects/google_cloud/databases/index.html.haml b/app/views/projects/google_cloud/databases/index.html.haml
index ad732317d8d..0528ac3d1f5 100644
--- a/app/views/projects/google_cloud/databases/index.html.haml
+++ b/app/views/projects/google_cloud/databases/index.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project)
- breadcrumb_title s_('CloudSeed|Databases')
- page_title s_('CloudSeed|Databases')
diff --git a/app/views/projects/google_cloud/deployments/index.html.haml b/app/views/projects/google_cloud/deployments/index.html.haml
index b140159a7f5..22a365671bc 100644
--- a/app/views/projects/google_cloud/deployments/index.html.haml
+++ b/app/views/projects/google_cloud/deployments/index.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project)
- breadcrumb_title s_('CloudSeed|Deployments')
- page_title s_('CloudSeed|Deployments')
diff --git a/app/views/projects/google_cloud/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml
index d7cabaa029b..36b5630611e 100644
--- a/app/views/projects/google_cloud/gcp_regions/index.html.haml
+++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project)
- breadcrumb_title _('CloudSeed|Regions')
- page_title s_('CloudSeed|Regions')
diff --git a/app/views/projects/google_cloud/service_accounts/index.html.haml b/app/views/projects/google_cloud/service_accounts/index.html.haml
index 6191de577fe..8f70818abd9 100644
--- a/app/views/projects/google_cloud/service_accounts/index.html.haml
+++ b/app/views/projects/google_cloud/service_accounts/index.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project)
- breadcrumb_title s_('CloudSeed|Service Account')
- page_title s_('CloudSeed|Service Account')
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index ca0307aed60..04d400688d4 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -15,20 +15,7 @@
- if defined?(@daily_coverage_options)
.repo-charts.my-5
- .sub-header-block.border-top
- .d-flex.justify-content-between.align-items-center
- %h4.sub-header.m-0
- - start_date = capture do
- #{@daily_coverage_options[:base_params][:start_date].strftime('%b %d')}
- - end_date = capture do
- #{@daily_coverage_options[:base_params][:end_date].strftime('%b %d')}
- = (_("Code coverage statistics for %{ref} %{start_date} - %{end_date}") % { ref: "<strong>#{h @ref}</strong>", start_date: start_date, end_date: end_date }).html_safe
- - download_path = capture do
- #{@daily_coverage_options[:download_path]}
- %a.btn.gl-button.btn-default.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" }
- %small
- = _("Download raw data (.csv)")
- #js-code-coverage-chart{ data: { graph_endpoint: "#{@daily_coverage_options[:graph_api_path]}?#{@daily_coverage_options[:base_params].to_query}" } }
+ #js-code-coverage-chart{ data: project_coverage_chart_data_attributes(@daily_coverage_options, @ref) }
.repo-charts
.sub-header-block.border-top
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
deleted file mode 100644
index 6a46b0b3510..00000000000
--- a/app/views/projects/hook_logs/_index.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- docs_link_url = help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks')
-- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
-- link_end = '</a>'.html_safe
-
-.row.gl-mt-3.gl-mb-3
- .col-lg-3
- %h4.gl-mt-0
- = _('Recent events')
- %p= _('GitLab events trigger webhooks. Use the request details of a webhook to help troubleshoot problems. %{link_start}How do I troubleshoot?%{link_end}').html_safe % { link_start: link_start, link_end: link_end }
- .col-lg-9
- = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index 74af65904cd..b350455807d 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -18,4 +18,4 @@
%hr
-= render partial: 'projects/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs, project: @project }
+= render partial: 'shared/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs, project: @project }
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index bcfa32566fb..306f24d717b 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -1,19 +1,19 @@
-- page_title _("Import repository")
+- page_title _('Import repository')
%h1.page-title.gl-font-size-h-display
= _('Import repository')
%hr
- if @project.import_failed?
- .card.border-danger
- .card-header.bg-danger.text-white The repository could not be imported.
- .card-body
- %pre
- :preserve
- #{h(@project.import_state.last_error)}
+ = render Pajamas::AlertComponent.new(title: s_('Import|The repository could not be imported.'),
+ dismissible: false,
+ variant: :danger,
+ alert_options: { class: 'gl-mb-5' }) do |c|
+ = c.body do
+ = @project.import_state.last_error
= gitlab_ui_form_for @project, url: project_import_path(@project), method: :post, html: { class: 'js-project-import' } do |f|
- = render "shared/import_form", f: f
+ = render 'shared/import_form', f: f
.form-actions
- = f.submit 'Start import', class: "gl-button btn btn-confirm", data: { disable_with: false }
+ = f.submit 'Start import', class: 'gl-button btn btn-confirm', data: { disable_with: false }
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 801841edc26..f9798d25b06 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -6,11 +6,10 @@
- create_mr_text = can_create_confidential_merge_request? ? _('Create confidential merge request') : _('Create merge request')
- can_create_path = can_create_branch_project_issue_path(@project, @issue)
- - create_mr_path = project_new_merge_request_path(@project, merge_request: { source_branch: @issue.to_branch_name, target_branch: @project.default_branch, issue_iid: @issue.iid })
- create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid, format: :json)
- refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
- .create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
+ .create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path(from: @issue.to_branch_name, source_project: @project, to: @project.default_branch, mr_params: { issue_iid: @issue.iid }), create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
.btn-group.unavailable
%button.gl-button.btn{ type: 'button', disabled: 'disabled' }
= gl_loading_icon(inline: true, css_class: 'js-create-mr-spinner gl-button-icon gl-display-none')
diff --git a/app/views/projects/issues/_related_issues.html.haml b/app/views/projects/issues/_related_issues.html.haml
index bab37609c20..1c252958525 100644
--- a/app/views/projects/issues/_related_issues.html.haml
+++ b/app/views/projects/issues/_related_issues.html.haml
@@ -1,5 +1,7 @@
- if can?(current_user, :read_issue_link, @project)
.js-related-issues-root{ data: { endpoint: project_issue_links_path(@project, @issue),
can_add_related_issues: "#{can?(current_user, :admin_issue_link, @issue)}",
+ full_path: @project.full_path,
+ has_issue_weights_feature: @project.licensed_feature_available?(:issue_weights).to_s,
help_path: help_page_path('user/project/issues/related_issues'),
- show_categorized_issues: "false" } }
+ show_categorized_issues: @project.licensed_feature_available?(:blocked_issues).to_s } }
diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml
index 5d478784350..df2ffdd30ee 100644
--- a/app/views/projects/issues/_work_item_links.html.haml
+++ b/app/views/projects/issues/_work_item_links.html.haml
@@ -1,2 +1,2 @@
- if Feature.enabled?(:work_items_hierarchy, @project)
- .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path } }
+ .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path, wi: work_items_index_data(@project) } }
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 647464b31f8..f7a02c521f5 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -11,14 +11,14 @@
.labels-container.gl-mt-5
- if can_admin_label && search.blank?
%p.text-muted
- = _('Labels can be applied to issues and merge requests.')
- %br
- = _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.')
+ = _('Labels can be applied to issues and merge requests. Star a label to make it a priority label.')
-# Only show it in the first page
- hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
.prioritized-labels.gl-mb-7{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
%h4.gl-mt-3= _('Prioritized Labels')
+ %p.text-muted
+ = _('Drag to reorder prioritized labels and change their relative priority.')
.manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
#js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
= render 'shared/empty_states/priority_labels'
diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
index 6b367c735c3..62cd8bd94e3 100644
--- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
+++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
@@ -34,9 +34,10 @@
= display_issuable_type
- unless current_controller?('conflicts')
- - if current_user && moved_mr_sidebar_enabled? && !@merge_request.merged?
- %li.gl-new-dropdown-divider
- %hr.dropdown-divider
+ - if current_user && moved_mr_sidebar_enabled?
+ - if !@merge_request.merged?
+ %li.gl-new-dropdown-divider
+ %hr.dropdown-divider
%li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point
- unless issuable_author_is_current_user(@merge_request)
%li.gl-new-dropdown-item
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 4ef557fbd8f..78976be5dd7 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -32,7 +32,7 @@
= tab_link_for @merge_request, :commits do
= _("Commits")
= gl_badge_tag @commits_count, { size: :sm }
- - if @number_of_pipelines.nonzero?
+ - if @project.builds_enabled?
= render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do
= tab_link_for @merge_request, :pipelines do
= _("Pipelines")
@@ -44,7 +44,7 @@
- if Feature.enabled?(:moved_mr_sidebar, @project)
.gl-ml-auto.gl-align-items-center.gl-display-none.gl-md-display-flex.js-expand-sidebar{ class: "gl-lg-display-none!" }
= render Pajamas::ButtonComponent.new(size: 'small',
- icon: 'angle-double-left',
+ icon: 'chevron-double-lg-left',
button_options: { class: 'js-sidebar-toggle' }) do
= _('Expand')
.d-flex.flex-wrap.align-items-center.justify-content-lg-end
@@ -80,7 +80,7 @@
= render "projects/merge_requests/tabs/pane", name: "commits", id: "commits", class: "commits" do
-# This tab is always loaded via AJAX
= render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do
- - if @number_of_pipelines.nonzero?
+ - if @project.builds_enabled?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
- params = request.query_parameters.merge(diff_head: true)
= render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: diffs_tab_pane_data(@project, @merge_request, params)
@@ -99,11 +99,8 @@
#js-review-bar
-- if Feature.enabled?(:mr_experience_survey, @project)
- #js-mr-experience-survey
-
-- if current_user&.mr_attention_requests_enabled?
- #js-need-attention-sidebar-onboarding
+- if Feature.enabled?(:mr_experience_survey, @project) && current_user
+ #js-mr-experience-survey{ data: { account_age: current_user.account_age_in_days } }
= render 'projects/invite_members_modal', project: @project
= render 'shared/web_ide_path'
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index 9b0508d8cb5..0d56bf7793d 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project, @milestone],
+= gitlab_ui_form_for [@project, @milestone],
html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
= form_errors(@milestone, pajamas_alert: true)
.form-group.row
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 07c38d9845c..56581fe7b18 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -13,7 +13,7 @@
.row{ 'v-cloak': true }
#blank-project-pane.tab-pane.active
= gitlab_ui_form_for @project, html: { class: 'new_project gl-mt-3' } do |f|
- = render 'new_project_fields', f: f, project_name_id: "blank-project-name"
+ = render 'new_project_fields', f: f, project_name_id: "blank-project-name", include_description: false
#create-from-template-pane.tab-pane
= render Pajamas::CardComponent.new(card_options: { class: 'gl-my-5' }) do |c|
diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml
index c5efacb21af..28f04d78861 100644
--- a/app/views/projects/pages/_access.html.haml
+++ b/app/views/projects/pages/_access.html.haml
@@ -1,8 +1,8 @@
- if @project.pages_deployed?
- .card
- .card-header
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { qa_selector: 'access_page_container' } }, footer_options: { class: 'gl-alert-warning' }) do |c|
+ - c.header do
= s_('GitLabPages|Access pages')
- .card-body
+ - c.body do
%p
%strong
= s_('GitLabPages|Your pages are served under:')
@@ -14,7 +14,7 @@
%p
= external_link(domain.url, domain.url)
- unless @project.public_pages?
- .card-footer.gl-alert-warning
+ - c.footer do
- help_page = help_page_path('user/project/pages/pages_access_control')
- link_start = '<a href="%{url}" target="_blank" class="gl-alert-link" rel="noopener noreferrer">'.html_safe % { url: help_page }
- link_end = '</a>'.html_safe
diff --git a/app/views/projects/pages/_header.html.haml b/app/views/projects/pages/_header.html.haml
new file mode 100644
index 00000000000..da35f2fdf09
--- /dev/null
+++ b/app/views/projects/pages/_header.html.haml
@@ -0,0 +1,11 @@
+- can_add_new_domain = can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https)
+
+%h1.page-title.gl-font-size-h-display.with-button
+ = s_('GitLabPages|Pages')
+ - if can_add_new_domain
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'float-right'}, href: new_project_pages_domain_path(@project)) do
+ = s_('GitLabPages|New Domain')
+%p
+ - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ - docs_link_end = '</a>'.html_safe
+ = s_('GitLabPages|With GitLab Pages you can host your static website directly from your GitLab repository. %{docs_link_start}Learn more.%{link_end}').html_safe % { docs_link_start: docs_link_start, link_end: docs_link_end }
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index 0ddf105ef60..16312da1353 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -1,38 +1,39 @@
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
- if can?(current_user, :update_pages, @project) && @domains.any?
- .card
- .card-header
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}) do |c|
+ - c.header do
Domains (#{@domains.size})
- %ul.list-group.list-group-flush
- - @domains.each do |domain|
- %li.list-group-item.gl-display-flex.gl-justify-content-space-between.gl-align-items-center
- .gl-display-flex.gl-align-items-center
- - if verification_enabled
- - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success']
- .domain-status.ci-status-icon.has-tooltip{ class: "gl-mr-5 ci-status-icon-#{status}", title: tooltip }
- = sprite_icon("status_#{status}" )
- .domain-name
- = external_link(domain.url, domain.url)
- - if domain.certificate
- %div
- = gl_badge_tag(s_('GitLabPages|Certificate: %{subject}') % { subject: domain.pages_domain.subject })
- - if domain.expired?
- = gl_badge_tag s_('GitLabPages|Expired'), variant: :danger
- %div
- = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted"
- = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped"
- - if domain.needs_verification?
- %li.list-group-item.bs-callout-warning
- - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
- - details_link_end = '</a>'.html_safe
- = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain,
- link_start: details_link_start,
- link_end: details_link_end }
- - if domain.show_auto_ssl_failed_warning?
- %li.list-group-item.bs-callout-warning
- - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
- - details_link_end = '</a>'.html_safe
- = s_("GitLabPages|Something went wrong while obtaining the Let's Encrypt certificate for %{domain}. To retry visit your %{link_start}domain details%{link_end}.").html_safe % { domain: domain.domain,
- link_start: details_link_start,
- link_end: details_link_end }
+ - c.body do
+ %ul.list-group.list-group-flush
+ - @domains.each do |domain|
+ %li.list-group-item.gl-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-p-0
+ .gl-display-flex.gl-align-items-center
+ - if verification_enabled
+ - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success']
+ .domain-status.ci-status-icon.has-tooltip{ class: "gl-mr-5 ci-status-icon-#{status}", title: tooltip }
+ = sprite_icon("status_#{status}" )
+ .domain-name
+ = external_link(domain.url, domain.url)
+ - if domain.certificate
+ %div
+ = gl_badge_tag(s_('GitLabPages|Certificate: %{subject}') % { subject: domain.pages_domain.subject })
+ - if domain.expired?
+ = gl_badge_tag s_('GitLabPages|Expired'), variant: :danger
+ %div
+ = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted"
+ = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped"
+ - if domain.needs_verification?
+ %li.list-group-item.bs-callout-warning
+ - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
+ - details_link_end = '</a>'.html_safe
+ = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain,
+ link_start: details_link_start,
+ link_end: details_link_end }
+ - if domain.show_auto_ssl_failed_warning?
+ %li.list-group-item.bs-callout-warning
+ - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
+ - details_link_end = '</a>'.html_safe
+ = s_("GitLabPages|Something went wrong while obtaining the Let's Encrypt certificate for %{domain}. To retry visit your %{link_start}domain details%{link_end}.").html_safe % { domain: domain.domain,
+ link_start: details_link_start,
+ link_end: details_link_end }
diff --git a/app/views/projects/pages/_no_domains.html.haml b/app/views/projects/pages/_no_domains.html.haml
index a537bd80d30..eee7d062d00 100644
--- a/app/views/projects/pages/_no_domains.html.haml
+++ b/app/views/projects/pages/_no_domains.html.haml
@@ -1,6 +1,6 @@
- if can?(current_user, :update_pages, @project)
- .card
- .card-header
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}, body_options: { class: 'gl-text-center nothing-here-block' }) do |c|
+ - c.header do
= s_('GitLabPages|Domains')
- .nothing-here-block
+ - c.body do
= s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.")
diff --git a/app/views/projects/pages/_use.html.haml b/app/views/projects/pages/_use.html.haml
index 20e6338fa76..dccf61c6ec5 100644
--- a/app/views/projects/pages/_use.html.haml
+++ b/app/views/projects/pages/_use.html.haml
@@ -1,10 +1,9 @@
- unless @project.pages_deployed?
- .card.border-info
- .card-header.bg-info.text-white
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-blue-500' }, header_options: { class: 'gl-bg-blue-500 gl-text-white' }) do |c|
+ - c.header do
= s_('GitLabPages|Configure pages')
- .card-body
- %p.gl-mb-0
- - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer'>".html_safe
- - samples_link_start = "<a href='https://gitlab.com/pages' target='_blank' rel='noopener noreferrer'>".html_safe
- - link_end = '</a>'.html_safe
- = s_('GitLabPages|Your Pages site is not configured yet. See the %{docs_link_start}GitLab Pages documentation%{link_end} to learn how to upload your static site and have GitLab serve it. You can also take some inspiration from the %{samples_link_start}sample Pages projects%{link_end}.').html_safe % { docs_link_start: docs_link_start, samples_link_start: samples_link_start, link_end: link_end }
+ - c.body do
+ - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer' data-track-action='click_link' data-track-label='pages_docs_link'>".html_safe
+ - samples_link_start = "<a href='https://gitlab.com/pages' target='_blank' rel='noopener noreferrer' data-track-action='click_link' data-track-label='pages_samples_link'>".html_safe
+ - link_end = '</a>'.html_safe
+ = s_('GitLabPages|Your Pages site is not configured yet. See the %{docs_link_start}GitLab Pages documentation%{link_end} to learn how to upload your static site and have GitLab serve it. You can also take some inspiration from the %{samples_link_start}sample Pages projects%{link_end}.').html_safe % { docs_link_start: docs_link_start, samples_link_start: samples_link_start, link_end: link_end }
diff --git a/app/views/projects/pages/_waiting.html.haml b/app/views/projects/pages/_waiting.html.haml
new file mode 100644
index 00000000000..e8acadbabe3
--- /dev/null
+++ b/app/views/projects/pages/_waiting.html.haml
@@ -0,0 +1,13 @@
+.empty-state
+ .row.gl-align-items-center.gl-justify-content-center
+ .order-md-2
+ = image_tag 'illustrations/pipelines_pending.svg'
+ .row.gl-align-items-center.gl-justify-content-center
+ .text-content.gl-text-center.order-md-1
+ %h4= s_("GitLabPages|Waiting for the Pages Pipeline to complete...")
+ %p= s_("GitLabPages|Your Project has been configured for Pages. Now we have to wait for the Pipeline to succeed for the first time.")
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: project_pipelines_path(@project)) do
+ = s_("GitLabPages|Check the Pipeline Status")
+ = render Pajamas::ButtonComponent.new(href: new_namespace_project_pages_path) do
+ = s_("GitLabPages|Start over")
+
diff --git a/app/views/projects/pages/disabled.html.haml b/app/views/projects/pages/disabled.html.haml
new file mode 100644
index 00000000000..769ecac636b
--- /dev/null
+++ b/app/views/projects/pages/disabled.html.haml
@@ -0,0 +1,4 @@
+= render 'header'
+
+.bs-callout.bs-callout-warning
+ = html_escape_once(s_('GitLabPages|GitLab Pages are disabled for this project. You can enable them on your project\'s %{strong_start}Settings &gt; General &gt; Visibility%{strong_end} page.')).html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
diff --git a/app/views/projects/pages/new.html.haml b/app/views/projects/pages/new.html.haml
new file mode 100644
index 00000000000..cdd52a933e9
--- /dev/null
+++ b/app/views/projects/pages/new.html.haml
@@ -0,0 +1,7 @@
+- if Feature.enabled?(:use_pipeline_wizard_for_pages, @group)
+ #js-pages{ data: @pipeline_wizard_data }
+
+- else
+ = render 'header'
+
+ = render 'use'
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 3fea9f9ff1b..01477967394 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -1,30 +1,20 @@
- page_title _('Pages')
-- if @project.pages_enabled?
- %h1.page-title.gl-font-size-h-display.with-button
- = s_('GitLabPages|Pages')
+- unless @project.pages_deployed?
+ = render 'waiting'
- - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https)
- = link_to new_project_pages_domain_path(@project), class: 'btn gl-button btn-confirm float-right', title: s_('GitLabPages|New Domain') do
- = s_('GitLabPages|New Domain')
+- else
+ = render 'header'
- %p.light
- - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer'>".html_safe
- - link_end = '</a>'.html_safe
- = s_('GitLabPages|With GitLab Pages you can host your static website directly from your GitLab repository. %{docs_link_start}Learn more.%{link_end}').html_safe % { docs_link_start: docs_link_start, link_end: link_end }
+ %section
= render 'pages_settings'
%hr.clearfix
-
- = render 'ssl_limitations_warning' if @project.pages_subdomain.include?(".")
- = render 'access'
- = render 'use'
- - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
- = render 'list'
- - else
- = render 'no_domains'
- = render 'destroy'
-- else
- .bs-callout.bs-callout-warning
- = html_escape_once(s_('GitLabPages|GitLab Pages are disabled for this project. You can enable them on your project\'s %{strong_start}Settings &gt; General &gt; Visibility%{strong_end} page.')).html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
+ = render 'ssl_limitations_warning' if @project.pages_subdomain.include?(".")
+ = render 'access'
+ - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
+ = render 'list'
+ - else
+ = render 'no_domains'
+ = render 'destroy'
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index edcd44563f7..c36c3ae5adf 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -33,7 +33,7 @@
= link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), class: 'btn gl-button btn-default btn-icon' do
= sprite_icon('play')
- if can?(current_user, :take_ownership_pipeline_schedule, pipeline_schedule)
- = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn gl-button btn-default' do
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-take-ownership-button has-tooltip', title: s_('PipelineSchedule|Take ownership to edit'), data: { url: take_ownership_pipeline_schedule_path(pipeline_schedule) } }) do
= s_('PipelineSchedules|Take ownership')
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
= link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-button btn-default btn-icon' do
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index a56e8f7f5c7..661cf465081 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -18,3 +18,5 @@
- else
.card.bg-light.gl-mt-3
.nothing-here-block= _("No schedules")
+
+#pipeline-take-ownership-modal
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index 5a655e7e83d..e16a2235e53 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -6,4 +6,5 @@
failed_pipelines_link: project_pipelines_path(@project, page: '1', scope: 'all', status: 'failed'),
coverage_chart_path: charts_project_graph_path(@project, @project.default_branch),
test_runs_empty_state_image_path: image_path('illustrations/pipeline.svg'),
+ project_quality_summary_feedback_image_path: image_path('illustrations/chat-bubble-sm.svg'),
default_branch: @project.default_branch } }
diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml
index 5e4b1397dd3..d0fdd3a729a 100644
--- a/app/views/projects/project_templates/_template.html.haml
+++ b/app/views/projects/project_templates/_template.html.haml
@@ -10,7 +10,8 @@
.controls.d-flex.align-items-center
%a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_action: "click_button", track_value: "" } }
= _("Preview")
- %label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name }
+ %label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name,
+ 'data-testid': "use_template_#{template.name}" }
%input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_action: "click_button", track_value: "" } }
%span{ data: { qa_selector: 'use_template_button' } }
= _("Use template")
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
index 3b8294a1dec..35770c32f9f 100644
--- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -1,9 +1,9 @@
= form_for [@project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' }
- .card
- .card-header.gl-font-weight-bold
+ = render Pajamas::CardComponent.new(card_options: { class: "gl-mb-5" }) do |c|
+ - c.header do
= s_("ProtectedBranch|Protect a branch")
- .card-body
+ - c.body do
= form_errors(@protected_branch, pajamas_alert: true)
.form-group.row
= f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12'
@@ -31,5 +31,5 @@
- force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url }
= (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe
= render_if_exists 'projects/protected_branches/ee/code_owner_approval_form', f: f
- .card-footer
+ - c.footer do
= f.submit s_('ProtectedBranch|Protect'), class: 'gl-button btn btn-confirm', disabled: true, data: { qa_selector: 'protect_button' }
diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml
index 449b6c25f50..5acd6f95df4 100644
--- a/app/views/projects/runners/_group_runners.html.haml
+++ b/app/views/projects/runners/_group_runners.html.haml
@@ -28,9 +28,9 @@
= _('This group does not have any group runners yet.')
- if can?(current_user, :admin_group_runners, @project.group)
- - register_runners_path = group_runners_path(@project.group)
- - group_link = link_to _("group's CI/CD settings."), register_runners_path
- = _('Group owners can register group runners in the %{link}').html_safe % { link: group_link }
+ - group_link_start = "<a href='#{group_runners_path(@project.group)}'>".html_safe
+ - group_link_end = '</a>'.html_safe
+ = s_("Runners|To register them, go to the %{link_start}group's Runners page%{link_end}.").html_safe % { link_start: group_link_start, link_end: group_link_end }
- else
= _('Ask your group owner to set up a group runner.')
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index 359e34d8918..7ecc8004334 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -37,7 +37,7 @@
token: @resource_access_token,
scopes: @scopes,
access_levels: ProjectMember.permissible_access_level_roles(current_user, @project),
- default_access_level: Gitlab::Access::MAINTAINER,
+ default_access_level: Gitlab::Access::GUEST,
prefix: :resource_access_token,
help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token')
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 64f45ec89d1..ea77bda0b0f 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -20,7 +20,7 @@
%fieldset.builds-feature.js-auto-devops-settings
.form-group
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
- .card.auto-devops-card
+ .card.gl-mb-3
.card-body
- autodevops_help_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
- auto_devops_badge = auto_devops_enabled ? (gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge gl-ml-3 gl-mt-n1'}) : ''
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 09f9ca60b3e..dd9cc296d52 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -41,7 +41,7 @@
= expanded ? _('Collapse') : _('Expand')
%p
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
- = link_to s_('How do I configure runners?'), help_page_path('ci/runners/index'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('What is GitLab Runner?'), 'https://docs.gitlab.com/runner/', target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'projects/runners/settings'
diff --git a/app/views/projects/settings/integrations/edit.html.haml b/app/views/projects/settings/integrations/edit.html.haml
index a250daafdbb..46276e6c6c9 100644
--- a/app/views/projects/settings/integrations/edit.html.haml
+++ b/app/views/projects/settings/integrations/edit.html.haml
@@ -6,4 +6,5 @@
= render 'form', integration: @integration
- if @web_hook_logs
- = render partial: 'projects/hook_logs/index', locals: { hook: @integration.service_hook, hook_logs: @web_hook_logs, project: @project }
+ %hr
+ = render partial: 'shared/hook_logs/index', locals: { hook: @integration.service_hook, hook_logs: @web_hook_logs, project: @project }
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 50bfd3c6976..87e3e03099c 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -2,22 +2,18 @@
- page_title _('Monitor Settings')
- breadcrumb_title _('Monitor Settings')
-.gl-alert.gl-alert-danger.gl-mb-5
- - removal_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/7188'
- - removal_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: removal_epic_link_url }
- - opstrace_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/6976'
- - opstrace_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: opstrace_link_url }
- - link_end = '</a>'.html_safe
- .gl-alert-container
- = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-title
- = s_('Deprecations|Feature deprecation and removal')
- .gl-alert-body
- %p
- = html_escape(s_('Deprecations|The metrics feature was deprecated in GitLab 14.7.'))
- = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end }
- = html_escape(s_('Deprecations|For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {opstrace_link_start: opstrace_link_start, link_end: link_end }
+= render Pajamas::AlertComponent.new(variant: :danger,
+ dismissible: false,
+ title: s_('Deprecations|Feature deprecation and removal')) do |c|
+ = c.body do
+ - removal_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/7188'
+ - removal_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: removal_epic_link_url }
+ - opstrace_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/6976'
+ - opstrace_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: opstrace_link_url }
+ - link_end = '</a>'.html_safe
+ = html_escape(s_('Deprecations|The metrics feature was deprecated in GitLab 14.7.'))
+ = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end }
+ = html_escape(s_('Deprecations|For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {opstrace_link_start: opstrace_link_start, link_end: link_end }
= render 'projects/settings/operations/metrics_dashboard'
= render 'projects/settings/operations/error_tracking'
diff --git a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml
new file mode 100644
index 00000000000..795544b75a2
--- /dev/null
+++ b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml
@@ -0,0 +1,6 @@
+- add_to_breadcrumbs _('Packages & Registries'), project_settings_packages_and_registries_path(@project)
+- breadcrumb_title s_('ContainerRegistry|Clean up image tags')
+- page_title s_('ContainerRegistry|Clean up image tags'), _('Packages & Registries')
+- @content_class = 'limit-container-width' unless fluid_layout
+
+#js-registry-settings-cleanup-image-tags{ data: cleanup_settings_data }
diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml
index 1a7821d3268..d579981ebc0 100644
--- a/app/views/projects/settings/packages_and_registries/show.html.haml
+++ b/app/views/projects/settings/packages_and_registries/show.html.haml
@@ -2,16 +2,4 @@
- page_title _('Packages & Registries')
- @content_class = 'limit-container-width' unless fluid_layout
-#js-registry-settings{ data: { project_id: @project.id,
- project_path: @project.full_path,
- cadence_options: cadence_options.to_json,
- keep_n_options: keep_n_options.to_json,
- older_than_options: older_than_options.to_json,
- is_admin: current_user&.admin.to_s,
- show_container_registry_settings: show_container_registry_settings(@project).to_s,
- show_package_registry_settings: show_package_registry_settings(@project).to_s,
- admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
- enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s,
- help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'),
- show_cleanup_policy_link: show_cleanup_policy_link(@project).to_s,
- tags_regex_help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'regex-pattern-examples') } }
+#js-registry-settings{ data: settings_data }
diff --git a/app/views/projects/tags/_edit_release_button.html.haml b/app/views/projects/tags/_edit_release_button.html.haml
index 5bdf1c7896c..1c2626e5612 100644
--- a/app/views/projects/tags/_edit_release_button.html.haml
+++ b/app/views/projects/tags/_edit_release_button.html.haml
@@ -1,11 +1,9 @@
-- if Feature.enabled?(:edit_tag_release_notes_via_release_page, project)
- - release_btn_text = s_('TagsPage|Create release')
- - release_btn_path = new_project_release_path(project, tag_name: tag.name)
- - if release
- - release_btn_text = s_('TagsPage|Edit release')
- - release_btn_path = edit_project_release_path(project, release)
- = link_to release_btn_path, class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: release_btn_text, data: { container: "body" } do
- = sprite_icon('pencil', css_class: 'gl-icon')
-- else
- = link_to edit_project_tag_release_path(project, tag.name), class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
- = sprite_icon('pencil', css_class: 'gl-icon')
+- release_btn_text = s_('TagsPage|Create release')
+- release_btn_path = new_project_release_path(project, tag_name: tag.name)
+- option_css_classes = local_assigns.fetch(:option_css_classes, '')
+- css_classes = "btn gl-button btn-default btn-icon btn-edit has-tooltip #{option_css_classes}"
+- if release
+ - release_btn_text = s_('TagsPage|Edit release')
+ - release_btn_path = edit_project_release_path(project, release)
+= link_to release_btn_path, class: css_classes, title: release_btn_text, data: { container: "body" } do
+ = sprite_icon('pencil', css_class: 'gl-icon')
diff --git a/app/views/projects/tags/_release_link.html.haml b/app/views/projects/tags/_release_link.html.haml
new file mode 100644
index 00000000000..c942d122a58
--- /dev/null
+++ b/app/views/projects/tags/_release_link.html.haml
@@ -0,0 +1,4 @@
+.gl-text-secondary
+ = sprite_icon("rocket", size: 12)
+ = _("Release")
+ = link_to release.name, project_release_path(project, release), class: "gl-text-blue-600!"
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 258f662420b..fcad8509a7d 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -18,10 +18,7 @@
= s_("TagsPage|Can't find HEAD commit for this tag")
- if release
- .text-secondary
- = sprite_icon("rocket", size: 12)
- = _("Release")
- = link_to release.name, project_release_path(@project, release), class: 'gl-text-blue-600!'
+ = render 'release_link', project: @project, release: release
- if tag.message.present?
%pre.wrap
@@ -40,5 +37,5 @@
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
- if can?(current_user, :admin_tag, @project)
- = render 'edit_release_button', tag: tag, project: @project, release: release
+ = render 'edit_release_button', tag: tag, project: @project, release: release, option_css_classes: 'gl-mr-3!'
= render 'projects/buttons/remove_tag', project: @project, tag: tag
diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml
deleted file mode 100644
index c99f146ea7a..00000000000
--- a/app/views/projects/tags/releases/edit.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-- add_to_breadcrumbs _("Tags"), project_tags_path(@project)
-- breadcrumb_title @tag.name
-- page_title _("Edit"), @tag.name, _("Tags")
-
-.sub-header-block.no-bottom-space
- .oneline
- .title
- Release notes for tag
- %strong= @tag.name
-
-= form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name),
- html: { class: 'common-note-form release-form js-quick-submit' }) do |f|
- = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
- = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…"
- = render 'shared/notes/hints'
- .error-alert
- .gl-mt-5.gl-display-flex
- = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3'
- = link_to _('Cancel'), project_tag_path(@project, @tag.name), class: "btn gl-button btn-default btn-cancel"
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 24da8e2db87..cb7751ecf2e 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -37,20 +37,21 @@
- else
= s_("TagsPage|Can't find HEAD commit for this tag")
+ - if @release
+ = render 'release_link', project: @project, release: @release
+
.nav-controls
- if @tag.has_signature?
= render partial: 'projects/commit/signature', object: @tag.signature
- if can?(current_user, :admin_tag, @project)
= render 'edit_release_button', tag: @tag, project: @project, release: @release
- = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse files') do
+ = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default has-tooltip', title: s_('TagsPage|Browse files') do
= sprite_icon('folder-open', css_class: 'gl-icon')
- = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse commits') do
+ = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default has-tooltip', title: s_('TagsPage|Browse commits') do
= sprite_icon('history', css_class: 'gl-icon')
- .controls-item
- = render 'projects/buttons/download', project: @project, ref: @tag.name
+ = render 'projects/buttons/download', project: @project, ref: @tag.name
- if can?(current_user, :admin_tag, @project)
- .btn-container.controls-item-full
- = render 'projects/buttons/remove_tag', project: @project, tag: @tag
+ = render 'projects/buttons/remove_tag', project: @project, tag: @tag
- if @tag.message.present?
%pre.wrap{ data: { qa_selector: 'tag_message_content' } }
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index 8b3d0ef17a4..0c53ed48210 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -1,4 +1,4 @@
-.row.gl-mt-3.gl-mb-3.triggers-container
+.row.gl-mt-3.gl-mb-3
.col-lg-12
.card
.card-header
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index ce036606a1c..bce7dc8a94b 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -6,7 +6,7 @@
- else
%span= trigger.short_token
- .label-container
+ .gl-display-inline-block.gl-ml-3
- unless trigger.can_access_project?
= gl_badge_tag s_('Trigger|invalid'), { variant: :danger }, { title: s_('Trigger|Trigger user has insufficient permissions to project'), data: { toggle: 'tooltip', container: 'body' } }
@@ -27,7 +27,7 @@
- else
Never
- %td.text-right.trigger-actions
+ %td.text-right.gl-white-space-nowrap
- revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?"
- if can?(current_user, :admin_trigger, trigger)
= link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "gl-button btn btn-default btn-icon" do
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index d5d3cd753f3..168f4ca10bc 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -3,7 +3,7 @@
= render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty?
.results.gl-md-display-flex.gl-mt-3
- - if %w(issues merge_requests).include?(@scope)
+ - if %w[issues merge_requests].include?(@scope)
#js-search-sidebar{ class: search_bar_classes }
.gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
- if @timeout
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 1f37e33a037..ac7d56520f7 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: 'qa-auto-devops-banner auto-devops-implicitly-enabled-banner' },
+ = render Pajamas::AlertComponent.new(alert_options: { class: 'auto-devops-implicitly-enabled-banner', data: { qa_selector: 'auto_devops_banner_content' } },
close_button_options: { class: 'hide-auto-devops-implicitly-enabled-banner',
data: { project_id: project.id }}) do |c|
= c.body do
diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml
index f7794677dc1..a202add339f 100644
--- a/app/views/shared/_broadcast_message.html.haml
+++ b/app/views/shared/_broadcast_message.html.haml
@@ -21,7 +21,7 @@
- else
- notification_class = "js-broadcast-notification-#{message.id}"
- notification_class << ' preview' if preview
- .broadcast-message.broadcast-notification-message.mt-2{ role: "alert", class: notification_class }
+ .broadcast-message.broadcast-notification-message.mt-2{ role: "alert", class: notification_class, data: { qa_selector: 'broadcast_notification_container' } }
= sprite_icon(icon_name, css_class: 'vertical-align-text-top')
- if message.message.present?
= render_broadcast_message(message)
@@ -31,5 +31,5 @@
= render Pajamas::ButtonComponent.new(variant: :link,
icon: 'close',
size: :small,
- button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601 } },
+ button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601, qa_selector: 'close_button' } },
icon_classes: 'gl-mx-3! gl-text-gray-700')
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 80b50f7a3de..6b502ee928e 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -5,7 +5,7 @@
%span.js-clone-dropdown-label
= enabled_protocol_button(container, enabled_protocol)
- else
- %a#clone-dropdown.input-group-text.gl-button.btn.btn-default.btn-icon.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
+ %a#clone-dropdown.input-group-text.gl-button.btn.btn-default.btn-icon.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } }
%span.js-clone-dropdown-label
= default_clone_protocol.upcase
= sprite_icon('chevron-down', css_class: 'gl-icon')
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index f8ac3832a77..23a17c07ea8 100644
--- a/app/views/shared/_file_highlight.html.haml
+++ b/app/views/shared/_file_highlight.html.haml
@@ -1,13 +1,17 @@
#blob-content.file-content.code.js-syntax-highlight
- offset = defined?(first_line_number) ? first_line_number : 1
- .line-numbers
+ .line-numbers{ class: "gl-p-0\!" }
- if blob.data.present?
- link = blob_link if defined?(blob_link)
+ - blame_link = project_blame_path(@project, tree_join(@ref, blob.path))
- blob.data.each_line.each_with_index do |_, index|
- i = index + offset
-# We're not using `link_to` because it is too slow once we get to thousands of lines.
- %a.file-line-num.diff-line-num{ href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i }
- = i
+ .line-links.diff-line-num
+ - if Feature.enabled?(:file_line_blame)
+ %a.file-line-blame{ href: "#{blame_link}#L#{i}" }
+ %a.file-line-num{ href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i }
+ = i
- highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil
.blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } }
%pre.code.highlight
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
deleted file mode 100644
index db5e055a1c4..00000000000
--- a/app/views/shared/_group_form.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- parent = @group.parent
-- group_path = root_url
-- group_path << parent.full_path + '/' if parent
-
-
-= render 'shared/groups/group_name_and_path_fields', f: f
diff --git a/app/views/shared/_help_dropdown_forum_link.html.haml b/app/views/shared/_help_dropdown_forum_link.html.haml
index f3c69a7c897..06889428e82 100644
--- a/app/views/shared/_help_dropdown_forum_link.html.haml
+++ b/app/views/shared/_help_dropdown_forum_link.html.haml
@@ -1,2 +1,2 @@
-= link_to _("Community forum"), "https://forum.gitlab.com/", target: '_blank', class: 'text-nowrap',
+= link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap',
rel: 'noopener noreferrer', data: { 'track_action': 'click_forum', 'track_property': 'question_menu' }
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 252f9c26f06..c351ea29c7c 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -3,11 +3,11 @@
- show_label_issues_link = subject_or_group_defined && show_label_issuables_link?(label, :issues)
- show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests)
-.label-name.gl-flex-shrink-0.gl-mt-2.gl-mr-3
+.label-name.gl-flex-shrink-0.gl-mt-2.gl-mr-5
= render_label(label, tooltip: false)
.label-description.gl-overflow-hidden.gl-w-full
.gl-display-flex.gl-align-items-stretch.gl-flex-wrap.gl-mt-2
- .gl-flex-basis-half.gl-flex-grow-1.gl-overflow-hidden.gl-mr-2
+ .gl-flex-basis-half.gl-flex-grow-1.gl-overflow-hidden.gl-mr-5
- if label.description.present?
= markdown_field(label, :description)
- elsif show_labels_full_path?(@project, @group)
diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml
index 821f1ede422..0bd5d1795d0 100644
--- a/app/views/shared/_new_project_item_select.html.haml
+++ b/app/views/shared/_new_project_item_select.html.haml
@@ -1,6 +1,6 @@
- if any_projects?(@projects)
.dropdown.b-dropdown.gl-new-dropdown.btn-group.project-item-select-holder{ class: 'gl-display-inline-flex!' }
- %a.btn.gl-button.btn-confirm.split-content-button.js-new-project-item-link.block-truncated.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } }
+ %a.btn.gl-button.btn-confirm.split-content-button.js-new-project-item-link.block-truncated{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } }
= gl_loading_icon(inline: true, color: 'light')
= project_select_tag :project_path, class: "project-item-select gl-absolute! gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled]
- %button.btn.dropdown-toggle.btn-confirm.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button.qa-new-project-item-select-button{ 'aria-label': _('Toggle project select') }
+ %button.btn.dropdown-toggle.btn-confirm.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button{ 'aria-label': _('Toggle project select') }
diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml
index 74e0a088656..20bf2141cc3 100644
--- a/app/views/shared/_ref_switcher.html.haml
+++ b/app/views/shared/_ref_switcher.html.haml
@@ -13,8 +13,8 @@
- @options && @options.each do |key, value|
= hidden_field_tag key, value, id: nil
.dropdown
- = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: ref, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: field_name, submit_form_on_click: true, visit: true }, { toggle_class: "js-project-refs-dropdown qa-branches-select" }
- .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging.qa-branches-dropdown{ class: ("dropdown-menu-right" if local_assigns[:align_right]) }
+ = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: ref, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: field_name, submit_form_on_click: true, visit: true, qa_selector: "branches_dropdown", testid: "branches-select" }, { toggle_class: "js-project-refs-dropdown" }
+ .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging{ class: ("dropdown-menu-right" if local_assigns[:align_right]), data: { qa_selector: "branches_dropdown_content" } }
.dropdown-page-one
= dropdown_title _("Switch branch/tag")
= dropdown_filter _("Search branches and tags")
diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
index f3942aa5dc2..770d335a88b 100644
--- a/app/views/shared/_remote_mirror_update_button.html.haml
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -3,5 +3,5 @@
button_options: { class: 'disabled', title: _('Updating'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'updating_button' } },
icon_classes: 'spin')
- elsif remote_mirror.enabled?
- = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn btn-icon gl-button qa-update-now-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do
+ = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn btn-icon gl-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body', qa_selector: 'update_now_button' }, title: _('Update now') do
= sprite_icon("retry")
diff --git a/app/views/shared/_search_settings.html.haml b/app/views/shared/_search_settings.html.haml
index 7265f090967..95eb421dbfe 100644
--- a/app/views/shared/_search_settings.html.haml
+++ b/app/views/shared/_search_settings.html.haml
@@ -4,4 +4,4 @@
%div{ class: container_class }
.js-search-settings-app
- %input.gl-form-input.form-control{ type: "text", placeholder: _("Search settings"), aria_label: _("Search settings"), disabled: true }
+ %input.gl-form-input.form-control{ type: "text", placeholder: _("Search settings"), aria: { label: _("Search settings") }, disabled: true }
diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml
index 0a74e47fa4c..4cdf1340d64 100644
--- a/app/views/shared/_sidebar_toggle_button.html.haml
+++ b/app/views/shared/_sidebar_toggle_button.html.haml
@@ -1,4 +1,4 @@
-%a.toggle-sidebar-button.js-toggle-sidebar.qa-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
+%a.toggle-sidebar-button.js-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
= sprite_icon('chevron-double-lg-left', size: 12, css_class: 'icon-chevron-double-lg-left')
%span.collapse-text.gl-ml-3= _("Collapse sidebar")
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index 0f6fc860883..dd4d2ab46c1 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -27,7 +27,7 @@
.row
.col
.js-access-tokens-expires-at{ data: expires_at_field_data }
- = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' }
+ = f.text_field :expires_at, class: 'gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' }
- if resource
.row
@@ -45,9 +45,5 @@
= link_to _("Learn more."), help_path, target: '_blank', rel: 'noopener noreferrer'
= render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes, f: f
- - if prefix == :personal_access_token && Feature.enabled?(:personal_access_tokens_scoped_to_projects, current_user)
- .js-access-tokens-projects
- %input{ type: 'hidden', name: 'personal_access_token[projects]', id: 'personal_access_token_projects', data: { js_name: 'projects' } }
-
.gl-mt-3
= f.submit _('Create %{type}') % { type: type }, class: 'gl-button btn btn-confirm', data: { qa_selector: 'create_token_button' }
diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml
index 5ca9cf8d9a4..53c6800f93d 100644
--- a/app/views/shared/access_tokens/_table.html.haml
+++ b/app/views/shared/access_tokens/_table.html.haml
@@ -45,7 +45,7 @@
%span.token-never-expires-label= _('Never')
- if resource
%td= resource.member(token.user).human_access
- %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: "gl-button btn btn-danger btn-sm float-right qa-revoke-button #{'btn-danger-secondary' unless token.expires?}", aria: { label: _('Revoke') }, data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type }, 'confirm-btn-variant': 'danger' }
+ %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: "gl-button btn btn-danger btn-sm float-right #{'btn-danger-secondary' unless token.expires?}", aria: { label: _('Revoke') }, data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type }, 'confirm-btn-variant': 'danger', qa_selector: 'revoke_button' }
- else
.settings-message.text-center
= no_active_tokens_message
diff --git a/app/views/shared/admin/_admin_note.html.haml b/app/views/shared/admin/_admin_note.html.haml
index 82407705885..9dcf181a118 100644
--- a/app/views/shared/admin/_admin_note.html.haml
+++ b/app/views/shared/admin/_admin_note.html.haml
@@ -1,7 +1,7 @@
- if @group.admin_note.present?
- text = @group.admin_note.note
- .card.border-info
- .card-header.bg-info.gl-text-white
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-blue-500 gl-mb-5' }, header_options: { class: 'gl-bg-blue-500 gl-text-white' }) do |c|
+ - c.header do
= s_('Admin|Admin notes')
- .card-body
+ - c.body do
%p= text
diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml
index 60641006e96..4db1d20e81b 100644
--- a/app/views/shared/blob/_markdown_buttons.html.haml
+++ b/app/views/shared/blob/_markdown_buttons.html.haml
@@ -22,11 +22,15 @@
= markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") })
= markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
- = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a task list") })
+ = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") })
= markdown_toolbar_button({ icon: "details-block",
data: { "md-tag" => "<details><summary>Click to expand</summary>\n{text}\n</details>", "md-prepend" => true, "md-select" => "Click to expand" },
title: _("Add a collapsible section") })
= markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") })
+ = markdown_toolbar_button({ icon: "paperclip",
+ data: { "testid" => "button-attach-file" },
+ css_class: 'js-attach-file-button markdown-selector',
+ title: _("Attach a file or image") })
- if show_fullscreen_button
%button.gl-button.btn.btn-default-tertiary.btn-icon.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } }
= sprite_icon("maximize")
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 2e04bbf3605..eade973d72a 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -12,7 +12,7 @@
.form-group
= f.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
- = f.text_field :expires_at, class: 'datepicker form-control', data: { qa_selector: 'deploy_token_expires_at_field' }, value: f.object.expires_at
+ = f.gitlab_ui_datepicker :expires_at, data: { qa_selector: 'deploy_token_expires_at_field' }, value: f.object.expires_at
.text-secondary= s_('DeployTokens|Enter an expiration date for your token. Defaults to never expire.')
.form-group
diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml
index 4e5e04ba4d4..e96fcd11cef 100644
--- a/app/views/shared/empty_states/_labels.html.haml
+++ b/app/views/shared/empty_states/_labels.html.haml
@@ -1,6 +1,6 @@
.row.empty-state.labels
.col-12
- .svg-content.qa-label-svg
+ .svg-content{ data: { qa_selector: 'label_svg_content' } }
= image_tag 'illustrations/labels.svg'
.col-12
.text-content
@@ -8,7 +8,7 @@
%p= _("You can also star a label to make it a priority label.")
.text-center
- if can?(current_user, :admin_label, @project)
- = link_to _('New label'), new_project_label_path(@project), class: 'btn gl-button btn-confirm qa-label-create-new', title: _('New label'), id: 'new_label_link'
+ = link_to _('New label'), new_project_label_path(@project), class: 'btn gl-button btn-confirm', title: _('New label'), id: 'new_label_link'
= link_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, class: 'btn gl-button btn-confirm-secondary', title: _('Generate a default set of labels'), id: 'generate_labels_link'
- if can?(current_user, :admin_label, @group)
= link_to _('New label'), new_group_label_path(@group), class: 'btn gl-button btn-confirm', title: _('New label'), id: 'new_label_link'
diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml
index a93f6e4c795..3381c5f0c67 100644
--- a/app/views/shared/empty_states/_priority_labels.html.haml
+++ b/app/views/shared/empty_states/_priority_labels.html.haml
@@ -1,5 +1,5 @@
.text-center
- .svg-content.qa-label-svg
+ .svg-content{ data: { qa_selector: 'label_svg_content' } }
= image_tag 'illustrations/priority_labels.svg'
- if can?(current_user, :admin_label, @project)
%p
diff --git a/app/views/shared/empty_states/_topics.html.haml b/app/views/shared/empty_states/_topics.html.haml
index fd82a853037..0283e852c7d 100644
--- a/app/views/shared/empty_states/_topics.html.haml
+++ b/app/views/shared/empty_states/_topics.html.haml
@@ -1,7 +1,7 @@
.row.empty-state
.col-12
.svg-content
- = image_tag 'illustrations/labels.svg', data: { qa_selector: 'svg_content' }
+ = image_tag 'illustrations/labels.svg'
.text-content.gl-text-center.gl-pt-0!
%h4= _('There are no topics to show.')
%p= _('Add topics to projects to help users find them.')
diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml
index 552b100d5dd..8304a2f18a0 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -3,7 +3,7 @@
- if can?(current_user, :create_wiki, @wiki.container)
- create_path = wiki_page_path(@wiki, params[:id], view: 'create')
- - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-confirm qa-create-first-page-link', title: s_('WikiEmpty|Create your first page')
+ - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-confirm', title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' }
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
%h4.text-left
diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml
index 3b100f832b2..0b7034838ed 100644
--- a/app/views/shared/empty_states/_wikis_layout.html.haml
+++ b/app/views/shared/empty_states/_wikis_layout.html.haml
@@ -1,6 +1,6 @@
.row.empty-state.empty-state-wiki
.col-12
- .svg-content.qa-svg-content
+ .svg-content{ data: { qa_selector: 'svg_content' } }
= image_tag image_path
.col-12
.text-content.text-center
diff --git a/app/views/shared/groups/_group_name_and_path_fields.html.haml b/app/views/shared/groups/_group_name_and_path_fields.html.haml
index 634b8448535..08192cc0cc5 100644
--- a/app/views/shared/groups/_group_name_and_path_fields.html.haml
+++ b/app/views/shared/groups/_group_name_and_path_fields.html.haml
@@ -1,5 +1,6 @@
-.js-group-name-and-path{ data: group_name_and_path_app_data(@group) }
+.js-group-name-and-path{ data: group_name_and_path_app_data.merge(new_subgroup: local_assigns[:new_subgroup].to_s) }
= f.hidden_field :name, data: { js_name: 'name' }
= f.hidden_field :path, maxlength: ::Namespace::URL_MAX_LENGTH, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, data: { js_name: 'path' }
= f.hidden_field :parent_id, value: @group.parent&.id, data: { js_name: 'parentId' }
+ = f.hidden_field :parent_full_path, value: @group.parent&.full_path, data: { js_name: 'parentFullPath' }
= f.hidden_field :id, data: { js_name: 'groupId' }
diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml
index a574394694d..2afac0ad733 100644
--- a/app/views/shared/groups/_search_form.html.haml
+++ b/app/views/shared/groups/_search_form.html.haml
@@ -1,2 +1,2 @@
= form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f|
- = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter qa-groups-filter', spellcheck: false, id: 'group-filter-form-field'
+ = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', data: { qa_selector: 'groups_filter_field' }, spellcheck: false, id: 'group-filter-form-field'
diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/shared/hook_logs/_index.html.haml
index 6a46b0b3510..6a46b0b3510 100644
--- a/app/views/admin/hook_logs/_index.html.haml
+++ b/app/views/shared/hook_logs/_index.html.haml
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
index 112b0368a3a..5326b26d655 100644
--- a/app/views/shared/issuable/_assignees.html.haml
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -3,11 +3,8 @@
- render_count = assignees_rendering_overflow ? max_render - 1 : max_render
- more_assignees_count = issuable.assignees.size - render_count
-- if issuable.instance_of?(MergeRequest) && current_user&.mr_attention_requests_enabled?
- = render 'shared/issuable/merge_request_assignees', issuable: issuable, count: render_count
-- else
- - issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
- = link_to_member(@project, assignee, name: false, title: s_("MrList|Assigned to %{name}") % { name: assignee.name})
+- issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
+ = link_to_member(@project, assignee, name: false, title: s_("MrList|Assigned to %{name}") % { name: assignee.name})
- if more_assignees_count > 0
%span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', qa_selector: 'avatar_counter_content' }, title: _("+%{more_assignees_count} more assignees") % { more_assignees_count: more_assignees_count} }
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index e90ea35f28e..ae8b266c092 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -3,7 +3,7 @@
- project = @target_project || @project
- presenter = local_assigns.fetch(:presenter, nil)
-= form_errors(issuable)
+= form_errors(issuable, pajamas_alert: true)
- if @conflict
= render Pajamas::AlertComponent.new(variant: :danger,
@@ -57,9 +57,9 @@
.gl-mt-5{ class: (is_footer ? "footer-block" : "middle-block") }
- if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = issuable.project.present.contribution_guide_path)
.gl-mb-5
- Please review the
- %strong= link_to('contribution guidelines', guide_url)
- for this project.
+ - contribution_guidelines_start = '<strong><a href="%{url}">'.html_safe % {url: strip_tags(guide_url)}
+ - contribution_guidelines_end = '</a></strong>'.html_safe
+ = sanitize(html_escape(_('Please review the %{linkStart}contribution guidelines%{linkEnd} for this project.')) % { linkStart: contribution_guidelines_start, linkEnd: contribution_guidelines_end })
- if issuable.new_record?
= form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button', track_experiment: 'promote_mr_approvals_in_free', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 }
diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index 08883bb3372..af63839d7c1 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -11,7 +11,7 @@
- dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by label'))
- dropdown_data = label_dropdown_data(edit_context, labels: labels_filter_path_with_defaults(only_group_labels: edit_context.is_a?(Group)), default_label: _('Labels'))
-- dropdown_data.merge!(data_options)
+- dropdown_data.merge!(data_options, qa_selector: "issuable_label_dropdown")
- label_name = local_assigns.fetch(:label_name, _('Labels'))
- no_default_styles = local_assigns.fetch(:no_default_styles, false)
- classes << 'js-extra-options' if extra_options
@@ -22,7 +22,7 @@
= hidden_field_tag data_options[:field_name], use_id ? label.try(:id) : label.try(:title), id: nil
.dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.qa-issuable-label{ class: classes.join(' '), type: "button", data: dropdown_data }
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect{ class: classes.join(' '), type: "button", data: dropdown_data }
- apply_is_default_styles = (selected.nil? || selected.empty?) && !no_default_styles
%span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) }
= multi_label_name(selected, label_name)
diff --git a/app/views/shared/issuable/_merge_request_assignees.html.haml b/app/views/shared/issuable/_merge_request_assignees.html.haml
deleted file mode 100644
index 6c7a2496ec6..00000000000
--- a/app/views/shared/issuable/_merge_request_assignees.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- issuable.merge_request_assignees.take(count).each do |merge_request_assignee| # rubocop: disable CodeReuse/ActiveRecord
- - assignee = merge_request_assignee.assignee
- - assignee_tooltip = ( merge_request_assignee.attention_requested? ? s_("MrList|Attention requested from assignee %{name}") : s_("MrList|Assigned to %{name}") ) % { name: assignee.name}
-
- = link_to_member(@project, assignee, name: false, title: assignee_tooltip, extra_class: "gl-flex-direction-row-reverse") do
- - if merge_request_assignee.attention_requested?
- %span.gl-display-inline-flex
- = sprite_icon('attention-solid-sm', css_class: 'gl-text-orange-500 icon-overlap-and-shadow')
diff --git a/app/views/shared/issuable/_merge_request_reviewers.html.haml b/app/views/shared/issuable/_merge_request_reviewers.html.haml
deleted file mode 100644
index 8dd74e12aff..00000000000
--- a/app/views/shared/issuable/_merge_request_reviewers.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- issuable.merge_request_reviewers.take(count).each do |merge_request_reviewer| # rubocop: disable CodeReuse/ActiveRecord
- - reviewer = merge_request_reviewer.reviewer
- - reviewer_tooltip = ( merge_request_reviewer.attention_requested? ? s_("MrList|Attention requested from reviewer %{name}") : s_("MrList|Review requested from %{name}") ) % { name: reviewer.name}
-
- = link_to_member(@project, reviewer, name: false, title: reviewer_tooltip, extra_class: "gl-flex-direction-row-reverse") do
- - if merge_request_reviewer.attention_requested?
- %span.gl-display-inline-flex
- = sprite_icon('attention-solid-sm', css_class: 'gl-text-orange-500 icon-overlap-and-shadow')
diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml
index dc713337747..ef539029272 100644
--- a/app/views/shared/issuable/_milestone_dropdown.html.haml
+++ b/app/views/shared/issuable/_milestone_dropdown.html.haml
@@ -7,8 +7,8 @@
- dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by milestone'))
- if selected.present? || params[:milestone_title].present?
= hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id)
-= dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "qa-issuable-milestone-dropdown js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "qa-issuable-dropdown-menu-milestone dropdown-menu-selectable dropdown-menu-milestone",
- placeholder: _('Search milestones'), footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: _('Milestone') } }) do
+= dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", dropdown_qa_selector: "issuable_milestone_dropdown_content",
+ placeholder: _('Search milestones'), footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: _('Milestone'), qa_selector: "issuable_milestone_dropdown", testid: "issuable-milestone-dropdown" } }) do
- if project
%ul.dropdown-footer-list
- if can? current_user, :admin_milestone, project
diff --git a/app/views/shared/issuable/_reviewers.html.haml b/app/views/shared/issuable/_reviewers.html.haml
index 3bf923eb946..4adb7096181 100644
--- a/app/views/shared/issuable/_reviewers.html.haml
+++ b/app/views/shared/issuable/_reviewers.html.haml
@@ -3,11 +3,8 @@
- render_count = reviewers_rendering_overflow ? max_render - 1 : max_render
- more_reviewers_count = issuable.reviewers.size - render_count
-- if issuable.instance_of?(MergeRequest) && current_user&.mr_attention_requests_enabled?
- = render 'shared/issuable/merge_request_reviewers', issuable: issuable, count: render_count
-- else
- - issuable.reviewers.take(render_count).each do |reviewer| # rubocop: disable CodeReuse/ActiveRecord
- = link_to_member(@project, reviewer, name: false, title: s_("MrList|Review requested from %{name}") % { name: reviewer.name})
+- issuable.reviewers.take(render_count).each do |reviewer| # rubocop: disable CodeReuse/ActiveRecord
+ = link_to_member(@project, reviewer, name: false, title: s_("MrList|Review requested from %{name}") % { name: reviewer.name})
- if more_reviewers_count > 0
%span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old' }, title: _("+%{more_reviewers_count} more reviewers") % { more_reviewers_count: more_reviewers_count} }
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 6394e05ae24..21716710015 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -88,16 +88,6 @@
= render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
- - if current_user&.mr_attention_requests_enabled?
- #js-dropdown-attention-requested.filtered-search-input-dropdown-menu.dropdown-menu
- - if current_user
- %ul{ data: { dropdown: true } }
- = render 'shared/issuable/user_dropdown_item',
- user: current_user
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- = render 'shared/issuable/user_dropdown_item',
- user: User.new(username: '{{username}}', name: '{{name}}'),
- avatar: { lazy: true, url: '{{avatar_url}}' }
= render_if_exists 'shared/issuable/approver_dropdown'
= render_if_exists 'shared/issuable/approved_by_dropdown'
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 55f5dce8b37..6da094924a0 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -26,11 +26,14 @@
= _('To-Do')
.js-issuable-todo{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } }
- .block.assignee.qa-assignee-block{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}" }
+ .block.assignee{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container' } }
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in
+ - if issuable_sidebar[:supports_severity]
+ #js-severity
+
- if reviewers
- .block.reviewer.qa-reviewer-block
+ .block.reviewer
= render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in
- if issuable_sidebar[:supports_escalation]
@@ -67,9 +70,6 @@
= _('Time tracking')
= gl_loading_icon(inline: true)
- - if issuable_sidebar[:supports_severity]
- #js-severity
-
- if issuable_sidebar.dig(:features_available, :health_status)
.js-sidebar-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) }
diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml
index ce252e74570..cd976b88304 100644
--- a/app/views/shared/issuable/_sidebar_reviewers.html.haml
+++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml
@@ -36,7 +36,7 @@
- data[:multi_select] = true
- data['dropdown-title'] = title
- data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
- - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
+ - data['max-select'] = dropdown_max_select(dropdown_options[:data])
- options[:data].merge!(data)
= render 'shared/issuable/sidebar_user_dropdown',
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 61cc408f6b3..76469b34832 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -35,7 +35,7 @@
= form.label :milestone_id, _('Milestone'), class: "col-12"
.col-12
.issuable-form-select-holder
- = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: _('Select milestone')
+ = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: _('Select milestone')
.form-group.row
= form.label :label_ids, _('Labels'), class: "col-12"
@@ -53,4 +53,4 @@
= form.label :due_date, _('Due date'), class: "col-12"
.col-12
.issuable-form-select-holder
- = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: _('Select due date'), autocomplete: 'off'
+ = form.gitlab_ui_datepicker :due_date, placeholder: _('Select due date'), autocomplete: 'off', id: "issuable-due-date"
diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
index f9c3c11eed8..efecffbcc2e 100644
--- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
@@ -8,4 +8,4 @@
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' }
= dropdown_tag(users_dropdown_label(issuable.assignees), options: assignees_dropdown_options(issuable.to_ability_name))
- = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-pl-4 qa-assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
+ = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-pl-4 #{'hide' if issuable.assignees.include?(current_user)}", data: { qa_selector: 'assign_to_me_link' }
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index e7c0833de0f..51f49c7ca8e 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -9,7 +9,7 @@
%div{ data: { testid: 'issue-title-input-field' } }
= form.text_field :title, required: true, aria: { required: true }, maxlength: 255, autofocus: true,
- autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', dir: 'auto'
+ autocomplete: 'off', class: 'form-control pad', dir: 'auto', data: { qa_selector: 'issuable_form_title_field' }
- if issuable.respond_to?(:draft?)
.form-text.text-muted
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index 39e7d196965..369aa53586f 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -19,7 +19,7 @@
= render_if_exists 'projects/issues/work_item_links'
= render_if_exists 'projects/issues/linked_resources'
- = render_if_exists 'projects/issues/related_issues'
+ = render 'projects/issues/related_issues'
#js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index f768b63afff..cf8bd23b153 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -1,23 +1,23 @@
= form_for @label, as: :label, url: url, html: { class: 'label-form js-quick-submit js-requires-input' } do |f|
- = form_errors(@label)
+ = form_errors(@label, pajamas_alert: true)
.form-group.row
.col-12
= f.label :title
- = f.text_field :title, class: "gl-form-input form-control js-label-title qa-label-title", required: true, autofocus: true
+ = f.text_field :title, class: "gl-form-input form-control js-label-title", required: true, autofocus: true, data: { qa_selector: 'label_title_field' }
= render_if_exists 'shared/labels/create_label_help_text'
.form-group.row
.col-12
= f.label :description
- = f.text_field :description, class: "gl-form-input form-control js-quick-submit qa-label-description"
+ = f.text_field :description, class: "gl-form-input form-control js-quick-submit", data: { qa_selector: 'label_description_field' }
.form-group.row
.col-12
= f.label :color, _("Background color")
.input-group
.input-group-prepend
.input-group-text.label-color-preview &nbsp;
- = f.text_field :color, class: "gl-form-input form-control qa-label-color"
+ = f.text_field :color, class: "gl-form-input form-control", data: { qa_selector: 'label_color_field' }
.form-text.text-muted
= _('Choose any color.')
%br
@@ -28,7 +28,7 @@
- if @label.persisted?
= f.submit _('Save changes'), class: 'btn gl-button btn-confirm js-save-button gl-mr-2'
- else
- = f.submit _('Create label'), class: 'btn gl-button btn-confirm js-save-button qa-label-create-button gl-mr-2'
+ = f.submit _('Create label'), class: 'btn gl-button btn-confirm js-save-button gl-mr-2', data: { qa_selector: 'label_create_button' }
= link_to _('Cancel'), back_path, class: 'btn gl-button btn-default btn-cancel gl-mr-2'
- if @label.persisted?
- presented_label = @label.present
diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml
index 622ad9db425..c82a22c73b8 100644
--- a/app/views/shared/labels/_nav.html.haml
+++ b/app/views/shared/labels/_nav.html.haml
@@ -14,8 +14,8 @@
= render Pajamas::ButtonComponent.new(icon: 'search', button_options: { type: "submit", "aria-label" => _('Submit search') })
= render 'shared/labels/sort_dropdown'
- if labels_or_filters && can_admin_label && @project
- = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_project_label_path(@project), button_options: { class: 'qa-label-create-new' }) do
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_project_label_path(@project), button_options: { data: { qa_selector: 'create_new_label_button' } }) do
= _('New label')
- if labels_or_filters && can_admin_label && @group
- = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_group_label_path(@group), button_options: { class: 'qa-label-create-new' }) do
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_group_label_path(@group), button_options: { data: { qa_selector: 'create_new_label_button' } }) do
= _('New label')
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index ec08dde37bf..98e2c6c43b1 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -5,15 +5,16 @@
- return if requesters.empty?
-.card.gl-mt-3{ data: { testid: 'access-requests' } }
- .card-header
- = _("Users requesting access to")
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 gl-mb-5', data: { testid: 'access-requests' } }, body_options: { class: 'gl-p-0' }) do |c|
+ - c.header do
+ = _('Users requesting access to')
%strong= membership_source.name
= gl_badge_tag requesters.size
= render 'shared/members/manage_access_button', path: membership_source.is_a?(Project) ? project_project_members_path(@project, tab: 'access_requests') : group_group_members_path(@group, tab: 'access_requests')
- %ul.content-list.members-list
- = render partial: 'shared/members/member',
- collection: requesters, as: :member,
- locals: { membership_source: membership_source,
+ - c.body do
+ %ul.content-list.members-list
+ = render partial: 'shared/members/member',
+ collection: requesters, as: :member,
+ locals: { membership_source: membership_source,
group: group,
current_user_is_group_owner: current_user_is_group_owner }
diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml
index 7a41e381a96..50e3e8e195c 100644
--- a/app/views/shared/milestones/_form_dates.html.haml
+++ b/app/views/shared/milestones/_form_dates.html.haml
@@ -2,10 +2,10 @@
.col-form-label.col-sm-2
= f.label :start_date, _('Start Date')
.col-sm-4
- = f.text_field :start_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off'
+ = f.gitlab_ui_datepicker :start_date, data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off'
%a.inline.float-right.gl-mt-2.js-clear-start-date{ href: "#" }= _('Clear start date')
.col-form-label.col-sm-2
= f.label :due_date, _('Due Date')
.col-sm-4
- = f.text_field :due_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off'
+ = f.gitlab_ui_datepicker :due_date, data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off'
%a.inline.float-right.gl-mt-2.js-clear-due-date{ href: "#" }= _('Clear due date')
diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml
index 18db556e024..334785685d5 100644
--- a/app/views/shared/milestones/_header.html.haml
+++ b/app/views/shared/milestones/_header.html.haml
@@ -20,10 +20,10 @@
#promote-milestone-modal
- if milestone.active?
- = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), button_options: { class: 'btn-grouped btn-close', data: { method: 'put' }, rel: 'nofollow' }) do
+ = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), method: :put, button_options: { class: 'btn-grouped btn-close' }) do
= _('Close milestone')
- else
- = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), button_options: { class: 'btn-grouped', data: { method: 'put' }, rel: 'nofollow' }) do
+ = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), method: :put, button_options: { class: 'btn-grouped' }) do
= _('Reopen milestone')
= render 'shared/milestones/delete_button'
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index c845d4df7df..44740db5a00 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -11,7 +11,7 @@
- if supports_file_upload
%span.uploading-container
%span.uploading-progress-container.hide
- = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
+ = sprite_icon('paperclip', css_class: 'gl-icon gl-vertical-align-text-bottom')
%span.attaching-file-message
-# Populated by app/assets/javascripts/dropzone_input.js
%span.uploading-progress 0%
@@ -19,7 +19,7 @@
%span.uploading-error-container.hide
%span.uploading-error-icon
- = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
+ = sprite_icon('paperclip', css_class: 'gl-icon gl-vertical-align-text-bottom')
%span.uploading-error-message
-# Populated by app/assets/javascripts/dropzone_input.js
%button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link
@@ -31,11 +31,6 @@
= _("attach a new file")
= _(".")
- %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom
- = sprite_icon('media')
- %span.gl-button-text
- = _("Attach a file")
-
%button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide
%span.gl-button-text
= _("Cancel")
diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml
index e96a9152c80..51a5c9dd38f 100644
--- a/app/views/shared/projects/_search_form.html.haml
+++ b/app/views/shared/projects/_search_form.html.haml
@@ -1,7 +1,7 @@
- form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : ''
- placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : 'Filter by name...'
-= form_tag filter_projects_path, method: :get, class: 'project-filter-form qa-project-filter-form', id: 'project-filter-form' do |f|
+= form_tag filter_projects_path, method: :get, class: 'project-filter-form', data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f|
= search_field_tag :name, params[:name],
placeholder: placeholder,
class: "project-filter-form-field form-control #{form_field_classes}",
diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml
index e3895663033..be513af4e3f 100644
--- a/app/views/shared/projects/_topics.html.haml
+++ b/app/views/shared/projects/_topics.html.haml
@@ -3,16 +3,16 @@
- if project.topics.present?
= cache_if(cache_enabled, [project, :topic_list], expires_in: 1.day) do
- %span.gl-w-full.gl-display-inline-flex.gl-font-base.gl-font-weight-normal.gl-align-items-center{ 'data-testid': 'project_topic_list' }
- = sprite_icon('tag', css_class: 'icon gl-relative gl-mr-2')
-
+ .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{ 'data-testid': 'project_topic_list' }
+ %span.gl-p-2.gl-text-gray-500
+ = _('Topics') + ':'
- project.topics_to_show.each do |topic|
- explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
- if topic[:title].length > max_project_topic_length
- %a.gl-mr-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
+ %a.gl-p-2.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
= gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
- else
- %a.gl-mr-3{ href: explore_project_topic_path, itemprop: 'keywords' }
+ %a.gl-p-2{ href: explore_project_topic_path, itemprop: 'keywords' }
= gl_badge_tag topic[:title]
- if project.has_extra_topics?
@@ -27,5 +27,5 @@
- else
%a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' }
= gl_badge_tag topic[:title]
- .text-nowrap{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
+ .text-nowrap.gl-p-2{ role: 'button', tabindex: 0, data: { toggle: 'popover', triggers: 'focus hover', html: 'true', placement: 'top', title: title, content: content } }
= _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown }
diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
index 32b9044c551..d10196a83cc 100644
--- a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
+++ b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
@@ -10,7 +10,7 @@
%td.merge_access_levels-container
= hidden_field_tag "allowed_to_merge_#{protected_branch.id}", merge_access_levels.first&.access_level
= dropdown_tag( (merge_access_levels.first&.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-merge qa-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header',
+ options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header',
data: { field_name: "allowed_to_merge_#{protected_branch.id}", preselected_items: access_levels_data(merge_access_levels) }})
- if user_merge_access_levels.any?
%p.small
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 361beda4d02..25070138128 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -4,6 +4,7 @@
- page_title user_display_name(@user)
- page_description @user.bio unless @user.blocked? || !@user.confirmed?
- page_itemtype 'http://schema.org/Person'
+- add_page_specific_style 'page_bundles/profile'
- link_classes = "flex-grow-1 mx-1 "
= content_for :meta_tags do
@@ -42,16 +43,18 @@
= sprite_icon('user')
- if current_user && current_user.id != @user.id
- if current_user.following?(@user)
- = link_to user_unfollow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do
- = _('Unfollow')
+ = form_tag user_unfollow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do
+ = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do
+ = _('Unfollow')
- else
- = link_to user_follow_path(@user, :json) , class: link_classes + 'btn gl-button btn-confirm', method: :post, data: { qa_selector: 'follow_user_link' } do
- = _('Follow')
+ = form_tag user_follow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do
+ = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do
+ = _('Follow')
.profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
.avatar-holder
= link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do
- = image_tag avatar_icon_for_user(@user, 90, current_user: current_user), class: "avatar s90", alt: '', itemprop: 'image'
+ = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" })
- if @user.blocked? || !@user.confirmed?
.user-info
@@ -65,14 +68,14 @@
- if @user.pronouns.present?
%span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle
= "(#{@user.pronouns})"
- - if @user&.status && user_status_set_to_busy?(@user.status)
+ - if @user.status&.busy?
%span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)")
- if @user.pronunciation.present?
.gl-align-items-center
%p.gl-mb-4.gl-text-gray-500= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation }
- - if show_status_emoji?(@user.status)
+ - if @user.status&.customized?
.cover-status.gl-display-inline-flex.gl-align-items-center
= emoji_icon(@user.status.emoji, class: 'gl-mr-2')
= markdown_field(@user.status, :message)
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 966a1202db2..8bba5e36b52 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -1551,15 +1551,6 @@
:weight: 1
:idempotent: false
:tags: []
-- :name: pipeline_background:archive_trace
- :worker_name: ArchiveTraceWorker
- :feature_category: :continuous_integration
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: false
- :tags: []
- :name: pipeline_background:ci_archive_trace
:worker_name: Ci::ArchiveTraceWorker
:feature_category: :continuous_integration
@@ -1650,6 +1641,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: pipeline_background:ci_track_failed_build
+ :worker_name: Ci::TrackFailedBuildWorker
+ :feature_category: :static_application_security_testing
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: pipeline_creation:ci_external_pull_requests_create_pipeline
:worker_name: Ci::ExternalPullRequests::CreatePipelineWorker
:feature_category: :continuous_integration
@@ -1776,15 +1776,6 @@
:weight: 2
:idempotent: false
:tags: []
-- :name: pipeline_processing:build_finished
- :worker_name: BuildFinishedWorker
- :feature_category: :continuous_integration
- :has_external_dependencies: false
- :urgency: :high
- :resource_boundary: :cpu
- :weight: 5
- :idempotent: false
- :tags: []
- :name: pipeline_processing:build_queue
:worker_name: BuildQueueWorker
:feature_category: :continuous_integration
@@ -2109,6 +2100,15 @@
:weight: 2
:idempotent: false
:tags: []
+- :name: ci_cancel_pipeline
+ :worker_name: Ci::CancelPipelineWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies: false
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: ci_delete_objects
:worker_name: Ci::DeleteObjectsWorker
:feature_category: :continuous_integration
@@ -2127,6 +2127,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: ci_runners_process_runner_version_update
+ :worker_name: Ci::Runners::ProcessRunnerVersionUpdateWorker
+ :feature_category: :runner_fleet
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: create_commit_signature
:worker_name: CreateCommitSignatureWorker
:feature_category: :source_code_management
@@ -2252,8 +2261,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent: false
- :tags:
- - :needs_own_queue
+ :tags: []
- :name: emails_on_push
:worker_name: EmailsOnPushWorker
:feature_category: :source_code_management
@@ -2551,6 +2559,24 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_create_approval_event
+ :worker_name: MergeRequests::CreateApprovalEventWorker
+ :feature_category: :code_review
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: merge_requests_create_approval_note
+ :worker_name: MergeRequests::CreateApprovalNoteWorker
+ :feature_category: :code_review
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: merge_requests_delete_source_branch
:worker_name: MergeRequests::DeleteSourceBranchWorker
:feature_category: :source_code_management
@@ -2560,6 +2586,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_execute_approval_hooks
+ :worker_name: MergeRequests::ExecuteApprovalHooksWorker
+ :feature_category: :code_review
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: merge_requests_handle_assignees_change
:worker_name: MergeRequests::HandleAssigneesChangeWorker
:feature_category: :code_review
@@ -2578,6 +2613,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_resolve_todos_after_approval
+ :worker_name: MergeRequests::ResolveTodosAfterApprovalWorker
+ :feature_category: :code_review
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: merge_requests_update_head_pipeline
:worker_name: MergeRequests::UpdateHeadPipelineWorker
:feature_category: :code_review
@@ -2812,6 +2856,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: projects_import_export_relation_export
+ :worker_name: Projects::ImportExport::RelationExportWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :memory
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: projects_inactive_projects_deletion_notification
:worker_name: Projects::InactiveProjectsDeletionNotificationWorker
:feature_category: :compliance_management
@@ -3018,8 +3071,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent: false
- :tags:
- - :needs_own_queue
+ :tags: []
- :name: snippets_schedule_bulk_repository_shard_moves
:worker_name: Snippets::ScheduleBulkRepositoryShardMovesWorker
:feature_category: :gitaly
diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb
deleted file mode 100644
index ecde05f94dc..00000000000
--- a/app/workers/archive_trace_worker.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class ArchiveTraceWorker < ::Ci::ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker
- # DEPRECATED: Not triggered since https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64934/
-end
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
deleted file mode 100644
index 0d41f7b9438..00000000000
--- a/app/workers/build_finished_worker.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-class BuildFinishedWorker < ::Ci::BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
- # DEPRECATED: Not triggered since https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64934/
-
- # We need to explicitly specify these settings. They aren't inheriting from the parent class.
- urgency :high
- worker_resource_boundary :cpu
-end
diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb
index 5c08344bfe3..2c62aed72d6 100644
--- a/app/workers/build_hooks_worker.rb
+++ b/app/workers/build_hooks_worker.rb
@@ -13,9 +13,9 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
- Ci::Build.includes({ runner: :tags })
- .find_by_id(build_id)
- .try(:execute_hooks)
+ build = Ci::Build.find_by_id(build_id)
+
+ build.execute_hooks if build
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb
index 25c7637a79f..36a50735fed 100644
--- a/app/workers/ci/build_finished_worker.rb
+++ b/app/workers/ci/build_finished_worker.rb
@@ -36,8 +36,7 @@ module Ci
build.update_coverage
Ci::BuildReportResultService.new.execute(build)
- # We execute these async as these are independent operations.
- BuildHooksWorker.perform_async(build)
+ build.feature_flagged_execute_hooks
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
build.track_deployment_usage
build.track_verify_usage
diff --git a/app/workers/ci/cancel_pipeline_worker.rb b/app/workers/ci/cancel_pipeline_worker.rb
new file mode 100644
index 00000000000..147839a0625
--- /dev/null
+++ b/app/workers/ci/cancel_pipeline_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Ci
+ class CancelPipelineWorker
+ include ApplicationWorker
+
+ # lots of updates to ci_builds
+ data_consistency :always
+ feature_category :continuous_integration
+ idempotent!
+ deduplicate :until_executed
+ urgency :high
+
+ def perform(pipeline_id, auto_canceled_by_pipeline_id)
+ ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ pipeline.cancel_running(
+ # cascade_to_children is false because we iterate through children
+ # we also cancel bridges prior to prevent more children
+ cascade_to_children: false,
+ auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id
+ )
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/runners/process_runner_version_update_worker.rb b/app/workers/ci/runners/process_runner_version_update_worker.rb
new file mode 100644
index 00000000000..f1ad0c8563e
--- /dev/null
+++ b/app/workers/ci/runners/process_runner_version_update_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class ProcessRunnerVersionUpdateWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ feature_category :runner_fleet
+ urgency :low
+
+ idempotent!
+ deduplicate :until_executing
+
+ def perform(version)
+ result = ::Ci::Runners::ProcessRunnerVersionUpdateService.new(version).execute
+
+ result.to_h.slice(:status, :message, :upgrade_status).each do |key, value|
+ log_extra_metadata_on_done(key, value)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb
index 035b2563e56..69ab477c80a 100644
--- a/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb
+++ b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb
@@ -12,11 +12,25 @@ module Ci
feature_category :runner_fleet
urgency :low
+ deduplicate :until_executed
idempotent!
- def perform
+ def perform(cronjob_scheduled = true)
+ if cronjob_scheduled
+ # Introduce some randomness across the day so that instances don't all hit the GitLab Releases API
+ # around the same time of day
+ period = rand(0..12.hours.in_seconds)
+ self.class.perform_in(period, false)
+
+ Sidekiq.logger.info(
+ class: self.class.name,
+ message: "rescheduled job for #{period.seconds.from_now}")
+
+ return
+ end
+
result = ::Ci::Runners::ReconcileExistingRunnerVersionsService.new.execute
- result.each { |key, value| log_extra_metadata_on_done(key, value) }
+ result.payload.each { |key, value| log_extra_metadata_on_done(key, value) }
end
end
end
diff --git a/app/workers/ci/track_failed_build_worker.rb b/app/workers/ci/track_failed_build_worker.rb
new file mode 100644
index 00000000000..2ad948876ac
--- /dev/null
+++ b/app/workers/ci/track_failed_build_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+# Worker for tracking exit codes of failed CI jobs
+module Ci
+ class TrackFailedBuildWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ feature_category :static_application_security_testing
+
+ urgency :low
+ data_consistency :sticky
+ worker_resource_boundary :cpu
+ idempotent!
+ worker_has_external_dependencies!
+
+ def perform(build_id, exit_code, failure_reason)
+ ::Ci::Build.find_by_id(build_id).try do |build|
+ ::Ci::TrackFailedBuildService.new(
+ build: build,
+ exit_code: exit_code,
+ failure_reason: failure_reason).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb
index 336d60d46ac..9300c2a5790 100644
--- a/app/workers/concerns/waitable_worker.rb
+++ b/app/workers/concerns/waitable_worker.rb
@@ -7,7 +7,7 @@ module WaitableWorker
# Schedules multiple jobs and waits for them to be completed.
def bulk_perform_and_wait(args_list)
# Short-circuit: it's more efficient to do small numbers of jobs inline
- if args_list.size == 1
+ if args_list.size == 1 && !always_async_project_authorizations_refresh?
return bulk_perform_inline(args_list)
end
@@ -29,6 +29,10 @@ module WaitableWorker
bulk_perform_async(failed) if failed.present?
end
+
+ def always_async_project_authorizations_refresh?
+ Feature.enabled?(:always_async_project_authorizations_refresh)
+ end
end
def perform(*args)
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index 54689df4d7b..339383476be 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -11,9 +11,6 @@ class EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker
urgency :high
weight 2
- # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1263
- tags :needs_own_queue
-
attr_accessor :raw
def perform(raw)
diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index d7bd8207f06..5cc9bb6954e 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -17,8 +17,8 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker
def perform(project_id, recipients, push_data, options = {})
options.symbolize_keys!
options.reverse_merge!(
- send_from_committer_email: false,
- disable_diffs: false
+ send_from_committer_email: false,
+ disable_diffs: false
)
send_from_committer_email = options[:send_from_committer_email]
disable_diffs = options[:disable_diffs]
@@ -64,14 +64,14 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker
send_email(
recipient,
project_id,
- author_id: author_id,
- ref: ref,
- action: action,
- compare: compare,
- reverse_compare: reverse_compare,
- diff_refs: diff_refs,
+ author_id: author_id,
+ ref: ref,
+ action: action,
+ compare: compare,
+ reverse_compare: reverse_compare,
+ diff_refs: diff_refs,
send_from_committer_email: send_from_committer_email,
- disable_diffs: disable_diffs
+ disable_diffs: disable_diffs
)
# These are input errors and won't be corrected even if Sidekiq retries
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 8155b910677..0ec0a1b58d2 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,32 +15,34 @@ module Gitlab
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
- importer = ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter
- return skip_to_next_stage(project, importer) if feature_disabled?(project)
+ importer = importer_class(project)
+ return skip_to_next_stage(project) if importer.nil?
- start_importer(project, importer, client)
+ info(project.id, message: "starting importer", importer: importer.name)
+ waiter = importer.new(project, client).execute
+ move_to_next_stage(project, { waiter.key => waiter.jobs_remaining })
end
private
- def start_importer(project, importer, client)
- info(project.id, message: "starting importer", importer: importer.name)
- waiter = importer.new(project, client).execute
- move_to_next_stage(project, waiter.key => waiter.jobs_remaining)
+ def importer_class(project)
+ if Feature.enabled?(:github_importer_single_endpoint_issue_events_import, project.group, type: :ops)
+ ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter
+ elsif Feature.enabled?(:github_importer_issue_events_import, project.group, type: :ops)
+ ::Gitlab::GithubImport::Importer::IssueEventsImporter
+ else
+ nil
+ end
end
- def skip_to_next_stage(project, importer)
- info(project.id, message: "skipping importer", importer: importer.name)
+ 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, :notes)
end
-
- def feature_disabled?(project)
- Feature.disabled?(:github_importer_issue_events_import, project.group, type: :ops)
- end
end
end
end
diff --git a/app/workers/merge_requests/create_approval_event_worker.rb b/app/workers/merge_requests/create_approval_event_worker.rb
new file mode 100644
index 00000000000..9b1a3c262e4
--- /dev/null
+++ b/app/workers/merge_requests/create_approval_event_worker.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class CreateApprovalEventWorker
+ include Gitlab::EventStore::Subscriber
+
+ data_consistency :always
+ feature_category :code_review
+ urgency :low
+ idempotent!
+
+ def handle_event(event)
+ current_user_id = event.data[:current_user_id]
+ merge_request_id = event.data[:merge_request_id]
+ current_user = User.find_by_id(current_user_id)
+
+ unless current_user
+ logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id))
+ return
+ end
+
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+
+ unless merge_request
+ logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id))
+ return
+ end
+
+ ::MergeRequests::CreateApprovalEventService
+ .new(project: merge_request.project, current_user: current_user)
+ .execute(merge_request)
+ end
+ end
+end
diff --git a/app/workers/merge_requests/create_approval_note_worker.rb b/app/workers/merge_requests/create_approval_note_worker.rb
new file mode 100644
index 00000000000..841431f6a9d
--- /dev/null
+++ b/app/workers/merge_requests/create_approval_note_worker.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class CreateApprovalNoteWorker
+ include Gitlab::EventStore::Subscriber
+
+ data_consistency :always
+ feature_category :code_review
+ urgency :low
+ idempotent!
+
+ def handle_event(event)
+ current_user_id = event.data[:current_user_id]
+ merge_request_id = event.data[:merge_request_id]
+ current_user = User.find_by_id(current_user_id)
+
+ unless current_user
+ logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id))
+ return
+ end
+
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+
+ unless merge_request
+ logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id))
+ return
+ end
+
+ SystemNoteService.approve_mr(merge_request, current_user)
+ end
+ end
+end
diff --git a/app/workers/merge_requests/execute_approval_hooks_worker.rb b/app/workers/merge_requests/execute_approval_hooks_worker.rb
new file mode 100644
index 00000000000..81eca425a38
--- /dev/null
+++ b/app/workers/merge_requests/execute_approval_hooks_worker.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class ExecuteApprovalHooksWorker
+ include Gitlab::EventStore::Subscriber
+
+ data_consistency :always
+ feature_category :code_review
+ urgency :low
+ idempotent!
+
+ # MergeRequests::ExecuteApprovalHooksService execute webhooks which are treated as external dependencies
+ worker_has_external_dependencies!
+
+ def handle_event(event)
+ current_user_id = event.data[:current_user_id]
+ merge_request_id = event.data[:merge_request_id]
+ current_user = User.find_by_id(current_user_id)
+
+ unless current_user
+ logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id))
+ return
+ end
+
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+
+ unless merge_request
+ logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id))
+ return
+ end
+
+ ::MergeRequests::ExecuteApprovalHooksService
+ .new(project: merge_request.project, current_user: current_user)
+ .execute(merge_request)
+ end
+ end
+end
diff --git a/app/workers/merge_requests/resolve_todos_after_approval_worker.rb b/app/workers/merge_requests/resolve_todos_after_approval_worker.rb
new file mode 100644
index 00000000000..7d9c76ea872
--- /dev/null
+++ b/app/workers/merge_requests/resolve_todos_after_approval_worker.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class ResolveTodosAfterApprovalWorker
+ include Gitlab::EventStore::Subscriber
+
+ data_consistency :always
+ feature_category :code_review
+ urgency :low
+ idempotent!
+
+ def handle_event(event)
+ current_user_id = event.data[:current_user_id]
+ merge_request_id = event.data[:merge_request_id]
+ current_user = User.find_by_id(current_user_id)
+
+ unless current_user
+ logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id))
+ return
+ end
+
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+
+ unless merge_request
+ logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id))
+ return
+ end
+
+ TodoService.new.resolve_todos_for_target(merge_request, current_user)
+ end
+ end
+end
diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb
index 13936fac1e4..e14f0dc7dfe 100644
--- a/app/workers/new_issue_worker.rb
+++ b/app/workers/new_issue_worker.rb
@@ -13,7 +13,11 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker
worker_resource_boundary :cpu
weight 2
- def perform(issue_id, user_id)
+ attr_reader :issuable_class
+
+ def perform(issue_id, user_id, issuable_class = 'Issue')
+ @issuable_class = issuable_class.constantize
+
return unless objects_found?(issue_id, user_id)
::EventCreateService.new.open_issue(issuable, user)
@@ -25,8 +29,4 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker
.new(project: issuable.project, current_user: user)
.execute(issuable)
end
-
- def issuable_class
- Issue
- end
end
diff --git a/app/workers/pages/invalidate_domain_cache_worker.rb b/app/workers/pages/invalidate_domain_cache_worker.rb
index 63b6f5c05b5..97e8966b342 100644
--- a/app/workers/pages/invalidate_domain_cache_worker.rb
+++ b/app/workers/pages/invalidate_domain_cache_worker.rb
@@ -15,9 +15,13 @@ module Pages
.clear_cache
end
- if event.data[:root_namespace_id]
+ event.data.values_at(
+ :root_namespace_id,
+ :old_root_namespace_id,
+ :new_root_namespace_id
+ ).compact.uniq.each do |namespace_id|
::Gitlab::Pages::CacheControl
- .for_namespace(event.data[:root_namespace_id])
+ .for_namespace(namespace_id)
.clear_cache
end
end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 68a0934e2b7..329ccfc6362 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -85,6 +85,7 @@ class PostReceive
replicate_snippet_changes(snippet)
expire_caches(post_received, snippet.repository)
+ snippet.touch
Snippets::UpdateStatisticsService.new(snippet).execute
end
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index 0e90b41e28d..cb1a7c8560a 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -47,7 +47,8 @@ class ProjectCacheWorker
Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute
- UpdateProjectStatisticsWorker.perform_in(LEASE_TIMEOUT, project.id, statistics)
+ lease_key = project_cache_worker_key(project.id, statistics)
+ UpdateProjectStatisticsWorker.perform_in(LEASE_TIMEOUT, lease_key, project.id, statistics)
end
private
diff --git a/app/workers/projects/import_export/relation_export_worker.rb b/app/workers/projects/import_export/relation_export_worker.rb
new file mode 100644
index 00000000000..13ca33c4457
--- /dev/null
+++ b/app/workers/projects/import_export/relation_export_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Projects
+ module ImportExport
+ class RelationExportWorker
+ include ApplicationWorker
+ include ExceptionBacktrace
+
+ idempotent!
+ data_consistency :always
+ deduplicate :until_executed
+ feature_category :importers
+ sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
+ urgency :low
+ worker_resource_boundary :memory
+
+ def perform(project_relation_export_id)
+ relation_export = Projects::ImportExport::RelationExport.find(project_relation_export_id)
+
+ if relation_export.queued?
+ Projects::ImportExport::RelationExportService.new(relation_export, jid).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/service_desk_email_receiver_worker.rb b/app/workers/service_desk_email_receiver_worker.rb
index c8ab8891856..b3b36ca2ada 100644
--- a/app/workers/service_desk_email_receiver_worker.rb
+++ b/app/workers/service_desk_email_receiver_worker.rb
@@ -9,9 +9,6 @@ class ServiceDeskEmailReceiverWorker < EmailReceiverWorker # rubocop:disable Sca
urgency :high
sidekiq_options retry: 3
- # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1263
- tags :needs_own_queue
-
def should_perform?
::Gitlab::ServiceDeskEmail.enabled?
end
diff --git a/app/workers/update_project_statistics_worker.rb b/app/workers/update_project_statistics_worker.rb
index 45a6cc8f476..3308fa149f5 100644
--- a/app/workers/update_project_statistics_worker.rb
+++ b/app/workers/update_project_statistics_worker.rb
@@ -10,10 +10,15 @@ class UpdateProjectStatisticsWorker # rubocop:disable Scalability/IdempotentWork
feature_category :source_code_management
- # project_id - The ID of the project for which to flush the cache.
- # statistics - An Array containing columns from ProjectStatistics to
- # refresh, if empty all columns will be refreshed
- def perform(project_id, statistics = [])
+ # lease_key - The exclusive lease key to take
+ # project_id - The ID of the project for which to flush the cache.
+ # statistics - An Array containing columns from ProjectStatistics to
+ # refresh, if empty all columns will be refreshed
+ def perform(lease_key, project_id, statistics = [])
+ return unless Gitlab::ExclusiveLease
+ .new(lease_key, timeout: ProjectCacheWorker::LEASE_TIMEOUT)
+ .try_obtain
+
project = Project.find_by_id(project_id)
Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute
diff --git a/app/workers/users/deactivate_dormant_users_worker.rb b/app/workers/users/deactivate_dormant_users_worker.rb
index d7ea20e4b62..b14b7e67450 100644
--- a/app/workers/users/deactivate_dormant_users_worker.rb
+++ b/app/workers/users/deactivate_dormant_users_worker.rb
@@ -10,43 +10,23 @@ module Users
feature_category :utilization
- NUMBER_OF_BATCHES = 50
- BATCH_SIZE = 200
- PAUSE_SECONDS = 0.25
-
def perform
return if Gitlab.com?
return unless ::Gitlab::CurrentSettings.current_application_settings.deactivate_dormant_users
- with_context(caller_id: self.class.name.to_s) do
- NUMBER_OF_BATCHES.times do
- result = User.connection.execute(update_query)
-
- break if result.cmd_tuples == 0
-
- sleep(PAUSE_SECONDS)
- end
- end
+ deactivate_users(User.dormant)
+ deactivate_users(User.with_no_activity)
end
private
- def update_query
- <<~SQL
- UPDATE "users"
- SET "state" = 'deactivated'
- WHERE "users"."id" IN (
- (#{users.dormant.to_sql})
- UNION
- (#{users.with_no_activity.to_sql})
- LIMIT #{BATCH_SIZE}
- )
- SQL
- end
-
- def users
- User.select(:id).limit(BATCH_SIZE)
+ def deactivate_users(scope)
+ with_context(caller_id: self.class.name.to_s) do
+ scope.each_batch do |batch|
+ batch.each(&:deactivate)
+ end
+ end
end
end
end
diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb
index 39440504927..cb5bae7ca4e 100644
--- a/app/workers/x509_issuer_crl_check_worker.rb
+++ b/app/workers/x509_issuer_crl_check_worker.rb
@@ -41,13 +41,13 @@ class X509IssuerCrlCheckWorker
certs.find_each do |cert|
logger.info(message: "Certificate revoked",
- id: cert.id,
- email: cert.email,
- subject: cert.subject,
- serial_number: cert.serial_number,
- issuer: cert.x509_issuer.id,
- issuer_subject: cert.x509_issuer.subject,
- issuer_crl_url: cert.x509_issuer.crl_url)
+ id: cert.id,
+ email: cert.email,
+ subject: cert.subject,
+ serial_number: cert.serial_number,
+ issuer: cert.x509_issuer.id,
+ issuer_subject: cert.x509_issuer.subject,
+ issuer_crl_url: cert.x509_issuer.crl_url)
end
certs.update_all(certificate_status: :revoked)
@@ -61,18 +61,18 @@ class X509IssuerCrlCheckWorker
OpenSSL::X509::CRL.new(response.body)
else
logger.warn(message: "Failed to download certificate revocation list",
- issuer: issuer.id,
- issuer_subject: issuer.subject,
- issuer_crl_url: issuer.crl_url)
+ issuer: issuer.id,
+ issuer_subject: issuer.subject,
+ issuer_crl_url: issuer.crl_url)
nil
end
rescue OpenSSL::X509::CRLError
logger.warn(message: "Failed to parse certificate revocation list",
- issuer: issuer.id,
- issuer_subject: issuer.subject,
- issuer_crl_url: issuer.crl_url)
+ issuer: issuer.id,
+ issuer_subject: issuer.subject,
+ issuer_crl_url: issuer.crl_url)
nil
end